import { getServerConfig } from "@/lib/config"; import { ttsProfile } from "@/lib/providers"; import { resolveModelTarget } from "@/lib/providers-server"; import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; import { acquireModelGate } from "@/lib/model-guard"; import { translate } from "@/lib/i18n-dict"; import { z } from "zod"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export const maxDuration = 120; const requestSchema = z.object({ /** One section of the script — a couple of hundred words at most. */ text: z.string().min(1).max(4000), voice: z.string().min(1).max(60).optional(), model: z.string().min(1).max(120).optional(), }); /** About 1 MB of text; audio comes back in the response, not the request. */ const MAX_BODY_BYTES = 1_048_576; /** * Natural podcast voices: the provider's own text-to-speech, proxied so the * key stays in the browser (it travels in the header, never lands on disk). * OpenAI-compatible `/audio/speech` only — OpenAI, Groq, and whatever local * server the custom provider points at. */ export async function POST(request: Request) { const locale = requestLocale(request); const crossOrigin = rejectCrossOrigin(request); if (crossOrigin) return crossOrigin; const oversized = rejectOversizedBody(request, MAX_BODY_BYTES); if (oversized) return oversized; const config = getServerConfig(); const limit = rateLimit(`tts:${clientKey(request)}`, config.rateLimits.generate, config.rateLimits.windowMs); if (!limit.allowed) return rateLimitResponse(limit, locale); /* TTS is a provider feature: no provider key, no natural voice. */ const resolved = resolveModelTarget(config, request, locale); if ("error" in resolved) { return Response.json({ error: resolved.error.message, kind: resolved.error.kind }, { status: resolved.error.status }); } if (resolved.target.kind !== "provider") { return Response.json( { error: translate(locale, "server.ttsNeedsProvider"), kind: "needs_key" }, { status: 400 }, ); } const provider = resolved.target.target; const providerId = (request.headers.get("x-freezmo-provider") ?? "").trim().toLowerCase(); const profile = ttsProfile(providerId); if (!profile) { return Response.json( { error: translate(locale, "server.ttsUnsupported", { provider: provider.label }), kind: "bad_provider" }, { status: 400 }, ); } let payload: unknown; try { payload = await request.json(); } catch { return Response.json({ error: translate(locale, "server.bodyNotJson") }, { status: 400 }); } const parsed = requestSchema.safeParse(payload); if (!parsed.success) { return Response.json({ error: translate(locale, "server.invalidRequest", { detail: "text" }) }, { status: 400 }); } const gate = acquireModelGate(request, config, locale); if ("response" in gate) return gate.response; const model = parsed.data.model ?? profile.model; const voice = parsed.data.voice ?? profile.voices.find((entry) => entry.male)?.id ?? profile.voices[0].id; const abort = new AbortController(); const timer = setTimeout(() => abort.abort(), config.timeoutMs); const onRequestAbort = () => abort.abort(); if (request.signal.aborted) abort.abort(); else request.signal.addEventListener("abort", onRequestAbort, { once: true }); try { const upstream = await fetch(`${provider.baseUrl.replace(/\/$/, "")}/audio/speech`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${provider.apiKey}`, }, body: JSON.stringify({ model, voice, input: parsed.data.text, response_format: profile.format, }), signal: abort.signal, }); if (!upstream.ok || !upstream.body) { const detail = await upstream.text().catch(() => ""); /* Never log the text that was spoken; only the status. */ console.error("[api/tts]", upstream.status, detail.slice(0, 200)); const kind = upstream.status === 401 || upstream.status === 403 ? "auth" : upstream.status === 429 ? "usage_limit" : "unknown"; return Response.json( { error: translate(locale, "server.ttsFailed", { provider: provider.label }), kind }, { status: upstream.status === 429 ? 429 : 502 }, ); } return new Response(upstream.body, { headers: { "Content-Type": profile.format === "wav" ? "audio/wav" : "audio/mpeg", "Cache-Control": "no-store, no-transform", }, }); } catch (error) { const timedOut = error instanceof Error && error.name === "AbortError" && !request.signal.aborted; console.error("[api/tts]", (error as Error)?.name ?? error); return Response.json( { error: translate(locale, timedOut ? "server.ttsTimeout" : "server.ttsFailed", { provider: provider.label }), kind: "network" }, { status: 502 }, ); } finally { clearTimeout(timer); request.signal.removeEventListener("abort", onRequestAbort); gate.gate.release(); } }