c8f4b50ef3
All literal "team" tier-value references renamed to "family" across API routes, admin UI, OpenAPI schemas, and lib/tiers.ts. The DB enum value itself is renamed in place via ALTER TYPE ... RENAME VALUE (migration 0044) rather than drizzle-kit's auto-generated drop-and-recreate-the-enum migration, which would have failed against any existing row still holding 'team' — RENAME VALUE preserves existing data with no cast/backfill needed. Also adds STRIPE_PLAN.md — a full Stripe billing integration plan (Checkout+Portal, tier→Price mapping, admin billing dashboard, and a multi-user Family-group design since Family is meant to cover several accounts under one subscription, not one payer). Planning only, no Stripe code yet. v0.47.0
80 lines
2.8 KiB
TypeScript
80 lines
2.8 KiB
TypeScript
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<T>(resolve: () => Promise<T>): 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<T> = { 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<T>(
|
|
userId: string,
|
|
tier: "free" | "pro" | "family",
|
|
fn: () => Promise<T>,
|
|
opts?: { skipQuota?: boolean }
|
|
): Promise<QuotaResult<T>> {
|
|
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) };
|
|
}
|
|
}
|