mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 11:43:57 +08:00
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:
Generated
+1
@@ -783,6 +783,7 @@ dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"json-five",
|
||||
"json5",
|
||||
"libc",
|
||||
"log",
|
||||
"objc2 0.5.2",
|
||||
"objc2-app-kit 0.2.2",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2870,6 +2870,7 @@
|
||||
"noEnabledModelsWarning": "利用可能な設定済みモデルがありません。先に OpenCode モデルを設定してください。",
|
||||
"modelSourcePartialWarning": "一部プロバイダーのモデル設定が不正なため、候補から除外しました。",
|
||||
"modelSourceFallbackWarning": "live プロバイダー状態の取得に失敗したため、設定済みプロバイダーへフォールバックしました。",
|
||||
"runtimeModelsFailedWarning": "OpenCode ランタイムモデルの取得に失敗しました。設定済みプロバイダーのモデルのみ表示します。",
|
||||
"importLocalReplaceSuccess": "ローカルファイルから読み込み、Agents/Categories/Other Fields を置き換えました",
|
||||
"importLocalFailed": "ローカルファイルの読み込みに失敗しました: {{error}}",
|
||||
"agentKeyPlaceholder": "agent キー",
|
||||
|
||||
@@ -2871,6 +2871,7 @@
|
||||
"noEnabledModelsWarning": "目前沒有可用的已設定模型,請先設定 OpenCode 模型",
|
||||
"modelSourcePartialWarning": "部分供應商模型設定無效,已自動跳過。",
|
||||
"modelSourceFallbackWarning": "讀取 live 供應商狀態失敗,已回退至已設定供應商清單。",
|
||||
"runtimeModelsFailedWarning": "讀取 OpenCode 執行時模型失敗,已僅顯示已設定供應商模型。",
|
||||
"importLocalReplaceSuccess": "已從本地檔案匯入並覆寫 Agent/Category/Other Fields",
|
||||
"importLocalFailed": "讀取本地檔案失敗:{{error}}",
|
||||
"agentKeyPlaceholder": "agent 鍵名",
|
||||
|
||||
@@ -2870,6 +2870,7 @@
|
||||
"noEnabledModelsWarning": "当前没有可用的已配置模型,请先配置 OpenCode 模型",
|
||||
"modelSourcePartialWarning": "部分供应商模型配置无效,已自动跳过。",
|
||||
"modelSourceFallbackWarning": "读取 live 供应商状态失败,已回退到已配置供应商列表。",
|
||||
"runtimeModelsFailedWarning": "读取 OpenCode 运行时模型失败,已仅展示已配置供应商模型。",
|
||||
"importLocalReplaceSuccess": "已从本地文件导入并覆盖 Agent/Category/Other Fields",
|
||||
"importLocalFailed": "读取本地文件失败: {{error}}",
|
||||
"agentKeyPlaceholder": "agent 键名",
|
||||
|
||||
@@ -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 反代) 可用模型列表
|
||||
*
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user