fix(audio): build multipart body manually to preserve Content-Type (#2842)

Integrated into release/v3.8.6
This commit is contained in:
mi
2026-05-29 03:29:25 -05:00
committed by GitHub
parent 1461a78d07
commit 1f5c215cc0
2 changed files with 136 additions and 37 deletions

View File

@@ -69,6 +69,43 @@ function getUploadedFileName(file: Blob & { name?: unknown }): string {
return typeof file.name === "string" && file.name.length > 0 ? file.name : "audio.wav";
}
async function buildMultipartBody(
file: Blob & { name?: unknown },
fields: Record<string, string>
): Promise<{ body: Uint8Array; contentType: string }> {
const boundary = "----OmniRouteAudioBoundary" + Date.now().toString(36);
const parts: Uint8Array[] = [];
const encoder = new TextEncoder();
for (const [name, value] of Object.entries(fields)) {
parts.push(
encoder.encode(
`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`
)
);
}
const fileName = getUploadedFileName(file).replace(/["]/g, "_");
const fileBytes = new Uint8Array(await file.arrayBuffer());
parts.push(
encoder.encode(
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${fileName}"\r\nContent-Type: ${file.type || "application/octet-stream"}\r\n\r\n`
)
);
parts.push(fileBytes);
parts.push(encoder.encode(`\r\n--${boundary}--\r\n`));
const totalLength = parts.reduce((sum, p) => sum + p.length, 0);
const body = new Uint8Array(totalLength);
let offset = 0;
for (const part of parts) {
body.set(part, offset);
offset += part.length;
}
return { body, contentType: "multipart/form-data; boundary=" + boundary };
}
/**
* Infer a suitable Content-Type for Deepgram from the browser-provided MIME
* type and the original filename. Deepgram accepts `audio/*` and many raw
@@ -231,14 +268,12 @@ async function handleAssemblyAITranscription(providerConfig, file, modelId, toke
* Multipart POST, transform response to { text }
*/
async function handleNvidiaTranscription(providerConfig, file, modelId, token) {
const upstreamForm = new FormData();
upstreamForm.append("file", file, getUploadedFileName(file));
upstreamForm.append("model", modelId);
const { body, contentType } = await buildMultipartBody(file, { model: modelId });
const res = await fetch(providerConfig.baseUrl, {
method: "POST",
headers: buildAuthHeaders(providerConfig, token),
body: upstreamForm,
headers: { ...buildAuthHeaders(providerConfig, token), "Content-Type": contentType },
body,
});
if (!res.ok) {
@@ -446,11 +481,7 @@ export async function handleAudioTranscription({
}
// Default: OpenAI/Groq/Qwen3-compatible multipart proxy
const upstreamForm = new FormData();
upstreamForm.append("file", file, getUploadedFileName(file));
upstreamForm.append("model", modelId);
// Forward optional parameters
const extraFields: Record<string, string> = {};
for (const key of [
"language",
"prompt",
@@ -460,15 +491,20 @@ export async function handleAudioTranscription({
]) {
const val = formData.get(key);
if (val !== null && val !== undefined) {
upstreamForm.append(key, /** @type {string} */ val);
extraFields[key] = String(val);
}
}
const { body: multipartBody, contentType: multipartCT } = await buildMultipartBody(file, {
model: modelId,
...extraFields,
});
try {
const res = await fetch(providerConfig.baseUrl, {
method: "POST",
headers: buildAuthHeaders(providerConfig, token),
body: upstreamForm,
headers: { ...buildAuthHeaders(providerConfig, token), "Content-Type": multipartCT },
body: multipartBody,
});
if (!res.ok) {
@@ -476,11 +512,11 @@ export async function handleAudioTranscription({
}
const data = await res.text();
const contentType = res.headers.get("content-type") || "application/json";
const respContentType = res.headers.get("content-type") || "application/json";
return new Response(data, {
status: 200,
headers: { "Content-Type": contentType },
headers: { "Content-Type": respContentType },
});
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));

View File

@@ -39,14 +39,10 @@ test("handleAudioTranscription proxies OpenAI-compatible multipart requests and
let captured;
globalThis.fetch = async (url, options = {}) => {
const upstreamEntries = Array.from(options.body.entries());
captured = {
url: String(url),
headers: options.headers,
entries: upstreamEntries.map(([key, value]) => [
key,
value instanceof File ? { name: value.name, type: value.type } : value,
]),
body: options.body,
};
return new Response(JSON.stringify({ text: "hello" }), {
@@ -73,15 +69,24 @@ test("handleAudioTranscription proxies OpenAI-compatible multipart requests and
assert.equal(response.status, 200);
assert.equal(captured.url, "https://api.openai.com/v1/audio/transcriptions");
assert.equal(captured.headers.Authorization, "Bearer openai-key");
assert.deepEqual(captured.entries, [
["file", { name: "clip.webm", type: "audio/webm" }],
["model", "whisper-1"],
["language", "pt"],
["prompt", "meeting"],
["response_format", "verbose_json"],
["temperature", "0.1"],
["timestamp_granularities[]", "word"],
]);
assert.ok(captured.body instanceof Uint8Array);
assert.match(captured.headers["Content-Type"], /^multipart\/form-data; boundary=/);
const bodyText = new TextDecoder().decode(captured.body);
assert.ok(bodyText.includes('name="model"'));
assert.ok(bodyText.includes("whisper-1"));
assert.ok(bodyText.includes('name="language"'));
assert.ok(bodyText.includes("pt"));
assert.ok(bodyText.includes('name="prompt"'));
assert.ok(bodyText.includes("meeting"));
assert.ok(bodyText.includes('name="response_format"'));
assert.ok(bodyText.includes("verbose_json"));
assert.ok(bodyText.includes('name="temperature"'));
assert.ok(bodyText.includes("0.1"));
assert.ok(bodyText.includes('name="timestamp_granularities[]"'));
assert.ok(bodyText.includes("word"));
assert.ok(bodyText.includes('name="file"'));
assert.ok(bodyText.includes('filename="clip.webm"'));
assert.deepEqual(await response.json(), { text: "hello" });
} finally {
globalThis.fetch = originalFetch;
@@ -171,10 +176,7 @@ test("handleAudioTranscription normalizes Nvidia responses to text", async () =>
globalThis.fetch = async (_url, options = {}) => {
captured = {
headers: options.headers,
entries: Array.from(options.body.entries()).map(([key, value]) => [
key,
value instanceof File ? { name: value.name, type: value.type } : value,
]),
body: options.body,
};
return new Response(JSON.stringify({ transcript: "nvidia text" }), {
@@ -198,10 +200,14 @@ test("handleAudioTranscription normalizes Nvidia responses to text", async () =>
});
assert.equal(captured.headers.Authorization, "Bearer nvidia-key");
assert.deepEqual(captured.entries, [
["file", { name: "clip.wav", type: "audio/wav" }],
["model", upstreamModel],
]);
assert.ok(captured.body instanceof Uint8Array);
assert.match(captured.headers["Content-Type"], /^multipart\/form-data; boundary=/);
const bodyText = new TextDecoder().decode(captured.body);
assert.ok(bodyText.includes('name="file"'));
assert.ok(bodyText.includes('filename="clip.wav"'));
assert.ok(bodyText.includes('name="model"'));
assert.ok(bodyText.includes(upstreamModel));
assert.deepEqual(await response.json(), { text: "nvidia text" });
}
} finally {
@@ -462,3 +468,60 @@ test("handleAudioTranscription returns a 500 when upstream fetch throws", async
globalThis.fetch = originalFetch;
}
});
test("buildMultipartBody produces valid multipart with correct boundary", async () => {
const { buildMultipartBody } = await import("../../open-sse/handlers/audioTranscription.ts");
const file = new File([Buffer.from("hello")], "audio.wav", { type: "audio/wav" });
const { body, contentType } = await buildMultipartBody(file, {
model: "whisper-1",
language: "en",
});
assert.ok(body instanceof Uint8Array);
assert.match(contentType, /^multipart\/form-data; boundary=/);
const boundary = contentType.split("boundary=")[1];
const bodyText = new TextDecoder().decode(body);
assert.ok(bodyText.startsWith("--" + boundary));
assert.ok(bodyText.endsWith("--" + boundary + "--\r\n"));
assert.ok(bodyText.includes('name="model"'));
assert.ok(bodyText.includes("whisper-1"));
assert.ok(bodyText.includes('name="language"'));
assert.ok(bodyText.includes("en"));
assert.ok(bodyText.includes('name="file"'));
assert.ok(bodyText.includes('filename="audio.wav"'));
assert.ok(bodyText.includes("Content-Type: audio/wav"));
assert.ok(bodyText.includes("hello"));
});
test("buildMultipartBody sanitizes filename with quotes and newlines", async () => {
const { buildMultipartBody } = await import("../../open-sse/handlers/audioTranscription.ts");
const rawName = 'bad"name\r\n.wav';
const file = new File([Buffer.from("x")], rawName, { type: "audio/wav" });
const { body, contentType } = await buildMultipartBody(file, { model: "test" });
const bodyText = new TextDecoder().decode(body);
assert.ok(!bodyText.includes('"'));
assert.ok(!bodyText.includes("\r"));
assert.ok(!bodyText.includes("\n"));
assert.ok(bodyText.includes("bad__name__.wav"));
});
test("buildMultipartBody defaults to audio.wav for unnamed files", async () => {
const { buildMultipartBody } = await import("../../open-sse/handlers/audioTranscription.ts");
const file = new File([Buffer.from("x")], "", { type: "audio/wav" });
const { body } = await buildMultipartBody(file, { model: "test" });
const bodyText = new TextDecoder().decode(body);
assert.ok(bodyText.includes('filename="audio.wav"'));
});
test("buildMultipartBody uses application/octet-stream for unknown MIME types", async () => {
const { buildMultipartBody } = await import("../../open-sse/handlers/audioTranscription.ts");
const file = new File([Buffer.from("x")], "data.bin", { type: "" });
const { body } = await buildMultipartBody(file, { model: "test" });
const bodyText = new TextDecoder().decode(body);
assert.ok(bodyText.includes("Content-Type: application/octet-stream"));
});