[Bug]: Multipart audio bodies serialize as "[object FormData]" after #1514 (undici vs global FormData mismatch) — ASR/TTS voice features fail entirely
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:
- Deploy
mainat or after 87c4524b with any OpenAI-compatible ASR provider. - Open the classroom, click the microphone, speak.
- Every attempt fails with the generic toast
An internal error occurred. Please try again later.
Minimal repro (no deployment needed):
// 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-8and the body[object FormData]. - The downstream OpenAI-compatible gateway cannot bind
modelfrom the body, falls back towhisper-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:
mainat and after 87c4524b (PR #1514, merged 2026-09-15);undici7.29.0 from package.json. - Node runtime: global
FormData/Blob/File(built-in undici) used bylib/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'sfetch— 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.tsstill build bodies with the globalFormData/Blob/File(e.g.new FormData()atlib/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/transcriptions→ 503request_length=457B(headers + the 17-byte string body — the multipart payload is simply gone)User-Agent: undici- Upstream gateway log: no
modelfield bound →whisper-1fallback → 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')tovi.mock('undici'): the mock receives theFormDataobject 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.
Source: THU-MAIC/OpenMAIC