{"record":{"id":"ac8cc2e6d84b023d","repo":"zeroclaw-labs/zeroclaw","slug":"openai-tts-api-error","errorCode":null,"errorMessage":"OpenAI TTS API error ({}): {}","messagePattern":"OpenAI TTS API error \\((.+?)\\): (.+?)","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"crates/zeroclaw-channels/src/tts.rs","lineNumber":142,"sourceCode":"        let resp = self\n            .client\n            .post(&self.base_url)\n            .bearer_auth(&self.api_key)\n            .json(&body)\n            .send()\n            .await\n            .context(\"Failed to send OpenAI TTS request\")?;\n\n        let status = resp.status();\n        if !status.is_success() {\n            let error_body: serde_json::Value = resp\n                .json()\n                .await\n                .unwrap_or_else(|_| serde_json::json!({\"error\": \"unknown\"}));\n            let msg = error_body[\"error\"][\"message\"]\n                .as_str()\n                .unwrap_or(\"unknown error\");\n            bail!(\"OpenAI TTS API error ({}): {}\", status, msg);\n        }\n\n        let bytes = resp\n            .bytes()\n            .await\n            .context(\"Failed to read OpenAI TTS response body\")?;\n        Ok(bytes.to_vec())\n    }\n\n    fn supported_voices(&self) -> Vec<String> {\n        [\"alloy\", \"echo\", \"fable\", \"onyx\", \"nova\", \"shimmer\"]\n            .iter()\n            .map(|s| (*s).to_string())\n            .collect()\n    }\n\n    fn supported_formats(&self) -> Vec<String> {\n        [\"mp3\", \"opus\", \"aac\", \"flac\", \"wav\", \"pcm\"]","sourceCodeStart":124,"sourceCodeEnd":160,"githubUrl":"https://github.com/zeroclaw-labs/zeroclaw/blob/88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc/crates/zeroclaw-channels/src/tts.rs#L124-L160","documentation":"Raised by OpenAiTtsProvider::synthesize when the POST to the configured TTS endpoint returns a non-2xx status. The endpoint defaults to https://api.openai.com/v1/audio/speech and can be overridden via [providers.tts.openai.<alias>].uri to target any OpenAI-compatible backend (Groq, Azure, proxies). The provider parses the JSON error body and bails with the HTTP status plus the upstream error.message (\"unknown error\" when the body has no such field).","triggerScenarios":"POST /v1/audio/speech with Bearer auth fails: 401 for a missing/invalid api_key, 429/403 for quota or billing exhaustion, 400 for a voice outside {alloy, echo, fable, onyx, nova, shimmer} or an unknown model (default tts-1), 400 when response_format (default opus) is not supported by the chosen model, and any non-2xx from a custom uri backend that is not fully OpenAI-TTS compatible.","commonSituations":"The OPENAI_API_KEY env fallback was removed in V0.8.0, so old deployments that relied on the env var now send a stale or empty key from [providers.tts.openai.<alias>].api_key. Pointing uri at a proxy or Groq often breaks on the opus response_format or on the voice list. Expired billing or a rotated key also lands here.","solutions":["Match the status in the message: 401 means the api_key under [providers.tts.openai.<alias>].api_key (env grammar ZEROCLAW_providers__tts__openai__<alias>__api_key) is wrong or empty; set a valid key and restart.","For 429/403, check the OpenAI organization quota and billing, then retry after the rate window.","For 400 naming the voice or model, set model (tts-1/tts-1-hd/gpt-4o-mini-tts) and voice (alloy/echo/fable/onyx/nova/shimmer) to supported values.","For 400 on response_format, switch response_format to mp3 or wav, or pick a model that supports opus.","If uri is overridden, confirm the backend implements POST /v1/audio/speech with the same JSON contract before blaming the key."],"exampleFix":"# before — voice is not an OpenAI voice, backend rejects the request\n[providers.tts.openai.main]\napi_key = \"sk-...\"\nvoice = \"Sarah\"\nresponse_format = \"opus\"\n\n# after\n[providers.tts.openai.main]\napi_key = \"sk-...\"\nvoice = \"shimmer\"        # alloy | echo | fable | onyx | nova | shimmer\nresponse_format = \"mp3\"   # broadest compatibility","handlingStrategy":"retry","validationCode":"// Fail fast on obviously broken OpenAI TTS config before first synthesis.\nfn validate_openai_tts(cfg: &TtsProviderConfig) -> anyhow::Result<()> {\n    let key = cfg.api_key.as_deref().map(str::trim).filter(|k| !k.is_empty());\n    anyhow::ensure!(key.is_some(), \"api_key missing under [providers.tts.openai.<alias>]\");\n    if let Some(fmt) = cfg.response_format.as_deref() {\n        const OK: &[&str] = &[\"mp3\", \"opus\", \"aac\", \"flac\", \"wav\", \"pcm\"];\n        anyhow::ensure!(OK.contains(&fmt), \"unsupported response_format {fmt}\");\n    }\n    Ok(())\n}","typeGuard":null,"tryCatchPattern":"match mgr.synthesize(text).await {\n    Ok(audio) => { /* send */ }\n    Err(err) => {\n        let msg = err.to_string();\n        if msg.contains(\"OpenAI TTS API error (401)\") {\n            return Err(err.context(\"fix api_key in [providers.tts.openai.<alias>]\")); // not retryable\n        }\n        if msg.contains(\"(429)\") || msg.contains(\"(5\") {\n            tokio::time::sleep(Duration::from_secs(5)).await; // exponential backoff in production\n            return mgr.synthesize(text).await;\n        }\n        Err(err)\n    }\n}","preventionTips":["Keep [providers.tts.openai.<alias>].api_key (or the ZEROCLAW_providers__tts__openai__<alias>__api_key env slot) in a secrets manager; the V0.8.0 removal of the OPENAI_API_KEY fallback means the env var alone silently stops working.","Pin voice to one of alloy/echo/fable/onyx/nova/shimmer and model to a current TTS model.","When overriding uri, smoke-test the backend with curl POST /v1/audio/speech before wiring it in.","Monitor 429s: OpenAI TTS rate limits are per-org; alert before quota exhaustion."],"tags":["openai","tts","http","api","config"],"backgroundTag":"openai-api-error","analyzedSha":"88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc","analyzedAt":"2026-08-23T01:07:41.857Z","schemaVersion":2},"datasetVersion":"2026-08-23T08:06:27.607Z"}