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:
parent
801f0dcca2
commit
6da2ba2a9a
3 changed files with 42 additions and 13 deletions
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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.;
|
||||
|
|
|
|||
|
|
@ -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"');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue