{"record":{"id":"cb1a64b965c35aa3","repo":"moeru-ai/airi","slug":"audio-models-upstream-returned-malformed-body","errorCode":null,"errorMessage":"audio models upstream returned malformed body","messagePattern":"audio models upstream returned malformed body","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/stage-ui/src/libs/providers/providers/official/index.ts","lineNumber":136,"sourceCode":"        ...originalSpeech(model),\n        ...extraOptions,\n      }\n      result.fetch = withCredentials()\n      return result\n    }\n    return provider\n  },\n  validationRequiredWhen: () => false,\n  extraMethods: {\n    listModels: async (): Promise<ModelInfo[]> => {\n      defaultSpeechModelId = null\n      const res = await globalThis.fetch(`${SERVER_URL}/api/v1/audio/models`, { headers: authHeaders() })\n      if (!res.ok)\n        throw new Error(`audio models upstream ${res.status}: ${await res.text().catch(() => '')}`.slice(0, 256))\n\n      const data = await res.json() as { models?: { id: string, name: string, description?: string }[], default?: string | null }\n      if (!Array.isArray(data.models))\n        throw new Error('audio models upstream returned malformed body')\n\n      defaultSpeechModelId = typeof data.default === 'string' && data.default.length > 0 ? data.default : null\n\n      return data.models.map(m => ({\n        id: m.id,\n        name: m.name,\n        description: m.description,\n        provider: OFFICIAL_SPEECH_PROVIDER_ID,\n      }))\n    },\n    listVoices: async (_config, _provider, model): Promise<VoiceInfo[]> => {\n      // Voice catalogs are model-scoped on the server side. Pass the active\n      // model through so Azure / cosyvoice / future provider voices route to\n      // the right adapter. If model discovery has not completed yet, keep the\n      // legacy `auto` request as a startup fallback.\n      const target = model && model.length > 0 ? model : 'auto'\n      const url = new URL(`${SERVER_URL}/api/v1/audio/voices`)\n      url.searchParams.set('model', target)","sourceCodeStart":118,"sourceCodeEnd":154,"githubUrl":"https://github.com/moeru-ai/airi/blob/677329427f32468c74b17f3ec47eeca4e05bec65/packages/stage-ui/src/libs/providers/providers/official/index.ts#L118-L154","documentation":"Thrown by the official speech provider's `listModels()` when `/api/v1/audio/models` returned HTTP 200 but the JSON body has no `models` array (`Array.isArray(data.models)` is false). It is a contract violation between the client's expected wire shape `{ models: [...], default?: string }` and what the server actually returned — not a transport failure.","triggerScenarios":"Server responds 200 with `null`/`{}` (gateway healthy but upstream catalog empty), a differently-shaped payload from an older/newer server revision, or a JSON body like `{ \"error\": ... }` delivered with status 200 by a misconfigured proxy that rewrites error statuses.","commonSituations":"Client and server versions skew across a deploy (new client expects `models[]`, old server returns a different key); an edge/CDN intercepting the request and returning a 200 HTML or JSON error page; the unspeech backend returning an envelope without `models` when no TTS provider is configured.","solutions":["curl the endpoint with a valid bearer token and inspect the actual body: does it contain `models: [...]`?","Align client and server versions — redeploy `server/apps/api` from the same revision as `packages/stage-ui`.","If a proxy rewrites error responses to 200, disable that behavior for `/api/v1/audio/*`.","If the body legitimately has no models, configure at least one TTS backend in `UNSPEECH_UPSTREAM` so the server emits a non-empty catalog."],"exampleFix":"// before\nconst data = await res.json() as { models?: ... }\nif (!Array.isArray(data.models))\n  throw new Error('audio models upstream returned malformed body')\n// after — keep a defensive fallback catalog shape and log the offending body\nconst data = await res.json() as { models?: ... }\nif (!Array.isArray(data.models)) {\n  console.warn('audio models payload lacked models[]', data)\n  return []\n}","handlingStrategy":"type-guard","validationCode":"const res = await fetch(url, { headers: authHeaders() })\nconst data: unknown = await res.json()\nif (!Array.isArray((data as { models?: unknown }).models))\n  // log body and degrade instead of throwing","typeGuard":"function isModelsCatalog(data: unknown): data is { models: Array<{ id: string, name: string, description?: string }>, default?: string | null } {\n  if (typeof data !== 'object' || data === null)\n    return false\n  const models = (data as { models?: unknown }).models\n  return Array.isArray(models) && models.every(m => typeof (m as { id?: unknown })?.id === 'string')\n}","tryCatchPattern":"try {\n  return data.models.map(toModelInfo)\n}\ncatch {\n  throw new Error('audio models upstream returned malformed body')\n}","preventionTips":["Contract-test the `/api/v1/audio/models` shape in server CI.","Deploy client and server together; the `models[]` key is part of the wire contract.","Log the raw body when validation fails so skew is diagnosable.","Prefer degrading to an empty catalog over crashing the settings page."],"tags":["schema","validation","api","server","speech"],"backgroundTag":"schema-validation-failed","analyzedSha":"677329427f32468c74b17f3ec47eeca4e05bec65","analyzedAt":"2026-08-18T17:29:58.153Z","schemaVersion":2},"datasetVersion":"2026-08-23T16:17:53.355Z"}