Skip to content
Open
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
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,12 @@ package-lock.json
#Ignore vscode AI rules
.github/instructions/codacy.instructions.md
README1.md
plans/
.repomixignore

# Local config files
.claude/
.opencode/
AGENTS.md
CLAUDE.md
release-manifest.json
82 changes: 82 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,88 @@ Environment variables actively used by code:
- Logging: `ENABLE_REQUEST_LOGS`
- Sync/cloud URLing: `NEXT_PUBLIC_BASE_URL`, `NEXT_PUBLIC_CLOUD_URL`
- Outbound proxy: `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY` and lowercase variants

### Per-Provider Proxy Configuration

_Updated: 2026-02-24_

Each provider connection can have its own proxy configuration, enabling different proxy settings for different providers.

**API Endpoints:**
- `GET/PUT/DELETE /api/providers/[id]/proxy` - Manage proxy config for a provider
- `POST /api/providers/[id]/proxy/test` - Test proxy connectivity

**Proxy Config Schema:**
```javascript
{
proxy: {
url: "http://user:pass@proxy.com:8080" | "socks5://proxy.com:1080",
bypass: ["*.local", "localhost", "192.168.*"] // optional
}
}
```

**Proxy Flow:**
```mermaid
sequenceDiagram
autonumber
participant Client as API Client
participant Core as chatCore
participant Exec as BaseExecutor
participant Factory as ProxyAgentFactory
participant Provider as AI Provider

Client->>Core: /v1/chat/completions
Core->>Exec: execute({ credentials, ... })
Exec->>Exec: getProxyAgent(targetUrl, credentials)

alt Per-provider proxy configured
Exec->>Factory: shouldUseProxy(url, credentials.proxy, globalNoProxy)
Factory->>Factory: Check bypass patterns
alt Should bypass
Factory-->>Exec: null (direct connection)
else Should use proxy
Factory->>Factory: getProxyAgent(proxyUrl)
Factory-->>Exec: ProxyAgent
end
else No per-provider proxy
Exec->>Factory: shouldUseProxy(url, null, globalNoProxy)
Factory->>Factory: Check env vars (HTTP_PROXY, etc.)
alt Global proxy configured
Factory-->>Exec: ProxyAgent
else No proxy
Factory-->>Exec: null (direct connection)
end
end

Exec->>Provider: fetch(url, { dispatcher: agent })
```

**Supported Proxy Protocols:**
- HTTP (`http://proxy.com:8080`)
- HTTPS (`https://proxy.com:8080`)
- SOCKS4 (`socks4://proxy.com:1080`)
- SOCKS5 (`socks5://proxy.com:1080`)

**Proxy Authentication:**
Embedded in URL as `protocol://user:pass@host:port`

