From 4c4857fdcb3506dc277f9df75d4df5879dca8d41 Mon Sep 17 00:00:00 2001 From: zerone0x Date: Mon, 23 Feb 2026 21:42:36 +0100 Subject: [PATCH] fix(providers): strip trailing /v1 from Anthropic baseUrl to prevent double-path The pi-ai Anthropic provider constructs the full API endpoint as `${baseUrl}/v1/messages`. If a user configures `models.providers.anthropic.baseUrl` with a trailing `/v1` (e.g. "https://api.anthropic.com/v1"), the resolved URL becomes "https://api.anthropic.com/v1/v1/messages" which the Anthropic API rejects with a 404 / connection failure. This regression appeared in v2026.2.22 when @mariozechner/pi-ai bumped from 0.54.0 to 0.54.1, which started appending the /v1 segment where the previous version did not. Fix: in normalizeModelCompat(), detect anthropic-messages models and strip a single trailing /v1 (with optional trailing slash) from the configured baseUrl before it is handed to pi-ai. Models with baseUrls that do not end in /v1 are unaffected. Non-anthropic-messages models are not touched. Adds 6 unit tests covering the normalisation scenarios. Fixes #24709 --- src/agents/model-compat.test.ts | 59 +++++++++++++++++++++++++++++++++ src/agents/model-compat.ts | 27 +++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/src/agents/model-compat.test.ts b/src/agents/model-compat.test.ts index 962724c665f..72a3fff9355 100644 --- a/src/agents/model-compat.test.ts +++ b/src/agents/model-compat.test.ts @@ -41,6 +41,65 @@ function createRegistry(models: Record>): ModelRegistry { } as ModelRegistry; } +describe("normalizeModelCompat — Anthropic baseUrl", () => { + const anthropicBase = (): Model => + ({ + id: "claude-opus-4-6", + name: "claude-opus-4-6", + api: "anthropic-messages", + provider: "anthropic", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 8_192, + }) as Model; + + it("strips /v1 suffix from anthropic-messages baseUrl", () => { + const model = { ...anthropicBase(), baseUrl: "https://api.anthropic.com/v1" }; + const normalized = normalizeModelCompat(model); + expect(normalized.baseUrl).toBe("https://api.anthropic.com"); + }); + + it("strips trailing /v1/ (with slash) from anthropic-messages baseUrl", () => { + const model = { ...anthropicBase(), baseUrl: "https://api.anthropic.com/v1/" }; + const normalized = normalizeModelCompat(model); + expect(normalized.baseUrl).toBe("https://api.anthropic.com"); + }); + + it("leaves anthropic-messages baseUrl without /v1 unchanged", () => { + const model = { ...anthropicBase(), baseUrl: "https://api.anthropic.com" }; + const normalized = normalizeModelCompat(model); + expect(normalized.baseUrl).toBe("https://api.anthropic.com"); + }); + + it("leaves baseUrl undefined unchanged for anthropic-messages", () => { + const model = anthropicBase(); + const normalized = normalizeModelCompat(model); + expect(normalized.baseUrl).toBeUndefined(); + }); + + it("does not strip /v1 from non-anthropic-messages models", () => { + const model = { + ...baseModel(), + provider: "openai", + api: "openai-responses" as Api, + baseUrl: "https://api.openai.com/v1", + }; + const normalized = normalizeModelCompat(model); + expect(normalized.baseUrl).toBe("https://api.openai.com/v1"); + }); + + it("strips /v1 from custom Anthropic proxy baseUrl", () => { + const model = { + ...anthropicBase(), + baseUrl: "https://my-proxy.example.com/anthropic/v1", + }; + const normalized = normalizeModelCompat(model); + expect(normalized.baseUrl).toBe("https://my-proxy.example.com/anthropic"); + }); +}); + describe("normalizeModelCompat", () => { it("forces supportsDeveloperRole off for z.ai models", () => { const model = baseModel(); diff --git a/src/agents/model-compat.ts b/src/agents/model-compat.ts index d97d3965103..4a9c4061205 100644 --- a/src/agents/model-compat.ts +++ b/src/agents/model-compat.ts @@ -4,8 +4,35 @@ function isOpenAiCompletionsModel(model: Model): model is Model<"openai-com return model.api === "openai-completions"; } +function isAnthropicMessagesModel(model: Model): model is Model<"anthropic-messages"> { + return model.api === "anthropic-messages"; +} + +/** + * pi-ai constructs the Anthropic API endpoint as `${baseUrl}/v1/messages`. + * If a user configures `baseUrl` with a trailing `/v1` (e.g. the previously + * recommended format "https://api.anthropic.com/v1"), the resulting URL + * becomes "…/v1/v1/messages" which the Anthropic API rejects with a 404. + * + * Strip a single trailing `/v1` (with optional trailing slash) from the + * baseUrl for anthropic-messages models so users with either format work. + */ +function normalizeAnthropicBaseUrl(baseUrl: string): string { + return baseUrl.replace(/\/v1\/?$/, ""); +} + export function normalizeModelCompat(model: Model): Model { const baseUrl = model.baseUrl ?? ""; + + // Normalise anthropic-messages baseUrl: strip trailing /v1 that users may + // have included in their config. pi-ai appends /v1/messages itself. + if (isAnthropicMessagesModel(model) && baseUrl) { + const normalised = normalizeAnthropicBaseUrl(baseUrl); + if (normalised !== baseUrl) { + return { ...model, baseUrl: normalised } as Model<"anthropic-messages">; + } + } + const isZai = model.provider === "zai" || baseUrl.includes("api.z.ai"); const isMoonshot = model.provider === "moonshot" ||