Compare commits

...
Author SHA1 Message Date
Dominic Cooney 64c71d6535 fix(vscode): never load bundled undici in VSCode builds to fix Anthropic connection errors
Fixes #11407

On VS Code 1.122+ (Electron 42 / Node 24), the Anthropic provider failed
every request with {"message":"Connection error."}. Root cause: merely
evaluating our bundled copy of undici (top-level import in shared/net.ts)
registers its Agent at the process-wide Symbol.for("undici.globalDispatcher.1"),
which Node's built-in fetch shares. With the bundled undici (7.26.0)
differing from the runtime's internal undici (7.24.4 in Electron 42),
built-in fetch drives a foreign Agent and rejects requests carrying an
explicit content-length header with UND_ERR_INVALID_ARG. The Anthropic
SDK <=0.40.x sets that header manually, surfacing the failure as
APIConnectionError: Connection error.

The VSCode build never needs bundled undici: VS Code patches global fetch
and WebSocket for proxy support in the extension host. undici is only
needed for standalone (JetBrains/CLI) env-var proxy support.

Changes:
- shared/net.ts: load undici lazily via require() inside the
  IS_STANDALONE === "true" branch only (esbuild statically rewrites the
  condition, so the VSCode bundle contains no require("undici") at all).
  Add createWebSocket() helper: global WebSocket on VSCode (proxy-patched
  by VS Code, supports the non-standard headers init since Node 22),
  bundled undici WebSocket on standalone (routes through the
  EnvHttpProxyAgent global dispatcher).
- openai-native.ts / openai-codex.ts: convert undici value imports to
  type-only imports; create Responses-API websockets via createWebSocket();
  replace UndiciWebSocket.OPEN statics with instance constants.

Verified under VS Code 1.124's Electron (ELECTRON_RUN_AS_NODE):
- VSCode bundle no longer registers a global dispatcher; fetch with an
  explicit content-length header succeeds where it previously failed
