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} + + + + Provider + { + const val = v === "default" ? null : v; + setField(providerSetting, val); + setField(modelSetting, null); + }} + > + + + + + + Auto (first configured key) + + OpenAI + Anthropic + OpenRouter + Ollama (local) + + + + + + Model + {presets ? ( + setField(modelSetting, v === "default" ? null : v)} + > + + + + + + Provider default + + + {PRESET_MODELS[provider]?.label} + {presets.map((m) => ( + {m.label} + ))} + + + + ) : ( + setField(modelSetting, e.target.value)} + disabled={!provider} + /> + )} + + + + ); + })} + + + { void handleSave(); }} disabled={saving} size="sm"> + {saving ? "Saving…" : "Save"} + + + ); +} 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",
+ 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. +
{label}
{description}