feat(agent): refresh assistant model catalog

This commit is contained in:
ginnoir
2026-07-08 19:37:17 -05:00
parent 4dc04b21d6
commit b279f16ba0
6 changed files with 111 additions and 41 deletions
+17 -8
View File
@@ -3,15 +3,22 @@ import { resolveApiAuth } from "@/lib/api-auth";
import { getAssistantPreferences } from "@/lib/assistant-preference"; import { getAssistantPreferences } from "@/lib/assistant-preference";
import { listLlmModels, resolveAssistantModel } from "@/lib/llm/models"; import { listLlmModels, resolveAssistantModel } from "@/lib/llm/models";
export const dynamic = "force-dynamic";
function noStore(response: Response): Response {
response.headers.set("Cache-Control", "no-store");
return response;
}
export async function GET(request: Request) { export async function GET(request: Request) {
const auth = await resolveApiAuth(request); const auth = await resolveApiAuth(request);
if (!auth?.userId) { if (!auth?.userId) {
return apiError("Unauthorized", 401); return noStore(apiError("Unauthorized", 401));
} }
const assistant = await getAssistantPreferences(auth.userId); const assistant = await getAssistantPreferences(auth.userId);
if (!assistant.enabled) { if (!assistant.enabled) {
return apiError("Assistant not enabled", 403); return noStore(apiError("Assistant not enabled", 403));
} }
const modelList = await listLlmModels(); const modelList = await listLlmModels();
@@ -22,10 +29,12 @@ export async function GET(request: Request) {
models: modelList.models, models: modelList.models,
}); });
return apiJson({ return noStore(
models: modelList.models, apiJson({
selectedModel: resolved.model, models: modelList.models,
fallbackModel: modelList.fallbackModel, selectedModel: resolved.model,
degraded: modelList.degraded, fallbackModel: modelList.fallbackModel,
}); degraded: modelList.degraded,
}),
);
} }
+6 -1
View File
@@ -65,7 +65,12 @@ export async function listLlmModels(options?: {
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`; if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
const response = await fetchImpl(`${config.baseUrl.replace(/\/$/, "")}/models`, { const modelsUrl = new URL(`${config.baseUrl.replace(/\/$/, "")}/models`);
if (fallbackModel === "uncensored") {
modelsUrl.searchParams.set("type", "uncensored");
}
const response = await fetchImpl(modelsUrl, {
method: "GET", method: "GET",
headers, headers,
}); });
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useRef, useState, useTransition } from "react"; import { useCallback, useEffect, useRef, useState, useTransition } from "react";
import { ChevronDown, ImagePlus, Loader2, Mic, Send, Square } from "lucide-react"; import { ChevronDown, ImagePlus, Loader2, Mic, RefreshCw, Send, Square } from "lucide-react";
import { setAssistantModel } from "@/app/settings/assistant-actions"; import { setAssistantModel } from "@/app/settings/assistant-actions";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -87,34 +87,44 @@ export function AssistantPanel({ configured, userId, assistantName, assistantMod
}; };
}, []); }, []);
useEffect(() => { const loadModels = useCallback(async (options?: { refresh?: boolean; signal?: AbortSignal }) => {
let cancelled = false; if (options?.signal?.aborted) return;
async function loadModels() { setModelsLoading(true);
setModelsLoading(true); try {
try { const response = await fetch(`/api/agent/models${options?.refresh ? "?refresh=1" : ""}`, {
const response = await fetch("/api/agent/models"); cache: "no-store",
if (!response.ok) throw new Error("Model discovery unavailable"); signal: options?.signal,
const payload = (await response.json()) as ModelsResponse; });
if (cancelled) return; if (!response.ok) throw new Error("Model discovery unavailable");
setModelOptions(payload.models); const payload = (await response.json()) as ModelsResponse;
setSelectedModel(payload.selectedModel); if (options?.signal?.aborted) return;
setFallbackModel(payload.fallbackModel); setModelOptions(payload.models);
setModelsDegraded(payload.degraded); setSelectedModel(payload.selectedModel);
} catch { setFallbackModel(payload.fallbackModel);
if (cancelled) return; setModelsDegraded(payload.degraded);
setModelsDegraded(true); setError(null);
} finally { } catch (err) {
if (!cancelled) setModelsLoading(false); if (err instanceof Error && err.name === "AbortError") return;
} setModelsDegraded(true);
setError("Model discovery unavailable");
} finally {
if (!options?.signal?.aborted) setModelsLoading(false);
} }
void loadModels();
return () => {
cancelled = true;
};
}, []); }, []);
useEffect(() => {
const controller = new AbortController();
queueMicrotask(() => {
void loadModels({ signal: controller.signal });
});
return () => {
controller.abort();
};
}, [loadModels]);
function scrollToBottom() { function scrollToBottom() {
requestAnimationFrame(() => { requestAnimationFrame(() => {
const node = listRef.current; const node = listRef.current;
@@ -271,6 +281,20 @@ export function AssistantPanel({ configured, userId, assistantName, assistantMod
/> />
</div> </div>
) : null} ) : null}
<Button
type="button"
size="sm"
variant="outline"
aria-label="Refresh assistant models"
disabled={modelsLoading || savingModel || isPending}
onClick={() => void loadModels({ refresh: true })}
>
{modelsLoading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RefreshCw className="size-4" />
)}
</Button>
{messages.length > 0 ? ( {messages.length > 0 ? (
<button <button
type="button" type="button"
+3
View File
@@ -28,6 +28,9 @@ test("assistant chat smoke after opt-in", async ({ page }) => {
await expect(page.getByRole("dialog", { name: "Assistant" })).toBeVisible(); await expect(page.getByRole("dialog", { name: "Assistant" })).toBeVisible();
const modelSelector = page.getByRole("combobox", { name: "Assistant model" }); const modelSelector = page.getByRole("combobox", { name: "Assistant model" });
await expect(modelSelector).toBeVisible(); await expect(modelSelector).toBeVisible();
const refreshModels = page.getByRole("button", { name: "Refresh assistant models" });
await expect(refreshModels).toBeVisible();
await refreshModels.click();
await expect.poll(() => modelSelector.evaluate((node) => node.tagName)).toBe("SELECT"); await expect.poll(() => modelSelector.evaluate((node) => node.tagName)).toBe("SELECT");
await expect await expect
.poll(() => modelSelector.evaluate((node) => node.getBoundingClientRect().height)) .poll(() => modelSelector.evaluate((node) => node.getBoundingClientRect().height))
+17
View File
@@ -0,0 +1,17 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
describe("GET /api/agent/models", () => {
it("is dynamic and returns no-store responses", async () => {
process.env.DATABASE_URL ??= "postgres://famapp:famapp@localhost:5432/famapp";
const { GET, dynamic } = await import("../../src/app/api/agent/models/route");
assert.equal(dynamic, "force-dynamic");
const response = await GET(new Request("http://localhost/api/agent/models?refresh=1"));
assert.equal(response.status, 401);
assert.equal(response.headers.get("Cache-Control"), "no-store");
});
});
+18 -6
View File
@@ -88,16 +88,28 @@ describe("listLlmModels", () => {
assert.equal(result.degraded, true); assert.equal(result.degraded, true);
}); });
it("uses an unadvertised fallback alias instead of unrelated provider models", async () => { it("fetches the typed uncensored catalog when uncensored is the fallback route", async () => {
const requests: Request[] = [];
const result = await listLlmModels({ const result = await listLlmModels({
config: { ...openAiConfig, model: "uncensored" }, config: { ...openAiConfig, model: "uncensored" },
fetchImpl: async () => fetchImpl: async (input, init) => {
Response.json({ requests.push(new Request(input, init));
data: [{ id: "auto" }, { id: "qwen3:8b" }, { id: "qwen3-coder:30b" }], return Response.json({
}), data: [
{ id: "uncensored" },
{ id: "gemma4-uncensored:26b" },
{ id: "dolphin-mistral:latest" },
],
});
},
}); });
assert.deepEqual(result.models, [{ id: "uncensored", label: "uncensored" }]); assert.equal(requests[0]?.url, "https://llm.example.test/v1/models?type=uncensored");
assert.deepEqual(result.models, [
{ id: "dolphin-mistral:latest", label: "dolphin-mistral:latest" },
{ id: "gemma4-uncensored:26b", label: "gemma4-uncensored:26b" },
{ id: "uncensored", label: "uncensored" },
]);
assert.equal(result.fallbackModel, "uncensored"); assert.equal(result.fallbackModel, "uncensored");
assert.equal(result.degraded, false); assert.equal(result.degraded, false);
}); });