mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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:
parent
bec556679f
commit
6bd19712e8
@ -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'),
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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, {
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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() }
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user