diff --git a/README.md b/README.md index 3cba9023..b1e839a0 100644 --- a/README.md +++ b/README.md @@ -198,9 +198,9 @@ Every item below is one more layer sedimented into a default — a capability yo - 🎨 **Style Pack Marketplace** — browse, install, and like community **style packs** from the in-app Marketplace, and publish your own (custom system prompt per pack, switchable by hotkey). Backed by a moderated marketplace backend; uploads are reviewed before they go public. - ⚡ **Streaming insertion** — polished text is written to the cursor character by character to reduce perceived latency, with an automatic one-shot-paste fallback. Toggle in Settings → Recording. - **Toggle and push-to-talk** recording modes, plus a **MediaPlayPause trigger** so wired-earbud inline controls can start and stop recording. `Esc` cancels at any phase, including polish and insertion. -- **Cloud ASR**: Volcengine streaming ASR (bigasr), iFlytek realtime ASR (RTASR), Alibaba Cloud Bailian (classic realtime / Qwen3 realtime / Fun-ASR-Flash file transcription), StepFun StepAudio (batch + realtime), Zhipu GLM-ASR, Xiaomi MiMo ASR, ElevenLabs Scribe, OpenAI-compatible batch transcription (OpenAI Whisper / Groq / SiliconFlow SenseVoice / OpenRouter / ZenMux), and Apple Speech (macOS). +- **Cloud ASR**: Volcengine streaming ASR (bigasr), iFlytek realtime ASR (RTASR), Alibaba Cloud Bailian (classic realtime / Qwen3 realtime / Fun-ASR-Flash file transcription), StepFun StepAudio (batch + realtime), Zhipu GLM-ASR, Xiaomi MiMo ASR, OrcaRouter audio-input Gemini, ElevenLabs Scribe, OpenAI-compatible batch transcription (OpenAI Whisper / Groq / SiliconFlow SenseVoice / OpenRouter / ZenMux), and Apple Speech (macOS). - **Local ASR**: bundled Qwen3-ASR (0.6B / 1.7B) via vendored `Open-Less/qwen-asr` (macOS); Windows Foundry Local Whisper and sherpa-onnx (experimental) variants. -- **Polish providers**: Ark (Volcengine), DeepSeek, OpenAI, Google Gemini, Codex OAuth, SiliconFlow, Atlas Cloud, Xiaomi MiMo, CometAPI, OpenRouter, Alibaba Cloud Coding Plan, CodingPlanX, MiniMax, StepFun, and OpenCode Zen — plus any OpenAI-compatible endpoint you bring. +- **Polish providers**: Ark (Volcengine), DeepSeek, OpenAI, Google Gemini, Codex OAuth, SiliconFlow, Atlas Cloud, Xiaomi MiMo, CometAPI, OpenRouter, OrcaRouter, Alibaba Cloud Coding Plan, CodingPlanX, MiniMax, StepFun, and OpenCode Zen — plus any OpenAI-compatible endpoint you bring. - **Four output modes**: raw, light polish, structured (**AI-prompt mode**), and formal. Plus a **translation hotkey** that converts speech directly into the configured target language ([#43](../../issues/43)). - **Selection-ask QA panel** — a separate hotkey opens a floating panel that runs voice Q&A against the highlighted text in any app ([#118](../../issues/118)). - **Main window**: Overview / History / Vocab / Style / Marketplace / Settings. Persistent tray icon, plus a mini status capsule that floats on screen and follows the display you are typing on (multi-monitor). diff --git a/README.zh.md b/README.zh.md index 58d7f865..dfd17954 100644 --- a/README.zh.md +++ b/README.zh.md @@ -203,9 +203,9 @@ OpenLess 只做一件事:**把语音变成可用的书面文字(尤其是 AI 提 - 🎨 **风格包市场**——在应用内的 Marketplace 浏览、安装、点赞社区**风格包**,并发布自己的(每个包一套自定义系统提示词,可用快捷键切换)。由经过审核的市场后端支撑;上传内容公开前会经过审核。 - ⚡ **流式插入**——润色后的文本逐字符写入光标以降低感知延迟,并带有自动的一次性粘贴回退。可在 设置 → 录音 中切换。 - **切换式与按住说话(push-to-talk)** 两种录音模式,外加 **MediaPlayPause 触发**,让有线耳机的线控也能开始 / 停止录音。`Esc` 可在任意阶段取消,包括润色与插入。 -- **云端 ASR**:Volcengine 流式 ASR(bigasr)、讯飞实时语音转写(RTASR)、阿里云百炼(经典实时 / Qwen3 实时 / Fun-ASR-Flash 录音文件)、阶跃星辰 StepAudio(批式 + 实时)、智谱 GLM-ASR、小米 MiMo ASR、ElevenLabs Scribe、OpenAI 兼容批量转写(OpenAI Whisper / Groq / 硅基流动 SenseVoice / OpenRouter / ZenMux),以及 Apple Speech(macOS)。 +- **云端 ASR**:Volcengine 流式 ASR(bigasr)、讯飞实时语音转写(RTASR)、阿里云百炼(经典实时 / Qwen3 实时 / Fun-ASR-Flash 录音文件)、阶跃星辰 StepAudio(批式 + 实时)、智谱 GLM-ASR、小米 MiMo ASR、OrcaRouter 音频输入 Gemini、ElevenLabs Scribe、OpenAI 兼容批量转写(OpenAI Whisper / Groq / 硅基流动 SenseVoice / OpenRouter / ZenMux),以及 Apple Speech(macOS)。 - **本地 ASR**:通过 vendored 的 `Open-Less/qwen-asr` 内置 Qwen3-ASR(0.6B / 1.7B)(macOS);Windows 上的 Foundry Local Whisper 与 sherpa-onnx(实验性)变体。 -- **润色提供方**:Ark(火山方舟)、DeepSeek、OpenAI、Google Gemini、Codex OAuth、硅基流动、Atlas Cloud、小米 MiMo、CometAPI、OpenRouter、阿里云 Coding Plan、CodingPlanX、MiniMax、StepFun、OpenCode Zen,以及你自带的任意 OpenAI 兼容端点。 +- **润色提供方**:Ark(火山方舟)、DeepSeek、OpenAI、Google Gemini、Codex OAuth、硅基流动、Atlas Cloud、小米 MiMo、CometAPI、OpenRouter、OrcaRouter、阿里云 Coding Plan、CodingPlanX、MiniMax、StepFun、OpenCode Zen,以及你自带的任意 OpenAI 兼容端点。 - **四种输出模式**:原文、轻度润色、结构化(**AI 提示词模式**)、正式。另有一个**翻译快捷键**,将语音直接转换为所配置的目标语言([#43](../../issues/43))。 - **选区问答面板**——一个独立快捷键打开浮动面板,针对任意应用中被高亮选中的文本进行语音问答([#118](../../issues/118))。 - **主窗口**:概览 / 历史 / 词典 / 风格 / 市场 / 设置。常驻托盘图标,以及一个浮于屏幕、并跟随你正在输入的显示器的迷你状态胶囊(多显示器)。 diff --git a/openless-all/app/crates/openless-core/src/asr/mimo.rs b/openless-all/app/crates/openless-core/src/asr/mimo.rs index c92b0ff5..1eed4762 100644 --- a/openless-all/app/crates/openless-core/src/asr/mimo.rs +++ b/openless-all/app/crates/openless-core/src/asr/mimo.rs @@ -20,11 +20,19 @@ const MIMO_MAX_CHUNK_DURATION_MS: u64 = 180_000; pub const PROVIDER_ID: &str = "xiaomi-mimo-asr"; pub const DEFAULT_ENDPOINT: &str = "https://api.xiaomimimo.com/v1"; pub const DEFAULT_MODEL: &str = "mimo-v2.5-asr"; +pub const ORCAROUTER_PROVIDER_ID: &str = "orcarouter"; +pub const ORCAROUTER_DEFAULT_ENDPOINT: &str = "https://api.orcarouter.ai/v1"; +pub const ORCAROUTER_DEFAULT_MODEL: &str = "google/gemini-2.5-flash"; +const ORCAROUTER_TRANSCRIPTION_PROMPT: &str = + "Transcribe this audio verbatim. Return only the transcript, without commentary."; pub struct MimoBatchASR { api_key: String, base_url: String, model: String, + provider_name: &'static str, + prompt: Option<&'static str>, + audio_data_url: bool, buffer: Mutex>, } @@ -34,6 +42,21 @@ impl MimoBatchASR { api_key, base_url, model, + provider_name: "MiMo", + prompt: None, + audio_data_url: true, + buffer: Mutex::new(Vec::new()), + } + } + + pub fn new_orcarouter(api_key: String, base_url: String, model: String) -> Self { + Self { + api_key, + base_url, + model, + provider_name: "OrcaRouter", + prompt: Some(ORCAROUTER_TRANSCRIPTION_PROMPT), + audio_data_url: false, buffer: Mutex::new(Vec::new()), } } @@ -56,7 +79,7 @@ impl MimoBatchASR { async fn transcribe_inner(&self, pcm: &[u8]) -> Result { if self.api_key.trim().is_empty() { - anyhow::bail!("MiMo API key missing"); + anyhow::bail!("{} API key missing", self.provider_name); } let duration_ms = pcm_duration_ms(pcm); @@ -80,7 +103,7 @@ impl MimoBatchASR { .map(|chunk| i16::from_le_bytes([chunk[0], chunk[1]])) .collect(); let wav = encode_wav_16k_mono(&samples); - let body = mimo_chat_body(&self.model, &wav); + let body = audio_input_chat_body(&self.model, &wav, self.prompt, self.audio_data_url); let url = mimo_chat_completions_url(&self.base_url)?; let client = crate::net::http(); let resp = client @@ -89,15 +112,18 @@ impl MimoBatchASR { .json(&body) .send() .await - .context("MiMo ASR HTTP request failed")?; + .with_context(|| format!("{} ASR HTTP request failed", self.provider_name))?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); - anyhow::bail!("MiMo ASR API error {}: {}", status, body); + anyhow::bail!("{} ASR API error {}: {}", self.provider_name, status, body); } - let json: Value = resp.json().await.context("parse MiMo ASR response")?; + let json: Value = resp + .json() + .await + .with_context(|| format!("parse {} ASR response", self.provider_name))?; Ok(extract_mimo_text(&json).trim().to_string()) } @@ -128,22 +154,42 @@ pub fn mimo_chat_completions_url(base_url: &str) -> Result { } pub fn mimo_chat_body(model: &str, wav: &[u8]) -> Value { - let audio_data = format!( - "data:audio/wav;base64,{}", - base64::engine::general_purpose::STANDARD.encode(wav) - ); + audio_input_chat_body(model, wav, None, true) +} + +pub fn orcarouter_chat_body(model: &str, wav: &[u8]) -> Value { + audio_input_chat_body(model, wav, Some(ORCAROUTER_TRANSCRIPTION_PROMPT), false) +} + +fn audio_input_chat_body( + model: &str, + wav: &[u8], + prompt: Option<&str>, + audio_data_url: bool, +) -> Value { + let encoded = base64::engine::general_purpose::STANDARD.encode(wav); + let audio_data = if audio_data_url { + format!("data:audio/wav;base64,{encoded}") + } else { + encoded + }; + let mut content = Vec::with_capacity(if prompt.is_some() { 2 } else { 1 }); + if let Some(text) = prompt { + content.push(serde_json::json!({ "type": "text", "text": text })); + } + content.push(serde_json::json!({ + "type": "input_audio", + "input_audio": { + "data": audio_data, + "format": "wav", + }, + })); serde_json::json!({ "model": model, "stream": false, "messages": [{ "role": "user", - "content": [{ - "type": "input_audio", - "input_audio": { - "data": audio_data, - "format": "wav", - }, - }], + "content": content, }], }) } @@ -298,6 +344,21 @@ mod tests { .starts_with("data:audio/wav;base64,")); } + #[test] + fn orcarouter_body_uses_prompt_and_raw_base64_audio() { + let body = orcarouter_chat_body(ORCAROUTER_DEFAULT_MODEL, b"wav"); + assert_eq!(body["model"], ORCAROUTER_DEFAULT_MODEL); + assert_eq!(body["messages"][0]["content"][0]["type"], "text"); + assert_eq!( + body["messages"][0]["content"][0]["text"], + ORCAROUTER_TRANSCRIPTION_PROMPT + ); + let audio = &body["messages"][0]["content"][1]; + assert_eq!(audio["type"], "input_audio"); + assert_eq!(audio["input_audio"]["format"], "wav"); + assert_eq!(audio["input_audio"]["data"], "d2F2"); + } + #[test] fn mimo_response_extracts_message_content() { let json = serde_json::json!({ diff --git a/openless-all/app/crates/openless-core/src/cloud_providers.rs b/openless-all/app/crates/openless-core/src/cloud_providers.rs index 9c037173..f79ccaea 100644 --- a/openless-all/app/crates/openless-core/src/cloud_providers.rs +++ b/openless-all/app/crates/openless-core/src/cloud_providers.rs @@ -55,6 +55,7 @@ pub const SHARED_CLOUD_ASR_PROVIDER_TYPES: &[&str] = &[ "groq", "whisper", "openrouter", + "orcarouter", "zenmux", "openai-compatible", "xiaomi-mimo-asr", @@ -72,6 +73,7 @@ pub const SHARED_CLOUD_LLM_PROVIDER_TYPES: &[&str] = &[ "mimo", "cometapi", "openrouterFree", + "orcarouter", "alibabaCoding", "codingPlanX", "minimax", @@ -437,14 +439,16 @@ async fn build_cloud_transcription_session( ActiveAsrProviderKind::Mimo => { require_configured(&api_key, "ASR API key")?; let effective_model = non_blank_owned(model) - .unwrap_or_else(|| crate::asr::mimo::DEFAULT_MODEL.to_string()); + .unwrap_or_else(|| default_asr_model(provider_type).unwrap().to_string()); + let endpoint = non_blank_owned(endpoint) + .unwrap_or_else(|| default_asr_endpoint(provider_type).unwrap().to_string()); + let provider = if provider_type == crate::asr::mimo::ORCAROUTER_PROVIDER_ID { + MimoBatchASR::new_orcarouter(api_key, endpoint, effective_model.clone()) + } else { + MimoBatchASR::new(api_key, endpoint, effective_model.clone()) + }; ( - CloudTranscriptionSessionKind::Mimo(Arc::new(MimoBatchASR::new( - api_key, - non_blank_owned(endpoint) - .unwrap_or_else(|| crate::asr::mimo::DEFAULT_ENDPOINT.to_string()), - effective_model.clone(), - ))), + CloudTranscriptionSessionKind::Mimo(Arc::new(provider)), Some(effective_model), ) } diff --git a/openless-all/app/crates/openless-core/src/polish.rs b/openless-all/app/crates/openless-core/src/polish.rs index cef5cf31..f37c9a98 100644 --- a/openless-all/app/crates/openless-core/src/polish.rs +++ b/openless-all/app/crates/openless-core/src/polish.rs @@ -183,6 +183,7 @@ fn is_builtin_llm_provider(provider_id: &str) -> bool { | "mimo" | "cometapi" | "openrouterFree" + | "orcarouter" | "alibabaCoding" | "codingPlanX" | "minimax" @@ -1878,7 +1879,9 @@ pub(crate) fn openai_compatible_thinking_control(provider_id: &str) -> Option Some(ThinkingControl::EnableThinking), // StepFun step-3.x-flash 系列按官方文档接受 reasoning_effort(low/medium/high, // 无法完全关闭思考);非推理模型(如 step-1o-turbo-vision)会忽略该字段。 - "openai" | "codingPlanX" | "stepfun" => Some(ThinkingControl::ReasoningEffort), + "openai" | "orcarouter" | "codingPlanX" | "stepfun" => { + Some(ThinkingControl::ReasoningEffort) + } // custom / 其他未声明 provider 走 base_url 兜底识别——用户用自定义 // endpoint 接入 MiniMax 时,根据 base_url 命中即下发官方 thinking 参数。 _ => None, @@ -3088,6 +3091,26 @@ mod tests { assert_eq!(body["reasoning_effort"], "medium"); } + #[test] + fn orcarouter_chat_body_maps_thinking_toggle_to_reasoning_effort() { + for (enabled, expected) in [(false, "low"), (true, "medium")] { + let provider = OpenAICompatibleLLMProvider::new( + OpenAICompatibleConfig::new( + "orcarouter", + "OrcaRouter", + "https://api.orcarouter.ai/v1", + "k", + "google/gemini-2.5-flash", + ) + .with_thinking_enabled(enabled), + ); + + let body = provider.chat_body(false, vec![json!({ "role": "user", "content": "hi" })]); + + assert_eq!(body["reasoning_effort"], expected); + } + } + #[test] fn chat_body_omits_temperature_for_unconfigured_custom_provider() { let provider = OpenAICompatibleLLMProvider::new(OpenAICompatibleConfig::new( diff --git a/openless-all/app/crates/openless-core/src/provider_rules.rs b/openless-all/app/crates/openless-core/src/provider_rules.rs index 9ca25a0a..4b460aca 100644 --- a/openless-all/app/crates/openless-core/src/provider_rules.rs +++ b/openless-all/app/crates/openless-core/src/provider_rules.rs @@ -34,6 +34,7 @@ const ASR_PROVIDER_TYPES: &[(&str, &str)] = &[ ("groq", "asrGroq"), ("whisper", "asrWhisper"), ("openrouter", "asrOpenrouter"), + ("orcarouter", "orcarouter"), ("zenmux", "asrZenmux"), (OPENAI_COMPATIBLE_ASR_PROVIDER_ID, "asrOpenAiCompatible"), ("xiaomi-mimo-asr", "asrXiaomiMimo"), @@ -58,6 +59,7 @@ const LLM_PROVIDER_TYPES: &[(&str, &str)] = &[ ("mimo", "mimo"), ("cometapi", "cometapi"), ("openrouterFree", "openrouterFree"), + ("orcarouter", "orcarouter"), ("alibabaCoding", "alibabaCoding"), ("codingPlanX", "codingPlanX"), ("minimax", "minimax"), @@ -456,6 +458,7 @@ pub fn default_asr_endpoint(provider_type: &str) -> Option<&'static str> { "groq" => Some("https://api.groq.com/openai/v1"), "whisper" => Some("https://api.openai.com/v1"), "openrouter" => Some("https://openrouter.ai/api/v1"), + "orcarouter" => Some(crate::asr::mimo::ORCAROUTER_DEFAULT_ENDPOINT), "zenmux" => Some("https://zenmux.ai/api/v1"), "xiaomi-mimo-asr" => Some("https://api.xiaomimimo.com/v1"), _ => None, @@ -474,6 +477,7 @@ pub fn default_asr_model(provider_type: &str) -> Option<&'static str> { "groq" => Some("whisper-large-v3-turbo"), "whisper" => Some("whisper-1"), "openrouter" => Some("openai/whisper-large-v3-turbo"), + "orcarouter" => Some(crate::asr::mimo::ORCAROUTER_DEFAULT_MODEL), "zenmux" => Some(crate::asr::whisper::ZENMUX_DEFAULT_MODEL), "xiaomi-mimo-asr" => Some(crate::asr::mimo::DEFAULT_MODEL), _ => None, @@ -492,6 +496,7 @@ pub fn default_llm_endpoint(provider_type: &str) -> Option<&'static str> { "mimo" => Some("https://api.xiaomimimo.com/v1"), "cometapi" => Some("https://api.cometapi.com/v1"), "openrouterFree" => Some("https://openrouter.ai/api/v1"), + "orcarouter" => Some(crate::asr::mimo::ORCAROUTER_DEFAULT_ENDPOINT), "alibabaCoding" => Some("https://coding-intl.dashscope.aliyuncs.com/v1"), "codingPlanX" => Some("https://api.codingplanx.ai/v1"), "minimax" => Some("https://api.minimaxi.com/v1"), @@ -511,6 +516,7 @@ pub fn default_llm_model(provider_type: &str) -> Option<&'static str> { crate::polish::CODEX_OAUTH_PROVIDER_ID => Some(crate::polish::CODEX_DEFAULT_MODEL), "mimo" => Some("xiaomi/mimo-v2-flash"), "openrouterFree" => Some("qwen/qwen3-coder:free"), + "orcarouter" => Some("orcarouter/fusion-flash"), "alibabaCoding" => Some("qwen3-coder-plus"), "codingPlanX" => Some("gpt-5-mini"), "minimax" => Some("MiniMax-M3"), @@ -566,7 +572,7 @@ pub fn active_asr_provider_kind(id: &str) -> ActiveAsrProviderKind { BAILIAN_PROVIDER_ID => ActiveAsrProviderKind::Bailian, QWEN3_REALTIME_PROVIDER_ID => ActiveAsrProviderKind::Qwen3Realtime, STEPFUN_REALTIME_PROVIDER_ID => ActiveAsrProviderKind::StepfunRealtime, - MIMO_PROVIDER_ID => ActiveAsrProviderKind::Mimo, + MIMO_PROVIDER_ID | crate::asr::mimo::ORCAROUTER_PROVIDER_ID => ActiveAsrProviderKind::Mimo, DASHSCOPE_MULTIMODAL_PROVIDER_ID => ActiveAsrProviderKind::DashScopeMultimodal, ELEVENLABS_PROVIDER_ID => ActiveAsrProviderKind::ElevenLabs, XFYUN_PROVIDER_ID => ActiveAsrProviderKind::Xfyun, @@ -588,7 +594,7 @@ pub fn is_stepfun_realtime_provider(id: &str) -> bool { } pub fn is_mimo_provider(id: &str) -> bool { - id == MIMO_PROVIDER_ID + matches!(id, MIMO_PROVIDER_ID | crate::asr::mimo::ORCAROUTER_PROVIDER_ID) } pub fn is_dashscope_multimodal_provider(id: &str) -> bool { diff --git a/openless-all/app/crates/openless-core/src/provider_service.rs b/openless-all/app/crates/openless-core/src/provider_service.rs index a4d1c2d4..a3bbd298 100644 --- a/openless-all/app/crates/openless-core/src/provider_service.rs +++ b/openless-all/app/crates/openless-core/src/provider_service.rs @@ -642,11 +642,25 @@ async fn fetch_models( .endpoint .as_deref() .filter(|value| !value.trim().is_empty()) - .or_else(|| default_llm_endpoint(&resolved.provider_type)) - .or_else(|| default_omni_endpoint(&resolved.provider_type)) + .or_else(|| match resolved.kind { + ProviderKind::Asr => default_asr_endpoint(&resolved.provider_type), + ProviderKind::Llm => default_llm_endpoint(&resolved.provider_type), + ProviderKind::Omni => default_omni_endpoint(&resolved.provider_type), + }) .ok_or_else(|| provider_error("provider endpoint is not configured"))?; let url = models_url(endpoint)?; let is_gemini = resolved.provider_type == "gemini"; + let orcarouter_filter = (resolved.provider_type == "orcarouter").then(|| { + let endpoint_type = match (resolved.kind, resolved.protocol.format) { + (ProviderKind::Llm, LlmRequestFormat::Responses) => "openai-response", + (ProviderKind::Llm, LlmRequestFormat::Messages) => "anthropic", + _ => "openai", + }; + OrcaRouterCatalogFilter { + endpoint_type, + kind: resolved.kind, + } + }); let mut request_headers = Vec::new(); if let Some(api_key) = resolved .api_key @@ -655,11 +669,14 @@ async fn fetch_models( { if is_gemini { request_headers.push(("x-goog-api-key".to_string(), api_key.to_string())); + } else if orcarouter_filter.is_some() { + request_headers.push(("Authorization".to_string(), format!("Bearer {api_key}"))); } else { request_headers.extend(resolved.protocol.format.headers(api_key)); } } - if resolved.protocol.format == LlmRequestFormat::Messages + if orcarouter_filter.is_none() + && resolved.protocol.format == LlmRequestFormat::Messages && !request_headers .iter() .any(|(name, _)| name == "anthropic-version") @@ -692,10 +709,20 @@ async fn fetch_models( if response.body.len() > MODEL_LIST_MAX_BYTES { return Err(provider_error("provider model response is too large")); } - parse_model_list(&response.body, is_gemini) + parse_model_list(&response.body, is_gemini, orcarouter_filter) } -fn parse_model_list(body: &[u8], is_gemini: bool) -> Result, BackendError> { +#[derive(Clone, Copy)] +struct OrcaRouterCatalogFilter { + endpoint_type: &'static str, + kind: ProviderKind, +} + +fn parse_model_list( + body: &[u8], + is_gemini: bool, + orcarouter_filter: Option, +) -> Result, BackendError> { let value: serde_json::Value = serde_json::from_slice(body) .map_err(|_| provider_error("provider model response is invalid JSON"))?; let models = if is_gemini { @@ -729,9 +756,37 @@ fn parse_model_list(body: &[u8], is_gemini: bool) -> Result, Backend .and_then(serde_json::Value::as_array) .ok_or_else(|| provider_error("provider model response is missing data"))? .iter() + .filter(|item| { + orcarouter_filter.is_none_or(|filter| { + let supports_endpoint = item + .get("supported_endpoint_types") + .and_then(serde_json::Value::as_array) + .is_some_and(|types| { + types + .iter() + .any(|value| value.as_str() == Some(filter.endpoint_type)) + }); + let supports_audio = filter.kind != ProviderKind::Asr + || item + .pointer("/architecture/input_modalities") + .and_then(serde_json::Value::as_array) + .is_some_and(|modalities| { + modalities + .iter() + .any(|value| value.as_str() == Some("audio")) + }); + supports_endpoint && supports_audio + }) + }) .filter_map(|item| item.get("id").and_then(serde_json::Value::as_str)) .map(str::trim) .filter(|name| !name.is_empty()) + .filter(|name| { + if !orcarouter_filter.is_some_and(|filter| filter.kind == ProviderKind::Asr) { + return true; + } + name.to_ascii_lowercase().starts_with("google/gemini") + }) .map(str::to_string) .collect::>() }; @@ -1282,6 +1337,7 @@ mod tests { let models = parse_model_list( br#"{"data":[{"id":"gpt-z"},{"id":""},{"id":"gpt-a"},{"id":"gpt-z"}]}"#, false, + None, ) .unwrap(); assert_eq!(models, vec!["gpt-a", "gpt-z"]); @@ -1292,6 +1348,7 @@ mod tests { let models = parse_model_list( br#"{"models":[{"name":"models/gemini-z","supportedGenerationMethods":["generateContent"]},{"name":"models/embedding","supportedGenerationMethods":["embedContent"]},{"name":"gemini-a"}]}"#, true, + None, ) .unwrap(); assert_eq!(models, vec!["gemini-a", "gemini-z"]); @@ -1299,11 +1356,159 @@ mod tests { #[test] fn invalid_model_response_is_a_provider_error_without_body() { - let error = parse_model_list(br#"{"error":"secret-key"}"#, false).unwrap_err(); + let error = parse_model_list(br#"{"error":"secret-key"}"#, false, None).unwrap_err(); assert_eq!(error.code, BackendErrorCode::Provider); assert!(!format!("{error:?}").contains("secret-key")); } + #[tokio::test] + async fn orcarouter_catalog_uses_core_channel_defaults_and_filters_capabilities() { + let catalog = r#"{"data":[ + {"id":"orcarouter/fusion-flash","supported_endpoint_types":["openai","anthropic"]}, + {"id":"google/gemini-2.5-flash","supported_endpoint_types":["openai"],"architecture":{"input_modalities":["text","audio"]}}, + {"id":"google/gemini-2.5-flash","supported_endpoint_types":["openai"],"architecture":{"input_modalities":["text","audio"]}}, + {"id":"google/gemini-image","supported_endpoint_types":["openai-image"]}, + {"id":"google/gemini-tts","supported_endpoint_types":["openai"],"architecture":{"input_modalities":["text"]}}, + {"id":"google/gemini-unknown","supported_endpoint_types":["openai"]}, + {"id":"openai/embedding","supported_endpoint_types":["embeddings"]}, + {"id":"legacy/chat"} + ]}"#; + for (kind, channel_kind, key_account, expected) in [ + (ProviderKind::Llm, ChannelKind::Llm, LLM_API_KEY_ACCOUNT, + vec!["google/gemini-2.5-flash", "google/gemini-tts", "google/gemini-unknown", "orcarouter/fusion-flash"]), + (ProviderKind::Asr, ChannelKind::Asr, ASR_API_KEY_ACCOUNT, + vec!["google/gemini-2.5-flash"]), + ] { + let credentials = Arc::new(InMemoryCredentialStore::default()); + let channel = create_channel_with_values(&credentials, channel_kind, "orcarouter", &[]).await; + let transport = Arc::new(FakeProviderTransport::default()); + transport.push_response(200, catalog.as_bytes().to_vec()); + let service = ProviderService::new_with_transport(credentials.clone(), Arc::new(crate::TokioTaskSpawner), transport.clone()); + let request = ProviderRequest { kind, channel_id: Some(channel.clone()), thinking_enabled: false }; + assert!(service.list_models(request.clone()).await.is_err()); + let namespace = if kind == ProviderKind::Llm { CredentialNamespace::Llm } else { CredentialNamespace::Asr }; + credentials.write(CredentialKey::new(namespace, Some(channel), key_account).unwrap(), SecretValue::new("fixture-key")).await.unwrap(); + let result = service.list_models(request).await.unwrap(); + assert_eq!(result.models, expected); + let requests = transport.requests(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].url, "https://api.orcarouter.ai/v1/models"); + } + } + + #[tokio::test] + async fn orcarouter_catalog_matches_the_selected_llm_protocol() { + let catalog = br#"{"data":[ + {"id":"model/chat","supported_endpoint_types":["openai"]}, + {"id":"model/responses","supported_endpoint_types":["openai-response"]}, + {"id":"model/messages","supported_endpoint_types":["anthropic"]}, + {"id":"model/all","supported_endpoint_types":["openai","openai-response","anthropic"]}, + {"id":"model/unknown"} + ]}"#; + for (format, expected) in [ + ("chat_completions", vec!["model/all", "model/chat"]), + ("responses", vec!["model/all", "model/responses"]), + ("messages", vec!["model/all", "model/messages"]), + ] { + let credentials = Arc::new(InMemoryCredentialStore::default()); + let channel = create_channel_with_values( + &credentials, + ChannelKind::Llm, + "orcarouter", + &[ + (LLM_API_KEY_ACCOUNT, "fixture-key"), + (crate::llm_protocol::REQUEST_FORMAT_ACCOUNT, format), + ], + ) + .await; + let transport = Arc::new(FakeProviderTransport::default()); + transport.push_response(200, catalog.to_vec()); + let service = ProviderService::new_with_transport( + credentials, + Arc::new(crate::TokioTaskSpawner), + transport.clone(), + ); + + let result = service + .list_models(ProviderRequest { + kind: ProviderKind::Llm, + channel_id: Some(channel), + thinking_enabled: false, + }) + .await + .unwrap(); + + assert_eq!(result.models, expected, "request format: {format}"); + assert_eq!( + transport.requests()[0].headers, + vec![( + "Authorization".to_string(), + "Bearer fixture-key".to_string() + )], + "OrcaRouter /models always uses bearer authentication" + ); + } + } + + #[tokio::test] + async fn custom_asr_endpoint_does_not_enable_orcarouter_catalog_rules() { + let credentials = Arc::new(InMemoryCredentialStore::default()); + let channel = create_channel_with_values( + &credentials, + ChannelKind::Asr, + "openai-compatible", + &[ + (ASR_ENDPOINT_ACCOUNT, "https://api.orcarouter.ai/v1"), + (ASR_MODEL_ACCOUNT, "whisper-1"), + ], + ) + .await; + let transport = Arc::new(FakeProviderTransport::default()); + transport.push_response( + 200, + br#"{"data":[{"id":"google/gemini-audio"},{"id":"openai/whisper-1"}]}"#.to_vec(), + ); + let service = ProviderService::new_with_transport( + credentials, + Arc::new(crate::TokioTaskSpawner), + transport, + ); + + let result = service + .list_models(ProviderRequest { + kind: ProviderKind::Asr, + channel_id: Some(channel), + thinking_enabled: false, + }) + .await + .unwrap(); + + assert_eq!( + result.models, + vec!["google/gemini-audio", "openai/whisper-1"] + ); + } + + #[tokio::test] + async fn orcarouter_validation_uses_shared_audio_chat_transcription() { + let (endpoint, request) = spawn_http_response("200 OK", "application/json", + r#"{"choices":[{"message":{"content":"transcript"}}]}"#); + let credentials = Arc::new(InMemoryCredentialStore::default()); + let channel = create_channel_with_values(&credentials, ChannelKind::Asr, "orcarouter", &[ + (ASR_ENDPOINT_ACCOUNT, &endpoint), (ASR_API_KEY_ACCOUNT, "fixture-key"), + ]).await; + let service = ProviderService::new(credentials, Arc::new(crate::TokioTaskSpawner)); + service.validate(ProviderRequest { kind: ProviderKind::Asr, channel_id: Some(channel), thinking_enabled: false }).await.unwrap(); + let request = String::from_utf8(request.recv_timeout(Duration::from_secs(2)).unwrap()).unwrap(); + assert!(request.starts_with("POST /v1/chat/completions ")); + let body: serde_json::Value = serde_json::from_str(request.split_once("\r\n\r\n").unwrap().1).unwrap(); + assert_eq!(body["model"], crate::asr::mimo::ORCAROUTER_DEFAULT_MODEL); + let content = &body["messages"][0]["content"]; + assert_eq!(content[0]["type"], "text"); + assert_eq!(content[1]["input_audio"]["format"], "wav"); + assert!(!content[1]["input_audio"]["data"].as_str().unwrap().starts_with("data:")); + } + async fn service_with_fake_transport() -> (ProviderService, Arc, String) { let credentials = Arc::new(InMemoryCredentialStore::default()); diff --git a/openless-all/app/src/components/ui/SelectLite.tsx b/openless-all/app/src/components/ui/SelectLite.tsx index 3c5a25f8..01bb432c 100644 --- a/openless-all/app/src/components/ui/SelectLite.tsx +++ b/openless-all/app/src/components/ui/SelectLite.tsx @@ -1,4 +1,5 @@ -// SelectLite 在各平台提供一致的下拉交互、键盘操作和浮层定位。 +// SelectLite — 三平台统一的自定义下拉,替代 native setQuery(event.target.value)} + onKeyDown={handleSearchKeyDown} + placeholder={searchPlaceholder} + aria-label={searchPlaceholder} + aria-autocomplete="list" + aria-expanded={open} + aria-controls={listboxId} + aria-activedescendant={highlightedOptionId} + style={{ + flex: 1, + minWidth: 0, + border: 0, + outline: 0, + padding: 0, + background: 'transparent', + color: 'var(--ol-ink)', + font: 'inherit', + }} + /> + + {filteredOptions.length}/{options.length} + + + )}
- {options.map((option, index) => { + {filteredOptions.length === 0 && ( +
+ {emptyMessage} +
+ )} + {filteredOptions.map((option, index) => { const isSelected = option.value === value; const isHighlighted = index === highlight; return (
!option.disabled && setHighlight(index)} - onMouseDown={(event) => { + onMouseDown={event => { event.preventDefault(); selectIndex(index); }} @@ -365,10 +495,9 @@ export function SelectLite({ borderRadius: 6, cursor: option.disabled ? 'not-allowed' : 'default', opacity: option.disabled ? 0.45 : 1, - background: - isHighlighted && !option.disabled - ? 'var(--ol-select-option-hover-bg)' - : 'transparent', + background: isHighlighted && !option.disabled + ? 'var(--ol-select-option-hover-bg)' + : 'transparent', color: isSelected ? 'var(--ol-blue)' : 'var(--ol-ink)', fontWeight: isSelected ? 600 : 500, whiteSpace: 'nowrap', @@ -377,9 +506,7 @@ export function SelectLite({ transition: 'background 0.10s var(--ol-motion-quick)', }} > - + {option.label} {option.trailing} @@ -387,9 +514,10 @@ export function SelectLite({
); })} -
, - document.body, - )} + + , + document.body, + )} ); } diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 21c05a52..d550ce4c 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1252,6 +1252,7 @@ export const en: typeof zhCN = { mimo: 'Xiaomi MiMo', cometapi: 'CometAPI', openrouterFree: 'OpenRouter (free models)', + orcarouter: 'OrcaRouter', alibabaCoding: 'Alibaba Cloud Coding Plan', codingPlanX: 'CodingPlanX', minimax: 'MiniMax (M3)', @@ -1340,6 +1341,10 @@ export const en: typeof zhCN = { modelLabel: 'Model', customModelLabel: 'Custom model\u2026', presetListLabel: 'Back to presets', + searchModels: 'Search models…', + noMatchingModels: 'No matching models', + orcarouterCatalogHint: 'Loaded from OrcaRouter /models. Select a catalog model; manual model IDs are disabled for this provider.', + orcarouterAsrCatalogHint: 'Loaded from OrcaRouter /models and limited to Gemini models compatible with audio input. Manual model IDs are disabled.', temperatureLabel: 'Temperature', temperaturePlaceholder: 'Leave empty to omit; range 0–2 inclusive, e.g. 0.3', extraHeadersLabel: 'Extra headers', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 476fdcfa..023c4092 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1249,6 +1249,7 @@ export const ja: typeof zhCN = { mimo: 'Xiaomi MiMo', cometapi: 'CometAPI', openrouterFree: 'OpenRouter(無料モデル)', + orcarouter: 'OrcaRouter', alibabaCoding: 'Alibaba Cloud Coding Plan', codingPlanX: 'CodingPlanX', minimax: 'MiniMax(M3)', @@ -1337,6 +1338,10 @@ export const ja: typeof zhCN = { modelLabel: 'モデル', customModelLabel: 'カスタムモデル…', presetListLabel: 'プリセットに戻る', + searchModels: 'モデルを検索…', + noMatchingModels: '一致するモデルがありません', + orcarouterCatalogHint: 'OrcaRouter /models から取得します。このプロバイダーではカタログから選択し、モデル ID の手入力はできません。', + orcarouterAsrCatalogHint: 'OrcaRouter /models から取得し、音声入力に対応する Gemini のみ表示します。モデル ID の手入力はできません。', temperatureLabel: 'Temperature', temperaturePlaceholder: '空欄なら送信しません。範囲は 0〜2(両端を含む)。例: 0.3', extraHeadersLabel: '追加 Headers', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index dc9bdc52..6714c558 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1241,6 +1241,7 @@ export const ko: typeof zhCN = { mimo: 'Xiaomi MiMo', cometapi: 'CometAPI', openrouterFree: 'OpenRouter(무료 모델)', + orcarouter: 'OrcaRouter', alibabaCoding: 'Alibaba Cloud Coding Plan', codingPlanX: 'CodingPlanX', minimax: 'MiniMax (M3)', @@ -1328,6 +1329,10 @@ export const ko: typeof zhCN = { modelLabel: '모델', customModelLabel: '사용자 정의 모델…', presetListLabel: '프리셋으로 돌아가기', + searchModels: '모델 검색…', + noMatchingModels: '일치하는 모델이 없습니다', + orcarouterCatalogHint: 'OrcaRouter /models에서 불러옵니다. 이 공급자는 카탈로그 모델만 선택할 수 있으며 모델 ID 직접 입력은 지원하지 않습니다.', + orcarouterAsrCatalogHint: 'OrcaRouter /models에서 불러오며 오디오 입력을 지원하는 Gemini 모델만 표시합니다. 모델 ID 직접 입력은 지원하지 않습니다.', temperatureLabel: 'Temperature', temperaturePlaceholder: '비워 두면 보내지 않음. 범위 0~2(양 끝 포함), 예: 0.3', extraHeadersLabel: '추가 Headers', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 7b7596ea..35c4b90a 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1193,6 +1193,7 @@ export const zhCN = { mimo: '小米 MiMo', cometapi: 'CometAPI', openrouterFree: 'OpenRouter(免费模型)', + orcarouter: 'OrcaRouter', alibabaCoding: '阿里云 Coding Plan', codingPlanX: 'CodingPlanX', minimax: 'MiniMax(M3)', @@ -1277,6 +1278,10 @@ export const zhCN = { modelLabel: '模型', customModelLabel: '自定义模型…', presetListLabel: '返回预设列表', + searchModels: '搜索模型…', + noMatchingModels: '没有匹配的模型', + orcarouterCatalogHint: '模型来自 OrcaRouter /models;此供应商只允许从目录中选择,不支持手动填写模型 ID。', + orcarouterAsrCatalogHint: '模型来自 OrcaRouter /models,并仅显示兼容音频输入的 Gemini;不支持手动填写模型 ID。', temperatureLabel: 'Temperature', temperaturePlaceholder: '留空则不发送;范围 0~2(含边界),例如 0.3', extraHeadersLabel: '额外 Headers', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index d4f882b6..71086cc2 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1195,6 +1195,7 @@ export const zhTW: typeof zhCN = { mimo: '小米 MiMo', cometapi: 'CometAPI', openrouterFree: 'OpenRouter(免費模型)', + orcarouter: 'OrcaRouter', alibabaCoding: '阿里雲 Coding Plan', codingPlanX: 'CodingPlanX', minimax: 'MiniMax(M3)', @@ -1279,6 +1280,10 @@ export const zhTW: typeof zhCN = { modelLabel: '模型', customModelLabel: '自訂模型…', presetListLabel: '返回預設清單', + searchModels: '搜尋模型…', + noMatchingModels: '沒有符合的模型', + orcarouterCatalogHint: '模型來自 OrcaRouter /models;此供應商只允許從目錄中選擇,不支援手動填寫模型 ID。', + orcarouterAsrCatalogHint: '模型來自 OrcaRouter /models,並僅顯示相容音訊輸入的 Gemini;不支援手動填寫模型 ID。', temperatureLabel: 'Temperature', temperaturePlaceholder: '留空則不發送;範圍 0~2(含邊界),例如 0.3', extraHeadersLabel: '額外 Headers', diff --git a/openless-all/app/src/lib/ipc/asr-credentials.ts b/openless-all/app/src/lib/ipc/asr-credentials.ts index 4beed199..4a428692 100644 --- a/openless-all/app/src/lib/ipc/asr-credentials.ts +++ b/openless-all/app/src/lib/ipc/asr-credentials.ts @@ -1,7 +1,8 @@ import type { CredentialsStatus } from '../types'; -import { invokeOrMock } from './shared'; +import { invokeOrMock, isTauri } from './shared'; import { mockCredentialsStatus, mockCredentialValues } from './mock-data'; import { invalidateMockChannelTest } from './channels'; +import type { LlmRequestFormat } from './providers'; export interface ProviderCheckResult { ok: boolean; @@ -11,6 +12,36 @@ export interface ProviderModelsResult { models: string[]; } +interface OrcaRouterCatalogModel { + id?: string + supported_endpoint_types?: string[] + architecture?: { + input_modalities?: string[] + } +} + +export function filterOrcaRouterModels( + models: OrcaRouterCatalogModel[], + kind: "llm" | "asr", + requestFormat: LlmRequestFormat = "chat_completions", +): string[] { + const endpointType = kind === "asr" + ? "openai" + : { + chat_completions: "openai", + responses: "openai-response", + messages: "anthropic", + }[requestFormat] + return models + .filter(model => model.supported_endpoint_types?.includes(endpointType) === true) + .filter(model => kind === "llm" || ( + model.id?.toLowerCase().startsWith("google/gemini") === true + && model.architecture?.input_modalities?.includes("audio") === true + )) + .map(model => model.id?.trim() ?? "") + .filter(Boolean) +} + export function getCredentials(): Promise { return invokeOrMock('get_credentials', undefined, () => mockCredentialsStatus); } @@ -52,10 +83,27 @@ export function validateProviderCredentials( })); } -export function listProviderModels( +export async function listProviderModels( kind: 'llm' | 'asr' | 'omni', channelId?: string, + providerType?: string, ): Promise { + if (!isTauri && providerType === 'orcarouter' && (kind === 'llm' || kind === 'asr')) { + const endpointAccount = kind === 'llm' ? 'ark.endpoint' : 'asr.endpoint'; + const endpoint = mockCredentialValues.get(`${channelId ?? ''}:${endpointAccount}`); + if (endpoint && new URL(endpoint).hostname.toLowerCase() === 'api.orcarouter.ai') { + const response = await fetch('/__openless_dev/orcarouter/models'); + if (!response.ok) { + throw new Error(`OrcaRouter /models returned ${response.status}`); + } + const payload = await response.json() as { data?: OrcaRouterCatalogModel[] }; + const storedFormat = mockCredentialValues.get(`${channelId ?? ''}:ark.request_format`); + const requestFormat: LlmRequestFormat = storedFormat === 'responses' || storedFormat === 'messages' + ? storedFormat + : 'chat_completions'; + return { models: filterOrcaRouterModels(payload.data ?? [], kind, requestFormat) }; + } + } return invokeOrMock('list_provider_models', { kind, channelId }, () => ({ models: kind === 'llm' ? ['gpt-4o', 'deepseek-v4-flash', 'deepseek-v4-pro'] : ['whisper-1'], })); diff --git a/openless-all/app/src/lib/ipc/channels.ts b/openless-all/app/src/lib/ipc/channels.ts index 35c1b2fd..f288f7b0 100644 --- a/openless-all/app/src/lib/ipc/channels.ts +++ b/openless-all/app/src/lib/ipc/channels.ts @@ -76,6 +76,14 @@ const mockChannels: Record = { order: 1, lastTest: null, }, + { + id: 'orcarouter-asr', + name: 'OrcaRouter-ASR', + providerType: 'orcarouter', + enabled: false, + order: 2, + lastTest: null, + }, ], }; @@ -146,7 +154,11 @@ export function deleteChannelIfBlank(kind: ChannelKind, id: string): Promise { - return invokeOrMock('rename_channel', { kind, id, name }, () => undefined); + return invokeOrMock('rename_channel', { kind, id, name }, () => { + const channel = mockChannels[kind].find((channel) => channel.id === id); + if (channel) channel.name = name; + return undefined; + }); } export function deleteChannel(kind: ChannelKind, id: string): Promise { @@ -158,7 +170,11 @@ export function deleteChannel(kind: ChannelKind, id: string): Promise { } export function setChannelEnabled(kind: ChannelKind, id: string, enabled: boolean): Promise { - return invokeOrMock('set_channel_enabled', { kind, id, enabled }, () => undefined); + return invokeOrMock('set_channel_enabled', { kind, id, enabled }, () => { + const channel = mockChannels[kind].find((channel) => channel.id === id); + if (channel) channel.enabled = enabled; + return undefined; + }); } /** ids 是拖拽后的完整顺序;后端会把未提及的渠道排到末尾。 */ diff --git a/openless-all/app/src/lib/ipc/mock-data.ts b/openless-all/app/src/lib/ipc/mock-data.ts index 66498311..b58e65a8 100644 --- a/openless-all/app/src/lib/ipc/mock-data.ts +++ b/openless-all/app/src/lib/ipc/mock-data.ts @@ -1,4 +1,7 @@ -export const mockCredentialValues = new Map(); +export const mockCredentialValues = new Map([ + ['orcarouter-asr:asr.endpoint', 'https://api.orcarouter.ai/v1'], + ['orcarouter-asr:asr.model', 'google/gemini-2.5-flash'], +]); import type { ActivityDay, CorrectionRule, diff --git a/openless-all/app/src/lib/ipc/provider-descriptors.generated.json b/openless-all/app/src/lib/ipc/provider-descriptors.generated.json index 5a628788..31174585 100644 --- a/openless-all/app/src/lib/ipc/provider-descriptors.generated.json +++ b/openless-all/app/src/lib/ipc/provider-descriptors.generated.json @@ -157,6 +157,18 @@ "supportedRequestFormats": [], "validationProbe": "asr_silence" }, + { + "authRequirement": "api_key", + "defaultEndpoint": "https://api.orcarouter.ai/v1", + "defaultModel": "google/gemini-2.5-flash", + "defaultRequestFormat": null, + "kind": "asr", + "labelKey": "orcarouter", + "providerType": "orcarouter", + "staticModels": [], + "supportedRequestFormats": [], + "validationProbe": "asr_silence" + }, { "authRequirement": "api_key", "defaultEndpoint": "https://zenmux.ai/api/v1", @@ -450,6 +462,22 @@ ], "validationProbe": "llm_text" }, + { + "authRequirement": "api_key_unless_custom_endpoint", + "defaultEndpoint": "https://api.orcarouter.ai/v1", + "defaultModel": "orcarouter/fusion-flash", + "defaultRequestFormat": "chat_completions", + "kind": "llm", + "labelKey": "orcarouter", + "providerType": "orcarouter", + "staticModels": [], + "supportedRequestFormats": [ + "chat_completions", + "responses", + "messages" + ], + "validationProbe": "llm_text" + }, { "authRequirement": "api_key_unless_custom_endpoint", "defaultEndpoint": "https://coding-intl.dashscope.aliyuncs.com/v1", diff --git a/openless-all/app/src/pages/Overview.tsx b/openless-all/app/src/pages/Overview.tsx index 86565b36..5c247b7d 100644 --- a/openless-all/app/src/pages/Overview.tsx +++ b/openless-all/app/src/pages/Overview.tsx @@ -59,6 +59,7 @@ const LLM_NAME_KEY_BY_ID: Record = { mimo: 'mimo', cometapi: 'cometapi', openrouterFree: 'openrouterFree', + orcarouter: 'orcarouter', alibabaCoding: 'alibabaCoding', codingPlanX: 'codingPlanX', minimax: 'minimax', diff --git a/openless-all/app/src/pages/settings/ChannelList.test.ts b/openless-all/app/src/pages/settings/ChannelList.test.ts index 3a5c9e96..2b1b0d97 100644 --- a/openless-all/app/src/pages/settings/ChannelList.test.ts +++ b/openless-all/app/src/pages/settings/ChannelList.test.ts @@ -1,6 +1,6 @@ import type { OS } from '../../components/WindowChrome'; import type { ProviderDescriptor } from '../../lib/ipc'; -import { presetsFor, shouldRecycleDraft } from './ChannelList'; +import { defaultChannelNameForProvider, presetsFor, shouldRecycleDraft } from './ChannelList'; const localProviders = [ 'local-qwen3', @@ -102,4 +102,14 @@ if (shouldRecycleDraft(null, false)) { throw new Error('an existing channel must never enter draft cleanup'); } +if (defaultChannelNameForProvider('orcarouter', '') !== 'OrcaRouter') { + throw new Error('a blank OrcaRouter channel must receive the OrcaRouter default name'); +} +if (defaultChannelNameForProvider('orcarouter', 'My primary key') !== 'My primary key') { + throw new Error('the OrcaRouter default name must not overwrite a custom channel name'); +} +if (defaultChannelNameForProvider('openai', '') !== '') { + throw new Error('non-OrcaRouter channel naming must remain unchanged'); +} + console.log('ChannelList platform filtering and draft lifecycle tests passed'); diff --git a/openless-all/app/src/pages/settings/ChannelList.tsx b/openless-all/app/src/pages/settings/ChannelList.tsx index baa159bb..9b303ba3 100644 --- a/openless-all/app/src/pages/settings/ChannelList.tsx +++ b/openless-all/app/src/pages/settings/ChannelList.tsx @@ -115,6 +115,12 @@ export function shouldRecycleDraft(draftId: string | null, touched: boolean): bo return draftId != null && !touched; } +/** OrcaRouter 渠道使用统一的无空格品牌名;只填空名称,不覆盖用户自定义命名。 */ +export function defaultChannelNameForProvider(providerType: string, currentName: string): string { + if (currentName.trim() || providerType !== 'orcarouter') return currentName; + return 'OrcaRouter'; +} + function presetLabel( kind: ChannelKind, providerType: string, @@ -1004,6 +1010,11 @@ function ChannelModal({ if (!preset) return; const endpointAccount = kind === 'llm' ? 'ark.endpoint' : 'asr.endpoint'; const modelAccount = kind === 'llm' ? 'ark.model_id' : 'asr.model'; + if (next === 'orcarouter') { + if (preset.defaultEndpoint) await setCredential(endpointAccount, preset.defaultEndpoint, channel.id); + if (preset.defaultModel) await setCredential(modelAccount, preset.defaultModel, channel.id); + return; + } if (preset.defaultEndpoint && !(await readCredential(endpointAccount, channel.id))?.trim()) { await setCredential(endpointAccount, preset.defaultEndpoint, channel.id); } @@ -1023,6 +1034,11 @@ function ChannelModal({ await setChannelProviderType(kind, channel.id, next); await fillProviderDefaults(next); setProviderType(next); + const defaultName = defaultChannelNameForProvider(next, name); + if (defaultName !== name) { + await renameChannel(kind, channel.id, defaultName); + setName(defaultName); + } await onChanged(); } catch (error) { console.error('[channels] change provider failed', error); diff --git a/openless-all/app/src/pages/settings/LlmProtocolFields.tsx b/openless-all/app/src/pages/settings/LlmProtocolFields.tsx index 16d526f6..5066f7a2 100644 --- a/openless-all/app/src/pages/settings/LlmProtocolFields.tsx +++ b/openless-all/app/src/pages/settings/LlmProtocolFields.tsx @@ -61,7 +61,7 @@ export function LlmProtocolFields({ formats: LlmRequestFormat[]; onUserMutation: () => void; onBlockedChange: (account: string, blocked: boolean) => void; - onSaved?: () => void; + onSaved?: (changedAccounts?: string[]) => void; }) { const { t } = useTranslation(); const [values, setValues] = useState(emptyValues); @@ -107,14 +107,14 @@ export function LlmProtocolFields({ setSaving(true); setError(null); try { - for (const account of accounts) { - if (next[account] !== saved[account]) - await setCredential(account, next[account], channelId); + const changedAccounts = accounts.filter((account) => next[account] !== saved[account]); + for (const account of changedAccounts) { + await setCredential(account, next[account], channelId); } if (mounted.current) { setSaved(next); emitSaved('saved', t('common.saved')); - onSaved?.(); + onSaved?.(changedAccounts); } } catch { if (mounted.current) { diff --git a/openless-all/app/src/pages/settings/ProvidersSection.test.ts b/openless-all/app/src/pages/settings/ProvidersSection.test.ts index ae9df22b..f4246ac5 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.test.ts +++ b/openless-all/app/src/pages/settings/ProvidersSection.test.ts @@ -1,6 +1,7 @@ -import { LLM_LABELS } from './ProvidersSection'; +import { LLM_LABELS, prioritizeOrcaRouterModels } from './ProvidersSection'; import { ASR_LABELS } from './shared'; import { presetsFor } from './ChannelList'; +import { filterOrcaRouterModels } from '../../lib/ipc/asr-credentials'; const atlascloudPreset = LLM_LABELS.find((p) => p.id === 'atlascloud'); if (LLM_LABELS.find((p) => p.id === 'opencode')?.nameKey !== 'opencode') { @@ -43,3 +44,53 @@ if (coreAsr.length !== 1 || coreAsr[0].authRequirement !== 'endpoint_model_optio 'Core provider descriptor must replace the browser fallback in the channel picker', ); } + +const prioritizedOrcaRouterModels = prioritizeOrcaRouterModels([ + 'openai/gpt-5-mini', + 'orcarouter/fusion-mini', + 'anthropic/claude-haiku-4.5', + 'orcarouter/fusion-flash', +]); + +if (prioritizedOrcaRouterModels.join(',') !== [ + 'orcarouter/fusion-flash', + 'orcarouter/fusion-mini', + 'anthropic/claude-haiku-4.5', + 'openai/gpt-5-mini', +].join(',')) { + throw new Error(`unexpected OrcaRouter model ordering: ${prioritizedOrcaRouterModels.join(',')}`); +} + +const protocolCatalog = [ + { id: 'model/chat', supported_endpoint_types: ['openai'] }, + { id: 'model/responses', supported_endpoint_types: ['openai-response'] }, + { id: 'model/messages', supported_endpoint_types: ['anthropic'] }, + { id: 'model/all', supported_endpoint_types: ['openai', 'openai-response', 'anthropic'] }, + { id: 'model/unknown' }, +]; +for (const [format, expected] of [ + ['chat_completions', ['model/chat', 'model/all']], + ['responses', ['model/responses', 'model/all']], + ['messages', ['model/messages', 'model/all']], +] as const) { + const actual = filterOrcaRouterModels(protocolCatalog, 'llm', format); + if (actual.join(',') !== expected.join(',')) { + throw new Error(`unexpected ${format} catalog: ${actual.join(',')}`); + } +} + +const asrCatalog = [ + { id: 'google/gemini-audio', supported_endpoint_types: ['openai'], architecture: { input_modalities: ['text', 'audio'] } }, + { id: 'google/gemini-text', supported_endpoint_types: ['openai'], architecture: { input_modalities: ['text'] } }, + { id: 'google/gemini-unknown', supported_endpoint_types: ['openai'] }, + { id: 'meta/audio', supported_endpoint_types: ['openai'], architecture: { input_modalities: ['audio'] } }, +]; +if (filterOrcaRouterModels(asrCatalog, 'asr').join(',') !== 'google/gemini-audio') { + throw new Error('OrcaRouter ASR catalog must require declared Gemini audio input'); +} + +for (const labels of [LLM_LABELS, ASR_LABELS]) { + if (!labels.some(label => label.id === 'orcarouter' && label.nameKey === 'orcarouter')) { + throw new Error('OrcaRouter provider label is missing'); + } +} diff --git a/openless-all/app/src/pages/settings/ProvidersSection.tsx b/openless-all/app/src/pages/settings/ProvidersSection.tsx index c023ab25..090fed13 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.tsx +++ b/openless-all/app/src/pages/settings/ProvidersSection.tsx @@ -133,6 +133,7 @@ export const LLM_LABELS = [ ['mimo', 'mimo'], ['cometapi', 'cometapi'], ['openrouterFree', 'openrouterFree'], + ['orcarouter', 'orcarouter'], ['alibabaCoding', 'alibabaCoding'], ['codingPlanX', 'codingPlanX'], ['minimax', 'minimax'], @@ -187,6 +188,7 @@ export function ChannelCredentialFields({ const { prefs, updatePrefs } = useHotkeySettings(); const [llmModelRevision, setLlmModelRevision] = useState(0); const [configRevision, setConfigRevision] = useState(0); + const [orcarouterCatalogRevision, setOrcarouterCatalogRevision] = useState(0); const [blockedFields, setBlockedFields] = useState>({}); const trackField = useCallback((account: string, blocked: boolean) => { setBlockedFields((previous) => @@ -252,7 +254,12 @@ export function ChannelCredentialFields({ formats={descriptor.supportedRequestFormats} onUserMutation={onLlmMutation} onBlockedChange={trackField} - onSaved={onTested} + onSaved={(changedAccounts) => { + if (providerType === 'orcarouter' && changedAccounts?.includes('ark.request_format')) { + setOrcarouterCatalogRevision((value) => value + 1); + } + onTested?.(); + }} /> )} {codexOAuthSelected ? ( @@ -312,6 +319,23 @@ export function ChannelCredentialFields({ description={t('settings.channels.modelHint')} /> + {providerType === 'orcarouter' ? ( + + } + /> + ) : ( } /> + )} {['custom', 'custom_responses', 'custom_messages'].includes(providerType) && ( setLlmModelRevision((v) => v + 1)} onTested={onTested} onUserMutation={onUserMutation} + showFetchModels={providerType !== 'orcarouter'} /> ); @@ -504,6 +530,29 @@ export function ChannelCredentialFields({ ); } + if (providerType === 'orcarouter') { + return ( + <> + + + + setAsrModelRevision(v => v + 1)} onTested={onTested} + onUserMutation={onUserMutation} showFetchModels={false} /> + + ); + } + return ( <> { + const leftOwn = left.startsWith('orcarouter/'); + const rightOwn = right.startsWith('orcarouter/'); + if (leftOwn !== rightOwn) return leftOwn ? -1 : 1; + return left.localeCompare(right); + }); +} + +function CatalogModelField({ + kind, + provider, + baseUrl, + defaultModel, + trailing, + onUserMutation, + onBlockedChange, +}: { + kind: 'llm' | 'asr'; + provider: string; + baseUrl: string; + defaultModel: string; + trailing?: ReactNode; + onUserMutation?: () => void; + onBlockedChange?: (account: string, blocked: boolean) => void; +}) { + const { t } = useTranslation(); + const baseLayoutStack = useLayoutStack(); + const conservative = useConservativeLayout(); + const layoutStack = conservative || baseLayoutStack; + const [models, setModels] = useState([]); + const [selectedModel, setSelectedModel] = useState(''); + const [status, setStatus] = useState('loading'); + const [message, setMessage] = useState(t('settings.providers.loadingModels')); + const requestRef = useRef(0); + const endpointAccount = kind === 'llm' ? 'ark.endpoint' : 'asr.endpoint'; + const modelAccount = kind === 'llm' ? 'ark.model_id' : 'asr.model'; + + useEffect(() => { + onBlockedChange?.(modelAccount, status !== 'success'); + }, [modelAccount, status, onBlockedChange]); + + const loadModels = async (initialize: boolean) => { + const requestId = ++requestRef.current; + setStatus('loading'); + setMessage(t('settings.providers.loadingModels')); + try { + // A newly created/migrated channel may not have received its preset yet. + // Fill only an empty endpoint here; an explicit endpoint edit remains + // respected, matching the behavior of the other named providers. + if (initialize) { + const endpoint = await readCredential(endpointAccount, provider); + if (!endpoint?.trim()) { + await setCredential(endpointAccount, baseUrl, provider); + } + } + const [savedModel, result] = await Promise.all([ + readCredential(modelAccount, provider), + listProviderModels(kind, provider, 'orcarouter'), + ]); + if (requestId !== requestRef.current) return; + const nextModels = prioritizeOrcaRouterModels(result.models); + setModels(nextModels); + if (nextModels.length === 0) { + setSelectedModel(''); + setStatus('empty'); + setMessage(t('settings.providers.modelsEmpty')); + return; + } + + const current = savedModel?.trim() ?? ''; + const nextModel = nextModels.includes(current) + ? current + : nextModels.includes(defaultModel) + ? defaultModel + : nextModels[0]; + if (nextModel !== current) { + await setCredential(modelAccount, nextModel, provider); + } + if (requestId !== requestRef.current) return; + setSelectedModel(nextModel); + setStatus('success'); + setMessage(t('settings.providers.modelsLoaded', { count: nextModels.length })); + } catch (error) { + if (requestId !== requestRef.current) return; + setModels([]); + setStatus('error'); + setMessage(providerErrorMessage(error, t)); + } + }; + + useEffect(() => { + void loadModels(true); + return () => { + requestRef.current += 1; + }; + // The channel id defines the credential scope; changing it must reload the catalog. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [provider]); + + const applyModel = async (model: string) => { + onUserMutation?.(); + setStatus('loading'); + setMessage(t('common.saving')); + try { + await setCredential(modelAccount, model, provider); + setSelectedModel(model); + setStatus('success'); + setMessage(t('settings.providers.modelSaved', { model })); + emitSaved('saved', t('common.saved')); + } catch (error) { + setStatus('error'); + setMessage(providerErrorMessage(error, t)); + emitSaved('failed', t('common.operationFailed')); + } + }; + + return ( + +
+
+ void applyModel(model)} + options={models.map((model) => ({ value: model, label: model }))} + placeholder={status === 'loading' + ? t('settings.providers.loadingModels') + : t('settings.providers.selectModel')} + disabled={status === 'loading' || models.length === 0} + searchable + searchPlaceholder={t('settings.providers.searchModels')} + emptyMessage={t('settings.providers.noMatchingModels')} + ariaLabel={t('settings.providers.selectModel')} + style={{ + flex: layoutStack ? '1 1 100%' : 1, + width: '100%', + minWidth: 0, + maxWidth: '100%', + fontFamily: 'var(--ol-font-mono)', + }} + /> + + {trailing} +
+ + {message} + + + {t(kind === 'asr' + ? 'settings.providers.orcarouterAsrCatalogHint' + : 'settings.providers.orcarouterCatalogHint')} + +
+
+ ); +} + function ProviderTools({ kind, modelAccount, diff --git a/openless-all/app/src/pages/settings/shared.tsx b/openless-all/app/src/pages/settings/shared.tsx index ae9214bd..de5aa95d 100644 --- a/openless-all/app/src/pages/settings/shared.tsx +++ b/openless-all/app/src/pages/settings/shared.tsx @@ -231,6 +231,7 @@ export const ASR_LABELS = [ { id: 'groq', nameKey: 'asrGroq' }, { id: 'whisper', nameKey: 'asrWhisper' }, { id: 'openrouter', nameKey: 'asrOpenrouter' }, + { id: 'orcarouter', nameKey: 'orcarouter' }, { id: 'zenmux', nameKey: 'asrZenmux' }, { id: 'openai-compatible', nameKey: 'asrOpenAiCompatible' }, { id: 'xiaomi-mimo-asr', nameKey: 'asrXiaomiMimo' }, diff --git a/openless-all/app/vite.config.ts b/openless-all/app/vite.config.ts index cba87391..104f032d 100644 --- a/openless-all/app/vite.config.ts +++ b/openless-all/app/vite.config.ts @@ -30,6 +30,15 @@ export default defineConfig(async () => ({ ? { protocol: 'ws', host, port: 1421 } : undefined, watch: { ignored: ['**/src-tauri/**'] }, + proxy: { + // Browser-only preview parity: the native app calls OrcaRouter directly, + // while Vite needs a same-origin bridge because /models does not advertise CORS. + '/__openless_dev/orcarouter/models': { + target: 'https://api.orcarouter.ai', + changeOrigin: true, + rewrite: () => '/v1/models', + }, + }, }, envPrefix: ['VITE_', 'TAURI_'], build: {