feat: list OpenRouter's transcription models, which its plain catalogue hides
Nightly Build / build (push) Successful in 7m33s

Adding a transcribe model on OpenRouter logged "provider 'OpenRouter' does
not support transcription model listing" and dropped the user into typing a
model id by hand — `list_transcribe_models` was never implemented for it, so
the trait default answered None.

OpenRouter does serve the catalogue: it is the same `/models` envelope under
`output_modalities=transcription`. The filter is not an optimisation — those
models carry `architecture.modality = "audio->transcription"` and are absent
from the unfiltered listing, so nothing else surfaces them. `fetch_openai_models`
therefore takes an optional raw query string; plain OpenAI has no filters, but
a gateway hosting several service kinds needs to say which catalogue it wants.

Transcription itself already worked: OpenRouter accepts the OpenAI-style
multipart body that `OpenAiAudioTranscriber` sends, so only the listing was
missing. The feed says nothing about per-model languages, hence the empty
`languages` — the hint stays the user's to set.
This commit is contained in:
2026-08-04 15:19:57 +01:00
parent e29dc40202
commit 88997ad256
3 changed files with 48 additions and 4 deletions
+10 -1
View File
@@ -48,13 +48,22 @@ pub(crate) fn extra_with_reasoning(
/// the `data` envelope so each provider can map and enrich them with its own /// the `data` envelope so each provider can map and enrich them with its own
/// heuristics. `api_key` is sent as a bearer token when present (local /// heuristics. `api_key` is sent as a bearer token when present (local
/// providers pass `None`); `who` is the display name used in error messages. /// providers pass `None`); `who` is the display name used in error messages.
///
/// `query` appends a raw query string (no leading `?`). Plain OpenAI has no
/// filters, but a gateway hosting several service kinds needs one to say which
/// catalogue it wants — OpenRouter's STT models are absent from the unfiltered
/// listing and only appear under `output_modalities=transcription`.
pub(crate) async fn fetch_openai_models( pub(crate) async fn fetch_openai_models(
http: &reqwest::Client, http: &reqwest::Client,
base_url: &str, base_url: &str,
api_key: Option<&str>, api_key: Option<&str>,
query: Option<&str>,
who: &str, who: &str,
) -> Result<Vec<serde_json::Value>> { ) -> Result<Vec<serde_json::Value>> {
let url = format!("{}/models", base_url.trim_end_matches('/')); let url = match query {
Some(q) => format!("{}/models?{q}", base_url.trim_end_matches('/')),
None => format!("{}/models", base_url.trim_end_matches('/')),
};
let mut req = http.get(&url); let mut req = http.get(&url);
if let Some(key) = api_key { if let Some(key) = api_key {
req = req.bearer_auth(key); req = req.bearer_auth(key);
@@ -6,7 +6,7 @@ use crate::image_generate::ImageGenerateModelRecord;
use crate::image_generate::openrouter_image::OpenRouterImageGenerator; use crate::image_generate::openrouter_image::OpenRouterImageGenerator;
use crate::llm::{LlmModelRecord, LlmProviderRecord}; use crate::llm::{LlmModelRecord, LlmProviderRecord};
use crate::llm::providers::{RemoteLlmModelInfo, build_openai_llm, fetch_openai_models}; use crate::llm::providers::{RemoteLlmModelInfo, build_openai_llm, fetch_openai_models};
use crate::transcribe::TranscribeModelRecord; use crate::transcribe::{RemoteTranscribeModelInfo, TranscribeModelRecord};
use crate::transcribe::openai_audio::OpenAiAudioTranscriber; use crate::transcribe::openai_audio::OpenAiAudioTranscriber;
use crate::tts::TtsModelRecord; use crate::tts::TtsModelRecord;
use crate::tts::openai_tts::OpenAiTtsSynthesiser; use crate::tts::openai_tts::OpenAiTtsSynthesiser;
@@ -47,7 +47,7 @@ impl OpenRouterProvider {
} }
async fn fetch_catalog(&self, api_key: &str) -> Result<Vec<RemoteLlmModelInfo>> { async fn fetch_catalog(&self, api_key: &str) -> Result<Vec<RemoteLlmModelInfo>> {
let raw = fetch_openai_models(&self.http, "https://openrouter.ai/api/v1", Some(api_key), "OpenRouter").await?; let raw = fetch_openai_models(&self.http, "https://openrouter.ai/api/v1", Some(api_key), None, "OpenRouter").await?;
let models = raw let models = raw
.iter() .iter()
@@ -97,6 +97,35 @@ impl OpenRouterProvider {
Ok(models) Ok(models)
} }
/// OpenRouter's STT catalogue is the same `/models` envelope, filtered by
/// output modality. The filter is not an optimisation: these models carry
/// `architecture.modality = "audio->transcription"` and are **absent** from
/// the unfiltered listing, so nothing but this query surfaces them.
///
/// The feed says nothing about which languages each model covers (every
/// entry here is multilingual or auto-detecting anyway), hence the empty
/// `languages` — the per-model hint stays the user's to set.
async fn fetch_transcribe_catalog(&self, api_key: &str) -> Result<Vec<RemoteTranscribeModelInfo>> {
let raw = fetch_openai_models(
&self.http, "https://openrouter.ai/api/v1", Some(api_key),
Some("output_modalities=transcription"), "OpenRouter",
).await?;
Ok(raw
.iter()
.filter_map(|m| {
let id = m["id"].as_str()?.to_string();
let name = m["name"].as_str().unwrap_or(&id).to_string();
Some(RemoteTranscribeModelInfo {
id,
name,
description: m["description"].as_str().map(String::from),
languages: Vec::new(),
})
})
.collect())
}
} }
#[async_trait::async_trait] #[async_trait::async_trait]
@@ -113,6 +142,12 @@ impl ApiProvider for OpenRouterProvider {
Ok(Some(self.fetch_catalog(api_key).await?)) Ok(Some(self.fetch_catalog(api_key).await?))
} }
async fn list_transcribe_models(&self, record: &LlmProviderRecord) -> Result<Option<Vec<RemoteTranscribeModelInfo>>> {
let api_key = record.api_key.as_deref()
.ok_or_else(|| anyhow!("provider '{}': api_key required for openrouter model listing", record.name))?;
Ok(Some(self.fetch_transcribe_catalog(api_key).await?))
}
fn reasoning_mode(&self, _model_id: &str, capabilities: &[String]) -> Option<ReasoningMode> { fn reasoning_mode(&self, _model_id: &str, capabilities: &[String]) -> Option<ReasoningMode> {
// Fallback for stored/manually-added models with no catalog descriptor. // Fallback for stored/manually-added models with no catalog descriptor.
// The precise per-model set comes from `parse_reasoning` in the catalog; // The precise per-model set comes from `parse_reasoning` in the catalog;
@@ -28,7 +28,7 @@ impl RequestyProvider {
} }
async fn fetch_catalog(&self, api_key: &str) -> Result<Vec<RemoteLlmModelInfo>> { async fn fetch_catalog(&self, api_key: &str) -> Result<Vec<RemoteLlmModelInfo>> {
let raw = fetch_openai_models(self.http(), BASE_URL, Some(api_key), "Requesty").await?; let raw = fetch_openai_models(self.http(), BASE_URL, Some(api_key), None, "Requesty").await?;
Ok(raw.iter().filter_map(map_model).collect()) Ok(raw.iter().filter_map(map_model).collect())
} }
} }