Reject task=translate on the server for models that cannot translate (D-044)

The UI already hid it; a direct API call to POST /api/transcribe or the live
WebSocket could still get the source language back labelled as a translation.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 14:33:08 +01:00
co-authored by Claude Opus 5.5
parent 96d100bd45
commit 3583d60847
4 changed files with 62 additions and 4 deletions
+3 -2
View File
@@ -427,8 +427,9 @@ Found while building transcript translation (D-045), 2026-10-29:
every row of a padded batch, so translation runs one sentence per call
(~4× slower than batch 8 would be). Re-check after the 4.3 upgrade with
`bun scripts/bench-translate.ts` ("output identical" across batch sizes).
- [ ] The server API still accepts `task: "translate"` with large-v3-turbo
(only the UI hides it, D-044).
- [x] The server API still accepts `task: "translate"` with large-v3-turbo
(only the UI hides it, D-044). — Fixed: `POST /api/transcribe` and the
live WebSocket answer 400 via `translateNotSupported` (src/server/transcribe.ts).
Found in spec 001 workstream 6 (D-046), open:
- [ ] Transformers.js 4.2 has no beam search: `num_beams > 1` in a model's
+9 -1
View File
@@ -7,7 +7,13 @@ import { ortAliasPlugin } from "../scripts/ortAlias.ts";
import { injectPrecacheManifest } from "./pwa/precache.ts";
import { auth } from "./server/auth.ts";
import { AUTH_ENABLED } from "./server/flags.ts";
import { MAX_UPLOAD_BYTES, parseLanguageAndTask, sidecar, transcribe } from "./server/transcribe.ts";
import {
MAX_UPLOAD_BYTES,
parseLanguageAndTask,
sidecar,
transcribe,
translateNotSupported,
} from "./server/transcribe.ts";
import { liveWebSocket } from "./server/live.ts";
import { getFasterWhisperModel } from "./lib/models.ts";
@@ -140,6 +146,8 @@ async function handleApiRequest(request: Request, server: any): Promise<Response
// because this entry point is a WebSocket, not JSON over HTTP).
const options = parseLanguageAndTask(url);
if (typeof options === "string") return new Response(options, { status: 400 });
const translateError = translateNotSupported(modelId, options.task);
if (translateError) return new Response(translateError, { status: 400 });
// Fail fast like the HTTP route (D-013 "degrade gracefully") instead of
// opening a socket that can only report unavailability once the first
// segment reaches the sidecar.
+14 -1
View File
@@ -27,7 +27,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import type { TranscribeStreamLine } from "../lib/engine";
import { HINT_MAX_CHARS, normalizeHint } from "../lib/hint";
import { getFasterWhisperModel } from "../lib/models";
import { getFasterWhisperModel, getModel } from "../lib/models";
import type { TranscriptChunk, WhisperTask } from "../worker/protocol";
import { getSessionUser as appGetSessionUser, type SessionUser } from "./auth.ts";
@@ -377,6 +377,17 @@ export function parseLanguageAndTask(
return { language, task: rawTask, ...(prompt && { prompt }) };
}
/**
* Whisper's translate task only on models trained for it (D-044): the UI
* hides it elsewhere, and the server rejects it the same way so a direct
* API call can't get the source language back labelled as a translation.
* Returns the 400 error text, or undefined when the request is fine.
*/
export function translateNotSupported(modelId: string, task: WhisperTask): string | undefined {
if (task !== "translate" || getModel(modelId)?.canTranslate) return undefined;
return `model ${modelId} cannot translate; use task=transcribe`;
}
/** Parse + validate query params; a string is the 400 error text. */
function parseParams(url: URL): TranscribeParams | string {
const modelId = url.searchParams.get("model");
@@ -387,6 +398,8 @@ function parseParams(url: URL): TranscribeParams | string {
const options = parseLanguageAndTask(url);
if (typeof options === "string") return options;
const translateError = translateNotSupported(modelId, options.task);
if (translateError) return translateError;
return { modelId, fasterWhisperModel, ...options };
}
+36
View File
@@ -6,6 +6,7 @@ import {
createSidecar,
createTranscribeHandlers,
parseLanguageAndTask,
translateNotSupported,
type Sidecar,
type SidecarSegment,
type SidecarTranscribeRequest,
@@ -262,6 +263,41 @@ describe("POST /api/transcribe", () => {
}
});
test("400 for translate on a model not trained for it; translate still works where it is (D-044)", async () => {
const sidecar = fakeSidecar();
const handlers = createTranscribeHandlers({
sidecar,
getSessionUser: sessionFromCookie,
tempDir: await makeTempDir(),
});
for (const model of [
"onnx-community/whisper-large-v3-turbo",
"onnx-community/whisper-small.en",
]) {
const response = await handlers.post(
postRequest(`?model=${model}&task=translate`, pcmBody([0.1]), { cookie: "sid=ok" }),
);
expect(response.status).toBe(400);
expect(await response.text()).toContain("cannot translate");
}
expect(sidecar.requests).toHaveLength(0);
const ok = await handlers.post(
postRequest("?model=onnx-community/whisper-small&task=translate", pcmBody([0.1]), {
cookie: "sid=ok",
}),
);
expect(ok.status).toBe(200);
});
test("translateNotSupported: the shared check for the HTTP and live routes", () => {
expect(translateNotSupported("onnx-community/whisper-large-v3-turbo", "translate")).toContain(
"cannot translate",
);
expect(translateNotSupported("onnx-community/whisper-large-v3-turbo", "transcribe")).toBeUndefined();
expect(translateNotSupported("onnx-community/whisper-small", "translate")).toBeUndefined();
});
test("503 with the reason when the sidecar is unavailable", async () => {
const sidecar = fakeSidecar({ available: false, reason: "faster-whisper is not installed" });
const handlers = createTranscribeHandlers({