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:
parent
2695883f0d
commit
9648b8553c
3 changed files with 64 additions and 6 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 !== "")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue