diff --git a/artifacts/api-server/src/lib/aiAnalysis.ts b/artifacts/api-server/src/lib/aiAnalysis.ts index a53c3eb..d8daa25 100644 --- a/artifacts/api-server/src/lib/aiAnalysis.ts +++ b/artifacts/api-server/src/lib/aiAnalysis.ts @@ -101,12 +101,25 @@ function extractJson(text: string): unknown { return JSON.parse(candidate.slice(start, end + 1)); } +const ENDPOINT_SUFFIXES = ["/chat/completions", "/completions", "/messages"]; + +export function normalizeBaseUrl(raw: string): string { + let url = raw.replace(/\/+$/, ""); + for (const suffix of ENDPOINT_SUFFIXES) { + if (url.endsWith(suffix)) { + url = url.slice(0, url.length - suffix.length).replace(/\/+$/, ""); + break; + } + } + return url; +} + async function callOpenAiCompatible( provider: AiProvider, system: string, user: string, ): Promise { - const base = provider.baseUrl.replace(/\/$/, ""); + const base = normalizeBaseUrl(provider.baseUrl); const url = `${base}/chat/completions`; const res = await fetchWithTimeout(url, { method: "POST", @@ -139,7 +152,7 @@ async function callAnthropic( system: string, user: string, ): Promise { - const base = provider.baseUrl.replace(/\/$/, ""); + const base = normalizeBaseUrl(provider.baseUrl); const url = `${base}/messages`; const res = await fetchWithTimeout(url, { method: "POST", @@ -179,7 +192,7 @@ export async function callProvider( export async function listProviderModels( provider: AiProvider, ): Promise { - const base = provider.baseUrl.replace(/\/$/, ""); + const base = normalizeBaseUrl(provider.baseUrl); const url = `${base}/models`; const headers: Record = provider.apiType === "anthropic" diff --git a/artifacts/api-server/src/routes/providers.listModels.test.ts b/artifacts/api-server/src/routes/providers.listModels.test.ts index 629fd4c..14d2aa4 100644 --- a/artifacts/api-server/src/routes/providers.listModels.test.ts +++ b/artifacts/api-server/src/routes/providers.listModels.test.ts @@ -7,6 +7,7 @@ import { afterEach, vi, } from "vitest"; +import { normalizeBaseUrl } from "../lib/aiAnalysis"; import type { AddressInfo } from "node:net"; import type { Server } from "node:http"; @@ -301,3 +302,47 @@ describe("POST /api/providers/list-models", () => { expect(json.message).not.toContain(token); }); }); + +describe("normalizeBaseUrl", () => { + it("strips /chat/completions suffix", () => { + expect(normalizeBaseUrl("https://api.openai.com/v1/chat/completions")).toBe( + "https://api.openai.com/v1", + ); + }); + + it("strips /completions suffix", () => { + expect(normalizeBaseUrl("https://api.example.com/v1/completions")).toBe( + "https://api.example.com/v1", + ); + }); + + it("strips /messages suffix", () => { + expect(normalizeBaseUrl("https://api.anthropic.com/v1/messages")).toBe( + "https://api.anthropic.com/v1", + ); + }); + + it("strips trailing slashes without a known suffix", () => { + expect(normalizeBaseUrl("https://api.example.com/v1/")).toBe( + "https://api.example.com/v1", + ); + }); + + it("leaves a clean base URL unchanged", () => { + expect(normalizeBaseUrl("https://api.example.com/v1")).toBe( + "https://api.example.com/v1", + ); + }); + + it("strips trailing slash after known suffix is removed", () => { + expect(normalizeBaseUrl("https://api.example.com/v1/completions/")).toBe( + "https://api.example.com/v1", + ); + }); + + it("does not strip partial suffix matches (e.g. /completions-extra)", () => { + expect( + normalizeBaseUrl("https://api.example.com/v1/completions-extra"), + ).toBe("https://api.example.com/v1/completions-extra"); + }); +}); diff --git a/artifacts/api-server/src/routes/providers.ts b/artifacts/api-server/src/routes/providers.ts index 6912f0e..f8d0012 100644 --- a/artifacts/api-server/src/routes/providers.ts +++ b/artifacts/api-server/src/routes/providers.ts @@ -15,7 +15,7 @@ import { ListProviderModelsBody, ListProviderModelsResponse, } from "@workspace/api-zod"; -import { callProvider, listProviderModels } from "../lib/aiAnalysis"; +import { callProvider, listProviderModels, normalizeBaseUrl } from "../lib/aiAnalysis"; import { t, reqLang } from "../lib/i18n"; const router: IRouter = Router(); @@ -57,7 +57,7 @@ router.post("/providers", async (req, res) => { .values({ name: d.name, apiType: d.apiType, - baseUrl: d.baseUrl, + baseUrl: normalizeBaseUrl(d.baseUrl), model: d.model, apiToken: d.apiToken ?? null, enabled: d.enabled ?? true, @@ -81,7 +81,7 @@ router.patch("/providers/:id", async (req, res) => { const update: Partial = {}; if (d.name !== undefined) update.name = d.name; if (d.apiType !== undefined) update.apiType = d.apiType; - if (d.baseUrl !== undefined) update.baseUrl = d.baseUrl; + if (d.baseUrl !== undefined) update.baseUrl = normalizeBaseUrl(d.baseUrl); if (d.model !== undefined) update.model = d.model; if (d.enabled !== undefined) update.enabled = d.enabled; if (d.apiToken !== undefined && d.apiToken !== "")