fix(provider-proxy): honor per-account proxy toggles (#3349)

Integrated into release/v3.8.14 — honor per-account proxy toggles + auto-fallback opt-in via PROXY_AUTO_SELECT_ENABLED.
This commit is contained in:
Randi
2026-06-07 01:06:45 -04:00
committed by GitHub
parent 9c2beb7312
commit da16a173a8
7 changed files with 183 additions and 56 deletions

View File

@@ -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<string | null> {
if (!isFeatureFlagEnabled("PROXY_AUTO_SELECT_ENABLED")) return null;
if (!targetUrl) return null;
let hostname: string;

View File

@@ -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);

View File

@@ -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)}
/>
))}

View File

@@ -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");

View File

@@ -12,6 +12,7 @@ import {
} from "./encryption";
import { invalidateDbCache } from "./readCache";
import { normalizeProviderSpecificData } from "@/lib/providers/requestDefaults";
import { bumpProxyConfigGeneration } from "./settings";
type JsonRecord = Record<string, unknown>;
@@ -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<string, number> | 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"

View File

@@ -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;

View File

@@ -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/.