mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 22:22:24 -05:00
feat: show available models in model input
This commit is contained in:
1 parent
bf083bcb26
commit
8718d82ab3
7 files changed
+178
-5
No files matched your search
@@ -65,7 +65,7 @@ import { getUserAuthorPreviewEnabled, setUserAuthorPreviewEnabled } from "../ser
|
||||
import { checkForUpdates } from "../services/versionService";
|
||||
import { getWeatherLocation, setWeatherLocation, weatherLocationSchema } from "../services/weatherLocationService";
|
||||
import { dropPreviewsAffectedBySimplification } from "../services/modelPreviewCache";
|
||||
import { testAiCategorizationProvider } from "../services/aiCategorizationService";
|
||||
import { listAiProviderModels, testAiCategorizationProvider } from "../services/aiCategorizationService";
|
||||
import { testFlaresolverr } from "../services/flaresolverr";
|
||||
|
||||
const router = Router();
|
||||
@@ -642,6 +642,24 @@ router.patch(
|
||||
}),
|
||||
);
|
||||
|
||||
// Omitted fields use the saved value; api_key null lists without a key.
|
||||
const aiModelListSchema = z
|
||||
.object({ base_url: z.string().trim().min(1).optional(), api_key: z.string().nullable().optional() })
|
||||
.strict();
|
||||
router.post(
|
||||
"/settings/ai-categorization/models",
|
||||
requireAdmin,
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(aiModelListSchema, req.body ?? {});
|
||||
res.json(
|
||||
await listAiProviderModels({
|
||||
...(body.base_url !== undefined ? { baseUrl: normalizeAiBaseUrl(body.base_url) } : {}),
|
||||
...(body.api_key !== undefined ? { apiKey: body.api_key || null } : {}),
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
// Omitted fields use the saved value; api_key null tests without a key.
|
||||
const aiCategorizationTestSchema = z
|
||||
.object({
|
||||
|
||||
@@ -775,6 +775,42 @@ export function cancelAiCategorizationRun(userId: string): AiCategorizationRun |
|
||||
return run;
|
||||
}
|
||||
|
||||
// A model list comes back quickly or not at all; this only fills the settings form's suggestions.
|
||||
const MODEL_LIST_TIMEOUT_MS = 10_000;
|
||||
|
||||
/**
|
||||
* The models the provider serves, from the OpenAI-style `GET /models` that Ollama, LM Studio, vLLM, OpenAI, OpenRouter
|
||||
* and Gemini all answer, so the settings form can suggest them. Given values fall back to the saved ones, like a test.
|
||||
*/
|
||||
export async function listAiProviderModels(
|
||||
overrides: Partial<Pick<AiCategorizationSettings, "baseUrl" | "apiKey">> = {},
|
||||
): Promise<{ ok: true; models: string[] } | { ok: false; error: string }> {
|
||||
const settings = { ...(await getAiCategorizationSettings()), ...overrides };
|
||||
if (!settings.baseUrl) return { ok: false, error: "Set the API base URL first" };
|
||||
try {
|
||||
const response = await fetch(`${settings.baseUrl.replace(/\/+$/, "")}/models`, {
|
||||
headers: settings.apiKey ? { authorization: `Bearer ${settings.apiKey}` } : {},
|
||||
signal: AbortSignal.timeout(MODEL_LIST_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) return { ok: false, error: `AI provider returned HTTP ${response.status}` };
|
||||
const body = (await response.json()) as { data?: unknown };
|
||||
const ids = Array.isArray(body.data)
|
||||
? body.data.flatMap((item) =>
|
||||
item && typeof item === "object" && typeof (item as { id?: unknown }).id === "string"
|
||||
? [(item as { id: string }).id]
|
||||
: [],
|
||||
)
|
||||
: [];
|
||||
return { ok: true, models: [...new Set(ids)].toSorted((a, b) => a.localeCompare(b)) };
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error && error.name === "TimeoutError"
|
||||
? `The AI provider did not answer within ${MODEL_LIST_TIMEOUT_MS / 1000} s`
|
||||
: noSecretError(error).message;
|
||||
return { ok: false, error: settings.apiKey ? message.replaceAll(settings.apiKey, "[redacted]") : message };
|
||||
}
|
||||
}
|
||||
|
||||
/** Tests the given connection values, falling back to the saved ones, so a connection can be checked before saving. */
|
||||
export async function testAiCategorizationProvider(
|
||||
overrides: Partial<Pick<AiCategorizationSettings, "baseUrl" | "model" | "apiKey" | "timeoutMs">> = {},
|
||||
|
||||
@@ -302,6 +302,49 @@ describe("AI categorization", () => {
|
||||
expect(cleared.body).toMatchObject({ base_url: null, model: null });
|
||||
});
|
||||
|
||||
it("lists the provider's models for the settings form, with the saved key when none is typed", async () => {
|
||||
const seen: { path: string; authorization?: string }[] = [];
|
||||
let status = 200;
|
||||
const server = http.createServer((req, res) => {
|
||||
seen.push({ path: req.url ?? "", authorization: req.headers.authorization });
|
||||
res.writeHead(status, { "content-type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({ object: "list", data: [{ id: "qwen3:8b" }, { id: "gemma3:4b" }, { id: "qwen3:8b" }, 7] }),
|
||||
);
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Server did not bind a TCP port");
|
||||
const baseUrl = `http://127.0.0.1:${address.port}/v1`;
|
||||
try {
|
||||
await setAiCategorizationSettings({ baseUrl, apiKey: "sk-saved" });
|
||||
const listed = await request(app).post("/api/settings/ai-categorization/models").set(auth()).send({});
|
||||
// Sorted, without duplicates or entries that aren't models.
|
||||
expect(listed.body).toEqual({ ok: true, models: ["gemma3:4b", "qwen3:8b"] });
|
||||
expect(seen[0]).toEqual({ path: "/v1/models", authorization: "Bearer sk-saved" });
|
||||
|
||||
// Typed values win, so the list follows the form before it's saved.
|
||||
await request(app)
|
||||
.post("/api/settings/ai-categorization/models")
|
||||
.set(auth())
|
||||
.send({ base_url: baseUrl, api_key: null });
|
||||
expect(seen[1].authorization).toBeUndefined();
|
||||
|
||||
status = 401;
|
||||
const refused = await request(app).post("/api/settings/ai-categorization/models").set(auth()).send({});
|
||||
expect(refused.body).toEqual({ ok: false, error: "AI provider returned HTTP 401" });
|
||||
|
||||
const member = await newUser();
|
||||
const forbidden = await request(app)
|
||||
.post("/api/settings/ai-categorization/models")
|
||||
.set(auth(member.token))
|
||||
.send({});
|
||||
expect(forbidden.status).toBe(403);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it("waits before retrying 429 responses both without and with Retry-After", async () => {
|
||||
const target = await category("Retry target");
|
||||
const withoutHeader = await startProvider(() =>
|
||||
|
||||
@@ -151,6 +151,20 @@ export const settingsApi = {
|
||||
return res.json();
|
||||
},
|
||||
|
||||
/** The models the provider serves, for the model field's suggestions; omitted values use the saved ones. */
|
||||
listAiModels: async (
|
||||
payload: Pick<AiCategorizationSettingsInput, "base_url" | "api_key"> = {},
|
||||
): Promise<{ ok: true; models: string[] } | { ok: false; error: string }> => {
|
||||
const res = await fetch(`${apiBase()}/settings/ai-categorization/models`, {
|
||||
method: "POST",
|
||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.status === 401) throw new UnauthorizedError();
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to list AI models"));
|
||||
return res.json();
|
||||
},
|
||||
|
||||
getVersionCheck: async (): Promise<VersionCheck> => {
|
||||
const res = await fetch(`${apiBase()}/settings/version-check`, { headers: authHeaders() });
|
||||
assertOk(res, "Failed to check for updates");
|
||||
|
||||
@@ -615,6 +615,9 @@
|
||||
"baseUrl": "API base URL",
|
||||
"baseUrlHint": "e.g. http://localhost:11434 for Ollama. /v1 is added when the URL has no path.",
|
||||
"model": "Model name",
|
||||
"modelsAvailable_one": "{{count}} model available from this provider. You can also type any name.",
|
||||
"modelsAvailable_other": "{{count}} models available from this provider. You can also type any name.",
|
||||
"modelsUnavailable": "Couldn't list this provider's models ({{error}}). Type the model's name.",
|
||||
"apiKey": "API key",
|
||||
"keySet": "A key is saved. Type a new one to replace it.",
|
||||
"clearKey": "Clear API key",
|
||||
|
||||
@@ -617,6 +617,11 @@
|
||||
"baseUrl": "API bazinis URL",
|
||||
"baseUrlHint": "pvz., http://localhost:11434 Ollama atveju. Jei URL neturi kelio, pridedamas /v1.",
|
||||
"model": "Modelio pavadinimas",
|
||||
"modelsAvailable_one": "Šis tiekėjas siūlo {{count}} modelį. Taip pat galite įrašyti bet kurį pavadinimą.",
|
||||
"modelsAvailable_few": "Šis tiekėjas siūlo {{count}} modelius. Taip pat galite įrašyti bet kurį pavadinimą.",
|
||||
"modelsAvailable_many": "Šis tiekėjas siūlo {{count}} modelio. Taip pat galite įrašyti bet kurį pavadinimą.",
|
||||
"modelsAvailable_other": "Šis tiekėjas siūlo {{count}} modelių. Taip pat galite įrašyti bet kurį pavadinimą.",
|
||||
"modelsUnavailable": "Nepavyko gauti šio tiekėjo modelių sąrašo ({{error}}). Įrašykite modelio pavadinimą.",
|
||||
"apiKey": "API raktas",
|
||||
"keySet": "Raktas išsaugotas. Įveskite naują, kad jį pakeistumėte.",
|
||||
"clearKey": "Pašalinti API raktą",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Alert from "@mui/material/Alert";
|
||||
import Autocomplete from "@mui/material/Autocomplete";
|
||||
import Button from "@mui/material/Button";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import InputAdornment from "@mui/material/InputAdornment";
|
||||
@@ -38,6 +39,9 @@ export default function AiConnectionSection({ onUnauthorized }: Props) {
|
||||
const [clearKey, setClearKey] = useState(false);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const [modelsError, setModelsError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
@@ -61,6 +65,41 @@ export default function AiConnectionSection({ onUnauthorized }: Props) {
|
||||
};
|
||||
}, [onUnauthorized, t]);
|
||||
|
||||
// The provider's own list, refetched a moment after the URL or key stops changing; the field still takes any name.
|
||||
useEffect(() => {
|
||||
const baseUrl = draft.base_url.trim();
|
||||
if (loading || !baseUrl) {
|
||||
setModels([]);
|
||||
setModelsError(null);
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
const timer = window.setTimeout(() => {
|
||||
setModelsLoading(true);
|
||||
void settingsApi
|
||||
.listAiModels({
|
||||
base_url: baseUrl,
|
||||
...(clearKey ? { api_key: null } : draft.api_key ? { api_key: draft.api_key } : {}),
|
||||
})
|
||||
.then((result) => {
|
||||
if (!active) return;
|
||||
setModels(result.ok ? result.models : []);
|
||||
setModelsError(result.ok ? null : result.error);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
else if (active) setModelsError(err instanceof Error ? err.message : String(err));
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setModelsLoading(false);
|
||||
});
|
||||
}, 600);
|
||||
return () => {
|
||||
active = false;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [loading, draft.base_url, draft.api_key, clearKey, onUnauthorized]);
|
||||
|
||||
const update = <K extends keyof Draft>(key: K, value: Draft[K]) =>
|
||||
setDraft((current) => ({ ...current, [key]: value }));
|
||||
|
||||
@@ -132,12 +171,27 @@ export default function AiConnectionSection({ onUnauthorized }: Props) {
|
||||
disabled={busy}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label={t("adminSettings.ai.connection.model")}
|
||||
value={draft.model}
|
||||
onChange={(event) => update("model", event.target.value)}
|
||||
<Autocomplete
|
||||
freeSolo
|
||||
options={models}
|
||||
inputValue={draft.model}
|
||||
onInputChange={(_event, value) => update("model", value)}
|
||||
loading={modelsLoading}
|
||||
disabled={busy}
|
||||
fullWidth
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={t("adminSettings.ai.connection.model")}
|
||||
helperText={
|
||||
modelsError
|
||||
? t("adminSettings.ai.connection.modelsUnavailable", { error: modelsError })
|
||||
: models.length
|
||||
? t("adminSettings.ai.connection.modelsAvailable", { count: models.length })
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<TextField
|
||||
type="password"
|
||||
|
||||
Reference in new issue
Block a user