mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-20 13:53:32 +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 () => {
|
test('should use configured network timeout for provider tests', async () => {
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(tmpDir, 'settings.json'),
|
path.join(tmpDir, 'settings.json'),
|
||||||
|
|||||||
@ -86,7 +86,7 @@ export function getManualNetworkProxyUrl(settings: NetworkSettings): string | un
|
|||||||
return url || undefined
|
return url || undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeLoopbackNoProxy(existing: string | undefined): string {
|
export function mergeLoopbackNoProxy(existing: string | undefined): string {
|
||||||
const entries = (existing ?? '')
|
const entries = (existing ?? '')
|
||||||
.split(/[,\s]+/)
|
.split(/[,\s]+/)
|
||||||
.map(entry => entry.trim())
|
.map(entry => entry.trim())
|
||||||
|
|||||||
@ -39,6 +39,7 @@ import { getProxyFetchOptions } from '../../utils/proxy.js'
|
|||||||
import {
|
import {
|
||||||
getManualNetworkProxyUrl,
|
getManualNetworkProxyUrl,
|
||||||
loadNetworkSettings,
|
loadNetworkSettings,
|
||||||
|
mergeLoopbackNoProxy,
|
||||||
type NetworkSettings,
|
type NetworkSettings,
|
||||||
} from './networkSettings.js'
|
} from './networkSettings.js'
|
||||||
import { normalizeModelStringForAPI } from '../../utils/model/model.js'
|
import { normalizeModelStringForAPI } from '../../utils/model/model.js'
|
||||||
@ -597,7 +598,11 @@ export class ProviderService {
|
|||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
try {
|
try {
|
||||||
const { url, headers, body } = buildDirectTestRequest(base, apiKey, modelId, format, authStrategy)
|
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, {
|
const response = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
@ -660,7 +665,11 @@ export class ProviderService {
|
|||||||
transformedBody = anthropicToOpenaiResponses(anthropicReq)
|
transformedBody = anthropicToOpenaiResponses(anthropicReq)
|
||||||
upstreamUrl = `${base}/v1/responses`
|
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
|
// Call upstream with transformed request
|
||||||
const response = await fetch(upstreamUrl, {
|
const response = await fetch(upstreamUrl, {
|
||||||
|
|||||||
@ -35,6 +35,32 @@ describe('proxy environment handling', () => {
|
|||||||
}).proxy).toBeUndefined()
|
}).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', () => {
|
test('keeps proxy fetch options for external provider targets', () => {
|
||||||
process.env.HTTP_PROXY = 'http://127.0.0.1:1181'
|
process.env.HTTP_PROXY = 'http://127.0.0.1:1181'
|
||||||
process.env.HTTPS_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 {
|
try {
|
||||||
const url = new URL(urlString)
|
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 port = url.port || (url.protocol === 'https:' ? '443' : '80')
|
||||||
const hostWithPort = `${hostname}:${port}`
|
const hostWithPort = `${hostname}:${port}`
|
||||||
|
const bracketedHostWithPort = hostname.includes(':') ? `[${hostname}]:${port}` : hostWithPort
|
||||||
|
|
||||||
// Split by comma or space and trim each entry
|
// Split by comma or space and trim each entry
|
||||||
const noProxyList = noProxy.split(/[,\s]+/).filter(Boolean)
|
const noProxyList = noProxy.split(/[,\s]+/).filter(Boolean)
|
||||||
|
|
||||||
return noProxyList.some(pattern => {
|
return noProxyList.some(pattern => {
|
||||||
pattern = pattern.toLowerCase().trim()
|
pattern = pattern.toLowerCase().trim()
|
||||||
|
const normalizedPattern = pattern.startsWith('[') && pattern.endsWith(']')
|
||||||
|
? pattern.slice(1, -1)
|
||||||
|
: pattern
|
||||||
|
|
||||||
// Check for port-specific match
|
// Check for port-specific match
|
||||||
if (pattern.includes(':')) {
|
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)
|
// 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
|
// Check for exact hostname match or IP address
|
||||||
return hostname === pattern
|
return hostname === normalizedPattern
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
// If URL parsing fails, don't bypass proxy
|
// 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
|
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
|
* requests get misrouted to api.anthropic.com. Only the Anthropic SDK client
|
||||||
* should pass `true` here.
|
* 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
|
tls?: TLSConfig
|
||||||
dispatcher?: undici.Dispatcher
|
dispatcher?: undici.Dispatcher
|
||||||
proxy?: string
|
proxy?: string
|
||||||
@ -313,7 +330,7 @@ export function getProxyFetchOptions(opts?: { forAnthropicAPI?: boolean; proxyUr
|
|||||||
? opts.proxyUrl || undefined
|
? opts.proxyUrl || undefined
|
||||||
: getProxyUrl()
|
: getProxyUrl()
|
||||||
|
|
||||||
if (proxyUrl && shouldBypassProxyForTarget(opts?.targetUrl)) {
|
if (proxyUrl && shouldBypassProxyForTarget(opts?.targetUrl, opts?.noProxy)) {
|
||||||
return { ...base, ...getTLSFetchOptions() }
|
return { ...base, ...getTLSFetchOptions() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user