openreader/src/app/api/tts/route.ts
Richard R 9da9232d39 feat: add support for Replicate TTS provider and models
- Updated environment variables documentation to include Replicate as a TTS provider option.
- Added Replicate to the sidebar for TTS provider guides.
- Included Replicate as a dependency in package.json and pnpm-lock.yaml.
- Enhanced audiobook chapter generation to normalize native speed settings based on the TTS provider.
- Improved error handling in TTS API routes to provide retry information for rate-limited responses.
- Updated AudiobookExportModal to reflect native speed support for Replicate models.
- Modified SettingsModal to set default model for Replicate.
- Enhanced SpeedControl component to conditionally render native speed controls based on provider support.
- Updated TTSContext to utilize effective native speed for TTS requests.
- Implemented Replicate request handling in the TTS generation logic.
- Added new documentation for configuring Replicate as a TTS provider.
2026-04-16 11:20:38 -06:00

273 lines
9.4 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import type { TTSRequestPayload } from '@/types/client';
import type { TTSError } from '@/types/tts';
import { headers } from 'next/headers';
import { auth } from '@/lib/server/auth/auth';
import { rateLimiter, RATE_LIMITS, isTtsRateLimitEnabled } from '@/lib/server/rate-limit/rate-limiter';
import { isAuthEnabled } from '@/lib/server/auth/config';
import { getClientIp } from '@/lib/server/rate-limit/request-ip';
import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id';
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
import {
buildTTSCacheKey,
generateTTSBuffer,
getCachedTTSBuffer,
getTTSContentType,
} from '@/lib/server/tts/generate';
export const runtime = 'nodejs';
export const maxDuration = 60;
function attachDeviceIdCookie(response: NextResponse, deviceId: string | null, didCreate: boolean) {
if (didCreate && deviceId) {
setDeviceIdCookie(response, deviceId);
}
}
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 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);
}
function getUpstreamStatus(error: unknown): number | undefined {
if (typeof error !== 'object' || error === null) return undefined;
const rec = error as Record<string, unknown>;
if (typeof rec.status === 'number') return rec.status;
if (typeof rec.statusCode === 'number') return rec.statusCode;
const response = rec.response as { status?: unknown } | undefined;
if (response && typeof response.status === 'number') return response.status;
return undefined;
}
function getUpstreamRetryAfterSeconds(error: unknown): number | undefined {
if (typeof error !== 'object' || error === null) return undefined;
const rec = error as Record<string, unknown>;
const response = rec.response as { headers?: { get?: (name: string) => string | null } } | undefined;
const retryAfterHeader = response?.headers?.get?.('retry-after');
if (!retryAfterHeader) return undefined;
const parsed = Number(retryAfterHeader);
if (!Number.isFinite(parsed) || parsed <= 0) return undefined;
return Math.ceil(parsed);
}
export async function POST(req: NextRequest) {
let providerForError: string | null = null;
let didCreateDeviceIdCookie = false;
let deviceIdToSet: string | null = null;
try {
const body = (await req.json()) as TTSRequestPayload;
const { text, voice, speed, format, model: reqModel, instructions } = body;
if (!text || !voice || !speed) {
const errorBody: TTSError = {
code: 'MISSING_PARAMETERS',
message: 'Missing required parameters',
};
return NextResponse.json(errorBody, { status: 400 });
}
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);
if (isTtsRateLimitEnabled()) {
const charCount = text.length;
const ip = getClientIp(req);
const device = isAnonymous ? getOrCreateDeviceId(req) : null;
if (device?.didCreate) {
didCreateDeviceIdCookie = true;
deviceIdToSet = device.deviceId;
}
const rateLimitResult = await rateLimiter.checkAndIncrementLimit(
{ id: session.user.id, isAnonymous },
charCount,
{
deviceId: device?.deviceId ?? null,
ip,
}
);
if (!rateLimitResult.allowed) {
const resetTimeMs = rateLimitResult.resetTimeMs;
const retryAfterSeconds = Math.max(
0,
Math.ceil((resetTimeMs - 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,
resetTimeMs,
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),
},
});
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
return response;
}
}
}
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';
const testNamespace = getOpenReaderTestNamespace(req.headers);
providerForError = provider;
const varyHeader = 'x-tts-provider, x-openai-key, x-openai-base-url, x-openreader-test-namespace';
const ttsRequest = {
text,
voice,
speed,
format,
model: reqModel,
instructions,
provider,
apiKey: openApiKey,
baseUrl: openApiBaseUrl,
testNamespace,
};
const contentType = getTTSContentType(format);
const cacheKey = buildTTSCacheKey(ttsRequest);
const etag = `W/"${cacheKey}"`;
const ifNoneMatch = req.headers.get('if-none-match');
const cachedBuffer = getCachedTTSBuffer(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: varyHeader,
},
});
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
return response;
}
const response = new NextResponse(new Uint8Array(cachedBuffer), {
headers: {
'Content-Type': contentType,
'X-Cache': 'HIT',
ETag: etag,
'Content-Length': String(cachedBuffer.byteLength),
'Cache-Control': 'private, max-age=1800',
Vary: varyHeader,
},
});
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
return response;
}
const buffer = await generateTTSBuffer(ttsRequest, req.signal);
const response = new NextResponse(new Uint8Array(buffer), {
headers: {
'Content-Type': contentType,
'X-Cache': 'MISS',
ETag: etag,
'Content-Length': String(buffer.byteLength),
'Cache-Control': 'private, max-age=1800',
Vary: varyHeader,
},
});
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
return response;
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return new NextResponse(null, { status: 499 });
}
const upstreamStatus = getUpstreamStatus(error);
if (upstreamStatus === 429) {
const retryAfterSeconds = getUpstreamRetryAfterSeconds(error);
const problem: ProblemDetails = {
type: PROBLEM_TYPES.upstreamRateLimited,
title: 'Upstream rate limited',
status: 429,
detail: retryAfterSeconds
? `The TTS provider is rate limiting requests. Please retry in about ${retryAfterSeconds}s.`
: 'The TTS provider is rate limiting requests. Please try again shortly.',
code: 'UPSTREAM_RATE_LIMIT',
provider: providerForError ?? undefined,
upstreamStatus,
retryAfterSeconds,
instance: req.nextUrl.pathname,
};
return new NextResponse(JSON.stringify(problem), {
status: 429,
headers: {
'Content-Type': 'application/problem+json',
...(retryAfterSeconds ? { 'Retry-After': String(retryAfterSeconds) } : {}),
},
});
}
const statusHint = getUpstreamStatus(error);
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn(
`Error generating TTS${typeof statusHint === 'number' ? ` (upstream ${statusHint})` : ''}: ${errorMessage}`,
);
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 });
}
}