moeru-ai/airi · error · Error
OpenRouter audio request failed: ${response.status} ${await
Error message
OpenRouter audio request failed: ${response.status} ${await response.text()} What it means
Thrown by the OpenRouter audio-speech provider's custom fetch wrapper after POSTing to OpenRouter's /chat/completions endpoint with modalities ['text','audio']. The wrapper builds an SSE PCM16 request and requires a 2xx response before it can collect audio chunks; any non-ok status aborts before decoding. The error message embeds the HTTP status code and the raw response body text so the upstream API error (rate limit, auth, model unavailable) is visible.
Source
Thrown at packages/stage-ui/src/libs/providers/providers/openrouter-audio-speech/index.ts:114
const body = JSON.parse(init.body) as { input?: string, voice?: string }
const response = await globalThis.fetch(new URL('chat/completions', baseUrl), {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
...OPENROUTER_ATTRIBUTION_HEADERS,
},
body: JSON.stringify({
model,
messages: [{ role: 'user', content: ttsPrompt(body.input ?? '') }],
modalities: ['text', 'audio'],
audio: { voice: body.voice, format: 'pcm16' },
stream: true,
}),
})
if (!response.ok)
throw new Error(`OpenRouter audio request failed: ${response.status} ${await response.text()}`)
if (!response.body)
throw new Error('OpenRouter audio response has no body')
const wav = toWavFromPCM16(decodeBase64Pcm(await collectAudioChunks(response.body)), 24000)
return new Response(new Blob([wav], { type: 'audio/wav' }), {
status: 200,
headers: { 'Content-Type': 'audio/wav' },
})
}
}
export const providerOpenRouterAudioSpeech = defineProvider<OpenRouterAudioConfig>({
id: 'openrouter-audio-speech',
name: 'OpenRouter',
nameLocalize: ({ t }) => t('settings.pages.providers.provider.openrouter-audio-speech.title'),
description: 'openrouter.ai',
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.openrouter-audio-speech.description'),
tasks: ['text-to-speech'],View on GitHub (pinned to 27111382b4)
Solutions
- Inspect the embedded status code and body text in the thrown message: a 401 means fix the OpenRouter apiKey in the provider config; 402 means add credits; 404 means the configured model id is not available for this key.
- Verify the model id passed to createAudioFetch is an OpenRouter model that supports audio output (default is 'openai/gpt-audio-mini').
- Confirm the voice value is one of the supported openAIVoices and that body.voice is non-empty when the request is built.
- If the baseUrl was overridden, ensure it points at a compatible OpenRouter-compatible endpoint that accepts modalities + audio fields, or revert to the DEFAULT_BASE_URL.
- For 429/5xx, retry with backoff or surface a user-facing 'temporarily unavailable' message.
Example fix
// before: opaque failure, only generic message
if (!response.ok)
throw new Error(`OpenRouter audio request failed`)
// after: keep status + body (already done) and add a typed retry for transient errors
if (!response.ok) {
const bodyText = await response.text()
if (response.status === 429 || response.status >= 500)
throw new RetryableError(`OpenRouter audio transient failure: ${response.status}`)
throw new Error(`OpenRouter audio request failed: ${response.status} ${bodyText}`)
} Defensive patterns
Strategy: retry
Validate before calling
// Validate OpenRouter config and inputs before the request
import { openRouterAudioConfigSchema, openAIVoices } from '../openrouter-audio-speech'
function validateOpenRouterAudioCall(config: { apiKey?: string, baseUrl?: string }, voice: string, input: string) {
const parsed = openRouterAudioConfigSchema.safeParse(config)
if (!parsed.success)
return { ok: false, reason: 'Invalid OpenRouter config: ' + parsed.error.message }
if (!parsed.data.apiKey)
return { ok: false, reason: 'OpenRouter apiKey is missing' }
if (!voice || !openAIVoices.includes(voice as typeof openAIVoices[number]))
return { ok: false, reason: `Voice "${voice}" is not supported` }
if (!input)
return { ok: false, reason: 'input text is empty' }
return { ok: true }
} Type guard
function isOpenRouterAudioConfig(value: unknown): value is { apiKey: string, baseUrl: string } {
return typeof value === 'object' && value !== null
&& typeof (value as any).apiKey === 'string' && (value as any).apiKey.length > 0
}
function isTransientStatus(status: number): boolean {
return status === 429 || status >= 500
} Try / catch
async function synthesizeWithRetry(fetchAudio: () => Promise<Response>, maxAttempts = 3): Promise<Response> {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await fetchAudio()
}
catch (error) {
const statusMatch = /request failed: (\d{3})/.exec(String(error instanceof Error ? error.message : error))
const status = statusMatch ? Number(statusMatch[1]) : 0
if (attempt === maxAttempts - 1 || !isTransientStatus(status))
throw error
await new Promise(r => setTimeout(r, 2 ** attempt * 500))
}
}
throw new Error('unreachable')
} Prevention
- Store the OpenRouter apiKey in the provider config validator so missing keys fail at config time, not at TTS time.
- Surface the embedded status code in the UI so users can act (add credits for 402, fix key for 401).
- Retry only transient statuses (429, 5xx); fail fast on 4xx to avoid burning quota.
When it happens
Trigger: The custom fetch returned by createAudioFetch is invoked with a body containing {input, voice}; globalThis.fetch to https://openrouter.ai/api/v1/chat/completions resolves with response.ok === false. Common status codes: 401 (bad/missing apiKey), 402 (insufficient credits), 404 (model id like 'openai/gpt-audio-mini' not available on the account/key), 429 (rate limit), 400 (voice not in the openAIVoices set or empty input).
Common situations: API key misconfigured or expired in provider settings; OpenRouter account out of credits; model id changed or not enabled for the key; voice name mismatch; baseUrl overridden to a proxy that does not support audio modalities; transient upstream 5xx or rate limiting during heavy TTS use.
Related errors
- OpenRouter audio response has no body
- Streaming transcription request failed with status ${respons
- web search failed: tavily ${response.status}${detail ? `: ${
- Auth request failed (${response.status})
- Gemini TTS request failed: ${response.status} ${await respon
AI-assisted analysis of moeru-ai/airi@27111382b4 (2026-08-12).
Data as JSON: /api/errors/72fff2c41761461b.
Report an issue: GitHub.