Update features and dependencies

This commit is contained in:
Arnaud
2026-07-01 11:10:37 +02:00
parent 9d9dfb46c6
commit 8b57a3fd87
107 changed files with 14654 additions and 458 deletions
+95
View File
@@ -0,0 +1,95 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Mock AI SDK providers
const mockOpenAiModel = vi.fn(() => "openai-model-instance");
const mockAnthropicModel = vi.fn(() => "anthropic-model-instance");
const mockOpenAiProvider = vi.fn(() => mockOpenAiModel);
const mockAnthropicProvider = vi.fn(() => mockAnthropicModel);
vi.mock("@ai-sdk/openai", () => ({
createOpenAI: vi.fn(() => mockOpenAiProvider),
}));
vi.mock("@ai-sdk/anthropic", () => ({
createAnthropic: vi.fn(() => mockAnthropicProvider),
}));
const { createOpenAI } = await import("@ai-sdk/openai");
const { createAnthropic } = await import("@ai-sdk/anthropic");
const { resolveModel } = await import("../factory");
beforeEach(() => {
vi.clearAllMocks();
delete process.env["OPENAI_API_KEY"];
delete process.env["ANTHROPIC_API_KEY"];
delete process.env["OPENROUTER_API_KEY"];
delete process.env["OLLAMA_BASE_URL"];
});
describe("resolveModel", () => {
it("uses openai provider when specified", () => {
resolveModel({ provider: "openai", apiKey: "sk-test" });
expect(createOpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: "sk-test" }));
});
it("uses anthropic provider when specified", () => {
resolveModel({ provider: "anthropic", apiKey: "sk-ant-test" });
expect(createAnthropic).toHaveBeenCalledWith(expect.objectContaining({ apiKey: "sk-ant-test" }));
});
it("uses openrouter base URL when provider is openrouter", () => {
resolveModel({ provider: "openrouter", apiKey: "or-key" });
expect(createOpenAI).toHaveBeenCalledWith(
expect.objectContaining({ baseURL: "https://openrouter.ai/api/v1" })
);
});
it("uses ollama base URL from env or default", () => {
process.env["OLLAMA_BASE_URL"] = "http://custom:11434/v1";
resolveModel({ provider: "ollama" });
expect(createOpenAI).toHaveBeenCalledWith(
expect.objectContaining({ baseURL: "http://custom:11434/v1" })
);
});
it("falls back to ollama default URL when env not set", () => {
resolveModel({ provider: "ollama" });
expect(createOpenAI).toHaveBeenCalledWith(
expect.objectContaining({ baseURL: "http://localhost:11434/v1" })
);
});
it("uses custom model when specified", () => {
resolveModel({ provider: "openai", model: "gpt-4-turbo", apiKey: "sk-test" });
expect(mockOpenAiProvider).toHaveBeenCalledWith("gpt-4-turbo");
});
it("uses default openai model when none specified", () => {
resolveModel({ provider: "openai", apiKey: "sk-test" });
expect(mockOpenAiProvider).toHaveBeenCalledWith("gpt-4o-mini");
});
it("uses default anthropic model when none specified", () => {
resolveModel({ provider: "anthropic", apiKey: "sk-ant" });
expect(mockAnthropicProvider).toHaveBeenCalledWith("claude-haiku-4-5-20251001");
});
it("defaults to openrouter when OPENROUTER_API_KEY is set (no explicit provider)", () => {
process.env["OPENROUTER_API_KEY"] = "or-key";
resolveModel({});
expect(createOpenAI).toHaveBeenCalledWith(
expect.objectContaining({ baseURL: "https://openrouter.ai/api/v1" })
);
});
it("defaults to openai when OPENAI_API_KEY set and no openrouter", () => {
process.env["OPENAI_API_KEY"] = "sk-test";
resolveModel({});
expect(createOpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: "sk-test" }));
});
it("throws on unknown provider", () => {
// @ts-expect-error - testing invalid input
expect(() => resolveModel({ provider: "nonexistent" })).toThrow();
});
});
@@ -0,0 +1,138 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { encrypt } from "../../encrypt";
const mockUserAiKeysFindMany = vi.fn();
const mockUserModelPrefsFindFirst = vi.fn();
const mockSiteSettingFindFirst = vi.fn();
vi.mock("@epicure/db", () => ({
db: {
query: {
userAiKeys: { findMany: mockUserAiKeysFindMany },
userModelPrefs: { findFirst: mockUserModelPrefsFindFirst },
siteSettings: { findFirst: mockSiteSettingFindFirst },
},
},
userAiKeys: {},
userModelPrefs: {},
siteSettings: {},
eq: vi.fn((a, b) => ({ a, b })),
and: vi.fn((...args) => args),
}));
const { getDefaultProviderWithKey, getModelConfigForUseCase, withUserKey } = await import("../resolve-user-key");
beforeEach(() => {
vi.clearAllMocks();
delete process.env["OPENAI_API_KEY"];
delete process.env["ANTHROPIC_API_KEY"];
delete process.env["OPENROUTER_API_KEY"];
// Make getSiteSetting return null by default
mockSiteSettingFindFirst.mockResolvedValue(null);
});
describe("getDefaultProviderWithKey", () => {
it("returns empty config when no keys at all", async () => {
mockUserAiKeysFindMany.mockResolvedValue([]);
const config = await getDefaultProviderWithKey("user1");
expect(config).toEqual({});
});
it("returns openrouter key first (priority order)", async () => {
const encOpenrouter = encrypt("or-key");
const encOpenai = encrypt("sk-test");
mockUserAiKeysFindMany.mockResolvedValue([
{ provider: "openai", encryptedKey: encOpenai },
{ provider: "openrouter", encryptedKey: encOpenrouter },
]);
const config = await getDefaultProviderWithKey("user1");
expect(config.provider).toBe("openrouter");
expect(config.apiKey).toBe("or-key");
});
it("falls back to openai when no openrouter", async () => {
const encOpenai = encrypt("sk-openai");
mockUserAiKeysFindMany.mockResolvedValue([
{ provider: "openai", encryptedKey: encOpenai },
]);
const config = await getDefaultProviderWithKey("user1");
expect(config.provider).toBe("openai");
expect(config.apiKey).toBe("sk-openai");
});
it("uses site settings when no BYOK key exists", async () => {
mockUserAiKeysFindMany.mockResolvedValue([]);
// Mock getSiteSetting via siteSettings.findFirst to return encrypted key for OPENAI
const encKey = encrypt("sk-from-site-settings");
mockSiteSettingFindFirst.mockResolvedValueOnce(null) // openrouter
.mockResolvedValueOnce({ value: encKey, isSecret: true }); // openai
const config = await getDefaultProviderWithKey("user1");
expect(config.provider).toBe("openai");
expect(config.apiKey).toBe("sk-from-site-settings");
});
it("skips corrupted BYOK key and tries next provider", async () => {
mockUserAiKeysFindMany.mockResolvedValue([
{ provider: "openrouter", encryptedKey: "CORRUPT:NOT:VALID" },
{ provider: "openai", encryptedKey: encrypt("sk-valid") },
]);
const config = await getDefaultProviderWithKey("user1");
expect(config.provider).toBe("openai");
expect(config.apiKey).toBe("sk-valid");
});
});
describe("withUserKey", () => {
it("injects BYOK key when user has one for this provider", async () => {
const encKey = encrypt("sk-user-key");
vi.mocked(mockUserAiKeysFindMany); // just ensure mock is ready
// withUserKey uses findFirst via userAiKeys
const mockFindFirst = vi.fn().mockResolvedValue({ encryptedKey: encKey });
vi.mocked((await import("@epicure/db")).db.query.userAiKeys as unknown as { findFirst: typeof mockFindFirst }).findFirst = mockFindFirst;
const config = await withUserKey("user1", { provider: "openai" });
expect(config.apiKey).toBe("sk-user-key");
});
it("returns config unchanged when no user key for provider", async () => {
const mockFindFirst = vi.fn().mockResolvedValue(null);
vi.mocked((await import("@epicure/db")).db.query.userAiKeys as unknown as { findFirst: typeof mockFindFirst }).findFirst = mockFindFirst;
const config = await withUserKey("user1", { provider: "anthropic" });
expect(config.apiKey).toBeUndefined();
});
});
describe("getModelConfigForUseCase", () => {
it("uses user model prefs when set", async () => {
mockUserModelPrefsFindFirst.mockResolvedValue({
textProvider: "anthropic",
textModel: "claude-sonnet-4-6",
visionProvider: null,
visionModel: null,
mealPlanProvider: null,
mealPlanModel: null,
});
const mockFindFirst = vi.fn().mockResolvedValue(null); // no BYOK key
vi.mocked((await import("@epicure/db")).db.query.userAiKeys as unknown as { findFirst: typeof mockFindFirst }).findFirst = mockFindFirst;
const config = await getModelConfigForUseCase("user1", "text");
expect(config.provider).toBe("anthropic");
expect(config.model).toBe("claude-sonnet-4-6");
});
it("falls back to getDefaultProviderWithKey when no prefs", async () => {
mockUserModelPrefsFindFirst.mockResolvedValue(null);
mockUserAiKeysFindMany.mockResolvedValue([
{ provider: "openai", encryptedKey: encrypt("sk-default") },
]);
const config = await getModelConfigForUseCase("user1", "vision");
expect(config.provider).toBe("openai");
expect(config.apiKey).toBe("sk-default");
});
});
+2 -2
View File
@@ -47,7 +47,7 @@ export async function adaptRecipe(
excludeIngredients: string[];
extraConstraints?: string;
},
config?: AiConfig,
config?: AiConfig & { userContext?: string },
locale?: string
): Promise<AdaptedRecipe> {
const model = resolveModel(config);
@@ -74,7 +74,7 @@ export async function adaptRecipe(
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}.`,
`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}.${config?.userContext ? `\n\nUser preferences and context:\n${config.userContext}` : ""}`,
prompt: `Adapt the following recipe with these constraints:\n\n${constraintText}\n\nOriginal recipe:\n${recipeText}`,
});
+13 -5
View File
@@ -11,7 +11,7 @@ const MealPlanSchema = z.object({
description: z.string().max(300),
ingredients: z.array(z.object({
rawName: z.string(),
quantity: z.string().optional(),
quantity: z.number().optional(),
unit: z.string().optional(),
})).max(20),
steps: z.array(z.object({
@@ -36,8 +36,9 @@ export async function generateMealPlan(
pantryItems?: string[];
days?: Array<"mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun">;
pantryMode?: boolean;
difficulty?: "easy" | "medium" | "hard";
},
config?: AiConfig,
config?: AiConfig & { userContext?: string },
locale?: string
): Promise<GeneratedMealPlan> {
const model = resolveModel(config);
@@ -45,28 +46,35 @@ export async function generateMealPlan(
const servings = options.servings ?? 2;
const days = options.days ?? ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
const pantryMode = options.pantryMode ?? false;
const difficulty = options.difficulty;
const dietaryClause = options.dietaryPrefs?.trim()
? `Dietary requirements: ${options.dietaryPrefs.trim()}.`
: "";
const difficultyClause = difficulty
? `All recipes must be ${difficulty} difficulty: ${{ easy: "simple techniques, few steps, everyday ingredients", medium: "moderate skill, standard techniques", hard: "advanced techniques, multiple components" }[difficulty]}.`
: "";
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}.`;
? `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. 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. 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. 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. Respond in ${lang}.`;
const systemPromptWithContext = systemPrompt + (config?.userContext ? `\n\nUser preferences and context:\n${config.userContext}` : "");
const { object } = await generateObject({
model,
schema: MealPlanSchema,
system: systemPrompt,
system: systemPromptWithContext,
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,
difficultyClause,
pantryClause,
pantryMode
? "Prioritize using the listed pantry ingredients across as many meals as possible. Minimize ingredients that need to be purchased."
+4 -2
View File
@@ -34,17 +34,19 @@ export type GeneratedRecipe = z.infer<typeof RecipeOutputSchema>;
export async function generateRecipe(
prompt: string,
config?: AiConfig & { language?: string }
config?: AiConfig & { language?: string; difficulty?: "easy" | "medium" | "hard"; userContext?: 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 difficultyInstruction = config?.difficulty ? ` The recipe must be ${config.difficulty} difficulty: ${{ easy: "simple techniques, minimal steps, common ingredients", medium: "moderate skill required, standard techniques", hard: "advanced techniques, multiple components, skilled cook required" }[config.difficulty]}.` : "";
const userInstruction = config?.userContext ? `\n\nUser preferences and context:\n${config.userContext}` : "";
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}`,
`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}${difficultyInstruction}${userInstruction}`,
prompt: `Create a recipe for: ${prompt}`,
});
+60
View File
@@ -1,7 +1,64 @@
import { generateObject } from "ai";
import dns from "node:dns/promises";
import { z } from "zod";
import { resolveModel, type AiConfig } from "../factory";
/**
* Resolves the hostname in `rawUrl` and returns an error string if the
* URL targets a private/reserved address range (SSRF guard), or null if safe.
*/
async function validateImportUrl(rawUrl: string): Promise<string | null> {
let url: URL;
try {
url = new URL(rawUrl);
} catch {
return "Invalid URL";
}
if (url.protocol !== "https:" && url.protocol !== "http:") {
return "URL must use http or https";
}
let addresses: string[];
try {
const results = await dns.lookup(url.hostname, { all: true, family: 0 });
addresses = results.map((r) => r.address);
} catch {
return "Unable to resolve hostname";
}
for (const addr of addresses) {
if (isPrivateAddress(addr)) {
return "URL must not point to a private or reserved address";
}
}
return null;
}
function isPrivateAddress(ip: string): boolean {
const v4 = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (v4) {
const [, a, b, c] = v4.map(Number) as [number, number, number, number, number];
if (a === 127) return true;
if (a === 10) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
if (a === 169 && b === 254) return true;
if (a >= 224) return true;
return false;
}
const lower = ip.toLowerCase();
if (lower === "::1") return true;
if (lower === "::") return true;
if (lower.startsWith("fe80:")) return true;
if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
const v4mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
if (v4mapped) return isPrivateAddress(v4mapped[1]!);
return false;
}
const ImportedRecipeSchema = z.object({
title: z.string(),
description: z.string().optional(),
@@ -33,6 +90,9 @@ const ImportedRecipeSchema = z.object({
export type ImportedRecipe = z.infer<typeof ImportedRecipeSchema>;
export async function importFromUrl(url: string, config?: AiConfig): Promise<ImportedRecipe> {
const ssrfError = await validateImportUrl(url);
if (ssrfError) throw new Error(ssrfError);
const res = await fetch(url, {
headers: { "User-Agent": "Mozilla/5.0 (compatible; Epicure/1.0; recipe importer)" },
signal: AbortSignal.timeout(10000),
+2 -2
View File
@@ -27,7 +27,7 @@ export async function suggestDrinks(
ingredients: Array<{ rawName: string }>;
},
count = 4,
config?: AiConfig,
config?: AiConfig & { userContext?: string },
locale?: string
): Promise<DrinkSuggestion[]> {
const model = resolveModel(config);
@@ -41,7 +41,7 @@ export async function suggestDrinks(
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}.`,
`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}.${config?.userContext ? `\n\nUser preferences and context:\n${config.userContext}` : ""}`,
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(", ")}`,
});
+2 -2
View File
@@ -26,7 +26,7 @@ export async function suggestPairings(
ingredients: Array<{ rawName: string }>;
},
count = 4,
config?: AiConfig,
config?: AiConfig & { userContext?: string },
locale?: string
): Promise<PairingSuggestion[]> {
const model = resolveModel(config);
@@ -40,7 +40,7 @@ export async function suggestPairings(
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}.`,
`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}.${config?.userContext ? `\n\nUser preferences and context:\n${config.userContext}` : ""}`,
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(", ")}`,
});
@@ -39,7 +39,7 @@ export async function suggestVariations(
steps: Array<{ instruction: string }>;
},
count = 3,
config?: AiConfig,
config?: AiConfig & { userContext?: string },
directions?: string,
locale?: string
): Promise<VariationSuggestion[]> {
@@ -61,7 +61,7 @@ export async function suggestVariations(
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}.`,
`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}.${config?.userContext ? `\n\nUser preferences and context:\n${config.userContext}` : ""}`,
prompt: `Suggest ${count} variations of this recipe:\n\n${recipeText}${directionsClause}`,
});
+14
View File
@@ -0,0 +1,14 @@
import { db, users, eq } from "@epicure/db";
export async function getUserPrivateBio(userId: string): Promise<string | null> {
const row = await db.query.users.findFirst({
where: eq(users.id, userId),
columns: { privateBio: true },
});
return row?.privateBio ?? null;
}
export function buildUserBioContext(privateBio: string | null): string {
if (!privateBio?.trim()) return "";
return `\n\nUser preferences and context:\n${privateBio.trim()}`;
}