import { NextResponse } from "next/server"; import { APICallError } from "ai"; import { checkAndIncrementTierLimit, refundAiCall, TierLimitError } from "@/lib/tiers"; import { ByokDecryptError } from "@/lib/ai/resolve-user-key"; /** * 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 }); } /** * Resolves a BYOK/model config and converts a decrypt failure into a clean * 400 response instead of letting it throw uncaught (or letting the caller * silently fall back to the platform key/billing). */ export async function resolveAiConfigOrError(resolve: () => Promise): Promise<{ ok: true; data: T } | { ok: false; response: NextResponse }> { try { return { ok: true, data: await resolve() }; } catch (err) { if (err instanceof ByokDecryptError) { return { ok: false, response: NextResponse.json({ error: err.message }, { status: 400 }) }; } throw err; } } 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. * * Pass `skipQuota: true` when the resolved AiConfig used the caller's own * BYOK key (config.isByok) — it's their own credentials/billing, so it * shouldn't count against the platform's monthly AI-call limit. */ export async function withAiQuota( userId: string, tier: "free" | "pro" | "team", fn: () => Promise, opts?: { skipQuota?: boolean } ): Promise> { if (!opts?.skipQuota) { 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) { if (!opts?.skipQuota) await refundAiCall(userId); return { ok: false, response: aiErrorResponse(err) }; } }