nexu-io/open-design · error · Error
${tag} speech ${resp.status}: ${truncate(text, 240)}
Error message
${tag} speech ${resp.status}: ${truncate(text, 240)} What it means
Thrown by the OpenAI/Azure text-to-speech renderer when `POST /v1/audio/speech` (or the Azure deployment equivalent at `…/openai/deployments/{id}/audio/speech`) returns a non-2xx status. The message embeds the provider tag (`openai` or `azure-openai`), the HTTP status, and up to 240 chars of the response body (`truncate`) so the underlying API cause — auth, model access, moderation, rate limit — reaches the agent instead of an opaque fetch failure.
Source
Thrown at apps/daemon/src/media/index.ts:1369
}
const headers: Record<string, string> = {
authorization: `Bearer ${credentials.apiKey}`,
'content-type': 'application/json',
};
if (azure) {
headers['api-key'] = credentials.apiKey;
}
const resp = await fetch(url, withMediaRequestInit(ctx, {
method: 'POST',
headers,
body: JSON.stringify(body),
}));
if (!resp.ok) {
const text = await resp.text();
const tag = azure ? 'azure-openai' : 'openai';
throw new Error(`${tag} speech ${resp.status}: ${truncate(text, 240)}`);
}
const arr = await resp.arrayBuffer();
const bytes = Buffer.from(arr);
if (bytes.length === 0) {
throw new Error('openai speech returned zero bytes');
}
const tag = azure ? 'azure-openai' : 'openai';
const noteBits = [`${tag}/${ctx.wireModel}`, voiceId, `${format}`, `${bytes.length} bytes`];
if (instructions) noteBits.splice(2, 0, 'styled');
return {
bytes,
providerNote: noteBits.join(' · '),
suggestedExt: format === 'opus' ? '.ogg' : `.${format}`,
};
}
// ---------------------------------------------------------------------------
// Provider: Volcengine Ark — Doubao Seedance 2.0 video.View on GitHub (pinned to 5be4028344)
Solutions
- Read the embedded status+body: 401/403 → fix the key (set `OPENAI_API_KEY` or the Azure key in Settings); 404 → create the Azure deployment for the model; 429 → back off or upgrade tier; 400 naming `voice` → pick a voice from `OPENAI_TTS_VOICES` (alloy/ash/ballad/coral/echo/fable/onyx/nova/sage/shimmer) or switch the model to `gpt-4o-mini-tts` to use it as `instructions`.
- Verify `credentials.apiKey` and the resolved `baseUrl` via Settings (or `od media config`) before rendering.
- Shorten the input text to stay under the 4096-character TTS limit and reword content moderation may flag.
Example fix
// before
const body = { input: text, voice: requestedVoice, response_format: format };
// arbitrary voice ids rejected by non-gpt-4o-mini-tts models → 400
// after
let voiceId = 'nova';
let instructions: string | undefined;
if (requestedVoice && OPENAI_TTS_VOICES.has(requestedVoice)) {
voiceId = requestedVoice;
} else if (requestedVoice && ctx.model === 'gpt-4o-mini-tts') {
instructions = requestedVoice; // free-form speaking style
} else if (requestedVoice) {
throw new Error(`voice '${requestedVoice}' is not one of: ${[...OPENAI_TTS_VOICES].join(', ')}`);
} Defensive patterns
Strategy: try-catch
Validate before calling
// Pre-validate the voice id and input length before the speech fetch.
const OPENAI_TTS_VOICES = new Set(['alloy','ash','ballad','coral','echo','fable','nova','onyx','sage','shimmer']);
function validateSpeechInput(model: string, voice: string | undefined, input: string): void {
if (Buffer.byteLength(input, 'utf8') > 4096) {
throw new Error(`speech input exceeds 4096-byte limit (${Buffer.byteLength(input, 'utf8')})`);
}
if (voice && !OPENAI_TTS_VOICES.has(voice) && model !== 'gpt-4o-mini-tts') {
throw new Error(`voice '${voice}' not in ${[...OPENAI_TTS_VOICES].join(',')} (use gpt-4o-mini-tts for free-form instructions)`);
}
} Try / catch
// Distinguish recoverable rate-limit/auth from hard validation failures.
try {
return await renderOpenAISpeech(ctx, credentials);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (/speech 429/.test(msg)) { /* back off + retry once */ }
if (/speech 40[13]/.test(msg)) { throw new Error('OpenAI auth failed — check OPENAI_API_KEY'); }
throw err;
} Prevention
- Always validate the voice id against `OPENAI_TTS_VOICES` unless the model is `gpt-4o-mini-tts`.
- Keep input text under the 4096-byte TTS limit; truncate upstream, not at the provider.
- Confirm the Azure deployment name matches the TTS model before pointing `baseUrl` at Azure.
When it happens
Trigger: `resp.ok === false` on the speech `fetch`. Concretely: 401/403 from a missing/revoked key, 404 because an Azure deployment wasn't provisioned for the TTS model, 400 from an unknown `voice` id (a value not in `OPENAI_TTS_VOICES` sent to a model other than `gpt-4o-mini-tts`), 429 org rate limit, or a 400/flagged-response when the input text trips OpenAI moderation or exceeds the 4096-char limit.
Common situations: (1) `OPENAI_API_KEY` unset or belonging to an org without audio-model access; (2) a UI voice value treated as free-form `instructions` on a non-`gpt-4o-mini-tts` model; (3) an Azure `baseUrl` whose deployment name doesn't match the requested TTS model; (4) long/disallowed input text hitting moderation; (5) shared org rate limits exhausted.
Related errors
- no OpenAI credential — configure an API key in Settings or s
- openai speech returned zero bytes
- ${tag} ${resp.status}: ${truncate(text, 240)}
- openai response had no data[0]
- azure ${resp.status}: ${await resp.text().catch(() => '')}
AI-assisted analysis of nexu-io/open-design@5be4028344 (2026-08-12).
Data as JSON: /api/errors/a2f7b231cba4b40d.
Report an issue: GitHub.