mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-18 20:59:55 -05:00
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:
1 parent
7a2e82ab6f
commit
29c54bac11
6 files changed
+21
-4
No files matched your search
@@ -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 } : {}),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user