**Priority Order:**
1. Per-provider proxy config (from `credentials.proxy`)
2. Global environment variables (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`)
3. Direct connection

**Bypass Patterns:**
- Merged from per-provider `proxy.bypass` array and global `NO_PROXY` env var
- Supports wildcards: `*.local`, `192.168.*`
- Exact match: `localhost`
- Suffix match: `.example.com` matches `api.example.com`

**Implementation Files:**
- `open-sse/utils/proxy-agent-factory.js` - Agent creation and caching
- `open-sse/executors/base.js` - Integration into executor
- `src/lib/localDb.js` - Proxy config persistence
- `src/app/api/providers/[id]/proxy/*` - API endpoints
- Platform/runtime helpers (not app-specific config): `APPDATA`, `NODE_ENV`, `PORT`, `HOSTNAME`

## Known Architectural Notes
Expand Down
38 changes: 36 additions & 2 deletions open-sse/executors/base.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,37 @@
import { HTTP_STATUS } from "../config/constants.js";
import { shouldUseProxy } from "../utils/proxy-agent-factory.js";

/**
* BaseExecutor - Base class for provider executors
*
* Supports per-provider proxy configuration via credentials.proxy:
* {
* url: "http://user:pass@proxy.com:8080" | "socks5://proxy.com:1080",
* bypass: ["*.local", "localhost"]
* }
*/
export class BaseExecutor {
constructor(provider, config) {
this.provider = provider;
this.config = config;
}

/**
* Get proxy agent for request
*
* Checks provider-specific proxy config first, falls back to global env vars.
* Respects bypass patterns from both provider config and NO_PROXY env var.
*
* @param {string} targetUrl - Target URL
* @param {object} credentials - Provider credentials (may include proxy config)
* @returns {Promise<Agent|null>} Proxy agent or null (for direct connection)
*/
async getProxyAgent(targetUrl, credentials = {}) {
const proxyConfig = credentials.proxy || null;
const globalNoProxy = process.env.NO_PROXY || process.env.no_proxy || '';
return shouldUseProxy(targetUrl, proxyConfig, globalNoProxy);
}

getProvider() {
return this.provider;
}
Expand Down Expand Up @@ -85,13 +108,24 @@ export class BaseExecutor {
const headers = this.buildHeaders(credentials, stream);
const transformedBody = this.transformRequest(model, body, stream, credentials);

// Get proxy agent
const proxyAgent = await this.getProxyAgent(url, credentials);

try {
const response = await fetch(url, {
const fetchOptions = {
method: "POST",
headers,
body: JSON.stringify(transformedBody),
signal
});
};

// Add dispatcher for proxy agent if available
if (proxyAgent) {
fetchOptions.dispatcher = proxyAgent;
log?.debug?.("PROXY", `Using proxy for ${url}`);
}

const response = await fetch(url, fetchOptions);

if (this.shouldRetry(response.status, urlIndex)) {
log?.debug?.("RETRY", `${response.status} on ${url}, trying fallback ${urlIndex + 1}`);
Expand Down
184 changes: 184 additions & 0 deletions open-sse/utils/proxy-agent-factory.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
/**
* Proxy Agent Factory
*
* Creates and caches proxy agents for HTTP/HTTPS and SOCKS protocols.
* Supports proxy authentication via URL credentials.
*
* Usage:
* import { getProxyAgent, shouldUseProxy } from './proxy-agent-factory.js'
* const agent = getProxyAgent('http://user:pass@proxy.com:8080')
*/

// LRU cache with max size limit to prevent memory leaks
const MAX_CACHE_SIZE = 100;
const agentCache = new Map();

/**
* Evict oldest entry if cache is full (LRU eviction)
*/
function evictIfFull() {
if (agentCache.size >= MAX_CACHE_SIZE) {
const firstKey = agentCache.keys().next().value;
agentCache.delete(firstKey);
}
}

/**
* Parse proxy URL into components
* @param {string} proxyUrl - Proxy URL (e.g., "http://user:pass@host:port")
* @returns {object} Parsed proxy config
*/
function parseProxyUrl(proxyUrl) {
if (!proxyUrl || typeof proxyUrl !== 'string') {
return null;
}

try {
const url = new URL(proxyUrl);
const protocol = url.protocol.replace(':', '');

if (!['http', 'https', 'socks', 'socks4', 'socks5'].includes(protocol)) {
throw new Error(`Unsupported proxy protocol: ${protocol}`);
}

return {
protocol,
host: url.hostname,
port: url.port ? parseInt(url.port, 10) : null,
username: decodeURIComponent(url.username || ''),
password: decodeURIComponent(url.password || ''),
};
} catch (error) {
throw new Error(`Invalid proxy URL: ${error.message}`);
}
}

/**
* Validate proxy URL format
* @param {string} proxyUrl - Proxy URL to validate
* @returns {boolean} True if valid
*/
function validateProxyUrl(proxyUrl) {
try {
const config = parseProxyUrl(proxyUrl);
return config !== null && config.host && config.port;
} catch {
return false;
}
}

/**
* Get or create proxy agent for given URL
* @param {string} proxyUrl - Proxy URL
* @returns {Promise<Agent|null>} Proxy agent or null for direct connection
*/
async function getProxyAgent(proxyUrl) {
if (!proxyUrl) {
return null;
}

// Normalize URL for cache key (remove credentials for security)
const config = parseProxyUrl(proxyUrl);
if (!config) {
return null;
}

// Validate port is present
if (!config.port) {
throw new Error(`Proxy URL must include port: ${proxyUrl}`);
}

const cacheKey = `${config.protocol}://${config.host}:${config.port}`;

if (agentCache.has(cacheKey)) {
// LRU: delete and re-add to mark as recently used
const agent = agentCache.get(cacheKey);
agentCache.delete(cacheKey);
agentCache.set(cacheKey, agent);
return agent;
}

// Evict oldest entry if cache is full
evictIfFull();

let agent;

if (config.protocol.startsWith('socks')) {
const { SocksProxyAgent } = await import('socks-proxy-agent');
agent = new SocksProxyAgent(proxyUrl);
} else {
const { HttpsProxyAgent } = await import('https-proxy-agent');
agent = new HttpsProxyAgent(proxyUrl);
}

agentCache.set(cacheKey, agent);
return agent;
}

/**
* Check if target URL should bypass proxy
* @param {string} targetUrl - Target URL to check
* @param {string[]} bypassPatterns - NO_PROXY patterns (e.g., ["*.local", "localhost"])
* @returns {boolean} True if should bypass proxy
*/
function shouldBypassProxy(targetUrl, bypassPatterns = []) {
if (!bypassPatterns || bypassPatterns.length === 0) {
return false;
}

try {
const hostname = new URL(targetUrl).hostname.toLowerCase();

return bypassPatterns.some(pattern => {
const p = pattern.trim().toLowerCase();

if (p === '*') return true;
if (p.startsWith('.')) {
return hostname.endsWith(p) || hostname === p.slice(1);
}
return hostname === p || hostname.endsWith(`.${p}`);
});
} catch {
return false;
}
}

/**
* Determine if proxy should be used for target URL
* @param {string} targetUrl - Target URL
* @param {object|null} proxyConfig - Proxy config with {url, bypass}
* @param {string} globalNoProxy - Global NO_PROXY env var
* @returns {Promise<Agent|null>} Agent or null
*/
async function shouldUseProxy(targetUrl, proxyConfig, globalNoProxy = '') {
if (!proxyConfig?.url) {
return null;
}

const bypassPatterns = [
...(globalNoProxy || '').split(',').filter(Boolean),
...(proxyConfig.bypass || []),
];

if (shouldBypassProxy(targetUrl, bypassPatterns)) {
return null;
}

return getProxyAgent(proxyConfig.url);
}

/**
* Clear proxy agent cache (useful for testing or config reload)
*/
function clearProxyCache() {
agentCache.clear();
}

export {
parseProxyUrl,
validateProxyUrl,
getProxyAgent,
shouldBypassProxy,
shouldUseProxy,
clearProxyCache,
};
Loading