feat: list OpenRouter's transcription models, which its plain catalogue hides
Nightly Build / build (push) Successful in 7m33s
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:
@@ -48,13 +48,22 @@ pub(crate) fn extra_with_reasoning(
|
||||
/// 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
|
||||
/// 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(
|
||||
http: &reqwest::Client,
|
||||
base_url: &str,
|
||||
api_key: Option<&str>,
|
||||
query: Option<&str>,
|
||||
who: &str,
|
||||
) -> 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);
|
||||
if let Some(key) = api_key {
|
||||
req = req.bearer_auth(key);
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::image_generate::ImageGenerateModelRecord;
|
||||
use crate::image_generate::openrouter_image::OpenRouterImageGenerator;
|
||||
use crate::llm::{LlmModelRecord, LlmProviderRecord};
|
||||
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::tts::TtsModelRecord;
|
||||
use crate::tts::openai_tts::OpenAiTtsSynthesiser;
|
||||
@@ -47,7 +47,7 @@ impl OpenRouterProvider {
|
||||
}
|
||||
|
||||
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
|
||||
.iter()
|
||||
@@ -97,6 +97,35 @@ impl OpenRouterProvider {
|
||||
|
||||
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]
|
||||
@@ -113,6 +142,12 @@ impl ApiProvider for OpenRouterProvider {
|
||||
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> {
|
||||
// Fallback for stored/manually-added models with no catalog descriptor.
|
||||
// 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>> {
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user