diff --git a/crates/skald-core/src/llm/providers/mod.rs b/crates/skald-core/src/llm/providers/mod.rs index 0be1b2b..d429ec8 100644 --- a/crates/skald-core/src/llm/providers/mod.rs +++ b/crates/skald-core/src/llm/providers/mod.rs @@ -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> { - 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); diff --git a/crates/skald-core/src/llm/providers/openrouter.rs b/crates/skald-core/src/llm/providers/openrouter.rs index a3aae5e..6f1eb93 100644 --- a/crates/skald-core/src/llm/providers/openrouter.rs +++ b/crates/skald-core/src/llm/providers/openrouter.rs @@ -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> { - 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> { + 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>> { + 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 { // Fallback for stored/manually-added models with no catalog descriptor. // The precise per-model set comes from `parse_reasoning` in the catalog; diff --git a/crates/skald-core/src/llm/providers/requesty.rs b/crates/skald-core/src/llm/providers/requesty.rs index 601d06f..cdb6e7f 100644 --- a/crates/skald-core/src/llm/providers/requesty.rs +++ b/crates/skald-core/src/llm/providers/requesty.rs @@ -28,7 +28,7 @@ impl RequestyProvider { } async fn fetch_catalog(&self, api_key: &str) -> Result> { - 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()) } }