skillguard/artifacts/api-server/src/routes/providers.ts
amertensreplit 9648b8553c 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
2026-06-16 17:53:07 +00:00

282 lines
8.2 KiB
TypeScript

import { Router, type IRouter, type Request } from "express";
import { db } from "@workspace/db";
import { aiProvidersTable, type AiProvider } from "@workspace/db";
import { eq } from "drizzle-orm";
import {
ListProvidersResponse,
CreateProviderBody,
UpdateProviderParams,
UpdateProviderBody,
UpdateProviderResponse,
DeleteProviderParams,
TestProviderParams,
TestProviderResponse,
TestProviderConnectionBody,
ListProviderModelsBody,
ListProviderModelsResponse,
} from "@workspace/api-zod";
import { callProvider, listProviderModels, normalizeBaseUrl } from "../lib/aiAnalysis";
import { t, reqLang } from "../lib/i18n";
const router: IRouter = Router();
function maskToken(token: string | null): string {
if (!token) return "";
if (token.length <= 8) return "••••";
return `${token.slice(0, 3)}${token.slice(-4)}`;
}
function serializeProvider(p: AiProvider) {
return {
id: p.id,
name: p.name,
apiType: p.apiType,
baseUrl: p.baseUrl,
model: p.model,
enabled: p.enabled,
hasToken: !!p.apiToken,
tokenPreview: maskToken(p.apiToken),
createdAt: p.createdAt.toISOString(),
};
}
router.get("/providers", async (_req, res) => {
const rows = await db.select().from(aiProvidersTable).orderBy(aiProvidersTable.id);
res.json(ListProvidersResponse.parse(rows.map(serializeProvider)));
});
router.post("/providers", async (req, res) => {
const parsed = CreateProviderBody.safeParse(req.body);
if (!parsed.success)
return res
.status(400)
.json({ message: t("invalidInput", reqLang(req)), details: parsed.error.issues });
const d = parsed.data;
const [created] = await db
.insert(aiProvidersTable)
.values({
name: d.name,
apiType: d.apiType,
baseUrl: normalizeBaseUrl(d.baseUrl),
model: d.model,
apiToken: d.apiToken ?? null,
enabled: d.enabled ?? true,
})
.returning();
return res
.status(201)
.json(UpdateProviderResponse.parse(serializeProvider(created)));
});
router.patch("/providers/:id", async (req, res) => {
const params = UpdateProviderParams.safeParse(req.params);
if (!params.success) return res.status(400).json({ message: t("invalidId", reqLang(req)) });
const parsed = UpdateProviderBody.safeParse(req.body);
if (!parsed.success)
return res
.status(400)
.json({ message: t("invalidInput", reqLang(req)), details: parsed.error.issues });
const d = parsed.data;
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 = 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 !== "")
update.apiToken = d.apiToken;
const [updated] = await db
.update(aiProvidersTable)
.set(update)
.where(eq(aiProvidersTable.id, params.data.id))
.returning();
if (!updated)
return res.status(404).json({ message: t("providerNotFound", reqLang(req)) });
return res.json(UpdateProviderResponse.parse(serializeProvider(updated)));
});
router.delete("/providers/:id", async (req, res) => {
const params = DeleteProviderParams.safeParse(req.params);
if (!params.success) return res.status(400).json({ message: t("invalidId", reqLang(req)) });
await db
.delete(aiProvidersTable)
.where(eq(aiProvidersTable.id, params.data.id));
return res.status(204).send();
});
router.post("/providers/:id/test", async (req, res) => {
const params = TestProviderParams.safeParse(req.params);
if (!params.success) return res.status(400).json({ message: t("invalidId", reqLang(req)) });
const [provider] = await db
.select()
.from(aiProvidersTable)
.where(eq(aiProvidersTable.id, params.data.id));
if (!provider)
return res.status(404).json({ message: t("providerNotFound", reqLang(req)) });
if (!provider.apiToken) {
return res.json(
TestProviderResponse.parse({
ok: false,
message: t("noApiTokenPlain", reqLang(req)),
}),
);
}
try {
const reply = await callProvider(
provider,
"Du bist ein Verbindungstest.",
'Antworte mit dem einzelnen Wort "OK".',
);
return res.json(
TestProviderResponse.parse({
ok: true,
message: t("connSuccessReply", reqLang(req), {
reply: reply.trim().slice(0, 80) || t("connReplyEmpty", reqLang(req)),
}),
}),
);
} catch (err) {
return res.json(
TestProviderResponse.parse({
ok: false,
message: err instanceof Error ? err.message : t("connFailed", reqLang(req)),
}),
);
}
});
router.post("/providers/test-connection", async (req, res) => {
const parsed = TestProviderConnectionBody.safeParse(req.body);
if (!parsed.success)
return res
.status(400)
.json({ message: t("invalidInput", reqLang(req)), details: parsed.error.issues });
const d = parsed.data;
let token: string | null =
d.apiToken && d.apiToken !== "" ? d.apiToken : null;
if (!token && d.providerId !== undefined && d.providerId !== null) {
const [existing] = await db
.select()
.from(aiProvidersTable)
.where(eq(aiProvidersTable.id, d.providerId));
if (existing?.apiToken) token = existing.apiToken;
}
if (!token) {
return res.json(
TestProviderResponse.parse({
ok: false,
message: t("noApiTokenProvided", reqLang(req)),
}),
);
}
const hasModel = typeof d.model === "string" && d.model.trim() !== "";
const provider: AiProvider = {
id: d.providerId ?? 0,
name: "",
apiType: d.apiType,
baseUrl: d.baseUrl,
model: hasModel ? (d.model as string) : "",
apiToken: token,
enabled: true,
createdAt: new Date(),
};
try {
if (hasModel) {
const reply = await callProvider(
provider,
"Du bist ein Verbindungstest.",
'Antworte mit dem einzelnen Wort "OK".',
);
return res.json(
TestProviderResponse.parse({
ok: true,
message: t("connSuccessReply", reqLang(req), {
reply: reply.trim().slice(0, 80) || t("connReplyEmpty", reqLang(req)),
}),
}),
);
}
const models = await listProviderModels(provider);
return res.json(
TestProviderResponse.parse({
ok: true,
message:
models.length > 0
? t("connSuccessModels", reqLang(req), { count: String(models.length) })
: t("connSuccessNoModels", reqLang(req)),
}),
);
} catch (err) {
return res.json(
TestProviderResponse.parse({
ok: false,
message: err instanceof Error ? err.message : t("connFailed", reqLang(req)),
}),
);
}
});
router.post("/providers/list-models", async (req, res) => {
const parsed = ListProviderModelsBody.safeParse(req.body);
if (!parsed.success)
return res
.status(400)
.json({ message: t("invalidInput", reqLang(req)), details: parsed.error.issues });
const d = parsed.data;
let token: string | null =
d.apiToken && d.apiToken !== "" ? d.apiToken : null;
if (!token && d.providerId !== undefined && d.providerId !== null) {
const [existing] = await db
.select()
.from(aiProvidersTable)
.where(eq(aiProvidersTable.id, d.providerId));
if (existing?.apiToken) token = existing.apiToken;
}
if (!token) {
return res.json(
ListProviderModelsResponse.parse({
ok: false,
models: [],
message: t("noApiTokenProvided", reqLang(req)),
}),
);
}
const provider: AiProvider = {
id: d.providerId ?? 0,
name: "",
apiType: d.apiType,
baseUrl: d.baseUrl,
model: "",
apiToken: token,
enabled: true,
createdAt: new Date(),
};
try {
const models = await listProviderModels(provider);
return res.json(
ListProviderModelsResponse.parse({ ok: true, models }),
);
} catch (err) {
return res.json(
ListProviderModelsResponse.parse({
ok: false,
models: [],
message:
err instanceof Error
? err.message
: t("modelsLoadFailed", reqLang(req)),
}),
);
}
});
export default router;