{"record":{"id":"72fff2c41761461b","repo":"moeru-ai/airi","slug":"openrouter-audio-request-failed-response-status","errorCode":null,"errorMessage":"OpenRouter audio request failed: ${response.status} ${await response.text()}","messagePattern":"OpenRouter audio request failed: (.+?) (.+?)","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/stage-ui/src/libs/providers/providers/openrouter-audio-speech/index.ts","lineNumber":114,"sourceCode":"\n    const body = JSON.parse(init.body) as { input?: string, voice?: string }\n    const response = await globalThis.fetch(new URL('chat/completions', baseUrl), {\n      method: 'POST',\n      headers: {\n        'Authorization': `Bearer ${apiKey}`,\n        'Content-Type': 'application/json',\n        ...OPENROUTER_ATTRIBUTION_HEADERS,\n      },\n      body: JSON.stringify({\n        model,\n        messages: [{ role: 'user', content: ttsPrompt(body.input ?? '') }],\n        modalities: ['text', 'audio'],\n        audio: { voice: body.voice, format: 'pcm16' },\n        stream: true,\n      }),\n    })\n    if (!response.ok)\n      throw new Error(`OpenRouter audio request failed: ${response.status} ${await response.text()}`)\n    if (!response.body)\n      throw new Error('OpenRouter audio response has no body')\n\n    const wav = toWavFromPCM16(decodeBase64Pcm(await collectAudioChunks(response.body)), 24000)\n    return new Response(new Blob([wav], { type: 'audio/wav' }), {\n      status: 200,\n      headers: { 'Content-Type': 'audio/wav' },\n    })\n  }\n}\n\nexport const providerOpenRouterAudioSpeech = defineProvider<OpenRouterAudioConfig>({\n  id: 'openrouter-audio-speech',\n  name: 'OpenRouter',\n  nameLocalize: ({ t }) => t('settings.pages.providers.provider.openrouter-audio-speech.title'),\n  description: 'openrouter.ai',\n  descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.openrouter-audio-speech.description'),\n  tasks: ['text-to-speech'],","sourceCodeStart":96,"sourceCodeEnd":132,"githubUrl":"https://github.com/moeru-ai/airi/blob/27111382b4a79a7e983289d6e983a06af185ed0f/packages/stage-ui/src/libs/providers/providers/openrouter-audio-speech/index.ts#L96-L132","documentation":"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.","triggerScenarios":"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).","commonSituations":"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.","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."],"exampleFix":"// before: opaque failure, only generic message\nif (!response.ok)\n  throw new Error(`OpenRouter audio request failed`)\n\n// after: keep status + body (already done) and add a typed retry for transient errors\nif (!response.ok) {\n  const bodyText = await response.text()\n  if (response.status === 429 || response.status >= 500)\n    throw new RetryableError(`OpenRouter audio transient failure: ${response.status}`)\n  throw new Error(`OpenRouter audio request failed: ${response.status} ${bodyText}`)\n}","handlingStrategy":"retry","validationCode":"// Validate OpenRouter config and inputs before the request\nimport { openRouterAudioConfigSchema, openAIVoices } from '../openrouter-audio-speech'\n\nfunction validateOpenRouterAudioCall(config: { apiKey?: string, baseUrl?: string }, voice: string, input: string) {\n  const parsed = openRouterAudioConfigSchema.safeParse(config)\n  if (!parsed.success)\n    return { ok: false, reason: 'Invalid OpenRouter config: ' + parsed.error.message }\n  if (!parsed.data.apiKey)\n    return { ok: false, reason: 'OpenRouter apiKey is missing' }\n  if (!voice || !openAIVoices.includes(voice as typeof openAIVoices[number]))\n    return { ok: false, reason: `Voice \"${voice}\" is not supported` }\n  if (!input)\n    return { ok: false, reason: 'input text is empty' }\n  return { ok: true }\n}","typeGuard":"function isOpenRouterAudioConfig(value: unknown): value is { apiKey: string, baseUrl: string } {\n  return typeof value === 'object' && value !== null\n    && typeof (value as any).apiKey === 'string' && (value as any).apiKey.length > 0\n}\n\nfunction isTransientStatus(status: number): boolean {\n  return status === 429 || status >= 500\n}","tryCatchPattern":"async function synthesizeWithRetry(fetchAudio: () => Promise<Response>, maxAttempts = 3): Promise<Response> {\n  for (let attempt = 0; attempt < maxAttempts; attempt++) {\n    try {\n      return await fetchAudio()\n    }\n    catch (error) {\n      const statusMatch = /request failed: (\\d{3})/.exec(String(error instanceof Error ? error.message : error))\n      const status = statusMatch ? Number(statusMatch[1]) : 0\n      if (attempt === maxAttempts - 1 || !isTransientStatus(status))\n        throw error\n      await new Promise(r => setTimeout(r, 2 ** attempt * 500))\n    }\n  }\n  throw new Error('unreachable')\n}","preventionTips":["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."],"tags":["network","http","tts","openrouter","api-key","streaming"],"backgroundTag":null,"analyzedSha":"27111382b4a79a7e983289d6e983a06af185ed0f","analyzedAt":"2026-08-12T18:33:34.132Z","schemaVersion":2},"datasetVersion":"2026-08-12T23:17:12.415Z"}