feat(pi-ai): support ANTHROPIC_BASE_URL env var for custom proxy endpoints (#4140)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
(cherry picked from commit e94cf668e2fdf28537aead642b4062cd3a22a8d3)
This commit is contained in:
Nils Reeh 2026-04-14 01:58:19 +02:00 committed by Mikael Hugo
parent f1d483c304
commit 1914f31342
2 changed files with 66 additions and 2 deletions

View file

@ -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<typeof resolveAnthropicBaseUrl>[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",
);
});

View file

@ -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<typeof Anthropic> {
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(
{