From affa6f5c3d0add8138c2a527abfdd754851af738 Mon Sep 17 00:00:00 2001 From: Arnaud Date: Tue, 14 Jul 2026 13:54:24 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 5 + apps/web/app/admin/settings/page.tsx | 12 ++ apps/web/app/api/v1/admin/settings/route.ts | 6 + .../api/v1/ai/batch-cook/generate/route.ts | 2 +- .../app/api/v1/ai/meal-plan/generate/route.ts | 2 +- apps/web/app/api/v1/ai/scale/route.ts | 2 +- apps/web/app/api/v1/ai/substitute/route.ts | 2 +- .../admin/admin-default-model-form.tsx | 190 ++++++++++++++++++ apps/web/lib/ai/resolve-user-key.ts | 49 +++-- apps/web/lib/changelog.ts | 9 +- apps/web/lib/openapi.ts | 6 + apps/web/lib/site-settings.ts | 14 +- apps/web/package.json | 2 +- package.json | 2 +- 14 files changed, 280 insertions(+), 23 deletions(-) create mode 100644 apps/web/components/admin/admin-default-model-form.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 63d48f1..2040ae8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/apps/web/app/admin/settings/page.tsx b/apps/web/app/admin/settings/page.tsx index 51d6350..c8106d5 100644 --- a/apps/web/app/admin/settings/page.tsx +++ b/apps/web/app/admin/settings/page.tsx @@ -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) => ( ))} + + ); } diff --git a/apps/web/app/api/v1/admin/settings/route.ts b/apps/web/app/api/v1/admin/settings/route.ts index 92e36d3..13995e0 100644 --- a/apps/web/app/api/v1/admin/settings/route.ts +++ b/apps/web/app/api/v1/admin/settings/route.ts @@ -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() { diff --git a/apps/web/app/api/v1/ai/batch-cook/generate/route.ts b/apps/web/app/api/v1/ai/batch-cook/generate/route.ts index d2a725b..aa9064d 100644 --- a/apps/web/app/api/v1/ai/batch-cook/generate/route.ts +++ b/apps/web/app/api/v1/ai/batch-cook/generate/route.ts @@ -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; diff --git a/apps/web/app/api/v1/ai/meal-plan/generate/route.ts b/apps/web/app/api/v1/ai/meal-plan/generate/route.ts index 2a92eb3..b0535ea 100644 --- a/apps/web/app/api/v1/ai/meal-plan/generate/route.ts +++ b/apps/web/app/api/v1/ai/meal-plan/generate/route.ts @@ -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; diff --git a/apps/web/app/api/v1/ai/scale/route.ts b/apps/web/app/api/v1/ai/scale/route.ts index e015124..2a59500 100644 --- a/apps/web/app/api/v1/ai/scale/route.ts +++ b/apps/web/app/api/v1/ai/scale/route.ts @@ -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; diff --git a/apps/web/app/api/v1/ai/substitute/route.ts b/apps/web/app/api/v1/ai/substitute/route.ts index a7bc853..efaae29 100644 --- a/apps/web/app/api/v1/ai/substitute/route.ts +++ b/apps/web/app/api/v1/ai/substitute/route.ts @@ -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; } diff --git a/apps/web/components/admin/admin-default-model-form.tsx b/apps/web/components/admin/admin-default-model-form.tsx new file mode 100644 index 0000000..6e68076 --- /dev/null +++ b/apps/web/components/admin/admin-default-model-form.tsx @@ -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 = { + 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>; + +export function AdminDefaultModelForm({ initialValues }: { initialValues: Values }) { + const [values, setValues] = useState(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 ( +
+
+

Default AI Providers

+

+ 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. +

+
+ +
+ {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 ( +
+
+

{label}

+

{description}

+
+
+
+ + +
+ +
+ + {presets ? ( + + ) : ( + setField(modelSetting, e.target.value)} + disabled={!provider} + /> + )} +
+
+
+ ); + })} +
+ + +
+ ); +} diff --git a/apps/web/lib/ai/resolve-user-key.ts b/apps/web/lib/ai/resolve-user-key.ts index 6d9e509..30bc48d 100644 --- a/apps/web/lib/ai/resolve-user-key.ts +++ b/apps/web/lib/ai/resolve-user-key.ts @@ -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 = { + 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 = { + 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 { +export async function getDefaultProviderWithKey(userId: string, useCase?: ModelUseCase): Promise { 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 k.provider === p); if (row) { @@ -75,20 +88,26 @@ export async function getDefaultProviderWithKey(userId: string): Promise = { - 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[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 {}; } diff --git a/apps/web/lib/changelog.ts b/apps/web/lib/changelog.ts index 55e72b7..fe83aba 100644 --- a/apps/web/lib/changelog.ts +++ b/apps/web/lib/changelog.ts @@ -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", diff --git a/apps/web/lib/openapi.ts b/apps/web/lib/openapi.ts index 1a36f3b..187b7ba 100644 --- a/apps/web/lib/openapi.ts +++ b/apps/web/lib/openapi.ts @@ -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) })); diff --git a/apps/web/lib/site-settings.ts b/apps/web/lib/site-settings.ts index ee0205a..e92b518 100644 --- a/apps/web/lib/site-settings.ts +++ b/apps/web/lib/site-settings.ts @@ -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 = {}; diff --git a/apps/web/package.json b/apps/web/package.json index 5f37df3..4711a8d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@epicure/web", - "version": "0.29.0", + "version": "0.30.0", "private": true, "scripts": { "dev": "next dev", diff --git a/package.json b/package.json index 46e390a..663f2b7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "epicure", - "version": "0.29.0", + "version": "0.30.0", "private": true, "scripts": { "dev": "pnpm --filter web dev",