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:
Arnaud
2026-07-01 08:10:19 +02:00
parent 84d6cfeb07
commit d9d58fd01a
26 changed files with 1656 additions and 0 deletions
+82
View File
@@ -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;
}
+60
View File
@@ -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;
}
+63
View File
@@ -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;
}
+36
View File
@@ -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;
}
+52
View File
@@ -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 23 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;
}