mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-18 21:22:28 +03:00
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:
@@ -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";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
88
tests/unit/audio-transcription-opus-filename.test.ts
Normal file
88
tests/unit/audio-transcription-opus-filename.test.ts
Normal 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");
|
||||
});
|
||||
Reference in New Issue
Block a user