{"record":{"id":"31db7ddc194834e5","repo":"moeru-ai/airi","slug":"no-streaming-tts-model-selected-and-server-returne","errorCode":null,"errorMessage":"No streaming TTS model selected and server returned no default","messagePattern":"No streaming TTS model selected and server returned no default","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/stage-pages/src/pages/settings/providers/speech/official-provider-speech-streaming.vue","lineNumber":95,"sourceCode":"})\n\n// Volcengine TTS 1.0 and 2.0 ship different voice catalogues (mars/moon/ICL\n// vs uranus/saturn; see unspeech voices.go). Re-fetch on model change so the\n// list switches accordingly.\nwatch(model, async () => {\n  await loadVoices()\n})\n\n// Synthesize via the streaming session helper. The page uses the SAME\n// transport the runtime pipeline uses (ws → API proxy → unspeech\n// bridge → Volcengine v3 bidirectional) so the preview faithfully\n// represents what the user hears in actual chat. The session is opened\n// per-preview because there's no LLM token stream here — we just send\n// one `text` frame containing the static preview prompt.\nasync function handleGenerateSpeech(input: string, voiceId: string, _useSSML: boolean): Promise<ArrayBuffer> {\n  const requestedModel = model.value\n  if (!requestedModel)\n    throw new Error('No streaming TTS model selected and server returned no default')\n  // `model` looks like `volcengine/seed-tts-2.0`. The trailing path is\n  // forwarded as Volcengine's `api_resource_id` so the upstream knows which\n  // model variant to use; matches the wiring in `Stage.vue`. We require the\n  // `<backend>/<resource>` shape and refuse anything else — silently picking\n  // a fallback resource id hides config drift.\n  const slashIndex = requestedModel.indexOf('/')\n  if (slashIndex < 0)\n    throw new Error(`Streaming model id missing backend prefix: ${requestedModel}`)\n  const apiResourceId = requestedModel.slice(slashIndex + 1)\n  const result = await streamingSynthesize({\n    model: requestedModel,\n    voice: voiceId,\n    input,\n    extraBody: {\n      api_resource_id: apiResourceId,\n      audio: { sample_rate: 24000, bit_rate: 64000 },\n    },\n  })","sourceCodeStart":77,"sourceCodeEnd":113,"githubUrl":"https://github.com/moeru-ai/airi/blob/677329427f32468c74b17f3ec47eeca4e05bec65/packages/stage-pages/src/pages/settings/providers/speech/official-provider-speech-streaming.vue#L77-L113","documentation":"Guard in the official streaming speech playground: the model to synthesize with comes from the stored provider config or, failing that, a server-provided default. The catalog and default are fetched from the server's `/api/v1/audio/models/streaming` endpoint (operator-controlled via `UNSPEECH_UPSTREAM.streaming`); `serverDefaultModel` is `getDefaultStreamingModel() ?? providerModels[0]?.id ?? null`. If the stored model is unset AND the server returned an empty catalog with no curated default, `model.value` resolves to `''` and this error is thrown before opening the WebSocket session.","triggerScenarios":"Operator has not configured `UNSPEECH_UPSTREAM.streaming` (or no default model) server-side, so the models request returns an empty list; the models request failed (auth expired, network) leaving both stored config and server default empty; user cleared the stored model value; generating before the onMounted fetch resolves.","commonSituations":"Self-hosted deployments without the streaming upstream configured; expired login so the API returns an empty/unauthorized payload treated as no models; new backend versions that renamed models leaving stale-empty state; pointing the client at a server that does not expose the streaming endpoint.","solutions":["Wait for the model picker to populate (modelsLoading) and explicitly select a streaming model, which persists it to provider config.","Verify the server exposes streaming TTS: check the `/api/v1/audio/models/streaming` response in the network tab and have the operator set `UNSPEECH_UPSTREAM.streaming` with a default model.","Re-login if the session expired — an unauthenticated models fetch can come back empty.","Restart the app after the operator fixes server config so onMounted re-fetches the catalog.","If the catalog is intentionally empty, remove/hide the provider page rather than letting users hit this dead end."],"exampleFix":"// before\nconst requestedModel = model.value\nif (!requestedModel)\n  throw new Error('No streaming TTS model selected and server returned no default')\n\n// after\nconst requestedModel = model.value\nif (!requestedModel)\n  throw new Error(modelsLoading.value\n    ? 'Streaming model catalog is still loading — try again in a moment'\n    : 'No streaming TTS model available: select one, or ask the operator to configure UNSPEECH_UPSTREAM.streaming')","handlingStrategy":"validation","validationCode":"const canGenerate = computed(() => !!model.value && !modelsLoading.value)\n// template: <button :disabled=\"!canGenerate\">Generate</button>\nif (modelsLoading.value)\n  throw new Error('Streaming model catalog still loading')\nif (!model.value)\n  throw new Error('Select a streaming model first')","typeGuard":"function hasStreamingModel(m: string | null | undefined): m is string {\n  return typeof m === 'string' && m.length > 0\n}","tryCatchPattern":"try { return await streamingSynthesize({ ... }) }\ncatch (error) { showError(errorMessageFrom(error)) }","preventionTips":["Persist the selected model to provider config as soon as the catalog loads (the page already does this when serverDefaultModel resolves).","Operators: always curate a default model in UNSPEECH_UPSTREAM.streaming.","Distinguish 'loading' from 'empty catalog' in the UI so users retry instead of failing."],"tags":["speech","streaming","model-selection","server-config"],"backgroundTag":"missing-model-selection","analyzedSha":"677329427f32468c74b17f3ec47eeca4e05bec65","analyzedAt":"2026-08-18T17:29:58.153Z","contentChangedAt":"2026-08-18T17:29:58.153Z","schemaVersion":2},"datasetVersion":"2026-09-14T05:17:10.506Z"}