fix: bypass proxy for loopback provider checks

Provider connectivity tests now pass the actual upstream URL and loopback no-proxy rules into proxy fetch option resolution, including IPv6 loopback. This closes the local test/connectivity gap in the #896 proxy compatibility work without closing GitHub issues before release.

Tested: bun test src/utils/proxy.test.ts src/server/__tests__/providers.test.ts src/server/__tests__/network-settings.test.ts src/services/api/client.test.ts

Tested: bun run check:server

Not-tested: Windows dev-sidecar real-machine retest and release build confirmation for #896.

Confidence: medium

Scope-risk: narrow
This commit is contained in:
程序员阿江(Relakkes) 2026-06-23 16:03:36 +08:00
parent bec556679f
commit 6bd19712e8
5 changed files with 114 additions and 10 deletions

View File

@ -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'),

View File

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

View File

@ -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, {

View File

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

View File

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