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
+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;
}