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
|
## Built-in models
|
||||||
|
|
||||||
- `openai/gpt-4o-mini-tts`
|
- `openai/gpt-4o-mini-tts` (works with your existing OpenAI API key)
|
||||||
- `elevenlabs/eleven_multilingual_v2`
|
- `elevenlabs/eleven_multilingual_v2`
|
||||||
- `cartesia/sonic-3.5`
|
- `cartesia/sonic-3.5`
|
||||||
- `deepgram/aura-2`
|
- `deepgram/aura-2`
|
||||||
|
|
|
||||||
|
|
@ -696,10 +696,15 @@ const SPEECH_SDK_PROVIDER_FACTORIES: Record<string, SpeechSdkModelFactory> = {
|
||||||
async function runSpeechSdkRequest(
|
async function runSpeechSdkRequest(
|
||||||
request: ResolvedServerTTSRequest,
|
request: ResolvedServerTTSRequest,
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
maxRetries: number,
|
upstreamSettings: ResolvedTtsUpstreamRuntimeSettings,
|
||||||
): Promise<Buffer> {
|
): Promise<Buffer> {
|
||||||
const model = request.model as string;
|
const model = request.model as string;
|
||||||
const prefix = speechSdkProviderPrefix(model);
|
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];
|
const factory = SPEECH_SDK_PROVIDER_FACTORIES[prefix];
|
||||||
if (!factory) {
|
if (!factory) {
|
||||||
throw new Error(
|
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
|
// 'default' is the placeholder voice for providers without a static voice
|
||||||
// list; omit it so the provider's own default applies.
|
// list; omit it so the provider's own default applies.
|
||||||
const voice = request.voice === 'default' ? undefined : request.voice;
|
const voice = request.voice === 'default' ? undefined : request.voice;
|
||||||
const result = await generateSpeech({
|
|
||||||
model: factory({ apiKey: request.apiKey || undefined })(modelId || undefined),
|
// Overall budget across the SDK's internal retries, mirroring the timeout
|
||||||
text: request.text,
|
// the OpenAI-compatible path configures on its client.
|
||||||
voice: voice as string,
|
const timeoutController = new AbortController();
|
||||||
output: { format: 'mp3' },
|
const timeoutId = setTimeout(() => timeoutController.abort(), upstreamSettings.ttsUpstreamTimeoutMs);
|
||||||
maxRetries,
|
const onAbort = () => timeoutController.abort();
|
||||||
abortSignal: signal,
|
signal.addEventListener('abort', onAbort, { once: true });
|
||||||
});
|
try {
|
||||||
return Buffer.from(result.audio.uint8Array);
|
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(
|
async function runProviderRequest(
|
||||||
|
|
@ -733,7 +749,7 @@ async function runProviderRequest(
|
||||||
const raw = request.provider === 'replicate'
|
const raw = request.provider === 'replicate'
|
||||||
? await runReplicateRequest(request, signal, upstreamSettings.ttsUpstreamMaxRetries)
|
? await runReplicateRequest(request, signal, upstreamSettings.ttsUpstreamMaxRetries)
|
||||||
: request.provider === 'speech-sdk'
|
: request.provider === 'speech-sdk'
|
||||||
? await runSpeechSdkRequest(request, signal, upstreamSettings.ttsUpstreamMaxRetries)
|
? await runSpeechSdkRequest(request, signal, upstreamSettings)
|
||||||
: await runOpenAiCompatibleRequest(request, signal, upstreamSettings);
|
: await runOpenAiCompatibleRequest(request, signal, upstreamSettings);
|
||||||
|
|
||||||
// OpenAI-compatible servers (and some Replicate models) may emit wav/ogg/etc.;
|
// OpenAI-compatible servers (and some Replicate models) may emit wav/ogg/etc.;
|
||||||
|
|
|
||||||
|
|
@ -149,4 +149,17 @@ describe('speech-sdk request resolution', () => {
|
||||||
apiKey: 'unused',
|
apiKey: 'unused',
|
||||||
})).rejects.toThrow('Unknown Speech SDK provider prefix');
|
})).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