moeru-ai/airi · error · Error

OpenRouter audio response has no body

Error message

OpenRouter audio response has no body

What it means

Thrown by the OpenRouter audio speech provider when the request succeeded (`response.ok`) but `response.body` is null/undefined, i.e. there is no readable stream to collect SSE audio chunks from. The code requests `stream: true`, so a body-less 2xx means the transport or an intermediary broke the streaming contract — the PCM/WAV assembly cannot even start.

Solutions

  1. Check the runtime: run where native `fetch` with ReadableStream bodies is available (modern browsers, Electron renderer, Node 18+ undici).
  2. Remove or configure intermediaries to pass SSE through untouched (no buffering, no content-encoding rewrites).
  3. If a fetch wrapper logs bodies, make it tee (`response.body.tee()`) instead of consuming the original.
  4. As a fallback path, request a non-streaming completion and read base64 audio from the JSON when a body is unavailable.

Example fix

// before
if (!response.ok)
  throw new Error(`OpenRouter audio request failed: ...`)
if (!response.body)
  throw new Error('OpenRouter audio response has no body')
// after — one combined guard with an actionable message
if (!response.ok || !response.body)
  throw new Error(`OpenRouter audio unusable response: status=${response.status} body=${response.body ? 'yes' : 'missing (stream stripped?)'}`)
Defensive patterns

Strategy: type-guard

Validate before calling

// nothing caller-side can guarantee a body; validate the response instead
if (!response.body)
  throw new Error('No streaming body — check proxy/runtime support before using OpenRouter audio TTS')

Type guard

function hasStreamingBody(res: Response): res is Response & { body: ReadableStream<Uint8Array> } {
  return res.ok && res.body instanceof ReadableStream
}

Try / catch

if (!hasStreamingBody(response))
  // fall back to a buffered completion instead of collecting chunks
  return bufferedAudioCompletion(request)
const wav = toWavFromPCM16(decodeBase64Pcm(await collectAudioChunks(response.body)), 24000)

Prevention

When it happens

Trigger: An intermediary (corporate proxy, service worker, Electron `<webview>` fetch shim, HTTP/1.0 hop) strips or buffers the streamed body; a runtime where `fetch` does not expose streaming bodies (older Node without `dom.highWaterMark`-era undici, jsdom); accidental double-read of the body upstream consumed it before this check.

Common situations: Running the renderer inside an environment whose fetch is polyfilled without stream support; a debugging proxy (e.g. Charles/fiddler) decompressing and dechunking SSE; response already consumed by a logging interceptor.

Related errors


AI-assisted analysis of moeru-ai/airi@677329427f (2026-08-18). Data as JSON: /api/errors/12d1e6184b3863fc. Report an issue: GitHub.

Appendix: source

Thrown at packages/stage-ui/src/libs/providers/providers/openrouter-audio-speech/index.ts:116

    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'],
  icon: 'i-lobe-icons:openrouter',
  createProviderConfig: () => openRouterAudioConfigSchema,

View on GitHub (pinned to 677329427f)