#1579·OpenMAIC

[Bug]: Multipart audio bodies serialize as "[object FormData]" after #1514 (undici vs global FormData mismatch) — ASR/TTS voice features fail entirely

Author: cosarahCreated Sep 18, 2026Updated Sep 18, 2026
Labelsbugarea:providers

Bug Description

Since #1514 (commit 87c4524b), every audio-provider request built with a multipart FormData body is sent out with the literal 17-byte string [object FormData] as the entire request body and content-type: text/plain;charset=UTF-8. The audio bytes and all form fields (including model) never leave the process. As a result all voice features that post multipart bodies fail entirely — ASR (voice input), TTS paths that use FormData, voice registration/cloning — on any deployment running main at or after 87c4524b.

#1514 switched the audio transport from the global fetch to the npm undici package's own fetch (correctly motivated: the pinned dispatcher is only guaranteed to be honored when the fetching undici and the dispatcher-providing undici are the same copy). But the provider adapters kept constructing bodies with Node's global FormData/Blob/File — which are the built-in undici's classes. npm undici's body serializer brand-checks for its own classes; a "foreign" FormData falls through to the string-coercion branch, producing [object FormData].

Steps to Reproduce

End-user repro:

  1. Deploy main at or after 87c4524b with any OpenAI-compatible ASR provider.
  2. Open the classroom, click the microphone, speak.
  3. Every attempt fails with the generic toast An internal error occurred. Please try again later.

Minimal repro (no deployment needed):

javascript
// node repro.mjs  —  package.json has "undici": "7.29.0"
import { fetch } from 'undici';
import { createServer } from 'node:http';

createServer((req, res) => {
  let body = '';
  req.on('data', (c) => (body += c));
  req.on('end', () => {
    console.log('content-type:', req.headers['content-type']);
    console.log('body:', JSON.stringify(body)); // → "[object FormData]"
    res.end('{}');
  });
}).listen(8787, async () => {
  const fd = new FormData();            // Node global = built-in undici's class
  fd.append('model', 'whisper-1');
  fd.append('file', new File([new Uint8Array([1, 2, 3])], 'a.wav', { type: 'audio/wav' }));
  await fetch('http://127.0.0.1:8787/', { method: 'POST', body: fd }); // npm undici fetch
});

Observed on the wire: content-type: text/plain;charset=UTF-8, body exactly [object FormData] (17 bytes). Swapping the first line for the global fetch produces a correct multipart/form-data; boundary=… body.

Expected Behavior

Multipart audio requests reach the provider as multipart/form-data with the audio bytes and form fields intact; ASR/TTS/voice-registration work as before #1514.

Actual Behavior

  • The outbound request carries content-type: text/plain;charset=UTF-8 and the body [object FormData].
  • The downstream OpenAI-compatible gateway cannot bind model from the body, falls back to whisper-1, finds no channel for it, and answers 503 — e.g. 当前分组 default 下对于模型 whisper-1 无可用渠道.
  • The route maps this to the generic client error An internal error occurred. Please try again later. — no provider-side clue surfaces.

Environment

  • Affected version: main at and after 87c4524b (PR #1514, merged 2026-09-15); undici 7.29.0 from package.json.
  • Node runtime: global FormData/Blob/File (built-in undici) used by lib/audio/* adapters.
  • Deployment method / browser / OS: not applicable (server-side regression).

Root Cause Analysis

  • lib/server/audio-provider-fetch.ts (added in #1514) issues provider calls with npm undici's fetch — necessary for the pinned dispatcher to be honored, as its own module comment explains ("Node's bundled undici and the package's undici are different copies").
  • lib/audio/asr-providers.ts, lib/audio/tts-providers.ts, lib/audio/voxcpm-registration.ts, lib/audio/qwen-voice-clone.ts still build bodies with the global FormData/Blob/File (e.g. new FormData() at lib/audio/asr-providers.ts:231, 355, 571).
  • npm undici's serializer brand-checks body instanceof <its own FormData>; the global class fails the check and the body is coerced to a string. Two undici copies in one process, each trusting only its own brands → [object FormData].
  • The adapters using the global classes is the correct API boundary; the mismatch belongs to the transport.

Evidence (production incident on an internal fork)

An internal deployment tracking upstream main shipped #1514 on 2026-09-16 18:38:39 CST. From that moment voice input failed 100% and went unnoticed for over two days. Ingress fingerprints for the failing calls:

  • POST /v1/audio/transcriptions503
  • request_length=457B (headers + the 17-byte string body — the multipart payload is simply gone)
  • User-Agent: undici
  • Upstream gateway log: no model field bound → whisper-1 fallback → 503 当前分组 default 下对于模型 whisper-1 无可用渠道

#1570 reports the exact user-facing symptom ("An internal error occurred" on every microphone attempt, local capture and Web Speech API working) on a hosted instance — very likely this regression.

Affected Area

All multipart audio requests issued through audioProviderFetch:

  • ASR: transcribeWavOpenAICompatibleASR, custom ASR, Azure and other FormData-based providers (lib/audio/asr-providers.ts)
  • TTS FormData paths (lib/audio/tts-providers.ts)
  • Voice registration / cloning: lib/audio/voxcpm-registration.ts, lib/audio/qwen-voice-clone.ts, lib/audio/voice-registration.ts

JSON / string / Buffer body paths are unaffected.

Why the tests in #1514 did not catch it

  • The unit-test doubles moved from vi.stubGlobal('fetch') to vi.mock('undici'): the mock receives the FormData object directly and never serializes it, so the bug — which lives exactly in the serialization step — is invisible.
  • The new real-loopback HTTP tests only ever sent a string body ('{}'), never a multipart one.

Suggested Fix Direction

Normalize the body in the transport (lib/server/audio-provider-fetch.ts) into the undici package's realm before calling undici fetch: rebuild FormData-like bodies (Symbol.toStringTag === 'FormData', not already an undici FormData) via new UndiciFormData() + entries(), rewrapping file parts as undici File (ArrayBuffer is a language builtin and crosses copies safely); likewise rewrap bare Blob/File bodies preserving type/name/lastModified. Leave the adapters on the global classes. Add a regression test that drives a real loopback request with a global-built FormData and asserts the received content-type is multipart/form-data with the fields/audio intact.

Happy to send a PR.