From dbcf7abda1e08c87e9a1d0713567581f7f04f7c8 Mon Sep 17 00:00:00 2001 From: Ian Alloway Date: Thu, 16 Jul 2026 06:47:40 -0400 Subject: [PATCH] fix(anthropic-vertex): keep ADC fetch provider-local (#108350) * fix(anthropic-vertex): shim native fetch so Google auth avoids gaxios's node-fetch import @anthropic-ai/vertex-sdk's bundled gaxios only uses native fetch when a global `window.fetch` exists; otherwise it dynamically imports `node-fetch`, which can fail to resolve depending on how the plugin's dependencies are installed. That failure surfaces deep inside gaxios's token-exchange path as "Cannot convert undefined or null to object", breaking every Vertex auth request (#107341). The same gaxios root cause hit a different provider in #41380, where a global window.fetch shim was the confirmed workaround. Fixes #107341 * fix(anthropic-vertex): compare window directly against undefined oxlint's unicorn/no-typeof-undefined flagged the typeof check; a direct comparison is safe here since target.window is a property access, not a possibly-undeclared identifier. * fix(anthropic-vertex): keep ADC fetch provider-local Co-authored-by: Ian Alloway * test(anthropic-vertex): inject auth in API fixtures Co-authored-by: Ian Alloway * refactor(anthropic-vertex): clarify auth transport contract Co-authored-by: Ian Alloway * fix(anthropic-vertex): preserve ADC proxy routing * chore: leave changelog to release workflow --------- Co-authored-by: Ian Alloway Co-authored-by: Peter Steinberger --- extensions/anthropic-vertex/api.test.ts | 3 + .../anthropic-vertex/npm-shrinkwrap.json | 13 ++- extensions/anthropic-vertex/package.json | 4 +- .../anthropic-vertex/stream-runtime.test.ts | 94 ++++++++++++++++++- extensions/anthropic-vertex/stream-runtime.ts | 31 ++++++ pnpm-lock.yaml | 6 ++ 6 files changed, 144 insertions(+), 7 deletions(-) diff --git a/extensions/anthropic-vertex/api.test.ts b/extensions/anthropic-vertex/api.test.ts index d7f42a1688ed..8a65bd802733 100644 --- a/extensions/anthropic-vertex/api.test.ts +++ b/extensions/anthropic-vertex/api.test.ts @@ -16,10 +16,13 @@ function createStreamDeps(): { const MockAnthropicVertex = function MockAnthropicVertex(options: unknown) { anthropicVertexCtorMock(options); } as unknown as AnthropicVertexStreamDeps["AnthropicVertex"]; + const MockGoogleAuth = + function MockGoogleAuth() {} as unknown as AnthropicVertexStreamDeps["GoogleAuth"]; return { deps: { AnthropicVertex: MockAnthropicVertex, + GoogleAuth: MockGoogleAuth, streamAnthropic: streamAnthropicMock, }, streamAnthropicMock, diff --git a/extensions/anthropic-vertex/npm-shrinkwrap.json b/extensions/anthropic-vertex/npm-shrinkwrap.json index b30b1e695ef6..c146bd8403b4 100644 --- a/extensions/anthropic-vertex/npm-shrinkwrap.json +++ b/extensions/anthropic-vertex/npm-shrinkwrap.json @@ -8,7 +8,9 @@ "name": "@openclaw/anthropic-vertex-provider", "version": "2026.7.2", "dependencies": { - "@anthropic-ai/vertex-sdk": "0.19.0" + "@anthropic-ai/vertex-sdk": "0.19.0", + "google-auth-library": "10.9.0", + "undici": "8.5.0" } }, "node_modules/@anthropic-ai/sdk": { @@ -363,6 +365,15 @@ "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", "license": "MIT" }, + "node_modules/undici": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", + "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", diff --git a/extensions/anthropic-vertex/package.json b/extensions/anthropic-vertex/package.json index bb5606bf4809..5e19386892f4 100644 --- a/extensions/anthropic-vertex/package.json +++ b/extensions/anthropic-vertex/package.json @@ -8,7 +8,9 @@ }, "type": "module", "dependencies": { - "@anthropic-ai/vertex-sdk": "0.19.0" + "@anthropic-ai/vertex-sdk": "0.19.0", + "google-auth-library": "10.9.0", + "undici": "8.5.0" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/anthropic-vertex/stream-runtime.test.ts b/extensions/anthropic-vertex/stream-runtime.test.ts index 912401a124fd..db4cf476536f 100644 --- a/extensions/anthropic-vertex/stream-runtime.test.ts +++ b/extensions/anthropic-vertex/stream-runtime.test.ts @@ -1,4 +1,6 @@ // Anthropic Vertex tests cover stream runtime plugin behavior. +import { once } from "node:events"; +import { createServer } from "node:http"; import { createAssistantMessageEventStream, type Model } from "openclaw/plugin-sdk/llm"; import { beforeAll, describe, expect, it, vi } from "vitest"; import type { AnthropicVertexStreamDeps } from "./stream-runtime.js"; @@ -7,6 +9,8 @@ function createStreamDeps(): { deps: AnthropicVertexStreamDeps; streamAnthropicMock: ReturnType; anthropicVertexCtorMock: ReturnType; + googleAuthCtorMock: ReturnType; + googleAuthClient: InstanceType; } { const streamAnthropicMock = vi.fn( (..._args: Parameters) => @@ -16,14 +20,23 @@ function createStreamDeps(): { const MockAnthropicVertex = function MockAnthropicVertex(options: unknown) { anthropicVertexCtorMock(options); } as unknown as AnthropicVertexStreamDeps["AnthropicVertex"]; + const googleAuthCtorMock = vi.fn(); + const googleAuthClient = {} as InstanceType; + const MockGoogleAuth = function MockGoogleAuth(options: unknown) { + googleAuthCtorMock(options); + return googleAuthClient; + } as unknown as AnthropicVertexStreamDeps["GoogleAuth"]; return { deps: { AnthropicVertex: MockAnthropicVertex, + GoogleAuth: MockGoogleAuth, streamAnthropic: streamAnthropicMock, }, streamAnthropicMock, anthropicVertexCtorMock, + googleAuthCtorMock, + googleAuthClient, }; } @@ -149,18 +162,85 @@ describe("createAnthropicVertexStreamFn", () => { }); it("omits projectId when ADC credentials are used without an explicit project", () => { - const { deps, anthropicVertexCtorMock } = createStreamDeps(); + const { deps, anthropicVertexCtorMock, googleAuthClient } = createStreamDeps(); const streamFn = createAnthropicVertexStreamFn(undefined, "global", undefined, deps); void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 128000 }), { messages: [] }, {}); expect(anthropicVertexCtorMock).toHaveBeenCalledWith({ + googleAuth: googleAuthClient, region: "global", }); }); + it("uses provider-local proxy-aware fetch without mutating the global window", async () => { + const { deps, anthropicVertexCtorMock, googleAuthCtorMock, googleAuthClient } = + createStreamDeps(); + const windowDescriptor = Object.getOwnPropertyDescriptor(globalThis, "window"); + + createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps); + + expect(googleAuthCtorMock).toHaveBeenCalledWith({ + scopes: ["https://www.googleapis.com/auth/cloud-platform"], + clientOptions: { + transporterOptions: { fetchImplementation: expect.any(Function) }, + }, + }); + const authOptions = googleAuthCtorMock.mock.calls[0]?.[0] as + | { + clientOptions?: { + transporterOptions?: { fetchImplementation?: typeof globalThis.fetch }; + }; + } + | undefined; + const fetchImplementation = authOptions?.clientOptions?.transporterOptions?.fetchImplementation; + expect(fetchImplementation).not.toBe(globalThis.fetch); + + let proxyHit = false; + const proxy = createServer((_request, response) => { + proxyHit = true; + response.end("proxied"); + }); + proxy.on("connect", (_request, socket) => { + proxyHit = true; + socket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + socket.once("data", () => { + socket.end("HTTP/1.1 200 OK\r\nContent-Length: 7\r\n\r\nproxied"); + }); + }); + proxy.listen(0, "127.0.0.1"); + await once(proxy, "listening"); + const address = proxy.address(); + if (!address || typeof address === "string" || !fetchImplementation) { + proxy.close(); + throw new Error("Expected local proxy and Google auth fetch implementation"); + } + const proxyUrl = `http://127.0.0.1:${address.port}`; + vi.stubEnv("HTTP_PROXY", proxyUrl); + vi.stubEnv("http_proxy", proxyUrl); + vi.stubEnv("NO_PROXY", ""); + vi.stubEnv("no_proxy", ""); + try { + const response = await fetchImplementation("http://vertex-token.invalid/token", { + agent: {}, + } as never); + expect(await response.text()).toBe("proxied"); + expect(proxyHit).toBe(true); + } finally { + vi.unstubAllEnvs(); + proxy.close(); + await once(proxy, "close"); + } + expect(anthropicVertexCtorMock).toHaveBeenCalledWith({ + googleAuth: googleAuthClient, + projectId: "vertex-project", + region: "us-east5", + }); + expect(Object.getOwnPropertyDescriptor(globalThis, "window")).toEqual(windowDescriptor); + }); + it("passes an explicit baseURL through to the Vertex client", () => { - const { deps, anthropicVertexCtorMock } = createStreamDeps(); + const { deps, anthropicVertexCtorMock, googleAuthClient } = createStreamDeps(); const streamFn = createAnthropicVertexStreamFn( "vertex-project", "us-east5", @@ -171,6 +251,7 @@ describe("createAnthropicVertexStreamFn", () => { void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 128000 }), { messages: [] }, {}); expect(anthropicVertexCtorMock).toHaveBeenCalledWith({ + googleAuth: googleAuthClient, projectId: "vertex-project", region: "us-east5", baseURL: "https://proxy.example.test/vertex/v1", @@ -468,7 +549,7 @@ describe("createAnthropicVertexStreamFn", () => { describe("createAnthropicVertexStreamFnForModel", () => { it("derives project and region from the model and env", () => { - const { deps, anthropicVertexCtorMock } = createStreamDeps(); + const { deps, anthropicVertexCtorMock, googleAuthClient } = createStreamDeps(); const streamFn = createAnthropicVertexStreamFnForModel( { baseUrl: "https://europe-west4-aiplatform.googleapis.com" }, { GOOGLE_CLOUD_PROJECT_ID: "vertex-project" } as NodeJS.ProcessEnv, @@ -478,6 +559,7 @@ describe("createAnthropicVertexStreamFnForModel", () => { void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 64000 }), { messages: [] }, {}); expect(anthropicVertexCtorMock).toHaveBeenCalledWith({ + googleAuth: googleAuthClient, projectId: "vertex-project", region: "europe-west4", baseURL: "https://europe-west4-aiplatform.googleapis.com/v1", @@ -485,7 +567,7 @@ describe("createAnthropicVertexStreamFnForModel", () => { }); it("preserves explicit custom provider base URLs", () => { - const { deps, anthropicVertexCtorMock } = createStreamDeps(); + const { deps, anthropicVertexCtorMock, googleAuthClient } = createStreamDeps(); const streamFn = createAnthropicVertexStreamFnForModel( { baseUrl: "https://proxy.example.test/custom-root/v1" }, { GOOGLE_CLOUD_PROJECT_ID: "vertex-project" } as NodeJS.ProcessEnv, @@ -495,6 +577,7 @@ describe("createAnthropicVertexStreamFnForModel", () => { void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 64000 }), { messages: [] }, {}); expect(anthropicVertexCtorMock).toHaveBeenCalledWith({ + googleAuth: googleAuthClient, projectId: "vertex-project", region: "global", baseURL: "https://proxy.example.test/custom-root/v1", @@ -502,7 +585,7 @@ describe("createAnthropicVertexStreamFnForModel", () => { }); it("adds /v1 for path-prefixed custom provider base URLs", () => { - const { deps, anthropicVertexCtorMock } = createStreamDeps(); + const { deps, anthropicVertexCtorMock, googleAuthClient } = createStreamDeps(); const streamFn = createAnthropicVertexStreamFnForModel( { baseUrl: "https://proxy.example.test/custom-root" }, { GOOGLE_CLOUD_PROJECT_ID: "vertex-project" } as NodeJS.ProcessEnv, @@ -512,6 +595,7 @@ describe("createAnthropicVertexStreamFnForModel", () => { void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 64000 }), { messages: [] }, {}); expect(anthropicVertexCtorMock).toHaveBeenCalledWith({ + googleAuth: googleAuthClient, projectId: "vertex-project", region: "global", baseURL: "https://proxy.example.test/custom-root/v1", diff --git a/extensions/anthropic-vertex/stream-runtime.ts b/extensions/anthropic-vertex/stream-runtime.ts index a0abbfea8b79..b5d2f92c11a5 100644 --- a/extensions/anthropic-vertex/stream-runtime.ts +++ b/extensions/anthropic-vertex/stream-runtime.ts @@ -3,6 +3,7 @@ * OpenClaw stream options for the shared Anthropic Messages transport. */ import { AnthropicVertex as AnthropicVertexSdk } from "@anthropic-ai/vertex-sdk"; +import { GoogleAuth, type GoogleAuthOptions } from "google-auth-library"; import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; import { clampThinkingLevel, @@ -21,8 +22,26 @@ import { supportsClaudeNativeMaxEffort, supportsClaudeNativeXhighEffort, } from "openclaw/plugin-sdk/provider-model-shared"; +import { EnvHttpProxyAgent, fetch as undiciFetch } from "undici"; import { resolveAnthropicVertexClientRegion, resolveAnthropicVertexProjectId } from "./region.js"; +const GOOGLE_CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform"; + +// Proxy settings are process-stable. Reuse one dispatcher so auth requests do +// not leak sockets while avoiding gaxios's broken node-fetch dynamic import. +let googleAuthDispatcher: EnvHttpProxyAgent | undefined; + +const googleAuthFetch: typeof globalThis.fetch = (input, init) => { + googleAuthDispatcher ??= new EnvHttpProxyAgent(); + const fetchInit = { ...init } as Parameters[1] & { agent?: unknown }; + delete fetchInit.agent; + fetchInit.dispatcher = googleAuthDispatcher; + return undiciFetch( + input as Parameters[0], + fetchInit, + ) as unknown as ReturnType; +}; + type AnthropicVertexTransportOptions = ProviderStreamOptions & { client?: unknown; thinkingEnabled?: boolean; @@ -34,6 +53,7 @@ type AnthropicVertexEffort = NonNullable unknown; + GoogleAuth: new (options?: GoogleAuthOptions) => GoogleAuth; streamAnthropic: typeof streamDefault; }; const defaultAnthropicVertexStreamDeps: AnthropicVertexStreamDeps = { AnthropicVertex: AnthropicVertexSdk as AnthropicVertexStreamDeps["AnthropicVertex"], + GoogleAuth, streamAnthropic: streamDefault, }; @@ -135,7 +157,16 @@ export function createAnthropicVertexStreamFn( baseURL?: string, deps: AnthropicVertexStreamDeps = defaultAnthropicVertexStreamDeps, ): StreamFn { + // GoogleAuth carries clientOptions into file-backed ADC clients. Keep the + // proxy-aware transport provider-local; a window shim changes detection globally. + const googleAuth = new deps.GoogleAuth({ + scopes: [GOOGLE_CLOUD_PLATFORM_SCOPE], + clientOptions: { + transporterOptions: { fetchImplementation: googleAuthFetch }, + }, + }); const client = new deps.AnthropicVertex({ + googleAuth, region, ...(baseURL ? { baseURL } : {}), ...(projectId ? { projectId } : {}), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a3985e80929b..8b6363269a15 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -435,6 +435,12 @@ importers: '@anthropic-ai/vertex-sdk': specifier: 0.19.0 version: 0.19.0(zod@4.4.3) + google-auth-library: + specifier: 10.9.0 + version: 10.9.0 + undici: + specifier: 8.5.0 + version: 8.5.0 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:*