Support OpenAI verbose_json for /v1/audio/transcriptions (+ multipart field fix) - #510
Support OpenAI verbose_json for /v1/audio/transcriptions (+ multipart field fix)#510ademasi wants to merge 5 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Adds OpenAI-compatible response_format=verbose_json output to /v1/audio/transcriptions and fixes multipart forwarding so non-file/model fields reach the transcription handler.
Changes:
- Forward additional multipart fields (
response_format,language,prompt,stream,temperature) into the transcription request JSON. - Add
verbose_jsonresponse mode that parses Whisper timestamp markers into OpenAI-stylesegments, while preserving the existing{model, text}response for default/json mode.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
src/server/server.cpp |
Forwards more multipart form fields into the transcription request payload. |
src/server/rest_handler.cpp |
Implements verbose_json formatting by extracting segments from `< |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (parts.count("language")) request_json["language"] = parts["language"].content; | ||
| if (parts.count("prompt")) request_json["prompt"] = parts["prompt"].content; | ||
| if (parts.count("stream")) request_json["stream"] = (parts["stream"].content == "true"); | ||
| if (parts.count("temperature")) request_json["temperature"] = std::stof(parts["temperature"].content); |
There was a problem hiding this comment.
temperature is parsed with std::stof(parts["temperature"].content) directly in the request handler. If the multipart field is present but empty/invalid (or uses a locale-specific format), this throws and is caught by the generic handler exception path, returning a 500 instead of a client error. Consider validating/parsing defensively (e.g., try/catch and return a structured 400 error, or ignore invalid values and let the handler apply defaults).
| if (parts.count("temperature")) request_json["temperature"] = std::stof(parts["temperature"].content); | |
| if (parts.count("temperature")) { | |
| const std::string& temperature_content = parts["temperature"].content; | |
| std::istringstream temperature_stream(temperature_content); | |
| temperature_stream.imbue(std::locale::classic()); | |
| float temperature = 0.0f; | |
| char extra = '\0'; | |
| if (!(temperature_stream >> temperature) || (temperature_stream >> extra)) { | |
| json error_response = { | |
| {"error", { | |
| {"message", "Invalid temperature: expected a numeric value."}, | |
| {"type", "invalid_request_error"}, | |
| {"param", "temperature"} | |
| }} | |
| }; | |
| send_response(error_response); | |
| return; | |
| } | |
| request_json["temperature"] = temperature; | |
| } |
| #else | ||
| throw std::runtime_error("ASR models are not supported in this build"); | ||
| std::string audio_context; | ||
| std::string raw_output; | ||
| std::string language; | ||
| #endif |
There was a problem hiding this comment.
In the FASTFLOWLM_LINUX_LIMITED_MODELS branch, throw std::runtime_error(...) is followed by declarations of raw_output / language. This code is unreachable and can trigger compiler warnings (and break builds when warnings are treated as errors). Move the declarations before the throw (or remove them entirely and restructure the conditional so the variables exist in both branches without unreachable statements).
| if (want_verbose) { | ||
| std::vector<std::tuple<float, size_t, size_t>> markers; | ||
| auto it = std::sregex_iterator(raw_output.begin(), raw_output.end(), ts_regex); | ||
| auto end = std::sregex_iterator(); | ||
| for (; it != end; ++it) { | ||
| markers.emplace_back( | ||
| std::stof((*it)[1].str()), | ||
| static_cast<size_t>(it->position()), | ||
| static_cast<size_t>(it->position() + it->length())); | ||
| } | ||
|
|
||
| json segments = json::array(); | ||
| std::string plain_text; | ||
| float max_end = 0.0f; | ||
| for (size_t i = 0; i + 1 < markers.size(); ++i) { | ||
| float seg_start = std::get<0>(markers[i]); | ||
| float seg_end = std::get<0>(markers[i + 1]); | ||
| size_t text_begin = std::get<2>(markers[i]); | ||
| size_t text_end = std::get<1>(markers[i + 1]); | ||
| if (text_end < text_begin) continue; | ||
| std::string seg_text = raw_output.substr(text_begin, text_end - text_begin); | ||
| size_t a = seg_text.find_first_not_of(" \t\n\r"); | ||
| if (a == std::string::npos) continue; | ||
| size_t b = seg_text.find_last_not_of(" \t\n\r"); | ||
| seg_text = seg_text.substr(a, b - a + 1); | ||
| if (seg_text.empty()) continue; | ||
|
|
||
| segments.push_back({ | ||
| {"id", (int)segments.size()}, | ||
| {"seek", 0}, | ||
| {"start", seg_start}, | ||
| {"end", seg_end}, | ||
| {"text", std::string(" ") + seg_text}, | ||
| {"tokens", json::array()}, | ||
| {"temperature", 0.0}, | ||
| {"avg_logprob", 0.0}, | ||
| {"compression_ratio", 0.0}, | ||
| {"no_speech_prob", 0.0} | ||
| }); | ||
| if (!plain_text.empty()) plain_text += " "; | ||
| plain_text += seg_text; | ||
| if (seg_end > max_end) max_end = seg_end; | ||
| } | ||
|
|
||
| response = { | ||
| {"task", "transcribe"}, | ||
| {"language", language}, | ||
| {"duration", max_end}, | ||
| {"text", plain_text}, | ||
| {"segments", segments}, | ||
| {"model", model} | ||
| }; |
There was a problem hiding this comment.
verbose_json segmentation assumes there are at least 2 timestamp markers; if markers.size() < 2, the loop never runs and the response returns empty text, empty segments, and duration stays 0. This can happen for very short outputs where the model emits only the initial timestamp marker before EOS. Add a fallback for insufficient markers (e.g., strip markers and return a single segment or fall back to the non-verbose {model,text} response) so verbose_json never drops transcription text.
d29b268 to
55435e8
Compare
The multipart dispatcher for POST /v1/audio/transcriptions was only forwarding `model` and `file` from the parsed multipart body to `handle_openai_audio_transcriptions`, silently dropping every other OpenAI-spec field: `response_format`, `language`, `prompt`, `stream`, and `temperature`. This meant callers who sent, e.g., `response_format=verbose_json` via multipart (which is the canonical way per the OpenAI audio API) would always get the default `json` response regardless of what they asked for, and similar for language hints, prompts, streaming, and temperature. Forward all of these from the parsed `parts` map into the request JSON before dispatching. `stream` is parsed as a boolean and `temperature` as a float to match the JSON-body code path in the handler. Fields not present in the multipart body are simply omitted, preserving the existing defaults in the handler. This is a bug fix independent of any response-format features. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a client calls `POST /v1/audio/transcriptions` with
`response_format=verbose_json`, the handler now returns an
OpenAI-compatible payload of the form:
{
"task": "transcribe",
"language": "<detected>",
"duration": <seconds>,
"text": "<flat transcript>",
"segments": [
{"id", "seek", "start", "end", "text",
"tokens", "temperature", "avg_logprob",
"compression_ratio", "no_speech_prob"},
...
],
"model": "<model-id>"
}
Per-segment `start` / `end` timestamps are parsed from the Whisper
engine's internal `<|X.XX|>` markers by enabling the timestamp flag on
`whisper_engine->generate(...)` when verbose output is requested, then
splitting the raw output on a `<\|(\d+\.\d+)\|>` regex. Consecutive
markers bound each segment; whitespace-only or empty segments are
dropped. `duration` is the maximum end-time across emitted segments.
The plain `json` format is unchanged on the wire and remains
backward-compatible: the handler still returns `{model, text}`, but
the `text` field now has any stray timestamp markers stripped (the
underlying generator is only asked for timestamps when
`verbose_json` is requested, so the strip is a belt-and-suspenders
pass).
Motivation: enables downstream tools (diarization pipelines, subtitle
generators, alignment tools) to align transcript text with audio
timecodes via the standard OpenAI API surface, instead of requiring
a FastFlowLM-specific endpoint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously, `std::stof(parts["temperature"].content)` was called unconditionally when the multipart body contained a `temperature` field. If the value was empty, non-numeric, or in a locale-specific format, this threw `std::invalid_argument` / `std::out_of_range`, which was caught by the generic exception handler downstream and surfaced to the client as a 500 — masking what was really a malformed client request. Wrap the parse in a try/catch and drop the field on parse failure, mirroring the way `language` and `prompt` silently fall through to the handler's defaults when absent. The handler then applies its default `temperature`, the request succeeds, and we no longer 500 on malformed input. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The `FASTFLOWLM_LINUX_LIMITED_MODELS` branch had `throw std::runtime_error(...)` followed by declarations of `raw_output` and `language`. Those declarations were unreachable: `throw` exits the function before they execute, and they existed only to satisfy the post-`#endif` code that referenced the variables. Move the declarations above the `#ifdef` so both branches share the same single declaration. The non-LIMITED branch assigns into the hoisted variables; the LIMITED branch throws unconditionally. No behavioral change, removes the unreachable-code smell flagged by review. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The original verbose_json builder iterates over consecutive marker pairs to bound segments, so it requires at least two `<|X.XX|>` markers in the Whisper output. Very short clips (or any case where the model emits only the initial timestamp before EOS) produce fewer than two markers, and previously this returned an empty `segments: []`, an empty `text`, and `duration: 0` — silently dropping the transcript even though `raw_output` had content. After the segmentation loop, if `segments` is still empty, emit a single fallback segment containing the full stripped transcript. Its `start` defaults to the first available marker (or 0 when no markers exist) and `end` defaults to the last available marker (or 0). `duration` is updated accordingly. If the trimmed transcript is empty as well, no fallback is emitted — `segments` and `text` genuinely have nothing to surface. This guarantees verbose_json never returns less information than the plain `json` path on the same input. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
55435e8 to
17ad979
Compare
|
Hi FastFlowLM team 👋 Friendly nudge on this one. I've rebased the branch onto the latest
Quick recap of what the PR does:
I've been running it locally against long multi-speaker recordings to feed a diarization pipeline, and having the timestamps over the standard OpenAI API surface makes alignment trivial. Would you be open to merging this? Happy to make any changes you'd like — for instance, if you'd prefer the segment split done at the tokenizer level rather than via regex on the decoded string, I'm glad to rework it that way. Thanks for the great project! |
Support OpenAI
verbose_jsonfor/v1/audio/transcriptions(+ multipart field fix)Summary
response_format=verbose_jsontoPOST /v1/audio/transcriptions, returning an OpenAI-compatible payload with per-segment timestamps (start/end) extracted from Whisper's internal<|X.XX|>markers.modelandfilewas silently dropped on the way to the transcription handler (soresponse_format,language,prompt,stream, andtemperaturehad no effect when sent via multipart).verbose_json(or don't setresponse_formatat all) get the same{model, text}payload shape as before.Changes
src/server/server.cpp— the multipart handler for/v1/audio/transcriptionsnow forwardsresponse_format,language,prompt,stream, andtemperaturefrom the parsed multipart body into the request JSON before dispatching tohandle_openai_audio_transcriptions. Previously onlymodelandfilewere forwarded.src/server/rest_handler.cpp—handle_openai_audio_transcriptionsnow readsresponse_formatfrom the request. When it isverbose_json, the Whisper engine is invoked with timestamps enabled and the raw output is split on<\|(\d+\.\d+)\|>to build asegmentsarray; otherwise the existing{model, text}behavior is preserved (with timestamp markers stripped as a defensive step). Adds#include <regex>.Before / after
Before
Request (either JSON body or multipart):
Response (every format returned the same shape;
response_formatwas ignored by the multipart path entirely):{ "model": "whisper-...", "text": "This is the transcribed text of the audio clip..." }After
Same request with
response_format=verbose_json:{ "task": "transcribe", "language": "en", "duration": 12.34, "text": "This is the transcribed text of the audio clip split into two segments.", "segments": [ { "id": 0, "seek": 0, "start": 0.00, "end": 6.20, "text": " This is the transcribed text of the audio", "tokens": [], "temperature": 0.0, "avg_logprob": 0.0, "compression_ratio": 0.0, "no_speech_prob": 0.0 }, { "id": 1, "seek": 0, "start": 6.20, "end": 12.34, "text": " clip split into two segments.", "tokens": [], "temperature": 0.0, "avg_logprob": 0.0, "compression_ratio": 0.0, "no_speech_prob": 0.0 } ], "model": "whisper-..." }Same request with
response_format=json(or omitted): byte-for-byte identical to the old behavior, i.e.{model, text}.Backward compatibility
response_format=jsoncallers (and callers that omitresponse_format) see the same{model, text}response shape as before. The only change to that path is that any stray<|X.XX|>timestamp markers are stripped fromtextbefore returning — but because the underlying generator is only asked to emit timestamps whenverbose_jsonis requested, this strip is a belt-and-suspenders pass and is a no-op for the plain path.Test plan
response_format=verbose_jsonreturned 200+ segments with monotonically increasingstart/endtimestamps and a matching flattextfield.durationfield in the verbose response matches the expected clip length (within one segment boundary).response_format=jsonpath manually verified to return{model, text}with no behavioral change and no timestamp markers intext.language,prompt,temperature, andstreamsent via multipart now reach the handler (previously dropped).Notes for reviewers
<|X.XX|>markers usingstd::regexwith the pattern<\|(\d+\.\d+)\|>. This is intentionally conservative — it assumes the markers are emitted verbatim in the decoded string and can't straddle a Unicode boundary. In practice this holds today because the markers are ASCII, but it is fragile if the tokenizer or decoder output format changes. A more robust long-term fix would be to do the split at the tokenizer level (i.e. havewhisper_engine->generate(...)return structured segment data directly, rather than reconstructing it from the flat string), which is out of scope for this PR.tokens,avg_logprob,compression_ratio, andno_speech_probfields are emitted with zero/empty defaults because the Whisper engine doesn't currently surface them; they are present in the response so the shape matches the OpenAI spec and downstream clients don't have to special-case missing keys. Filling them in is a follow-up.fix(server): forward multipart audio fields...) can be taken independently of theverbose_jsonfeature commit if desired.