fix(tts): apply upstream timeout and validate provider/model form in Speech SDK path

Review-driven: thread ttsUpstreamTimeoutMs into the speech-sdk branch as an
overall budget across SDK retries, and fail fast on model ids missing the
provider/model form instead of falling through to provider defaults.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bilal Tahir 2026-06-11 12:19:31 -07:00
parent 801f0dcca2
commit 6da2ba2a9a
3 changed files with 42 additions and 13 deletions

View file

@ -24,7 +24,7 @@ See [TTS Providers](../tts-providers) for admin-shared vs per-user behavior.
## Built-in models
- `openai/gpt-4o-mini-tts`
- `openai/gpt-4o-mini-tts` (works with your existing OpenAI API key)
- `elevenlabs/eleven_multilingual_v2`
- `cartesia/sonic-3.5`
- `deepgram/aura-2`

View file

@ -696,10 +696,15 @@ const SPEECH_SDK_PROVIDER_FACTORIES: Record<string, SpeechSdkModelFactory> = {
async function runSpeechSdkRequest(
request: ResolvedServerTTSRequest,
signal: AbortSignal,
maxRetries: number,
upstreamSettings: ResolvedTtsUpstreamRuntimeSettings,
): Promise<Buffer> {
const model = request.model as string;
const prefix = speechSdkProviderPrefix(model);
const modelId = model.slice(prefix.length + 1);
if (!prefix || !modelId) {
throw new Error(`Invalid Speech SDK model "${model}". Expected "provider/model".`);
}
const factory = SPEECH_SDK_PROVIDER_FACTORIES[prefix];
if (!factory) {
throw new Error(
@ -707,19 +712,30 @@ async function runSpeechSdkRequest(
);
}
const modelId = model.slice(prefix.length + 1);
// 'default' is the placeholder voice for providers without a static voice
// list; omit it so the provider's own default applies.
const voice = request.voice === 'default' ? undefined : request.voice;
const result = await generateSpeech({
model: factory({ apiKey: request.apiKey || undefined })(modelId || undefined),
text: request.text,
voice: voice as string,
output: { format: 'mp3' },
maxRetries,
abortSignal: signal,
});
return Buffer.from(result.audio.uint8Array);
// Overall budget across the SDK's internal retries, mirroring the timeout
// the OpenAI-compatible path configures on its client.
const timeoutController = new AbortController();
const timeoutId = setTimeout(() => timeoutController.abort(), upstreamSettings.ttsUpstreamTimeoutMs);
const onAbort = () => timeoutController.abort();
signal.addEventListener('abort', onAbort, { once: true });
try {
const result = await generateSpeech({
model: factory({ apiKey: request.apiKey || undefined })(modelId),
text: request.text,
voice: voice as string,
output: { format: 'mp3' },
maxRetries: upstreamSettings.ttsUpstreamMaxRetries,
abortSignal: timeoutController.signal,
});
return Buffer.from(result.audio.uint8Array);
} finally {
clearTimeout(timeoutId);
signal.removeEventListener('abort', onAbort);
}
}
async function runProviderRequest(
@ -733,7 +749,7 @@ async function runProviderRequest(
const raw = request.provider === 'replicate'
? await runReplicateRequest(request, signal, upstreamSettings.ttsUpstreamMaxRetries)
: request.provider === 'speech-sdk'
? await runSpeechSdkRequest(request, signal, upstreamSettings.ttsUpstreamMaxRetries)
? await runSpeechSdkRequest(request, signal, upstreamSettings)
: await runOpenAiCompatibleRequest(request, signal, upstreamSettings);
// OpenAI-compatible servers (and some Replicate models) may emit wav/ogg/etc.;

View file

@ -149,4 +149,17 @@ describe('speech-sdk request resolution', () => {
apiKey: 'unused',
})).rejects.toThrow('Unknown Speech SDK provider prefix');
});
test('rejects models missing the provider/model form', async () => {
for (const model of ['openai', 'openai/', '/gpt-4o-mini-tts']) {
await expect(generateTTSBuffer({
text: 'hello',
voice: 'alloy',
speed: 1,
model,
provider: 'speech-sdk',
apiKey: 'unused',
})).rejects.toThrow('Expected "provider/model"');
}
});
});