mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
fix(audio): build multipart body manually to preserve Content-Type (#2842)
Integrated into release/v3.8.6
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user