feat(agent): let users choose model route

This commit is contained in:
ginnoir
2026-07-08 19:50:46 -05:00
parent b279f16ba0
commit ec3f96dab1
13 changed files with 235 additions and 45 deletions
+5
View File
@@ -0,0 +1,5 @@
ALTER TABLE "users" ADD COLUMN "assistant_model_route" text;--> statement-breakpoint
UPDATE "users"
SET "assistant_model_route" = "assistant_model",
"assistant_model" = NULL
WHERE "assistant_model" IN ('auto', 'uncensored');
+8 -1
View File
@@ -183,6 +183,13 @@
"when": 1783560000000, "when": 1783560000000,
"tag": "0025_assistant_model", "tag": "0025_assistant_model",
"breakpoints": true "breakpoints": true
},
{
"idx": 26,
"version": "7",
"when": 1783561000000,
"tag": "0026_assistant_model_route",
"breakpoints": true
} }
] ]
} }
+1 -1
View File
@@ -32,7 +32,7 @@ export async function POST(request: Request) {
return apiError(parsed.error.issues[0]?.message ?? "Validation error", 400); return apiError(parsed.error.issues[0]?.message ?? "Validation error", 400);
} }
const modelList = await listLlmModels(); const modelList = await listLlmModels({ route: assistant.modelRoute });
const modelResolution = resolveAssistantModel({ const modelResolution = resolveAssistantModel({
requestedModel: parsed.data.model, requestedModel: parsed.data.model,
savedModel: assistant.model, savedModel: assistant.model,
+10 -3
View File
@@ -1,7 +1,7 @@
import { apiError, apiJson } from "@/lib/api-handler"; import { apiError, apiJson } from "@/lib/api-handler";
import { resolveApiAuth } from "@/lib/api-auth"; 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 { isValidAssistantModelRoute, listLlmModels, resolveAssistantModel } from "@/lib/llm/models";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -21,10 +21,16 @@ export async function GET(request: Request) {
return noStore(apiError("Assistant not enabled", 403)); return noStore(apiError("Assistant not enabled", 403));
} }
const modelList = await listLlmModels(); const requestedRoute = new URL(request.url).searchParams.get("route");
if (requestedRoute !== null && !isValidAssistantModelRoute(requestedRoute)) {
return noStore(apiError("Invalid assistant model route", 400));
}
const modelRoute = requestedRoute ?? assistant.modelRoute;
const modelList = await listLlmModels({ route: modelRoute });
const resolved = resolveAssistantModel({ const resolved = resolveAssistantModel({
requestedModel: null, requestedModel: null,
savedModel: assistant.model, savedModel: requestedRoute === null ? assistant.model : null,
fallbackModel: modelList.fallbackModel, fallbackModel: modelList.fallbackModel,
models: modelList.models, models: modelList.models,
}); });
@@ -34,6 +40,7 @@ export async function GET(request: Request) {
models: modelList.models, models: modelList.models,
selectedModel: resolved.model, selectedModel: resolved.model,
fallbackModel: modelList.fallbackModel, fallbackModel: modelList.fallbackModel,
route: modelList.route,
degraded: modelList.degraded, degraded: modelList.degraded,
}), }),
); );
+4
View File
@@ -104,6 +104,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
let signedIn = false; let signedIn = false;
let assistantEnabled = false; let assistantEnabled = false;
let assistantName = DEFAULT_ASSISTANT_NAME; let assistantName = DEFAULT_ASSISTANT_NAME;
let assistantModelRoute: string | null = null;
let assistantModel: string | null = null; let assistantModel: string | null = null;
const session = await auth(); const session = await auth();
@@ -118,6 +119,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
themeNavStyle: users.themeNavStyle, themeNavStyle: users.themeNavStyle,
assistantEnabled: users.assistantEnabled, assistantEnabled: users.assistantEnabled,
assistantName: users.assistantName, assistantName: users.assistantName,
assistantModelRoute: users.assistantModelRoute,
assistantModel: users.assistantModel, assistantModel: users.assistantModel,
}) })
.from(users) .from(users)
@@ -131,6 +133,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
navStyle = row.themeNavStyle as NavStyle; navStyle = row.themeNavStyle as NavStyle;
assistantEnabled = row.assistantEnabled; assistantEnabled = row.assistantEnabled;
assistantName = row.assistantName?.trim() || DEFAULT_ASSISTANT_NAME; assistantName = row.assistantName?.trim() || DEFAULT_ASSISTANT_NAME;
assistantModelRoute = row.assistantModelRoute?.trim() || null;
assistantModel = row.assistantModel?.trim() || null; assistantModel = row.assistantModel?.trim() || null;
} }
userDashboards = await db userDashboards = await db
@@ -189,6 +192,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
configured={isLlmConfigured()} configured={isLlmConfigured()}
userId={session.user.id} userId={session.user.id}
assistantName={assistantName} assistantName={assistantName}
assistantModelRoute={assistantModelRoute}
assistantModel={assistantModel} assistantModel={assistantModel}
/> />
) : null} ) : null}
+22 -2
View File
@@ -9,9 +9,15 @@ import {
MAX_ASSISTANT_NAME_LENGTH, MAX_ASSISTANT_NAME_LENGTH,
MAX_ASSISTANT_SYSTEM_PROMPT_LENGTH, MAX_ASSISTANT_SYSTEM_PROMPT_LENGTH,
} from "@/lib/assistant-config"; } from "@/lib/assistant-config";
import { isValidLlmModelId, listLlmModels } from "@/lib/llm/models"; import {
isValidAssistantModelRoute,
isValidLlmModelId,
listLlmModels,
type AssistantModelRoute,
} from "@/lib/llm/models";
import { users } from "@/modules/_core/schema"; import { users } from "@/modules/_core/schema";
import { getCurrentSession } from "@/lib/session"; import { getCurrentSession } from "@/lib/session";
import { getAssistantPreferences } from "@/lib/assistant-preference";
const assistantNameSchema = z const assistantNameSchema = z
.string() .string()
@@ -51,6 +57,19 @@ export async function setAssistantSystemPrompt(prompt: string | null): Promise<v
revalidateAssistantSurfaces(); revalidateAssistantSurfaces();
} }
export async function setAssistantModelRoute(route: AssistantModelRoute): Promise<void> {
if (!isValidAssistantModelRoute(route)) {
throw new Error("Invalid assistant model route");
}
const { user } = await getCurrentSession();
await db
.update(users)
.set({ assistantModelRoute: route, assistantModel: null })
.where(eq(users.id, user.id));
revalidateAssistantSurfaces();
}
export async function setAssistantModel(model: string | null): Promise<void> { export async function setAssistantModel(model: string | null): Promise<void> {
const { user } = await getCurrentSession(); const { user } = await getCurrentSession();
const normalized = model?.trim() || null; const normalized = model?.trim() || null;
@@ -59,7 +78,8 @@ export async function setAssistantModel(model: string | null): Promise<void> {
throw new Error("Invalid assistant model"); throw new Error("Invalid assistant model");
} }
const available = await listLlmModels(); const assistant = await getAssistantPreferences(user.id);
const available = await listLlmModels({ route: assistant.modelRoute });
const requested = normalized === available.fallbackModel ? null : normalized; const requested = normalized === available.fallbackModel ? null : normalized;
if (requested !== null && !available.models.some((option) => option.id === requested)) { if (requested !== null && !available.models.some((option) => option.id === requested)) {
+7
View File
@@ -1,6 +1,7 @@
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { DEFAULT_ASSISTANT_NAME } from "@/lib/assistant-config"; import { DEFAULT_ASSISTANT_NAME } from "@/lib/assistant-config";
import { isValidAssistantModelRoute, type AssistantModelRoute } from "@/lib/llm/models";
import { users } from "@/modules/_core/schema"; import { users } from "@/modules/_core/schema";
export { export {
@@ -14,6 +15,7 @@ export type AssistantPreferences = {
enabled: boolean; enabled: boolean;
name: string; name: string;
systemPrompt: string | null; systemPrompt: string | null;
modelRoute: AssistantModelRoute | null;
model: string | null; model: string | null;
}; };
@@ -23,6 +25,7 @@ export async function getAssistantPreferences(userId: string): Promise<Assistant
assistantEnabled: users.assistantEnabled, assistantEnabled: users.assistantEnabled,
assistantName: users.assistantName, assistantName: users.assistantName,
assistantSystemPrompt: users.assistantSystemPrompt, assistantSystemPrompt: users.assistantSystemPrompt,
assistantModelRoute: users.assistantModelRoute,
assistantModel: users.assistantModel, assistantModel: users.assistantModel,
}) })
.from(users) .from(users)
@@ -33,6 +36,10 @@ export async function getAssistantPreferences(userId: string): Promise<Assistant
enabled: row?.assistantEnabled ?? false, enabled: row?.assistantEnabled ?? false,
name: row?.assistantName?.trim() || DEFAULT_ASSISTANT_NAME, name: row?.assistantName?.trim() || DEFAULT_ASSISTANT_NAME,
systemPrompt: row?.assistantSystemPrompt ?? null, systemPrompt: row?.assistantSystemPrompt ?? null,
modelRoute:
row?.assistantModelRoute && isValidAssistantModelRoute(row.assistantModelRoute)
? row.assistantModelRoute
: null,
model: row?.assistantModel?.trim() || null, model: row?.assistantModel?.trim() || null,
}; };
} }
+30 -7
View File
@@ -5,9 +5,14 @@ export type LlmModelOption = {
label: string; label: string;
}; };
export const ASSISTANT_MODEL_ROUTES = ["auto", "uncensored"] as const;
export type AssistantModelRoute = (typeof ASSISTANT_MODEL_ROUTES)[number];
export type LlmModelsResult = { export type LlmModelsResult = {
models: LlmModelOption[]; models: LlmModelOption[];
fallbackModel: string; fallbackModel: string;
route: AssistantModelRoute;
degraded: boolean; degraded: boolean;
}; };
@@ -28,6 +33,22 @@ export function isValidLlmModelId(value: string): boolean {
); );
} }
export function isValidAssistantModelRoute(value: string): value is AssistantModelRoute {
return ASSISTANT_MODEL_ROUTES.some((route) => route === value);
}
function resolveModelRoute(
route: AssistantModelRoute | null | undefined,
config: LlmConfig,
): { route: AssistantModelRoute; fallbackModel: string } {
if (route) {
return { route, fallbackModel: route };
}
const defaultRoute: AssistantModelRoute = config.model === "uncensored" ? "uncensored" : "auto";
return { route: defaultRoute, fallbackModel: config.model };
}
export function normalizeLlmModelsPayload(payload: unknown): LlmModelOption[] { export function normalizeLlmModelsPayload(payload: unknown): LlmModelOption[] {
const data = const data =
typeof payload === "object" && payload !== null && "data" in payload typeof payload === "object" && payload !== null && "data" in payload
@@ -51,14 +72,15 @@ export function normalizeLlmModelsPayload(payload: unknown): LlmModelOption[] {
export async function listLlmModels(options?: { export async function listLlmModels(options?: {
config?: LlmConfig; config?: LlmConfig;
fetchImpl?: typeof fetch; fetchImpl?: typeof fetch;
route?: AssistantModelRoute | null;
}): Promise<LlmModelsResult> { }): Promise<LlmModelsResult> {
const config = options?.config ?? getLlmConfig(); const config = options?.config ?? getLlmConfig();
const fetchImpl = options?.fetchImpl ?? fetch; const fetchImpl = options?.fetchImpl ?? fetch;
const fallbackModel = config.model; const { route, fallbackModel } = resolveModelRoute(options?.route, config);
const fallbackOption = { id: fallbackModel, label: fallbackModel }; const fallbackOption = { id: fallbackModel, label: fallbackModel };
if (config.provider === "mock" || !config.baseUrl) { if (config.provider === "mock" || !config.baseUrl) {
return { models: [fallbackOption], fallbackModel, degraded: false }; return { models: [fallbackOption], fallbackModel, route, degraded: false };
} }
try { try {
@@ -66,7 +88,7 @@ export async function listLlmModels(options?: {
if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`; if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
const modelsUrl = new URL(`${config.baseUrl.replace(/\/$/, "")}/models`); const modelsUrl = new URL(`${config.baseUrl.replace(/\/$/, "")}/models`);
if (fallbackModel === "uncensored") { if (route === "uncensored") {
modelsUrl.searchParams.set("type", "uncensored"); modelsUrl.searchParams.set("type", "uncensored");
} }
@@ -76,26 +98,27 @@ export async function listLlmModels(options?: {
}); });
if (!response.ok) { if (!response.ok) {
return { models: [fallbackOption], fallbackModel, degraded: true }; return { models: [fallbackOption], fallbackModel, route, degraded: true };
} }
const models = normalizeLlmModelsPayload(await response.json()); const models = normalizeLlmModelsPayload(await response.json());
if (models.length === 0) { if (models.length === 0) {
return { models: [fallbackOption], fallbackModel, degraded: true }; return { models: [fallbackOption], fallbackModel, route, degraded: true };
} }
if (!models.some((model) => model.id === fallbackModel)) { if (!models.some((model) => model.id === fallbackModel)) {
return { models: [fallbackOption], fallbackModel, degraded: false }; return { models: [fallbackOption], fallbackModel, route, degraded: false };
} }
return { return {
models, models,
fallbackModel, fallbackModel,
route,
degraded: false, degraded: false,
}; };
} catch { } catch {
return { models: [fallbackOption], fallbackModel, degraded: true }; return { models: [fallbackOption], fallbackModel, route, degraded: true };
} }
} }
+1
View File
@@ -38,6 +38,7 @@ export const users = pgTable("users", {
assistantEnabled: boolean("assistant_enabled").notNull().default(false), assistantEnabled: boolean("assistant_enabled").notNull().default(false),
assistantName: text("assistant_name").notNull().default("Assistant"), assistantName: text("assistant_name").notNull().default("Assistant"),
assistantSystemPrompt: text("assistant_system_prompt"), assistantSystemPrompt: text("assistant_system_prompt"),
assistantModelRoute: text("assistant_model_route"),
assistantModel: text("assistant_model"), assistantModel: text("assistant_model"),
defaultEventReminderOffsets: jsonb("default_event_reminder_offsets") defaultEventReminderOffsets: jsonb("default_event_reminder_offsets")
.notNull() .notNull()
@@ -8,10 +8,17 @@ type Props = {
configured: boolean; configured: boolean;
userId: string; userId: string;
assistantName: string; assistantName: string;
assistantModelRoute: string | null;
assistantModel: string | null; assistantModel: string | null;
}; };
export function AssistantBubble({ configured, userId, assistantName, assistantModel }: Props) { export function AssistantBubble({
configured,
userId,
assistantName,
assistantModelRoute,
assistantModel,
}: Props) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
return ( return (
@@ -42,6 +49,7 @@ export function AssistantBubble({ configured, userId, assistantName, assistantMo
configured={configured} configured={configured}
userId={userId} userId={userId}
assistantName={assistantName} assistantName={assistantName}
assistantModelRoute={assistantModelRoute}
assistantModel={assistantModel} assistantModel={assistantModel}
/> />
</div> </div>
@@ -2,9 +2,10 @@
import { useCallback, useEffect, useRef, useState, useTransition } from "react"; import { useCallback, useEffect, useRef, useState, useTransition } from "react";
import { ChevronDown, ImagePlus, Loader2, Mic, RefreshCw, 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, setAssistantModelRoute } 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";
import type { AssistantModelRoute } from "@/lib/llm/models";
import { consumeAgentChatStream } from "../assistant-chat-stream"; import { consumeAgentChatStream } from "../assistant-chat-stream";
import { import {
clearAssistantChat, clearAssistantChat,
@@ -19,6 +20,7 @@ type Props = {
configured: boolean; configured: boolean;
userId: string; userId: string;
assistantName: string; assistantName: string;
assistantModelRoute: string | null;
assistantModel: string | null; assistantModel: string | null;
}; };
@@ -35,6 +37,7 @@ type ModelsResponse = {
models: LlmModelOption[]; models: LlmModelOption[];
selectedModel: string; selectedModel: string;
fallbackModel: string; fallbackModel: string;
route: AssistantModelRoute;
degraded: boolean; degraded: boolean;
}; };
@@ -50,7 +53,17 @@ async function uploadAssistantImage(file: File): Promise<string> {
return payload.url; return payload.url;
} }
export function AssistantPanel({ configured, userId, assistantName, assistantModel }: Props) { function normalizeAssistantModelRoute(value: string | null): AssistantModelRoute {
return value === "uncensored" ? "uncensored" : "auto";
}
export function AssistantPanel({
configured,
userId,
assistantName,
assistantModelRoute,
assistantModel,
}: Props) {
const [messages, setMessages] = useState<AssistantChatMessage[]>(() => loadAssistantChat(userId)); const [messages, setMessages] = useState<AssistantChatMessage[]>(() => loadAssistantChat(userId));
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [pendingImage, setPendingImage] = useState<PendingImage | null>(null); const [pendingImage, setPendingImage] = useState<PendingImage | null>(null);
@@ -59,6 +72,9 @@ export function AssistantPanel({ configured, userId, assistantName, assistantMod
const [isPending, setIsPending] = useState(false); const [isPending, setIsPending] = useState(false);
const [activityLabel, setActivityLabel] = useState<string | null>(null); const [activityLabel, setActivityLabel] = useState<string | null>(null);
const [modelOptions, setModelOptions] = useState<LlmModelOption[]>([]); const [modelOptions, setModelOptions] = useState<LlmModelOption[]>([]);
const [selectedRoute, setSelectedRoute] = useState<AssistantModelRoute>(() =>
normalizeAssistantModelRoute(assistantModelRoute),
);
const [selectedModel, setSelectedModel] = useState(assistantModel ?? ""); const [selectedModel, setSelectedModel] = useState(assistantModel ?? "");
const [fallbackModel, setFallbackModel] = useState(""); const [fallbackModel, setFallbackModel] = useState("");
const [modelsDegraded, setModelsDegraded] = useState(false); const [modelsDegraded, setModelsDegraded] = useState(false);
@@ -87,31 +103,40 @@ export function AssistantPanel({ configured, userId, assistantName, assistantMod
}; };
}, []); }, []);
const loadModels = useCallback(async (options?: { refresh?: boolean; signal?: AbortSignal }) => { const loadModels = useCallback(
if (options?.signal?.aborted) return; async (options?: { refresh?: boolean; route?: AssistantModelRoute; signal?: AbortSignal }) => {
setModelsLoading(true);
try {
const response = await fetch(`/api/agent/models${options?.refresh ? "?refresh=1" : ""}`, {
cache: "no-store",
signal: options?.signal,
});
if (!response.ok) throw new Error("Model discovery unavailable");
const payload = (await response.json()) as ModelsResponse;
if (options?.signal?.aborted) return; if (options?.signal?.aborted) return;
setModelOptions(payload.models);
setSelectedModel(payload.selectedModel); setModelsLoading(true);
setFallbackModel(payload.fallbackModel); try {
setModelsDegraded(payload.degraded); const params = new URLSearchParams();
setError(null); if (options?.refresh) params.set("refresh", "1");
} catch (err) { if (options?.route) params.set("route", options.route);
if (err instanceof Error && err.name === "AbortError") return; const query = params.size > 0 ? `?${params.toString()}` : "";
setModelsDegraded(true);
setError("Model discovery unavailable"); const response = await fetch(`/api/agent/models${query}`, {
} finally { cache: "no-store",
if (!options?.signal?.aborted) setModelsLoading(false); signal: options?.signal,
} });
}, []); if (!response.ok) throw new Error("Model discovery unavailable");
const payload = (await response.json()) as ModelsResponse;
if (options?.signal?.aborted) return;
setModelOptions(payload.models);
setSelectedRoute(payload.route);
setSelectedModel(payload.selectedModel);
setFallbackModel(payload.fallbackModel);
setModelsDegraded(payload.degraded);
setError(null);
} catch (err) {
if (err instanceof Error && err.name === "AbortError") return;
setModelsDegraded(true);
setError("Model discovery unavailable");
} finally {
if (!options?.signal?.aborted) setModelsLoading(false);
}
},
[],
);
useEffect(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
@@ -175,6 +200,26 @@ export function AssistantPanel({ configured, userId, assistantName, assistantMod
}); });
} }
function changeModelRoute(nextRoute: string) {
const route = normalizeAssistantModelRoute(nextRoute);
setSelectedRoute(route);
setSelectedModel(route);
setFallbackModel(route);
setModelOptions([]);
setModelsDegraded(false);
setError(null);
startTransition(async () => {
try {
await setAssistantModelRoute(route);
await loadModels({ route, refresh: true });
} catch (err) {
setError(err instanceof Error ? err.message : "Could not save assistant route");
}
});
}
async function sendMessage() { async function sendMessage() {
const text = input.trim(); const text = input.trim();
const hasImage = pendingImage !== null; const hasImage = pendingImage !== null;
@@ -242,6 +287,7 @@ export function AssistantPanel({ configured, userId, assistantName, assistantMod
const inputDisabled = isPending || voiceState === "transcribing" || uploadingImage; const inputDisabled = isPending || voiceState === "transcribing" || uploadingImage;
const canSend = const canSend =
!isPending && !isPending &&
!savingModel &&
voiceState === "idle" && voiceState === "idle" &&
!uploadingImage && !uploadingImage &&
(input.trim().length > 0 || pendingImage !== null); (input.trim().length > 0 || pendingImage !== null);
@@ -260,6 +306,22 @@ export function AssistantPanel({ configured, userId, assistantName, assistantMod
) : null} ) : null}
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<div className="relative max-w-32">
<select
aria-label="Assistant model route"
value={selectedRoute}
onChange={(event) => changeModelRoute(event.target.value)}
disabled={modelsLoading || savingModel || isPending}
className="h-9 w-full max-w-32 appearance-none truncate rounded-[min(var(--radius-md),10px)] border border-input bg-[var(--card)] py-0 pr-8 pl-3 text-[13px] leading-9 text-[var(--ink)] outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="auto">Auto</option>
<option value="uncensored">Uncensored</option>
</select>
<ChevronDown
className="pointer-events-none absolute top-1/2 right-2 size-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
</div>
{modelOptions.length > 0 ? ( {modelOptions.length > 0 ? (
<div className="relative max-w-36"> <div className="relative max-w-36">
<select <select
@@ -287,7 +349,7 @@ export function AssistantPanel({ configured, userId, assistantName, assistantMod
variant="outline" variant="outline"
aria-label="Refresh assistant models" aria-label="Refresh assistant models"
disabled={modelsLoading || savingModel || isPending} disabled={modelsLoading || savingModel || isPending}
onClick={() => void loadModels({ refresh: true })} onClick={() => void loadModels({ route: selectedRoute, refresh: true })}
> >
{modelsLoading ? ( {modelsLoading ? (
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
+9 -1
View File
@@ -26,7 +26,15 @@ test("assistant chat smoke after opt-in", async ({ page }) => {
await page.goto("/"); await page.goto("/");
await page.getByRole("button", { name: "Open assistant" }).click(); await page.getByRole("button", { name: "Open assistant" }).click();
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 routeSelector = page.getByRole("combobox", { name: "Assistant model route" });
await expect(routeSelector).toBeVisible();
await routeSelector.selectOption("auto");
await expect(routeSelector).toHaveValue("auto");
await expect(routeSelector).toBeEnabled();
await routeSelector.selectOption("uncensored");
await expect(routeSelector).toHaveValue("uncensored");
await expect(routeSelector).toBeEnabled();
const modelSelector = page.getByRole("combobox", { name: "Assistant model", exact: true });
await expect(modelSelector).toBeVisible(); await expect(modelSelector).toBeVisible();
const refreshModels = page.getByRole("button", { name: "Refresh assistant models" }); const refreshModels = page.getByRole("button", { name: "Refresh assistant models" });
await expect(refreshModels).toBeVisible(); await expect(refreshModels).toBeVisible();
+40 -2
View File
@@ -1,6 +1,7 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import { import {
isValidAssistantModelRoute,
isValidLlmModelId, isValidLlmModelId,
listLlmModels, listLlmModels,
normalizeLlmModelsPayload, normalizeLlmModelsPayload,
@@ -56,6 +57,18 @@ describe("isValidLlmModelId", () => {
}); });
}); });
describe("isValidAssistantModelRoute", () => {
it("accepts the supported route families", () => {
assert.equal(isValidAssistantModelRoute("auto"), true);
assert.equal(isValidAssistantModelRoute("uncensored"), true);
});
it("rejects unsupported or padded route families", () => {
assert.equal(isValidAssistantModelRoute("bogus"), false);
assert.equal(isValidAssistantModelRoute(" uncensored "), false);
});
});
describe("listLlmModels", () => { describe("listLlmModels", () => {
it("fetches provider models with API key auth when the fallback is advertised", async () => { it("fetches provider models with API key auth when the fallback is advertised", async () => {
const requests: Request[] = []; const requests: Request[] = [];
@@ -88,10 +101,11 @@ describe("listLlmModels", () => {
assert.equal(result.degraded, true); assert.equal(result.degraded, true);
}); });
it("fetches the typed uncensored catalog when uncensored is the fallback route", async () => { it("fetches the typed uncensored catalog when uncensored is the selected route", async () => {
const requests: Request[] = []; const requests: Request[] = [];
const result = await listLlmModels({ const result = await listLlmModels({
config: { ...openAiConfig, model: "uncensored" }, config: { ...openAiConfig, model: "auto" },
route: "uncensored",
fetchImpl: async (input, init) => { fetchImpl: async (input, init) => {
requests.push(new Request(input, init)); requests.push(new Request(input, init));
return Response.json({ return Response.json({
@@ -111,6 +125,30 @@ describe("listLlmModels", () => {
{ id: "uncensored", label: "uncensored" }, { id: "uncensored", label: "uncensored" },
]); ]);
assert.equal(result.fallbackModel, "uncensored"); assert.equal(result.fallbackModel, "uncensored");
assert.equal(result.route, "uncensored");
assert.equal(result.degraded, false);
});
it("fetches the default catalog when auto is selected over an uncensored deployment default", async () => {
const requests: Request[] = [];
const result = await listLlmModels({
config: { ...openAiConfig, model: "uncensored" },
route: "auto",
fetchImpl: async (input, init) => {
requests.push(new Request(input, init));
return Response.json({
data: [{ id: "auto" }, { id: "qwen3:8b" }],
});
},
});
assert.equal(requests[0]?.url, "https://llm.example.test/v1/models");
assert.deepEqual(result.models, [
{ id: "auto", label: "auto" },
{ id: "qwen3:8b", label: "qwen3:8b" },
]);
assert.equal(result.fallbackModel, "auto");
assert.equal(result.route, "auto");
assert.equal(result.degraded, false); assert.equal(result.degraded, false);
}); });