fix(admin): LLM provider/model switching now reads DB overrides correctly

- get_llm_provider() now accepts provider + model args so DB overrides
  propagate through to all provider constructors (was always reading
  env vars, ignoring the admin UI selection)
- /test-llm replaced with lightweight ping (max_tokens=20) instead of
  running a full bill analysis; shows model name + reply, no truncation
- /api/settings/llm-models endpoint fetches available models live from
  each provider's API (OpenAI, Anthropic REST, Gemini, Ollama)
- Admin UI model picker dynamically populated from provider API;
  falls back to manual text input on error; Custom model name option kept
- Default Gemini model updated: gemini-1.5-pro → gemini-2.0-flash

Co-Authored-By: Jack Levy
This commit is contained in:
Jack Levy
2026-03-01 04:03:51 -05:00
parent 12a3eac48f
commit defc2c116d
6 changed files with 466 additions and 50 deletions

View File

@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Settings,
@@ -16,17 +16,21 @@ import {
FileText,
Brain,
BarChart3,
Bell,
Copy,
Rss,
} from "lucide-react";
import { settingsAPI, adminAPI, type AdminUser } from "@/lib/api";
import { settingsAPI, adminAPI, notificationsAPI, type AdminUser, type LLMModel } from "@/lib/api";
import { useAuthStore } from "@/stores/authStore";
const LLM_PROVIDERS = [
{ value: "openai", label: "OpenAI (GPT-4o)", hint: "Requires OPENAI_API_KEY in .env" },
{ value: "openai", label: "OpenAI", hint: "Requires OPENAI_API_KEY in .env" },
{ value: "anthropic", label: "Anthropic (Claude)", hint: "Requires ANTHROPIC_API_KEY in .env" },
{ value: "gemini", label: "Google Gemini", hint: "Requires GEMINI_API_KEY in .env" },
{ value: "ollama", label: "Ollama (Local)", hint: "Requires Ollama running on host" },
];
export default function SettingsPage() {
const qc = useQueryClient();
const currentUser = useAuthStore((s) => s.user);
@@ -64,11 +68,60 @@ export default function SettingsPage() {
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-users"] }),
});
const { data: notifSettings, refetch: refetchNotif } = useQuery({
queryKey: ["notification-settings"],
queryFn: () => notificationsAPI.getSettings(),
});
const updateNotif = useMutation({
mutationFn: (data: Parameters<typeof notificationsAPI.updateSettings>[0]) =>
notificationsAPI.updateSettings(data),
onSuccess: () => refetchNotif(),
});
const resetRss = useMutation({
mutationFn: () => notificationsAPI.resetRssToken(),
onSuccess: () => refetchNotif(),
});
const [ntfyUrl, setNtfyUrl] = useState("");
const [ntfyToken, setNtfyToken] = useState("");
const [notifSaved, setNotifSaved] = useState(false);
const [copied, setCopied] = useState(false);
// Live model list from provider API
const { data: modelsData, isFetching: modelsFetching, refetch: refetchModels } = useQuery({
queryKey: ["llm-models", settings?.llm_provider],
queryFn: () => settingsAPI.listModels(settings!.llm_provider),
enabled: !!currentUser?.is_admin && !!settings?.llm_provider,
staleTime: 5 * 60 * 1000,
retry: false,
});
const liveModels: LLMModel[] = modelsData?.models ?? [];
const modelsError: string | undefined = modelsData?.error;
// Model picker state
const [showCustomModel, setShowCustomModel] = useState(false);
const [customModel, setCustomModel] = useState("");
useEffect(() => {
if (!settings || modelsFetching) return;
const inList = liveModels.some((m) => m.id === settings.llm_model);
if (!inList && settings.llm_model) {
setShowCustomModel(true);
setCustomModel(settings.llm_model);
} else {
setShowCustomModel(false);
setCustomModel("");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settings?.llm_provider, settings?.llm_model, modelsFetching]);
const [testResult, setTestResult] = useState<{
status: string;
detail?: string;
summary_preview?: string;
reply?: string;
provider?: string;
model?: string;
} | null>(null);
const [testing, setTesting] = useState(false);
const [taskIds, setTaskIds] = useState<Record<string, string>>({});
@@ -236,9 +289,6 @@ export default function SettingsPage() {
<h2 className="font-semibold flex items-center gap-2">
<Cpu className="w-4 h-4" /> LLM Provider
</h2>
<p className="text-xs text-muted-foreground">
Current: <strong>{settings?.llm_provider}</strong> / <strong>{settings?.llm_model}</strong>
</p>
<div className="space-y-2">
{LLM_PROVIDERS.map(({ value, label, hint }) => (
<label key={value} className="flex items-start gap-3 cursor-pointer">
@@ -247,7 +297,11 @@ export default function SettingsPage() {
name="provider"
value={value}
checked={settings?.llm_provider === value}
onChange={() => updateSetting.mutate({ key: "llm_provider", value })}
onChange={() => {
updateSetting.mutate({ key: "llm_provider", value });
setShowCustomModel(false);
setCustomModel("");
}}
className="mt-0.5"
/>
<div>
@@ -257,6 +311,76 @@ export default function SettingsPage() {
</label>
))}
</div>
{/* Model picker — live from provider API */}
<div className="space-y-2 pt-3 border-t border-border">
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Model</label>
{modelsFetching && <span className="text-xs text-muted-foreground">Loading models</span>}
{modelsError && !modelsFetching && (
<span className="text-xs text-amber-600 dark:text-amber-400">{modelsError}</span>
)}
{!modelsFetching && liveModels.length > 0 && (
<button onClick={() => refetchModels()} className="text-xs text-muted-foreground hover:text-foreground transition-colors">
Refresh
</button>
)}
</div>
{liveModels.length > 0 ? (
<select
value={showCustomModel ? "__custom__" : (settings?.llm_model ?? "")}
onChange={(e) => {
if (e.target.value === "__custom__") {
setShowCustomModel(true);
setCustomModel(settings?.llm_model ?? "");
} else {
setShowCustomModel(false);
setCustomModel("");
updateSetting.mutate({ key: "llm_model", value: e.target.value });
}
}}
className="w-full px-3 py-1.5 text-sm bg-background border border-border rounded-md"
>
{liveModels.map((m) => (
<option key={m.id} value={m.id}>{m.name !== m.id ? `${m.name} (${m.id})` : m.id}</option>
))}
<option value="__custom__">Custom model name</option>
</select>
) : (
!modelsFetching && (
<p className="text-xs text-muted-foreground">
{modelsError ? "Could not fetch models — enter a model name manually below." : "No models found."}
</p>
)
)}
{(showCustomModel || (liveModels.length === 0 && !modelsFetching)) && (
<div className="flex gap-2">
<input
type="text"
placeholder="e.g. gpt-4o or gemini-2.0-flash"
value={customModel}
onChange={(e) => setCustomModel(e.target.value)}
className="flex-1 px-3 py-1.5 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
/>
<button
onClick={() => {
if (customModel.trim()) updateSetting.mutate({ key: "llm_model", value: customModel.trim() });
}}
disabled={!customModel.trim() || updateSetting.isPending}
className="px-3 py-1.5 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50"
>
Save
</button>
</div>
)}
<p className="text-xs text-muted-foreground">
Active: <strong>{settings?.llm_provider}</strong> / <strong>{settings?.llm_model}</strong>
</p>
</div>
<div className="flex items-center gap-3 pt-2 border-t border-border">
<button
onClick={testLLM}
@@ -272,7 +396,7 @@ export default function SettingsPage() {
<>
<CheckCircle className="w-4 h-4 text-green-500" />
<span className="text-green-600 dark:text-green-400">
{testResult.provider} {testResult.summary_preview?.slice(0, 60)}...
{testResult.model} {testResult.reply}
</span>
</>
) : (
@@ -329,6 +453,113 @@ export default function SettingsPage() {
</div>
</section>
{/* Notifications */}
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
<h2 className="font-semibold flex items-center gap-2">
<Bell className="w-4 h-4" /> Notifications
</h2>
{/* ntfy */}
<div className="space-y-3">
<div>
<label className="text-sm font-medium">ntfy Topic URL</label>
<p className="text-xs text-muted-foreground mb-1.5">
Your ntfy topic use <a href="https://ntfy.sh" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">ntfy.sh</a> (public) or your self-hosted server.
e.g. <code className="bg-muted px-1 rounded text-xs">https://ntfy.sh/your-topic</code>
</p>
<input
type="url"
placeholder="https://ntfy.sh/your-topic"
defaultValue={notifSettings?.ntfy_topic_url ?? ""}
onChange={(e) => setNtfyUrl(e.target.value)}
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div>
<label className="text-sm font-medium">ntfy Auth Token <span className="text-muted-foreground font-normal">(optional)</span></label>
<p className="text-xs text-muted-foreground mb-1.5">Required only for private/self-hosted topics with access control.</p>
<input
type="password"
placeholder="tk_..."
defaultValue={notifSettings?.ntfy_token ?? ""}
onChange={(e) => setNtfyToken(e.target.value)}
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div className="flex items-center gap-3 pt-1">
<button
onClick={() => {
updateNotif.mutate({
ntfy_topic_url: ntfyUrl || notifSettings?.ntfy_topic_url || "",
ntfy_token: ntfyToken || notifSettings?.ntfy_token || "",
ntfy_enabled: true,
}, {
onSuccess: () => { setNotifSaved(true); setTimeout(() => setNotifSaved(false), 2000); }
});
}}
disabled={updateNotif.isPending}
className="flex items-center gap-2 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{notifSaved ? <CheckCircle className="w-3.5 h-3.5" /> : <Bell className="w-3.5 h-3.5" />}
{notifSaved ? "Saved!" : "Save & Enable"}
</button>
{notifSettings?.ntfy_enabled && (
<button
onClick={() => updateNotif.mutate({ ntfy_enabled: false })}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Disable
</button>
)}
{notifSettings?.ntfy_enabled && (
<span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
<CheckCircle className="w-3 h-3" /> ntfy active
</span>
)}
</div>
</div>
{/* RSS */}
<div className="pt-3 border-t border-border space-y-2">
<div className="flex items-center gap-2">
<Rss className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">RSS Feed</span>
</div>
{notifSettings?.rss_token ? (
<div className="space-y-2">
<div className="flex items-center gap-2">
<code className="flex-1 text-xs bg-muted px-2 py-1.5 rounded truncate">
{`${window.location.origin}/api/notifications/feed/${notifSettings.rss_token}.xml`}
</code>
<button
onClick={() => {
navigator.clipboard.writeText(
`${window.location.origin}/api/notifications/feed/${notifSettings.rss_token}.xml`
);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
className="shrink-0 p-1.5 rounded hover:bg-accent transition-colors"
title="Copy RSS URL"
>
{copied ? <CheckCircle className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4 text-muted-foreground" />}
</button>
</div>
<button
onClick={() => resetRss.mutate()}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Regenerate URL (invalidates old link)
</button>
</div>
) : (
<p className="text-xs text-muted-foreground">
Save your ntfy settings above to generate your personal RSS feed URL.
</p>
)}
</div>
</section>
{/* Manual Controls */}
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
<h2 className="font-semibold">Manual Controls</h2>