fix(api): accept .opus uploads on /v1/audio/transcriptions (#10607)

Whisper-compatible upstreams pick the decoder from the multipart filename
against an allow-list (flac, m4a, mp3, mp4, mpeg, mpga, oga, ogg, wav,
webm) that has no `opus`, and OmniRoute forwarded the client's filename
verbatim. The same bytes transcribed as `note.ogg` and 400'd as
`note.opus`. Since /v1/audio/speech emits audio/opus for
`response_format=opus`, clients re-uploading their own voice notes hit
this on every round trip.

A `.opus` file is Opus in an Ogg container (RFC 7845), so `.ogg` is a
truthful relabel and is already on the allow-list. Rewrite the extension
in getUploadedFileName, the single choke point feeding
buildMultipartBody.

The OpenRouter STT path had the same root cause with a quieter symptom:
`.opus` matched neither its extension list nor its MIME map, so it fell
through to the "wav" default and announced Opus bytes as WAV. Map both
the extension and audio/opus to its already-supported ogg container.

Fixes #10588
This commit is contained in:
monem
2026-08-18 15:53:01 +02:00
committed by GitHub
parent 70f94685e6
commit 735d2c9659
3 changed files with 110 additions and 1 deletions

View File

@@ -69,8 +69,24 @@ function isValidPathSegment(segment: string): boolean {
return !segment.includes("..") && !segment.includes("//");
}
/**
* A `.opus` file is Opus audio in an Ogg container (RFC 7845) — the same bytes
* a client would otherwise name `.ogg`. Whisper-compatible upstreams pick the
* decoder from the *filename* and their allow-list
* (`flac, m4a, mp3, mp4, mpeg, mpga, oga, ogg, wav, webm`) has no `opus`, so
* `note.opus` 400s while byte-identical `note.ogg` succeeds. Since
* `/v1/audio/speech` emits `audio/opus` for `response_format=opus`, clients
* round-tripping their own voice notes hit this constantly. Relabel to the
* container that actually describes the bytes.
*/
function normalizeUploadExtension(name: string): string {
return name.replace(/\.opus$/i, ".ogg");
}
function getUploadedFileName(file: Blob & { name?: unknown }): string {
return typeof file.name === "string" && file.name.length > 0 ? file.name : "audio.wav";
return typeof file.name === "string" && file.name.length > 0
? normalizeUploadExtension(file.name)
: "audio.wav";
}
/**

View File

@@ -21,6 +21,10 @@ import { upstreamErrorResponse } from "./audioTranscription.ts";
export function resolveOpenRouterAudioFormat(file: Blob & { name?: unknown }): string {
const fileName = typeof file.name === "string" ? file.name.toLowerCase() : "";
const extension = fileName.includes(".") ? fileName.split(".").pop() || "" : "";
// `.opus` is Ogg-encapsulated Opus (RFC 7845). Without this it matched
// neither the extension list nor the MIME map below and fell through to the
// "wav" default, so Opus bytes were announced to the upstream as WAV.
if (extension === "opus") return "ogg";
if (["wav", "mp3", "flac", "m4a", "ogg", "webm", "aac"].includes(extension)) {
return extension;
}
@@ -33,6 +37,7 @@ export function resolveOpenRouterAudioFormat(file: Blob & { name?: unknown }): s
"audio/x-flac": "flac",
"audio/mp4": "m4a",
"audio/ogg": "ogg",
"audio/opus": "ogg",
"audio/webm": "webm",
"audio/aac": "aac",
};

View File

@@ -0,0 +1,88 @@
import test from "node:test";
import assert from "node:assert/strict";
const { buildMultipartBody } = await import("../../open-sse/handlers/audioTranscription.ts");
const { resolveOpenRouterAudioFormat } = await import(
"../../open-sse/handlers/openrouterTranscription.ts"
);
/**
* `.opus` is Opus audio in an Ogg container (RFC 7845) — byte-identical to what
* a client would name `.ogg`. Whisper-compatible upstreams select the decoder
* from the multipart *filename* against an allow-list
* (`flac, m4a, mp3, mp4, mpeg, mpga, oga, ogg, wav, webm`) that has no `opus`,
* so `note.opus` used to 400 while the same bytes as `note.ogg` transcribed
* fine. `/v1/audio/speech` emits `audio/opus` for `response_format=opus`, so
* clients re-uploading their own voice notes hit it constantly (#10588).
*/
function audioFile(name: string, type = "") {
return Object.assign(new Blob([new Uint8Array([1, 2, 3, 4])], { type }), { name });
}
function filenameIn(body: Uint8Array): string {
const header = new TextDecoder().decode(body);
return /filename="([^"]*)"/.exec(header)?.[1] ?? "";
}
test("a .opus upload is announced to the upstream as .ogg", async () => {
const { body } = await buildMultipartBody(audioFile("note.opus", "audio/opus"), {
model: "whisper-1",
});
assert.equal(filenameIn(body), "note.ogg");
});
test("the rewrite is case-insensitive and keeps the rest of the name intact", async () => {
const { body } = await buildMultipartBody(audioFile("Voice Note 2026.OPUS"), {
model: "whisper-1",
});
assert.equal(filenameIn(body), "Voice Note 2026.ogg");
});
test("only a trailing .opus is rewritten — not one mid-name", async () => {
// `opus` appearing anywhere else is part of the name, not the container.
const { body } = await buildMultipartBody(audioFile("opus-demo.wav"), { model: "whisper-1" });
assert.equal(filenameIn(body), "opus-demo.wav");
const nested = await buildMultipartBody(audioFile("take.opus.mp3"), { model: "whisper-1" });
assert.equal(filenameIn(nested.body), "take.opus.mp3");
});
test("other extensions are forwarded unchanged", async () => {
for (const name of ["note.ogg", "note.mp3", "note.wav", "note.webm"]) {
const { body } = await buildMultipartBody(audioFile(name), { model: "whisper-1" });
assert.equal(filenameIn(body), name);
}
});
test("a nameless blob still falls back to audio.wav", async () => {
const blob = new Blob([new Uint8Array([1, 2, 3, 4])], { type: "audio/wav" });
const { body } = await buildMultipartBody(blob as Blob & { name?: unknown }, {
model: "whisper-1",
});
assert.equal(filenameIn(body), "audio.wav");
});
/**
* The OpenRouter STT endpoint takes the container as a JSON field rather than a
* filename. `.opus` matched neither its extension list nor its MIME map, so it
* fell through to the `"wav"` default — announcing Opus bytes as WAV.
*/
test("OpenRouter STT resolves .opus to its ogg container, not the wav default", () => {
assert.equal(resolveOpenRouterAudioFormat(audioFile("note.opus")), "ogg");
assert.equal(resolveOpenRouterAudioFormat(audioFile("note.OPUS")), "ogg");
});
test("OpenRouter STT resolves an audio/opus MIME to ogg", () => {
assert.equal(resolveOpenRouterAudioFormat(audioFile("blob", "audio/opus")), "ogg");
});
test("OpenRouter STT still resolves the formats it already supported", () => {
assert.equal(resolveOpenRouterAudioFormat(audioFile("a.ogg")), "ogg");
assert.equal(resolveOpenRouterAudioFormat(audioFile("a.mp3")), "mp3");
assert.equal(resolveOpenRouterAudioFormat(audioFile("blob", "audio/webm;codecs=opus")), "webm");
assert.equal(resolveOpenRouterAudioFormat(audioFile("mystery.xyz")), "wav");
});