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

(cherry picked from commit 4c4857fdcb)
This commit is contained in:
zerone0x
2026-02-23 21:42:36 +01:00
committed by Peter Steinberger
parent 01c1f68ab3
commit ac6cec7677
2 changed files with 85 additions and 0 deletions

View File

@@ -41,6 +41,65 @@ function createRegistry(models: Record<string, Model<Api>>): ModelRegistry {
} as ModelRegistry;
}
describe("normalizeModelCompat — Anthropic baseUrl", () => {
const anthropicBase = (): Model<Api> =>
({
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<Api>;
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();