mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
Generated
+6
@@ -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:*
|
||||
|
||||
Reference in New Issue
Block a user