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
This commit is contained in:
Allen Xu
2026-08-04 09:43:20 +08:00
committed by GitHub
parent 968794e30e
commit 92ca95ffcd
12 changed files with 843 additions and 109 deletions
+1
View File
@@ -783,6 +783,7 @@ dependencies = [
"indexmap 2.13.0",
"json-five",
"json5",
"libc",
"log",
"objc2 0.5.2",
"objc2-app-kit 0.2.2",
+3
View File
@@ -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 = [
+564 -23
View File
@@ -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::p
}
#[cfg(target_os = "windows")]
fn run_windows_tool_version_command(
fn run_windows_tool_command(
tool_path: &Path,
args: &[&str],
new_path: &str,
) -> std::io::Result<std::process::Output> {
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::<Vec<_>>()
.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<std::process::Output> {
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<String> {
/// 用与 `try_get_version` 相同的登录 shell 解析 PATH 默认命中的可执行文件路径,
/// canonicalize 后作为"命令行默认 / 升级目标"的锚点(与升级会作用的那处对齐)。
#[cfg(not(target_os = "windows"))]
fn resolve_path_default(tool: &str) -> Option<std::path::PathBuf> {
use std::process::Command;
fn resolve_path_default(
tool: &str,
deadline: Option<CommandDeadline>,
) -> Result<Option<std::path::PathBuf>, 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<std::path::PathBuf> {
fn resolve_path_default(
tool: &str,
deadline: Option<CommandDeadline>,
) -> Result<Option<std::path::PathBuf>, 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<ToolInstallation> {
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::path::PathBuf> = std::collections::HashSet::new();
let mut installs: Vec<ToolInstallation> = Vec::new();
@@ -2602,6 +2644,468 @@ fn default_install(installs: &[ToolInstallation]) -> Option<&ToolInstallation> {
})
}
fn locate_default_tool(
tool: &str,
deadline: Option<CommandDeadline>,
) -> Result<std::path::PathBuf, String> {
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<std::time::Duration>) -> Option<Self> {
timeout.map(|limit| Self {
expires_at: std::time::Instant::now() + limit,
limit,
})
}
fn remaining(self) -> Result<std::time::Duration, String> {
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<CommandDeadline>,
) -> Result<std::process::Output, String> {
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<std::time::Duration>,
extra_env: &[(&str, String)],
working_dir: &Path,
) -> Result<std::process::Output, String> {
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<CommandDeadline>,
extra_env: &[(&str, String)],
working_dir: &Path,
) -> Result<std::process::Output, String> {
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::<Vec<_>>()
.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<String> {
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<Vec<String>, 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<CommandDeadline>,
) -> Result<String, String> {
let invocation = std::iter::once(tool)
.chain(args.iter().copied())
.collect::<Vec<_>>()
.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<CommandDeadline>,
extra_env: &[(&str, String)],
working_dir: &Path,
) -> Result<std::process::Output, String> {
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;
+115
View File
@@ -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<Vec<OpenCodeModelRef>, 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<OpenCodeModelRef> {
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::<BTreeSet<_>>()
.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());
}
}
+1
View File
@@ -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,
@@ -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<ReturnType<typeof getOpenCodeModels>> =
[];
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<string>("");
const lastRuntimeModelsWarningRef = useRef<string>("");
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<string, string>();
const variantsMap: Record<string, string[]> = {};
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,
+1
View File
@@ -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",
+1
View File
@@ -2870,6 +2870,7 @@
"noEnabledModelsWarning": "利用可能な設定済みモデルがありません。先に OpenCode モデルを設定してください。",
"modelSourcePartialWarning": "一部プロバイダーのモデル設定が不正なため、候補から除外しました。",
"modelSourceFallbackWarning": "live プロバイダー状態の取得に失敗したため、設定済みプロバイダーへフォールバックしました。",
"runtimeModelsFailedWarning": "OpenCode ランタイムモデルの取得に失敗しました。設定済みプロバイダーのモデルのみ表示します。",
"importLocalReplaceSuccess": "ローカルファイルから読み込み、Agents/Categories/Other Fields を置き換えました",
"importLocalFailed": "ローカルファイルの読み込みに失敗しました: {{error}}",
"agentKeyPlaceholder": "agent キー",
+1
View File
@@ -2871,6 +2871,7 @@
"noEnabledModelsWarning": "目前沒有可用的已設定模型,請先設定 OpenCode 模型",
"modelSourcePartialWarning": "部分供應商模型設定無效,已自動跳過。",
"modelSourceFallbackWarning": "讀取 live 供應商狀態失敗,已回退至已設定供應商清單。",
"runtimeModelsFailedWarning": "讀取 OpenCode 執行時模型失敗,已僅顯示已設定供應商模型。",
"importLocalReplaceSuccess": "已從本地檔案匯入並覆寫 Agent/Category/Other Fields",
"importLocalFailed": "讀取本地檔案失敗:{{error}}",
"agentKeyPlaceholder": "agent 鍵名",
+1
View File
@@ -2870,6 +2870,7 @@
"noEnabledModelsWarning": "当前没有可用的已配置模型,请先配置 OpenCode 模型",
"modelSourcePartialWarning": "部分供应商模型配置无效,已自动跳过。",
"modelSourceFallbackWarning": "读取 live 供应商状态失败,已回退到已配置供应商列表。",
"runtimeModelsFailedWarning": "读取 OpenCode 运行时模型失败,已仅展示已配置供应商模型。",
"importLocalReplaceSuccess": "已从本地文件导入并覆盖 Agent/Category/Other Fields",
"importLocalFailed": "读取本地文件失败: {{error}}",
"agentKeyPlaceholder": "agent 键名",
+10
View File
@@ -29,6 +29,16 @@ export async function fetchModelsForConfig(
});
}
export interface OpenCodeModelRef {
providerId: string;
modelId: string;
}
/** 获取 OpenCode 当前运行时可用模型(包含 OAuth 与 Zen 免费模型)。 */
export async function getOpenCodeModels(): Promise<OpenCodeModelRef[]> {
return invoke("get_opencode_models");
}
/**
* 获取 Codex OAuth (ChatGPT Plus/Pro 反代) 可用模型列表
*
+6
View File
@@ -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"],
});
},
});
};