import { NextRequest, NextResponse } from 'next/server'; import OpenAI from 'openai'; import { SpeechCreateParams } from 'openai/resources/audio/speech.mjs'; import { isKokoroModel } from '@/utils/voice'; import { LRUCache } from 'lru-cache'; import { createHash } from 'crypto'; import type { TTSRequestPayload } from '@/types/client'; import type { TTSError, TTSAudioBuffer } from '@/types/tts'; import { headers } from 'next/headers'; import { auth } from '@/lib/server/auth'; import { rateLimiter, RATE_LIMITS } from '@/lib/server/rate-limiter'; import { isAuthEnabled } from '@/lib/server/auth-config'; import { getClientIp } from '@/lib/server/request-ip'; import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/device-id'; type CustomVoice = string; type ExtendedSpeechParams = Omit & { voice: SpeechCreateParams['voice'] | CustomVoice; instructions?: string; }; type AudioBufferValue = TTSAudioBuffer; const TTS_CACHE_MAX_SIZE_BYTES = Number(process.env.TTS_CACHE_MAX_SIZE_BYTES || 256 * 1024 * 1024); // 256MB const TTS_CACHE_TTL_MS = Number(process.env.TTS_CACHE_TTL_MS || 1000 * 60 * 30); // 30 minutes const ttsAudioCache = new LRUCache({ maxSize: TTS_CACHE_MAX_SIZE_BYTES, sizeCalculation: (value) => value.byteLength, ttl: TTS_CACHE_TTL_MS, }); type InflightEntry = { promise: Promise; controller: AbortController; consumers: number; }; const inflightRequests = new Map(); const PROBLEM_TYPES = { dailyQuotaExceeded: 'https://openreader.app/problems/daily-quota-exceeded', upstreamRateLimited: 'https://openreader.app/problems/upstream-rate-limited', } as const; type ProblemDetails = { type: string; title: string; status: number; detail?: string; instance?: string; code?: string; [key: string]: unknown; }; function sleep(ms: number) { return new Promise((res) => setTimeout(res, ms)); } async function fetchTTSBufferWithRetry( openai: OpenAI, createParams: ExtendedSpeechParams, signal: AbortSignal ): Promise { let attempt = 0; const maxRetries = Number(process.env.TTS_MAX_RETRIES ?? 2); let delay = Number(process.env.TTS_RETRY_INITIAL_MS ?? 250); const maxDelay = Number(process.env.TTS_RETRY_MAX_MS ?? 2000); const backoff = Number(process.env.TTS_RETRY_BACKOFF ?? 2); // Retry on 429 and 5xx only; never retry aborts for (; ;) { try { const response = await openai.audio.speech.create(createParams as SpeechCreateParams, { signal }); return await response.arrayBuffer(); } catch (err: unknown) { if (signal?.aborted || (err instanceof Error && err.name === 'AbortError')) { throw err; } const status = (() => { if (typeof err === 'object' && err !== null) { const rec = err as Record; if (typeof rec.status === 'number') return rec.status as number; if (typeof rec.statusCode === 'number') return rec.statusCode as number; } return 0; })(); const retryable = status === 429 || status >= 500; if (!retryable || attempt >= maxRetries) { throw err; } await sleep(Math.min(delay, maxDelay)); delay = Math.min(maxDelay, delay * backoff); attempt += 1; } } } function makeCacheKey(input: { provider: string; model: string | null | undefined; voice: string | undefined; speed: number; format: string; text: string; instructions?: string; }) { const canonical = { provider: input.provider, model: input.model || '', voice: input.voice || '', speed: input.speed, format: input.format, text: input.text, // Only include instructions when present (for models like gpt-4o-mini-tts) instructions: input.instructions || undefined, }; return createHash('sha256').update(JSON.stringify(canonical)).digest('hex'); } function formatLimitForHint(limit: number): string { if (!Number.isFinite(limit) || limit <= 0) return String(limit); if (limit >= 1_000_000) { const m = limit / 1_000_000; return `${m % 1 === 0 ? m.toFixed(0) : m.toFixed(1)}M`; } if (limit >= 1_000) return `${Math.round(limit / 1_000)}K`; return String(limit); } export async function POST(req: NextRequest) { let providerForError: string | null = null; try { // Parse body first to get text for rate limiting const body = (await req.json()) as TTSRequestPayload; const { text, voice, speed, format, model: req_model, instructions } = body; if (!text || !voice || !speed) { const errorBody: TTSError = { code: 'MISSING_PARAMETERS', message: 'Missing required parameters', }; return NextResponse.json(errorBody, { status: 400 }); } // Auth and rate limiting check (only when auth is enabled) let didCreateDeviceIdCookie = false; let deviceIdToSet: string | null = null; if (isAuthEnabled() && auth) { const session = await auth.api.getSession({ headers: await headers() }); if (!session?.user) { return NextResponse.json( { code: 'UNAUTHORIZED', message: 'Authentication required' }, { status: 401 } ); } const isAnonymous = Boolean(session.user.isAnonymous); const charCount = text.length; const ip = getClientIp(req); const device = isAnonymous ? getOrCreateDeviceId(req) : null; if (device?.didCreate) { didCreateDeviceIdCookie = true; deviceIdToSet = device.deviceId; } // Check rate limit const rateLimitResult = await rateLimiter.checkAndIncrementLimit( { id: session.user.id, isAnonymous }, charCount, { deviceId: device?.deviceId ?? null, ip, } ); if (!rateLimitResult.allowed) { const resetTime = rateLimitResult.resetTime.toISOString(); const retryAfterSeconds = Math.max( 0, Math.ceil((rateLimitResult.resetTime.getTime() - Date.now()) / 1000) ); const problem: ProblemDetails = { type: PROBLEM_TYPES.dailyQuotaExceeded, title: 'Daily quota exceeded', status: 429, detail: 'Daily character limit exceeded', code: 'USER_DAILY_QUOTA_EXCEEDED', currentCount: rateLimitResult.currentCount, limit: rateLimitResult.limit, remainingChars: rateLimitResult.remainingChars, resetTime, userType: isAnonymous ? 'anonymous' : 'authenticated', upgradeHint: isAnonymous ? `Sign up to increase your limit from ${formatLimitForHint(RATE_LIMITS.ANONYMOUS)} to ${formatLimitForHint(RATE_LIMITS.AUTHENTICATED)} characters per day` : undefined, instance: req.nextUrl.pathname, }; const response = new NextResponse(JSON.stringify(problem), { status: 429, headers: { 'Content-Type': 'application/problem+json', 'Retry-After': String(retryAfterSeconds), }, }); if (didCreateDeviceIdCookie && deviceIdToSet) { setDeviceIdCookie(response, deviceIdToSet); } return response; } } // Get API credentials from headers or fall back to environment variables const openApiKey = req.headers.get('x-openai-key') || process.env.API_KEY || 'none'; const openApiBaseUrl = req.headers.get('x-openai-base-url') || process.env.API_BASE; const provider = req.headers.get('x-tts-provider') || 'openai'; providerForError = provider; console.log('Received TTS request:', { provider, req_model, voice, speed, format, hasInstructions: Boolean(instructions) }); // Use default Kokoro model for Deepinfra if none specified, then fall back to a safe default const rawModel = provider === 'deepinfra' && !req_model ? 'hexgrad/Kokoro-82M' : req_model; const model: SpeechCreateParams['model'] = (rawModel ?? 'gpt-4o-mini-tts') as SpeechCreateParams['model']; // Initialize OpenAI client with abort signal (OpenAI/deepinfra) const openai = new OpenAI({ apiKey: openApiKey, baseURL: openApiBaseUrl, }); const normalizedVoice = ( !isKokoroModel(model as string) && voice.includes('+') ? (voice.split('+')[0].trim()) : voice ) as SpeechCreateParams['voice']; const createParams: ExtendedSpeechParams = { model: model, voice: normalizedVoice, input: text, speed: speed, response_format: format, }; // Only add instructions if model is gpt-4o-mini-tts and instructions are provided if ((model as string) === 'gpt-4o-mini-tts' && instructions) { createParams.instructions = instructions; } // Compute cache key and check LRU before making provider call const contentType = 'audio/mpeg'; // Preserve voice string as-is for cache key (no weight stripping) const voiceForKey = typeof createParams.voice === 'string' ? createParams.voice : String(createParams.voice); const cacheKey = makeCacheKey({ provider, model: createParams.model, voice: voiceForKey, speed: Number(createParams.speed), format: String(createParams.response_format), text, instructions: createParams.instructions, }); const etag = `W/"${cacheKey}"`; const ifNoneMatch = req.headers.get('if-none-match'); const cachedBuffer = ttsAudioCache.get(cacheKey); if (cachedBuffer) { if (ifNoneMatch && (ifNoneMatch.includes(cacheKey) || ifNoneMatch.includes(etag))) { const response = new NextResponse(null, { status: 304, headers: { 'ETag': etag, 'Cache-Control': 'private, max-age=1800', 'Vary': 'x-tts-provider, x-openai-key, x-openai-base-url' } }); if (didCreateDeviceIdCookie && deviceIdToSet) { setDeviceIdCookie(response, deviceIdToSet); } return response; } console.log('TTS cache HIT for key:', cacheKey.slice(0, 8)); const response = new NextResponse(cachedBuffer, { headers: { 'Content-Type': contentType, 'X-Cache': 'HIT', 'ETag': etag, 'Content-Length': String(cachedBuffer.byteLength), 'Cache-Control': 'private, max-age=1800', 'Vary': 'x-tts-provider, x-openai-key, x-openai-base-url' } }); if (didCreateDeviceIdCookie && deviceIdToSet) { setDeviceIdCookie(response, deviceIdToSet); } return response; } // De-duplicate identical in-flight requests const existing = inflightRequests.get(cacheKey); if (existing) { console.log('TTS in-flight JOIN for key:', cacheKey.slice(0, 8)); existing.consumers += 1; const onAbort = () => { existing.consumers = Math.max(0, existing.consumers - 1); if (existing.consumers === 0) { existing.controller.abort(); } }; req.signal.addEventListener('abort', onAbort, { once: true }); try { const buffer = await existing.promise; const response = new NextResponse(buffer, { headers: { 'Content-Type': contentType, 'X-Cache': 'INFLIGHT', 'ETag': etag, 'Content-Length': String(buffer.byteLength), 'Cache-Control': 'private, max-age=1800', 'Vary': 'x-tts-provider, x-openai-key, x-openai-base-url' } }); if (didCreateDeviceIdCookie && deviceIdToSet) { setDeviceIdCookie(response, deviceIdToSet); } return response; } finally { try { req.signal.removeEventListener('abort', onAbort); } catch { } } } const controller = new AbortController(); const entry: InflightEntry = { controller, consumers: 1, promise: (async () => { try { const buffer = await fetchTTSBufferWithRetry(openai, createParams, controller.signal); // Save to cache ttsAudioCache.set(cacheKey, buffer); return buffer; } finally { inflightRequests.delete(cacheKey); } })() }; inflightRequests.set(cacheKey, entry); const onAbort = () => { entry.consumers = Math.max(0, entry.consumers - 1); if (entry.consumers === 0) { entry.controller.abort(); } }; req.signal.addEventListener('abort', onAbort, { once: true }); let buffer: TTSAudioBuffer; try { buffer = await entry.promise; } finally { try { req.signal.removeEventListener('abort', onAbort); } catch { } } const response = new NextResponse(buffer, { headers: { 'Content-Type': contentType, 'X-Cache': 'MISS', 'ETag': etag, 'Content-Length': String(buffer.byteLength), 'Cache-Control': 'private, max-age=1800', 'Vary': 'x-tts-provider, x-openai-key, x-openai-base-url' } }); if (didCreateDeviceIdCookie && deviceIdToSet) { setDeviceIdCookie(response, deviceIdToSet); } return response; } catch (error) { // Check if this was an abort error if (error instanceof Error && error.name === 'AbortError') { console.log('TTS request aborted by client'); return new NextResponse(null, { status: 499 }); // Use 499 status for client closed request } const upstreamStatus = (() => { if (typeof error === 'object' && error !== null) { const rec = error as Record; if (typeof rec.status === 'number') return rec.status as number; if (typeof rec.statusCode === 'number') return rec.statusCode as number; } return undefined; })(); if (upstreamStatus === 429) { const problem: ProblemDetails = { type: PROBLEM_TYPES.upstreamRateLimited, title: 'Upstream rate limited', status: 429, detail: 'The TTS provider is rate limiting requests. Please try again shortly.', code: 'UPSTREAM_RATE_LIMIT', provider: providerForError ?? undefined, upstreamStatus, instance: req.nextUrl.pathname, }; return new NextResponse(JSON.stringify(problem), { status: 429, headers: { 'Content-Type': 'application/problem+json', }, }); } console.warn('Error generating TTS:', error); const errorBody: TTSError = { code: 'TTS_GENERATION_FAILED', message: 'Failed to generate audio', details: process.env.NODE_ENV !== 'production' ? String(error) : undefined, }; return NextResponse.json( errorBody, { status: 500 } ); } }