feat: show available models in model input

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-14 14:43:08 +02:00
1 parent bf083bcb26
commit 8718d82ab3
7 files changed
+178 -5

No files matched your search

+19 -1
View File
@@ -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">> = {},
+43
View File
@@ -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(() =>
+14
View File
@@ -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");
+3
View File
@@ -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",
+5
View File
@@ -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"