Skip to content

feat(transcription): transcribe with OpenRouter - #540

Open
chhoumann wants to merge 9 commits into
feat/transcription-providersfrom
feat/transcription-openrouter
Open

chhoumann wants to merge 9 commits into
feat/transcription-providersfrom
feat/transcription-openrouter

Conversation

@chhoumann

@chhoumann chhoumann commented Nov 14, 2026 •

Copy link
Copy Markdown
Owner

Summary

PodNotes is moving transcription to current models from several providers, so that a provider is a credential row, one provider file and a registry line, and no user runs a retired model. This PR is step 4 of that plan: OpenRouter. It stacks on #539.

  • Settings. Transcription provider offers OpenRouter between Deepgram and OpenAI. Choosing it shows the OpenRouter API key row and a Model row with Recommended (Grok STT 2.0), Grok STT 2.0, ElevenLabs Scribe v2 and MAI-Transcribe 2.
  • Requests. Each model gets the request measured to return word times and speakers. Grok STT 2.0 gets base64 JSON routed to xAI with provider.only, which takes diarize from provider.options.xai. A request routed elsewhere answers 200 with no speakers. Scribe v2 and MAI-Transcribe 2 get multipart with diarize beside the model. Scribe v2 gets the address of an episode a saved public feed lists, so nothing is downloaded. With speaker labels off, no request carries diarize.
  • Parts. OpenRouter takes 25 MB a request and gives the model 60 s, so an upload goes in parts of at most 18 MB and 1,800 s, one at a time. Labels from two parts name different speakers, and the notice says OpenRouter transcribed this episode in 3 parts, and speaker labels restart in each, so one person can have several labels. Merging labels across parts is the next PR.
  • Missing speakers. A note saved with speaker labels asked for and none returned says Saved without speaker labels, because Grok STT 2.0 returned none. This holds for every provider.
  • Model freshness. PodNotes reads OpenRouter's public catalog, without the key, when the Model row draws and when an OpenRouter job starts, and keeps it in memory for 24 hours. It only takes models away: an unlisted model is not offered, a model whose expiration_date has come is skipped, and a later end date reads Grok STT 2.0, ends <date>. A chosen model the catalog drops stays selected as <label>, no longer offered, and the job runs the recommended model with a notice. A failed read is not kept and leaves the tested list.
  • Errors. 402 says the account is out of credit, 401 that the key was rejected, 404 that the model is not offered. A 404 whose message names the account's data policy or zero data retention says the model is not offered under the account's OpenRouter privacy settings. Each ends the job at the first part, and none quotes OpenRouter.
  • Notices naming the providers that label speakers now end with OpenRouter.
  • Times in a later part are rounded to the millisecond, so their links read time=1400.112, not time=1400.1119999994578.

For review:

  • The registry owns the catalog. A provider exposes an optional catalog() beside failure(). The registry keeps the reads in a Svelte store, so the Model row redraws when a read lands while it is open, and tests reset it through the same handle.
  • The privacy 404 is its own failure kind, privacy, which ends the job like model-unavailable, because the user's action differs.
  • withdrawnModel sits beside unknownModel on the choice, because each says something different: this version has no such model, or the provider's catalog dropped it.

Not measured with a real key, for the lead's real-key check:

  • The privacy 404 is matched by /data policy|zero data retention|\bzdr\b/i on error.message. No probe saved the real body. The pattern follows OpenRouter's published "No endpoints found matching your data policy (Zero data retention)".
  • Scribe v2 by address sends format: "mp3", as measured on an MP3 episode. An M4A episode by address is unmeasured.
  • Scribe v2 and MAI-Transcribe 2 with speaker labels off send verbose_json without diarize. Whether they still return words is unmeasured.
  • An address Scribe v2 cannot fetch through OpenRouter ends as OpenRouter could not transcribe this audio. with no upload, because OpenRouter's answer for it is unknown.

Not in this PR: merged speaker labels across parts, the weekly model review, and the docs, which describe the released version until the release.

OpenRouter chosen in Settings Scribe v2 note from a public episode by address
Grok STT 2.0 note from a 70 minute MP3 in three parts, with the notice

Verification

