From 524310433c4ff2c80fdfacb3d4a16bdd3686d1e0 Mon Sep 17 00:00:00 2001 From: Arnaud Date: Thu, 2 Jul 2026 12:32:31 +0200 Subject: [PATCH] fix: return clean errors and refund quota on AI provider failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI routes had no consistent error handling — a provider failure (e.g. OpenRouter returning a degraded-model error) crashed the route as an uncaught 500 and, worse, still charged the user's monthly aiCall quota for a request that never succeeded. Adds withAiQuota()/aiErrorResponse() (lib/ai/ai-error.ts) and applies them across all 12 AI generation routes: charges the quota, runs the AI call, refunds the credit and returns a clean user-facing message if it throws. Frontend needs no changes — existing dialogs already toast whatever `error` string comes back. Co-Authored-By: Claude Sonnet 5 --- apps/web/app/api/v1/ai/adapt/[id]/route.ts | 37 +++++---- apps/web/app/api/v1/ai/drinks/[id]/route.ts | 34 ++++---- .../app/api/v1/ai/generate-from-idea/route.ts | 20 +++-- apps/web/app/api/v1/ai/generate/route.ts | 23 ++--- apps/web/app/api/v1/ai/import-photo/route.ts | 23 ++--- apps/web/app/api/v1/ai/import-url/route.ts | 17 ++-- .../app/api/v1/ai/meal-plan/generate/route.ts | 30 ++++--- apps/web/app/api/v1/ai/pairings/[id]/route.ts | 34 ++++---- apps/web/app/api/v1/ai/scale/route.ts | 29 +++---- apps/web/app/api/v1/ai/substitute/route.ts | 21 ++--- .../web/app/api/v1/ai/translate/[id]/route.ts | 26 +++--- .../app/api/v1/ai/variations/[id]/route.ts | 34 ++++---- apps/web/lib/__tests__/tiers.test.ts | 10 ++- apps/web/lib/ai/__tests__/ai-error.test.ts | 83 +++++++++++++++++++ apps/web/lib/ai/ai-error.ts | 55 ++++++++++++ apps/web/lib/tiers.ts | 14 ++++ 16 files changed, 325 insertions(+), 165 deletions(-) create mode 100644 apps/web/lib/ai/__tests__/ai-error.test.ts create mode 100644 apps/web/lib/ai/ai-error.ts diff --git a/apps/web/app/api/v1/ai/adapt/[id]/route.ts b/apps/web/app/api/v1/ai/adapt/[id]/route.ts index 1c86d39..37b539e 100644 --- a/apps/web/app/api/v1/ai/adapt/[id]/route.ts +++ b/apps/web/app/api/v1/ai/adapt/[id]/route.ts @@ -3,7 +3,7 @@ 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 { checkAndIncrementTierLimit } from "@/lib/tiers"; +import { withAiQuota } from "@/lib/ai/ai-error"; import { adaptRecipe } from "@/lib/ai/features/adapt-recipe"; import { withUserKey } from "@/lib/ai/resolve-user-key"; import { getUserPrivateBio } from "@/lib/ai/user-bio"; @@ -43,27 +43,30 @@ export async function POST(req: NextRequest, { params }: Params) { return NextResponse.json({ error: "Provide at least one constraint" }, { status: 400 }); } - await checkAndIncrementTierLimit(userId, session!.user.tier as "free" | "pro", "aiCall"); - const [aiConfig, privateBio] = await Promise.all([ withUserKey(userId, { provider: parsed.data.provider, model: parsed.data.model }), getUserPrivateBio(userId), ]); - 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, userContext: privateBio ?? undefined }, - (session!.user as { locale?: string }).locale ?? "en" + + const result = await withAiQuota(userId, session!.user.tier as "free" | "pro", () => + 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, userContext: privateBio ?? undefined }, + (session!.user as { locale?: string }).locale ?? "en" + ) ); + if (!result.ok) return result.response; + const adapted = result.data; const newId = crypto.randomUUID(); const now = new Date(); diff --git a/apps/web/app/api/v1/ai/drinks/[id]/route.ts b/apps/web/app/api/v1/ai/drinks/[id]/route.ts index d4192bf..e138fc7 100644 --- a/apps/web/app/api/v1/ai/drinks/[id]/route.ts +++ b/apps/web/app/api/v1/ai/drinks/[id]/route.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { and, eq } from "@epicure/db"; import { db, recipes } from "@epicure/db"; import { requireSession } from "@/lib/api-auth"; -import { checkAndIncrementTierLimit } from "@/lib/tiers"; +import { withAiQuota } from "@/lib/ai/ai-error"; import { suggestDrinks } from "@/lib/ai/features/suggest-drinks"; import { withUserKey } from "@/lib/ai/resolve-user-key"; import { getUserPrivateBio } from "@/lib/ai/user-bio"; @@ -34,24 +34,26 @@ export async function POST(req: NextRequest, { params }: Params) { const parsed = Schema.safeParse(body ?? {}); if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 }); - await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall"); - const [aiConfig, privateBio] = await Promise.all([ withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model }), getUserPrivateBio(session!.user.id), ]); - const drinks = await suggestDrinks( - { - title: recipe.title, - description: recipe.description, - difficulty: recipe.difficulty, - dietaryTags: recipe.dietaryTags as Record | null, - ingredients: recipe.ingredients, - }, - parsed.data.count, - { ...aiConfig, userContext: privateBio ?? undefined }, - (session!.user as { locale?: string }).locale ?? "en" - ); - return NextResponse.json({ drinks }); + const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () => + suggestDrinks( + { + title: recipe.title, + description: recipe.description, + difficulty: recipe.difficulty, + dietaryTags: recipe.dietaryTags as Record | null, + ingredients: recipe.ingredients, + }, + parsed.data.count, + { ...aiConfig, userContext: privateBio ?? undefined }, + (session!.user as { locale?: string }).locale ?? "en" + ) + ); + if (!result.ok) return result.response; + + return NextResponse.json({ drinks: result.data }); } diff --git a/apps/web/app/api/v1/ai/generate-from-idea/route.ts b/apps/web/app/api/v1/ai/generate-from-idea/route.ts index 6c13e30..a434236 100644 --- a/apps/web/app/api/v1/ai/generate-from-idea/route.ts +++ b/apps/web/app/api/v1/ai/generate-from-idea/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { requireSession } from "@/lib/api-auth"; import { applyRateLimit } from "@/lib/rate-limit"; -import { checkAndIncrementTierLimit } from "@/lib/tiers"; +import { withAiQuota } from "@/lib/ai/ai-error"; import { generateRecipe } from "@/lib/ai/features/generate-recipe"; import { getUserPrivateBio } from "@/lib/ai/user-bio"; import { db, recipes, recipeIngredients, recipeSteps } from "@epicure/db"; @@ -29,17 +29,19 @@ export async function POST(req: NextRequest) { const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 10, 60); if (limited) return limited; - await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall"); - const privateBio = await getUserPrivateBio(session!.user.id); const locale = (session!.user as { locale?: string }).locale ?? "en"; - const recipe = await generateRecipe(parsed.data.title, { - provider: parsed.data.provider, - model: parsed.data.model, - userContext: privateBio ?? undefined, - language: LANG[locale] ?? "English", - }); + const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () => + generateRecipe(parsed.data.title, { + provider: parsed.data.provider, + model: parsed.data.model, + userContext: privateBio ?? undefined, + language: LANG[locale] ?? "English", + }) + ); + if (!result.ok) return result.response; + const recipe = result.data; const recipeId = crypto.randomUUID(); diff --git a/apps/web/app/api/v1/ai/generate/route.ts b/apps/web/app/api/v1/ai/generate/route.ts index 0dd91be..07c4446 100644 --- a/apps/web/app/api/v1/ai/generate/route.ts +++ b/apps/web/app/api/v1/ai/generate/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { requireSession } from "@/lib/api-auth"; import { applyRateLimit } from "@/lib/rate-limit"; -import { checkAndIncrementTierLimit } from "@/lib/tiers"; +import { withAiQuota } from "@/lib/ai/ai-error"; import { generateRecipe } from "@/lib/ai/features/generate-recipe"; import { getUserPrivateBio } from "@/lib/ai/user-bio"; @@ -27,17 +27,18 @@ export async function POST(req: NextRequest) { const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 10, 60); if (limited) return limited; - await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall"); - const privateBio = await getUserPrivateBio(session!.user.id); - const recipe = await generateRecipe(parsed.data.prompt, { - provider: parsed.data.provider, - model: parsed.data.model, - language: parsed.data.language, - difficulty: parsed.data.difficulty, - userContext: privateBio ?? undefined, - }); + const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () => + generateRecipe(parsed.data.prompt, { + provider: parsed.data.provider, + model: parsed.data.model, + language: parsed.data.language, + difficulty: parsed.data.difficulty, + userContext: privateBio ?? undefined, + }) + ); + if (!result.ok) return result.response; - return NextResponse.json(recipe); + return NextResponse.json(result.data); } diff --git a/apps/web/app/api/v1/ai/import-photo/route.ts b/apps/web/app/api/v1/ai/import-photo/route.ts index f2dadc5..c2ed7ba 100644 --- a/apps/web/app/api/v1/ai/import-photo/route.ts +++ b/apps/web/app/api/v1/ai/import-photo/route.ts @@ -3,7 +3,7 @@ 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 { checkAndIncrementTierLimit, TierLimitError } from "@/lib/tiers"; +import { withAiQuota } from "@/lib/ai/ai-error"; import { importFromPhoto } from "@/lib/ai/features/import-photo"; import { getModelConfigForUseCase } from "@/lib/ai/resolve-user-key"; @@ -28,15 +28,6 @@ export async function POST(req: NextRequest) { const userId = session!.user.id; const locale = (session!.user as { locale?: string }).locale ?? "en"; - try { - await checkAndIncrementTierLimit(userId, session!.user.tier as "free" | "pro", "aiCall"); - } catch (err: unknown) { - if (err instanceof TierLimitError) { - return NextResponse.json({ error: "AI call limit reached for your tier" }, { status: 403 }); - } - throw err; - } - const aiConfig = await getModelConfigForUseCase(userId, "vision"); // Fall back to vision-capable defaults if no explicit model configured @@ -45,13 +36,11 @@ export async function POST(req: NextRequest) { 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 }); - } + const result = await withAiQuota(userId, session!.user.tier as "free" | "pro", () => + importFromPhoto(parsed.data.imageBase64, parsed.data.mimeType, aiConfig, locale) + ); + if (!result.ok) return result.response; + const recipe = result.data; const newRecipeId = crypto.randomUUID(); const now = new Date(); diff --git a/apps/web/app/api/v1/ai/import-url/route.ts b/apps/web/app/api/v1/ai/import-url/route.ts index d0056b3..f13280c 100644 --- a/apps/web/app/api/v1/ai/import-url/route.ts +++ b/apps/web/app/api/v1/ai/import-url/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { requireSession } from "@/lib/api-auth"; import { applyRateLimit } from "@/lib/rate-limit"; -import { checkAndIncrementTierLimit } from "@/lib/tiers"; +import { withAiQuota } from "@/lib/ai/ai-error"; import { importFromUrl } from "@/lib/ai/features/import-url"; import { validateWebhookUrl } from "@/lib/validate-webhook-url"; @@ -30,12 +30,13 @@ export async function POST(req: NextRequest) { const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 10, 60); if (limited) return limited; - await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall"); + const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () => + importFromUrl(parsed.data.url, { + provider: parsed.data.provider, + model: parsed.data.model, + }) + ); + if (!result.ok) return result.response; - const recipe = await importFromUrl(parsed.data.url, { - provider: parsed.data.provider, - model: parsed.data.model, - }); - - return NextResponse.json(recipe); + return NextResponse.json(result.data); } diff --git a/apps/web/app/api/v1/ai/meal-plan/generate/route.ts b/apps/web/app/api/v1/ai/meal-plan/generate/route.ts index 3e6b4b0..3fe3eab 100644 --- a/apps/web/app/api/v1/ai/meal-plan/generate/route.ts +++ b/apps/web/app/api/v1/ai/meal-plan/generate/route.ts @@ -4,6 +4,7 @@ import { db, recipes, recipeIngredients, recipeSteps, mealPlans, mealPlanEntries import { requireSession } from "@/lib/api-auth"; import { applyRateLimit } from "@/lib/rate-limit"; import { getDefaultProviderWithKey } from "@/lib/ai/resolve-user-key"; +import { aiErrorResponse } from "@/lib/ai/ai-error"; import { generateMealPlan } from "@/lib/ai/features/generate-meal-plan"; import { getUserPrivateBio } from "@/lib/ai/user-bio"; @@ -52,18 +53,23 @@ export async function POST(req: NextRequest) { 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, - difficulty: parsed.data.difficulty, - }, - { ...config, userContext: privateBio ?? undefined }, - locale - ); + let plan; + try { + plan = await generateMealPlan( + { + dietaryPrefs: parsed.data.dietaryPrefs, + servings: parsed.data.servings, + pantryItems: pantryItemNames, + days: parsed.data.days, + pantryMode: parsed.data.pantryMode, + difficulty: parsed.data.difficulty, + }, + { ...config, userContext: privateBio ?? undefined }, + locale + ); + } catch (err) { + return aiErrorResponse(err); + } // Ensure meal plan row exists for the week let mealPlan = await db.query.mealPlans.findFirst({ diff --git a/apps/web/app/api/v1/ai/pairings/[id]/route.ts b/apps/web/app/api/v1/ai/pairings/[id]/route.ts index 4a58d15..fda808a 100644 --- a/apps/web/app/api/v1/ai/pairings/[id]/route.ts +++ b/apps/web/app/api/v1/ai/pairings/[id]/route.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { and, eq } from "@epicure/db"; import { db, recipes } from "@epicure/db"; import { requireSession } from "@/lib/api-auth"; -import { checkAndIncrementTierLimit } from "@/lib/tiers"; +import { withAiQuota } from "@/lib/ai/ai-error"; import { suggestPairings } from "@/lib/ai/features/suggest-pairings"; import { withUserKey } from "@/lib/ai/resolve-user-key"; import { getUserPrivateBio } from "@/lib/ai/user-bio"; @@ -35,24 +35,26 @@ export async function POST(req: NextRequest, { params }: Params) { const parsed = Schema.safeParse(body ?? {}); if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 }); - await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall"); - const [aiConfig, privateBio] = await Promise.all([ withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model }), getUserPrivateBio(session!.user.id), ]); - const pairings = await suggestPairings( - { - title: recipe.title, - description: recipe.description, - difficulty: recipe.difficulty, - dietaryTags: recipe.dietaryTags as Record | null, - ingredients: recipe.ingredients, - }, - parsed.data.count, - { ...aiConfig, userContext: privateBio ?? undefined }, - (session!.user as { locale?: string }).locale ?? "en" - ); - return NextResponse.json({ pairings }); + const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () => + suggestPairings( + { + title: recipe.title, + description: recipe.description, + difficulty: recipe.difficulty, + dietaryTags: recipe.dietaryTags as Record | null, + ingredients: recipe.ingredients, + }, + parsed.data.count, + { ...aiConfig, userContext: privateBio ?? undefined }, + (session!.user as { locale?: string }).locale ?? "en" + ) + ); + if (!result.ok) return result.response; + + return NextResponse.json({ pairings: result.data }); } diff --git a/apps/web/app/api/v1/ai/scale/route.ts b/apps/web/app/api/v1/ai/scale/route.ts index ac9ad5e..90f682d 100644 --- a/apps/web/app/api/v1/ai/scale/route.ts +++ b/apps/web/app/api/v1/ai/scale/route.ts @@ -4,7 +4,7 @@ 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 { checkAndIncrementTierLimit } from "@/lib/tiers"; +import { withAiQuota } from "@/lib/ai/ai-error"; import { scaleRecipe } from "@/lib/ai/features/scale-recipe"; const Schema = z.object({ @@ -40,21 +40,22 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } - await checkAndIncrementTierLimit(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" + const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () => + scaleRecipe( + { + title: recipe.title, + baseServings: recipe.baseServings, + ingredients: recipe.ingredients, + }, + targetServings, + recipe.baseServings, + aiConfig, + (session!.user as { locale?: string }).locale ?? "en" + ) ); + if (!result.ok) return result.response; - return NextResponse.json({ ingredients: scaledIngredients }); + return NextResponse.json({ ingredients: result.data }); } diff --git a/apps/web/app/api/v1/ai/substitute/route.ts b/apps/web/app/api/v1/ai/substitute/route.ts index b5f399c..0cf9cbc 100644 --- a/apps/web/app/api/v1/ai/substitute/route.ts +++ b/apps/web/app/api/v1/ai/substitute/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { requireSession } from "@/lib/api-auth"; -import { checkAndIncrementTierLimit, TierLimitError } from "@/lib/tiers"; +import { withAiQuota } from "@/lib/ai/ai-error"; import { applyRateLimit } from "@/lib/rate-limit"; import { getDefaultProviderWithKey } from "@/lib/ai/resolve-user-key"; import { substituteIngredient } from "@/lib/ai/features/substitute-ingredient"; @@ -24,15 +24,6 @@ export async function POST(req: NextRequest) { const rateLimitRes = await applyRateLimit(`ai:substitute:${session!.user.id}`, 10, 60); if (rateLimitRes) return rateLimitRes; - try { - await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall"); - } catch (err) { - if (err instanceof TierLimitError) { - return NextResponse.json({ error: "AI call limit reached for your tier" }, { status: 403 }); - } - throw err; - } - const context = parsed.data.recipeTitle ? `recipe "${parsed.data.recipeTitle}"` : "a general recipe"; @@ -43,12 +34,10 @@ export async function POST(req: NextRequest) { const locale = (session!.user as { locale?: string }).locale ?? "en"; - const substitutions = await substituteIngredient( - parsed.data.ingredient, - context, - aiConfig, - locale + const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () => + substituteIngredient(parsed.data.ingredient, context, aiConfig, locale) ); + if (!result.ok) return result.response; - return NextResponse.json({ substitutions }); + return NextResponse.json({ substitutions: result.data }); } diff --git a/apps/web/app/api/v1/ai/translate/[id]/route.ts b/apps/web/app/api/v1/ai/translate/[id]/route.ts index 360e3be..34a56ce 100644 --- a/apps/web/app/api/v1/ai/translate/[id]/route.ts +++ b/apps/web/app/api/v1/ai/translate/[id]/route.ts @@ -3,7 +3,7 @@ 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 { checkAndIncrementTierLimit } from "@/lib/tiers"; +import { withAiQuota } from "@/lib/ai/ai-error"; import { translateRecipe } from "@/lib/ai/features/translate-recipe"; const Schema = z.object({ @@ -35,18 +35,20 @@ export async function POST(req: NextRequest, { params }: Params) { return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 }); } - await checkAndIncrementTierLimit(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 } + const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () => + translateRecipe( + { + title: recipe.title, + description: recipe.description, + ingredients: recipe.ingredients, + steps: recipe.steps, + }, + parsed.data.targetLanguage, + { provider: parsed.data.provider, model: parsed.data.model } + ) ); + if (!result.ok) return result.response; + const translation = result.data; // Save as new draft recipe const newId = crypto.randomUUID(); diff --git a/apps/web/app/api/v1/ai/variations/[id]/route.ts b/apps/web/app/api/v1/ai/variations/[id]/route.ts index e88baee..049fcd5 100644 --- a/apps/web/app/api/v1/ai/variations/[id]/route.ts +++ b/apps/web/app/api/v1/ai/variations/[id]/route.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { and, eq } from "@epicure/db"; import { db, recipes } from "@epicure/db"; import { requireSession } from "@/lib/api-auth"; -import { checkAndIncrementTierLimit } from "@/lib/tiers"; +import { withAiQuota } from "@/lib/ai/ai-error"; import { suggestVariations } from "@/lib/ai/features/suggest-variations"; import { withUserKey } from "@/lib/ai/resolve-user-key"; import { getUserPrivateBio } from "@/lib/ai/user-bio"; @@ -38,24 +38,26 @@ export async function POST(req: NextRequest, { params }: Params) { return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 }); } - await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall"); - const [aiConfig, privateBio] = await Promise.all([ withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model }), getUserPrivateBio(session!.user.id), ]); - const variations = await suggestVariations( - { - title: recipe.title, - description: recipe.description, - ingredients: recipe.ingredients, - steps: recipe.steps, - }, - parsed.data.count, - { ...aiConfig, userContext: privateBio ?? undefined }, - parsed.data.directions, - (session!.user as { locale?: string }).locale ?? "en" - ); - return NextResponse.json({ variations }); + const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () => + suggestVariations( + { + title: recipe.title, + description: recipe.description, + ingredients: recipe.ingredients, + steps: recipe.steps, + }, + parsed.data.count, + { ...aiConfig, userContext: privateBio ?? undefined }, + parsed.data.directions, + (session!.user as { locale?: string }).locale ?? "en" + ) + ); + if (!result.ok) return result.response; + + return NextResponse.json({ variations: result.data }); } diff --git a/apps/web/lib/__tests__/tiers.test.ts b/apps/web/lib/__tests__/tiers.test.ts index bbc1f00..c965826 100644 --- a/apps/web/lib/__tests__/tiers.test.ts +++ b/apps/web/lib/__tests__/tiers.test.ts @@ -23,7 +23,7 @@ vi.mock("@epicure/db", () => ({ })); // Import after mock -const { checkAndIncrementTierLimit, incrementUsage } = await import("../tiers"); +const { checkAndIncrementTierLimit, incrementUsage, refundAiCall } = await import("../tiers"); function makeChain(finalValue: unknown) { const chain = { @@ -93,6 +93,14 @@ describe("checkAndIncrementTierLimit", () => { }); }); +describe("refundAiCall", () => { + it("issues a decrement-with-floor UPDATE for the current month", async () => { + mockDb.execute.mockResolvedValueOnce(undefined); + await refundAiCall("user1"); + expect(mockDb.execute).toHaveBeenCalledTimes(1); + }); +}); + describe("incrementUsage", () => { it("calls insert with correct aiCall initial values", async () => { const chain = makeInsertChain(); diff --git a/apps/web/lib/ai/__tests__/ai-error.test.ts b/apps/web/lib/ai/__tests__/ai-error.test.ts new file mode 100644 index 0000000..a19f079 --- /dev/null +++ b/apps/web/lib/ai/__tests__/ai-error.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { APICallError } from "ai"; + +const { mockCheckAndIncrement, mockRefund } = vi.hoisted(() => ({ + mockCheckAndIncrement: vi.fn(), + mockRefund: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/tiers", async () => { + const actual = await vi.importActual("@/lib/tiers"); + return { + ...actual, + checkAndIncrementTierLimit: mockCheckAndIncrement, + refundAiCall: mockRefund, + }; +}); + +const { aiErrorResponse, withAiQuota } = await import("../ai-error"); +const { TierLimitError } = await import("@/lib/tiers"); + +beforeEach(() => { + vi.clearAllMocks(); + mockCheckAndIncrement.mockResolvedValue(undefined); +}); + +function makeApiCallError(isRetryable: boolean) { + return new APICallError({ + message: "Provider returned error", + url: "https://openrouter.ai/api/v1/responses", + requestBodyValues: {}, + statusCode: isRetryable ? 503 : 400, + isRetryable, + }); +} + +describe("aiErrorResponse", () => { + it("maps a retryable APICallError to a 503 with a friendly message", async () => { + const res = aiErrorResponse(makeApiCallError(true)); + expect(res.status).toBe(503); + const body = await res.json() as { error: string; retryable: boolean }; + expect(body.retryable).toBe(true); + expect(body.error).not.toMatch(/openrouter|nvidia|nemotron/i); + }); + + it("maps a non-retryable APICallError to a 502", async () => { + const res = aiErrorResponse(makeApiCallError(false)); + expect(res.status).toBe(502); + const body = await res.json() as { retryable: boolean }; + expect(body.retryable).toBe(false); + }); + + it("maps an unknown error to a generic 502", async () => { + const res = aiErrorResponse(new Error("boom")); + expect(res.status).toBe(502); + }); +}); + +describe("withAiQuota", () => { + it("returns 403 when the tier limit is already reached, without calling fn", async () => { + mockCheckAndIncrement.mockRejectedValue(new TierLimitError("aiCall", "free")); + const fn = vi.fn(); + const result = await withAiQuota("user-1", "free", fn); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.response.status).toBe(403); + expect(fn).not.toHaveBeenCalled(); + }); + + it("refunds the charged credit when fn throws", async () => { + const fn = vi.fn().mockRejectedValue(makeApiCallError(true)); + const result = await withAiQuota("user-1", "free", fn); + expect(result.ok).toBe(false); + expect(mockRefund).toHaveBeenCalledWith("user-1"); + if (!result.ok) expect(result.response.status).toBe(503); + }); + + it("returns fn's result and does not refund on success", async () => { + const fn = vi.fn().mockResolvedValue({ title: "Pasta" }); + const result = await withAiQuota("user-1", "free", fn); + expect(result.ok).toBe(true); + if (result.ok) expect(result.data).toEqual({ title: "Pasta" }); + expect(mockRefund).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/lib/ai/ai-error.ts b/apps/web/lib/ai/ai-error.ts new file mode 100644 index 0000000..73cab24 --- /dev/null +++ b/apps/web/lib/ai/ai-error.ts @@ -0,0 +1,55 @@ +import { NextResponse } from "next/server"; +import { APICallError } from "ai"; +import { checkAndIncrementTierLimit, refundAiCall, TierLimitError } from "@/lib/tiers"; + +/** + * Maps an AI-call failure to a clean JSON response instead of letting the + * raw provider error (which can include internal provider names/payloads) + * reach the client or crash the route as an uncaught 500. + */ +export function aiErrorResponse(err: unknown): NextResponse { + if (APICallError.isInstance(err)) { + return NextResponse.json( + { + error: err.isRetryable + ? "The AI provider is temporarily unavailable. Please try again in a moment." + : "The AI provider rejected this request. Try switching models in Settings.", + retryable: err.isRetryable, + }, + { status: err.isRetryable ? 503 : 502 } + ); + } + + return NextResponse.json({ error: "AI request failed. Please try again." }, { status: 502 }); +} + +type QuotaResult = { ok: true; data: T } | { ok: false; response: NextResponse }; + +/** + * Charges one aiCall credit, runs `fn`, and refunds the credit if `fn` + * throws — so a provider outage never silently burns a user's quota. + * Centralizes the TierLimitError → 403 and AI-failure → clean-JSON mapping + * that every AI route needs instead of repeating try/catch per route. + */ +export async function withAiQuota( + userId: string, + tier: "free" | "pro", + fn: () => Promise +): Promise> { + try { + await checkAndIncrementTierLimit(userId, tier, "aiCall"); + } catch (err) { + if (err instanceof TierLimitError) { + return { ok: false, response: NextResponse.json({ error: "AI call limit reached for your tier" }, { status: 403 }) }; + } + throw err; + } + + try { + const data = await fn(); + return { ok: true, data }; + } catch (err) { + await refundAiCall(userId); + return { ok: false, response: aiErrorResponse(err) }; + } +} diff --git a/apps/web/lib/tiers.ts b/apps/web/lib/tiers.ts index 32e1472..8e258f2 100644 --- a/apps/web/lib/tiers.ts +++ b/apps/web/lib/tiers.ts @@ -69,6 +69,20 @@ export async function checkAndIncrementTierLimit( } } +/** + * Refunds one aiCall credit for the current month. Call this when an AI + * request failed after the quota was already charged (e.g. provider error) + * so users aren't billed against their limit for a call that never succeeded. + */ +export async function refundAiCall(userId: string): Promise { + const month = currentMonth(); + await db.execute(sql` + UPDATE user_usage + SET ai_calls_used = GREATEST(ai_calls_used - 1, 0) + WHERE user_id = ${userId} AND month = ${month} + `); +} + export async function incrementUsage( userId: string, key: LimitKey,