Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion open-sse/services/proxyAutoSelector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down
13 changes: 7 additions & 6 deletions open-sse/utils/proxyFetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -338,18 +342,15 @@ 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;
} catch {
// ignore
}
if (targetHostname) {
const fallbackProxyUrl = await findWorkingProxy(
targetHostname,
targetUrl
);
const fallbackProxyUrl = await findWorkingProxy(targetHostname, targetUrl);
if (fallbackProxyUrl) {
try {
const dispatcher = createProxyDispatcher(fallbackProxyUrl);
Expand Down
19 changes: 15 additions & 4 deletions src/app/(dashboard)/dashboard/providers/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)}
/>
))}
Expand Down Expand Up @@ -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)}
/>
))}
Expand Down
21 changes: 20 additions & 1 deletion src/lib/db/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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");
Expand Down
34 changes: 20 additions & 14 deletions src/lib/db/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>;

Expand Down Expand Up @@ -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;
}
Comment on lines +77 to +86

// 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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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);
Expand All @@ -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"
Expand Down
70 changes: 40 additions & 30 deletions src/lib/db/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -726,29 +743,22 @@ 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;
}
}

// 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);
Expand All @@ -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 };
Expand All @@ -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;
Expand Down
Loading