zeroclaw-labs/zeroclaw · error

OpenAI TTS API error ({}): {}

Error message

OpenAI TTS API error ({}): {}

What it means

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).

Source

Thrown at crates/zeroclaw-channels/src/tts.rs:142

        let resp = self
            .client
            .post(&self.base_url)
            .bearer_auth(&self.api_key)
            .json(&body)
            .send()
            .await
            .context("Failed to send OpenAI TTS request")?;

        let status = resp.status();
        if !status.is_success() {
            let error_body: serde_json::Value = resp
                .json()
                .await
                .unwrap_or_else(|_| serde_json::json!({"error": "unknown"}));
            let msg = error_body["error"]["message"]
                .as_str()
                .unwrap_or("unknown error");
            bail!("OpenAI TTS API error ({}): {}", status, msg);
        }

        let bytes = resp
            .bytes()
            .await
            .context("Failed to read OpenAI TTS response body")?;
        Ok(bytes.to_vec())
    }

    fn supported_voices(&self) -> Vec<String> {
        ["alloy", "echo", "fable", "onyx", "nova", "shimmer"]
            .iter()
            .map(|s| (*s).to_string())
            .collect()
    }

    fn supported_formats(&self) -> Vec<String> {
        ["mp3", "opus", "aac", "flac", "wav", "pcm"]

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. 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.
  2. For 429/403, check the OpenAI organization quota and billing, then retry after the rate window.
  3. 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.
  4. For 400 on response_format, switch response_format to mp3 or wav, or pick a model that supports opus.
  5. If uri is overridden, confirm the backend implements POST /v1/audio/speech with the same JSON contract before blaming the key.

Example fix

# before — voice is not an OpenAI voice, backend rejects the request
[providers.tts.openai.main]
api_key = "sk-..."
voice = "Sarah"
response_format = "opus"

# after
[providers.tts.openai.main]
api_key = "sk-..."
voice = "shimmer"        # alloy | echo | fable | onyx | nova | shimmer
response_format = "mp3"   # broadest compatibility
Defensive patterns

Strategy: retry

Validate before calling

// Fail fast on obviously broken OpenAI TTS config before first synthesis.
fn validate_openai_tts(cfg: &TtsProviderConfig) -> anyhow::Result<()> {
    let key = cfg.api_key.as_deref().map(str::trim).filter(|k| !k.is_empty());
    anyhow::ensure!(key.is_some(), "api_key missing under [providers.tts.openai.<alias>]");
    if let Some(fmt) = cfg.response_format.as_deref() {
        const OK: &[&str] = &["mp3", "opus", "aac", "flac", "wav", "pcm"];
        anyhow::ensure!(OK.contains(&fmt), "unsupported response_format {fmt}");
    }
    Ok(())
}

Try / catch

match mgr.synthesize(text).await {
    Ok(audio) => { /* send */ }
    Err(err) => {
        let msg = err.to_string();
        if msg.contains("OpenAI TTS API error (401)") {
            return Err(err.context("fix api_key in [providers.tts.openai.<alias>]")); // not retryable
        }
        if msg.contains("(429)") || msg.contains("(5") {
            tokio::time::sleep(Duration::from_secs(5)).await; // exponential backoff in production
            return mgr.synthesize(text).await;
        }
        Err(err)
    }
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23). Data as JSON: /api/errors/ac8cc2e6d84b023d. Report an issue: GitHub.