mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 05:45:04 +03:00
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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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/.
|
||||
|
||||
Reference in New Issue
Block a user