feat: admin-configurable default AI providers/models (v0.30.0)
Add DEFAULT_{TEXT,VISION,MEAL_PLAN}_{PROVIDER,MODEL} site settings,
editable from Settings -> Admin (new AdminDefaultModelForm, mirroring
the per-user model-prefs UI). getDefaultProviderWithKey now accepts
the use case and checks the admin default (after the user's own BYOK
key, before the old "first configured site key" heuristic) so admins
can pin a specific provider/model per feature instead of it being
whichever key happens to exist.
Wired into scale/substitute/batch-cook/meal-plan generation routes,
which previously called getDefaultProviderWithKey() without a use
case and so never had visibility into per-feature routing at all.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type Provider = "openai" | "anthropic" | "openrouter" | "ollama" | "";
|
||||
|
||||
type UseCase = {
|
||||
key: "text" | "vision" | "mealPlan";
|
||||
providerSetting: "DEFAULT_TEXT_PROVIDER" | "DEFAULT_VISION_PROVIDER" | "DEFAULT_MEAL_PLAN_PROVIDER";
|
||||
modelSetting: "DEFAULT_TEXT_MODEL" | "DEFAULT_VISION_MODEL" | "DEFAULT_MEAL_PLAN_MODEL";
|
||||
label: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const USE_CASES: UseCase[] = [
|
||||
{
|
||||
key: "text",
|
||||
providerSetting: "DEFAULT_TEXT_PROVIDER",
|
||||
modelSetting: "DEFAULT_TEXT_MODEL",
|
||||
label: "Text generation",
|
||||
description: "Recipe generation, chat, substitutions, pairings, and other text-based AI features.",
|
||||
},
|
||||
{
|
||||
key: "vision",
|
||||
providerSetting: "DEFAULT_VISION_PROVIDER",
|
||||
modelSetting: "DEFAULT_VISION_MODEL",
|
||||
label: "Vision (photo import/scan)",
|
||||
description: "Importing a recipe from a photo and pantry barcode/photo scanning.",
|
||||
},
|
||||
{
|
||||
key: "mealPlan",
|
||||
providerSetting: "DEFAULT_MEAL_PLAN_PROVIDER",
|
||||
modelSetting: "DEFAULT_MEAL_PLAN_MODEL",
|
||||
label: "Meal plan generation",
|
||||
description: "Generating a full week's meal plan.",
|
||||
},
|
||||
];
|
||||
|
||||
const PRESET_MODELS: Record<string, { label: string; models: { value: string; label: string }[] }> = {
|
||||
openai: {
|
||||
label: "OpenAI",
|
||||
models: [
|
||||
{ value: "gpt-4o", label: "GPT-4o" },
|
||||
{ value: "gpt-4o-mini", label: "GPT-4o mini (faster)" },
|
||||
{ value: "o3-mini", label: "o3-mini (reasoning)" },
|
||||
{ value: "gpt-4-turbo", label: "GPT-4 Turbo" },
|
||||
],
|
||||
},
|
||||
anthropic: {
|
||||
label: "Anthropic",
|
||||
models: [
|
||||
{ value: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
|
||||
{ value: "claude-opus-4-8", label: "Claude Opus 4.8 (most capable)" },
|
||||
{ value: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5 (fastest)" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
type Values = Partial<Record<UseCase["providerSetting"] | UseCase["modelSetting"], string | null>>;
|
||||
|
||||
export function AdminDefaultModelForm({ initialValues }: { initialValues: Values }) {
|
||||
const [values, setValues] = useState<Values>(initialValues);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
function setField(key: UseCase["providerSetting"] | UseCase["modelSetting"], value: string | null) {
|
||||
setValues((prev) => ({ ...prev, [key]: value || null }));
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch("/api/v1/admin/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
if (!res.ok) throw new Error("Save failed");
|
||||
toast.success("Default AI providers saved");
|
||||
} catch {
|
||||
toast.error("Failed to save defaults");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border p-6 space-y-4">
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg">Default AI Providers</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
What to use for a given feature when a user hasn't set their own model preference and has no personal API key.
|
||||
Leave a provider unset to fall back to whichever provider key is configured above.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{USE_CASES.map(({ key, providerSetting, modelSetting, label, description }) => {
|
||||
const provider = (values[providerSetting] ?? "") as Provider;
|
||||
const model = (values[modelSetting] ?? "") as string;
|
||||
const presets = provider && PRESET_MODELS[provider]?.models;
|
||||
|
||||
return (
|
||||
<div key={key} className="rounded-lg border p-4 space-y-3">
|
||||
<div>
|
||||
<p className="font-medium text-sm">{label}</p>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Provider</Label>
|
||||
<Select
|
||||
value={provider || "default"}
|
||||
onValueChange={(v) => {
|
||||
const val = v === "default" ? null : v;
|
||||
setField(providerSetting, val);
|
||||
setField(modelSetting, null);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">
|
||||
<span className="text-muted-foreground">Auto (first configured key)</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="openai">OpenAI</SelectItem>
|
||||
<SelectItem value="anthropic">Anthropic</SelectItem>
|
||||
<SelectItem value="openrouter">OpenRouter</SelectItem>
|
||||
<SelectItem value="ollama">Ollama (local)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Model</Label>
|
||||
{presets ? (
|
||||
<Select
|
||||
value={model || "default"}
|
||||
onValueChange={(v) => setField(modelSetting, v === "default" ? null : v)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="Provider default" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">
|
||||
<span className="text-muted-foreground">Provider default</span>
|
||||
</SelectItem>
|
||||
<SelectGroup>
|
||||
<SelectLabel>{PRESET_MODELS[provider]?.label}</SelectLabel>
|
||||
{presets.map((m) => (
|
||||
<SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
className="h-8 text-sm"
|
||||
placeholder={provider ? "Model name" : "—"}
|
||||
value={model}
|
||||
onChange={(e) => setField(modelSetting, e.target.value)}
|
||||
disabled={!provider}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button onClick={() => { void handleSave(); }} disabled={saving} size="sm">
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user