Run on Linux with Node 22.23.4, on the final tree c46f215a:

  • npm run lint, npm run format:check, npm run typecheck, npm run build and npm run docs:build pass.
  • npm run test: 124 files, 2,878 tests pass.
  • npm run test:tooling: 8 files, 48 tests pass.
  • npm run test:e2e on Obsidian 1.14.4: 33 files, 257 tests pass, J-TR-09 and J-TR-10 new.
  • PODNOTES_OBSIDIAN_APP=/opt/Obsidian/obsidian npm run test:e2e -- settings-tab journey-resilience on Obsidian 1.13.7: 2 files, 23 tests pass.
  • npm run size: 106,031 gzip bytes, 131 under the new 106,162 budget. The base measures 104,706 in a separate build of 1eef3e6f. The budget rises by the 1,296 bytes OpenRouter measured at a844ca70, and the millisecond fix after it adds 29.
  • npm run plant: 27 planted defects, each caught by the unit test named below, and 2 more caught by npm run test:e2e.

Obsidian 1.14.4 on Linux under Xvfb, in this worktree's isolated vault, with the verify skill's fixture provider answering OpenRouter and its catalog. No real key was used. The fixture feed is http://podnotes.test:24280/feed.xml. The local file is Fixtures/Long Fixture.mp3, 4,200 s of sine at 8 kbps mono from ffmpeg, 4,200,452 bytes.

  • Settings rows. OpenRouter was chosen with the mouse and arrow keys. The row shows the key row and the four Model options above. data.json held provider: "openrouter" and model: "". The network log showed one GET https://openrouter.ai/api/v1/models with no upload. The warning beside the key is Obsidian's own Secrets are not encrypted, because Xvfb has no keyring.
  • Scribe v2, public episode. With ElevenLabs Scribe v2 chosen by keyboard, Transcribe current episode from audio on Fixture Episode 1 sent one POST https://openrouter.ai/api/v1/audio/transcriptions, application/json, 160 bytes, naming http://podnotes.test:24280/episode-1.wav. The fixture server saw no request during the command. The note has **1:** at 0 s and **2:** at 12 s.
  • Grok STT 2.0, long local MP3. On Recommended (Grok STT 2.0), after a plugin reload, the command sent the catalog GET, then three POSTs of 1,867,279, 1,867,027 and 1,866,931 bytes of JSON, one after the other. The note numbers six speakers at time=0, 12, 1400.112, 1412.112, 2800.224 and 2812.224, and the restart notice stayed until clicked. Before the millisecond fix the same run linked time=1400.1119999994578.

dev:errors reported no error. The instance and the fixture server are stopped. The lead verifies with a real key separately.

