This commit is contained in:
Povilas Kanapickas 2026-05-27 11:27:37 +08:00 committed by GitHub
commit 1ce255f3ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 375 additions and 2 deletions

5
pnpm-lock.yaml generated
View file

@ -571,6 +571,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)
@ -22880,7 +22883,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

View file

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

View file

@ -17,6 +17,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"
@ -41,6 +42,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa
this.client = new Anthropic({
baseURL: this.options.anthropicBaseUrl || undefined,
[apiKeyFieldName]: this.options.apiKey,
httpAgent: getProxyHttpAgent(),
})
}

View file

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

View file

@ -458,6 +458,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",

View file

@ -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<unknown>,
languageModelAccessInformation: {} as vscode.LanguageModelAccessInformation,
} as unknown as vscode.ExtensionContext
}
describe("proxyFetch", () => {
let mockHttpConfig: { get: ReturnType<typeof vi.fn> }
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,
})
})
})
})

105
src/utils/proxyFetch.ts Normal file
View file

@ -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<string>("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<boolean>("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
}
}