Add Oh My Pi support through the OpenAI Responses API

Discover CPA models dynamically and route the managed provider to /v1/responses.
This commit is contained in:
lzt404 committed 2026-10-08 18:36:58 +08:00
1 parent aba62fa6bf
commit d7e39e8fdc
23 files changed
+414 -20

No files matched your search

+1
View File
@@ -111,6 +111,7 @@ OpenAI、Claude、Gemini、およびその他の互換形式の間で変換で
- Antigravity CLI
- Kimi Code
- Grok Build
- Oh My Pi
対応クライアントでは、利用可能なモデルカタログの同期、デフォルトモデルの選択、管理設定を適用する前の
元設定のバックアップ、以前の設定への復元、利用可能なデスクトップまたは CLI エントリーポイントの起動ができます。
+1
View File
@@ -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
+1
View File
@@ -119,6 +119,7 @@ API 接入页面按照协议或 Provider 管理上游 API 凭证和服务地址
- Antigravity CLI
- Kimi Code
- Grok Build
- Oh My Pi
对于受支持的客户端,软件可以同步可用模型目录、选择默认模型、在应用托管配置前备份原始配置,
以及恢复之前的配置。
+2
View File
@@ -87,6 +87,7 @@ fn clients() -> Vec<AgentClient> {
"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())
+1
View File
@@ -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());
+194 -1
View File
@@ -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::<serde_json::Value>(actual).ok()
== json5::from_str::<serde_json::Value>(expected).ok()
}
AgentClient::Hermes => {
AgentClient::Hermes | AgentClient::Omp => {
serde_norway::from_str::<serde_norway::Value>(actual).ok()
== serde_norway::from_str::<serde_norway::Value>(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<String, String> {
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<String, String> {
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<Images, String> {
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<Option<String>, 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<Option<String>, 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,
+89
View File
@@ -52,6 +52,10 @@ pub(crate) fn agent_config_paths(client: AgentClient, home: &Path) -> Vec<PathBu
AgentClient::ZCode => 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<String> {
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>), 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<bool, String> {
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<PathBuf> {
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,
+30 -2
View File
@@ -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<String, String> {
@@ -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::<Vec<_>>()
.join(" ");
let assignments = environment_to_set
.iter()
.map(|(key, value)| format!("{}={}", shell_single_quote(key), shell_single_quote(value)))
.collect::<Vec<_>>()
.join(" ");
let invocation = std::iter::once(shell_single_quote(&path_to_string(executable)))
.chain(
arguments
@@ -1202,9 +1220,10 @@ fn launch_cli_agent(
.collect::<Vec<_>>()
.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}"))
+5 -1
View File
@@ -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::<serde_yaml::Value>(actual).ok()
== serde_yaml::from_str::<serde_yaml::Value>(expected).ok()
}
AgentClient::DeepSeekHarness => {
AgentClient::Omp | AgentClient::DeepSeekHarness => {
serde_norway::from_str::<serde_norway::Value>(actual).ok()
== serde_norway::from_str::<serde_norway::Value>(expected).ok()
}
+1
View File
@@ -78,6 +78,7 @@ fn tracked_configuration_paths(app: &tauri::AppHandle) -> Result<Vec<PathBuf>, S
AgentClient::AntigravityCli,
AgentClient::KimiCode,
AgentClient::GrokBuild,
AgentClient::Omp,
] {
paths.extend(agent_managed_paths(client, &home));
}
+10
View File
@@ -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"],
}
}
}
@@ -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));
}
+1
View File
@@ -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));
+1
View File
@@ -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': '前のクライアントページ',
+1
View File
@@ -1159,6 +1159,7 @@ export const en: Record<MessageKey, string> = {
'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',
+1
View File
@@ -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': '上一页客户端',
+1
View File
@@ -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 = {
+9 -1
View File
@@ -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',
],
+1
View File
@@ -9,6 +9,7 @@ export type AgentConfigurationClientId =
| 'zcode'
| 'kimi-code'
| 'grok-build'
| 'omp'
| 'pi';
export type AgentConfigurationModificationState = 'unconfigured' | 'applied' | 'invalid';
+13 -13
View File
@@ -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();
+1 -1
View File
@@ -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();
+1
View File
@@ -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');
+1 -1
View File
@@ -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<string,any>={};