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
282 lines
8.2 KiB
TypeScript
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;
|