feat(ai): accept the provider base URL with or without /v1

A base URL with no path, like Ollama's http://host:11434, gets /v1 added.
URLs that already have a path are kept, so OpenRouter (/api/v1) and Gemini
(/v1beta/openai) keep working. Saved values are normalized when read.
This commit is contained in:
Tautvydas Derzinskas committed 2026-10-11 21:16:37 +02:00
1 parent 7a2e82ab6f
commit 29c54bac11
6 files changed
+21 -4

No files matched your search

+3 -2
View File
@@ -12,6 +12,7 @@ import {
getSimplifyPreviews,
getSmtpSettings,
getFlaresolverrUrl,
normalizeAiBaseUrl,
setFlaresolverrUrl,
getThingiverseAccessToken,
setAllowRegistrations,
@@ -608,7 +609,7 @@ router.patch(
asyncHandler(async (req, res) => {
const body = parseBody(aiCategorizationSettingsSchema, req.body);
const connection = {
...(body.base_url !== undefined ? { baseUrl: body.base_url?.replace(/\/+$/, "") || null } : {}),
...(body.base_url !== undefined ? { baseUrl: normalizeAiBaseUrl(body.base_url) } : {}),
...(body.api_key !== undefined ? { apiKey: body.api_key } : {}),
...(body.model !== undefined ? { model: body.model || null } : {}),
...(body.timeout_ms !== undefined ? { timeoutMs: body.timeout_ms } : {}),
@@ -649,7 +650,7 @@ router.post(
asyncHandler(async (req, res) => {
const body = parseBody(aiCategorizationTestSchema, req.body ?? {});
const result = await testAiCategorizationProvider({
...(body.base_url !== undefined ? { baseUrl: body.base_url.replace(/\/+$/, "") } : {}),
...(body.base_url !== undefined ? { baseUrl: normalizeAiBaseUrl(body.base_url) } : {}),
...(body.model !== undefined ? { model: body.model } : {}),
...(body.api_key !== undefined ? { apiKey: body.api_key || null } : {}),
...(body.timeout_ms !== undefined ? { timeoutMs: body.timeout_ms } : {}),
+13 -1
View File
@@ -222,12 +222,24 @@ export const DEFAULT_AI_CATEGORIZATION_SETTINGS: AiCategorizationSettings = {
timeoutMs: 60000,
};
/** Ollama, LM Studio, vLLM and OpenAI serve the API under /v1, so a bare origin gets it added. Any other path is
* kept, for providers like OpenRouter (/api/v1) or Gemini (/v1beta/openai). */
export function normalizeAiBaseUrl(value: string | null | undefined): string | null {
const trimmed = (value ?? "").trim().replace(/\/+$/, "");
if (!trimmed) return null;
try {
const url = new URL(trimmed);
if (url.pathname === "/" && !url.search && !url.hash) return `${trimmed}/v1`;
} catch {}
return trimmed;
}
function validAiSettings(value: unknown): Partial<AiCategorizationSettings> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const raw = value as Record<string, unknown>;
return {
...(raw.mode === "off" || raw.mode === "suggest" || raw.mode === "auto" ? { mode: raw.mode } : {}),
...(typeof raw.baseUrl === "string" || raw.baseUrl === null ? { baseUrl: raw.baseUrl?.trim() || null } : {}),
...(typeof raw.baseUrl === "string" || raw.baseUrl === null ? { baseUrl: normalizeAiBaseUrl(raw.baseUrl) } : {}),
...(typeof raw.apiKey === "string" || raw.apiKey === null ? { apiKey: raw.apiKey?.trim() || null } : {}),
...(typeof raw.model === "string" || raw.model === null ? { model: raw.model?.trim() || null } : {}),
...(typeof raw.threshold === "number" && raw.threshold >= 0 && raw.threshold <= 1
+2 -1
View File
@@ -272,10 +272,11 @@ describe("AI categorization", () => {
.set(auth())
.send({ base_url: liveUrl, model: "local-model" });
expect(draft.body).toMatchObject({ ok: true, model: "local-model" });
// A bare origin gets /v1 added, as Ollama and most local servers need.
const saved = await request(app)
.patch("/api/settings/ai-categorization")
.set(auth())
.send({ base_url: liveUrl, model: "local-model" });
.send({ base_url: liveUrl.replace(/\/v1$/, "/"), model: "local-model" });
expect(saved.status).toBe(200);
expect(saved.body.base_url).toBe(liveUrl);
+1
View File
@@ -592,6 +592,7 @@
"heading": "Connect AI",
"subtitle": "An OpenAI-compatible service that Thingport's AI features use.",
"baseUrl": "API base URL",
"baseUrlHint": "e.g. http://localhost:11434 for Ollama. /v1 is added when the URL has no path.",
"model": "Model name",
"apiKey": "API key",
"keySet": "A key is saved. Type a new one to replace it.",
+1
View File
@@ -594,6 +594,7 @@
"heading": "DI prijungimas",
"subtitle": "Su OpenAI suderinama paslauga, kurią naudoja Thingport DI funkcijos.",
"baseUrl": "API bazinis URL",
"baseUrlHint": "pvz., http://localhost:11434 Ollama atveju. Jei URL neturi kelio, pridedamas /v1.",
"model": "Modelio pavadinimas",
"apiKey": "API raktas",
"keySet": "Raktas išsaugotas. Įveskite naują, kad jį pakeistumėte.",
@@ -128,6 +128,7 @@ export default function AiConnectionSection({ onUnauthorized }: Props) {
label={t("adminSettings.ai.connection.baseUrl")}
value={draft.base_url}
onChange={(event) => update("base_url", event.target.value)}
helperText={t("adminSettings.ai.connection.baseUrlHint")}
disabled={busy}
fullWidth
/>