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 <ian@allowayllc.com>

* test(anthropic-vertex): inject auth in API fixtures

Co-authored-by: Ian Alloway <ian@allowayllc.com>

* refactor(anthropic-vertex): clarify auth transport contract

Co-authored-by: Ian Alloway <ian@allowayllc.com>

* fix(anthropic-vertex): preserve ADC proxy routing

* chore: leave changelog to release workflow

---------

Co-authored-by: Ian Alloway <ian@allowayllc.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Ian Alloway
2026-07-16 06:47:40 -04:00
committed by GitHub
parent b7e099d36e
commit dbcf7abda1
6 changed files with 144 additions and 7 deletions
+3
View File
@@ -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,
+12 -1
View File
@@ -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",
+3 -1
View File
@@ -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:*"
@@ -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<typeof vi.fn>;
anthropicVertexCtorMock: ReturnType<typeof vi.fn>;
googleAuthCtorMock: ReturnType<typeof vi.fn>;
googleAuthClient: InstanceType<AnthropicVertexStreamDeps["GoogleAuth"]>;
} {
const streamAnthropicMock = vi.fn(
(..._args: Parameters<AnthropicVertexStreamDeps["streamAnthropic"]>) =>
@@ -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<AnthropicVertexStreamDeps["GoogleAuth"]>;
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",
@@ -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<typeof undiciFetch>[1] & { agent?: unknown };
delete fetchInit.agent;
fetchInit.dispatcher = googleAuthDispatcher;
return undiciFetch(
input as Parameters<typeof undiciFetch>[0],
fetchInit,
) as unknown as ReturnType<typeof globalThis.fetch>;
};
type AnthropicVertexTransportOptions = ProviderStreamOptions & {
client?: unknown;
thinkingEnabled?: boolean;
@@ -34,6 +53,7 @@ type AnthropicVertexEffort = NonNullable<AnthropicVertexTransportOptions["effort
type AnthropicVertexAdaptiveEffort = AnthropicVertexEffort | "xhigh";
type AnthropicVertexClientOptions = {
baseURL?: string;
googleAuth: GoogleAuth;
projectId?: string;
region: string;
};
@@ -41,11 +61,13 @@ type AnthropicVertexClientOptions = {
/** Injectable dependencies for Anthropic Vertex stream tests. */
export type AnthropicVertexStreamDeps = {
AnthropicVertex: new (options: AnthropicVertexClientOptions) => 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 } : {}),
+6
View File
@@ -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:*