feat(ai): Vercel AI SDK provider factory with BYOK and per-use-case model prefs
Provider factory (OpenAI/Anthropic/OpenRouter/Ollama). generateObject for all outputs. Features: recipe generation, photo-to-recipe vision, variations, drink/meal pairings, ingredient substitution, recipe adaptation, nutrition analysis, URL import, meal plan gen. User BYOK keys (AES-256-GCM). Per-use-case model preferences (text/vision/mealPlan). Site-level key override via admin settings.
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { and, eq } from "@epicure/db";
|
||||
import { db, recipes, recipeIngredients, recipeSteps } from "@epicure/db";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
|
||||
import { adaptRecipe } from "@/lib/ai/features/adapt-recipe";
|
||||
import { withUserKey } from "@/lib/ai/resolve-user-key";
|
||||
|
||||
const Schema = z.object({
|
||||
excludeIngredients: z.array(z.string()).default([]),
|
||||
extraConstraints: z.string().max(500).optional(),
|
||||
provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(),
|
||||
model: z.string().optional(),
|
||||
});
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function POST(req: NextRequest, { params }: Params) {
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return response;
|
||||
|
||||
const { id } = await params;
|
||||
const userId = session!.user.id;
|
||||
|
||||
const recipe = await db.query.recipes.findFirst({
|
||||
where: and(eq(recipes.id, id)),
|
||||
with: {
|
||||
ingredients: { orderBy: (t, { asc }) => asc(t.order) },
|
||||
steps: { orderBy: (t, { asc }) => asc(t.order) },
|
||||
},
|
||||
});
|
||||
if (!recipe || (recipe.visibility === "private" && recipe.authorId !== userId)) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await req.json() as unknown;
|
||||
const parsed = Schema.safeParse(body);
|
||||
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
|
||||
|
||||
if (parsed.data.excludeIngredients.length === 0 && !parsed.data.extraConstraints?.trim()) {
|
||||
return NextResponse.json({ error: "Provide at least one constraint" }, { status: 400 });
|
||||
}
|
||||
|
||||
await checkTierLimit(userId, session!.user.tier as "free" | "pro", "aiCall");
|
||||
|
||||
const aiConfig = await withUserKey(userId, { provider: parsed.data.provider, model: parsed.data.model });
|
||||
const adapted = await adaptRecipe(
|
||||
{
|
||||
title: recipe.title,
|
||||
description: recipe.description,
|
||||
baseServings: recipe.baseServings,
|
||||
ingredients: recipe.ingredients,
|
||||
steps: recipe.steps,
|
||||
},
|
||||
{
|
||||
excludeIngredients: parsed.data.excludeIngredients,
|
||||
extraConstraints: parsed.data.extraConstraints,
|
||||
},
|
||||
aiConfig,
|
||||
(session!.user as { locale?: string }).locale ?? "en"
|
||||
);
|
||||
|
||||
await incrementUsage(userId, "aiCall");
|
||||
|
||||
const newId = crypto.randomUUID();
|
||||
const now = new Date();
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(recipes).values({
|
||||
id: newId,
|
||||
authorId: userId,
|
||||
title: adapted.title,
|
||||
description: adapted.description,
|
||||
baseServings: adapted.baseServings,
|
||||
visibility: "private",
|
||||
difficulty: adapted.difficulty,
|
||||
prepMins: adapted.prepMins,
|
||||
cookMins: adapted.cookMins,
|
||||
dietaryTags: adapted.dietaryTags ?? {},
|
||||
aiGenerated: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
if (adapted.ingredients.length > 0) {
|
||||
await tx.insert(recipeIngredients).values(
|
||||
adapted.ingredients.map((ing, i) => ({
|
||||
id: crypto.randomUUID(),
|
||||
recipeId: newId,
|
||||
rawName: ing.rawName,
|
||||
quantity: ing.quantity !== undefined ? String(ing.quantity) : undefined,
|
||||
unit: ing.unit,
|
||||
note: ing.note,
|
||||
order: i,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
if (adapted.steps.length > 0) {
|
||||
await tx.insert(recipeSteps).values(
|
||||
adapted.steps.map((step, i) => ({
|
||||
id: crypto.randomUUID(),
|
||||
recipeId: newId,
|
||||
instruction: step.instruction,
|
||||
timerSeconds: step.timerSeconds,
|
||||
order: i,
|
||||
}))
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ id: newId, adaptationNotes: adapted.adaptationNotes });
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { and, eq } from "@epicure/db";
|
||||
import { db, recipes } from "@epicure/db";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
|
||||
import { suggestDrinks } from "@/lib/ai/features/suggest-drinks";
|
||||
import { withUserKey } from "@/lib/ai/resolve-user-key";
|
||||
|
||||
const Schema = z.object({
|
||||
count: z.number().int().min(1).max(6).default(4),
|
||||
provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(),
|
||||
model: z.string().optional(),
|
||||
});
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function POST(req: NextRequest, { params }: Params) {
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return response;
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const recipe = await db.query.recipes.findFirst({
|
||||
where: and(eq(recipes.id, id)),
|
||||
with: { ingredients: true },
|
||||
});
|
||||
if (!recipe || (recipe.visibility === "private" && recipe.authorId !== session!.user.id)) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await req.json() as unknown;
|
||||
const parsed = Schema.safeParse(body ?? {});
|
||||
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
|
||||
|
||||
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
|
||||
|
||||
const aiConfig = await withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model });
|
||||
const drinks = await suggestDrinks(
|
||||
{
|
||||
title: recipe.title,
|
||||
description: recipe.description,
|
||||
difficulty: recipe.difficulty,
|
||||
dietaryTags: recipe.dietaryTags as Record<string, boolean> | null,
|
||||
ingredients: recipe.ingredients,
|
||||
},
|
||||
parsed.data.count,
|
||||
aiConfig,
|
||||
(session!.user as { locale?: string }).locale ?? "en"
|
||||
);
|
||||
|
||||
await incrementUsage(session!.user.id, "aiCall");
|
||||
|
||||
return NextResponse.json({ drinks });
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { applyRateLimit } from "@/lib/rate-limit";
|
||||
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
|
||||
import { generateRecipe } from "@/lib/ai/features/generate-recipe";
|
||||
|
||||
const Schema = z.object({
|
||||
prompt: z.string().min(3).max(500),
|
||||
language: z.string().max(10).default("en"),
|
||||
provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(),
|
||||
model: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return response;
|
||||
|
||||
const body = await req.json() as unknown;
|
||||
const parsed = Schema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 10, 60);
|
||||
if (limited) return limited;
|
||||
|
||||
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
|
||||
|
||||
const recipe = await generateRecipe(parsed.data.prompt, {
|
||||
provider: parsed.data.provider,
|
||||
model: parsed.data.model,
|
||||
language: parsed.data.language,
|
||||
});
|
||||
|
||||
await incrementUsage(session!.user.id, "aiCall");
|
||||
|
||||
return NextResponse.json(recipe);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db, recipes, recipeIngredients, recipeSteps } from "@epicure/db";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { applyRateLimit } from "@/lib/rate-limit";
|
||||
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
|
||||
import { importFromPhoto } from "@/lib/ai/features/import-photo";
|
||||
import { getModelConfigForUseCase } from "@/lib/ai/resolve-user-key";
|
||||
|
||||
const Schema = z.object({
|
||||
imageBase64: z.string().max(14_000_000),
|
||||
mimeType: z.enum(["image/jpeg", "image/png", "image/webp"]),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return response;
|
||||
|
||||
const body = await req.json() as unknown;
|
||||
const parsed = Schema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 5, 60);
|
||||
if (limited) return limited;
|
||||
|
||||
try {
|
||||
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "Tier limit reached";
|
||||
return NextResponse.json({ error: msg }, { status: 403 });
|
||||
}
|
||||
|
||||
const userId = session!.user.id;
|
||||
const locale = (session!.user as { locale?: string }).locale ?? "en";
|
||||
|
||||
const aiConfig = await getModelConfigForUseCase(userId, "vision");
|
||||
|
||||
// Fall back to vision-capable defaults if no explicit model configured
|
||||
if (!aiConfig.model) {
|
||||
if (aiConfig.provider === "openai") aiConfig.model = "gpt-4o";
|
||||
else if (aiConfig.provider === "anthropic") aiConfig.model = "claude-sonnet-4-6";
|
||||
}
|
||||
|
||||
let recipe;
|
||||
try {
|
||||
recipe = await importFromPhoto(parsed.data.imageBase64, parsed.data.mimeType, aiConfig, locale);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "AI analysis failed";
|
||||
return NextResponse.json({ error: msg }, { status: 502 });
|
||||
}
|
||||
|
||||
await incrementUsage(userId, "aiCall");
|
||||
|
||||
const newRecipeId = crypto.randomUUID();
|
||||
const now = new Date();
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(recipes).values({
|
||||
id: newRecipeId,
|
||||
authorId: userId,
|
||||
title: recipe.title,
|
||||
description: recipe.description,
|
||||
baseServings: recipe.baseServings ?? 4,
|
||||
visibility: "private",
|
||||
difficulty: recipe.difficulty,
|
||||
prepMins: recipe.prepMins,
|
||||
cookMins: recipe.cookMins,
|
||||
dietaryTags: recipe.dietaryTags ?? {},
|
||||
aiGenerated: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
if (recipe.ingredients.length > 0) {
|
||||
await tx.insert(recipeIngredients).values(
|
||||
recipe.ingredients.map((ing, i) => ({
|
||||
id: crypto.randomUUID(),
|
||||
recipeId: newRecipeId,
|
||||
rawName: ing.rawName,
|
||||
quantity: ing.quantity !== undefined ? String(ing.quantity) : undefined,
|
||||
unit: ing.unit,
|
||||
note: ing.note,
|
||||
order: i,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
if (recipe.steps.length > 0) {
|
||||
await tx.insert(recipeSteps).values(
|
||||
recipe.steps.map((step, i) => ({
|
||||
id: crypto.randomUUID(),
|
||||
recipeId: newRecipeId,
|
||||
instruction: step.instruction,
|
||||
timerSeconds: step.timerSeconds,
|
||||
order: i,
|
||||
}))
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ id: newRecipeId });
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { applyRateLimit } from "@/lib/rate-limit";
|
||||
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
|
||||
import { importFromUrl } from "@/lib/ai/features/import-url";
|
||||
|
||||
const Schema = z.object({
|
||||
url: z.string().url(),
|
||||
provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(),
|
||||
model: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return response;
|
||||
|
||||
const body = await req.json() as unknown;
|
||||
const parsed = Schema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 10, 60);
|
||||
if (limited) return limited;
|
||||
|
||||
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
|
||||
|
||||
const recipe = await importFromUrl(parsed.data.url, {
|
||||
provider: parsed.data.provider,
|
||||
model: parsed.data.model,
|
||||
});
|
||||
|
||||
await incrementUsage(session!.user.id, "aiCall");
|
||||
|
||||
return NextResponse.json(recipe);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db, recipes, recipeIngredients, recipeSteps, mealPlans, mealPlanEntries, pantryItems, eq, and } from "@epicure/db";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { applyRateLimit } from "@/lib/rate-limit";
|
||||
import { getDefaultProviderWithKey } from "@/lib/ai/resolve-user-key";
|
||||
import { generateMealPlan } from "@/lib/ai/features/generate-meal-plan";
|
||||
|
||||
const DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
|
||||
|
||||
const Schema = z.object({
|
||||
weekStart: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
dietaryPrefs: z.string().max(200).optional(),
|
||||
servings: z.number().int().min(1).max(20).default(2),
|
||||
days: z.array(z.enum(DAYS)).min(1).max(7).default([...DAYS]),
|
||||
usePantry: z.boolean().default(false),
|
||||
pantryMode: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return response;
|
||||
|
||||
const body = await req.json() as unknown;
|
||||
const parsed = Schema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 3, 60);
|
||||
if (limited) return limited;
|
||||
|
||||
const userId = session!.user.id;
|
||||
const locale = (session!.user as { locale?: string }).locale ?? "en";
|
||||
const config = await getDefaultProviderWithKey(userId);
|
||||
|
||||
// pantryMode forces usePantry on so pantry items are always fetched when maximizing pantry use
|
||||
const effectiveUsePantry = parsed.data.usePantry || parsed.data.pantryMode;
|
||||
|
||||
// Optionally fetch pantry items
|
||||
let pantryItemNames: string[] = [];
|
||||
if (effectiveUsePantry) {
|
||||
const pantry = await db
|
||||
.select({ rawName: pantryItems.rawName })
|
||||
.from(pantryItems)
|
||||
.where(eq(pantryItems.userId, userId));
|
||||
pantryItemNames = pantry.map((p) => p.rawName);
|
||||
}
|
||||
|
||||
const plan = await generateMealPlan(
|
||||
{
|
||||
dietaryPrefs: parsed.data.dietaryPrefs,
|
||||
servings: parsed.data.servings,
|
||||
pantryItems: pantryItemNames,
|
||||
days: parsed.data.days,
|
||||
pantryMode: parsed.data.pantryMode,
|
||||
},
|
||||
config,
|
||||
locale
|
||||
);
|
||||
|
||||
// Ensure meal plan row exists for the week
|
||||
let mealPlan = await db.query.mealPlans.findFirst({
|
||||
where: and(eq(mealPlans.userId, userId), eq(mealPlans.weekStart, parsed.data.weekStart)),
|
||||
});
|
||||
|
||||
if (!mealPlan) {
|
||||
const planId = crypto.randomUUID();
|
||||
await db.insert(mealPlans).values({ id: planId, userId, weekStart: parsed.data.weekStart });
|
||||
mealPlan = { id: planId, userId, weekStart: parsed.data.weekStart, createdAt: new Date() };
|
||||
}
|
||||
|
||||
const createdEntries: Array<{ id: string; day: string; mealType: string; recipeId: string; recipeTitle: string }> = [];
|
||||
|
||||
for (const entry of plan.entries) {
|
||||
// Create draft recipe
|
||||
const recipeId = crypto.randomUUID();
|
||||
await db.insert(recipes).values({
|
||||
id: recipeId,
|
||||
authorId: userId,
|
||||
title: entry.recipe.title,
|
||||
description: entry.recipe.description,
|
||||
baseServings: entry.servings,
|
||||
visibility: "private",
|
||||
aiGenerated: true,
|
||||
difficulty: entry.recipe.difficulty ?? null,
|
||||
prepMins: entry.recipe.prepMins ?? null,
|
||||
cookMins: entry.recipe.cookMins ?? null,
|
||||
});
|
||||
|
||||
if (entry.recipe.ingredients.length > 0) {
|
||||
await db.insert(recipeIngredients).values(
|
||||
entry.recipe.ingredients.map((ing, i) => ({
|
||||
id: crypto.randomUUID(),
|
||||
recipeId,
|
||||
rawName: ing.rawName,
|
||||
quantity: ing.quantity ?? null,
|
||||
unit: ing.unit ?? null,
|
||||
order: i,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
if (entry.recipe.steps.length > 0) {
|
||||
await db.insert(recipeSteps).values(
|
||||
entry.recipe.steps.map((step, i) => ({
|
||||
id: crypto.randomUUID(),
|
||||
recipeId,
|
||||
instruction: step.instruction,
|
||||
order: i,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// Remove any existing entry for this day+mealType, then insert new
|
||||
const existingEntry = await db.query.mealPlanEntries.findFirst({
|
||||
where: and(
|
||||
eq(mealPlanEntries.mealPlanId, mealPlan!.id),
|
||||
eq(mealPlanEntries.day, entry.day),
|
||||
eq(mealPlanEntries.mealType, entry.mealType)
|
||||
),
|
||||
});
|
||||
|
||||
if (existingEntry) {
|
||||
await db.delete(mealPlanEntries).where(eq(mealPlanEntries.id, existingEntry.id));
|
||||
}
|
||||
|
||||
const entryId = crypto.randomUUID();
|
||||
await db.insert(mealPlanEntries).values({
|
||||
id: entryId,
|
||||
mealPlanId: mealPlan!.id,
|
||||
day: entry.day,
|
||||
mealType: entry.mealType,
|
||||
recipeId,
|
||||
servings: entry.servings,
|
||||
});
|
||||
|
||||
createdEntries.push({ id: entryId, day: entry.day, mealType: entry.mealType, recipeId, recipeTitle: entry.recipe.title });
|
||||
}
|
||||
|
||||
return NextResponse.json({ weekStart: parsed.data.weekStart, entries: createdEntries });
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { and, eq } from "@epicure/db";
|
||||
import { db, recipes } from "@epicure/db";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
|
||||
import { suggestPairings } from "@/lib/ai/features/suggest-pairings";
|
||||
import { withUserKey } from "@/lib/ai/resolve-user-key";
|
||||
|
||||
const Schema = z.object({
|
||||
count: z.number().int().min(1).max(6).default(4),
|
||||
provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(),
|
||||
model: z.string().optional(),
|
||||
});
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function POST(req: NextRequest, { params }: Params) {
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return response;
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
// Allow pairings for own recipes or public recipes
|
||||
const recipe = await db.query.recipes.findFirst({
|
||||
where: and(eq(recipes.id, id)),
|
||||
with: { ingredients: true },
|
||||
});
|
||||
if (!recipe || (recipe.visibility === "private" && recipe.authorId !== session!.user.id)) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await req.json() as unknown;
|
||||
const parsed = Schema.safeParse(body ?? {});
|
||||
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
|
||||
|
||||
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
|
||||
|
||||
const aiConfig = await withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model });
|
||||
const pairings = await suggestPairings(
|
||||
{
|
||||
title: recipe.title,
|
||||
description: recipe.description,
|
||||
difficulty: recipe.difficulty,
|
||||
dietaryTags: recipe.dietaryTags as Record<string, boolean> | null,
|
||||
ingredients: recipe.ingredients,
|
||||
},
|
||||
parsed.data.count,
|
||||
aiConfig,
|
||||
(session!.user as { locale?: string }).locale ?? "en"
|
||||
);
|
||||
|
||||
await incrementUsage(session!.user.id, "aiCall");
|
||||
|
||||
return NextResponse.json({ pairings });
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db, recipes, eq, and, or } from "@epicure/db";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { applyRateLimit } from "@/lib/rate-limit";
|
||||
import { getDefaultProviderWithKey } from "@/lib/ai/resolve-user-key";
|
||||
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
|
||||
import { scaleRecipe } from "@/lib/ai/features/scale-recipe";
|
||||
|
||||
const Schema = z.object({
|
||||
recipeId: z.string(),
|
||||
targetServings: z.number().int().min(1).max(100),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return response;
|
||||
|
||||
const limited = await applyRateLimit(`rl:ai:scale:${session!.user.id}`, 20, 60);
|
||||
if (limited) return limited;
|
||||
|
||||
const body = await req.json() as unknown;
|
||||
const parsed = Schema.safeParse(body);
|
||||
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
|
||||
|
||||
const { recipeId, targetServings } = parsed.data;
|
||||
|
||||
const recipe = await db.query.recipes.findFirst({
|
||||
where: and(
|
||||
eq(recipes.id, recipeId),
|
||||
or(
|
||||
eq(recipes.authorId, session!.user.id),
|
||||
eq(recipes.visibility, "public")
|
||||
)
|
||||
),
|
||||
with: { ingredients: true },
|
||||
});
|
||||
|
||||
if (!recipe) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
|
||||
|
||||
const aiConfig = await getDefaultProviderWithKey(session!.user.id);
|
||||
|
||||
const scaledIngredients = await scaleRecipe(
|
||||
{
|
||||
title: recipe.title,
|
||||
baseServings: recipe.baseServings,
|
||||
ingredients: recipe.ingredients,
|
||||
},
|
||||
targetServings,
|
||||
recipe.baseServings,
|
||||
aiConfig,
|
||||
(session!.user as { locale?: string }).locale ?? "en"
|
||||
);
|
||||
|
||||
await incrementUsage(session!.user.id, "aiCall");
|
||||
|
||||
return NextResponse.json({ ingredients: scaledIngredients });
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
|
||||
import { substituteIngredient } from "@/lib/ai/features/substitute-ingredient";
|
||||
|
||||
const Schema = z.object({
|
||||
ingredient: z.string().min(1).max(200),
|
||||
recipeTitle: z.string().max(200).optional(),
|
||||
provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(),
|
||||
model: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return response;
|
||||
|
||||
const body = await req.json() as unknown;
|
||||
const parsed = Schema.safeParse(body);
|
||||
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
|
||||
|
||||
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
|
||||
|
||||
const context = parsed.data.recipeTitle
|
||||
? `recipe "${parsed.data.recipeTitle}"`
|
||||
: "a general recipe";
|
||||
|
||||
const substitutions = await substituteIngredient(
|
||||
parsed.data.ingredient,
|
||||
context,
|
||||
{ provider: parsed.data.provider, model: parsed.data.model }
|
||||
);
|
||||
|
||||
await incrementUsage(session!.user.id, "aiCall");
|
||||
return NextResponse.json({ substitutions });
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { and, eq } from "@epicure/db";
|
||||
import { db, recipes, recipeIngredients, recipeSteps } from "@epicure/db";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
|
||||
import { translateRecipe } from "@/lib/ai/features/translate-recipe";
|
||||
|
||||
const Schema = z.object({
|
||||
targetLanguage: z.string().min(2).max(50),
|
||||
provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(),
|
||||
model: z.string().optional(),
|
||||
});
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function POST(req: NextRequest, { params }: Params) {
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return response;
|
||||
|
||||
const { id } = await params;
|
||||
const recipe = await db.query.recipes.findFirst({
|
||||
where: and(eq(recipes.id, id), eq(recipes.authorId, session!.user.id)),
|
||||
with: {
|
||||
ingredients: { orderBy: (t, { asc }) => asc(t.order) },
|
||||
steps: { orderBy: (t, { asc }) => asc(t.order) },
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipe) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
const body = await req.json() as unknown;
|
||||
const parsed = Schema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
|
||||
|
||||
const translation = await translateRecipe(
|
||||
{
|
||||
title: recipe.title,
|
||||
description: recipe.description,
|
||||
ingredients: recipe.ingredients,
|
||||
steps: recipe.steps,
|
||||
},
|
||||
parsed.data.targetLanguage,
|
||||
{ provider: parsed.data.provider, model: parsed.data.model }
|
||||
);
|
||||
|
||||
await incrementUsage(session!.user.id, "aiCall");
|
||||
|
||||
// Save as new draft recipe
|
||||
const newId = crypto.randomUUID();
|
||||
const now = new Date();
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(recipes).values({
|
||||
id: newId,
|
||||
authorId: session!.user.id,
|
||||
title: translation.title,
|
||||
description: translation.description,
|
||||
baseServings: recipe.baseServings,
|
||||
visibility: "private",
|
||||
difficulty: recipe.difficulty,
|
||||
prepMins: recipe.prepMins,
|
||||
cookMins: recipe.cookMins,
|
||||
dietaryTags: recipe.dietaryTags ?? {},
|
||||
aiGenerated: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
if (translation.ingredients.length > 0) {
|
||||
await tx.insert(recipeIngredients).values(
|
||||
translation.ingredients.map((ing, i) => ({
|
||||
id: crypto.randomUUID(),
|
||||
recipeId: newId,
|
||||
rawName: ing.rawName,
|
||||
quantity: recipe.ingredients[i]?.quantity ?? undefined,
|
||||
unit: recipe.ingredients[i]?.unit ?? undefined,
|
||||
note: ing.note ?? undefined,
|
||||
order: i,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
if (translation.steps.length > 0) {
|
||||
await tx.insert(recipeSteps).values(
|
||||
translation.steps.map((step, i) => ({
|
||||
id: crypto.randomUUID(),
|
||||
recipeId: newId,
|
||||
instruction: step.instruction,
|
||||
timerSeconds: recipe.steps[i]?.timerSeconds ?? undefined,
|
||||
order: i,
|
||||
}))
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ id: newId });
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { and, eq } from "@epicure/db";
|
||||
import { db, recipes } from "@epicure/db";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
|
||||
import { suggestVariations } from "@/lib/ai/features/suggest-variations";
|
||||
import { withUserKey } from "@/lib/ai/resolve-user-key";
|
||||
|
||||
const Schema = z.object({
|
||||
count: z.number().int().min(1).max(5).default(3),
|
||||
directions: z.string().max(500).optional(),
|
||||
provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(),
|
||||
model: z.string().optional(),
|
||||
});
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function POST(req: NextRequest, { params }: Params) {
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return response;
|
||||
|
||||
const { id } = await params;
|
||||
const recipe = await db.query.recipes.findFirst({
|
||||
where: and(eq(recipes.id, id), eq(recipes.authorId, session!.user.id)),
|
||||
with: {
|
||||
ingredients: { orderBy: (t, { asc }) => asc(t.order) },
|
||||
steps: { orderBy: (t, { asc }) => asc(t.order) },
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipe) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
const body = await req.json() as unknown;
|
||||
const parsed = Schema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
|
||||
|
||||
const aiConfig = await withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model });
|
||||
const variations = await suggestVariations(
|
||||
{
|
||||
title: recipe.title,
|
||||
description: recipe.description,
|
||||
ingredients: recipe.ingredients,
|
||||
steps: recipe.steps,
|
||||
},
|
||||
parsed.data.count,
|
||||
aiConfig,
|
||||
parsed.data.directions,
|
||||
(session!.user as { locale?: string }).locale ?? "en"
|
||||
);
|
||||
|
||||
await incrementUsage(session!.user.id, "aiCall");
|
||||
|
||||
return NextResponse.json({ variations });
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { createOpenAI } from "@ai-sdk/openai";
|
||||
import { createAnthropic } from "@ai-sdk/anthropic";
|
||||
import type { LanguageModel } from "ai";
|
||||
|
||||
export type AiProvider = "openai" | "anthropic" | "openrouter" | "ollama";
|
||||
|
||||
export type AiConfig = {
|
||||
provider?: AiProvider;
|
||||
model?: string;
|
||||
apiKey?: string;
|
||||
};
|
||||
|
||||
export function resolveModel(config: AiConfig = {}): LanguageModel {
|
||||
const provider = config.provider ?? getDefaultProvider();
|
||||
|
||||
switch (provider) {
|
||||
case "openai": {
|
||||
const openai = createOpenAI({ apiKey: config.apiKey ?? process.env["OPENAI_API_KEY"] });
|
||||
return openai(config.model ?? "gpt-4o-mini");
|
||||
}
|
||||
case "anthropic": {
|
||||
const anthropic = createAnthropic({ apiKey: config.apiKey ?? process.env["ANTHROPIC_API_KEY"] });
|
||||
return anthropic(config.model ?? "claude-haiku-4-5-20251001");
|
||||
}
|
||||
case "openrouter": {
|
||||
const openrouter = createOpenAI({
|
||||
apiKey: config.apiKey ?? process.env["OPENROUTER_API_KEY"],
|
||||
baseURL: "https://openrouter.ai/api/v1",
|
||||
});
|
||||
return openrouter(config.model ?? process.env["OPENROUTER_DEFAULT_MODEL"] ?? "google/gemini-flash-1.5");
|
||||
}
|
||||
case "ollama": {
|
||||
const ollama = createOpenAI({
|
||||
apiKey: "ollama",
|
||||
baseURL: process.env["OLLAMA_BASE_URL"] ?? "http://localhost:11434/v1",
|
||||
});
|
||||
return ollama(config.model ?? "llama3.2");
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unknown AI provider: ${String(provider)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultProvider(): AiProvider {
|
||||
if (process.env["OPENROUTER_API_KEY"]) return "openrouter";
|
||||
if (process.env["OPENAI_API_KEY"]) return "openai";
|
||||
if (process.env["ANTHROPIC_API_KEY"]) return "anthropic";
|
||||
return "ollama";
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { generateObject } from "ai";
|
||||
import { z } from "zod";
|
||||
import { resolveModel, type AiConfig } from "../factory";
|
||||
|
||||
const AdaptedRecipeSchema = z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
baseServings: z.number().int().min(1).max(100),
|
||||
prepMins: z.number().int().min(0).optional(),
|
||||
cookMins: z.number().int().min(0).optional(),
|
||||
difficulty: z.enum(["easy", "medium", "hard"]),
|
||||
dietaryTags: z.object({
|
||||
vegan: z.boolean().optional(),
|
||||
vegetarian: z.boolean().optional(),
|
||||
glutenFree: z.boolean().optional(),
|
||||
dairyFree: z.boolean().optional(),
|
||||
nutFree: z.boolean().optional(),
|
||||
halal: z.boolean().optional(),
|
||||
kosher: z.boolean().optional(),
|
||||
}),
|
||||
ingredients: z.array(z.object({
|
||||
rawName: z.string(),
|
||||
quantity: z.number().optional(),
|
||||
unit: z.string().optional(),
|
||||
note: z.string().optional(),
|
||||
})),
|
||||
steps: z.array(z.object({
|
||||
instruction: z.string(),
|
||||
timerSeconds: z.number().int().optional(),
|
||||
})),
|
||||
adaptationNotes: z.string(),
|
||||
});
|
||||
|
||||
export type AdaptedRecipe = z.infer<typeof AdaptedRecipeSchema>;
|
||||
|
||||
const LANG: Record<string, string> = { en: "English", fr: "French" };
|
||||
|
||||
export async function adaptRecipe(
|
||||
recipe: {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
baseServings: number;
|
||||
ingredients: Array<{ rawName: string; quantity?: string | number | null; unit?: string | null }>;
|
||||
steps: Array<{ instruction: string }>;
|
||||
},
|
||||
constraints: {
|
||||
excludeIngredients: string[];
|
||||
extraConstraints?: string;
|
||||
},
|
||||
config?: AiConfig,
|
||||
locale?: string
|
||||
): Promise<AdaptedRecipe> {
|
||||
const model = resolveModel(config);
|
||||
const lang = LANG[locale ?? "en"] ?? "English";
|
||||
|
||||
const recipeText = [
|
||||
`Title: ${recipe.title}`,
|
||||
recipe.description ? `Description: ${recipe.description}` : "",
|
||||
`Servings: ${recipe.baseServings}`,
|
||||
`Ingredients:\n${recipe.ingredients.map((i) => ` - ${i.quantity ? `${i.quantity} ` : ""}${i.unit ? `${i.unit} ` : ""}${i.rawName}`).join("\n")}`,
|
||||
`Steps:\n${recipe.steps.map((s, i) => ` ${i + 1}. ${s.instruction}`).join("\n")}`,
|
||||
].filter(Boolean).join("\n");
|
||||
|
||||
const constraintText = [
|
||||
constraints.excludeIngredients.length > 0
|
||||
? `MUST NOT contain or use: ${constraints.excludeIngredients.join(", ")}. Find suitable substitutes that preserve the dish character.`
|
||||
: "",
|
||||
constraints.extraConstraints?.trim()
|
||||
? `Additional constraints: ${constraints.extraConstraints}`
|
||||
: "",
|
||||
].filter(Boolean).join("\n");
|
||||
|
||||
const { object } = await generateObject({
|
||||
model,
|
||||
schema: AdaptedRecipeSchema,
|
||||
system:
|
||||
`You are a professional chef specializing in recipe adaptation. When adapting recipes, find the best substitutes that preserve the spirit, flavor profile, and texture of the original. Always explain what changed and why in adaptationNotes. For ingredient quantities: use numbers only, units separately. Respond in ${lang}.`,
|
||||
prompt: `Adapt the following recipe with these constraints:\n\n${constraintText}\n\nOriginal recipe:\n${recipeText}`,
|
||||
});
|
||||
|
||||
return object;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { generateObject } from "ai";
|
||||
import { z } from "zod";
|
||||
import { resolveModel, type AiConfig } from "../factory";
|
||||
|
||||
const NutritionSchema = z.object({
|
||||
perServing: z.object({
|
||||
calories: z.number().describe("Total calories per serving (kcal)"),
|
||||
proteinG: z.number().describe("Protein in grams per serving"),
|
||||
carbsG: z.number().describe("Total carbohydrates in grams per serving"),
|
||||
fatG: z.number().describe("Total fat in grams per serving"),
|
||||
fiberG: z.number().describe("Dietary fiber in grams per serving"),
|
||||
sodiumMg: z.number().describe("Sodium in milligrams per serving"),
|
||||
}),
|
||||
});
|
||||
|
||||
export type NutritionEstimate = z.infer<typeof NutritionSchema>;
|
||||
|
||||
export async function estimateNutrition(
|
||||
recipe: {
|
||||
title: string;
|
||||
baseServings: number;
|
||||
ingredients: Array<{
|
||||
rawName: string;
|
||||
quantity?: string | number | null;
|
||||
unit?: string | null;
|
||||
}>;
|
||||
},
|
||||
config?: AiConfig
|
||||
): Promise<NutritionEstimate> {
|
||||
const model = resolveModel(config);
|
||||
|
||||
const ingredientList = recipe.ingredients
|
||||
.map((i) => {
|
||||
const parts: string[] = [];
|
||||
if (i.quantity) parts.push(String(i.quantity));
|
||||
if (i.unit) parts.push(i.unit);
|
||||
parts.push(i.rawName);
|
||||
return `- ${parts.join(" ")}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const { object } = await generateObject({
|
||||
model,
|
||||
schema: NutritionSchema,
|
||||
system:
|
||||
"You are an expert nutritionist with deep knowledge of food composition and nutritional values. Estimate nutrition data accurately based on ingredients, quantities, and typical cooking methods. When quantities are not specified, use typical serving amounts. Provide realistic estimates based on standard nutritional databases.",
|
||||
prompt: `Estimate the nutritional content per serving for the following recipe.
|
||||
|
||||
Recipe: ${recipe.title}
|
||||
Servings: ${recipe.baseServings}
|
||||
|
||||
Ingredients:
|
||||
${ingredientList}
|
||||
|
||||
Calculate nutrition values per single serving (total divided by ${recipe.baseServings} servings).`,
|
||||
});
|
||||
|
||||
return object;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { generateObject } from "ai";
|
||||
import { z } from "zod";
|
||||
import { resolveModel, type AiConfig } from "../factory";
|
||||
|
||||
const MealPlanSchema = z.object({
|
||||
entries: z.array(z.object({
|
||||
day: z.enum(["mon", "tue", "wed", "thu", "fri", "sat", "sun"]),
|
||||
mealType: z.enum(["breakfast", "lunch", "dinner"]),
|
||||
recipe: z.object({
|
||||
title: z.string().max(150),
|
||||
description: z.string().max(300),
|
||||
ingredients: z.array(z.object({
|
||||
rawName: z.string(),
|
||||
quantity: z.string().optional(),
|
||||
unit: z.string().optional(),
|
||||
})).max(20),
|
||||
steps: z.array(z.object({
|
||||
instruction: z.string(),
|
||||
})).max(12),
|
||||
prepMins: z.number().int().min(0).max(180).optional(),
|
||||
cookMins: z.number().int().min(0).max(360).optional(),
|
||||
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
|
||||
}),
|
||||
servings: z.number().int().min(1).max(20),
|
||||
})),
|
||||
});
|
||||
|
||||
export type GeneratedMealPlan = z.infer<typeof MealPlanSchema>;
|
||||
|
||||
const LANG: Record<string, string> = { en: "English", fr: "French" };
|
||||
|
||||
export async function generateMealPlan(
|
||||
options: {
|
||||
dietaryPrefs?: string;
|
||||
servings?: number;
|
||||
pantryItems?: string[];
|
||||
days?: Array<"mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun">;
|
||||
pantryMode?: boolean;
|
||||
},
|
||||
config?: AiConfig,
|
||||
locale?: string
|
||||
): Promise<GeneratedMealPlan> {
|
||||
const model = resolveModel(config);
|
||||
const lang = LANG[locale ?? "en"] ?? "English";
|
||||
const servings = options.servings ?? 2;
|
||||
const days = options.days ?? ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
|
||||
const pantryMode = options.pantryMode ?? false;
|
||||
|
||||
const dietaryClause = options.dietaryPrefs?.trim()
|
||||
? `Dietary requirements: ${options.dietaryPrefs.trim()}.`
|
||||
: "";
|
||||
const pantryClause =
|
||||
options.pantryItems && options.pantryItems.length > 0
|
||||
? `Available pantry ingredients to use: ${options.pantryItems.slice(0, 20).join(", ")}.`
|
||||
: "";
|
||||
|
||||
const systemPrompt = pantryMode
|
||||
? `You are a professional nutritionist and chef. Generate a meal plan that maximizes use of the provided pantry items. Prefer meals using multiple pantry ingredients. Minimize additional shopping required. Vary cuisines and cooking methods where possible. Make meals achievable for home cooks. Respond in ${lang}.`
|
||||
: `You are a professional nutritionist and chef. Generate balanced, practical, and delicious weekly meal plans. Vary cuisines and cooking methods throughout the week. Make meals achievable for home cooks. Respond in ${lang}.`;
|
||||
|
||||
const { object } = await generateObject({
|
||||
model,
|
||||
schema: MealPlanSchema,
|
||||
system: systemPrompt,
|
||||
prompt: [
|
||||
`Generate a meal plan for ${days.length} day(s): ${days.join(", ")}.`,
|
||||
`Include breakfast, lunch, and dinner for each day.`,
|
||||
`Plan for ${servings} servings per meal.`,
|
||||
dietaryClause,
|
||||
pantryClause,
|
||||
pantryMode
|
||||
? "Prioritize using the listed pantry ingredients across as many meals as possible. Minimize ingredients that need to be purchased."
|
||||
: "Ensure nutritional balance across the week. Vary ingredients and cooking styles.",
|
||||
].filter(Boolean).join(" "),
|
||||
});
|
||||
|
||||
return object;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { generateObject } from "ai";
|
||||
import { z } from "zod";
|
||||
import { resolveModel, type AiConfig } from "../factory";
|
||||
|
||||
const RecipeOutputSchema = z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
baseServings: z.number().int().min(1).max(100),
|
||||
prepMins: z.number().int().min(0).optional(),
|
||||
cookMins: z.number().int().min(0).optional(),
|
||||
difficulty: z.enum(["easy", "medium", "hard"]),
|
||||
dietaryTags: z.object({
|
||||
vegan: z.boolean().optional(),
|
||||
vegetarian: z.boolean().optional(),
|
||||
glutenFree: z.boolean().optional(),
|
||||
dairyFree: z.boolean().optional(),
|
||||
nutFree: z.boolean().optional(),
|
||||
halal: z.boolean().optional(),
|
||||
kosher: z.boolean().optional(),
|
||||
}),
|
||||
ingredients: z.array(z.object({
|
||||
rawName: z.string(),
|
||||
quantity: z.number().optional(),
|
||||
unit: z.string().optional(),
|
||||
note: z.string().optional(),
|
||||
})),
|
||||
steps: z.array(z.object({
|
||||
instruction: z.string(),
|
||||
timerSeconds: z.number().int().optional(),
|
||||
})),
|
||||
});
|
||||
|
||||
export type GeneratedRecipe = z.infer<typeof RecipeOutputSchema>;
|
||||
|
||||
export async function generateRecipe(
|
||||
prompt: string,
|
||||
config?: AiConfig & { language?: string }
|
||||
): Promise<GeneratedRecipe> {
|
||||
const model = resolveModel(config);
|
||||
const lang = config?.language ?? "en";
|
||||
const langInstruction = lang !== "en" ? ` Write the entire recipe (title, description, ingredient names, step instructions) in ${lang}.` : "";
|
||||
|
||||
const { object } = await generateObject({
|
||||
model,
|
||||
schema: RecipeOutputSchema,
|
||||
system:
|
||||
`You are a professional chef and culinary writer. Generate detailed, accurate, and delicious recipes. For ingredients: quantity must be a number only (e.g. 0.25, 1.5, 2), unit is a separate string (e.g. 'cup', 'tbsp', 'g', 'ml'). Never combine quantity and unit into one string. Include helpful notes for ingredients when relevant.${langInstruction}`,
|
||||
prompt: `Create a recipe for: ${prompt}`,
|
||||
});
|
||||
|
||||
return object;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { generateObject } from "ai";
|
||||
import { z } from "zod";
|
||||
import { resolveModel, type AiConfig } from "../factory";
|
||||
|
||||
const ImportedRecipeSchema = z.object({
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
baseServings: z.number().int().min(1).optional(),
|
||||
prepMins: z.number().int().min(0).optional(),
|
||||
cookMins: z.number().int().min(0).optional(),
|
||||
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
|
||||
dietaryTags: z.object({
|
||||
vegan: z.boolean().optional(),
|
||||
vegetarian: z.boolean().optional(),
|
||||
glutenFree: z.boolean().optional(),
|
||||
dairyFree: z.boolean().optional(),
|
||||
nutFree: z.boolean().optional(),
|
||||
halal: z.boolean().optional(),
|
||||
kosher: z.boolean().optional(),
|
||||
}).optional(),
|
||||
ingredients: z.array(z.object({
|
||||
rawName: z.string(),
|
||||
quantity: z.string().optional(),
|
||||
unit: z.string().optional(),
|
||||
note: z.string().optional(),
|
||||
})),
|
||||
steps: z.array(z.object({
|
||||
instruction: z.string(),
|
||||
timerSeconds: z.number().int().optional(),
|
||||
})),
|
||||
});
|
||||
|
||||
export type ImportedRecipe = z.infer<typeof ImportedRecipeSchema>;
|
||||
|
||||
export async function importFromPhoto(
|
||||
imageBase64: string,
|
||||
mimeType: "image/jpeg" | "image/png" | "image/webp",
|
||||
config?: AiConfig,
|
||||
_locale?: string,
|
||||
): Promise<ImportedRecipe> {
|
||||
const model = resolveModel(config);
|
||||
|
||||
const { object } = await generateObject({
|
||||
model,
|
||||
schema: ImportedRecipeSchema,
|
||||
system:
|
||||
"You are a recipe extraction specialist. Extract the complete recipe from the provided image. Be precise with ingredient quantities and cooking instructions.",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "image", image: imageBase64, mimeType },
|
||||
{ type: "text", text: "Extract the complete recipe from this image." },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return object;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { generateObject } from "ai";
|
||||
import { z } from "zod";
|
||||
import { resolveModel, type AiConfig } from "../factory";
|
||||
|
||||
const ImportedRecipeSchema = z.object({
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
baseServings: z.number().int().min(1).optional(),
|
||||
prepMins: z.number().int().min(0).optional(),
|
||||
cookMins: z.number().int().min(0).optional(),
|
||||
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
|
||||
dietaryTags: z.object({
|
||||
vegan: z.boolean().optional(),
|
||||
vegetarian: z.boolean().optional(),
|
||||
glutenFree: z.boolean().optional(),
|
||||
dairyFree: z.boolean().optional(),
|
||||
nutFree: z.boolean().optional(),
|
||||
halal: z.boolean().optional(),
|
||||
kosher: z.boolean().optional(),
|
||||
}).optional(),
|
||||
ingredients: z.array(z.object({
|
||||
rawName: z.string(),
|
||||
quantity: z.string().optional(),
|
||||
unit: z.string().optional(),
|
||||
note: z.string().optional(),
|
||||
})),
|
||||
steps: z.array(z.object({
|
||||
instruction: z.string(),
|
||||
timerSeconds: z.number().int().optional(),
|
||||
})),
|
||||
});
|
||||
|
||||
export type ImportedRecipe = z.infer<typeof ImportedRecipeSchema>;
|
||||
|
||||
export async function importFromUrl(url: string, config?: AiConfig): Promise<ImportedRecipe> {
|
||||
const res = await fetch(url, {
|
||||
headers: { "User-Agent": "Mozilla/5.0 (compatible; Epicure/1.0; recipe importer)" },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Failed to fetch URL: ${res.status}`);
|
||||
|
||||
const html = await res.text();
|
||||
// strip scripts/styles, take first 30k chars to stay within context
|
||||
const cleaned = html
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.slice(0, 30000);
|
||||
|
||||
const model = resolveModel(config);
|
||||
|
||||
const { object } = await generateObject({
|
||||
model,
|
||||
schema: ImportedRecipeSchema,
|
||||
system:
|
||||
"You are an expert at extracting structured recipe data from web page text. Extract all recipe information accurately. For ingredients, separate quantity, unit, and name. For steps, extract timer durations in seconds when mentioned.",
|
||||
prompt: `Extract the recipe from this web page:\n\nURL: ${url}\n\nContent:\n${cleaned}`,
|
||||
});
|
||||
|
||||
return object;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { generateObject } from "ai";
|
||||
import { z } from "zod";
|
||||
import { resolveModel, type AiConfig } from "../factory";
|
||||
|
||||
const DietaryOutputSchema = z.object({
|
||||
vegan: z.boolean(),
|
||||
vegetarian: z.boolean(),
|
||||
glutenFree: z.boolean(),
|
||||
dairyFree: z.boolean(),
|
||||
nutFree: z.boolean(),
|
||||
halal: z.boolean(),
|
||||
kosher: z.boolean(),
|
||||
reasoning: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
export type InferredDietaryTags = Omit<z.infer<typeof DietaryOutputSchema>, "reasoning">;
|
||||
|
||||
export async function inferDietaryTags(
|
||||
ingredients: Array<{ rawName: string; unit?: string | null }>,
|
||||
config?: AiConfig
|
||||
): Promise<InferredDietaryTags> {
|
||||
const model = resolveModel(config);
|
||||
|
||||
const ingredientList = ingredients.map((i) => i.rawName).join(", ");
|
||||
|
||||
const { object } = await generateObject({
|
||||
model,
|
||||
schema: DietaryOutputSchema,
|
||||
system:
|
||||
"You are a dietary classification expert. Analyze recipe ingredients and determine dietary tags accurately. Be conservative — only mark something as true if you are confident based on the ingredients listed.",
|
||||
prompt: `Classify these ingredients for dietary tags: ${ingredientList}`,
|
||||
});
|
||||
|
||||
const { reasoning: _reasoning, ...tags } = object;
|
||||
return tags;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { generateObject } from "ai";
|
||||
import { z } from "zod";
|
||||
import { resolveModel, type AiConfig } from "../factory";
|
||||
|
||||
const ScaledIngredientsSchema = z.object({
|
||||
ingredients: z.array(
|
||||
z.object({
|
||||
rawName: z.string(),
|
||||
quantity: z.string(),
|
||||
unit: z.string().nullable(),
|
||||
note: z.string().optional(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
export type ScaledIngredient = z.infer<typeof ScaledIngredientsSchema>["ingredients"][number];
|
||||
|
||||
type RecipeInput = {
|
||||
title: string;
|
||||
baseServings: number;
|
||||
ingredients: Array<{
|
||||
rawName: string;
|
||||
quantity: string | null;
|
||||
unit: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function scaleRecipe(
|
||||
recipe: RecipeInput,
|
||||
targetServings: number,
|
||||
originalServings: number,
|
||||
config?: AiConfig,
|
||||
locale?: string
|
||||
): Promise<ScaledIngredient[]> {
|
||||
const model = resolveModel(config);
|
||||
|
||||
const ingredientList = recipe.ingredients
|
||||
.map((ing) => {
|
||||
const parts = [ing.quantity, ing.unit, ing.rawName].filter(Boolean);
|
||||
return `- ${parts.join(" ")}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const { object } = await generateObject({
|
||||
model,
|
||||
schema: ScaledIngredientsSchema,
|
||||
system: `You are a culinary scaling expert. Scale recipe ingredients from ${originalServings} to ${targetServings} servings. Use practical quantities — no 0.33 eggs (round to whole), no 0.17 cans (use 0.25 or 0.5), avoid impractical fractions. Substitute whole units where sensible. Add a note when rounding significantly.`,
|
||||
prompt: `Recipe: "${recipe.title}"\n\nIngredients to scale:\n${ingredientList}`,
|
||||
});
|
||||
|
||||
return object.ingredients;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { generateObject } from "ai";
|
||||
import { z } from "zod";
|
||||
import { resolveModel, type AiConfig } from "../factory";
|
||||
|
||||
const SubstitutionsSchema = z.object({
|
||||
substitutions: z.array(z.object({
|
||||
name: z.string(),
|
||||
ratio: z.string(),
|
||||
note: z.string(),
|
||||
flavorImpact: z.enum(["minimal", "moderate", "significant"]),
|
||||
})),
|
||||
});
|
||||
|
||||
export type Substitution = z.infer<typeof SubstitutionsSchema>["substitutions"][number];
|
||||
|
||||
export async function substituteIngredient(
|
||||
ingredient: string,
|
||||
recipeContext: string,
|
||||
config?: AiConfig
|
||||
): Promise<Substitution[]> {
|
||||
const model = resolveModel(config);
|
||||
|
||||
const { object } = await generateObject({
|
||||
model,
|
||||
schema: SubstitutionsSchema,
|
||||
system:
|
||||
"You are a professional chef specializing in ingredient substitutions. Provide practical, commonly available substitutes. For each substitute: name what to use, the ratio (e.g. '1:1', '3/4 the amount'), and a brief note on technique adjustments. Rate the flavor impact honestly.",
|
||||
prompt: `Suggest 3-4 substitutes for "${ingredient}" in this recipe context: ${recipeContext}`,
|
||||
});
|
||||
|
||||
return object.substitutions;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { generateObject } from "ai";
|
||||
import { z } from "zod";
|
||||
import { resolveModel, type AiConfig } from "../factory";
|
||||
|
||||
const DrinksOutputSchema = z.object({
|
||||
drinks: z.array(z.object({
|
||||
name: z.string(),
|
||||
type: z.enum(["wine", "beer", "cocktail", "spirit", "non-alcoholic", "hot"]),
|
||||
alcoholic: z.boolean(),
|
||||
description: z.string(),
|
||||
examples: z.array(z.string()).min(1).max(3),
|
||||
whyItPairs: z.string(),
|
||||
servingTip: z.string().optional(),
|
||||
})),
|
||||
});
|
||||
|
||||
export type DrinkSuggestion = z.infer<typeof DrinksOutputSchema>["drinks"][number];
|
||||
|
||||
const LANG: Record<string, string> = { en: "English", fr: "French" };
|
||||
|
||||
export async function suggestDrinks(
|
||||
recipe: {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
difficulty?: string | null;
|
||||
dietaryTags?: Record<string, boolean> | null;
|
||||
ingredients: Array<{ rawName: string }>;
|
||||
},
|
||||
count = 4,
|
||||
config?: AiConfig,
|
||||
locale?: string
|
||||
): Promise<DrinkSuggestion[]> {
|
||||
const model = resolveModel(config);
|
||||
const lang = LANG[locale ?? "en"] ?? "English";
|
||||
|
||||
const dietaryContext = recipe.dietaryTags
|
||||
? Object.entries(recipe.dietaryTags).filter(([, v]) => v).map(([k]) => k).join(", ")
|
||||
: "";
|
||||
|
||||
const { object } = await generateObject({
|
||||
model,
|
||||
schema: DrinksOutputSchema,
|
||||
system:
|
||||
`You are a sommelier and beverage expert. For each suggestion, lead with a style or category (e.g. 'Dry white Burgundy', 'Session IPA', 'Sparkling water with citrus') rather than a specific product. Then provide 2–3 concrete examples to illustrate the style. Consider flavor profiles, acidity, tannins, sweetness, weight, and cultural pairing traditions. Include a mix of alcoholic and non-alcoholic options. Respond in ${lang}.`,
|
||||
prompt: `Suggest ${count} drinks to serve with this recipe.\n\nRecipe: ${recipe.title}\n${recipe.description ? `Description: ${recipe.description}` : ""}\n${dietaryContext ? `Dietary: ${dietaryContext}` : ""}\nKey ingredients: ${recipe.ingredients.slice(0, 8).map((i) => i.rawName).join(", ")}`,
|
||||
});
|
||||
|
||||
return object.drinks;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { generateObject } from "ai";
|
||||
import { z } from "zod";
|
||||
import { resolveModel, type AiConfig } from "../factory";
|
||||
|
||||
const PairingsOutputSchema = z.object({
|
||||
pairings: z.array(z.object({
|
||||
name: z.string(),
|
||||
role: z.enum(["starter", "side", "salad", "bread", "drink", "dessert", "sauce"]),
|
||||
description: z.string(),
|
||||
whyItPairs: z.string(),
|
||||
difficulty: z.enum(["easy", "medium", "hard"]),
|
||||
prepTimeMins: z.number().int().optional(),
|
||||
})),
|
||||
});
|
||||
|
||||
export type PairingSuggestion = z.infer<typeof PairingsOutputSchema>["pairings"][number];
|
||||
|
||||
const LANG: Record<string, string> = { en: "English", fr: "French" };
|
||||
|
||||
export async function suggestPairings(
|
||||
recipe: {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
difficulty?: string | null;
|
||||
dietaryTags?: Record<string, boolean> | null;
|
||||
ingredients: Array<{ rawName: string }>;
|
||||
},
|
||||
count = 4,
|
||||
config?: AiConfig,
|
||||
locale?: string
|
||||
): Promise<PairingSuggestion[]> {
|
||||
const model = resolveModel(config);
|
||||
const lang = LANG[locale ?? "en"] ?? "English";
|
||||
|
||||
const dietaryContext = recipe.dietaryTags
|
||||
? Object.entries(recipe.dietaryTags).filter(([, v]) => v).map(([k]) => k).join(", ")
|
||||
: "";
|
||||
|
||||
const { object } = await generateObject({
|
||||
model,
|
||||
schema: PairingsOutputSchema,
|
||||
system:
|
||||
`You are an expert chef and menu planner. Suggest complementary dishes that form a balanced, cohesive complete meal. Consider flavor profiles, cooking techniques, dietary restrictions, and cultural harmony. Avoid duplicating ingredients heavily. Suggest a balanced mix of roles (starter, sides, dessert, drinks). Respond in ${lang}.`,
|
||||
prompt: `Suggest ${count} dishes that pair well with this recipe to form a complete meal:\n\nRecipe: ${recipe.title}\n${recipe.description ? `Description: ${recipe.description}` : ""}\n${dietaryContext ? `Dietary: ${dietaryContext}` : ""}\nKey ingredients: ${recipe.ingredients.slice(0, 8).map((i) => i.rawName).join(", ")}`,
|
||||
});
|
||||
|
||||
return object.pairings;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { generateObject } from "ai";
|
||||
import { z } from "zod";
|
||||
import { resolveModel, type AiConfig } from "../factory";
|
||||
|
||||
const VariationsOutputSchema = z.object({
|
||||
variations: z.array(z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
changedIngredients: z.array(z.object({
|
||||
original: z.string(),
|
||||
replacement: z.string(),
|
||||
note: z.string().optional(),
|
||||
})),
|
||||
changedSteps: z.array(z.object({
|
||||
stepNumber: z.number().int(),
|
||||
change: z.string(),
|
||||
})).optional(),
|
||||
dietaryTags: z.object({
|
||||
vegan: z.boolean().optional(),
|
||||
vegetarian: z.boolean().optional(),
|
||||
glutenFree: z.boolean().optional(),
|
||||
dairyFree: z.boolean().optional(),
|
||||
nutFree: z.boolean().optional(),
|
||||
halal: z.boolean().optional(),
|
||||
kosher: z.boolean().optional(),
|
||||
}).optional(),
|
||||
})),
|
||||
});
|
||||
|
||||
export type VariationSuggestion = z.infer<typeof VariationsOutputSchema>["variations"][number];
|
||||
|
||||
const LANG: Record<string, string> = { en: "English", fr: "French" };
|
||||
|
||||
export async function suggestVariations(
|
||||
recipe: {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
ingredients: Array<{ rawName: string; quantity?: string | null; unit?: string | null }>;
|
||||
steps: Array<{ instruction: string }>;
|
||||
},
|
||||
count = 3,
|
||||
config?: AiConfig,
|
||||
directions?: string,
|
||||
locale?: string
|
||||
): Promise<VariationSuggestion[]> {
|
||||
const model = resolveModel(config);
|
||||
const lang = LANG[locale ?? "en"] ?? "English";
|
||||
|
||||
const recipeText = [
|
||||
`Recipe: ${recipe.title}`,
|
||||
recipe.description ? `Description: ${recipe.description}` : "",
|
||||
`Ingredients: ${recipe.ingredients.map((i) => `${i.quantity ?? ""} ${i.unit ?? ""} ${i.rawName}`.trim()).join(", ")}`,
|
||||
`Steps: ${recipe.steps.map((s, i) => `${i + 1}. ${s.instruction}`).join(" | ")}`,
|
||||
].filter(Boolean).join("\n");
|
||||
|
||||
const directionsClause = directions?.trim()
|
||||
? `\n\nUser directions: ${directions.trim()}`
|
||||
: "";
|
||||
|
||||
const { object } = await generateObject({
|
||||
model,
|
||||
schema: VariationsOutputSchema,
|
||||
system:
|
||||
`You are a creative chef. Suggest meaningful variations of recipes — dietary adaptations, flavor profiles, technique changes. Each variation should be distinct and practical. Respond in ${lang}.`,
|
||||
prompt: `Suggest ${count} variations of this recipe:\n\n${recipeText}${directionsClause}`,
|
||||
});
|
||||
|
||||
return object.variations;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { generateObject } from "ai";
|
||||
import { z } from "zod";
|
||||
import { resolveModel, type AiConfig } from "../factory";
|
||||
|
||||
const TranslationOutputSchema = z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
ingredients: z.array(z.object({
|
||||
rawName: z.string(),
|
||||
note: z.string().optional(),
|
||||
})),
|
||||
steps: z.array(z.object({
|
||||
instruction: z.string(),
|
||||
})),
|
||||
});
|
||||
|
||||
export type TranslatedRecipe = z.infer<typeof TranslationOutputSchema>;
|
||||
|
||||
export async function translateRecipe(
|
||||
recipe: {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
ingredients: Array<{ rawName: string; note?: string | null }>;
|
||||
steps: Array<{ instruction: string }>;
|
||||
},
|
||||
targetLanguage: string,
|
||||
config?: AiConfig
|
||||
): Promise<TranslatedRecipe> {
|
||||
const model = resolveModel(config);
|
||||
|
||||
const { object } = await generateObject({
|
||||
model,
|
||||
schema: TranslationOutputSchema,
|
||||
system:
|
||||
"You are a professional culinary translator. Translate recipes accurately, preserving culinary meaning, cooking terms, and cultural context. Keep ingredient quantities and units as-is (numbers and unit abbreviations are universal). Only translate text content.",
|
||||
prompt: `Translate the following recipe into ${targetLanguage}. Return the same structure with translated text.\n\nTitle: ${recipe.title}\nDescription: ${recipe.description ?? ""}\nIngredients: ${recipe.ingredients.map((i) => `- ${i.rawName}${i.note ? ` (${i.note})` : ""}`).join("\n")}\nSteps: ${recipe.steps.map((s, i) => `${i + 1}. ${s.instruction}`).join("\n")}`,
|
||||
});
|
||||
|
||||
return object;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { db, userAiKeys, userModelPrefs, eq, and } from "@epicure/db";
|
||||
import { decrypt } from "@/lib/encrypt";
|
||||
import { getSiteSetting } from "@/lib/site-settings";
|
||||
import type { AiConfig, AiProvider } from "./factory";
|
||||
|
||||
export type ModelUseCase = "text" | "vision" | "mealPlan";
|
||||
|
||||
export async function withUserKey(userId: string, config: AiConfig): Promise<AiConfig> {
|
||||
const provider = config.provider;
|
||||
if (!provider) return config;
|
||||
|
||||
const row = await db.query.userAiKeys.findFirst({
|
||||
where: and(eq(userAiKeys.userId, userId), eq(userAiKeys.provider, provider)),
|
||||
});
|
||||
|
||||
if (!row) return config;
|
||||
|
||||
try {
|
||||
const apiKey = decrypt(row.encryptedKey);
|
||||
return { ...config, apiKey };
|
||||
} catch {
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getModelConfigForUseCase(userId: string, useCase: ModelUseCase): Promise<AiConfig> {
|
||||
const prefs = await db.query.userModelPrefs.findFirst({
|
||||
where: eq(userModelPrefs.userId, userId),
|
||||
});
|
||||
|
||||
const providerField = `${useCase}Provider` as keyof typeof prefs;
|
||||
const modelField = `${useCase}Model` as keyof typeof prefs;
|
||||
const provider = prefs?.[providerField] as AiProvider | null | undefined;
|
||||
const model = prefs?.[modelField] as string | null | undefined;
|
||||
|
||||
if (provider) {
|
||||
const config: AiConfig = { provider, model: model ?? undefined };
|
||||
return withUserKey(userId, config);
|
||||
}
|
||||
|
||||
return getDefaultProviderWithKey(userId);
|
||||
}
|
||||
|
||||
export async function getDefaultProviderWithKey(userId: string): Promise<AiConfig> {
|
||||
const keys = await db.query.userAiKeys.findMany({
|
||||
where: eq(userAiKeys.userId, userId),
|
||||
columns: { provider: true, encryptedKey: true },
|
||||
});
|
||||
|
||||
const order: AiProvider[] = ["openrouter", "openai", "anthropic", "ollama"];
|
||||
|
||||
// 1. User BYOK key takes highest priority
|
||||
for (const p of order) {
|
||||
const row = keys.find((k) => k.provider === p);
|
||||
if (row) {
|
||||
try {
|
||||
return { provider: p, apiKey: decrypt(row.encryptedKey) };
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
};
|
||||
for (const p of order) {
|
||||
const envKey = providerEnvKeys[p];
|
||||
if (!envKey) continue;
|
||||
const apiKey = await getSiteSetting(envKey as Parameters<typeof getSiteSetting>[0]);
|
||||
if (apiKey) return { provider: p, apiKey };
|
||||
}
|
||||
|
||||
// 3. Let factory use raw env vars
|
||||
return {};
|
||||
}
|
||||
Reference in New Issue
Block a user