From 4f32c441ed9061276a6736c571c54516fc2af472 Mon Sep 17 00:00:00 2001 From: Povilas Kanapickas Date: Sun, 1 Mar 2026 18:14:46 +0200 Subject: [PATCH] fix: pass proxy httpAgent to Anthropic SDK (node-fetch based) The Anthropic SDK v0.x uses node-fetch with custom agentkeepalive agents, which bypass VSCode's http/https module proxy patching. Add https-proxy-agent dependency and getProxyHttpAgent() utility that creates an HttpsProxyAgent from the configured proxy URL. Pass it as httpAgent to all Anthropic/AnthropicVertex client constructors: - anthropic.ts - minimax.ts (uses Anthropic SDK for Anthropic-compatible endpoint) - anthropic-vertex.ts --- pnpm-lock.yaml | 5 +- src/api/providers/anthropic-vertex.ts | 7 +- src/api/providers/anthropic.ts | 2 + src/api/providers/minimax.ts | 2 + src/package.json | 1 + src/utils/__tests__/proxyFetch.spec.ts | 255 +++++++++++++++++++++++++ src/utils/proxyFetch.ts | 105 ++++++++++ 7 files changed, 375 insertions(+), 2 deletions(-) create mode 100644 src/utils/__tests__/proxyFetch.spec.ts create mode 100644 src/utils/proxyFetch.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d95c2f02346..b64882841f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -872,6 +872,9 @@ importers: gray-matter: specifier: ^4.0.3 version: 4.0.3 + https-proxy-agent: + specifier: ^7.0.6 + version: 7.0.6 i18next: specifier: ^25.0.0 version: 25.2.1(typescript@5.8.3) @@ -17622,7 +17625,7 @@ snapshots: https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.3 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3 transitivePeerDependencies: - supports-color diff --git a/src/api/providers/anthropic-vertex.ts b/src/api/providers/anthropic-vertex.ts index 3ed5dd45cce..37ea4db17c0 100644 --- a/src/api/providers/anthropic-vertex.ts +++ b/src/api/providers/anthropic-vertex.ts @@ -25,6 +25,7 @@ import { import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" +import { getProxyHttpAgent } from "../../utils/proxyFetch" // https://docs.anthropic.com/en/api/claude-on-vertex-ai export class AnthropicVertexHandler extends BaseProvider implements SingleCompletionHandler { @@ -40,10 +41,13 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple const projectId = this.options.vertexProjectId ?? "not-provided" const region = this.options.vertexRegion ?? "us-east5" + const httpAgent = getProxyHttpAgent() + if (this.options.vertexJsonCredentials) { this.client = new AnthropicVertex({ projectId, region, + httpAgent, googleAuth: new GoogleAuth({ scopes: ["https://www.googleapis.com/auth/cloud-platform"], credentials: safeJsonParse(this.options.vertexJsonCredentials, undefined), @@ -53,13 +57,14 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple this.client = new AnthropicVertex({ projectId, region, + httpAgent, googleAuth: new GoogleAuth({ scopes: ["https://www.googleapis.com/auth/cloud-platform"], keyFile: this.options.vertexKeyFile, }), }) } else { - this.client = new AnthropicVertex({ projectId, region }) + this.client = new AnthropicVertex({ projectId, region, httpAgent }) } } diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index 1786a105a5e..30c1caa7272 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -19,6 +19,7 @@ import { ApiStream } from "../transform/stream" import { getModelParams } from "../transform/model-params" import { filterNonAnthropicBlocks } from "../transform/anthropic-filter" import { handleProviderError } from "./utils/error-handler" +import { getProxyHttpAgent } from "../../utils/proxyFetch" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" @@ -43,6 +44,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa this.client = new Anthropic({ baseURL: this.options.anthropicBaseUrl || undefined, [apiKeyFieldName]: this.options.apiKey, + httpAgent: getProxyHttpAgent(), }) } diff --git a/src/api/providers/minimax.ts b/src/api/providers/minimax.ts index bfcf4e3be40..81b3afd6fde 100644 --- a/src/api/providers/minimax.ts +++ b/src/api/providers/minimax.ts @@ -15,6 +15,7 @@ import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" import { calculateApiCostAnthropic } from "../../shared/cost" import { convertOpenAIToolsToAnthropic } from "../../core/prompts/tools/native-tools/converters" +import { getProxyHttpAgent } from "../../utils/proxyFetch" /** * Converts OpenAI tool_choice to Anthropic ToolChoice format @@ -73,6 +74,7 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand this.client = new Anthropic({ baseURL, apiKey: options.minimaxApiKey, + httpAgent: getProxyHttpAgent(), }) } diff --git a/src/package.json b/src/package.json index 241312ac8c6..6fa3f3deec8 100644 --- a/src/package.json +++ b/src/package.json @@ -489,6 +489,7 @@ "global-agent": "^3.0.0", "google-auth-library": "^9.15.1", "gray-matter": "^4.0.3", + "https-proxy-agent": "^7.0.6", "i18next": "^25.0.0", "ignore": "^7.0.3", "isbinaryfile": "^5.0.2", diff --git a/src/utils/__tests__/proxyFetch.spec.ts b/src/utils/__tests__/proxyFetch.spec.ts new file mode 100644 index 00000000000..d34b843ab95 --- /dev/null +++ b/src/utils/__tests__/proxyFetch.spec.ts @@ -0,0 +1,255 @@ +import * as vscode from "vscode" + +const mockHttpsProxyAgentConstructor = vi.fn() + +vi.mock("https-proxy-agent", () => ({ + HttpsProxyAgent: mockHttpsProxyAgentConstructor, +})) + +vi.mock("vscode", () => ({ + workspace: { + getConfiguration: vi.fn(), + onDidChangeConfiguration: vi.fn(() => ({ dispose: vi.fn() })), + }, +})) + +function createMockContext(): vscode.ExtensionContext { + return { + extensionMode: 1, // Production + subscriptions: [], + extensionPath: "/test/path", + globalState: { + get: vi.fn(), + update: vi.fn(), + keys: vi.fn().mockReturnValue([]), + setKeysForSync: vi.fn(), + }, + workspaceState: { + get: vi.fn(), + update: vi.fn(), + keys: vi.fn().mockReturnValue([]), + }, + secrets: { + get: vi.fn(), + store: vi.fn(), + delete: vi.fn(), + onDidChange: vi.fn(), + }, + extensionUri: { fsPath: "/test/path" } as vscode.Uri, + globalStorageUri: { fsPath: "/test/global" } as vscode.Uri, + logUri: { fsPath: "/test/logs" } as vscode.Uri, + storageUri: { fsPath: "/test/storage" } as vscode.Uri, + storagePath: "/test/storage", + globalStoragePath: "/test/global", + logPath: "/test/logs", + asAbsolutePath: vi.fn((p) => `/test/path/${p}`), + environmentVariableCollection: {} as vscode.GlobalEnvironmentVariableCollection, + extension: {} as vscode.Extension, + languageModelAccessInformation: {} as vscode.LanguageModelAccessInformation, + } as unknown as vscode.ExtensionContext +} + +describe("proxyFetch", () => { + let mockHttpConfig: { get: ReturnType } + let savedFetch: typeof globalThis.fetch + + beforeEach(() => { + vi.clearAllMocks() + vi.resetModules() + + savedFetch = globalThis.fetch + + mockHttpConfig = { + get: vi.fn().mockReturnValue(undefined), + } + + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue( + mockHttpConfig as unknown as vscode.WorkspaceConfiguration, + ) + + // Clear proxy env vars + delete process.env.HTTPS_PROXY + delete process.env.https_proxy + delete process.env.HTTP_PROXY + delete process.env.http_proxy + }) + + afterEach(() => { + globalThis.fetch = savedFetch + }) + + describe("resolveProxyUrl", () => { + it("should return undefined when no proxy is configured", async () => { + const { resolveProxyUrl } = await import("../proxyFetch") + + const result = resolveProxyUrl() + + expect(result).toBeUndefined() + }) + + it("should return VSCode http.proxy setting when configured", async () => { + mockHttpConfig.get.mockImplementation((key: string) => { + if (key === "proxy") return "http://corporate-proxy:8080" + return undefined + }) + + const { resolveProxyUrl } = await import("../proxyFetch") + + const result = resolveProxyUrl() + + expect(result).toBe("http://corporate-proxy:8080") + expect(vscode.workspace.getConfiguration).toHaveBeenCalledWith("http") + }) + + it("should trim whitespace from VSCode proxy setting", async () => { + mockHttpConfig.get.mockImplementation((key: string) => { + if (key === "proxy") return " http://corporate-proxy:8080 " + return undefined + }) + + const { resolveProxyUrl } = await import("../proxyFetch") + + const result = resolveProxyUrl() + + expect(result).toBe("http://corporate-proxy:8080") + }) + + it("should ignore empty VSCode proxy setting and fall back to env vars", async () => { + mockHttpConfig.get.mockImplementation((key: string) => { + if (key === "proxy") return " " + return undefined + }) + process.env.HTTPS_PROXY = "http://env-proxy:3128" + + const { resolveProxyUrl } = await import("../proxyFetch") + + const result = resolveProxyUrl() + + expect(result).toBe("http://env-proxy:3128") + }) + + it("should prefer HTTPS_PROXY over HTTP_PROXY", async () => { + process.env.HTTPS_PROXY = "http://https-proxy:3128" + process.env.HTTP_PROXY = "http://http-proxy:3128" + + const { resolveProxyUrl } = await import("../proxyFetch") + + const result = resolveProxyUrl() + + expect(result).toBe("http://https-proxy:3128") + }) + + it("should fall back to lowercase env vars", async () => { + process.env.https_proxy = "http://lowercase-proxy:3128" + + const { resolveProxyUrl } = await import("../proxyFetch") + + const result = resolveProxyUrl() + + expect(result).toBe("http://lowercase-proxy:3128") + }) + + it("should prefer VSCode setting over env vars", async () => { + mockHttpConfig.get.mockImplementation((key: string) => { + if (key === "proxy") return "http://vscode-proxy:8080" + return undefined + }) + process.env.HTTPS_PROXY = "http://env-proxy:3128" + + const { resolveProxyUrl } = await import("../proxyFetch") + + const result = resolveProxyUrl() + + expect(result).toBe("http://vscode-proxy:8080") + }) + }) + + describe("getProxyHttpAgent", () => { + it("should return undefined when no proxy is configured", async () => { + const { getProxyHttpAgent } = await import("../proxyFetch") + + const agent = getProxyHttpAgent() + + expect(agent).toBeUndefined() + expect(mockHttpsProxyAgentConstructor).not.toHaveBeenCalled() + }) + + it("should return an HttpsProxyAgent when proxy is configured", async () => { + mockHttpConfig.get.mockImplementation((key: string) => { + if (key === "proxy") return "http://corporate-proxy:8080" + if (key === "proxyStrictSSL") return true + return undefined + }) + + const mockAgent = { mock: true } + mockHttpsProxyAgentConstructor.mockReturnValue(mockAgent) + + const { getProxyHttpAgent } = await import("../proxyFetch") + + const agent = getProxyHttpAgent() + + expect(agent).toBe(mockAgent) + expect(mockHttpsProxyAgentConstructor).toHaveBeenCalledWith("http://corporate-proxy:8080", { + rejectUnauthorized: true, + }) + }) + + it("should disable TLS verification when proxyStrictSSL is false", async () => { + mockHttpConfig.get.mockImplementation((key: string) => { + if (key === "proxy") return "http://corporate-proxy:8080" + if (key === "proxyStrictSSL") return false + return undefined + }) + + mockHttpsProxyAgentConstructor.mockReturnValue({ mock: true }) + + const { getProxyHttpAgent } = await import("../proxyFetch") + + getProxyHttpAgent() + + expect(mockHttpsProxyAgentConstructor).toHaveBeenCalledWith("http://corporate-proxy:8080", { + rejectUnauthorized: false, + }) + }) + + it("should return undefined and log error when HttpsProxyAgent constructor throws", async () => { + mockHttpConfig.get.mockImplementation((key: string) => { + if (key === "proxy") return "http://bad-proxy:9999" + if (key === "proxyStrictSSL") return true + return undefined + }) + + mockHttpsProxyAgentConstructor.mockImplementation(() => { + throw new Error("Invalid proxy URL") + }) + + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + const { getProxyHttpAgent } = await import("../proxyFetch") + + const agent = getProxyHttpAgent() + + expect(agent).toBeUndefined() + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("[ProxyFetch] Failed to create HttpsProxyAgent"), + ) + + consoleErrorSpy.mockRestore() + }) + + it("should use env proxy when VSCode setting is not configured", async () => { + process.env.HTTPS_PROXY = "http://env-proxy:3128" + + mockHttpsProxyAgentConstructor.mockReturnValue({ mock: true }) + + const { getProxyHttpAgent } = await import("../proxyFetch") + + const agent = getProxyHttpAgent() + + expect(agent).toBeDefined() + expect(mockHttpsProxyAgentConstructor).toHaveBeenCalledWith("http://env-proxy:3128", { + rejectUnauthorized: true, + }) + }) + }) +}) diff --git a/src/utils/proxyFetch.ts b/src/utils/proxyFetch.ts new file mode 100644 index 00000000000..4b53aca3cee --- /dev/null +++ b/src/utils/proxyFetch.ts @@ -0,0 +1,105 @@ +/** + * Proxy-Aware HTTP Agent Module + * + * Provides proxy support for SDKs that use `node-fetch` with custom agents + * (e.g. Anthropic SDK v0.x), routing their traffic through the user's + * configured proxy (VSCode `http.proxy` setting or standard environment + * variables). + * + * Background: + * - VSCode patches Node.js `http`/`https` modules to honour `http.proxy`, so + * libraries that use those modules (e.g. axios) are already proxied. + * - The Anthropic SDK uses `node-fetch` with custom `agentkeepalive` agents, + * which bypass VSCode's proxy patching. For these SDKs, we provide + * `getProxyHttpAgent()` to create an `HttpsProxyAgent` that can be passed + * as the `httpAgent` option. + */ + +import * as vscode from "vscode" +import type { Agent } from "node:http" +import { HttpsProxyAgent } from "https-proxy-agent" + +/** + * Resolve the effective proxy URL. + * + * Priority: + * 1. VSCode `http.proxy` setting (works in both local and remote mode) + * 2. Standard environment variables (`HTTPS_PROXY`, `HTTP_PROXY`, `https_proxy`, `http_proxy`) + * + * Returns `undefined` when no proxy is configured. + */ +export function resolveProxyUrl(): string | undefined { + // 1. VSCode setting + const httpConfig = vscode.workspace.getConfiguration("http") + const vsCodeProxy = httpConfig.get("proxy") + if (typeof vsCodeProxy === "string" && vsCodeProxy.trim()) { + return vsCodeProxy.trim() + } + + // 2. Environment variables (standard precedence) + const envProxy = + process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy + if (envProxy && envProxy.trim()) { + return envProxy.trim() + } + + return undefined +} + +/** + * Check whether TLS certificate verification should be strict. + * + * Reads VSCode's `http.proxyStrictSSL` setting (defaults to `true`). + */ +function isStrictSSL(): boolean { + const httpConfig = vscode.workspace.getConfiguration("http") + return httpConfig.get("proxyStrictSSL") ?? true +} + +/** + * Redact credentials from a proxy URL for safe logging. + */ +function redactUrl(url: string): string { + try { + const parsed = new URL(url) + parsed.username = "" + parsed.password = "" + return parsed.toString() + } catch { + return url.replace(/\/\/[^@/]+@/g, "//REDACTED@") + } +} + +/** + * Create an `HttpsProxyAgent` for SDKs that use `node-fetch` with custom + * agents (e.g. Anthropic SDK v0.x). + * + * Returns `undefined` when no proxy is configured, so callers can use: + * + * ```ts + * new Anthropic({ httpAgent: getProxyHttpAgent() }) + * ``` + * + * The Anthropic SDK falls back to its default `agentkeepalive` agent when + * `httpAgent` is `undefined`, which is the correct behaviour when no proxy + * is needed. + */ +export function getProxyHttpAgent(): Agent | undefined { + const proxyUrl = resolveProxyUrl() + if (!proxyUrl) { + return undefined + } + + const strictSSL = isStrictSSL() + + try { + return new HttpsProxyAgent(proxyUrl, { + rejectUnauthorized: strictSSL, + }) + } catch (error) { + console.error( + `[ProxyFetch] Failed to create HttpsProxyAgent for "${redactUrl(proxyUrl)}": ${error instanceof Error ? error.message : String(error)}`, + ) + return undefined + } +}