moeru-ai/airi · error · Error
audio voices upstream returned malformed body
Error message
audio voices upstream returned malformed body
What it means
Thrown by the official speech provider's `listVoices()` when `/api/v1/audio/voices` answered 200 but the JSON lacks a `voices` array. Expected shape mirrors unspeech `types.ListVoicesResponse` (`voices[]` with labels/languages/preview URLs) plus a server-injected `recommended` map, which is stashed before this check runs. A 200-without-`voices[]` means the server contract changed or something rewrote the response.
Source
Thrown at packages/stage-ui/src/libs/providers/providers/official/index.ts:182
id: string
name: string
description?: string
labels?: Record<string, unknown>
tags?: string[]
languages?: { code: string, title: string }[]
compatible_models?: string[]
preview_audio_url?: string
}[]
recommended?: Record<string, string>
}
// Refresh the server-side recommendation map. Done here rather than
// threading it through the return value because the auto-pick watcher
// lives in this module and reads the same singleton.
recommendedVoicesByProvider[OFFICIAL_SPEECH_PROVIDER_ID] = (data.recommended && typeof data.recommended === 'object') ? data.recommended : {}
if (!Array.isArray(data.voices))
throw new Error('audio voices upstream returned malformed body')
return data.voices.map((v) => {
// unspeech surfaces gender inside labels rather than as a top-level field.
const rawGender = typeof v.labels?.gender === 'string' ? (v.labels.gender as string) : undefined
return {
id: v.id,
name: v.name,
provider: OFFICIAL_SPEECH_PROVIDER_ID,
description: v.description || undefined,
gender: rawGender?.toLowerCase() || undefined,
previewURL: v.preview_audio_url || undefined,
// NOTICE: deliberately dropping `compatible_models`. The official
// provider resolves voices through the server's /audio/voices?model=
// endpoint, which already returns only voices valid for the active
// model. Re-applying the client-side filter on top can zero out the
// list when upstream compatibility ids differ from AIRI's router ids.
// See packages/stage-pages/.../speech.vue filter predicate.
languages: Array.isArray(v.languages) ? v.languages : [],View on GitHub (pinned to 677329427f)
Solutions
- curl the voices endpoint with a bearer token and confirm the body is JSON containing `voices: [...]`.
- Redeploy server and client from the same git revision so the wire contract matches.
- If an edge returns 200 HTML for auth redirects, fix the routing so API paths never hit the auth UI.
- As a stopgap, degrade to a cached/empty voice list instead of crashing the settings page.
Example fix
// before
if (!Array.isArray(data.voices))
throw new Error('audio voices upstream returned malformed body')
// after — validate defensively and surface what was actually received
if (!Array.isArray(data.voices)) {
console.warn('voices payload lacked voices[]', data)
return []
} Defensive patterns
Strategy: type-guard
Validate before calling
const data: unknown = await res.json()
if (!isVoicesCatalog(data))
console.warn('unexpected voices payload', data) Type guard
function isVoicesCatalog(data: unknown): data is { voices: Array<{ id: string, name: string, labels?: Record<string, unknown> }>, recommended?: Record<string, string> } {
if (typeof data !== 'object' || data === null)
return false
const voices = (data as { voices?: unknown }).voices
return Array.isArray(voices) && voices.every(v => typeof (v as { id?: unknown })?.id === 'string')
} Try / catch
try {
return data.voices.map(toVoiceInfo)
}
catch {
throw new Error('audio voices upstream returned malformed body')
} Prevention
- Pin the unspeech `ListVoicesResponse` shape in a shared contract test.
- Never let an auth edge answer 200 HTML for API routes.
- Have the server emit `voices: []` for empty catalogs.
- Degrade to a cached voice list while logging the malformed payload.
When it happens
Trigger: Server returns `200` with `{}` or an error envelope; version skew between the client's VoiceInfo mapping and the server route; a gateway that answers 200 with an HTML login/consent page because the bearer token was silently consumed by an auth edge.
Common situations: Redeploying only the frontend against an older API; Caddy/auth edge in `server/dev/caddy` misrouting `/api/v1/audio/voices` to the auth service; unspeech returning a draft wire shape after an upstream protocol bump.
Understand the failure class
Background: Schema validation failed / invalid input schema: payload rejected because its shape doesn't match the expected schema — this error's family across 28 libraries.
- Parsing and encoding errors: unexpected token, malformed input — why parsers reject input and how to find the real culprit.
Related errors
- streaming voices upstream returned malformed body
- audio models upstream returned malformed body
- audio voices upstream ${res.status}: ${await res.text().catc
- streaming models upstream missing models[]
- streaming models upstream ${res.status}: ${await res.text().
AI-assisted analysis of moeru-ai/airi@677329427f (2026-08-18).
Data as JSON: /api/errors/337fc584be06ee6d.
Report an issue: GitHub.