Planted defects, each caught by the named test through npm run plant
Planted defect In Caught by
Grok without provider.only providers/openrouter.ts Grok STT 2.0, the recommended model, gets even a public episode as base64 JSON, routed to xAI and asked there for speaker labels
Grok sent an address providers/openrouter.ts Grok STT 2.0, the recommended model, gets even a public episode as base64 JSON, routed to xAI and asked there for speaker labels
sounds kept as words providers/openrouter.ts Grok STT 2.0, the recommended model, gets even a public episode as base64 JSON, routed to xAI and asked there for speaker labels
Grok asked for speakers with labels off providers/openrouter.ts with speaker labels off, x-ai/grok-stt-2.0 is asked for none, and the note names no speaker
Scribe v2 and MAI asked for speakers with labels off providers/openrouter.ts with speaker labels off, microsoft/mai-transcribe-2 is asked for none, and the note names no speaker
Scribe v2 and MAI name diarize differently providers/openrouter.ts microsoft/mai-transcribe-2 gets an upload as multipart, asked for speaker labels beside the model
Scribe v2 always uploads providers/openrouter.ts Scribe v2 takes a public episode by its address in one request, and its audio is never loaded
part offset dropped cues.ts a 40 MB episode goes in parts of at most 18 MB and 1,800 s, one at a time, timed to the millisecond from the episode's start
parts of 24 MB providers/openrouter.ts a 40 MB episode goes in parts of at most 18 MB and 1,800 s, one at a time, timed to the millisecond from the episode's start
cue times not rounded cues.ts a 40 MB episode goes in parts of at most 18 MB and 1,800 s, one at a time, timed to the millisecond from the episode's start
labels reused across parts cues.ts labels from two parts never name the same speaker, and the notice says labels restart in each
no restart notice transcribe.ts labels from two parts never name the same speaker, and the notice says labels restart in each
no check for missing speakers transcribe.ts speaker labels asked for and none returned, as from a route that ignores the option, saves the note and says so
OpenRouter's text in the sentence http.ts a 402 is said in PodNotes' words, with none of OpenRouter's, and no other part is sent
privacy 404 read as any 404 providers/openrouter.ts a 404 for a model no route of which meets the account's privacy settings is said in PodNotes' words, with none of OpenRouter's, and no other part is sent
privacy failure does not end the job transcribe.ts a 404 for a model no route of which meets the account's privacy settings is said in PodNotes' words, with none of OpenRouter's, and no other part is sent
unlisted model still offered registry.ts a chosen model the catalog no longer lists runs the recommended one, and the notice says so
no catalog read at job start TranscriptionService.ts a chosen model the catalog no longer lists runs the recommended one, and the notice says so
end date ignored by the filter registry.ts a model whose end date has come is skipped, and the next one is recommended
failed read kept as an empty catalog registry.ts a catalog that cannot be read leaves every model offered, and the next job reads it again
catalog read on every job registry.ts the catalog is read again only once a day has passed
catalog kept for two days registry.ts the catalog is read again only once a day has passed
expiration_date not read providers/openrouter.ts the catalog read as the row draws leaves out a model it does not list and dates one that ends
Model row reads no catalog PodNotesSettingsTab.ts the catalog read as the row draws leaves out a model it does not list and dates one that ends
Model row ignores a read that lands PodNotesSettingsTab.ts the catalog read as the row draws leaves out a model it does not list and dates one that ends
dropped model not marked PodNotesSettingsTab.ts a chosen model the catalog no longer lists stays selected, marked as no longer offered
an unmeasured model offered providers/openrouter.ts choosing it shows its API key row and its three models, Grok STT 2.0 recommended
Scribe v2 always uploads providers/openrouter.ts J-TR-09 a public episode goes to Scribe v2 by its address in one request, and its audio is not fetched (npm run test:e2e)
Grok audio sent as a data URI providers/openrouter.ts J-TR-10 Grok STT 2.0 gets the episode's audio as base64 in one request (npm run test:e2e)

Real OpenRouter key (lead, after review)

