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:
Arnaud
2026-07-14 13:54:24 +02:00
parent d8bc077f47
commit affa6f5c3d
14 changed files with 280 additions and 23 deletions
+5
View File
@@ -2,6 +2,11 @@
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
## 0.30.0 — 2026-07-14 16:05
### Added
- **Admins can now set the site-wide default AI provider/model** per use case (text generation, photo/vision, meal-plan generation) from Settings → Admin, applied whenever a user hasn't picked their own model preference or brought their own API key.
## 0.29.0 — 2026-07-14 15:20
### Added
+12
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { getAllSiteSettings, isSignupsDisabled } from "@/lib/site-settings";
import { AdminSettingsForm } from "@/components/admin/admin-settings-form";
import { AdminDefaultModelForm } from "@/components/admin/admin-default-model-form";
import { SignupsToggle } from "@/components/admin/signups-toggle";
export const metadata: Metadata = {};
@@ -42,6 +43,17 @@ export default async function AdminSettingsPage() {
{SETTING_GROUPS.map((group) => (
<AdminSettingsForm key={group.title} group={group} settings={settings} />
))}
<AdminDefaultModelForm
initialValues={{
DEFAULT_TEXT_PROVIDER: settings["DEFAULT_TEXT_PROVIDER"]?.value ?? null,
DEFAULT_TEXT_MODEL: settings["DEFAULT_TEXT_MODEL"]?.value ?? null,
DEFAULT_VISION_PROVIDER: settings["DEFAULT_VISION_PROVIDER"]?.value ?? null,
DEFAULT_VISION_MODEL: settings["DEFAULT_VISION_MODEL"]?.value ?? null,
DEFAULT_MEAL_PLAN_PROVIDER: settings["DEFAULT_MEAL_PLAN_PROVIDER"]?.value ?? null,
DEFAULT_MEAL_PLAN_MODEL: settings["DEFAULT_MEAL_PLAN_MODEL"]?.value ?? null,
}}
/>
</div>
);
}
@@ -14,6 +14,12 @@ const ALLOWED_KEYS: SiteSettingKey[] = [
"NEXT_PUBLIC_VAPID_PUBLIC_KEY",
"VAPID_PRIVATE_KEY",
"SIGNUPS_DISABLED",
"DEFAULT_TEXT_PROVIDER",
"DEFAULT_TEXT_MODEL",
"DEFAULT_VISION_PROVIDER",
"DEFAULT_VISION_MODEL",
"DEFAULT_MEAL_PLAN_PROVIDER",
"DEFAULT_MEAL_PLAN_MODEL",
];
async function requireAdmin() {
@@ -37,7 +37,7 @@ export async function POST(req: NextRequest) {
const userId = session!.user.id;
const locale = (session!.user as { locale?: string }).locale ?? "en";
const [configResult, privateBio] = await Promise.all([
resolveAiConfigOrError(() => getDefaultProviderWithKey(userId)),
resolveAiConfigOrError(() => getDefaultProviderWithKey(userId, "text")),
getUserPrivateBio(userId),
]);
if (!configResult.ok) return configResult.response;
@@ -38,7 +38,7 @@ export async function POST(req: NextRequest) {
const userId = session!.user.id;
const locale = (session!.user as { locale?: string }).locale ?? "en";
const [configResult, privateBio] = await Promise.all([
resolveAiConfigOrError(() => getDefaultProviderWithKey(userId)),
resolveAiConfigOrError(() => getDefaultProviderWithKey(userId, "mealPlan")),
getUserPrivateBio(userId),
]);
if (!configResult.ok) return configResult.response;
+1 -1
View File
@@ -40,7 +40,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const configResult = await resolveAiConfigOrError(() => getDefaultProviderWithKey(session!.user.id));
const configResult = await resolveAiConfigOrError(() => getDefaultProviderWithKey(session!.user.id, "text"));
if (!configResult.ok) return configResult.response;
const aiConfig = configResult.data;
+1 -1
View File
@@ -32,7 +32,7 @@ export async function POST(req: NextRequest) {
if (parsed.data.provider) {
aiConfig = { provider: parsed.data.provider, model: parsed.data.model };
} else {
const configResult = await resolveAiConfigOrError(() => getDefaultProviderWithKey(session!.user.id));
const configResult = await resolveAiConfigOrError(() => getDefaultProviderWithKey(session!.user.id, "text"));
if (!configResult.ok) return configResult.response;
aiConfig = configResult.data;
}
@@ -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&apos;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>
);
}
+34 -15
View File
@@ -1,10 +1,23 @@
import { db, userAiKeys, userModelPrefs, eq, and } from "@epicure/db";
import { decrypt } from "@/lib/encrypt";
import { getSiteSetting } from "@/lib/site-settings";
import { getSiteSetting, type SiteSettingKey } from "@/lib/site-settings";
import type { AiConfig, AiProvider } from "./factory";
export type ModelUseCase = "text" | "vision" | "mealPlan";
const USE_CASE_SITE_DEFAULTS: Record<ModelUseCase, { provider: SiteSettingKey; model: SiteSettingKey }> = {
text: { provider: "DEFAULT_TEXT_PROVIDER", model: "DEFAULT_TEXT_MODEL" },
vision: { provider: "DEFAULT_VISION_PROVIDER", model: "DEFAULT_VISION_MODEL" },
mealPlan: { provider: "DEFAULT_MEAL_PLAN_PROVIDER", model: "DEFAULT_MEAL_PLAN_MODEL" },
};
const PROVIDER_API_KEY_SETTING: Record<AiProvider, SiteSettingKey | null> = {
openrouter: "OPENROUTER_API_KEY",
openai: "OPENAI_API_KEY",
anthropic: "ANTHROPIC_API_KEY",
ollama: null,
};
/**
* Thrown when a user's saved BYOK key exists but fails to decrypt (e.g. the
* encryption secret rotated, or the stored ciphertext is corrupt). Callers
@@ -52,10 +65,10 @@ export async function getModelConfigForUseCase(userId: string, useCase: ModelUse
return withUserKey(userId, config);
}
return getDefaultProviderWithKey(userId);
return getDefaultProviderWithKey(userId, useCase);
}
export async function getDefaultProviderWithKey(userId: string): Promise<AiConfig> {
export async function getDefaultProviderWithKey(userId: string, useCase?: ModelUseCase): Promise<AiConfig> {
const keys = await db.query.userAiKeys.findMany({
where: eq(userAiKeys.userId, userId),
columns: { provider: true, encryptedKey: true },
@@ -63,7 +76,7 @@ export async function getDefaultProviderWithKey(userId: string): Promise<AiConfi
const order: AiProvider[] = ["openrouter", "openai", "anthropic", "ollama"];
// 1. User BYOK key takes highest priority
// 1. User's own BYOK key takes highest priority — it's their own credentials/billing.
for (const p of order) {
const row = keys.find((k) => k.provider === p);
if (row) {
@@ -75,20 +88,26 @@ export async function getDefaultProviderWithKey(userId: string): Promise<AiConfi
}
}
// 2. Admin site-setting overrides (DB → env fallback handled inside getSiteSetting)
const providerEnvKeys: Record<AiProvider, string | null> = {
openrouter: "OPENROUTER_API_KEY",
openai: "OPENAI_API_KEY",
anthropic: "ANTHROPIC_API_KEY",
ollama: null,
};
// 2. Admin-configured default provider/model for this use case, if set.
if (useCase) {
const setting = USE_CASE_SITE_DEFAULTS[useCase];
const defaultProvider = (await getSiteSetting(setting.provider)) as AiProvider | null;
if (defaultProvider) {
const defaultModel = await getSiteSetting(setting.model);
const apiKeySetting = PROVIDER_API_KEY_SETTING[defaultProvider];
const apiKey = apiKeySetting ? await getSiteSetting(apiKeySetting) : null;
return { provider: defaultProvider, model: defaultModel ?? undefined, apiKey: apiKey ?? undefined };
}
}
// 3. Admin site-setting API keys, first provider found (no explicit default set)
for (const p of order) {
const envKey = providerEnvKeys[p];
if (!envKey) continue;
const apiKey = await getSiteSetting(envKey as Parameters<typeof getSiteSetting>[0]);
const apiKeySetting = PROVIDER_API_KEY_SETTING[p];
if (!apiKeySetting) continue;
const apiKey = await getSiteSetting(apiKeySetting);
if (apiKey) return { provider: p, apiKey };
}
// 3. Let factory use raw env vars
// 4. Let factory use raw env vars
return {};
}
+8 -1
View File
@@ -1,5 +1,5 @@
// Mirrors CHANGELOG.md at the repo root — update both together.
export const APP_VERSION = "0.29.0";
export const APP_VERSION = "0.30.0";
export type ChangelogEntry = {
version: string;
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.30.0",
date: "2026-07-14 16:05",
added: [
"**Admins can now set the site-wide default AI provider/model** per use case (text generation, photo/vision, meal-plan generation) from Settings → Admin, applied whenever a user hasn't picked their own model preference or brought their own API key.",
],
},
{
version: "0.29.0",
date: "2026-07-14 15:20",
+6
View File
@@ -682,6 +682,12 @@ export function generateOpenApiSpec(): object {
NEXT_PUBLIC_VAPID_PUBLIC_KEY: z.string().nullable().optional(),
VAPID_PRIVATE_KEY: z.string().nullable().optional().describe("secret — write-only, never returned"),
SIGNUPS_DISABLED: z.string().nullable().optional(),
DEFAULT_TEXT_PROVIDER: z.enum(["openai", "anthropic", "openrouter", "ollama"]).nullable().optional().describe("Site-wide default for recipe/chat text generation when a user has no personal model preference and no BYOK key."),
DEFAULT_TEXT_MODEL: z.string().nullable().optional(),
DEFAULT_VISION_PROVIDER: z.enum(["openai", "anthropic", "openrouter", "ollama"]).nullable().optional().describe("Site-wide default for photo/vision use cases (pantry scan, photo import)."),
DEFAULT_VISION_MODEL: z.string().nullable().optional(),
DEFAULT_MEAL_PLAN_PROVIDER: z.enum(["openai", "anthropic", "openrouter", "ollama"]).nullable().optional().describe("Site-wide default for AI meal-plan generation."),
DEFAULT_MEAL_PLAN_MODEL: z.string().nullable().optional(),
}).describe("Unknown keys are silently ignored. Setting a key to null or \"\" deletes it (falls back to env var)."));
const TestEmailBodyRef = registry.register("TestEmailBody", z.object({ to: z.string().min(1) }));
+13 -1
View File
@@ -9,7 +9,13 @@ export type SiteSettingKey =
| "OLLAMA_BASE_URL"
| "NEXT_PUBLIC_VAPID_PUBLIC_KEY"
| "VAPID_PRIVATE_KEY"
| "SIGNUPS_DISABLED";
| "SIGNUPS_DISABLED"
| "DEFAULT_TEXT_PROVIDER"
| "DEFAULT_TEXT_MODEL"
| "DEFAULT_VISION_PROVIDER"
| "DEFAULT_VISION_MODEL"
| "DEFAULT_MEAL_PLAN_PROVIDER"
| "DEFAULT_MEAL_PLAN_MODEL";
const SECRET_KEYS: SiteSettingKey[] = [
"OPENAI_API_KEY",
@@ -46,6 +52,12 @@ export async function getAllSiteSettings(): Promise<Record<string, { value: stri
"OLLAMA_BASE_URL",
"NEXT_PUBLIC_VAPID_PUBLIC_KEY",
"VAPID_PRIVATE_KEY",
"DEFAULT_TEXT_PROVIDER",
"DEFAULT_TEXT_MODEL",
"DEFAULT_VISION_PROVIDER",
"DEFAULT_VISION_MODEL",
"DEFAULT_MEAL_PLAN_PROVIDER",
"DEFAULT_MEAL_PLAN_MODEL",
];
const result: Record<string, { value: string | null; isSecret: boolean; fromDb: boolean }> = {};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@epicure/web",
"version": "0.29.0",
"version": "0.30.0",
"private": true,
"scripts": {
"dev": "next dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "epicure",
"version": "0.29.0",
"version": "0.30.0",
"private": true,
"scripts": {
"dev": "pnpm --filter web dev",