{"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":168,"sourceCode":"      // https://github.com/moeru-ai/unspeech/blob/main/pkg/backend/types/voices.go\n      const data = await res.json() as {\n        voices?: {\n          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      // An aborted response must not replace the current session's recommendations.\n      signal?.throwIfAborted()\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          // Keep recommendations in the response so stale catalogs cannot mutate\n          // a separate cache, and synchronized windows retain the same metadata.\n          recommendedFor: Object.entries(data.recommended ?? {}).filter(([, id]) => id === v.id).map(([locale]) => locale),\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","sourceCodeStart":150,"sourceCodeEnd":186,"githubUrl":"https://github.com/moeru-ai/airi/blob/438a067dde47aa0bdb46c2323d1fe293dc805218/packages/stage-ui/src/libs/providers/providers/official/index.ts#L150-L186","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":"438a067dde47aa0bdb46c2323d1fe293dc805218","analyzedAt":"2026-09-17T01:14:42.644Z","contentChangedAt":"2026-09-17T01:14:42.644Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}