refactor(tts): extract voice resolution logic to server module and update imports

Move resolveVoices and resolveReplicateVoiceInputKey to a new server-side
voice-resolution module to improve separation of concerns and reduce shared
bundle size. Update all imports and related tests to use the new module.
Remove unused LRUMap and related caches from shared catalog. Update Replicate
cooldown logic to use per-scope LRU cache for improved concurrency handling.
This commit is contained in:
Richard R 2026-04-16 15:46:55 -06:00
parent 67097109ed
commit 0db53fbd4e
6 changed files with 534 additions and 379 deletions

View file

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/server/auth/auth';
import { getDefaultVoices, resolveVoices } from '@/lib/shared/tts-provider-catalog';
import { getDefaultVoices } from '@/lib/shared/tts-provider-catalog';
import { resolveVoices } from '@/lib/server/tts/voice-resolution';
export async function GET(req: NextRequest) {
try {

View file

@ -4,10 +4,10 @@ import { SpeechCreateParams } from 'openai/resources/audio/speech.mjs';
import { isKokoroModel } from '@/lib/shared/kokoro';
import {
REPLICATE_KOKORO_82M_VERSIONED_MODEL,
resolveReplicateVoiceInputKey,
supportsNativeModelSpeed,
supportsTtsInstructions,
} from '@/lib/shared/tts-provider-catalog';
import { resolveReplicateVoiceInputKey } from '@/lib/server/tts/voice-resolution';
import { getUpstreamRetryAfterSeconds, getUpstreamStatus } from '@/lib/server/tts/upstream-response';
import { LRUCache } from 'lru-cache';
import { createHash } from 'crypto';
@ -52,7 +52,10 @@ type InflightEntry = {
consumers: number;
};
let replicateBlockedUntilMs = 0;
const REPLICATE_COOLDOWN_SCOPE_CACHE_MAX_ENTRIES = 512;
const replicateBlockedUntilByScope = new LRUCache<string, number>({
max: REPLICATE_COOLDOWN_SCOPE_CACHE_MAX_ENTRIES,
});
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
@ -94,20 +97,131 @@ function sleepWithSignal(ms: number, signal: AbortSignal): Promise<void> {
});
}
function applyReplicateCooldown(cooldownMs: number) {
if (!Number.isFinite(cooldownMs) || cooldownMs <= 0) return;
const next = Date.now() + cooldownMs;
replicateBlockedUntilMs = Math.max(replicateBlockedUntilMs, next);
function getReplicateCooldownScopeKey(request: ResolvedServerTTSRequest): string {
return createHash('sha256')
.update(`replicate:${request.apiKey}:${request.model as string}`)
.digest('hex');
}
async function runWithReplicateGate<T>(signal: AbortSignal, operation: () => Promise<T>): Promise<T> {
const waitMs = Math.max(0, replicateBlockedUntilMs - Date.now());
function applyReplicateCooldown(scopeKey: string, cooldownMs: number) {
if (!Number.isFinite(cooldownMs) || cooldownMs <= 0) return;
const next = Date.now() + cooldownMs;
const current = replicateBlockedUntilByScope.get(scopeKey) ?? 0;
replicateBlockedUntilByScope.set(scopeKey, Math.max(current, next));
}
async function runWithReplicateGate<T>(
scopeKey: string,
signal: AbortSignal,
operation: () => Promise<T>
): Promise<T> {
const blockedUntilMs = replicateBlockedUntilByScope.get(scopeKey) ?? 0;
const waitMs = Math.max(0, blockedUntilMs - Date.now());
if (waitMs > 0) {
await sleepWithSignal(waitMs, signal);
}
return operation();
}
function normalizeReplicateUrlCandidate(value: unknown): string | null {
if (value instanceof URL) {
return value.toString();
}
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
if (trimmed.startsWith('data:')) {
return trimmed;
}
try {
const parsed = new URL(trimmed);
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? trimmed : null;
} catch {
return null;
}
}
function extractReplicateAudioUrlFromValue(value: unknown, seen: Set<object>): string | null {
const direct = normalizeReplicateUrlCandidate(value);
if (direct) {
return direct;
}
if (typeof value !== 'object' || value === null) {
return null;
}
if (seen.has(value)) {
return null;
}
seen.add(value);
if (Array.isArray(value)) {
for (const item of value) {
const extracted = extractReplicateAudioUrlFromValue(item, seen);
if (extracted) {
return extracted;
}
}
return null;
}
const maybeUrlMethod = (value as { url?: unknown }).url;
if (typeof maybeUrlMethod === 'function') {
try {
const fromUrlMethod = normalizeReplicateUrlCandidate(maybeUrlMethod.call(value));
if (fromUrlMethod) {
return fromUrlMethod;
}
} catch { }
} else {
const fromUrlField = normalizeReplicateUrlCandidate(maybeUrlMethod);
if (fromUrlField) {
return fromUrlField;
}
}
const maybeToString = (value as { toString?: unknown }).toString;
if (typeof maybeToString === 'function') {
try {
const fromToString = normalizeReplicateUrlCandidate(maybeToString.call(value));
if (fromToString) {
return fromToString;
}
} catch { }
}
const record = value as Record<string, unknown>;
for (const key of ['audio', 'output', 'outputs', 'file', 'files', 'data', 'result']) {
if (!(key in record)) continue;
const extracted = extractReplicateAudioUrlFromValue(record[key], seen);
if (extracted) {
return extracted;
}
}
for (const nested of Object.values(record)) {
const extracted = extractReplicateAudioUrlFromValue(nested, seen);
if (extracted) {
return extracted;
}
}
return null;
}
export function extractReplicateAudioUrl(output: unknown): string | null {
return extractReplicateAudioUrlFromValue(output, new Set<object>());
}
function resolveTTSRequest(input: ServerTTSRequest): ResolvedServerTTSRequest {
const provider = input.provider || 'openai';
const rawModel = provider === 'deepinfra' && !input.model ? 'hexgrad/Kokoro-82M'
@ -319,8 +433,9 @@ async function runReplicateRequest(request: ResolvedServerTTSRequest, signal: Ab
const replicate = new Replicate({ auth: request.apiKey });
const input = await buildReplicateInput(request);
const modelId = request.model as `${string}/${string}`;
const cooldownScopeKey = getReplicateCooldownScopeKey(request);
return runWithReplicateGate(signal, async () => {
return runWithReplicateGate(cooldownScopeKey, signal, async () => {
const maxRetries = Number(process.env.TTS_MAX_RETRIES ?? 2);
let attempt = 0;
@ -328,8 +443,10 @@ async function runReplicateRequest(request: ResolvedServerTTSRequest, signal: Ab
try {
const output = await replicate.run(modelId, { input, signal }) as unknown;
// Output is a URI string pointing to the generated audio file
const audioUrl = typeof output === 'string' ? output : String(output);
const audioUrl = extractReplicateAudioUrl(output);
if (!audioUrl) {
throw new Error('Replicate output did not include a fetchable audio URL');
}
const audioResponse = await fetch(audioUrl, { signal });
if (!audioResponse.ok) {
const error = new Error(`Failed to fetch Replicate audio: ${audioResponse.status}`) as Error & {
@ -357,7 +474,7 @@ async function runReplicateRequest(request: ResolvedServerTTSRequest, signal: Ab
const retryAfterSeconds = status === 429 ? getUpstreamRetryAfterSeconds(error) : undefined;
const delay = retryAfterSeconds ? Math.max(retryAfterSeconds * 1000, 1000) : 10_000;
if (status === 429) {
applyReplicateCooldown(delay);
applyReplicateCooldown(cooldownScopeKey, delay);
}
if (!retryable || attempt >= maxRetries) {

View file

@ -0,0 +1,359 @@
import { LRUCache } from 'lru-cache';
import {
getDefaultVoices,
resolveProviderModels,
resolveVoiceSource,
type ReplicateVoiceInputKey,
type ResolveVoicesOptions,
} from '@/lib/shared/tts-provider-catalog';
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function parseReplicateModelIdentifier(model: string): {
owner: string;
name: string;
version?: string;
} | null {
const [ref, version] = model.split(':', 2);
const segments = ref.split('/');
if (segments.length !== 2 || !segments[0] || !segments[1]) {
return null;
}
const parsed = {
owner: segments[0],
name: segments[1],
};
return version
? { ...parsed, version }
: parsed;
}
function extractSchemaStringEnums(schemaNode: unknown, seen = new Set<object>()): string[] {
if (!isRecord(schemaNode)) {
return [];
}
if (seen.has(schemaNode)) {
return [];
}
seen.add(schemaNode);
const values: string[] = [];
if (Array.isArray(schemaNode.enum)) {
values.push(...schemaNode.enum.filter((value): value is string => typeof value === 'string'));
}
if (typeof schemaNode.const === 'string') {
values.push(schemaNode.const);
}
for (const key of ['anyOf', 'allOf', 'oneOf'] as const) {
const branch = schemaNode[key];
if (!Array.isArray(branch)) continue;
for (const item of branch) {
values.push(...extractSchemaStringEnums(item, seen));
}
}
if (schemaNode.items) {
values.push(...extractSchemaStringEnums(schemaNode.items, seen));
}
return values;
}
function walkRecordGraph(root: unknown, visit: (node: Record<string, unknown>) => boolean | void): void {
if (!isRecord(root)) {
return;
}
const stack: Record<string, unknown>[] = [root];
const seen = new Set<object>();
while (stack.length > 0) {
const current = stack.pop();
if (!current) {
continue;
}
if (seen.has(current)) {
continue;
}
seen.add(current);
if (visit(current)) {
return;
}
for (const value of Object.values(current)) {
if (Array.isArray(value)) {
for (const item of value) {
if (isRecord(item)) {
stack.push(item);
}
}
} else if (isRecord(value)) {
stack.push(value);
}
}
}
}
const REPLICATE_VOICE_KEYS = ['voice', 'voice_id', 'speaker'] as const satisfies readonly ReplicateVoiceInputKey[];
const REPLICATE_BUILT_IN_MODELS = new Set(
resolveProviderModels('replicate')
.map((model) => model.id)
.filter((id) => id !== 'custom')
);
function extractReplicateVoicesFromOpenApiSchema(openApiSchema: unknown): string[] {
const voices: string[] = [];
walkRecordGraph(openApiSchema, (node) => {
const properties = node.properties;
if (!isRecord(properties)) {
return;
}
for (const key of REPLICATE_VOICE_KEYS) {
if (!(key in properties)) continue;
voices.push(...extractSchemaStringEnums(properties[key]));
}
});
return Array.from(
new Set(
voices
.map((voice) => voice.trim())
.filter((voice) => voice.length > 0)
)
);
}
function extractReplicateVoiceInputKeyFromOpenApiSchema(openApiSchema: unknown): ReplicateVoiceInputKey | null {
let found: ReplicateVoiceInputKey | null = null;
walkRecordGraph(openApiSchema, (node) => {
const properties = node.properties;
if (!isRecord(properties)) {
return;
}
for (const key of REPLICATE_VOICE_KEYS) {
if (key in properties) {
found = key;
return true;
}
}
});
return found;
}
async function fetchReplicateOpenApiSchema(apiKey: string, model: string): Promise<unknown | null> {
const parsedModel = parseReplicateModelIdentifier(model);
if (!parsedModel) {
return null;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, 10_000);
try {
const endpoint = parsedModel.version
? `https://api.replicate.com/v1/models/${parsedModel.owner}/${parsedModel.name}/versions/${parsedModel.version}`
: `https://api.replicate.com/v1/models/${parsedModel.owner}/${parsedModel.name}`;
const response = await fetch(endpoint, {
signal: controller.signal,
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
return null;
}
const data = await response.json();
let openApiSchema: unknown = null;
if (parsedModel.version) {
if (isRecord(data)) {
openApiSchema = data.openapi_schema;
}
} else if (isRecord(data) && isRecord(data.latest_version)) {
openApiSchema = data.latest_version.openapi_schema;
}
return openApiSchema;
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
return null;
}
console.error('Error fetching Replicate model schema:', error);
return null;
} finally {
clearTimeout(timeoutId);
}
}
const REPLICATE_VOICE_INPUT_KEY_CACHE_MAX_ENTRIES = 128;
const REPLICATE_OPENAPI_SCHEMA_PROMISE_CACHE_MAX_ENTRIES = 128;
const replicateVoiceInputKeyCache = new LRUCache<string, ReplicateVoiceInputKey>({
max: REPLICATE_VOICE_INPUT_KEY_CACHE_MAX_ENTRIES,
});
const replicateOpenApiSchemaPromiseCache = new LRUCache<string, Promise<unknown | null>>({
max: REPLICATE_OPENAPI_SCHEMA_PROMISE_CACHE_MAX_ENTRIES,
});
async function getReplicateOpenApiSchemaCached(apiKey: string, model: string): Promise<unknown | null> {
const cachedPromise = replicateOpenApiSchemaPromiseCache.get(model);
if (cachedPromise) {
return cachedPromise;
}
const fetchPromise = fetchReplicateOpenApiSchema(apiKey, model);
replicateOpenApiSchemaPromiseCache.set(model, fetchPromise);
const schema = await fetchPromise;
if (schema === null) {
replicateOpenApiSchemaPromiseCache.delete(model);
}
return schema;
}
async function fetchReplicateVoices(apiKey: string, model: string): Promise<string[] | null> {
const openApiSchema = await getReplicateOpenApiSchemaCached(apiKey, model);
const apiVoices = extractReplicateVoicesFromOpenApiSchema(openApiSchema);
return apiVoices.length > 0 ? apiVoices : null;
}
export async function resolveReplicateVoiceInputKey({
provider,
model,
apiKey = '',
}: ResolveVoicesOptions): Promise<ReplicateVoiceInputKey | null> {
if (provider !== 'replicate' || REPLICATE_BUILT_IN_MODELS.has(model) || !apiKey) {
return null;
}
const cached = replicateVoiceInputKeyCache.get(model);
if (cached) {
return cached;
}
const openApiSchema = await getReplicateOpenApiSchemaCached(apiKey, model);
const inputKey = extractReplicateVoiceInputKeyFromOpenApiSchema(openApiSchema);
if (inputKey) {
replicateVoiceInputKeyCache.set(model, inputKey);
}
return inputKey;
}
async function fetchDeepinfraVoices(apiKey: string): Promise<string[]> {
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, 10_000);
try {
const response = await fetch('https://api.deepinfra.com/v1/voices', {
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
});
if (!response.ok) {
throw new Error('Failed to fetch Deepinfra voices');
}
const data = await response.json();
if (data.voices && Array.isArray(data.voices)) {
return data.voices
.filter((voice: { user_id?: string }) => voice.user_id !== 'preset')
.map((voice: { name: string }) => voice.name);
}
return [];
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
return [];
}
console.error('Error fetching Deepinfra voices:', error);
return [];
} finally {
clearTimeout(timeoutId);
}
}
async function fetchCustomOpenAiVoices(baseUrl: string, apiKey: string): Promise<string[] | null> {
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, 10_000);
try {
const normalizedBaseUrl = baseUrl.replace(/\/+$/, '');
const response = await fetch(`${normalizedBaseUrl}/audio/voices`, {
signal: controller.signal,
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
return null;
}
const data = await response.json();
return Array.isArray(data.voices) && data.voices.every((voice: unknown) => typeof voice === 'string')
? data.voices
: null;
} catch {
console.log('Custom endpoint does not support voices, using defaults');
return null;
} finally {
clearTimeout(timeoutId);
}
}
export async function resolveVoices({ provider, model, apiKey = '', baseUrl = '' }: ResolveVoicesOptions): Promise<string[]> {
const defaultVoices = getDefaultVoices(provider, model);
const voiceSource = resolveVoiceSource(provider, model);
if (voiceSource === 'deepinfra-api') {
const apiVoices = await fetchDeepinfraVoices(apiKey);
if (apiVoices.length > 0) {
return [...defaultVoices, ...apiVoices];
}
return defaultVoices;
}
if (voiceSource === 'custom-openai-api') {
if (!baseUrl) {
return defaultVoices;
}
const apiVoices = await fetchCustomOpenAiVoices(baseUrl, apiKey);
if (apiVoices !== null) {
return apiVoices;
}
}
if (voiceSource === 'replicate-api') {
if (!apiKey) {
return defaultVoices;
}
const apiVoices = await fetchReplicateVoices(apiKey, model);
if (apiVoices !== null) {
return apiVoices;
}
}
return defaultVoices;
}

View file

@ -69,7 +69,6 @@ const REPLICATE_MODELS: TtsModelDefinition[] = [
{ id: 'inworld/tts-1.5-mini', name: 'inworld/tts-1.5-mini' },
{ id: 'custom', name: 'Other' },
];
const REPLICATE_BUILT_IN_MODELS = new Set(REPLICATE_MODELS.map((model) => model.id).filter((id) => id !== 'custom'));
const DEEPINFRA_API_VOICE_MODELS = new Set([
'ResembleAI/chatterbox',
'Zyphra/Zonos-v0.1-hybrid',
@ -108,7 +107,6 @@ export const MINIMAX_SPEECH_VOICES = [
] as const;
export const QWEN3_TTS_VOICES = ['Aiden', 'Dylan'] as const;
export const INWORLD_TTS_VOICES = ['Ashley', 'Dennis', 'Alex', 'Darlene'] as const;
const REPLICATE_VOICE_KEYS: readonly ReplicateVoiceInputKey[] = ['voice', 'voice_id', 'speaker'];
const REPLICATE_DEFAULT_VOICES_BY_MODEL: Record<string, readonly string[]> = {
[REPLICATE_KOKORO_82M_VERSIONED_MODEL]: KOKORO_DEFAULT_VOICES,
'google/gemini-3.1-flash-tts': GEMINI_FLASH_TTS_VOICES,
@ -125,55 +123,6 @@ const DEEPINFRA_DEFAULT_VOICES_BY_MODEL: Record<string, readonly string[]> = {
'Zyphra/Zonos-v0.1-transformer': ['random'],
};
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[] = [
{
id: 'custom-openai',
@ -267,10 +216,6 @@ export function getDefaultVoices(provider: string, model: string): string[] {
return [...OPENAI_DEFAULT_VOICES];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function parseReplicateModelIdentifier(model: string): {
owner: string;
name: string;
@ -292,211 +237,6 @@ function parseReplicateModelIdentifier(model: string): {
: parsed;
}
function extractSchemaStringEnums(schemaNode: unknown, seen = new Set<object>()): string[] {
if (!isRecord(schemaNode)) {
return [];
}
if (seen.has(schemaNode)) {
return [];
}
seen.add(schemaNode);
const values: string[] = [];
if (Array.isArray(schemaNode.enum)) {
values.push(...schemaNode.enum.filter((value): value is string => typeof value === 'string'));
}
if (typeof schemaNode.const === 'string') {
values.push(schemaNode.const);
}
for (const key of ['anyOf', 'allOf', 'oneOf'] as const) {
const branch = schemaNode[key];
if (!Array.isArray(branch)) continue;
for (const item of branch) {
values.push(...extractSchemaStringEnums(item, seen));
}
}
if (schemaNode.items) {
values.push(...extractSchemaStringEnums(schemaNode.items, seen));
}
return values;
}
function walkRecordGraph(root: unknown, visit: (node: Record<string, unknown>) => boolean | void): void {
if (!isRecord(root)) {
return;
}
const stack: Record<string, unknown>[] = [root];
const seen = new Set<object>();
while (stack.length > 0) {
const current = stack.pop();
if (!current) {
continue;
}
if (seen.has(current)) {
continue;
}
seen.add(current);
if (visit(current)) {
return;
}
for (const value of Object.values(current)) {
if (Array.isArray(value)) {
for (const item of value) {
if (isRecord(item)) {
stack.push(item);
}
}
} else if (isRecord(value)) {
stack.push(value);
}
}
}
}
function extractReplicateVoicesFromOpenApiSchema(openApiSchema: unknown): string[] {
const voices: string[] = [];
walkRecordGraph(openApiSchema, (node) => {
const properties = node.properties;
if (!isRecord(properties)) {
return;
}
for (const key of REPLICATE_VOICE_KEYS) {
if (!(key in properties)) continue;
voices.push(...extractSchemaStringEnums(properties[key]));
}
});
return Array.from(
new Set(
voices
.map((voice) => voice.trim())
.filter((voice) => voice.length > 0)
)
);
}
function extractReplicateVoiceInputKeyFromOpenApiSchema(openApiSchema: unknown): ReplicateVoiceInputKey | null {
let found: ReplicateVoiceInputKey | null = null;
walkRecordGraph(openApiSchema, (node) => {
const properties = node.properties;
if (!isRecord(properties)) {
return;
}
for (const key of REPLICATE_VOICE_KEYS) {
if (key in properties) {
found = key;
return true;
}
}
});
return found;
}
async function fetchReplicateOpenApiSchema(apiKey: string, model: string): Promise<unknown | null> {
const parsedModel = parseReplicateModelIdentifier(model);
if (!parsedModel) {
return null;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, 10_000);
try {
const endpoint = parsedModel.version
? `https://api.replicate.com/v1/models/${parsedModel.owner}/${parsedModel.name}/versions/${parsedModel.version}`
: `https://api.replicate.com/v1/models/${parsedModel.owner}/${parsedModel.name}`;
const response = await fetch(endpoint, {
signal: controller.signal,
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
return null;
}
const data = await response.json();
let openApiSchema: unknown = null;
if (parsedModel.version) {
if (isRecord(data)) {
openApiSchema = data.openapi_schema;
}
} else if (isRecord(data) && isRecord(data.latest_version)) {
openApiSchema = data.latest_version.openapi_schema;
}
return openApiSchema;
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
return null;
}
console.error('Error fetching Replicate model schema:', error);
return null;
} finally {
clearTimeout(timeoutId);
}
}
async function getReplicateOpenApiSchemaCached(apiKey: string, model: string): Promise<unknown | null> {
const cachedPromise = replicateOpenApiSchemaPromiseCache.get(model);
if (cachedPromise) {
return cachedPromise;
}
const fetchPromise = fetchReplicateOpenApiSchema(apiKey, model);
replicateOpenApiSchemaPromiseCache.set(model, fetchPromise);
const schema = await fetchPromise;
if (schema === null) {
replicateOpenApiSchemaPromiseCache.delete(model);
}
return schema;
}
async function fetchReplicateVoices(apiKey: string, model: string): Promise<string[] | null> {
const openApiSchema = await getReplicateOpenApiSchemaCached(apiKey, model);
const apiVoices = extractReplicateVoicesFromOpenApiSchema(openApiSchema);
return apiVoices.length > 0 ? apiVoices : null;
}
export async function resolveReplicateVoiceInputKey({
provider,
model,
apiKey = '',
}: ResolveVoicesOptions): Promise<ReplicateVoiceInputKey | null> {
if (provider !== 'replicate' || REPLICATE_BUILT_IN_MODELS.has(model) || !apiKey) {
return null;
}
const cached = replicateVoiceInputKeyCache.get(model);
if (cached) {
return cached;
}
const openApiSchema = await getReplicateOpenApiSchemaCached(apiKey, model);
const inputKey = extractReplicateVoiceInputKeyFromOpenApiSchema(openApiSchema);
if (inputKey) {
replicateVoiceInputKeyCache.set(model, inputKey);
}
return inputKey;
}
export function resolveVoiceSource(provider: string, model: string): TtsVoiceSource {
if (provider === 'deepinfra' && DEEPINFRA_API_VOICE_MODELS.has(model)) {
return 'deepinfra-api';
@ -512,107 +252,3 @@ export function resolveVoiceSource(provider: string, model: string): TtsVoiceSou
return 'static';
}
async function fetchDeepinfraVoices(apiKey: string): Promise<string[]> {
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, 10_000);
try {
const response = await fetch('https://api.deepinfra.com/v1/voices', {
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
});
if (!response.ok) {
throw new Error('Failed to fetch Deepinfra voices');
}
const data = await response.json();
if (data.voices && Array.isArray(data.voices)) {
return data.voices
.filter((voice: { user_id?: string }) => voice.user_id !== 'preset')
.map((voice: { name: string }) => voice.name);
}
return [];
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
return [];
}
console.error('Error fetching Deepinfra voices:', error);
return [];
} finally {
clearTimeout(timeoutId);
}
}
async function fetchCustomOpenAiVoices(baseUrl: string, apiKey: string): Promise<string[] | null> {
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, 10_000);
try {
const normalizedBaseUrl = baseUrl.replace(/\/+$/, '');
const response = await fetch(`${normalizedBaseUrl}/audio/voices`, {
signal: controller.signal,
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
return null;
}
const data = await response.json();
return Array.isArray(data.voices) && data.voices.every((voice: unknown) => typeof voice === 'string')
? data.voices
: null;
} catch {
console.log('Custom endpoint does not support voices, using defaults');
return null;
} finally {
clearTimeout(timeoutId);
}
}
export async function resolveVoices({ provider, model, apiKey = '', baseUrl = '' }: ResolveVoicesOptions): Promise<string[]> {
const defaultVoices = getDefaultVoices(provider, model);
const voiceSource = resolveVoiceSource(provider, model);
if (voiceSource === 'deepinfra-api') {
const apiVoices = await fetchDeepinfraVoices(apiKey);
if (apiVoices.length > 0) {
return [...defaultVoices, ...apiVoices];
}
return defaultVoices;
}
if (voiceSource === 'custom-openai-api') {
if (!baseUrl) {
return defaultVoices;
}
const apiVoices = await fetchCustomOpenAiVoices(baseUrl, apiKey);
if (apiVoices !== null) {
return apiVoices;
}
}
if (voiceSource === 'replicate-api') {
if (!apiKey) {
return defaultVoices;
}
const apiVoices = await fetchReplicateVoices(apiKey, model);
if (apiVoices !== null) {
return apiVoices;
}
}
return defaultVoices;
}

View file

@ -0,0 +1,43 @@
import { expect, test } from '@playwright/test';
import { extractReplicateAudioUrl } from '../../src/lib/server/tts/generate';
test.describe('replicate output URL extraction', () => {
test('returns direct URL string output', () => {
expect(extractReplicateAudioUrl('https://replicate.delivery/audio.mp3')).toBe(
'https://replicate.delivery/audio.mp3'
);
});
test('extracts URL from FileOutput-like objects', () => {
const output = {
url: () => new URL('https://replicate.delivery/file.wav'),
};
expect(extractReplicateAudioUrl(output)).toBe('https://replicate.delivery/file.wav');
});
test('extracts first URL from array outputs', () => {
const output: unknown[] = [
{ value: 'not-a-url' },
{ toString: () => 'https://replicate.delivery/chunk-0.mp3' },
'https://replicate.delivery/chunk-1.mp3',
];
expect(extractReplicateAudioUrl(output)).toBe('https://replicate.delivery/chunk-0.mp3');
});
test('extracts nested URL from object outputs', () => {
const output = {
output: {
audio: {
url: 'https://replicate.delivery/nested.mp3',
},
},
};
expect(extractReplicateAudioUrl(output)).toBe('https://replicate.delivery/nested.mp3');
});
test('returns null for non-url outputs', () => {
const output = { status: 'ok', value: 123 };
expect(extractReplicateAudioUrl(output)).toBeNull();
});
});

View file

@ -4,12 +4,11 @@ import {
REPLICATE_KOKORO_82M_VERSIONED_MODEL,
getDefaultVoices,
providerSupportsCustomModel,
resolveReplicateVoiceInputKey,
resolveVoices,
resolveProviderModels,
supportsNativeModelSpeed,
supportsTtsInstructions,
} from '../../src/lib/shared/tts-provider-catalog';
import { resolveReplicateVoiceInputKey, resolveVoices } from '../../src/lib/server/tts/voice-resolution';
import { applyConfigUpdate, getVoicePreferenceKey } from '../../src/lib/client/config/updates';
import { buildSyncedPreferencePatch } from '../../src/lib/client/config/preferences';