- standalone bundle still installs EnvHttpProxyAgent as global dispatcher
- tsc --noEmit, biome lint, and unit tests (1470 passing) are clean
2026-06-11 20:23:50 +09:00
3 changed files with 100 additions and 41 deletions
@@ -1,21 +1,21 @@
import { ModelInfo, OpenAiCodexModelId, openAiCodexDefaultModelId, openAiCodexModels } from "@shared/api"
import { type ModelInfo, type OpenAiCodexModelId, openAiCodexDefaultModelId, openAiCodexModels } from "@shared/api"
import { normalizeOpenaiReasoningEffort } from "@shared/storage/types"
import OpenAI from "openai"
import type { ChatCompletionTool } from "openai/resources/chat/completions"
import * as os from "os"
import { MessageEvent as UndiciMessageEvent, WebSocket as UndiciWebSocket } from "undici"
import type { MessageEvent as UndiciMessageEvent } from "undici"
import { v7 as uuidv7 } from "uuid"
import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
import { featureFlagsService } from "@/services/feature-flags"
import { ClineStorageMessage } from "@/shared/messages/content"
import { fetch } from "@/shared/net"
import type { ClineStorageMessage } from "@/shared/messages/content"
import { type ClineWebSocket, createWebSocket, fetch } from "@/shared/net"
import { ApiFormat } from "@/shared/proto/cline/models"
import { FeatureFlag } from "@/shared/services/feature-flags/feature-flags"
import { Logger } from "@/shared/services/Logger"
import { ApiHandler, CommonApiHandlerOptions } from "../"
import type { ApiHandler, CommonApiHandlerOptions } from "../"
import { convertToOpenAIResponsesInput } from "../transform/openai-response-format"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import type { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
/**
* OpenAI Codex base URL for API requests
@@ -42,7 +42,7 @@ interface OpenAiCodexHandlerOptions extends CommonApiHandlerOptions {
export class OpenAiCodexHandler implements ApiHandler {
private options: OpenAiCodexHandlerOptions
private client?: OpenAI
private responsesWs: UndiciWebSocket | undefined
private responsesWs: ClineWebSocket | undefined
private websocketRequestInFlight = false
// Session ID for the Codex API (persists for the lifetime of the handler)
private readonly sessionId: string
@@ -313,19 +313,17 @@ export class OpenAiCodexHandler implements ApiHandler {
return false
}
private async ensureResponsesWebsocket(accessToken: string, codexHeaders: Record<string, string>): Promise<UndiciWebSocket> {
if (this.responsesWs && this.responsesWs.readyState === UndiciWebSocket.OPEN) {
private async ensureResponsesWebsocket(accessToken: string, codexHeaders: Record<string, string>): Promise<ClineWebSocket> {
if (this.responsesWs && this.responsesWs.readyState === this.responsesWs.OPEN) {
return this.responsesWs
}
this.closeResponsesWebsocket()
const ws = new UndiciWebSocket(CODEX_RESPONSES_WEBSOCKET_URL, {
headers: {
Authorization: `Bearer ${accessToken}`,
"OpenAI-Beta": "responses_websockets=2026-02-06",
...codexHeaders,
},
const ws = createWebSocket(CODEX_RESPONSES_WEBSOCKET_URL, {
Authorization: `Bearer ${accessToken}`,
"OpenAI-Beta": "responses_websockets=2026-02-06",
...codexHeaders,
})
await new Promise<void>((resolve, reject) => {
@@ -1,32 +1,32 @@
import {
ModelInfo,
OpenAiCompatibleModelInfo,
OpenAiNativeModelId,
type ModelInfo,
type OpenAiCompatibleModelInfo,
type OpenAiNativeModelId,
openAiNativeDefaultModelId,
openAiNativeModels,
} from "@shared/api"
import { normalizeOpenaiReasoningEffort } from "@shared/storage/types"
import { calculateApiCostOpenAI } from "@utils/cost"
import OpenAI from "openai"
import type OpenAI from "openai"
import type {
ChatCompletionFunctionTool,
ChatCompletionReasoningEffort,
ChatCompletionTool,
} from "openai/resources/chat/completions"
import { MessageEvent as UndiciMessageEvent, WebSocket as UndiciWebSocket } from "undici"
import type { MessageEvent as UndiciMessageEvent } from "undici"
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
import { featureFlagsService } from "@/services/feature-flags"
import { ClineStorageMessage } from "@/shared/messages/content"
import { createOpenAIClient } from "@/shared/net"
import type { ClineStorageMessage } from "@/shared/messages/content"
import { type ClineWebSocket, createOpenAIClient, createWebSocket } from "@/shared/net"
import { ApiFormat } from "@/shared/proto/cline/models"
import { FeatureFlag } from "@/shared/services/feature-flags/feature-flags"
import { Logger } from "@/shared/services/Logger"
import { isGPT5ModelFamily } from "@/utils/model-utils"
import { ApiHandler, CommonApiHandlerOptions } from "../"
import type { ApiHandler, CommonApiHandlerOptions } from "../"
import { withRetry } from "../retry"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { convertToOpenAIResponsesInput } from "../transform/openai-response-format"
import { ApiStream } from "../transform/stream"
import type { ApiStream } from "../transform/stream"
import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-processor"
interface OpenAiNativeHandlerOptions extends CommonApiHandlerOptions {
@@ -40,8 +40,8 @@ interface OpenAiNativeHandlerOptions extends CommonApiHandlerOptions {
export class OpenAiNativeHandler implements ApiHandler {
private options: OpenAiNativeHandlerOptions
private client: OpenAI | undefined
private responsesWs: UndiciWebSocket | undefined
private responsesWsReadyPromise: Promise<UndiciWebSocket> | undefined
private responsesWs: ClineWebSocket | undefined
private responsesWsReadyPromise: Promise<ClineWebSocket> | undefined
private websocketRequestInFlight = false
private abortController?: AbortController
@@ -269,7 +269,9 @@ export class OpenAiNativeHandler implements ApiHandler {
): ApiStream {
const client = this.ensureClient()
Logger.debug(`OpenAI Responses Input (HTTP): ${JSON.stringify(params.input)}`)
const stream = await client.responses.create(params, { signal: this.abortController?.signal })
const stream = await client.responses.create(params, {
signal: this.abortController?.signal,
})
yield* this.processResponsesEvents(stream, modelInfo)
}
@@ -307,8 +309,8 @@ export class OpenAiNativeHandler implements ApiHandler {
return false
}
private async ensureResponsesWebsocket(): Promise<UndiciWebSocket> {
if (this.responsesWs && this.responsesWs.readyState === UndiciWebSocket.OPEN) {
private async ensureResponsesWebsocket(): Promise<ClineWebSocket> {
if (this.responsesWs && this.responsesWs.readyState === this.responsesWs.OPEN) {
return this.responsesWs
}
@@ -322,16 +324,14 @@ export class OpenAiNativeHandler implements ApiHandler {
throw new Error("OpenAI API key is required")
}
const ws = new UndiciWebSocket("wss://api.openai.com/v1/responses", {
headers: {
Authorization: `Bearer ${this.options.openAiNativeApiKey}`,
"OpenAI-Beta": "responses_websockets=2026-02-06",
...buildExternalBasicHeaders(),
},
const ws = createWebSocket("wss://api.openai.com/v1/responses", {
Authorization: `Bearer ${this.options.openAiNativeApiKey}`,
"OpenAI-Beta": "responses_websockets=2026-02-06",
...buildExternalBasicHeaders(),
})
this.responsesWs = ws
const readyPromise = new Promise<UndiciWebSocket>((resolve, reject) => {
const readyPromise = new Promise<ClineWebSocket>((resolve, reject) => {
const cleanup = () => {
ws.removeEventListener("open", handleOpen)
ws.removeEventListener("error", handleError)
@@ -509,7 +509,11 @@ export class OpenAiNativeHandler implements ApiHandler {
if (chunk.type === "response.output_item.added") {
const item = chunk.item
if (item.type === "function_call" && item.id) {
functionCallByItemId.set(item.id, { call_id: item.call_id, name: item.name, id: item.id })
functionCallByItemId.set(item.id, {
call_id: item.call_id,
name: item.name,
id: item.id,
})
yield {
type: "tool_calls",
id: item.id,
@@ -536,7 +540,11 @@ export class OpenAiNativeHandler implements ApiHandler {
const item = chunk.item
if (item.type === "function_call") {
if (item.id) {
functionCallByItemId.set(item.id, { call_id: item.call_id, name: item.name, id: item.id })
functionCallByItemId.set(item.id, {
call_id: item.call_id,
name: item.name,
id: item.id,
})
}
yield {
type: "tool_calls",
+56 -3
View File
@@ -93,10 +93,29 @@
* ```
*/
import OpenAI, { ClientOptions as OpenAIClientOptions } from "openai"
import { EnvHttpProxyAgent, setGlobalDispatcher, fetch as undiciFetch } from "undici"
import OpenAI, { type ClientOptions as OpenAIClientOptions } from "openai"
import type { WebSocket as UndiciWebSocket } from "undici"
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
/**
* IMPORTANT: undici must only ever be loaded in standalone (JetBrains/CLI)
* builds, and only via the lazy `require` calls below--never via a top-level
* value import.
*
* Merely evaluating the undici module registers its Agent at the global
* symbol `Symbol.for("undici.globalDispatcher.1")`, which is shared with
* Node's built-in fetch. When the bundled undici version differs from the
* undici built into the Node runtime (e.g. VS Code's Electron), built-in
* fetch picks up the foreign Agent and requests can fail with errors like
* `UND_ERR_INVALID_ARG: invalid content-length header`. This broke the
* Anthropic provider on VS Code 1.124 / Electron 42 / Node 24.
* See https://github.com/cline/cline/issues/11407
*/
function requireUndici(): typeof import("undici") {
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require("undici")
}
let mockFetch: typeof globalThis.fetch | undefined
/**
@@ -117,7 +136,9 @@ export const fetch: typeof globalThis.fetch = (() => {
// to "true" or "false" (as strings) in the JetBrains/CLI build.
// We must use explicit string comparison because "false" is truthy in JS.
if (process.env.IS_STANDALONE === "true") {
// Configure undici with ProxyAgent
// Configure undici with ProxyAgent. undici is loaded lazily so that
// the VSCode build never evaluates it (see requireUndici above).
const { EnvHttpProxyAgent, setGlobalDispatcher, fetch: undiciFetch } = requireUndici()
const agent = new EnvHttpProxyAgent({})
setGlobalDispatcher(agent)
baseFetch = undiciFetch as any as typeof globalThis.fetch
@@ -200,3 +221,35 @@ export function createOpenAIClient(options: OpenAIClientOptions): OpenAI {
fetch, // Use configured fetch with proxy support
})
}
/**
* WebSocket type used by `createWebSocket`. Node 22+'s global WebSocket is
* undici's implementation, so undici's type describes both branches below.
*/
export type ClineWebSocket = UndiciWebSocket
/**
* Creates a WebSocket that supports custom request headers (a non-standard
* extension provided by undici's WebSocket, which is also Node's built-in
* WebSocket since Node 22).
*
* - **VSCode**: uses `globalThis.WebSocket`, which VS Code's extension host
* patches for proxy support. We must not use the bundled undici here (see
* requireUndici above).
* - **JetBrains, CLI**: uses the bundled undici WebSocket so the connection
* goes through the EnvHttpProxyAgent global dispatcher configured above.
*/
export function createWebSocket(url: string, headers: Record<string, string>): ClineWebSocket {
if (process.env.IS_STANDALONE === "true") {
const { WebSocket: StandaloneWebSocket } = requireUndici()
return new StandaloneWebSocket(url, { headers })
}
// Note: `headers` in the init object is non-standard, but supported by
// Node's (undici-based) global WebSocket implementation.
return new (
globalThis.WebSocket as unknown as new (
url: string,
init: { headers: Record<string, string> },
) => ClineWebSocket
)(url, { headers })
}