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

  1. curl the voices endpoint with a bearer token and confirm the body is JSON containing `voices: [...]`.
  2. Redeploy server and client from the same git revision so the wire contract matches.
  3. If an edge returns 200 HTML for auth redirects, fix the routing so API paths never hit the auth UI.
  4. 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

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.

Related errors


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