Files
Epicure/apps/web/app/api/v1/ai/substitute/route.ts
T
Arnaud affa6f5c3d feat: admin-configurable default AI providers/models (v0.30.0)
Add DEFAULT_{TEXT,VISION,MEAL_PLAN}_{PROVIDER,MODEL} site settings,
editable from Settings -> Admin (new AdminDefaultModelForm, mirroring
the per-user model-prefs UI). getDefaultProviderWithKey now accepts
the use case and checks the admin default (after the user's own BYOK
key, before the old "first configured site key" heuristic) so admins
can pin a specific provider/model per feature instead of it being
whichever key happens to exist.

Wired into scale/substitute/batch-cook/meal-plan generation routes,
which previously called getDefaultProviderWithKey() without a use
case and so never had visibility into per-feature routing at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 13:54:24 +02:00

49 lines
1.9 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { withAiQuota, resolveAiConfigOrError } 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";
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 requireSessionOrApiKey(req);
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 });
const rateLimitRes = await applyRateLimit(`ai:substitute:${session!.user.id}`, 10, 60);
if (rateLimitRes) return rateLimitRes;
const context = parsed.data.recipeTitle
? `recipe "${parsed.data.recipeTitle}"`
: "a general recipe";
let aiConfig;
if (parsed.data.provider) {
aiConfig = { provider: parsed.data.provider, model: parsed.data.model };
} else {
const configResult = await resolveAiConfigOrError(() => getDefaultProviderWithKey(session!.user.id, "text"));
if (!configResult.ok) return configResult.response;
aiConfig = configResult.data;
}
const locale = (session!.user as { locale?: string }).locale ?? "en";
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: result.data });
}