From bafaa516a912a51bca7972f09bd94f81067fbb4c Mon Sep 17 00:00:00 2001 From: Ted Slusser Date: Tue, 16 Jun 2026 18:24:23 -0700 Subject: [PATCH] fix(models): normalize bare-host ANTHROPIC_BASE_URL in getAnthropicProvider AI coding harnesses (Claude Code, Cursor) export ANTHROPIC_BASE_URL as a bare host (https://api.anthropic.com, no /v1) for their own runtime. @ai-sdk/anthropic prefers this env var over its https://api.anthropic.com/v1 default and uses it verbatim, so getAnthropicProvider()'s createAnthropic({ apiKey }) call (no baseURL) silently inherits the bare host and every Anthropic request 404s at .../messages. Other providers (e.g. Gemini) keep working, so consensus runs look half-broken with nothing pointing at the env. Normalize the env value (append /v1 when it lacks a version path) and pass it explicitly as baseURL. Unset env returns undefined so the provider keeps its own default. Scoped to the direct path; the gateway path already sets baseURL. Closes #57. See vercel/ai#15542 for the upstream SDK issue. Co-Authored-By: Claude Opus 4.8 --- src/__tests__/models.test.ts | 47 ++++++++++++++++++++++++++++++++++++ src/models.ts | 23 ++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 src/__tests__/models.test.ts diff --git a/src/__tests__/models.test.ts b/src/__tests__/models.test.ts new file mode 100644 index 0000000..27be216 --- /dev/null +++ b/src/__tests__/models.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { normalizedAnthropicBaseURL } from "../models"; + +describe("normalizedAnthropicBaseURL", () => { + const original = process.env.ANTHROPIC_BASE_URL; + + beforeEach(() => { + delete process.env.ANTHROPIC_BASE_URL; + }); + + afterEach(() => { + if (original === undefined) { + delete process.env.ANTHROPIC_BASE_URL; + } else { + process.env.ANTHROPIC_BASE_URL = original; + } + }); + + it("returns undefined when ANTHROPIC_BASE_URL is unset", () => { + expect(normalizedAnthropicBaseURL()).toBeUndefined(); + }); + + it("appends /v1 to a bare host (Claude Code / Cursor style)", () => { + process.env.ANTHROPIC_BASE_URL = "https://api.anthropic.com"; + expect(normalizedAnthropicBaseURL()).toBe("https://api.anthropic.com/v1"); + }); + + it("trims a trailing slash before appending /v1", () => { + process.env.ANTHROPIC_BASE_URL = "https://api.anthropic.com/"; + expect(normalizedAnthropicBaseURL()).toBe("https://api.anthropic.com/v1"); + }); + + it("leaves a value that already ends in /v1 unchanged", () => { + process.env.ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1"; + expect(normalizedAnthropicBaseURL()).toBe("https://api.anthropic.com/v1"); + }); + + it("leaves a value that ends in /v1/ unchanged", () => { + process.env.ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1/"; + expect(normalizedAnthropicBaseURL()).toBe("https://api.anthropic.com/v1/"); + }); + + it("normalizes a custom proxy host", () => { + process.env.ANTHROPIC_BASE_URL = "https://proxy.internal/anthropic"; + expect(normalizedAnthropicBaseURL()).toBe("https://proxy.internal/anthropic/v1"); + }); +}); diff --git a/src/models.ts b/src/models.ts index 47d6b88..27a48bb 100644 --- a/src/models.ts +++ b/src/models.ts @@ -34,6 +34,28 @@ function getGoogleProvider() { return _google; } +/** + * Normalize a bare-host `ANTHROPIC_BASE_URL`. + * + * AI coding harnesses (Claude Code, Cursor) export `ANTHROPIC_BASE_URL` without + * the `/v1` suffix for their own runtime. `@ai-sdk/anthropic` prefers this env + * var over its `https://api.anthropic.com/v1` default and uses it verbatim, so + * a bare host drops `/v1` and every request 404s at `.../messages` — while + * other providers (e.g. Gemini) keep working, making consensus runs look + * half-broken. Append `/v1` when the configured base lacks a version path. + * + * Returns `undefined` when the env var is unset so the provider falls back to + * its own default. Remove once the SDK normalizes upstream. + * + * @see https://github.com/bug0inc/passmark/issues/57 + * @see https://github.com/vercel/ai/issues/15542 + */ +export function normalizedAnthropicBaseURL(): string | undefined { + const base = process.env.ANTHROPIC_BASE_URL; + if (!base) return undefined; + return /\/v1\/?$/.test(base) ? base : `${base.replace(/\/$/, "")}/v1`; +} + function getAnthropicProvider() { if (!_anthropic) { if (!process.env.ANTHROPIC_API_KEY) { @@ -43,6 +65,7 @@ function getAnthropicProvider() { } _anthropic = createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY, + baseURL: normalizedAnthropicBaseURL(), }); } return _anthropic;