From d7e39e8fdcb6db14ac79f28677b4baeb3c69b263 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Tue, 6 Oct 2026 19:09:47 +0800 Subject: [PATCH] Add Oh My Pi support through the OpenAI Responses API Discover CPA models dynamically and route the managed provider to /v1/responses. --- README.ja.md | 1 + README.md | 1 + README.zh-CN.md | 1 + src-tauri/src/agents/backups/tests.rs | 2 + src-tauri/src/agents/commands.rs | 1 + src-tauri/src/agents/configuration.rs | 195 ++++++++++++++++++++- src-tauri/src/agents/discovery.rs | 89 ++++++++++ src-tauri/src/agents/launch.rs | 32 +++- src-tauri/src/agents/state.rs | 6 +- src-tauri/src/configuration_watcher.rs | 1 + src-tauri/src/main.rs | 10 ++ src-tauri/src/tests/agent_configuration.rs | 48 +++++ src-tauri/src/tests/agent_paths.rs | 1 + src/i18n/ja.ts | 1 + src/i18n/locales/en.ts | 1 + src/i18n/locales/zh-CN.ts | 1 + src/mocks/browserMockRuntime.ts | 1 + src/pages/AgentsPage.tsx | 10 +- src/services/agentConfigurationDraft.ts | 1 + tests/agent-client-list-ui.cjs | 26 +-- tests/agent-management-ui.cjs | 2 +- tests/agentModelPicker.test.ts | 1 + tests/fixtures/agent-backups.tsx | 2 +- 23 files changed, 414 insertions(+), 20 deletions(-) diff --git a/README.ja.md b/README.ja.md index 95b8d8b..27b3fc3 100644 --- a/README.ja.md +++ b/README.ja.md @@ -111,6 +111,7 @@ OpenAI、Claude、Gemini、およびその他の互換形式の間で変換で - Antigravity CLI - Kimi Code - Grok Build +- Oh My Pi 対応クライアントでは、利用可能なモデルカタログの同期、デフォルトモデルの選択、管理設定を適用する前の 元設定のバックアップ、以前の設定への復元、利用可能なデスクトップまたは CLI エントリーポイントの起動ができます。 diff --git a/README.md b/README.md index 70d81ad..b3d90f5 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,7 @@ proxy. Supported clients include: - Antigravity CLI - Kimi Code - Grok Build +- Oh My Pi For supported clients, the application can synchronize the available model catalog, select a default model, back up the original configuration before applying managed settings, and restore the diff --git a/README.zh-CN.md b/README.zh-CN.md index e79b7be..594b907 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -119,6 +119,7 @@ API 接入页面按照协议或 Provider 管理上游 API 凭证和服务地址 - Antigravity CLI - Kimi Code - Grok Build +- Oh My Pi 对于受支持的客户端,软件可以同步可用模型目录、选择默认模型、在应用托管配置前备份原始配置, 以及恢复之前的配置。 diff --git a/src-tauri/src/agents/backups/tests.rs b/src-tauri/src/agents/backups/tests.rs index e8df370..0bd0618 100644 --- a/src-tauri/src/agents/backups/tests.rs +++ b/src-tauri/src/agents/backups/tests.rs @@ -87,6 +87,7 @@ fn clients() -> Vec { "workbuddy", "kimi-code", "grok-build", + "omp", ] .into_iter() .map(|id| AgentClient::parse(id).unwrap()) @@ -417,6 +418,7 @@ fn clear_integration_needs_no_backup_and_removes_state_for_each_client() { AgentClient::WorkBuddy, AgentClient::KimiCode, AgentClient::GrokBuild, + AgentClient::Omp, ] .into_iter() .filter(|client| client.supported_platform()) diff --git a/src-tauri/src/agents/commands.rs b/src-tauri/src/agents/commands.rs index 2eb0345..06d42c5 100644 --- a/src-tauri/src/agents/commands.rs +++ b/src-tauri/src/agents/commands.rs @@ -33,6 +33,7 @@ pub(crate) fn inspect_agent_config_statuses( AgentStatusDetectionTarget::Client(AgentClient::AntigravityCli), AgentStatusDetectionTarget::Client(AgentClient::KimiCode), AgentStatusDetectionTarget::Client(AgentClient::GrokBuild), + AgentStatusDetectionTarget::Client(AgentClient::Omp), AgentStatusDetectionTarget::PiProvider, ]; let queue = Mutex::new(targets.into_iter().enumerate()); diff --git a/src-tauri/src/agents/configuration.rs b/src-tauri/src/agents/configuration.rs index c17dcba..5ec3ebf 100644 --- a/src-tauri/src/agents/configuration.rs +++ b/src-tauri/src/agents/configuration.rs @@ -232,6 +232,23 @@ pub(crate) fn build_agent_updates_with_oauth( after, }]) } + AgentClient::Omp => { + if paths.len() != 2 { + return Err("Invalid number of Oh My Pi configuration paths".to_string()); + } + let models_before = read_optional_text(&paths[0])?; + let settings_before = read_optional_text(&paths[1])?; + Ok(vec![ + AgentFileUpdate { + path: paths[0].clone(), + after: build_omp_models_config(models_before.as_deref(), &openai_base, api_key)?, + }, + AgentFileUpdate { + path: paths[1].clone(), + after: build_omp_settings_config(settings_before.as_deref(), model)?, + }, + ]) + } } } @@ -1649,6 +1666,7 @@ pub(crate) fn prepare_agent_managed_removal( AgentClient::AntigravityCli => prepare_antigravity_removal(client, paths), AgentClient::KimiCode => prepare_kimi_code_managed_removal(paths), AgentClient::GrokBuild => prepare_grok_build_managed_removal(paths), + AgentClient::Omp => prepare_omp_managed_removal(paths), } } @@ -2624,7 +2642,7 @@ pub(crate) fn agent_config_semantically_equal( json5::from_str::(actual).ok() == json5::from_str::(expected).ok() } - AgentClient::Hermes => { + AgentClient::Hermes | AgentClient::Omp => { serde_norway::from_str::(actual).ok() == serde_norway::from_str::(expected).ok() } @@ -2703,6 +2721,17 @@ pub(crate) fn build_agent_session_restored_bytes_with_preference( AgentClient::AntigravityCli => restore_antigravity_config(client, path, current, original)?, AgentClient::KimiCode => build_restored_kimi_code_config(current, original)?, AgentClient::GrokBuild => build_restored_grok_build_config(current, original)?, + AgentClient::Omp => { + let index = paths + .iter() + .position(|candidate| candidate == path) + .ok_or_else(|| "Oh My Pi restoration path mismatch".to_string())?; + match index { + 0 => build_restored_omp_models(current, original)?, + 1 => build_restored_omp_settings(current, original)?, + _ => return Err("Invalid Oh My Pi restoration path index".to_string()), + } + } }; if prefer_exact_original { if let (Some(restored), Some(original), Some(original_bytes)) = @@ -3263,6 +3292,170 @@ pub(crate) fn managed_model_alias(model: &str) -> String { format!("{MANAGED_AGENT_PROVIDER_ID}/{model}") } + +pub(crate) fn build_omp_models_config( + existing: Option<&str>, + base_url: &str, + api_key: &str, +) -> Result { + let _ = api_key; + render_agent_yaml_mapping_update(existing, "Oh My Pi models.yml", |root| { + let providers = root + .entry(yaml_key("providers")) + .or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new())); + let providers = providers + .as_mapping_mut() + .ok_or_else(|| "Oh My Pi providers must be a YAML mapping".to_string())?; + let mut provider = serde_norway::Mapping::new(); + provider.insert(yaml_key("baseUrl"), serde_norway::Value::String(base_url.to_string())); + provider.insert( + yaml_key("apiKey"), + serde_norway::Value::String(OMP_AGENT_API_KEY_ENV.to_string()), + ); + provider.insert( + yaml_key("api"), + serde_norway::Value::String(OMP_AGENT_API.to_string()), + ); + let mut discovery = serde_norway::Mapping::new(); + discovery.insert( + yaml_key("type"), + serde_norway::Value::String("openai-models-list".to_string()), + ); + provider.insert(yaml_key("discovery"), serde_norway::Value::Mapping(discovery)); + providers.insert( + yaml_key(OMP_AGENT_PROVIDER_ID), + serde_norway::Value::Mapping(provider), + ); + Ok(()) + }) +} + +pub(crate) fn build_omp_settings_config( + existing: Option<&str>, + model: &str, +) -> Result { + render_agent_yaml_mapping_update(existing, "Oh My Pi config.yml", |root| { + let roles = root + .entry(yaml_key("modelRoles")) + .or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new())); + let roles = roles + .as_mapping_mut() + .ok_or_else(|| "Oh My Pi modelRoles must be a YAML mapping".to_string())?; + roles.insert( + yaml_key("default"), + serde_norway::Value::String(format!("{OMP_AGENT_PROVIDER_ID}/{model}")), + ); + Ok(()) + }) +} + +pub(crate) fn prepare_omp_managed_removal(paths: &[PathBuf]) -> Result { + if paths.len() != 2 { + return Err("Invalid number of Oh My Pi configuration paths".to_string()); + } + let mut updates = Vec::new(); + if paths[0].is_file() { + let content = fs::read_to_string(&paths[0]).map_err(|error| { + format!("Failed to read Oh My Pi models.yml {}: {error}", path_to_string(&paths[0])) + })?; + let rendered = render_agent_yaml_mapping_update(Some(&content), "Oh My Pi models.yml", |root| { + let mut remove_providers = false; + if let Some(providers) = root + .get_mut(yaml_key("providers")) + .and_then(serde_norway::Value::as_mapping_mut) + { + providers.remove(yaml_key(OMP_AGENT_PROVIDER_ID)); + remove_providers = providers.is_empty(); + } + if remove_providers { + root.remove(yaml_key("providers")); + } + Ok(()) + })?; + let root = parse_agent_yaml_mapping(Some(&rendered), "Oh My Pi models.yml")?; + updates.push(( + paths[0].clone(), + (!root.is_empty()).then(|| rendered.into_bytes()), + )); + } + if paths[1].is_file() { + let content = fs::read_to_string(&paths[1]).map_err(|error| { + format!("Failed to read Oh My Pi config.yml {}: {error}", path_to_string(&paths[1])) + })?; + let rendered = render_agent_yaml_mapping_update(Some(&content), "Oh My Pi config.yml", |root| { + let mut remove_roles = false; + if let Some(roles) = root + .get_mut(yaml_key("modelRoles")) + .and_then(serde_norway::Value::as_mapping_mut) + { + if roles + .get(yaml_key("default")) + .and_then(serde_norway::Value::as_str) + .is_some_and(|value| value.starts_with(&format!("{OMP_AGENT_PROVIDER_ID}/"))) + { + roles.remove(yaml_key("default")); + } + remove_roles = roles.is_empty(); + } + if remove_roles { + root.remove(yaml_key("modelRoles")); + } + Ok(()) + })?; + let root = parse_agent_yaml_mapping(Some(&rendered), "Oh My Pi config.yml")?; + updates.push(( + paths[1].clone(), + (!root.is_empty()).then(|| rendered.into_bytes()), + )); + } + Ok(updates.into_iter().collect()) +} + +pub(crate) fn build_restored_omp_models( + current: &str, + original: Option<&str>, +) -> Result, String> { + let original_root = parse_agent_yaml_mapping(original, "Original Oh My Pi models.yml")?; + let rendered = render_agent_yaml_mapping_update(Some(current), "Current Oh My Pi models.yml", |root| { + let original_provider = yaml_mapping_value(&original_root, "providers") + .and_then(serde_norway::Value::as_mapping) + .and_then(|providers| yaml_mapping_value(providers, OMP_AGENT_PROVIDER_ID)) + .cloned(); + let providers = root + .entry(yaml_key("providers")) + .or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new())); + let providers = providers + .as_mapping_mut() + .ok_or_else(|| "Oh My Pi providers must be a YAML mapping".to_string())?; + if let Some(provider) = original_provider { + providers.insert(yaml_key(OMP_AGENT_PROVIDER_ID), provider); + } else { + providers.remove(yaml_key(OMP_AGENT_PROVIDER_ID)); + } + if providers.is_empty() + && yaml_mapping_value(&original_root, "providers").is_none() + { + root.remove(yaml_key("providers")); + } + Ok(()) + })?; + let root = parse_agent_yaml_mapping(Some(&rendered), "Restored Oh My Pi models.yml")?; + Ok((!root.is_empty() || original.is_some()).then_some(rendered)) +} + +pub(crate) fn build_restored_omp_settings( + current: &str, + original: Option<&str>, +) -> Result, String> { + let original_root = parse_agent_yaml_mapping(original, "Original Oh My Pi config.yml")?; + let rendered = render_agent_yaml_mapping_update(Some(current), "Current Oh My Pi config.yml", |root| { + restore_yaml_key(root, Some(&original_root), "modelRoles"); + Ok(()) + })?; + let root = parse_agent_yaml_mapping(Some(&rendered), "Restored Oh My Pi config.yml")?; + Ok((!root.is_empty() || original.is_some()).then_some(rendered)) +} + pub(crate) fn build_kimi_code_agent_config( existing: Option<&str>, base_url: &str, diff --git a/src-tauri/src/agents/discovery.rs b/src-tauri/src/agents/discovery.rs index 0b3e117..9815097 100644 --- a/src-tauri/src/agents/discovery.rs +++ b/src-tauri/src/agents/discovery.rs @@ -52,6 +52,10 @@ pub(crate) fn agent_config_paths(client: AgentClient, home: &Path) -> Vec vec![home.join(".zcode/v2").join(ZCODE_PROVIDER_CONFIG_FILE)], AgentClient::KimiCode => vec![kimi_code_home(home).join(KIMI_CODE_CONFIG_FILE)], AgentClient::GrokBuild => vec![grok_build_home(home).join(GROK_BUILD_CONFIG_FILE)], + AgentClient::Omp => { + let directory = omp_agent_directory(home); + vec![directory.join(OMP_MODELS_FILE), directory.join(OMP_SETTINGS_FILE)] + } } } @@ -107,6 +111,10 @@ pub(crate) fn grok_build_home(home: &Path) -> PathBuf { agent_configuration_environment("GROK_HOME").unwrap_or_else(|| home.join(".grok")) } +pub(crate) fn omp_agent_directory(home: &Path) -> PathBuf { + agent_configuration_environment("PI_CODING_AGENT_DIR").unwrap_or_else(|| home.join(".omp/agent")) +} + pub(crate) fn deepseek_harness_home(home: &Path) -> PathBuf { agent_configuration_environment("DSH_HOME").unwrap_or_else(|| home.join(".dsh")) } @@ -1326,6 +1334,8 @@ pub(crate) fn inspect_agent_managed_config( .map(|(configured, model)| (configured, model, false)), AgentClient::GrokBuild => inspect_grok_build_agent_config(&paths[0], port, api_key) .map(|(configured, model)| (configured, model, false)), + AgentClient::Omp => inspect_omp_agent_config(paths, port, api_key) + .map(|(configured, model)| (configured, model, false)), } } @@ -1561,6 +1571,7 @@ pub(crate) fn agent_has_managed_marker( Some("models"), "default", ), + AgentClient::Omp => omp_has_managed_marker(paths), } } @@ -2452,6 +2463,9 @@ pub(crate) fn find_agent_executable(client: AgentClient, home: &Path) -> Option< if client == AgentClient::GrokBuild { return find_grok_build_executable(home); } + if client == AgentClient::Omp { + return find_omp_executable(home); + } if client == AgentClient::OpenCode { return find_opencode_executable(home); } @@ -3453,6 +3467,81 @@ pub(crate) fn inspect_zcode_agent_config( Ok((configured, model)) } + +pub(crate) fn omp_provider_mapping(root: &serde_norway::Mapping) -> Option<&serde_norway::Mapping> { + yaml_mapping_value(root, "providers") + .and_then(serde_norway::Value::as_mapping) + .and_then(|providers| yaml_mapping_value(providers, OMP_AGENT_PROVIDER_ID)) + .and_then(serde_norway::Value::as_mapping) +} + +pub(crate) fn omp_selected_model(root: &serde_norway::Mapping) -> Option { + let selector = yaml_mapping_value(root, "modelRoles") + .and_then(serde_norway::Value::as_mapping) + .and_then(|roles| yaml_mapping_value(roles, "default")) + .and_then(serde_norway::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty())?; + let prefix = format!("{OMP_AGENT_PROVIDER_ID}/"); + selector.strip_prefix(&prefix).map(str::to_string) +} + +pub(crate) fn inspect_omp_agent_config( + paths: &[PathBuf], + port: u16, + api_key: &str, +) -> Result<(bool, Option), String> { + if paths.len() != 2 || !paths[0].is_file() || !paths[1].is_file() { + return Ok((false, None)); + } + let models = read_agent_yaml_mapping_or_empty(&paths[0], "Oh My Pi models.yml")?; + let settings = read_agent_yaml_mapping_or_empty(&paths[1], "Oh My Pi config.yml")?; + let provider = omp_provider_mapping(&models); + let model = omp_selected_model(&settings); + let expected = format!("{}/v1", managed_core_loopback_origin(port)); + let configured = provider + .and_then(|provider| yaml_mapping_value(provider, "baseUrl")) + .and_then(serde_norway::Value::as_str) + == Some(expected.as_str()) + && provider + .and_then(|provider| yaml_mapping_value(provider, "apiKey")) + .and_then(serde_norway::Value::as_str) + == Some(OMP_AGENT_API_KEY_ENV) + && provider + .and_then(|provider| yaml_mapping_value(provider, "api")) + .and_then(serde_norway::Value::as_str) + .is_some_and(|api| api == OMP_AGENT_API || api == "openai-completions") + && provider + .and_then(|provider| yaml_mapping_value(provider, "discovery")) + .and_then(serde_norway::Value::as_mapping) + .and_then(|discovery| yaml_mapping_value(discovery, "type")) + .and_then(serde_norway::Value::as_str) + == Some("openai-models-list") + && model.is_some(); + let _ = api_key; + Ok((configured, model)) +} + +pub(crate) fn omp_has_managed_marker(paths: &[PathBuf]) -> Result { + if paths.first().is_none_or(|path| !path.is_file()) { + return Ok(false); + } + let models = read_agent_yaml_mapping_or_empty(&paths[0], "Oh My Pi models.yml")?; + Ok(omp_provider_mapping(&models).is_some()) +} + +pub(crate) fn find_omp_executable(home: &Path) -> Option { + let bun = home.join(".bun/bin"); + #[cfg(target_os = "windows")] + let managed = [bun.join("omp.exe"), bun.join("omp.cmd")]; + #[cfg(not(target_os = "windows"))] + let managed = [bun.join("omp")]; + managed + .into_iter() + .find(|path| agent_cli_executable(path)) + .or_else(|| find_named_agent_executable(home, &["omp"])) +} + pub(crate) fn inspect_kimi_code_agent_config( path: &Path, port: u16, diff --git a/src-tauri/src/agents/launch.rs b/src-tauri/src/agents/launch.rs index b9a7197..7344f17 100644 --- a/src-tauri/src/agents/launch.rs +++ b/src-tauri/src/agents/launch.rs @@ -161,6 +161,7 @@ pub(crate) fn launch_agent( &launch_directory, &[], &[], + &[], &terminal, ); } @@ -188,7 +189,7 @@ pub(crate) fn launch_agent( } let executable = env::current_exe().map_err(|_| "Unable to locate CPA launch adapter")?; let directory = resolve_launch_directory(working_directory.as_deref(), &home)?; - launch_cli_agent(&executable, client.name(), &directory, &antigravity_cli_helper_arguments(&home), &[], &terminal) + launch_cli_agent(&executable, client.name(), &directory, &antigravity_cli_helper_arguments(&home), &[], &[], &terminal) } (AgentClient::WorkBuddy, "app") => { let executable = find_workbuddy_desktop_executable(&home) @@ -233,12 +234,14 @@ pub(crate) fn launch_agent( &[] }; let arguments = agent_cli_launch_arguments(client, deepseek_harness_options.as_ref())?; + let environment_to_set = omp_launch_environment(client, effective_agent_api_key(&config)); launch_cli_agent( &executable, client.name(), &launch_directory, &arguments, environment_to_remove, + &environment_to_set, &terminal, ) } @@ -247,6 +250,15 @@ pub(crate) fn launch_agent( } } + +fn omp_launch_environment(client: AgentClient, api_key: &str) -> Vec<(&str, &str)> { + if client == AgentClient::Omp { + vec![(OMP_AGENT_API_KEY_ENV, api_key)] + } else { + Vec::new() + } +} + fn deepseek_harness_launch_mode( options: Option<&DeepSeekHarnessLaunchOptions>, ) -> Result { @@ -1186,6 +1198,7 @@ fn launch_cli_agent( working_directory: &Path, arguments: &[String], environment_to_remove: &[&str], + environment_to_set: &[(&str, &str)], terminal: &str, ) -> Result<(), String> { let removals = environment_to_remove @@ -1193,6 +1206,11 @@ fn launch_cli_agent( .map(|key| format!("-u {}", shell_single_quote(key))) .collect::>() .join(" "); + let assignments = environment_to_set + .iter() + .map(|(key, value)| format!("{}={}", shell_single_quote(key), shell_single_quote(value))) + .collect::>() + .join(" "); let invocation = std::iter::once(shell_single_quote(&path_to_string(executable))) .chain( arguments @@ -1202,9 +1220,10 @@ fn launch_cli_agent( .collect::>() .join(" "); let command_line = format!( - "cd {} && exec env {} {}", + "cd {} && exec env {} {} {}", shell_single_quote(&path_to_string(working_directory)), removals, + assignments, invocation, ); let script = if terminal == "iterm2" { @@ -1245,6 +1264,7 @@ fn launch_cli_agent( working_directory: &Path, command_arguments: &[String], environment_to_remove: &[&str], + environment_to_set: &[(&str, &str)], terminal: &str, ) -> Result<(), String> { let definitions = linux_terminal_definitions(); @@ -1269,6 +1289,9 @@ fn launch_cli_agent( for key in environment_to_remove { command.env_remove(key); } + for (key, value) in environment_to_set { + command.env(key, value); + } match command.spawn() { Ok(_) => return Ok(()), Err(error) => last_error = Some(error.to_string()), @@ -1347,6 +1370,7 @@ fn launch_cli_agent( working_directory: &Path, arguments: &[String], environment_to_remove: &[&str], + environment_to_set: &[(&str, &str)], terminal: &str, ) -> Result<(), String> { use std::os::windows::process::CommandExt; @@ -1413,6 +1437,9 @@ fn launch_cli_agent( for key in environment_to_remove { command.env_remove(key); } + for (key, value) in environment_to_set { + command.env(key, value); + } command .spawn() .map(|_| ()) @@ -1426,6 +1453,7 @@ fn launch_cli_agent( _working_directory: &Path, _arguments: &[String], _environment_to_remove: &[&str], + _environment_to_set: &[(&str, &str)], _terminal: &str, ) -> Result<(), String> { Err(format!("The current platform does not support launching {label}")) diff --git a/src-tauri/src/agents/state.rs b/src-tauri/src/agents/state.rs index 8618232..b76cdc1 100644 --- a/src-tauri/src/agents/state.rs +++ b/src-tauri/src/agents/state.rs @@ -748,6 +748,10 @@ pub(crate) fn fresh_agent_contents_with_oauth( model, models, )?]), + AgentClient::Omp => Ok(vec![ + build_omp_models_config(None, &openai_base, api_key)?, + build_omp_settings_config(None, model)?, + ]), } } @@ -770,7 +774,7 @@ pub(crate) fn agent_contents_equal(client: AgentClient, actual: &str, expected: serde_yaml::from_str::(actual).ok() == serde_yaml::from_str::(expected).ok() } - AgentClient::DeepSeekHarness => { + AgentClient::Omp | AgentClient::DeepSeekHarness => { serde_norway::from_str::(actual).ok() == serde_norway::from_str::(expected).ok() } diff --git a/src-tauri/src/configuration_watcher.rs b/src-tauri/src/configuration_watcher.rs index b087fe3..ae50d54 100644 --- a/src-tauri/src/configuration_watcher.rs +++ b/src-tauri/src/configuration_watcher.rs @@ -78,6 +78,7 @@ fn tracked_configuration_paths(app: &tauri::AppHandle) -> Result, S AgentClient::AntigravityCli, AgentClient::KimiCode, AgentClient::GrokBuild, + AgentClient::Omp, ] { paths.extend(agent_managed_paths(client, &home)); } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 2546a9e..9d59902 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -174,6 +174,11 @@ const DEEPSEEK_HARNESS_SETTINGS_FILE: &str = "settings.yaml"; const DEEPSEEK_HARNESS_CREDENTIALS_FILE: &str = ".credentials.yaml"; const PI_AGENT_ID: &str = "pi"; const PI_AGENT_NAME: &str = "Pi"; +const OMP_AGENT_PROVIDER_ID: &str = "easy-cliproxyapi"; +const OMP_AGENT_API_KEY_ENV: &str = "EASYCLIPROXYAPI_API_KEY"; +const OMP_AGENT_API: &str = "openai-responses"; +const OMP_MODELS_FILE: &str = "models.yml"; +const OMP_SETTINGS_FILE: &str = "config.yml"; const PI_CLIPROXYAPI_PACKAGE: &str = "npm:@router-for-me/pi-cliproxyapi-provider"; const PI_CLIPROXYAPI_NPM_LATEST_URL: &str = "https://registry.npmjs.org/@router-for-me%2Fpi-cliproxyapi-provider/latest"; @@ -1358,6 +1363,7 @@ enum AgentClient { AntigravityCli, KimiCode, GrokBuild, + Omp, } #[derive(Clone, Debug)] @@ -1382,6 +1388,7 @@ impl AgentClient { "antigravity-cli" => Ok(Self::AntigravityCli), "kimi-code" => Ok(Self::KimiCode), "grok-build" => Ok(Self::GrokBuild), + "omp" => Ok(Self::Omp), _ => Err(format!("Unsupported agent client: {value}")), } } @@ -1400,6 +1407,7 @@ impl AgentClient { Self::AntigravityCli => "antigravity-cli", Self::KimiCode => "kimi-code", Self::GrokBuild => "grok-build", + Self::Omp => "omp", } } @@ -1417,6 +1425,7 @@ impl AgentClient { Self::AntigravityCli => "Antigravity CLI", Self::KimiCode => "Kimi Code", Self::GrokBuild => "Grok Build", + Self::Omp => "Oh My Pi", } } @@ -1443,6 +1452,7 @@ impl AgentClient { Self::AntigravityCli => &["agy"], Self::KimiCode => &["kimi"], Self::GrokBuild => &["grok"], + Self::Omp => &["omp"], } } } diff --git a/src-tauri/src/tests/agent_configuration.rs b/src-tauri/src/tests/agent_configuration.rs index 770829c..5d0b9d4 100644 --- a/src-tauri/src/tests/agent_configuration.rs +++ b/src-tauri/src/tests/agent_configuration.rs @@ -1549,6 +1549,53 @@ async fn codex_configuration_uses_public_v1_model_ids_for_selection_and_files() assert_eq!(ids, written); } +#[test] +fn omp_configuration_discovers_models_without_storing_the_api_key() { + let home = agent_test_home("omp-provider"); + let existing_models = "providers:\n other:\n baseUrl: https://example.test/v1\n apiKey: KEEP_OTHER\n"; + let existing_settings = "shellPath: E:/software/toolchain/w64devkit/bin/bash.exe\nmodelRoles:\n tiny: local/lfm2.5-230m\n"; + let models = build_omp_models_config(Some(existing_models), "http://127.0.0.1:8317/v1", "secret-value").unwrap(); + let settings = build_omp_settings_config(Some(existing_settings), "gpt-test").unwrap(); + let models_value: serde_norway::Value = serde_norway::from_str(&models).unwrap(); + let settings_value: serde_norway::Value = serde_norway::from_str(&settings).unwrap(); + let provider = &models_value["providers"]["easy-cliproxyapi"]; + assert_eq!(provider["baseUrl"].as_str(), Some("http://127.0.0.1:8317/v1")); + assert_eq!(provider["apiKey"].as_str(), Some("EASYCLIPROXYAPI_API_KEY")); + assert_eq!(provider["api"].as_str(), Some("openai-responses")); + assert_eq!(provider["discovery"]["type"].as_str(), Some("openai-models-list")); + assert!(provider.get("models").is_none()); + assert!(!models.contains("secret-value")); + assert_eq!(models_value["providers"]["other"]["apiKey"].as_str(), Some("KEEP_OTHER")); + assert_eq!(settings_value["shellPath"].as_str(), Some("E:/software/toolchain/w64devkit/bin/bash.exe")); + assert_eq!(settings_value["modelRoles"]["tiny"].as_str(), Some("local/lfm2.5-230m")); + assert_eq!(settings_value["modelRoles"]["default"].as_str(), Some("easy-cliproxyapi/gpt-test")); + + let paths = agent_config_paths(AgentClient::Omp, &home); + fs::create_dir_all(paths[0].parent().unwrap()).unwrap(); + fs::write(&paths[0], models).unwrap(); + fs::write(&paths[1], settings).unwrap(); + let (configured, model) = inspect_omp_agent_config(&paths, 8317, "secret-value").unwrap(); + assert!(configured); + assert_eq!(model.as_deref(), Some("gpt-test")); + assert!(omp_has_managed_marker(&paths).unwrap()); + + let removed = prepare_omp_managed_removal(&paths).unwrap(); + for (path, bytes) in removed { + match bytes { + Some(bytes) => fs::write(&path, bytes).unwrap(), + None => fs::write(&path, "").unwrap(), + } + } + let restored_models = fs::read_to_string(&paths[0]).unwrap(); + let restored_settings = fs::read_to_string(&paths[1]).unwrap(); + assert!(!restored_models.contains("easy-cliproxyapi")); + assert!(restored_models.contains("KEEP_OTHER")); + assert!(!restored_settings.contains("easy-cliproxyapi")); + assert!(restored_settings.contains("shellPath")); + assert!(restored_settings.contains("local/lfm2.5-230m")); + fs::remove_dir_all(home).unwrap(); +} + #[test] fn kimi_grok_and_zcode_use_cpa_runtime_context_windows() { for client in [ @@ -1566,6 +1613,7 @@ fn kimi_grok_and_zcode_use_cpa_runtime_context_windows() { AgentClient::OpenClaw, AgentClient::Hermes, AgentClient::DeepSeekHarness, + AgentClient::Omp, ] { assert!(!agent_uses_cpa_runtime_context_windows(client)); } diff --git a/src-tauri/src/tests/agent_paths.rs b/src-tauri/src/tests/agent_paths.rs index 492aaef..6c2bed9 100644 --- a/src-tauri/src/tests/agent_paths.rs +++ b/src-tauri/src/tests/agent_paths.rs @@ -12,6 +12,7 @@ fn installation_detection_separates_executables_from_version_metadata() { AgentClient::DeepSeekHarness, AgentClient::KimiCode, AgentClient::GrokBuild, + AgentClient::Omp, ] { assert!(agent_installation_detected(client, None, true, false)); assert!(!agent_installation_detected(client, None, false, false)); diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 4482133..bb8010d 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -1159,6 +1159,7 @@ export const jaOverrides = { 'agents.description.zcode': 'CPA の Anthropic Messages API と動的モデルカタログを使用', 'agents.description.kimiCode': 'CPA の OpenAI Chat Completions API と動的モデルカタログを使用', 'agents.description.grokBuild': 'CPA の OpenAI Chat Completions API とカスタムモデルカタログを使用', + 'agents.description.omp': 'OpenAI Responses エンドポイントから CPA モデルを動的に検出', 'agents.description.pi': 'CLIProxyAPI プロバイダーを使用して Pi CLI のモデルを動的に読み込みます', 'agents.clients.pages': 'クライアントのページ', 'agents.clients.previous': '前のクライアントページ', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index ca1d0b2..53b3c37 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1159,6 +1159,7 @@ export const en: Record = { 'agents.description.zcode': 'Uses the CPA Anthropic Messages API and dynamic model catalog', 'agents.description.kimiCode': 'Uses the CPA OpenAI Chat Completions API and dynamic model catalog', 'agents.description.grokBuild': 'Uses the CPA OpenAI Chat Completions API and custom model catalog', + 'agents.description.omp': 'Discovers CPA models through the OpenAI Responses endpoint', 'agents.description.pi': 'Loads models dynamically in Pi through the CLIProxyAPI provider', 'agents.clients.pages': 'Client pages', 'agents.clients.previous': 'Previous client page', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index c8307ef..0a5c770 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -1158,6 +1158,7 @@ export const zhCN = { 'agents.description.zcode': '使用 CPA 的 Anthropic Messages 接口和动态模型目录', 'agents.description.kimiCode': '使用 CPA 的 OpenAI Chat Completions 接口和动态模型目录', 'agents.description.grokBuild': '使用 CPA 的 OpenAI Chat Completions 接口和自定义模型目录', + 'agents.description.omp': '通过 OpenAI Responses 接口动态发现 CPA 模型', 'agents.description.pi': '使用 Pi CLI 和 CLIProxyAPI 提供方动态加载模型', 'agents.clients.pages': '客户端分页', 'agents.clients.previous': '上一页客户端', diff --git a/src/mocks/browserMockRuntime.ts b/src/mocks/browserMockRuntime.ts index 54339e2..81eba06 100644 --- a/src/mocks/browserMockRuntime.ts +++ b/src/mocks/browserMockRuntime.ts @@ -207,6 +207,7 @@ function createAgentStatuses() { ['antigravity-cli', 'Antigravity CLI'], ['kimi-code', 'Kimi Code'], ['grok-build', 'Grok Build'], + ['omp', 'Oh My Pi'], ['pi', 'Pi'], ] as const; const claudeMappings = { diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index 8f98222..e4bf1b4 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -82,6 +82,7 @@ type AgentClientId = | 'antigravity-cli' | 'kimi-code' | 'grok-build' + | 'omp' | 'pi'; type ClaudeModelMappingClientId = 'claude-code' | 'claude-desktop'; @@ -234,7 +235,7 @@ type AgentDefinition = { name: string; icon?: string; Icon?: ComponentType<{ size?: number; 'aria-hidden'?: boolean }>; - descriptionKey: 'agents.description.claudeCode' | 'agents.description.claudeDesktop' | 'agents.description.codex' | 'agents.description.opencode' | 'agents.description.openclaw' | 'agents.description.hermes' | 'agents.description.deepseekHarness' | 'agents.description.zcode' | 'agents.description.workbuddy' | 'agents.description.antigravityCli' | 'agents.description.kimiCode' | 'agents.description.grokBuild' | 'agents.description.pi'; + descriptionKey: 'agents.description.claudeCode' | 'agents.description.claudeDesktop' | 'agents.description.codex' | 'agents.description.opencode' | 'agents.description.openclaw' | 'agents.description.hermes' | 'agents.description.deepseekHarness' | 'agents.description.zcode' | 'agents.description.workbuddy' | 'agents.description.antigravityCli' | 'agents.description.kimiCode' | 'agents.description.grokBuild' | 'agents.description.omp' | 'agents.description.pi'; }; type AgentSubpageId = 'core' | 'management' | 'sessions'; @@ -276,6 +277,12 @@ const agentDefinitions: AgentDefinition[] = [ icon: opencodeIcon, descriptionKey: 'agents.description.opencode', }, + { + id: 'omp', + name: 'Oh My Pi', + icon: piIcon, + descriptionKey: 'agents.description.omp', + }, { id: 'pi', name: 'Pi', @@ -341,6 +348,7 @@ const agentSubpages: AgentSubpageDefinition[] = [ 'workbuddy', 'antigravity-cli', 'kimi-code', + 'omp', 'grok-build', 'pi', ], diff --git a/src/services/agentConfigurationDraft.ts b/src/services/agentConfigurationDraft.ts index 3e1a685..a820764 100644 --- a/src/services/agentConfigurationDraft.ts +++ b/src/services/agentConfigurationDraft.ts @@ -9,6 +9,7 @@ export type AgentConfigurationClientId = | 'zcode' | 'kimi-code' | 'grok-build' + | 'omp' | 'pi'; export type AgentConfigurationModificationState = 'unconfigured' | 'applied' | 'invalid'; diff --git a/tests/agent-client-list-ui.cjs b/tests/agent-client-list-ui.cjs index 7fbc224..8f7af38 100644 --- a/tests/agent-client-list-ui.cjs +++ b/tests/agent-client-list-ui.cjs @@ -104,7 +104,7 @@ const screenshots = path.join(os.tmpdir(), 'easycliproxy-agent-clients'); // Search reaches uninstalled clients; a cancelled draft never affects navigation or storage. await manage().click(); - assert.equal(await dialog().getByRole('checkbox').count(), 13); + assert.equal(await dialog().getByRole('checkbox').count(), 14); await search().fill('herMES'); assert.equal(await dialog().getByRole('checkbox').count(), 1); await checkbox('Hermes Agent').check(); @@ -173,7 +173,7 @@ const screenshots = path.join(os.tmpdir(), 'easycliproxy-agent-clients'); await open('not-installed&no-plugin&fresh'); await waitClients(['Codex']); await manage().click(); - assert.equal(await dialog().getByRole('checkbox').count(), 13); + assert.equal(await dialog().getByRole('checkbox').count(), 14); await closeAndFocus(() => cancel().click()); await open('mixed-clients&fail-detection'); await page.getByText(/模拟客户端检测失败/).waitFor(); @@ -191,11 +191,11 @@ const screenshots = path.join(os.tmpdir(), 'easycliproxy-agent-clients'); // Every saved client is reachable exactly once, in the same order, without a second switcher. await page.setViewportSize({ width: 1368, height: 912 }); - const twelve = ['claude-code', 'claude-desktop', 'codex', 'deepseek-harness', 'opencode', 'pi', + const savedClients = ['claude-code', 'claude-desktop', 'codex', 'deepseek-harness', 'opencode', 'omp', 'pi', 'grok-build', 'antigravity-cli', 'workbuddy', 'zcode', 'kimi-code', 'openclaw']; - const twelveNames = ['Claude Code', 'Claude Desktop', 'Codex', 'DeepSeek Harness', 'OpenCode', 'Pi', + const savedNames = ['Claude Code', 'Claude Desktop', 'Codex', 'DeepSeek Harness', 'OpenCode', 'Oh My Pi', 'Pi', 'Grok Build', 'Antigravity CLI', 'WorkBuddy', 'ZCode', 'Kimi Code', 'OpenClaw']; - await page.evaluate(({ key, ids }) => localStorage.setItem(key, JSON.stringify(ids)), { key: storageKey, ids: twelve }); + await page.evaluate(({ key, ids }) => localStorage.setItem(key, JSON.stringify(ids)), { key: storageKey, ids: savedClients }); await page.goto(`${base}/tests/fixtures/agent-backups.html?shell&client=claude-code`, { waitUntil: 'domcontentloaded' }); await ready(); const previous = () => page.getByRole('button', { name: '上一页客户端', exact: true }); @@ -216,7 +216,7 @@ const screenshots = path.join(os.tmpdir(), 'easycliproxy-agent-clients'); const collectPages = async () => { while (await previous().isEnabled()) await previous().click(); const names = []; - for (let index = 0; index < 13; index++) { + for (let index = 0; index < savedClients.length; index++) { await assertFits(); names.push(...await namesOnPage()); if (await next().isDisabled()) break; @@ -225,33 +225,33 @@ const screenshots = path.join(os.tmpdir(), 'easycliproxy-agent-clients'); return names; }; assert.equal(await page.getByRole('button', { name: '切换客户端', exact: true }).count(), 0); - assert.deepEqual(await collectPages(), twelveNames, 'no saved client is omitted, duplicated or reordered'); - assert.deepEqual(JSON.parse(await stored()), twelve, 'browsing leaves the management list unchanged'); + assert.deepEqual(await collectPages(), savedNames, 'no saved client is omitted, duplicated or reordered'); + assert.deepEqual(JSON.parse(await stored()), savedClients, 'browsing leaves the management list unchanged'); assert.equal(await page.evaluate(() => localStorage.getItem('cpa-gui.agent-selected-client.v1')), 'claude-code', 'browsing pages does not select a different client'); const lastPage = await namesOnPage(); await page.locator('.agent-list-items button').filter({ has: page.getByText('OpenClaw', { exact: true }) }).click(); assert.deepEqual(await namesOnPage(), lastPage, 'selecting a client does not replace or shuffle any row'); assert.equal(await activeClient(), 'OpenClaw'); - assert.deepEqual(JSON.parse(await stored()), twelve, 'selecting leaves the management list unchanged'); + assert.deepEqual(JSON.parse(await stored()), savedClients, 'selecting leaves the management list unchanged'); await page.setViewportSize({ width: 360, height: 600 }); await page.waitForFunction(() => document.querySelector('.agent-list-items button.active strong')?.textContent === 'OpenClaw' && document.querySelectorAll('.agent-list-items button').length <= 2); await assertFits(); - assert.deepEqual(await collectPages(), twelveNames, 'all managed clients remain reachable after resizing'); - assert.deepEqual(JSON.parse(await stored()), twelve); + assert.deepEqual(await collectPages(), savedNames, 'all managed clients remain reachable after resizing'); + assert.deepEqual(JSON.parse(await stored()), savedClients); // Add/remove only in management. Cancel is inert; Save controls the entire list. await manage().click(); await checkbox('Hermes Agent').check(); await checkbox('OpenClaw').uncheck(); await closeAndFocus(() => cancel().click()); - assert.deepEqual(JSON.parse(await stored()), twelve); + assert.deepEqual(JSON.parse(await stored()), savedClients); assert.equal(await activeClient(), 'OpenClaw'); await manage().click(); await checkbox('Hermes Agent').check(); await checkbox('OpenClaw').uncheck(); await closeAndFocus(() => save().click()); - const updatedNames = [...twelveNames.filter(name => name !== 'OpenClaw'), 'Hermes Agent']; + const updatedNames = [...savedNames.filter(name => name !== 'OpenClaw'), 'Hermes Agent']; assert.equal(await activeClient(), 'Claude Code', 'explicitly removing the current client selects a remaining client'); assert.deepEqual(await collectPages(), updatedNames); await page.locator('.agent-list-items button').filter({ has: page.getByText('Hermes Agent', { exact: true }) }).click(); diff --git a/tests/agent-management-ui.cjs b/tests/agent-management-ui.cjs index dd923c1..7a11cec 100644 --- a/tests/agent-management-ui.cjs +++ b/tests/agent-management-ui.cjs @@ -153,7 +153,7 @@ const path = require('node:path'); await open(mode + 'client=kimi-code'); assert.equal(await button('重启 App').count(), 0); - for (const client of ['claude-code', 'claude-desktop', 'opencode', 'openclaw', 'hermes', 'deepseek-harness', 'zcode', 'workbuddy', 'antigravity-cli', 'kimi-code', 'grok-build']) { + for (const client of ['claude-code', 'claude-desktop', 'opencode', 'openclaw', 'hermes', 'deepseek-harness', 'zcode', 'workbuddy', 'antigravity-cli', 'kimi-code', 'grok-build', 'omp']) { await open(mode + 'client=' + client); assert.ok(await button('关闭配置修改').isEnabled()); await tab('配置管理').click(); diff --git a/tests/agentModelPicker.test.ts b/tests/agentModelPicker.test.ts index 732e663..7b88b8a 100644 --- a/tests/agentModelPicker.test.ts +++ b/tests/agentModelPicker.test.ts @@ -154,6 +154,7 @@ describe('agent configuration update action', () => { 'zcode', 'kimi-code', 'grok-build', + 'omp', ])('%s updates only while its model draft differs', (client) => { expect(appliedConfiguration(client, 'model-b')).toBe('update'); expect(appliedConfiguration(client, ' MODEL-A ')).toBe('close'); diff --git a/tests/fixtures/agent-backups.tsx b/tests/fixtures/agent-backups.tsx index ffb96e3..397822d 100644 --- a/tests/fixtures/agent-backups.tsx +++ b/tests/fixtures/agent-backups.tsx @@ -9,7 +9,7 @@ if(params.has('reset-selections'))localStorage.removeItem('cpa-gui.agent-model-s localStorage.setItem('easy-cli-proxy-api.locale',params.get('locale') || 'zh-CN'); document.documentElement.dataset.theme = params.get('theme') || 'light'; localStorage.setItem('cpa-gui.agent-selected-client.v1', params.get('client') || 'codex'); -const ids = ['claude-code','claude-desktop','codex','opencode','openclaw','hermes','deepseek-harness','antigravity-cli','workbuddy','zcode','kimi-code','grok-build','pi']; +const ids = ['claude-code','claude-desktop','codex','opencode','openclaw','hermes','deepseek-harness','antigravity-cli','workbuddy','zcode','kimi-code','grok-build','omp','pi']; let count=0; let backupCount=0; const backups:any[]=[]; let currentModel=params.has('fresh')?null:'gpt-one'; let nativeOauth=params.has('native-oauth'); let nativeSwitchCount=0; let codexClosed=false; let closeCount=0; let currentOauth=false; const currentMappings:Record={};