diff --git a/open-sse/handlers/audioTranscription.ts b/open-sse/handlers/audioTranscription.ts index 6a19c55ea6..1ba5c94268 100644 --- a/open-sse/handlers/audioTranscription.ts +++ b/open-sse/handlers/audioTranscription.ts @@ -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 +): 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 = {}; 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)); diff --git a/tests/unit/audio-transcription-handler.test.ts b/tests/unit/audio-transcription-handler.test.ts index 8f3846019a..aeafe68e1d 100644 --- a/tests/unit/audio-transcription-handler.test.ts +++ b/tests/unit/audio-transcription-handler.test.ts @@ -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")); +});