From 1914f31342132cef3e0bfd2eac071c2d2b2e07bd Mon Sep 17 00:00:00 2001 From: Nils Reeh Date: Tue, 14 Apr 2026 01:58:19 +0200 Subject: [PATCH] feat(pi-ai): support ANTHROPIC_BASE_URL env var for custom proxy endpoints (#4140) Co-Authored-By: Claude Sonnet 4.6 (cherry picked from commit e94cf668e2fdf28537aead642b4062cd3a22a8d3) --- .../src/providers/anthropic-auth.test.ts | 48 ++++++++++++++++++- packages/pi-ai/src/providers/anthropic.ts | 20 +++++++- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/packages/pi-ai/src/providers/anthropic-auth.test.ts b/packages/pi-ai/src/providers/anthropic-auth.test.ts index 19e201b0d..f95ebafab 100644 --- a/packages/pi-ai/src/providers/anthropic-auth.test.ts +++ b/packages/pi-ai/src/providers/anthropic-auth.test.ts @@ -4,7 +4,7 @@ import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { usesAnthropicBearerAuth } from "./anthropic.js"; +import { usesAnthropicBearerAuth, resolveAnthropicBaseUrl } from "./anthropic.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -31,3 +31,49 @@ test("createClient routes Bearer-auth providers through authToken (#3783)", () = "Bearer-auth providers should send authToken instead", ); }); + +// Minimal model stub — only the field resolveAnthropicBaseUrl cares about. +const stubModel = { baseUrl: "https://api.anthropic.com" } as Parameters[0]; + +test("resolveAnthropicBaseUrl returns model.baseUrl when ANTHROPIC_BASE_URL is unset (#4140)", (t) => { + const saved = process.env.ANTHROPIC_BASE_URL; + t.after(() => { + if (saved === undefined) delete process.env.ANTHROPIC_BASE_URL; + else process.env.ANTHROPIC_BASE_URL = saved; + }); + + delete process.env.ANTHROPIC_BASE_URL; + assert.equal(resolveAnthropicBaseUrl(stubModel), "https://api.anthropic.com"); +}); + +test("resolveAnthropicBaseUrl prefers ANTHROPIC_BASE_URL over model.baseUrl (#4140)", (t) => { + const saved = process.env.ANTHROPIC_BASE_URL; + t.after(() => { + if (saved === undefined) delete process.env.ANTHROPIC_BASE_URL; + else process.env.ANTHROPIC_BASE_URL = saved; + }); + + process.env.ANTHROPIC_BASE_URL = "https://proxy.example.com"; + assert.equal(resolveAnthropicBaseUrl(stubModel), "https://proxy.example.com"); +}); + +test("resolveAnthropicBaseUrl ignores whitespace-only ANTHROPIC_BASE_URL (#4140)", (t) => { + const saved = process.env.ANTHROPIC_BASE_URL; + t.after(() => { + if (saved === undefined) delete process.env.ANTHROPIC_BASE_URL; + else process.env.ANTHROPIC_BASE_URL = saved; + }); + + process.env.ANTHROPIC_BASE_URL = " "; + assert.equal(resolveAnthropicBaseUrl(stubModel), "https://api.anthropic.com"); +}); + +test("createClient uses resolveAnthropicBaseUrl for all auth paths (#4140)", () => { + const source = readFileSync(join(__dirname, "..", "..", "src", "providers", "anthropic.ts"), "utf-8"); + const directUsages = (source.match(/baseURL:\s*model\.baseUrl/g) ?? []).length; + assert.equal(directUsages, 0, "createClient must not use model.baseUrl directly — use resolveAnthropicBaseUrl(model)"); + assert.ok( + source.includes("baseURL: resolveAnthropicBaseUrl(model)"), + "all createClient branches should pass baseURL through resolveAnthropicBaseUrl", + ); +}); diff --git a/packages/pi-ai/src/providers/anthropic.ts b/packages/pi-ai/src/providers/anthropic.ts index 375008ab4..93fea4555 100644 --- a/packages/pi-ai/src/providers/anthropic.ts +++ b/packages/pi-ai/src/providers/anthropic.ts @@ -25,6 +25,24 @@ import { export type { AnthropicEffort, AnthropicOptions }; export { extractRetryAfterMs }; +/** + * Resolve the base URL for Anthropic API requests. + * + * Resolution order: + * 1. ANTHROPIC_BASE_URL environment variable (if set and non-empty after trim) + * 2. model.baseUrl (from the model definition) + * + * This allows routing traffic through custom proxy endpoints (e.g. OpusMax, + * local mirrors, corporate gateways) without modifying model definitions. + */ +export function resolveAnthropicBaseUrl(model: Model<"anthropic-messages">): string { + const envBaseUrl = process.env.ANTHROPIC_BASE_URL?.trim(); + if (envBaseUrl) { + return envBaseUrl; + } + return model.baseUrl; +} + let _AnthropicClass: typeof Anthropic | undefined; async function getAnthropicClass(): Promise { if (!_AnthropicClass) { @@ -75,7 +93,7 @@ async function createClient( const client = new AnthropicClass({ apiKey: null, authToken: apiKey, - baseURL: model.baseUrl, + baseURL: resolveAnthropicBaseUrl(model), dangerouslyAllowBrowser: true, defaultHeaders: mergeHeaders( {