Live probes first, then Obsidian 1.14.4, Linux, build of 23a4d86b, palette command, a pass-through request log (no fixture):

  • Grok STT 2.0 on the 54 min, 39 MB local E231, labels on: one catalog GET without the key, then 3 sequential JSON POSTs of 18.1, 17.0 and 16.9 MB. Done in 35 s; 9,585-word note with timestamps and labels; the restart notice stayed up.
  • Scribe v2 on a public NPR News Now episode (https://feeds.npr.org/500005/podcast.xml), labels on: one 520-byte POST naming the address, no download, saved in 5 s with timestamps and speakers. Labels off: one 505-byte POST; the note has timestamps and no speakers.
  • dev:errors: none.

Review fixes (lead)

  • Probes showed Scribe v2 and MAI-Transcribe 2 return verbose_json with no words when neither diarize nor word timestamps are asked for, so a labels-off job failed with "OpenRouter found no speech". Both now always send word timestamps (timestamp_granularities[]=word, or ["word"] in JSON). Plant: drop it (caught by with speaker labels off, elevenlabs/scribe-v2 is still asked for word times, which OpenRouter sends only when asked).
  • An address the provider cannot download is a 400 "Provider could not download input_audio.url; ensure it is publicly reachable and serves a supported audio file" (captured live). It now maps to unfetched, so the job uploads once. Plant: return no failure kind (caught by Scribe v2 uploads the episode once when OpenRouter's provider cannot download its address).
  • The privacy matcher (/data policy|zero data retention|\bzdr\b/i) matches the real ZDR body captured earlier: "0 endpoints out of 1 requested are available matching your guardrail restrictions and data policy ... ZDR violation (account settings)".
  • Checks after the fixes: lint, format, typecheck, build, size (106,065 of 106,162), 2,881 unit tests.

Release impact

  • A new root field openRouterSecretId, empty by default. The key lives in SecretStorage as podnotes-openrouter-api-key. A settings export carries it as secrets.openRouter when keys are included. The schema stays at version 2. feat(transcription): choose a provider and model, with ElevenLabs as the default #539 measured that 2.24.0 keeps elevenLabsSecretId, an unknown root field to it. This field takes the same path, which I inferred and did not run.
  • transcript.provider can now be openrouter. A version without OpenRouter keeps the value and refuses to transcribe with a notice, as feat(transcription): choose a provider and model, with ElevenLabs as the default #539 does for any unknown provider.
  • With OpenRouter chosen, PodNotes sends a public GET to openrouter.ai/api/v1/models?output_modalities=transcription when the Model row draws or a job starts. After a read that works it waits a day. After one that fails it asks again next time.
  • The gzip budget rises from 104,866 to 106,162 bytes.

Note

Add OpenRouter as a transcription provider

  • Adds an OpenRouter provider in openrouter.ts with three models (Grok STT 2.0, ElevenLabs Scribe v2, MAI-Transcribe 2), a 10-minute request timeout, and support for URL or multipart/base64 audio depending on the model.
  • Adds a model-catalog system in registry.ts: catalogs are cached for one day, model lists are filtered by catalog entries, and expired or withdrawn models are flagged or replaced with a warning in transcribe.ts.
  • Adds OpenRouter credential storage (openRouterSecretId in DEFAULT_SETTINGS, CREDENTIALS mapping) and settings UI support with catalog-driven model dropdown, expiration dates, and a "no longer offered" label in PodNotesSettingsTab.ts.
  • Adds a new privacy failure kind with a user-facing message for OpenRouter 404 privacy errors, and millisecond rounding of transcript cue timestamps in cues.ts.
  • Behavioral Change: a stored model absent from the catalog now falls back to the first offered model with a warning instead of being treated as unknown; gzip size budget raised from 104,866 to 106,162 bytes in size.mjs.

Macroscope summarized 23a4d86.


Note

Medium Risk
Introduces a new paid external API path and credential storage alongside transcription request shaping and catalog caching; risk is moderated by extensive unit and e2e coverage.

Overview
Adds OpenRouter as a fourth transcription provider (between Deepgram and OpenAI), with a new openRouterSecretId credential field wired through settings, persistence, and export/import like the existing provider keys.

The provider supports Grok STT 2.0 (base64 JSON routed to xAI), ElevenLabs Scribe v2 (public episode URL when possible), and MAI-Transcribe 2, including chunked uploads (18 MB / 1,800 s), speaker-label handling, and user-facing error mapping (including a new privacy failure kind for data-policy 404s). A daily-cached public model catalog filters expired or withdrawn models; settings and jobs refresh it via refreshModels, and the Model dropdown shows end dates and “no longer offered” for stored models that fell off the catalog.

Cross-cutting tweaks include millisecond-rounded cue times for multi-part transcripts, extra warnings (withdrawn model fallback, missing speakers, speaker restart across parts), verify/e2e fixture stubs for OpenRouter, and a gzip bundle budget bump from 104,866 to 106,162 bytes.

Reviewed by Cursor Bugbot for commit 23a4d86. Bugbot is set up for automated code reviews on this repo. Configure here.

… one

The tests for a provider and a model a newer PodNotes stored used OpenRouter
and Grok STT 2.0, which this version is about to run. They now use AssemblyAI
and universal-3-6-pro.
OpenRouter is a credential row, one provider file and a registry line. Its
key reference is the new root field openRouterSecretId, stored in
SecretStorage as podnotes-openrouter-api-key, and a settings export carries
the key as secrets.openRouter.

It offers three models, each with the request measured to give word times
and speakers:

- Grok STT 2.0, recommended: base64 JSON with verbose_json and word and
  segment times, routed to xAI with provider.only, which takes diarize from
  provider.options.xai. A request routed elsewhere is answered with no
  speakers.
- ElevenLabs Scribe v2: multipart with diarize beside the model, or JSON with
  input_audio.url for an episode a saved public feed lists.
- MAI-Transcribe 2: multipart with diarize beside the model.

With speaker labels off, no request carries diarize. Sounds Scribe v2 lists
as audio_event items are left out.

OpenRouter takes 25 MB a request and gives the model 60 s, so an upload goes
in parts of at most 18 MB and 1,800 s. Parts of 24 MB were answered in 20 to
31 s.

A 402 says the account is out of credit, a 401 that the key was rejected,
and a 404 that the model is not offered. A 404 whose message names the
account's data policy or zero data retention says the model is not offered
under the account's OpenRouter privacy settings. Each ends the job at the
first part, and none quotes OpenRouter.
…t each part

A note saved with speaker labels asked for and none returned now says so:
"Saved without speaker labels, because Grok STT 2.0 returned none." OpenRouter
answers 200 with no speakers when a request reaches a route that ignores the
speaker option, so this is checked for every provider.

Labels from two parts already name different speakers. An episode sent in
parts now also says that its labels restart in each part, so one person can
have several labels, until labels are merged across parts.
…ll lists

PodNotes reads OpenRouter's public catalog of transcription models, without
the key, when the Model row draws and when an OpenRouter job starts. It keeps
the read in memory for 24 hours. A read that fails is not kept, so the next
one asks again, and the tested list is offered as it is.

The catalog only takes models away. A model it does not list is not offered,
and one whose expiration_date has come is skipped. A model with a later end
date reads "Grok STT 2.0, ends <date>" in the Model row. A catalog that would
leave no model is not followed.

A chosen model the catalog no longer lists stays selected in the Model row as
"<label>, no longer offered". A job runs the first offered model instead and
says "OpenRouter no longer offers ElevenLabs Scribe v2, so PodNotes used Grok
STT 2.0." The row redraws when a read lands while it is open.

A provider gets its catalog through an optional catalog() beside failure(),
so the registry owns the cache and the filter for any provider that has one.
A stub answers OpenRouter's transcriptions with two speakers, the second 12 s
in, and its model catalog with the three models PodNotes offers. It records
what each request named and a hash of the audio it carried, decoded from
base64 for JSON.

J-TR-09 plays a public episode on Scribe v2: one JSON request names the
episode's address, and the episode's audio is never fetched. J-TR-10 plays it
on the recommended Grok STT 2.0: one JSON request carries the audio as base64,
the same bytes the fixture server holds. Both notes have timestamps and
numbered speakers, and the catalog is read once, with no Authorization.

A journey seeds any provider by its credential row, and a model with it.
…eads them

The fixture provider answers openrouter.ai transcriptions with verbose_json
words from two speakers, the second 12 s in, and the model catalog with the
three models PodNotes offers. The log keeps openrouter.ai paths and reads the
address a JSON request names in input_audio.url.

The transcript guide adds the OpenRouter rows, its runs (Scribe v2 by
address, Grok by base64, a long MP3 in parts) and the notices that now name
OpenRouter.
…ures

The branch this builds on leaves main.js at 104,706 gzip bytes. The OpenRouter
provider, its model catalog filter and the speaker label warnings add 1,296,
which takes it to 106,002 and over the 104,866 budget. The budget rises by
those 1,296 bytes to 106,162, so the 160 bytes of headroom stay as they were.
A part's start is a sum of MP3 frame lengths, so a word's time in the episode
carried float noise into its link, as in time=1400.1119999994578 for a word
23:20 into a 70 minute episode Grok got in three parts. Cue times are now
rounded to the millisecond, as finely as providers time words.
…cannot fetch

Scribe v2 and MAI-Transcribe 2 list words only when asked for speakers or
word times, so a request with speaker labels off failed with "found no
speech". They now always ask for word timestamps.

OpenRouter answers an address its provider cannot download with a 400
"Provider could not download input_audio.url"; that now falls back to one
upload instead of failing the job.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Nov 14, 2026 •

Copy link
Copy Markdown

Deploying podnotes with  Cloudflare Pages  Cloudflare Pages

Latest commit: 23a4d86
Status: ✅  Deploy successful!
Preview URL: https://c7b0f154.podnotes.pages.dev
Branch Preview URL: https://feat-transcription-openroute.podnotes.pages.dev

View logs

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant