Accept OpenRouter transcription models that reject verbose_json - #14542
Merged
niksedk merged 1 commit intoSep 5, 2026
Merged
Conversation
OpenRouter hosts transcription models from providers other than OpenAI, and Google's Chirp models reject the request Subtitle Edit sends today in three separate ways. None of them is discoverable from the model name. - verbose_json is rejected. IsJsonOnlyModel only recognizes OpenAI's own gpt-*-transcribe name shape, and the rejection cannot be recognized from the error text either: OpenRouter sometimes forwards the provider's specific complaint and sometimes wraps it as an opaque "Provider returned 400". So any 400 on the first verbose_json attempt now gets one automatic retry with plain json before failing. Chirp returns no word timings, and the existing chunk pipeline already spans each chunk's duration and splits into sentences when only text comes back. - mp3 is rejected outright, with that same opaque 400. No request-level retry can fix it because the audio was already encoded before the service saw it, so the caller now extracts wav for these models. - The synchronous Google Speech-to-Text API underneath has a hard ~60 second audio cap, independent of file size. Confirmed empirically: identical audio succeeds at 60s and fails at 65s and 180s. Chunk sizing has to survive silence snapping, which can move each of a chunk's two cuts by up to 10s, so a chunk can run 20s past its target - 1 MB of 16 kHz mono PCM targets ~33s with a ~53s worst case.
muaz978
force-pushed
the
fix/openrouter-stt-non-gpt-verbose-json-rejection
branch
from
September 5, 2026 03:24
dc3e07d to
73c56ac
Compare
Author
|
Would you mind dear @niksedk answering the 2 important questions as they are critical and both are on you to decide? They are ready with already tested samples whenever you are ok with them |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Subtitle Edit already talks to OpenRouter's transcription endpoint, but the request it
sends today only works for OpenAI's own models. OpenRouter also hosts Google's Chirp
models on that same endpoint, and they reject the current request in three separate
ways. None of the three can be predicted from the model name, and none of them produce
a usable error message.
This PR makes those models work. It adds no engine, no new provider, and no setting.
Note
Scope: 4 files, one behavior change (accept Chirp). Nothing is removed, and no
existing model's behavior changes.
The three rejections
verbose_json400, sometimes with the provider's own message, sometimes an opaqueProvider returned 400jsonmp3audio400wavfor these models up front400again1.
verbose_jsonis refusedIsJsonOnlyModelonly recognizes OpenAI's owngpt-*-transcribename shape. Chirp needsthe same treatment but does not match that shape, and the failure cannot be recognized
from the error text either, because OpenRouter sometimes forwards the provider's specific
complaint and sometimes wraps it as an opaque
Provider returned 400.So rather than pattern matching on error strings, any
400on the firstverbose_jsonattempt now gets exactly one retry with plain
json. Any other status, or a model alreadyknown to be json only, does not retry.
Chirp returns no word timings in either format. That is a limitation of this model over
OpenRouter rather than something this PR can fix, and the existing chunk pipeline already
handles the text only case by spanning each chunk's duration and splitting into sentences.
2.
mp3is refusedNo request level retry can fix this one, because the audio was already encoded before the
service ever saw it. The caller now extracts
wavfor these models instead.Confirmed by sending byte identical audio as
mp3and aswav: onlywavsucceeds.3. There is a hard ~60 second cap
It is independent of file size, and it looks like the synchronous Google Speech-to-Text
API underneath. Confirmed empirically: identical audio succeeds at 60 s, then fails at
65 s and at 180 s with the same opaque
400, regardless of language or prompt.Why the chunk size is 1 MB and not 1.4 MB
Chunk sizing has to survive silence snapping.
ComputeAdjustedBoundariesmoves each cutby up to
maxOffsetSeconds(10 s by default) to land on a silence, and a chunk sitsbetween two independently snapped cuts. So a chunk can run up to 20 s past its target,
not 10 s.
At 16 kHz mono 16 bit PCM, roughly 32 KB/s:
Testing
24 unit tests ship with this PR. Beyond those, this was exercised against the live
OpenRouter endpoint across roughly 40 hours of transcription runs on Turkish TV drama:
verbose_jsonrejections, each one triggering the new fallbackProvider returned 400responses, where the mp3 rejection and the duration cap are indistinguishable from one anotherThe
mp3versuswavresult, and the 60 s versus 65 s versus 180 s result, each come frompaired runs on byte identical source audio.
A question for you: would you want the timestamped path in core?
Worth stating plainly: even with this fix, Chirp over OpenRouter returns text only.
Cue times are then inferred by splitting text proportionally to character count, which
cannot represent silence.
Important
In the OpenRouter output I measured, zero gaps longer than 2 seconds appear
anywhere, the largest gap in the entire file is 0.85 s, and 35 of 50 consecutive cue
boundaries touch at exactly 0.001 s, and the first Speech-to-Text v2 cue on the same
source does not arrive until 110.8 s because the opening theme is genuinely empty.
That is the signature of arithmetic subdivision
rather than of measured speech.
The same model, addressed directly through Google's Speech-to-Text v2 API, does return
word level timings. Measured on a 145 minute episode:
To be straight about the comparison: the OpenRouter figures are measured over the sample
I have from it rather than a full episode, so read them as the shape of the timing rather
than as a coverage result. That shape does not improve with length, because character
proportional timing cannot represent silence at any duration.
I would rather ask than assume: would you be open to that path living in Subtitle Edit
itself? It is a materially more accurate transcription engine, but it is not free, and
the cost sits mostly in onboarding rather than in code. Honestly, here is everything it
needs:
401, API keys are not supported by this API. Expected OAuth2 access token.Every existing online engine in SE uses a single API key textbox, so this needs a JSON file picker and a token exchange. This is the biggest change, and it is a product decision as much as a code one.us-speech.googleapis.com, with recognizer pathprojects/<id>/locations/us/recognizers/_. The global endpoint does not servechirp_3.Google.Cloud.Speech.V2pulls in gRPC and protobuf, which noticeably increases bundle size. A hand rolled REST client against the v2 API avoids that, at the cost of more code to maintain. Your call which you would prefer.Recognizemaps 1:1 onto the existingISttTranscriber.TranscribeAsync: inline audio, no bucket, word timings returned directly. It carries the same ~60 s cap as above, so it needs duration aware chunking, which this PR already builds.If that is more than you want to carry in core, particularly the service account auth and
the Cloud Storage dependency, then I completely understand, and my company will ship it as
a separate SE5 plugin instead, so the users who want it can opt in without everyone paying
for it. I just did not want to make that decision on your behalf.
Either way, this PR is only the OpenRouter fix, and it stands on its own.
One more small thing, whichever way you answer
There is a related snag I ran into.
RunPluginreturns early when the subtitle is empty:That is exactly the right guard for a plugin that transforms existing lines, which is
what plugins do today. But it makes a transcription plugin impossible to start from the
state users are actually in, which is a video open and no subtitle yet. As it stands they
would have to type a placeholder line first, then run the plugin, then delete it.
Would you accept a small opt in for this? Something like a manifest field
"allowsEmptySubtitle": true, which a plugin sets when it produces a subtitle ratherthan transforming one, leaving the current behavior untouched for every existing plugin.
Happy to send it as its own focused PR if you like the idea.