feat(providers): add OrcaRouter model catalog, LLM, and ASR support (#1022)

* feat(providers): add OrcaRouter model catalog integration

* fix(providers): harden OrcaRouter catalog integration

---------

Co-authored-by: Chris233 <h-chris233@outlook.com>
This commit is contained in:
AnKaifengandChris233 authored and GitHub committed 2026-09-09 00:41:55 +08:00
1 parent a3fc7171d2
commit 66915405fd
26 files changed
+941 -104

No files matched your search

+2 -2
View File
@@ -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).
+2 -2
View File
@@ -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))。
- **主窗口**:概览 / 历史 / 词典 / 风格 / 市场 / 设置。常驻托盘图标,以及一个浮于屏幕、并跟随你正在输入的显示器的迷你状态胶囊(多显示器)。
@@ -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<Vec<u8>>,
}
@@ -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<RawTranscript> {
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<String> {
}
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!({
@@ -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),
)
}
@@ -183,6 +183,7 @@ fn is_builtin_llm_provider(provider_id: &str) -> bool {
| "mimo"
| "cometapi"
| "openrouterFree"
| "orcarouter"
| "alibabaCoding"
| "codingPlanX"
| "minimax"
@@ -1726,7 +1727,9 @@ pub(crate) fn openai_compatible_thinking_control(provider_id: &str) -> Option<Th
"alibabaCoding" => 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,
@@ -2895,6 +2898,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(
@@ -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 {
@@ -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<Vec<String>, BackendError> {
#[derive(Clone, Copy)]
struct OrcaRouterCatalogFilter {
endpoint_type: &'static str,
kind: ProviderKind,
}
fn parse_model_list(
body: &[u8],
is_gemini: bool,
orcarouter_filter: Option<OrcaRouterCatalogFilter>,
) -> Result<Vec<String>, 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<Vec<String>, 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::<Vec<_>>()
};
@@ -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<FakeProviderTransport>, String)
{
let credentials = Arc::new(InMemoryCredentialStore::default());
+192 -50
View File
@@ -19,6 +19,7 @@
import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
@@ -48,6 +49,10 @@ interface SelectLiteProps {
ariaLabel?: string;
/** 下拉打开 / 关闭时回调 —— 让调用方按开合状态启停副作用(如电平监听)。 */
onOpenChange?: (open: boolean) => void;
/** 长列表可在 popover 顶部显示搜索框;筛选只匹配 label/value,不改原 options。 */
searchable?: boolean;
searchPlaceholder?: string;
emptyMessage?: string;
}
const DEFAULT_TRIGGER_STYLE: CSSProperties = {
@@ -80,12 +85,18 @@ export function SelectLite({
style,
ariaLabel,
onOpenChange,
searchable = false,
searchPlaceholder = 'Search…',
emptyMessage = 'No matching options',
}: SelectLiteProps) {
const [open, setOpen] = useState(false);
// leaving 让 popover 在卸载前播完 exit keyframe(用户报"没有收缩动画"——之前直接 unmount)
const [leaving, setLeaving] = useState(false);
const [highlight, setHighlight] = useState<number>(-1);
const [query, setQuery] = useState('');
const listboxId = useId();
const triggerRef = useRef<HTMLButtonElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const popoverRef = useRef<HTMLDivElement | null>(null);
const [anchor, setAnchor] = useState<{ left: number; top: number; width: number } | null>(null);
// popoverMounted 让 useLayoutEffect 在 popover 实际进入 DOM 后再触发一次 positionPopover,
@@ -96,7 +107,18 @@ export function SelectLite({
() => options.find(opt => opt.value === value),
[options, value],
);
const filteredOptions = useMemo(() => {
const needle = query.trim().toLocaleLowerCase();
if (!needle) return options;
return options.filter(option => (
option.label.toLocaleLowerCase().includes(needle)
|| option.value.toLocaleLowerCase().includes(needle)
));
}, [options, query]);
const displayLabel = selected?.label ?? placeholder ?? '';
const highlightedOptionId = highlight >= 0
? `${listboxId}-option-${highlight}`
: undefined;
const positionPopover = useCallback(() => {
const trigger = triggerRef.current;
@@ -157,9 +179,32 @@ export function SelectLite({
const target = popoverRef.current?.querySelector(
`[data-option-index="${highlight}"]`,
) as HTMLElement | null;
target?.scrollIntoView({ block: 'nearest' });
const scrollContainer = target?.parentElement;
if (!target || !scrollContainer) return;
const optionTop = target.offsetTop;
const optionBottom = optionTop + target.offsetHeight;
if (optionTop < scrollContainer.scrollTop) {
scrollContainer.scrollTop = optionTop;
} else if (optionBottom > scrollContainer.scrollTop + scrollContainer.clientHeight) {
scrollContainer.scrollTop = optionBottom - scrollContainer.clientHeight;
}
}, [highlight, open]);
useEffect(() => {
if (!open || !searchable || !popoverMounted) return;
searchRef.current?.focus();
}, [open, popoverMounted, searchable]);
useEffect(() => {
if (!open) return;
const selectedIndex = filteredOptions.findIndex(
option => option.value === value && !option.disabled,
);
setHighlight(selectedIndex >= 0
? selectedIndex
: filteredOptions.findIndex(option => !option.disabled));
}, [filteredOptions, open, value]);
// 点击外部 / 滚动外部 → 关闭。popover 内部 scroll 保持打开。
useEffect(() => {
if (!open) return;
@@ -177,8 +222,8 @@ export function SelectLite({
if (target && popoverRef.current?.contains(target)) return;
closeMenu();
};
// window resize 强制关闭:重算位置成本高且大多数 resize 表明 user 不再想看 popover。
const handleResize = () => closeMenu();
// 窗口尺寸变化时保持展开并重新锚定,避免桌面窗口轻微调整就丢失当前筛选。
const handleResize = () => positionPopover();
document.addEventListener('mousedown', handlePointerDown);
window.addEventListener('scroll', handleScrollOutside, { capture: true, passive: true });
@@ -192,10 +237,11 @@ export function SelectLite({
};
// closeMenu 是稳定引用(无 React state 依赖),不放 deps。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
}, [open, positionPopover]);
const openMenu = () => {
if (disabled) return;
setQuery('');
const initial = options.findIndex(opt => opt.value === value && !opt.disabled);
setHighlight(initial >= 0 ? initial : options.findIndex(opt => !opt.disabled));
setLeaving(false);
@@ -211,12 +257,13 @@ export function SelectLite({
setOpen(false);
setLeaving(false);
setHighlight(-1);
setQuery('');
setAnchor(null);
}, EXIT_ANIM_MS);
};
const selectIndex = (index: number) => {
const option = options[index];
const option = filteredOptions[index];
if (!option || option.disabled) return;
onChange(option.value);
closeMenu();
@@ -224,11 +271,11 @@ export function SelectLite({
};
const moveHighlight = (direction: 1 | -1) => {
if (options.length === 0) return;
if (filteredOptions.length === 0) return;
let next = highlight;
for (let i = 0; i < options.length; i += 1) {
next = (next + direction + options.length) % options.length;
if (!options[next]?.disabled) {
for (let i = 0; i < filteredOptions.length; i += 1) {
next = (next + direction + filteredOptions.length) % filteredOptions.length;
if (!filteredOptions[next]?.disabled) {
setHighlight(next);
return;
}
@@ -261,6 +308,37 @@ export function SelectLite({
}
};
const moveFocusFromTrigger = (backward: boolean) => {
const trigger = triggerRef.current;
if (!trigger) return;
const focusable = Array.from(document.querySelectorAll<HTMLElement>(
'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable="true"]',
)).filter(element => element.getClientRects().length > 0 && !popoverRef.current?.contains(element));
const index = focusable.indexOf(trigger);
focusable[index + (backward ? -1 : 1)]?.focus();
};
const handleSearchKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Escape') {
event.preventDefault();
closeMenu();
triggerRef.current?.focus();
} else if (event.key === 'ArrowDown') {
event.preventDefault();
moveHighlight(1);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
moveHighlight(-1);
} else if (event.key === 'Enter' && highlight >= 0) {
event.preventDefault();
selectIndex(highlight);
} else if (event.key === 'Tab') {
event.preventDefault();
closeMenu();
moveFocusFromTrigger(event.shiftKey);
}
};
const triggerStyle: CSSProperties = {
...DEFAULT_TRIGGER_STYLE,
...style,
@@ -277,6 +355,8 @@ export function SelectLite({
role="combobox"
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={open ? listboxId : undefined}
aria-activedescendant={open && !searchable ? highlightedOptionId : undefined}
aria-disabled={disabled}
aria-label={ariaLabel}
disabled={disabled}
@@ -305,7 +385,6 @@ export function SelectLite({
{open && anchor && createPortal(
<div
ref={setPopoverRef}
role="listbox"
style={{
position: 'fixed',
left: anchor.left,
@@ -313,8 +392,8 @@ export function SelectLite({
// 锁到 trigger 宽(不是 minWidth),避免 content 撑大让 popover 跑出
// trigger 范围;长 label 走 textOverflow:ellipsis 截断。
width: anchor.width,
maxHeight: 280,
overflowY: 'auto',
maxHeight: searchable ? 320 : 280,
overflow: 'hidden',
padding: 4,
borderRadius: 10,
border: '0.5px solid var(--ol-select-popover-border)',
@@ -331,48 +410,111 @@ export function SelectLite({
transformOrigin: 'top center',
}}
>
{options.map((option, index) => {
const isSelected = option.value === value;
const isHighlighted = index === highlight;
return (
<div
key={option.value || `__opt_${index}`}
data-option-index={index}
role="option"
aria-selected={isSelected}
aria-disabled={option.disabled}
onMouseEnter={() => !option.disabled && setHighlight(index)}
onMouseDown={event => {
event.preventDefault();
selectIndex(index);
}}
{searchable && (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 7,
margin: '1px 1px 4px',
padding: '0 9px',
height: 34,
borderRadius: 7,
border: '0.5px solid var(--ol-line-strong)',
background: 'var(--ol-select-trigger-bg)',
color: 'var(--ol-ink-4)',
}}
>
<Icon name="search" size={13} />
<input
ref={searchRef}
role="combobox"
value={query}
onChange={event => setQuery(event.target.value)}
onKeyDown={handleSearchKeyDown}
placeholder={searchPlaceholder}
aria-label={searchPlaceholder}
aria-autocomplete="list"
aria-expanded={open}
aria-controls={listboxId}
aria-activedescendant={highlightedOptionId}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '7px 10px',
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',
color: isSelected ? 'var(--ol-blue)' : 'var(--ol-ink)',
fontWeight: isSelected ? 600 : 500,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
transition: 'background 0.10s var(--ol-motion-quick)',
flex: 1,
minWidth: 0,
border: 0,
outline: 0,
padding: 0,
background: 'transparent',
color: 'var(--ol-ink)',
font: 'inherit',
}}
/>
<span style={{ fontSize: 10.5, whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' }}>
{filteredOptions.length}/{options.length}
</span>
</div>
)}
<div
id={listboxId}
role="listbox"
style={{ maxHeight: searchable ? 274 : 272, overflowY: 'auto' }}
>
{filteredOptions.length === 0 && (
<div
style={{
padding: '18px 10px',
textAlign: 'center',
color: 'var(--ol-ink-4)',
fontSize: 12,
}}
>
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{option.label}
</span>
{option.trailing}
{isSelected && <Icon name="check" size={12} />}
{emptyMessage}
</div>
);
})}
)}
{filteredOptions.map((option, index) => {
const isSelected = option.value === value;
const isHighlighted = index === highlight;
return (
<div
key={option.value || `__opt_${index}`}
id={`${listboxId}-option-${index}`}
data-option-index={index}
role="option"
aria-selected={isSelected}
aria-disabled={option.disabled}
onMouseEnter={() => !option.disabled && setHighlight(index)}
onMouseDown={event => {
event.preventDefault();
selectIndex(index);
}}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '7px 10px',
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',
color: isSelected ? 'var(--ol-blue)' : 'var(--ol-ink)',
fontWeight: isSelected ? 600 : 500,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
transition: 'background 0.10s var(--ol-motion-quick)',
}}
>
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{option.label}
</span>
{option.trailing}
{isSelected && <Icon name="check" size={12} />}
</div>
);
})}
</div>
</div>,
document.body,
)}
+5
View File
@@ -1006,6 +1006,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)',
@@ -1081,6 +1082,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',
+5
View File
@@ -1008,6 +1008,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)',
@@ -1083,6 +1084,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',
+5
View File
@@ -1008,6 +1008,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)',
@@ -1083,6 +1084,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',
+5
View File
@@ -1004,6 +1004,7 @@ export const zhCN = {
mimo: '小米 MiMo',
cometapi: 'CometAPI',
openrouterFree: 'OpenRouter(免费模型)',
orcarouter: 'OrcaRouter',
alibabaCoding: '阿里云 Coding Plan',
codingPlanX: 'CodingPlanX',
minimax: 'MiniMax(M3)',
@@ -1079,6 +1080,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',
+5
View File
@@ -1006,6 +1006,7 @@ export const zhTW: typeof zhCN = {
mimo: '小米 MiMo',
cometapi: 'CometAPI',
openrouterFree: 'OpenRouter(免費模型)',
orcarouter: 'OrcaRouter',
alibabaCoding: '阿里雲 Coding Plan',
codingPlanX: 'CodingPlanX',
minimax: 'MiniMax(M3)',
@@ -1081,6 +1082,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',
@@ -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<CredentialsStatus> {
return invokeOrMock(
"get_credentials",
@@ -68,10 +99,27 @@ export function validateProviderCredentials(
}))
}
export function listProviderModels(
export async function listProviderModels(
kind: "llm" | "asr" | "omni",
channelId?: string,
providerType?: string,
): Promise<ProviderModelsResult> {
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"
+18 -2
View File
@@ -76,6 +76,14 @@ const mockChannels: Record<ChannelKind, Channel[]> = {
order: 1,
lastTest: null,
},
{
id: "orcarouter-asr",
name: "OrcaRouter-ASR",
providerType: "orcarouter",
enabled: false,
order: 2,
lastTest: null,
},
],
}
@@ -153,7 +161,11 @@ export function renameChannel(
id: string,
name: string,
): Promise<void> {
return invokeOrMock("rename_channel", { kind, id, name }, () => undefined)
return invokeOrMock("rename_channel", { kind, id, name }, () => {
const channel = mockChannels[kind].find(item => item.id === id)
if (channel) channel.name = name
return undefined
})
}
export function deleteChannel(kind: ChannelKind, id: string): Promise<void> {
@@ -171,7 +183,11 @@ export function setChannelEnabled(
return invokeOrMock(
"set_channel_enabled",
{ kind, id, enabled },
() => undefined,
() => {
const channel = mockChannels[kind].find(item => item.id === id)
if (channel) channel.enabled = enabled
return undefined
},
)
}
+4 -1
View File
@@ -1,4 +1,7 @@
export const mockCredentialValues = new Map<string, string>()
export const mockCredentialValues = new Map<string, string>([
["orcarouter-asr:asr.endpoint", "https://api.orcarouter.ai/v1"],
["orcarouter-asr:asr.model", "google/gemini-2.5-flash"],
])
import type {
ActivityDay,
CorrectionRule,
+9 -3
View File
@@ -29,15 +29,21 @@ export interface ProviderDescriptor {
export function listProviderDescriptors(kind: ProviderKind): Promise<ProviderDescriptor[]> {
return invokeOrMock('list_provider_descriptors', { kind }, () => kind === 'llm' ? [
['opencode', 'opencode', 'chat_completions'],
['orcarouter', 'orcarouter', 'chat_completions'],
['custom', 'customChatCompletions', 'chat_completions'],
['custom_responses', 'customResponses', 'responses'],
['custom_messages', 'customMessages', 'messages'],
].map(([providerType, labelKey, format]) => ({
kind, providerType, labelKey,
defaultEndpoint: providerType === 'opencode' ? 'https://opencode.ai/zen/v1' : null,
defaultModel: providerType === 'opencode' ? 'deepseek-v4-flash' : null,
defaultEndpoint: providerType === 'orcarouter' ? 'https://api.orcarouter.ai/v1' : providerType === 'opencode' ? 'https://opencode.ai/zen/v1' : null,
defaultModel: providerType === 'orcarouter' ? 'orcarouter/fusion-flash' : providerType === 'opencode' ? 'deepseek-v4-flash' : null,
authRequirement: 'api_key_unless_custom_endpoint', validationProbe: 'llm_text', staticModels: [],
defaultRequestFormat: format as LlmRequestFormat,
supportedRequestFormats: ['chat_completions', 'responses', 'messages'],
})) : [])
})) : kind === 'asr' ? [{
kind, providerType: 'orcarouter', labelKey: 'orcarouter',
defaultEndpoint: 'https://api.orcarouter.ai/v1', defaultModel: 'google/gemini-2.5-flash',
authRequirement: 'api_key', validationProbe: 'asr_silence', staticModels: [],
defaultRequestFormat: null, supportedRequestFormats: [],
}] : [])
}
@@ -1895,7 +1895,8 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
sidebarEntries.some((e) => e.id === selectedModelId)
if (stillExists) return
const firstDownloaded = sidebarEntries.find((e) => e.isDownloaded)
setSelectedModelId(firstDownloaded?.id ?? sidebarEntries[0]?.id ?? null)
const fallbackId = firstDownloaded?.id ?? sidebarEntries[0]?.id ?? null
if (fallbackId !== selectedModelId) setSelectedModelId(fallbackId)
}, [sidebarEntries, selectedModelId, downloadDialogOpen])
// 从侧栏/看板分派引擎动作。不再有 setActive——激活 = 在 ASR 语音转写里
+1
View File
@@ -50,6 +50,7 @@ const LLM_NAME_KEY_BY_ID: Record<string, string> = {
mimo: 'mimo',
cometapi: 'cometapi',
openrouterFree: 'openrouterFree',
orcarouter: 'orcarouter',
alibabaCoding: 'alibabaCoding',
codingPlanX: 'codingPlanX',
opencode: 'opencode',
@@ -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');
@@ -103,6 +103,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,
@@ -752,6 +758,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);
}
@@ -771,6 +782,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);
@@ -32,7 +32,7 @@ export function LlmProtocolFields({ channelId, defaultFormat, formats, onUserMut
formats: LlmRequestFormat[];
onUserMutation: () => void;
onBlockedChange: (account: string, blocked: boolean) => void;
onSaved?: () => void;
onSaved?: (changedAccounts: readonly Account[]) => void;
}) {
const { t } = useTranslation();
const [values, setValues] = useState<ProtocolValues>(emptyValues);
@@ -66,13 +66,14 @@ export function LlmProtocolFields({ channelId, defaultFormat, formats, onUserMut
writing.current = true;
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) { setError('save'); emitSaved('failed', t('common.operationFailed')); }
@@ -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') {
@@ -39,3 +40,53 @@ const coreAsr = presetsFor('asr', 'win', true, undefined, [{
if (coreAsr.length !== 1 || coreAsr[0].authRequirement !== 'endpoint_model_optional_api_key') {
throw new Error('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');
}
}
@@ -89,7 +89,7 @@ export const LLM_LABELS = [
['ark', 'ark'], ['deepseek', 'deepseek'], ['siliconflow', 'siliconflow'],
['atlascloud', 'atlascloud'], ['openai', 'openai'], ['gemini', 'gemini'],
['codex_oauth', 'codexOAuth'], ['mimo', 'mimo'], ['cometapi', 'cometapi'],
['openrouterFree', 'openrouterFree'], ['alibabaCoding', 'alibabaCoding'],
['openrouterFree', 'openrouterFree'], ['orcarouter', 'orcarouter'], ['alibabaCoding', 'alibabaCoding'],
['codingPlanX', 'codingPlanX'], ['minimax', 'minimax'], ['stepfun', 'stepfun'],
['opencode', 'opencode'],
['custom', 'customChatCompletions'], ['custom_responses', 'customResponses'], ['custom_messages', 'customMessages'],
@@ -137,6 +137,7 @@ export function ChannelCredentialFields({
const layoutStack = conservative || baseLayoutStack;
const [llmModelRevision, setLlmModelRevision] = useState(0);
const [configRevision, setConfigRevision] = useState(0);
const [orcarouterCatalogRevision, setOrcarouterCatalogRevision] = useState(0);
const [blockedFields, setBlockedFields] = useState<Record<string, boolean>>({});
const trackField = useCallback((account: string, blocked: boolean) => {
setBlockedFields(previous => previous[account] === blocked ? previous : { ...previous, [account]: blocked });
@@ -191,7 +192,12 @@ export function ChannelCredentialFields({
{!!descriptor.supportedRequestFormats?.length && descriptor.defaultRequestFormat && (
<LlmProtocolFields channelId={channelId} defaultFormat={descriptor.defaultRequestFormat}
formats={descriptor.supportedRequestFormats} onUserMutation={onLlmMutation}
onBlockedChange={trackField} onSaved={onTested} />
onBlockedChange={trackField} onSaved={changedAccounts => {
if (providerType === 'orcarouter' && changedAccounts.includes('ark.request_format')) {
setOrcarouterCatalogRevision(value => value + 1);
}
onTested?.();
}} />
)}
{codexOAuthSelected ? (
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6, margin: '2px 0 10px' }}>
@@ -228,6 +234,12 @@ export function ChannelCredentialFields({
)}
</>
)}
{providerType === 'orcarouter' ? (
<CatalogModelField key={`${channelId}:catalog:${orcarouterCatalogRevision}`} kind="llm" provider={channelId}
baseUrl={defaultEndpoint ?? ''} defaultModel={defaultModel ?? ''}
onUserMutation={onLlmMutation} onBlockedChange={trackField}
trailing={<LlmThinkingToggle enabled={prefs?.llmThinkingEnabled ?? false} onToggle={onLlmThinkingToggle} />} />
) : (
<CredentialField key={`${channelId}:model:${llmModelRevision}`} label={t('settings.providers.modelLabel')}
account="ark.model_id" provider={channelId}
placeholder={defaultModel || 'model-name'} mono
@@ -240,9 +252,10 @@ export function ChannelCredentialFields({
/>
)}
/>
)}
<ProviderTools key={configRevision} disabled={Object.values(blockedFields).some(Boolean) || (!!descriptor.supportedRequestFormats?.length && blockedFields.protocol === undefined)} kind="llm" modelAccount="ark.model_id" provider={channelId}
onModelSelected={() => setLlmModelRevision(v => v + 1)} onTested={onTested}
onUserMutation={onUserMutation} />
onUserMutation={onUserMutation} showFetchModels={providerType !== 'orcarouter'} />
</>
);
}
@@ -339,6 +352,29 @@ export function ChannelCredentialFields({
);
}
if (providerType === 'orcarouter') {
return (
<>
<CredentialField key={`${channelId}:api_key`} label={t('settings.providers.apiKeyLabel')}
account="asr.api_key" provider={channelId} mono mask onUserMutation={onUserMutation} />
<CredentialField key={`${channelId}:endpoint`} label={t('settings.providers.baseUrlLabel')}
account="asr.endpoint" provider={channelId}
placeholder={defaultEndpoint ?? undefined} defaultValue={defaultEndpoint ?? undefined}
onUserMutation={onUserMutation} />
<CatalogModelField
kind="asr"
provider={channelId}
baseUrl={defaultEndpoint ?? ''}
defaultModel={defaultModel ?? ''}
onUserMutation={onUserMutation}
/>
<ProviderTools kind="asr" modelAccount="asr.model" provider={channelId}
onModelSelected={() => setAsrModelRevision(v => v + 1)} onTested={onTested}
onUserMutation={onUserMutation} showFetchModels={false} />
</>
);
}
return (
<>
<CredentialField key={`${channelId}:api_key`} label={t('settings.providers.apiKeyLabel')}
@@ -590,6 +626,178 @@ function BailianProtocolHint({ currentModel }: { currentModel: string }) {
type ProviderToolStatus = 'idle' | 'loading' | 'success' | 'empty' | 'error';
/**
* OrcaRouter exposes a large, changing catalog. Keep its own router models at
* the top, then sort the remaining vendor/model ids for predictable scanning.
*/
export function prioritizeOrcaRouterModels(models: string[]): string[] {
return [...models].sort((left, right) => {
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<string[]>([]);
const [selectedModel, setSelectedModel] = useState('');
const [status, setStatus] = useState<ProviderToolStatus>('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 (
<SettingRow label={t('settings.providers.modelLabel')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, width: '100%', maxWidth: layoutStack ? '100%' : 420 }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', width: '100%', flexWrap: layoutStack ? 'wrap' : 'nowrap' }}>
<SelectLite
value={selectedModel}
onChange={model => 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)',
}}
/>
<button
onClick={() => {
onUserMutation?.();
void loadModels(false);
}}
title={t('common.refresh')}
aria-label={t('common.refresh')}
style={iconBtnStyle}
disabled={status === 'loading'}
>
<Icon name="refresh" size={13} />
</button>
{trailing}
</div>
<span style={{ fontSize: 11, color: status === 'error' ? 'var(--ol-warn)' : status === 'success' ? 'var(--ol-ok)' : 'var(--ol-ink-4)', lineHeight: 1.4 }}>
{message}
</span>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', lineHeight: 1.4 }}>
{t(kind === 'asr'
? 'settings.providers.orcarouterAsrCatalogHint'
: 'settings.providers.orcarouterCatalogHint')}
</span>
</div>
</SettingRow>
);
}
function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested, onUserMutation, showFetchModels = true, disabled = false }: { disabled?: boolean; kind: 'llm' | 'asr' | 'omni'; modelAccount: string; provider?: string; onModelSelected: () => void; onTested?: () => void; onUserMutation?: () => void; showFetchModels?: boolean }) {
const { t } = useTranslation();
const baseLayoutStack = useLayoutStack();
@@ -223,6 +223,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' },
+9
View File
@@ -31,6 +31,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: {