From c5831df689dd651753c90887d08a4b9e1c0ec7e4 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 17 May 2026 00:02:37 +0200 Subject: [PATCH] fix(ai): vendor proxy env resolution closes #4513 --- package-lock.json | 5 -- packages/ai/CHANGELOG.md | 1 + packages/ai/package.json | 1 - packages/ai/src/utils/node-http-proxy.ts | 81 ++++++++++++++++++++++-- 4 files changed, 78 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 07555c8a..37a2d05d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6274,10 +6274,6 @@ "node": ">=12.0.0" } }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "license": "MIT" - }, "node_modules/pump": { "version": "3.0.4", "dev": true, @@ -7584,7 +7580,6 @@ "https-proxy-agent": "^7.0.6", "openai": "6.26.0", "partial-json": "^0.1.7", - "proxy-from-env": "^1.1.0", "typebox": "^1.1.24" }, "bin": { diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index cec9ae7c..f3b72aff 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -11,6 +11,7 @@ - Fixed GitHub Copilot model availability to ignore generic `GH_TOKEN` and `GITHUB_TOKEN` environment variables, requiring OAuth login or `COPILOT_GITHUB_TOKEN` instead ([#4485](https://github.com/earendil-works/pi/issues/4485)). - Fixed `openai-completions` streams to surface an error when the stream ends before any terminal `finish_reason`, so truncated responses can retry instead of being accepted as success ([#4345](https://github.com/earendil-works/pi/issues/4345)). - Fixed Bedrock proxy handling to preserve `NO_PROXY` exclusions while using HTTP(S)-only proxy agents. +- Fixed compiled Bun binaries failing to start outside the repo when Bedrock proxy support tried to resolve `proxy-from-env` from external `node_modules` ([#4513](https://github.com/earendil-works/pi/issues/4513)). - Fixed GitHub Copilot Claude test coverage to use the current Claude Sonnet 4.6 model ID. - Fixed OpenAI Responses requests for models that support disabling reasoning to send `reasoning.effort: "none"` when thinking is off. - Fixed Inception Mercury 2 tool calling on OpenRouter by marking `off` as unsupported in `thinkingLevelMap`, so the openai-completions provider omits the reasoning param instead of defaulting to `{reasoning:{effort:"none"}}` (which puts Mercury 2 in instant mode, disabling tool calls). diff --git a/packages/ai/package.json b/packages/ai/package.json index 149da8c3..4f4d5bf7 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -77,7 +77,6 @@ "https-proxy-agent": "^7.0.6", "openai": "6.26.0", "partial-json": "^0.1.7", - "proxy-from-env": "^1.1.0", "typebox": "^1.1.24" }, "keywords": [ diff --git a/packages/ai/src/utils/node-http-proxy.ts b/packages/ai/src/utils/node-http-proxy.ts index 22babf60..7b5f4750 100644 --- a/packages/ai/src/utils/node-http-proxy.ts +++ b/packages/ai/src/utils/node-http-proxy.ts @@ -1,11 +1,16 @@ import type { Agent as HttpAgent } from "node:http"; import type { Agent as HttpsAgent } from "node:https"; -import { createRequire } from "node:module"; import { HttpProxyAgent } from "http-proxy-agent"; import { HttpsProxyAgent } from "https-proxy-agent"; -const require = createRequire(import.meta.url); -const { getProxyForUrl } = require("proxy-from-env") as { getProxyForUrl: (url: string) => string }; +const DEFAULT_PROXY_PORTS: Record = { + ftp: 21, + gopher: 70, + http: 80, + https: 443, + ws: 80, + wss: 443, +}; export interface NodeHttpProxyAgents { httpAgent: HttpAgent; @@ -15,8 +20,76 @@ export interface NodeHttpProxyAgents { export const UNSUPPORTED_PROXY_PROTOCOL_MESSAGE = "Unsupported proxy protocol. SOCKS and PAC proxy URLs are not supported; use an HTTP or HTTPS proxy URL."; +function getProxyEnv(key: string): string { + return process.env[key.toLowerCase()] || process.env[key.toUpperCase()] || ""; +} + +function parseProxyTargetUrl(targetUrl: string | URL): URL | undefined { + if (targetUrl instanceof URL) { + return targetUrl; + } + + try { + return new URL(targetUrl); + } catch { + return undefined; + } +} + +function shouldProxyHostname(hostname: string, port: number): boolean { + const noProxy = getProxyEnv("no_proxy").toLowerCase(); + if (!noProxy) { + return true; + } + if (noProxy === "*") { + return false; + } + + return noProxy.split(/[,\s]/).every((proxy) => { + if (!proxy) { + return true; + } + + const parsedProxy = proxy.match(/^(.+):(\d+)$/); + let proxyHostname = parsedProxy ? parsedProxy[1] : proxy; + const proxyPort = parsedProxy ? Number.parseInt(parsedProxy[2]!, 10) : 0; + if (proxyPort && proxyPort !== port) { + return true; + } + + if (!/^[.*]/.test(proxyHostname)) { + return hostname !== proxyHostname; + } + + if (proxyHostname.startsWith("*")) { + proxyHostname = proxyHostname.slice(1); + } + return !hostname.endsWith(proxyHostname); + }); +} + +function getProxyForUrl(targetUrl: string | URL): string { + const parsedUrl = parseProxyTargetUrl(targetUrl); + if (!parsedUrl?.protocol || !parsedUrl.host) { + return ""; + } + + const protocol = parsedUrl.protocol.split(":", 1)[0]!; + const hostname = parsedUrl.host.replace(/:\d*$/, ""); + const port = Number.parseInt(parsedUrl.port, 10) || DEFAULT_PROXY_PORTS[protocol] || 0; + if (!shouldProxyHostname(hostname, port)) { + return ""; + } + + let proxy = getProxyEnv(`${protocol}_proxy`) || getProxyEnv("all_proxy"); + if (proxy && !proxy.includes("://")) { + proxy = `${protocol}://${proxy}`; + } + return proxy; +} + export function resolveHttpProxyUrlForTarget(targetUrl: string | URL): URL | undefined { - const proxy = getProxyForUrl(targetUrl.toString()); + const proxy = getProxyForUrl(targetUrl); if (!proxy) { return undefined; }