Fix provider baseUrl stripping endpoint suffixes before /models

Task: Fix provider baseUrl stripping endpoint suffixes before /models

## Problem
When users entered a full endpoint URL as the provider base URL (e.g.
`https://api.openai.com/v1/chat/completions` instead of
`https://api.openai.com/v1`), the server would append `/models` to it,
producing the invalid path `/v1/chat/completions/models` — resulting in
HTTP 404 errors for model discovery, connection tests, and analysis.

## Changes

### `artifacts/api-server/src/lib/aiAnalysis.ts`
- Added exported `normalizeBaseUrl(raw: string): string` helper that:
  - Strips trailing slashes
  - Strips known endpoint suffixes: `/chat/completions`, `/completions`, `/messages`
  - Strips trailing slashes again after suffix removal
- Applied `normalizeBaseUrl` in `callOpenAiCompatible`, `callAnthropic`,
  and `listProviderModels` (replacing the previous bare `.replace(/\/$/, "")`)

### `artifacts/api-server/src/routes/providers.ts`
- Imported `normalizeBaseUrl` from aiAnalysis
- Applied normalization to `baseUrl` in the POST /providers (create) handler
- Applied normalization to `baseUrl` in the PATCH /providers/:id (update) handler
- This ensures the canonical normalized value is persisted from the start

### `artifacts/api-server/src/routes/providers.listModels.test.ts`
- Added import of `normalizeBaseUrl`
- Added a new `describe("normalizeBaseUrl")` block with 7 unit tests covering:
  all three suffix patterns, trailing slashes, clean URLs, combined suffix+slash,
  and non-matching partial suffixes

## Test results
All 13 tests pass (6 existing + 7 new normalization unit tests).

Replit-Task-Id: 9ab5c336-d54e-4bc3-8f01-0b7486365c4b
This commit is contained in:
amertensreplit 2026-06-16 17:53:07 +00:00
parent 2695883f0d
commit 9648b8553c
3 changed files with 64 additions and 6 deletions

View file

@ -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<string> {
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<string> {
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<string[]> {
const base = provider.baseUrl.replace(/\/$/, "");
const base = normalizeBaseUrl(provider.baseUrl);
const url = `${base}/models`;
const headers: Record<string, string> =
provider.apiType === "anthropic"

View file

@ -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");
});
});

View file

@ -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<typeof aiProvidersTable.$inferInsert> = {};
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 !== "")