From 92ca95ffcd977ec8862d693f0d8ef8436a98a8a5 Mon Sep 17 00:00:00 2001 From: Allen Xu Date: Tue, 4 Aug 2026 09:43:20 +0800 Subject: [PATCH] feat(opencode): load OMO models from runtime opencode models (#5522) * feat(opencode): load OMO models from runtime opencode models Surface OAuth/Zen free models in OMO/OMO Slim pickers by running the installed OpenCode CLI, with a 20s timeout and a toast when discovery fails. * style: fix rustfmt and prettier for OMO runtime models * fix(opencode): pass OPENCODE_CONFIG_DIR for runtime model discovery Honor the configured OpenCode config directory when running `opencode models`, including WSL UNC path translation. Also drop the unused run_detected_tool_command wrapper that failed clippy. * fix(opencode): satisfy Windows Clippy * fix(opencode): bound runtime model discovery * fix(opencode): address runtime model review feedback * fix(ci): parenthesize unsafe kill expression --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 3 + src-tauri/src/commands/misc.rs | 587 +++++++++++++++++- src-tauri/src/commands/model_fetch.rs | 115 ++++ src-tauri/src/lib.rs | 1 + .../forms/hooks/useOmoModelSource.ts | 225 ++++--- src/i18n/locales/en.json | 1 + src/i18n/locales/ja.json | 1 + src/i18n/locales/zh-TW.json | 1 + src/i18n/locales/zh.json | 1 + src/lib/api/model-fetch.ts | 10 + src/lib/query/mutations.ts | 6 + 12 files changed, 843 insertions(+), 109 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3ef9f1f70..5cb0bb568 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -783,6 +783,7 @@ dependencies = [ "indexmap 2.13.0", "json-five", "json5", + "libc", "log", "objc2 0.5.2", "objc2-app-kit 0.2.2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ff69824cf..ece19cbb5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -89,6 +89,9 @@ tauri-plugin-single-instance = "2" [target.'cfg(target_os = "linux")'.dependencies] webkit2gtk = { version = "2.0.1", features = ["v2_16"] } +[target.'cfg(not(target_os = "windows"))'.dependencies] +libc = "0.2" + [target.'cfg(target_os = "windows")'.dependencies] winreg = "0.52" windows-sys = { version = "0.61", features = [ diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index 0cf46e426..d51b1883f 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -276,7 +276,7 @@ fn last_lines(text: &str, n: usize) -> String { lines[start..].join("\n") } -fn decode_command_output(bytes: &[u8]) -> String { +pub(crate) fn decode_command_output(bytes: &[u8]) -> String { #[cfg(target_os = "windows")] { decode_windows_command_output(bytes) @@ -1701,15 +1701,29 @@ fn windows_runnable_sibling_for_extensionless_tool(path: &Path) -> Option std::io::Result { use std::process::Command; if is_windows_command_script(tool_path) { let path = tool_path.to_string_lossy(); - let command = format!("call {} --version", win_quote_path_for_batch(&path)); + let args = args + .iter() + .map(|arg| windows_cmd_double_quote_arg(arg)) + .collect::>() + .join(" "); + let command = format!( + "call {}{}", + win_quote_path_for_batch(&path), + if args.is_empty() { + String::new() + } else { + format!(" {args}") + } + ); let mut cmd = Command::new("cmd"); return cmd .args(["/D", "/S", "/C"]) @@ -1720,12 +1734,20 @@ fn run_windows_tool_version_command( } Command::new(tool_path) - .arg("--version") + .args(args) .env("PATH", new_path) .creation_flags(CREATE_NO_WINDOW) .output() } +#[cfg(target_os = "windows")] +fn run_windows_tool_version_command( + tool_path: &Path, + new_path: &str, +) -> std::io::Result { + run_windows_tool_command(tool_path, &["--version"], new_path) +} + /// 扫描常见路径查找 CLI(PATH 主命令未命中时的兜底单探)。 fn scan_cli_version(tool: &str) -> ShellProbe { #[cfg(not(target_os = "windows"))] @@ -1930,49 +1952,69 @@ fn login_shell_path() -> Option { /// 用与 `try_get_version` 相同的登录 shell 解析 PATH 默认命中的可执行文件路径, /// canonicalize 后作为"命令行默认 / 升级目标"的锚点(与升级会作用的那处对齐)。 #[cfg(not(target_os = "windows"))] -fn resolve_path_default(tool: &str) -> Option { - use std::process::Command; +fn resolve_path_default( + tool: &str, + deadline: Option, +) -> Result, String> { + use std::process::{Command, Stdio}; + let shell = std::env::var("SHELL") .ok() .filter(|s| is_valid_shell(s)) .unwrap_or_else(|| "sh".to_string()); let flag = default_flag_for_shell(&shell); - let out = Command::new(shell) - .arg(flag) + let mut cmd = Command::new(shell); + cmd.arg(flag) .arg(format!("command -v {tool}")) - .output() - .ok()?; + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + isolate_child_process_group(&mut cmd); + let child = cmd + .spawn() + .map_err(|e| format!("Failed to locate {tool}: {e}"))?; + let out = wait_child_output(child, deadline)?; if !out.status.success() { - return None; + return Ok(None); } let raw = decode_command_output(&out.stdout); // 不能死取第一行:交互式 .zshrc 可能先打印欢迎语(如 "🚀 Welcome back"), // command -v 的真实路径在其后;取第一个 `/` 开头的行才稳。 - let first = first_abs_path_line(&raw)?; - std::fs::canonicalize(first).ok() + let Some(first) = first_abs_path_line(&raw) else { + return Ok(None); + }; + Ok(std::fs::canonicalize(first).ok()) } #[cfg(target_os = "windows")] -fn resolve_path_default(tool: &str) -> Option { +fn resolve_path_default( + tool: &str, + deadline: Option, +) -> Result, String> { use std::os::windows::process::CommandExt; - use std::process::Command; - let out = Command::new("cmd") + use std::process::{Command, Stdio}; + + let child = Command::new("cmd") .args(["/C", &format!("where {tool}")]) .creation_flags(CREATE_NO_WINDOW) - .output() - .ok()?; + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("Failed to locate {tool}: {e}"))?; + let out = wait_child_output(child, deadline)?; if !out.status.success() { - return None; + return Ok(None); } let raw = decode_command_output(&out.stdout); - let first = raw.lines().next()?.trim(); + let Some(first) = raw.lines().next().map(str::trim) else { + return Ok(None); + }; if first.is_empty() { - return None; + return Ok(None); } let path = Path::new(first); let preferred = windows_runnable_sibling_for_extensionless_tool(path).unwrap_or_else(|| path.to_path_buf()); - std::fs::canonicalize(preferred).ok() + Ok(std::fs::canonicalize(preferred).ok()) } /// 枚举工具在系统中的所有安装(不短路)。与 `scan_cli_version` 共用 @@ -1986,7 +2028,7 @@ fn enumerate_tool_installations(tool: &str) -> Vec { let current_path = std::env::var_os("PATH") .map(|value| value.to_string_lossy().into_owned()) .unwrap_or_default(); - let path_default = resolve_path_default(tool); + let path_default = resolve_path_default(tool, None).ok().flatten(); let mut seen: std::collections::HashSet = std::collections::HashSet::new(); let mut installs: Vec = Vec::new(); @@ -2602,6 +2644,468 @@ fn default_install(installs: &[ToolInstallation]) -> Option<&ToolInstallation> { }) } +fn locate_default_tool( + tool: &str, + deadline: Option, +) -> Result { + let path_default = resolve_path_default(tool, deadline)?; + + let mut seen = std::collections::HashSet::new(); + let mut candidates = Vec::new(); + for dir in build_tool_search_paths(tool) { + for candidate in tool_executable_candidates(tool, &dir) { + if !candidate.exists() { + continue; + } + let real = std::fs::canonicalize(&candidate).unwrap_or_else(|_| candidate.clone()); + if path_default.as_ref() == Some(&real) { + return Ok(candidate); + } + if seen.insert(real) { + candidates.push(candidate); + } + } + } + + if let Some(path) = path_default { + return Ok(path); + } + + match candidates.as_slice() { + [only] => Ok(only.clone()), + [] => Err(format!("{tool} is not installed")), + _ => Err(format!( + "{tool} is installed but its default installation is ambiguous" + )), + } +} + +#[derive(Clone, Copy)] +struct CommandDeadline { + expires_at: std::time::Instant, + limit: std::time::Duration, +} + +impl CommandDeadline { + fn from_timeout(timeout: Option) -> Option { + timeout.map(|limit| Self { + expires_at: std::time::Instant::now() + limit, + limit, + }) + } + + fn remaining(self) -> Result { + self.expires_at + .checked_duration_since(std::time::Instant::now()) + .filter(|remaining| !remaining.is_zero()) + .ok_or_else(|| self.timeout_error()) + } + + fn timeout_error(self) -> String { + format!("Command timed out after {}s", self.limit.as_secs()) + } +} + +#[cfg(target_os = "windows")] +fn terminate_child_tree(child: &mut std::process::Child) -> bool { + use std::os::windows::process::CommandExt; + use std::process::{Command, Stdio}; + + let status = Command::new("taskkill") + .args(["/PID", &child.id().to_string(), "/T", "/F"]) + .creation_flags(CREATE_NO_WINDOW) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + matches!(status, Ok(status) if status.success()) || child.kill().is_ok() +} + +#[cfg(not(target_os = "windows"))] +fn terminate_child_tree(child: &mut std::process::Child) -> bool { + let process_group = -(child.id() as libc::pid_t); + // SAFETY: runtime commands are placed in a dedicated process group before spawn. + (unsafe { libc::kill(process_group, libc::SIGKILL) == 0 }) || child.kill().is_ok() +} + +#[cfg(not(target_os = "windows"))] +fn isolate_child_process_group(cmd: &mut std::process::Command) { + use std::os::unix::process::CommandExt; + + cmd.process_group(0); +} + +fn wait_child_output( + mut child: std::process::Child, + deadline: Option, +) -> Result { + use std::io::Read; + + let stdout_pipe = child.stdout.take(); + let stderr_pipe = child.stderr.take(); + + let stdout_handle = stdout_pipe.map(|mut pipe| { + std::thread::spawn(move || { + let mut buf = Vec::new(); + let _ = pipe.read_to_end(&mut buf); + buf + }) + }); + let stderr_handle = stderr_pipe.map(|mut pipe| { + std::thread::spawn(move || { + let mut buf = Vec::new(); + let _ = pipe.read_to_end(&mut buf); + buf + }) + }); + + let status = match deadline { + None => child + .wait() + .map_err(|e| format!("Failed to wait for command: {e}"))?, + Some(deadline) => { + loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) => { + let remaining = match deadline.remaining() { + Ok(remaining) => remaining, + Err(error) => { + if terminate_child_tree(&mut child) { + let _ = child.wait(); + } + // Do not join pipe readers on timeout. If tree termination fails, + // a descendant may still own the write handle and never produce EOF. + drop(stdout_handle); + drop(stderr_handle); + return Err(error); + } + }; + std::thread::sleep(std::cmp::min( + std::time::Duration::from_millis(50), + remaining, + )); + } + Err(e) => { + if terminate_child_tree(&mut child) { + let _ = child.wait(); + } + return Err(format!("Failed to wait for command: {e}")); + } + } + } + } + }; + + if let Some(deadline) = deadline { + while stdout_handle + .as_ref() + .is_some_and(|handle| !handle.is_finished()) + || stderr_handle + .as_ref() + .is_some_and(|handle| !handle.is_finished()) + { + let remaining = match deadline.remaining() { + Ok(remaining) => remaining, + Err(error) => { + let _ = terminate_child_tree(&mut child); + drop(stdout_handle); + drop(stderr_handle); + return Err(error); + } + }; + std::thread::sleep(std::cmp::min( + std::time::Duration::from_millis(50), + remaining, + )); + } + } + + let stdout = stdout_handle + .map(|handle| handle.join().unwrap_or_default()) + .unwrap_or_default(); + let stderr = stderr_handle + .map(|handle| handle.join().unwrap_or_default()) + .unwrap_or_default(); + + Ok(std::process::Output { + status, + stdout, + stderr, + }) +} + +fn apply_extra_env(cmd: &mut std::process::Command, extra_env: &[(&str, String)]) { + for (key, value) in extra_env { + cmd.env(key, value); + } +} + +pub(crate) fn run_detected_tool_command_with_timeout( + tool: &str, + args: &[&str], + timeout: Option, + extra_env: &[(&str, String)], + working_dir: &Path, +) -> Result { + if !VALID_TOOLS.contains(&tool) { + return Err(format!("Unsupported tool: {tool}")); + } + if args.iter().any(|arg| { + arg.is_empty() + || !arg + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) + }) { + return Err("Invalid tool command arguments".to_string()); + } + + let deadline = CommandDeadline::from_timeout(timeout); + + #[cfg(target_os = "windows")] + if let Some(distro) = wsl_distro_for_tool(tool) { + return run_wsl_tool_command(tool, args, &distro, deadline, extra_env, working_dir); + } + + // Runtime execution only needs the default entry point. Full installation + // enumeration runs `--version` for every candidate and belongs to diagnostics. + let tool_path = locate_default_tool(tool, deadline)?; + let dir = tool_path + .parent() + .ok_or_else(|| format!("Invalid {tool} executable path"))?; + let current_path = std::env::var_os("PATH") + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_default(); + + #[cfg(target_os = "windows")] + { + run_windows_tool_command_capture( + &tool_path, + args, + &format!("{};{current_path}", dir.display()), + deadline, + extra_env, + working_dir, + ) + } + + #[cfg(not(target_os = "windows"))] + { + use std::process::{Command, Stdio}; + + let mut cmd = Command::new(&tool_path); + cmd.args(args) + .env("PATH", format!("{}:{current_path}", dir.display())) + .current_dir(working_dir) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + apply_extra_env(&mut cmd, extra_env); + isolate_child_process_group(&mut cmd); + let child = cmd + .spawn() + .map_err(|e| format!("Failed to run {tool}: {e}"))?; + wait_child_output(child, deadline) + } +} + +#[cfg(target_os = "windows")] +fn run_windows_tool_command_capture( + tool_path: &Path, + args: &[&str], + new_path: &str, + deadline: Option, + extra_env: &[(&str, String)], + working_dir: &Path, +) -> Result { + use std::process::{Command, Stdio}; + + let mut cmd = if is_windows_command_script(tool_path) { + let path = tool_path.to_string_lossy(); + let args = args + .iter() + .map(|arg| windows_cmd_double_quote_arg(arg)) + .collect::>() + .join(" "); + let command = format!( + "call {}{}", + win_quote_path_for_batch(&path), + if args.is_empty() { + String::new() + } else { + format!(" {args}") + } + ); + let mut cmd = Command::new("cmd"); + cmd.args(["/D", "/S", "/C"]) + .raw_arg(&command) + .env("PATH", new_path) + .creation_flags(CREATE_NO_WINDOW); + cmd + } else { + let mut cmd = Command::new(tool_path); + cmd.args(args) + .env("PATH", new_path) + .creation_flags(CREATE_NO_WINDOW); + cmd + }; + + apply_extra_env(&mut cmd, extra_env); + cmd.current_dir(working_dir) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let child = cmd + .spawn() + .map_err(|e| format!("Failed to run tool: {e}"))?; + wait_child_output(child, deadline) +} + +/// Convert `\\wsl$\Distro\home\user\...` / `\\wsl.localhost\...` to a Linux path. +#[cfg(target_os = "windows")] +fn wsl_unc_path_to_linux(path: &Path) -> Option { + use std::path::{Component, Prefix}; + + let mut components = path.components(); + let Component::Prefix(prefix) = components.next()? else { + return None; + }; + match prefix.kind() { + Prefix::UNC(server, _share) | Prefix::VerbatimUNC(server, _share) => { + let server_name = server.to_string_lossy(); + if !(server_name.eq_ignore_ascii_case("wsl$") + || server_name.eq_ignore_ascii_case("wsl.localhost")) + { + return None; + } + } + _ => return None, + } + + let mut linux = String::new(); + for component in components { + match component { + Component::RootDir => {} + Component::Normal(part) => { + linux.push('/'); + linux.push_str(&part.to_string_lossy()); + } + Component::CurDir | Component::ParentDir | Component::Prefix(_) => return None, + } + } + if linux.is_empty() { + None + } else { + Some(linux) + } +} + +#[cfg(target_os = "windows")] +fn build_wsl_env_argv(extra_env: &[(&str, String)]) -> Result, String> { + let mut env_argv = Vec::new(); + for (key, value) in extra_env { + if key.is_empty() + || key.contains('=') + || key.chars().any(|c| c.is_whitespace() || c.is_control()) + { + return Err(format!("invalid env for {key}")); + } + + let linux_value = if *key == "OPENCODE_CONFIG_DIR" { + let Some(value) = wsl_unc_path_to_linux(Path::new(value)) else { + continue; + }; + value + } else { + value.clone() + }; + if linux_value.chars().any(char::is_control) { + return Err(format!("invalid env for {key}")); + } + env_argv.push(format!("{key}={linux_value}")); + } + Ok(env_argv) +} + +#[cfg(target_os = "windows")] +fn build_wsl_tool_command( + tool: &str, + args: &[&str], + deadline: Option, +) -> Result { + let invocation = std::iter::once(tool) + .chain(args.iter().copied()) + .collect::>() + .join(" "); + let command = format!( + "for flag in -lic -lc -c; do if \"${{SHELL:-sh}}\" \"$flag\" 'command -v {tool}' >/dev/null 2>&1; then exec \"${{SHELL:-sh}}\" \"$flag\" '{invocation}'; fi; done; exit 127" + ); + + let Some(deadline) = deadline else { + return Ok(command); + }; + let remaining = deadline.remaining()?; + let timeout_arg = format!("{:.3}s", remaining.as_secs_f64()); + Ok(format!( + "command -v timeout >/dev/null 2>&1 || {{ echo 'timeout is required for bounded CLI execution' >&2; exit 127; }}; exec timeout --signal=TERM --kill-after=1s {timeout_arg} sh -c {}", + shell_single_quote(&command) + )) +} + +#[cfg(target_os = "windows")] +fn run_wsl_tool_command( + tool: &str, + args: &[&str], + distro: &str, + deadline: Option, + extra_env: &[(&str, String)], + working_dir: &Path, +) -> Result { + use std::process::{Command, Stdio}; + + if !is_valid_wsl_distro_name(distro) { + return Err(format!("[WSL:{distro}] invalid distro name")); + } + + let command = build_wsl_tool_command(tool, args, deadline)?; + let linux_working_dir = wsl_unc_path_to_linux(working_dir) + .ok_or_else(|| format!("[WSL:{distro}] invalid working directory"))?; + let env_argv = build_wsl_env_argv(extra_env).map_err(|e| format!("[WSL:{distro}] {e}"))?; + + let mut cmd = Command::new("wsl.exe"); + cmd.arg("-d") + .arg(distro) + .arg("--cd") + .arg(linux_working_dir) + .arg("--"); + if !env_argv.is_empty() { + cmd.arg("env"); + for item in &env_argv { + cmd.arg(item); + } + } + cmd.args(["sh", "-c", &command]) + .creation_flags(CREATE_NO_WINDOW) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let child = cmd + .spawn() + .map_err(|e| format!("[WSL:{distro}] failed to run {tool}: {e}"))?; + let output = wait_child_output(child, deadline).map_err(|e| { + if e.starts_with("Command timed out") { + format!("[WSL:{distro}] {e}") + } else { + e + } + })?; + if output.status.code() == Some(124) { + return Err(format!( + "[WSL:{distro}] {}", + deadline + .map(CommandDeadline::timeout_error) + .unwrap_or_else(|| "Command timed out".to_string()) + )); + } + Ok(output) +} + /// 基于已枚举的安装列表生成锚定升级命令(复用 enumerate 结果,避免二次探测)。 /// 读取 enumerate 时已 canonicalize 写入的 `inst.real`,**不再二次 canonicalize**—— /// 既消除冗余 syscall,也闭合"enumerate 与 anchor 看到同一真身"的一致性边界 @@ -3766,6 +4270,43 @@ mod tests { use super::*; use std::path::{Path, PathBuf}; + #[cfg(target_os = "windows")] + #[test] + fn wsl_env_allows_spaces_in_unc_config_path() { + let extra_env = [ + ( + "OPENCODE_CONFIG_DIR", + r"\\wsl$\Ubuntu\home\Jane Doe\.config\opencode".to_string(), + ), + ("OPENCODE_DISABLE_PROJECT_CONFIG", "true".to_string()), + ]; + + assert_eq!( + build_wsl_env_argv(&extra_env).unwrap(), + vec![ + "OPENCODE_CONFIG_DIR=/home/Jane Doe/.config/opencode".to_string(), + "OPENCODE_DISABLE_PROJECT_CONFIG=true".to_string(), + ] + ); + } + + #[cfg(target_os = "windows")] + #[test] + fn wsl_env_skips_host_config_path() { + let extra_env = [ + ( + "OPENCODE_CONFIG_DIR", + r"C:\Users\Jane Doe\.config\opencode".to_string(), + ), + ("OPENCODE_DISABLE_PROJECT_CONFIG", "true".to_string()), + ]; + + assert_eq!( + build_wsl_env_argv(&extra_env).unwrap(), + vec!["OPENCODE_DISABLE_PROJECT_CONFIG=true".to_string()] + ); + } + #[cfg(unix)] fn set_test_executable(path: &Path, executable: bool) { use std::os::unix::fs::PermissionsExt; diff --git a/src-tauri/src/commands/model_fetch.rs b/src-tauri/src/commands/model_fetch.rs index c211bd651..a4a114162 100644 --- a/src-tauri/src/commands/model_fetch.rs +++ b/src-tauri/src/commands/model_fetch.rs @@ -3,6 +3,89 @@ //! 提供 Tauri 命令,供前端在供应商表单中获取可用模型列表。 use crate::services::model_fetch::{self, FetchedModel}; +use serde::Serialize; +use std::collections::BTreeSet; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenCodeModelRef { + pub provider_id: String, + pub model_id: String, +} + +const OPENCODE_MODELS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20); + +/// 获取 OpenCode 当前运行时可用的模型。 +/// +/// 复用工具更新页的 CLI 定位逻辑执行 `opencode models`,因此会包含 OpenCode +/// 已加载的 OAuth 模型与 Zen 免费模型,而不是只读取 opencode.json。 +#[tauri::command] +pub async fn get_opencode_models() -> Result, String> { + tokio::task::spawn_blocking(|| { + // Align runtime discovery with the OpenCode config directory that + // cc-switch already uses for live read/write (settings override included). + let config_dir = crate::opencode_config::get_opencode_dir(); + let config_dir_env = config_dir.to_string_lossy().into_owned(); + let extra_env = [ + ("OPENCODE_CONFIG_DIR", config_dir_env), + ("OPENCODE_DISABLE_PROJECT_CONFIG", "true".to_string()), + ]; + let output = super::misc::run_detected_tool_command_with_timeout( + "opencode", + &["models"], + Some(OPENCODE_MODELS_TIMEOUT), + &extra_env, + &config_dir, + )?; + if !output.status.success() { + let stderr = super::misc::decode_command_output(&output.stderr); + let stdout = super::misc::decode_command_output(&output.stdout); + let detail = if stderr.trim().is_empty() { + stdout.trim() + } else { + stderr.trim() + }; + return Err(if detail.is_empty() { + "Failed to load OpenCode models".to_string() + } else { + format!("Failed to load OpenCode models: {detail}") + }); + } + + Ok(parse_opencode_models(&super::misc::decode_command_output( + &output.stdout, + ))) + }) + .await + .map_err(|e| format!("OpenCode model discovery task failed: {e}"))? +} + +fn parse_opencode_models(output: &str) -> Vec { + output + .lines() + .filter_map(|line| { + let (provider_id, model_id) = line.trim().split_once('/')?; + if provider_id.is_empty() + || model_id.is_empty() + || !provider_id + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) + || model_id + .chars() + .any(|c| c.is_whitespace() || c.is_control()) + { + return None; + } + Some((provider_id.to_string(), model_id.to_string())) + }) + .collect::>() + .into_iter() + .map(|(provider_id, model_id)| OpenCodeModelRef { + provider_id, + model_id, + }) + .collect() +} /// 获取供应商的可用模型列表 /// @@ -29,3 +112,35 @@ pub async fn fetch_models_for_config( ) .await } + +#[cfg(test)] +mod tests { + use super::{parse_opencode_models, OpenCodeModelRef}; + + #[test] + fn parses_sorts_and_deduplicates_models() { + assert_eq!( + parse_opencode_models( + "openrouter/vendor/model\nopencode/free-model\ninvalid\nopencode/free-model\n" + ), + vec![ + OpenCodeModelRef { + provider_id: "opencode".to_string(), + model_id: "free-model".to_string(), + }, + OpenCodeModelRef { + provider_id: "openrouter".to_string(), + model_id: "vendor/model".to_string(), + }, + ] + ); + } + + #[test] + fn skips_malformed_output_lines() { + assert!(parse_opencode_models( + "notice: loading models\n/model\nprovider/\nbad provider/model\nprovider/bad model\nprovider/bad\u{1b}[0m\n" + ) + .is_empty()); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9e8d0c99e..1c630e84b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1417,6 +1417,7 @@ pub fn run() { commands::apply_profile, // model list fetch (OpenAI-compatible /v1/models) commands::fetch_models_for_config, + commands::get_opencode_models, // ours: endpoint speed test + custom endpoint management commands::test_api_endpoints, commands::get_custom_endpoints, diff --git a/src/components/providers/forms/hooks/useOmoModelSource.ts b/src/components/providers/forms/hooks/useOmoModelSource.ts index 580a60b77..d7954b4e2 100644 --- a/src/components/providers/forms/hooks/useOmoModelSource.ts +++ b/src/components/providers/forms/hooks/useOmoModelSource.ts @@ -1,12 +1,17 @@ +import { useQuery } from "@tanstack/react-query"; import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { providersApi } from "@/lib/api"; +import { getOpenCodeModels } from "@/lib/api/model-fetch"; import { useProvidersQuery } from "@/lib/query/queries"; import type { OpenCodeProviderConfig } from "@/types"; import { OPENCODE_PRESET_MODEL_VARIANTS } from "@/config/opencodeProviderPresets"; import { parseOpencodeConfigStrict } from "../helpers/opencodeFormUtils"; +const EMPTY_DISCOVERED_MODELS: Awaited> = + []; + interface UseOmoModelSourceParams { isOmoCategory: boolean; providerId?: string; @@ -45,6 +50,19 @@ export function useOmoModelSource({ }: UseOmoModelSourceParams): OmoModelSourceResult { const { t } = useTranslation(); + const { + data: discoveredModels = EMPTY_DISCOVERED_MODELS, + isError: runtimeModelsFailed, + error: runtimeModelsError, + } = useQuery({ + queryKey: ["opencode", "runtime-models"], + queryFn: getOpenCodeModels, + enabled: isOmoCategory, + staleTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + retry: 1, + }); + const { data: opencodeProvidersData } = useProvidersQuery("opencode"); const existingOpencodeKeys = useMemo(() => { if (!opencodeProvidersData?.providers) return []; @@ -58,6 +76,7 @@ export function useOmoModelSource({ >(null); const [omoLiveIdsLoadFailed, setOmoLiveIdsLoadFailed] = useState(false); const lastOmoModelSourceWarningRef = useRef(""); + const lastRuntimeModelsWarningRef = useRef(""); useEffect(() => { let active = true; @@ -107,20 +126,6 @@ export function useOmoModelSource({ return empty; } - const allProviders = opencodeProvidersData?.providers; - if (!allProviders) { - return empty; - } - - const shouldFilterByLive = !omoLiveIdsLoadFailed; - if (shouldFilterByLive && enabledOpencodeProviderIds === null) { - return empty; - } - const liveSet = - shouldFilterByLive && enabledOpencodeProviderIds - ? new Set(enabledOpencodeProviderIds) - : null; - const dedupedOptions = new Map(); const variantsMap: Record = {}; const presetMetaMap: Record< @@ -132,91 +137,112 @@ export function useOmoModelSource({ > = {}; const parseFailedProviders: string[] = []; - for (const [providerKey, provider] of Object.entries(allProviders)) { - if (provider.category === "omo" || provider.category === "omo-slim") { - continue; - } - if (liveSet && !liveSet.has(providerKey)) { - continue; - } + const allProviders = opencodeProvidersData?.providers; + const liveReady = + omoLiveIdsLoadFailed || enabledOpencodeProviderIds !== null; - let parsedConfig: OpenCodeProviderConfig; - try { - parsedConfig = parseOpencodeConfigStrict(provider.settingsConfig); - } catch (error) { - parseFailedProviders.push(providerKey); - console.warn( - "[OMO_MODEL_SOURCE_PARSE_FAILED] failed to parse provider settings", - { - providerKey, - error, - }, - ); - continue; - } - for (const [modelId, model] of Object.entries( - parsedConfig.models || {}, - )) { - const modelName = - typeof model.name === "string" && model.name.trim() - ? model.name - : modelId; - const providerDisplayName = - typeof provider.name === "string" && provider.name.trim() - ? provider.name - : providerKey; - const value = `${providerKey}/${modelId}`; - const label = `${providerDisplayName} / ${modelName} (${modelId})`; - if (!dedupedOptions.has(value)) { - dedupedOptions.set(value, label); + // Configured providers are filtered by live ids when available. + // Runtime models are merged regardless, so OAuth/Zen entries still show + // while live/provider queries are in flight. + if (allProviders && liveReady) { + const liveSet = + !omoLiveIdsLoadFailed && enabledOpencodeProviderIds + ? new Set(enabledOpencodeProviderIds) + : null; + + for (const [providerKey, provider] of Object.entries(allProviders)) { + if (provider.category === "omo" || provider.category === "omo-slim") { + continue; + } + if (liveSet && !liveSet.has(providerKey)) { + continue; } - const rawVariants = model.variants; - if ( - rawVariants && - typeof rawVariants === "object" && - !Array.isArray(rawVariants) - ) { - const variantKeys = Object.keys(rawVariants).filter(Boolean); - if (variantKeys.length > 0) { - variantsMap[value] = variantKeys; + let parsedConfig: OpenCodeProviderConfig; + try { + parsedConfig = parseOpencodeConfigStrict(provider.settingsConfig); + } catch (error) { + parseFailedProviders.push(providerKey); + console.warn( + "[OMO_MODEL_SOURCE_PARSE_FAILED] failed to parse provider settings", + { + providerKey, + error, + }, + ); + continue; + } + for (const [modelId, model] of Object.entries( + parsedConfig.models || {}, + )) { + const modelName = + typeof model.name === "string" && model.name.trim() + ? model.name + : modelId; + const providerDisplayName = + typeof provider.name === "string" && provider.name.trim() + ? provider.name + : providerKey; + const value = `${providerKey}/${modelId}`; + const label = `${providerDisplayName} / ${modelName} (${modelId})`; + if (!dedupedOptions.has(value)) { + dedupedOptions.set(value, label); } - } - } - // Preset fallback: for models without config-defined variants, - // check if the npm package has preset variant definitions. - // Also collect preset metadata (options, limit) for enrichment. - const presetModels = OPENCODE_PRESET_MODEL_VARIANTS[parsedConfig.npm]; - if (presetModels) { - for (const modelId of Object.keys(parsedConfig.models || {})) { - const fullKey = `${providerKey}/${modelId}`; - const preset = presetModels.find((p) => p.id === modelId); - if (!preset) continue; - - // Variant fallback - if (!variantsMap[fullKey] && preset.variants) { - const presetKeys = Object.keys(preset.variants).filter(Boolean); - if (presetKeys.length > 0) { - variantsMap[fullKey] = presetKeys; + const rawVariants = model.variants; + if ( + rawVariants && + typeof rawVariants === "object" && + !Array.isArray(rawVariants) + ) { + const variantKeys = Object.keys(rawVariants).filter(Boolean); + if (variantKeys.length > 0) { + variantsMap[value] = variantKeys; } } + } - // Collect preset metadata for model enrichment - const meta: (typeof presetMetaMap)[string] = {}; - if (preset.options) meta.options = preset.options; - if (preset.contextLimit || preset.outputLimit) { - meta.limit = {}; - if (preset.contextLimit) meta.limit.context = preset.contextLimit; - if (preset.outputLimit) meta.limit.output = preset.outputLimit; - } - if (Object.keys(meta).length > 0) { - presetMetaMap[fullKey] = meta; + // Preset fallback: for models without config-defined variants, + // check if the npm package has preset variant definitions. + // Also collect preset metadata (options, limit) for enrichment. + const presetModels = OPENCODE_PRESET_MODEL_VARIANTS[parsedConfig.npm]; + if (presetModels) { + for (const modelId of Object.keys(parsedConfig.models || {})) { + const fullKey = `${providerKey}/${modelId}`; + const preset = presetModels.find((p) => p.id === modelId); + if (!preset) continue; + + // Variant fallback + if (!variantsMap[fullKey] && preset.variants) { + const presetKeys = Object.keys(preset.variants).filter(Boolean); + if (presetKeys.length > 0) { + variantsMap[fullKey] = presetKeys; + } + } + + // Collect preset metadata for model enrichment + const meta: (typeof presetMetaMap)[string] = {}; + if (preset.options) meta.options = preset.options; + if (preset.contextLimit || preset.outputLimit) { + meta.limit = {}; + if (preset.contextLimit) meta.limit.context = preset.contextLimit; + if (preset.outputLimit) meta.limit.output = preset.outputLimit; + } + if (Object.keys(meta).length > 0) { + presetMetaMap[fullKey] = meta; + } } } } } + for (const model of discoveredModels) { + const value = `${model.providerId}/${model.modelId}`; + if (!dedupedOptions.has(value)) { + dedupedOptions.set(value, `${model.providerId} / ${model.modelId}`); + } + } + return { options: Array.from(dedupedOptions.entries()) .map(([value, label]) => ({ value, label })) @@ -231,6 +257,7 @@ export function useOmoModelSource({ opencodeProvidersData?.providers, enabledOpencodeProviderIds, omoLiveIdsLoadFailed, + discoveredModels, ]); // Warning toast for parse failures / fallback @@ -271,6 +298,32 @@ export function useOmoModelSource({ t, ]); + // Warning toast when OpenCode runtime model discovery fails + useEffect(() => { + if (!isOmoCategory || !runtimeModelsFailed) { + if (!isOmoCategory) { + lastRuntimeModelsWarningRef.current = ""; + } + return; + } + + const detail = String( + (runtimeModelsError as { message?: string } | null)?.message || + runtimeModelsError || + "", + ); + const signature = detail || "runtime-models-failed"; + if (lastRuntimeModelsWarningRef.current === signature) return; + lastRuntimeModelsWarningRef.current = signature; + + toast.warning( + t("omo.runtimeModelsFailedWarning", { + defaultValue: + "Failed to load OpenCode runtime models. Showing configured providers only.", + }), + ); + }, [isOmoCategory, runtimeModelsFailed, runtimeModelsError, t]); + return { omoModelOptions: omoModelBuild.options, omoModelVariantsMap: omoModelBuild.variantsMap, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 8126c1e07..2da7345b4 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -2870,6 +2870,7 @@ "noEnabledModelsWarning": "No configured models available. Configure OpenCode models first.", "modelSourcePartialWarning": "Some provider model configs are invalid and were skipped.", "modelSourceFallbackWarning": "Failed to load live provider state. Falling back to configured providers.", + "runtimeModelsFailedWarning": "Failed to load OpenCode runtime models. Showing configured providers only.", "importLocalReplaceSuccess": "Imported local file and replaced Agents/Categories/Other Fields", "importLocalFailed": "Failed to read local file: {{error}}", "agentKeyPlaceholder": "agent key", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index d4e90f87a..8b4099604 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2870,6 +2870,7 @@ "noEnabledModelsWarning": "利用可能な設定済みモデルがありません。先に OpenCode モデルを設定してください。", "modelSourcePartialWarning": "一部プロバイダーのモデル設定が不正なため、候補から除外しました。", "modelSourceFallbackWarning": "live プロバイダー状態の取得に失敗したため、設定済みプロバイダーへフォールバックしました。", + "runtimeModelsFailedWarning": "OpenCode ランタイムモデルの取得に失敗しました。設定済みプロバイダーのモデルのみ表示します。", "importLocalReplaceSuccess": "ローカルファイルから読み込み、Agents/Categories/Other Fields を置き換えました", "importLocalFailed": "ローカルファイルの読み込みに失敗しました: {{error}}", "agentKeyPlaceholder": "agent キー", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 8d41fc26f..1f58f5137 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -2871,6 +2871,7 @@ "noEnabledModelsWarning": "目前沒有可用的已設定模型,請先設定 OpenCode 模型", "modelSourcePartialWarning": "部分供應商模型設定無效,已自動跳過。", "modelSourceFallbackWarning": "讀取 live 供應商狀態失敗,已回退至已設定供應商清單。", + "runtimeModelsFailedWarning": "讀取 OpenCode 執行時模型失敗,已僅顯示已設定供應商模型。", "importLocalReplaceSuccess": "已從本地檔案匯入並覆寫 Agent/Category/Other Fields", "importLocalFailed": "讀取本地檔案失敗:{{error}}", "agentKeyPlaceholder": "agent 鍵名", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 26fc6907f..73fcb8ed7 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -2870,6 +2870,7 @@ "noEnabledModelsWarning": "当前没有可用的已配置模型,请先配置 OpenCode 模型", "modelSourcePartialWarning": "部分供应商模型配置无效,已自动跳过。", "modelSourceFallbackWarning": "读取 live 供应商状态失败,已回退到已配置供应商列表。", + "runtimeModelsFailedWarning": "读取 OpenCode 运行时模型失败,已仅展示已配置供应商模型。", "importLocalReplaceSuccess": "已从本地文件导入并覆盖 Agent/Category/Other Fields", "importLocalFailed": "读取本地文件失败: {{error}}", "agentKeyPlaceholder": "agent 键名", diff --git a/src/lib/api/model-fetch.ts b/src/lib/api/model-fetch.ts index c3601273f..e6233775f 100644 --- a/src/lib/api/model-fetch.ts +++ b/src/lib/api/model-fetch.ts @@ -29,6 +29,16 @@ export async function fetchModelsForConfig( }); } +export interface OpenCodeModelRef { + providerId: string; + modelId: string; +} + +/** 获取 OpenCode 当前运行时可用模型(包含 OAuth 与 Zen 免费模型)。 */ +export async function getOpenCodeModels(): Promise { + return invoke("get_opencode_models"); +} + /** * 获取 Codex OAuth (ChatGPT Plus/Pro 反代) 可用模型列表 * diff --git a/src/lib/query/mutations.ts b/src/lib/query/mutations.ts index 8d6e8bd0f..9d5494f4e 100644 --- a/src/lib/query/mutations.ts +++ b/src/lib/query/mutations.ts @@ -298,6 +298,9 @@ export const useSwitchProviderMutation = (appId: AppId) => { await queryClient.invalidateQueries({ queryKey: ["opencodeLiveProviderIds"], }); + await queryClient.invalidateQueries({ + queryKey: ["opencode", "runtime-models"], + }); await queryClient.invalidateQueries({ queryKey: ["omo", "current-provider-id"], }); @@ -405,6 +408,9 @@ export const useSaveSettingsMutation = () => { }, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["settings"] }); + await queryClient.invalidateQueries({ + queryKey: ["opencode", "runtime-models"], + }); }, }); };