diff --git a/src/server/__tests__/providers.test.ts b/src/server/__tests__/providers.test.ts index 5d2c8465..c12c3d7b 100644 --- a/src/server/__tests__/providers.test.ts +++ b/src/server/__tests__/providers.test.ts @@ -1574,6 +1574,58 @@ describe('ProviderService', () => { } }) + test('bypasses manual proxy options when testing loopback provider endpoints', async () => { + await fs.writeFile( + path.join(tmpDir, 'settings.json'), + JSON.stringify({ + network: { + proxy: { mode: 'manual', url: 'http://127.0.0.1:1181' }, + }, + }), + 'utf-8', + ) + const originalFetch = globalThis.fetch + const calls: Array<{ url: string; proxy?: string }> = [] + globalThis.fetch = mock(async (url: string | URL | Request, init?: RequestInit) => { + calls.push({ + url: String(url), + proxy: (init as RequestInit & { proxy?: string } | undefined)?.proxy, + }) + return new Response(JSON.stringify({ + id: 'chatcmpl-1', + object: 'chat.completion', + created: 0, + model: 'local-model', + choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + }) as typeof fetch + + try { + const svc = new ProviderService() + const result = await svc.testProviderConfig({ + baseUrl: 'http://127.0.0.1:11434', + apiKey: 'local-key', + modelId: 'local-model', + authStrategy: 'api_key', + apiFormat: 'openai_chat', + }) + + expect(result.connectivity.success).toBe(true) + expect(result.proxy?.success).toBe(true) + expect(calls.map((call) => call.url)).toEqual([ + 'http://127.0.0.1:11434/v1/chat/completions', + 'http://127.0.0.1:11434/v1/chat/completions', + ]) + expect(calls.map((call) => call.proxy)).toEqual([undefined, undefined]) + } finally { + globalThis.fetch = originalFetch + } + }) + test('should use configured network timeout for provider tests', async () => { await fs.writeFile( path.join(tmpDir, 'settings.json'), diff --git a/src/server/services/networkSettings.ts b/src/server/services/networkSettings.ts index 036d924b..f58e682e 100644 --- a/src/server/services/networkSettings.ts +++ b/src/server/services/networkSettings.ts @@ -86,7 +86,7 @@ export function getManualNetworkProxyUrl(settings: NetworkSettings): string | un return url || undefined } -function mergeLoopbackNoProxy(existing: string | undefined): string { +export function mergeLoopbackNoProxy(existing: string | undefined): string { const entries = (existing ?? '') .split(/[,\s]+/) .map(entry => entry.trim()) diff --git a/src/server/services/providerService.ts b/src/server/services/providerService.ts index 80504267..ccf8a8b5 100644 --- a/src/server/services/providerService.ts +++ b/src/server/services/providerService.ts @@ -39,6 +39,7 @@ import { getProxyFetchOptions } from '../../utils/proxy.js' import { getManualNetworkProxyUrl, loadNetworkSettings, + mergeLoopbackNoProxy, type NetworkSettings, } from './networkSettings.js' import { normalizeModelStringForAPI } from '../../utils/model/model.js' @@ -597,7 +598,11 @@ export class ProviderService { const start = Date.now() try { const { url, headers, body } = buildDirectTestRequest(base, apiKey, modelId, format, authStrategy) - const proxyOptions = getProxyFetchOptions({ proxyUrl: getManualNetworkProxyUrl(networkSettings) }) + const proxyOptions = getProxyFetchOptions({ + proxyUrl: getManualNetworkProxyUrl(networkSettings), + targetUrl: url, + noProxy: mergeLoopbackNoProxy(process.env.no_proxy || process.env.NO_PROXY), + }) const response = await fetch(url, { method: 'POST', headers, @@ -660,7 +665,11 @@ export class ProviderService { transformedBody = anthropicToOpenaiResponses(anthropicReq) upstreamUrl = `${base}/v1/responses` } - const proxyOptions = getProxyFetchOptions({ proxyUrl: getManualNetworkProxyUrl(networkSettings) }) + const proxyOptions = getProxyFetchOptions({ + proxyUrl: getManualNetworkProxyUrl(networkSettings), + targetUrl: upstreamUrl, + noProxy: mergeLoopbackNoProxy(process.env.no_proxy || process.env.NO_PROXY), + }) // Call upstream with transformed request const response = await fetch(upstreamUrl, { diff --git a/src/utils/proxy.test.ts b/src/utils/proxy.test.ts index ff127575..04f60083 100644 --- a/src/utils/proxy.test.ts +++ b/src/utils/proxy.test.ts @@ -35,6 +35,32 @@ describe('proxy environment handling', () => { }).proxy).toBeUndefined() }) + test('bypasses bracketed IPv6 loopback targets for plain ::1 NO_PROXY entries', () => { + process.env.HTTP_PROXY = 'http://127.0.0.1:1181' + process.env.HTTPS_PROXY = 'http://127.0.0.1:1181' + process.env.NO_PROXY = '::1' + delete process.env.http_proxy + delete process.env.https_proxy + delete process.env.no_proxy + + expect(shouldBypassProxy('http://[::1]:3456/api/status')).toBe(true) + expect(getProxyFetchOptions({ + forAnthropicAPI: true, + targetUrl: 'http://[::1]:3456/proxy/providers/p1', + }).proxy).toBeUndefined() + }) + + test('uses explicit noProxy entries when an explicit proxyUrl is supplied', () => { + delete process.env.NO_PROXY + delete process.env.no_proxy + + expect(getProxyFetchOptions({ + proxyUrl: 'http://127.0.0.1:1181', + targetUrl: 'http://localhost:11434/v1/chat/completions', + noProxy: 'localhost,127.0.0.1,::1', + }).proxy).toBeUndefined() + }) + test('keeps proxy fetch options for external provider targets', () => { process.env.HTTP_PROXY = 'http://127.0.0.1:1181' process.env.HTTPS_PROXY = 'http://127.0.0.1:1181' diff --git a/src/utils/proxy.ts b/src/utils/proxy.ts index aaa11329..de1f3e53 100644 --- a/src/utils/proxy.ts +++ b/src/utils/proxy.ts @@ -96,19 +96,36 @@ export function shouldBypassProxy( try { const url = new URL(urlString) - const hostname = url.hostname.toLowerCase() + const rawHostname = url.hostname.toLowerCase() + const hostname = rawHostname.startsWith('[') && rawHostname.endsWith(']') + ? rawHostname.slice(1, -1) + : rawHostname const port = url.port || (url.protocol === 'https:' ? '443' : '80') const hostWithPort = `${hostname}:${port}` + const bracketedHostWithPort = hostname.includes(':') ? `[${hostname}]:${port}` : hostWithPort // Split by comma or space and trim each entry const noProxyList = noProxy.split(/[,\s]+/).filter(Boolean) return noProxyList.some(pattern => { pattern = pattern.toLowerCase().trim() + const normalizedPattern = pattern.startsWith('[') && pattern.endsWith(']') + ? pattern.slice(1, -1) + : pattern // Check for port-specific match if (pattern.includes(':')) { - return hostWithPort === pattern + const bracketedPortMatch = pattern.match(/^\[(.+)]:(\d+)$/) + if (bracketedPortMatch) { + return hostname === bracketedPortMatch[1] && port === bracketedPortMatch[2] + } + + const colonCount = (pattern.match(/:/g) ?? []).length + if (colonCount === 1) { + return hostWithPort === pattern || bracketedHostWithPort === pattern + } + + return hostname === normalizedPattern } // Check for domain suffix match (with or without leading dot) @@ -120,7 +137,7 @@ export function shouldBypassProxy( } // Check for exact hostname match or IP address - return hostname === pattern + return hostname === normalizedPattern }) } catch { // If URL parsing fails, don't bypass proxy @@ -128,9 +145,9 @@ export function shouldBypassProxy( } } -function shouldBypassProxyForTarget(targetUrl: string | URL | undefined | null): boolean { +function shouldBypassProxyForTarget(targetUrl: string | URL | undefined | null, noProxy?: string | null): boolean { if (!targetUrl) return false - return shouldBypassProxy(String(targetUrl)) + return shouldBypassProxy(String(targetUrl), noProxy ?? undefined) } /** @@ -290,7 +307,7 @@ export function getWebSocketProxyUrl(url: string): string | undefined { * requests get misrouted to api.anthropic.com. Only the Anthropic SDK client * should pass `true` here. */ -export function getProxyFetchOptions(opts?: { forAnthropicAPI?: boolean; proxyUrl?: string | null; targetUrl?: string | URL | null }): { +export function getProxyFetchOptions(opts?: { forAnthropicAPI?: boolean; proxyUrl?: string | null; targetUrl?: string | URL | null; noProxy?: string | null }): { tls?: TLSConfig dispatcher?: undici.Dispatcher proxy?: string @@ -313,7 +330,7 @@ export function getProxyFetchOptions(opts?: { forAnthropicAPI?: boolean; proxyUr ? opts.proxyUrl || undefined : getProxyUrl() - if (proxyUrl && shouldBypassProxyForTarget(opts?.targetUrl)) { + if (proxyUrl && shouldBypassProxyForTarget(opts?.targetUrl, opts?.noProxy)) { return { ...base, ...getTLSFetchOptions() } }