{"record":{"id":"337fc584be06ee6d","repo":"moeru-ai/airi","slug":"audio-voices-upstream-returned-malformed-body","errorCode":null,"errorMessage":"audio voices upstream returned malformed body","messagePattern":"audio voices upstream returned malformed body","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/stage-ui/src/libs/providers/providers/official/index.ts","lineNumber":182,"sourceCode":"          id: string\n          name: string\n          description?: string\n          labels?: Record<string, unknown>\n          tags?: string[]\n          languages?: { code: string, title: string }[]\n          compatible_models?: string[]\n          preview_audio_url?: string\n        }[]\n        recommended?: Record<string, string>\n      }\n\n      // Refresh the server-side recommendation map. Done here rather than\n      // threading it through the return value because the auto-pick watcher\n      // lives in this module and reads the same singleton.\n      recommendedVoicesByProvider[OFFICIAL_SPEECH_PROVIDER_ID] = (data.recommended && typeof data.recommended === 'object') ? data.recommended : {}\n\n      if (!Array.isArray(data.voices))\n        throw new Error('audio voices upstream returned malformed body')\n\n      return data.voices.map((v) => {\n        // unspeech surfaces gender inside labels rather than as a top-level field.\n        const rawGender = typeof v.labels?.gender === 'string' ? (v.labels.gender as string) : undefined\n        return {\n          id: v.id,\n          name: v.name,\n          provider: OFFICIAL_SPEECH_PROVIDER_ID,\n          description: v.description || undefined,\n          gender: rawGender?.toLowerCase() || undefined,\n          previewURL: v.preview_audio_url || undefined,\n          // NOTICE: deliberately dropping `compatible_models`. The official\n          // provider resolves voices through the server's /audio/voices?model=\n          // endpoint, which already returns only voices valid for the active\n          // model. Re-applying the client-side filter on top can zero out the\n          // list when upstream compatibility ids differ from AIRI's router ids.\n          // See packages/stage-pages/.../speech.vue filter predicate.\n          languages: Array.isArray(v.languages) ? v.languages : [],","sourceCodeStart":164,"sourceCodeEnd":200,"githubUrl":"https://github.com/moeru-ai/airi/blob/677329427f32468c74b17f3ec47eeca4e05bec65/packages/stage-ui/src/libs/providers/providers/official/index.ts#L164-L200","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// before\nif (!Array.isArray(data.voices))\n  throw new Error('audio voices upstream returned malformed body')\n// after — validate defensively and surface what was actually received\nif (!Array.isArray(data.voices)) {\n  console.warn('voices payload lacked voices[]', data)\n  return []\n}","handlingStrategy":"type-guard","validationCode":"const data: unknown = await res.json()\nif (!isVoicesCatalog(data))\n  console.warn('unexpected voices payload', data)","typeGuard":"function isVoicesCatalog(data: unknown): data is { voices: Array<{ id: string, name: string, labels?: Record<string, unknown> }>, recommended?: Record<string, string> } {\n  if (typeof data !== 'object' || data === null)\n    return false\n  const voices = (data as { voices?: unknown }).voices\n  return Array.isArray(voices) && voices.every(v => typeof (v as { id?: unknown })?.id === 'string')\n}","tryCatchPattern":"try {\n  return data.voices.map(toVoiceInfo)\n}\ncatch {\n  throw new Error('audio voices upstream returned malformed body')\n}","preventionTips":["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."],"tags":["schema","validation","api","server","tts","voices"],"backgroundTag":"schema-validation-failed","analyzedSha":"677329427f32468c74b17f3ec47eeca4e05bec65","analyzedAt":"2026-08-18T17:29:58.153Z","schemaVersion":2},"datasetVersion":"2026-08-23T16:17:53.355Z"}