mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-29 09:41:08 +00:00
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
This commit is contained in:
@@ -41,6 +41,65 @@ function createRegistry(models: Record<string, Model<Api>>): ModelRegistry {
|
|||||||
} as 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", () => {
|
describe("normalizeModelCompat", () => {
|
||||||
it("forces supportsDeveloperRole off for z.ai models", () => {
|
it("forces supportsDeveloperRole off for z.ai models", () => {
|
||||||
const model = baseModel();
|
const model = baseModel();
|
||||||
|
|||||||
@@ -4,8 +4,35 @@ function isOpenAiCompletionsModel(model: Model<Api>): model is Model<"openai-com
|
|||||||
return model.api === "openai-completions";
|
return model.api === "openai-completions";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAnthropicMessagesModel(model: Model<Api>): 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<Api>): Model<Api> {
|
export function normalizeModelCompat(model: Model<Api>): Model<Api> {
|
||||||
const baseUrl = model.baseUrl ?? "";
|
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 isZai = model.provider === "zai" || baseUrl.includes("api.z.ai");
|
||||||
const isMoonshot =
|
const isMoonshot =
|
||||||
model.provider === "moonshot" ||
|
model.provider === "moonshot" ||
|
||||||
|
|||||||
Reference in New Issue
Block a user