refactor(tts): centralize upstream response helpers and improve settings validation

Move getUpstreamStatus and getUpstreamRetryAfterSeconds to a shared utility
module for consistent upstream error handling across TTS endpoints. Strengthen
audiobook chapter API by introducing runtime validation for incoming settings
payloads, ensuring type safety and error reporting for malformed requests.
Replace in-memory Map caches with LRUMap for Replicate voice and schema
lookups, improving memory management and eviction logic.
This commit is contained in:
Richard R 2026-04-16 13:07:31 -06:00
parent 893f74f038
commit 3db3fd19ce
5 changed files with 157 additions and 69 deletions

View file

@ -41,7 +41,7 @@ interface ConversionRequest {
bookId?: string;
format?: TTSAudiobookFormat;
chapterIndex?: number;
settings?: AudiobookGenerationSettings;
settings?: unknown;
}
type ChapterObject = {
@ -99,6 +99,29 @@ function normalizeNativeSpeedForSettings(settings: AudiobookGenerationSettings):
: { ...settings, nativeSpeed: 1 };
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function isAudiobookFormat(value: unknown): value is TTSAudiobookFormat {
return value === 'mp3' || value === 'm4b';
}
function isAudiobookGenerationSettings(value: unknown): value is AudiobookGenerationSettings {
if (typeof value !== 'object' || value === null) {
return false;
}
const record = value as Record<string, unknown>;
return typeof record.ttsProvider === 'string'
&& typeof record.ttsModel === 'string'
&& typeof record.voice === 'string'
&& isFiniteNumber(record.nativeSpeed)
&& isFiniteNumber(record.postSpeed)
&& isAudiobookFormat(record.format)
&& (record.ttsInstructions === undefined || typeof record.ttsInstructions === 'string');
}
function chapterFileMimeType(format: TTSAudiobookFormat): string {
return format === 'mp3' ? 'audio/mpeg' : 'audio/mp4';
}
@ -297,24 +320,41 @@ export async function POST(request: NextRequest) {
const existingChapters = listChapterObjects(objectNames);
const hasChapters = existingChapters.length > 0;
let existingSettings: AudiobookGenerationSettings | null = null;
let normalizedExistingSettings: AudiobookGenerationSettings | undefined;
try {
existingSettings = JSON.parse(
const parsedSettings = JSON.parse(
(await getAudiobookObjectBuffer(bookId, storageUserId, 'audiobook.meta.json', testNamespace)).toString('utf8'),
) as AudiobookGenerationSettings;
) as unknown;
if (!isAudiobookGenerationSettings(parsedSettings)) {
console.error('Invalid audiobook.meta.json settings payload', { bookId, storageUserId });
return NextResponse.json({ error: 'Invalid audiobook metadata settings' }, { status: 500 });
}
normalizedExistingSettings = normalizeNativeSpeedForSettings(parsedSettings);
} catch (error) {
if (!isMissingBlobError(error)) throw error;
existingSettings = null;
normalizedExistingSettings = undefined;
}
const normalizedExistingSettings = existingSettings ? normalizeNativeSpeedForSettings(existingSettings) : undefined;
const incomingSettings = data.settings ? normalizeNativeSpeedForSettings(data.settings) : undefined;
const mergedSettings = (normalizedExistingSettings || incomingSettings)
const incomingSettings = (() => {
if (data.settings === undefined) {
return undefined;
}
if (!isAudiobookGenerationSettings(data.settings)) {
return null;
}
return normalizeNativeSpeedForSettings(data.settings);
})();
if (incomingSettings === null) {
return NextResponse.json({ error: 'Invalid audiobook settings payload' }, { status: 400 });
}
const mergedSettings = normalizedExistingSettings && incomingSettings
? normalizeNativeSpeedForSettings({
...(normalizedExistingSettings || {}),
...(incomingSettings || {}),
} as AudiobookGenerationSettings)
: undefined;
...normalizedExistingSettings,
...incomingSettings,
})
: normalizedExistingSettings ?? incomingSettings;
if (normalizedExistingSettings && hasChapters && incomingSettings) {
const mismatch =
@ -511,7 +551,7 @@ export async function POST(request: NextRequest) {
await deleteAudiobookObject(bookId, storageUserId, 'complete.mp3.manifest.json', testNamespace).catch(() => {});
await deleteAudiobookObject(bookId, storageUserId, 'complete.m4b.manifest.json', testNamespace).catch(() => {});
if (!existingSettings && incomingSettings) {
if (!normalizedExistingSettings && incomingSettings) {
await putAudiobookObject(
bookId,
storageUserId,

View file

@ -14,6 +14,7 @@ import {
getCachedTTSBuffer,
getTTSContentType,
} from '@/lib/server/tts/generate';
import { getUpstreamRetryAfterSeconds, getUpstreamStatus } from '@/lib/server/tts/upstream-response';
export const runtime = 'nodejs';
export const maxDuration = 60;
@ -49,33 +50,6 @@ function formatLimitForHint(limit: number): string {
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 Math.ceil(parsed);
}
const parsedDateMs = Date.parse(retryAfterHeader);
if (!Number.isFinite(parsedDateMs)) return undefined;
const seconds = (parsedDateMs - Date.now()) / 1000;
if (seconds <= 0) return undefined;
return Math.ceil(seconds);
}
export async function POST(req: NextRequest) {
let providerForError: string | null = null;
let didCreateDeviceIdCookie = false;

View file

@ -8,6 +8,7 @@ import {
supportsNativeModelSpeed,
supportsTtsInstructions,
} from '@/lib/shared/tts-provider-catalog';
import { getUpstreamRetryAfterSeconds, getUpstreamStatus } from '@/lib/server/tts/upstream-response';
import { LRUCache } from 'lru-cache';
import { createHash } from 'crypto';
import { access, readFile } from 'fs/promises';
@ -93,33 +94,6 @@ function sleepWithSignal(ms: number, signal: AbortSignal): Promise<void> {
});
}
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 parsed;
}
const parsedDateMs = Date.parse(retryAfterHeader);
if (!Number.isFinite(parsedDateMs)) return undefined;
const seconds = (parsedDateMs - Date.now()) / 1000;
if (seconds <= 0) return undefined;
return Math.ceil(seconds);
}
function applyReplicateCooldown(cooldownMs: number) {
if (!Number.isFinite(cooldownMs) || cooldownMs <= 0) return;
const next = Date.now() + cooldownMs;

View file

@ -0,0 +1,53 @@
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
export function getUpstreamStatus(error: unknown): number | undefined {
if (!isRecord(error)) return undefined;
if (typeof error.status === 'number') return error.status;
if (typeof error.statusCode === 'number') return error.statusCode;
const response = isRecord(error.response) ? error.response : undefined;
if (response && typeof response.status === 'number') return response.status;
return undefined;
}
function readRetryAfterHeader(error: unknown): string | undefined {
if (!isRecord(error)) return undefined;
const response = isRecord(error.response) ? error.response : undefined;
if (!response) return undefined;
const headers = response.headers;
if (isRecord(headers) && typeof headers.get === 'function') {
const value = headers.get('retry-after');
return typeof value === 'string' && value.length > 0 ? value : undefined;
}
if (isRecord(headers)) {
const lowerCaseValue = headers['retry-after'];
if (typeof lowerCaseValue === 'string' && lowerCaseValue.length > 0) {
return lowerCaseValue;
}
const canonicalValue = headers['Retry-After'];
if (typeof canonicalValue === 'string' && canonicalValue.length > 0) {
return canonicalValue;
}
}
return undefined;
}
export function getUpstreamRetryAfterSeconds(error: unknown): number | undefined {
const retryAfterHeader = readRetryAfterHeader(error);
if (!retryAfterHeader) return undefined;
const parsed = Number(retryAfterHeader);
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
const parsedDateMs = Date.parse(retryAfterHeader);
if (!Number.isFinite(parsedDateMs)) return undefined;
const seconds = (parsedDateMs - Date.now()) / 1000;
if (seconds <= 0) return undefined;
return Math.ceil(seconds);
}

View file

@ -124,8 +124,55 @@ const DEEPINFRA_DEFAULT_VOICES_BY_MODEL: Record<string, readonly string[]> = {
'Zyphra/Zonos-v0.1-hybrid': ['random'],
'Zyphra/Zonos-v0.1-transformer': ['random'],
};
const replicateVoiceInputKeyCache = new Map<string, ReplicateVoiceInputKey>();
const replicateOpenApiSchemaPromiseCache = new Map<string, Promise<unknown | null>>();
class LRUMap<K, V> {
private readonly maxEntries: number;
private readonly store = new Map<K, V>();
constructor(maxEntries: number) {
this.maxEntries = Math.max(1, maxEntries);
}
get(key: K): V | undefined {
const value = this.store.get(key);
if (value === undefined) {
return undefined;
}
this.store.delete(key);
this.store.set(key, value);
return value;
}
set(key: K, value: V): this {
if (this.store.has(key)) {
this.store.delete(key);
}
this.store.set(key, value);
if (this.store.size > this.maxEntries) {
const oldestKey = this.store.keys().next().value as K | undefined;
if (oldestKey !== undefined) {
this.store.delete(oldestKey);
}
}
return this;
}
delete(key: K): boolean {
return this.store.delete(key);
}
}
const REPLICATE_VOICE_INPUT_KEY_CACHE_MAX_ENTRIES = 128;
const REPLICATE_OPENAPI_SCHEMA_PROMISE_CACHE_MAX_ENTRIES = 128;
const replicateVoiceInputKeyCache = new LRUMap<string, ReplicateVoiceInputKey>(
REPLICATE_VOICE_INPUT_KEY_CACHE_MAX_ENTRIES,
);
const replicateOpenApiSchemaPromiseCache = new LRUMap<string, Promise<unknown | null>>(
REPLICATE_OPENAPI_SCHEMA_PROMISE_CACHE_MAX_ENTRIES,
);
export const TTS_PROVIDER_DEFINITIONS: TtsProviderDefinition[] = [
{