diff --git a/src-tauri/src/agents/backups/tests.rs b/src-tauri/src/agents/backups/tests.rs index 589d530..e8df370 100644 --- a/src-tauri/src/agents/backups/tests.rs +++ b/src-tauri/src/agents/backups/tests.rs @@ -476,6 +476,37 @@ fn clear_integration_needs_no_backup_and_removes_state_for_each_client() { } } +#[test] +fn zcode_provider_config_restores_an_existing_provider_selection() { + let home = Home::new(); + let paths = config_paths("zcode", &home.0).unwrap(); + let original = serde_json::json!({ + "schemaVersion": 1, + "config": { + "providerConfigRules": {"providerRules": [{ + "providerId": "other", + "providerName": "Other", + "config": { + "group": "standard-personal", + "access": {"type": "api-key", "apiKey": "other-secret"}, + "api": {"type": "anthropic-messages", "baseUrl": "https://example.com/api"}, + "personalModelIds": ["other-model"] + } + }]}, + "modelConfigRules": {"providerModelRules": [], "manualProviderModelRules": []}, + "defaultModelSelection": {"providerId": "other", "modelId": "other-model"} + } + }); + save(&paths[0], render(&paths[0], &original).unwrap()); + apply(&home.0, AgentClient::ZCode, "gpt-one").unwrap(); + let applied = parse(&paths[0], text(read_agent_bytes(&paths[0]).unwrap().as_deref()).unwrap()).unwrap(); + assert_eq!(applied["config"]["defaultModelSelection"]["providerId"], "cpa-gui"); + assert_eq!(applied["config"]["providerConfigRules"]["providerRules"].as_array().unwrap().len(), 2); + clear_agent_managed_configuration(AgentClient::ZCode, &home.0, 8317).unwrap(); + let restored = parse(&paths[0], text(read_agent_bytes(&paths[0]).unwrap().as_deref()).unwrap()).unwrap(); + assert_eq!(restored, original); +} + #[test] fn clear_integration_removes_claude_model_overrides_but_keeps_custom_settings() { let home = Home::new(); @@ -1200,6 +1231,7 @@ fn nested_model_options_and_third_party_providers_survive_all_updates() { let mut root = parse(path, text(raw.as_deref()).unwrap()).unwrap(); let container = match client { AgentClient::Codex if path == &paths[0] => Some("/model_providers"), + AgentClient::ZCode if path == &paths[0] => None, AgentClient::OpenCode | AgentClient::ZCode => Some("/provider"), AgentClient::OpenClaw => Some("/models/providers"), AgentClient::DeepSeekHarness if path == &paths[0] => Some("/llm-pi-ai/providers"), @@ -1221,10 +1253,28 @@ fn nested_model_options_and_third_party_providers_survive_all_updates() { serde_json::json!({"key":"keep-secret","nested":{"enabled":true}}), )); } + if client == AgentClient::ZCode && path == &paths[0] { + let pointer = "/config/providerConfigRules/providerRules"; + let third_party = serde_json::json!({ + "providerId": "third-party", + "providerName": "Third Party", + "config": { + "group": "standard-personal", + "access": {"type": "api-key", "apiKey": "keep-secret"}, + "api": {"type": "anthropic-messages", "baseUrl": "https://example.com/api"}, + "personalModelIds": ["third-model"] + } + }); + let rules = root.pointer_mut(pointer).unwrap().as_array_mut().unwrap(); + let index = rules.len(); + rules.push(third_party.clone()); + touched.push((path.clone(), format!("{pointer}/{index}"), third_party)); + } let inventory = match client { AgentClient::ClaudeDesktop if path == &paths[2] => { Some("/inferenceModels/0".to_string()) } + AgentClient::ZCode if path == &paths[0] => None, AgentClient::OpenCode | AgentClient::ZCode => { Some("/provider/cpa-gui/models/gpt-one".into()) } @@ -1246,6 +1296,13 @@ fn nested_model_options_and_third_party_providers_survive_all_updates() { .insert("extensions".into(), extensions.clone()); touched.push((path.clone(), format!("{pointer}/extensions"), extensions)); } + if client == AgentClient::ZCode && path == &paths[0] { + let pointer = "/config/modelConfigRules/providerModelRules/0/config/properties/supportsToolCall"; + root.pointer_mut("/config/modelConfigRules/providerModelRules/0/config/properties") + .unwrap().as_object_mut().unwrap() + .insert("supportsToolCall".into(), serde_json::json!(true)); + touched.push((path.clone(), pointer.into(), serde_json::json!(true))); + } save(path, render(path, &root).unwrap()); } let catalog = catalog("gpt-one"); diff --git a/src-tauri/src/agents/configuration.rs b/src-tauri/src/agents/configuration.rs index bfd5313..ff235f9 100644 --- a/src-tauri/src/agents/configuration.rs +++ b/src-tauri/src/agents/configuration.rs @@ -196,29 +196,11 @@ pub(crate) fn build_agent_updates_with_oauth( }]) } AgentClient::ZCode => { - let app_before = read_optional_text(&paths[0])?; - let cli_before = read_optional_text(&paths[1])?; - let app_after = - build_zcode_agent_config(app_before.as_deref(), &root_base, api_key, model, models) - ?; - let cli_after = build_zcode_cli_agent_config( - cli_before.as_deref(), - &root_base, - api_key, - model, - models, - ) - ?; - Ok(vec![ - AgentFileUpdate { - path: paths[0].clone(), - after: app_after, - }, - AgentFileUpdate { - path: paths[1].clone(), - after: cli_after, - }, - ]) + let before = read_optional_text(&paths[0])?; + let after = build_zcode_agent_config( + before.as_deref(), &root_base, api_key, model, models, + )?; + Ok(vec![AgentFileUpdate { path: paths[0].clone(), after }]) } AgentClient::KimiCode => { let before = read_optional_text(&paths[0])?; @@ -1273,59 +1255,47 @@ pub(crate) fn prepare_opencode_managed_removal(paths: &[PathBuf]) -> Result Result { - if paths.is_empty() { - return Err("ZCode configuration path is unavailable on the current platform".to_string()); - } - let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/"); - let mut changed_paths = Vec::new(); - for path in paths { - let updated = prepare_agent_json_removal(path, "ZCode configuration", |root| { - let mut changed = false; - let remove_model = if root - .get("model") - .and_then(serde_json::Value::as_str) - .is_some_and(|model| model.starts_with(&prefix)) - { - true - } else if let Some(model) = root - .get_mut("model") - .and_then(serde_json::Value::as_object_mut) - { - if model - .get("main") - .and_then(serde_json::Value::as_str) - .is_some_and(|model| model.starts_with(&prefix)) - { - model.remove("main"); - changed = true; - } - model.is_empty() - } else { - false - }; - if remove_model { - root.remove("model"); - changed = true; - } - let providers_empty = if let Some(providers) = root - .get_mut("provider") - .and_then(serde_json::Value::as_object_mut) - { - changed |= providers.remove(MANAGED_AGENT_PROVIDER_ID).is_some(); - providers.is_empty() - } else { - false - }; - if providers_empty { - root.remove("provider"); - } - changed - })?; - if let Some(update) = updated { - changed_paths.push(update); + let path = paths.first().ok_or("ZCode provider configuration path is unavailable")?; + let updated = prepare_agent_json_removal(path, "ZCode provider configuration", |root| { + let Some(config) = root.get_mut("config").and_then(serde_json::Value::as_object_mut) else { + return false; + }; + let mut changed = false; + if config.get("defaultModelSelection") + .and_then(|selection| selection.get("providerId")) + .and_then(serde_json::Value::as_str) == Some(MANAGED_AGENT_PROVIDER_ID) + { + config.remove("defaultModelSelection"); + changed = true; } - } - Ok(changed_paths) + if let Some(order) = config.get_mut("providerOrder").and_then(serde_json::Value::as_array_mut) { + let old_len = order.len(); + order.retain(|item| item.as_str() != Some(MANAGED_AGENT_PROVIDER_ID)); + changed |= order.len() != old_len; + } + if let Some(rules) = config.get_mut("providerConfigRules") + .and_then(|value| value.get_mut("providerRules")) + .and_then(serde_json::Value::as_array_mut) + { + let old_len = rules.len(); + rules.retain(|rule| rule.get("providerId").and_then(serde_json::Value::as_str) + != Some(MANAGED_AGENT_PROVIDER_ID)); + changed |= rules.len() != old_len; + } + for key in ["providerModelRules", "manualProviderModelRules"] { + if let Some(rules) = config.get_mut("modelConfigRules") + .and_then(|value| value.get_mut(key)) + .and_then(serde_json::Value::as_array_mut) + { + let old_len = rules.len(); + rules.retain(|rule| rule.get("providerId").and_then(serde_json::Value::as_str) + != Some(MANAGED_AGENT_PROVIDER_ID)); + changed |= rules.len() != old_len; + } + } + changed + })?; + Ok(updated.into_iter().collect()) } pub(crate) fn prepare_kimi_code_managed_removal(paths: &[PathBuf]) -> Result { @@ -1972,84 +1942,96 @@ pub(crate) fn build_restored_opencode_config( })) } -pub(crate) fn build_restored_zcode_config( +pub(crate) fn build_restored_zcode_provider_config( current: &str, original: Option<&str>, ) -> Result, String> { - let mut root = parse_agent_json_object(Some(current), "Current ZCode configuration")?; - let original_root = parse_restored_json_object(original, "Original ZCode configuration")?; - restore_json_key(&mut root, original_root.as_ref(), "model"); - let original_provider = original_root - .as_ref() - .and_then(|root| root.get("provider")) - .and_then(serde_json::Value::as_object); - let original_managed = original_provider - .and_then(|providers| providers.get(MANAGED_AGENT_PROVIDER_ID)) - .and_then(serde_json::Value::as_object); - if root - .get("provider") - .is_some_and(serde_json::Value::is_object) - { - let providers = root - .get_mut("provider") - .and_then(serde_json::Value::as_object_mut) - .expect("ZCode provider was checked as an object"); - if providers - .get(MANAGED_AGENT_PROVIDER_ID) - .is_some_and(serde_json::Value::is_object) - { - let managed = providers - .get_mut(MANAGED_AGENT_PROVIDER_ID) - .and_then(serde_json::Value::as_object_mut) - .expect("ZCode managed provider was checked as an object"); - for key in [ - "enabled", - "name", - "source", - "kind", - "defaultKind", - "apiFormat", - "npm", - "models", - ] { - restore_json_key(managed, original_managed, key); - } - let original_options = original_managed - .and_then(|managed| managed.get("options")) - .and_then(serde_json::Value::as_object); - if managed - .get("options") - .is_some_and(serde_json::Value::is_object) - { - let options = managed - .get_mut("options") - .and_then(serde_json::Value::as_object_mut) - .expect("ZCode options were checked as an object"); - for key in ["baseURL", "apiKey"] { - restore_json_key(options, original_options, key); - } - if options.is_empty() && original_options.is_none() { - managed.remove("options"); - } - } else { - restore_json_key(managed, original_managed, "options"); - } - if managed.is_empty() && original_managed.is_none() { - providers.remove(MANAGED_AGENT_PROVIDER_ID); - } - } else if let Some(original_managed) = original_provider - .and_then(|providers| providers.get(MANAGED_AGENT_PROVIDER_ID)) - .cloned() - { - providers.insert(MANAGED_AGENT_PROVIDER_ID.to_string(), original_managed); - } - if providers.is_empty() && original_provider.is_none() { - root.remove("provider"); - } - } else { - restore_json_key(&mut root, original_root.as_ref(), "provider"); + let mut root = serde_json::Value::Object(parse_agent_json_object( + Some(current), "Current ZCode provider configuration", + )?); + let original_root = parse_restored_json_object(original, "Original ZCode provider configuration")? + .map(serde_json::Value::Object); + if root.pointer("/config").is_none() { + return render_restored_json( + root.as_object().cloned().ok_or("Invalid ZCode provider configuration")?, + original.is_some(), + "Restored ZCode provider configuration", + ); } - render_restored_json(root, original.is_some(), "Restored ZCode configuration") + let config = root.pointer_mut("/config").and_then(serde_json::Value::as_object_mut) + .ok_or("ZCode provider configuration has invalid config")?; + let original_config = original_root.as_ref().and_then(|value| value.pointer("/config")); + if original.is_none() || config.get("defaultModelSelection") + .and_then(|selection| selection.get("providerId")) + .and_then(serde_json::Value::as_str) == Some(MANAGED_AGENT_PROVIDER_ID) + { + if let Some(selection) = original_config.and_then(|value| value.get("defaultModelSelection")) { + config.insert("defaultModelSelection".to_string(), selection.clone()); + } else { + config.remove("defaultModelSelection"); + } + } + if let Some(order) = config.get_mut("providerOrder").and_then(serde_json::Value::as_array_mut) { + order.retain(|item| item.as_str() != Some(MANAGED_AGENT_PROVIDER_ID)); + if let Some(original_order) = original_config + .and_then(|value| value.get("providerOrder")) + .and_then(serde_json::Value::as_array) + { + if let Some(index) = original_order.iter().position(|item| item.as_str() == Some(MANAGED_AGENT_PROVIDER_ID)) { + order.insert(index.min(order.len()), serde_json::json!(MANAGED_AGENT_PROVIDER_ID)); + } + } + } + for pointer in [ + "/providerConfigRules/providerRules", + "/modelConfigRules/providerModelRules", + "/modelConfigRules/manualProviderModelRules", + ] { + let Some(rules) = root.pointer(&format!("/config{pointer}")) + .and_then(serde_json::Value::as_array).cloned() else { + continue; + }; + let mut retained = rules.into_iter().filter(|rule| { + rule.get("providerId").and_then(serde_json::Value::as_str) + != Some(MANAGED_AGENT_PROVIDER_ID) + }).collect::>(); + if let Some(original_rules) = original_config.and_then(|value| value.pointer(pointer)) + .and_then(serde_json::Value::as_array) + { + for (index, rule) in original_rules.iter().enumerate() { + if rule.get("providerId").and_then(serde_json::Value::as_str) + == Some(MANAGED_AGENT_PROVIDER_ID) + { + retained.insert(index.min(retained.len()), rule.clone()); + } + } + } + if let Some(target) = root.pointer_mut(&format!("/config{pointer}")) { + *target = serde_json::Value::Array(retained); + } + } + if original.is_none() { + let config = root.get("config"); + let only_empty_scaffold = config + .and_then(serde_json::Value::as_object) + .is_some_and(|config| { + config.get("defaultModelSelection").is_none() + && config.get("providerOrder").and_then(serde_json::Value::as_array) + .is_none_or(Vec::is_empty) + && root.pointer("/config/providerConfigRules/providerRules") + .and_then(serde_json::Value::as_array).is_some_and(Vec::is_empty) + && root.pointer("/config/modelConfigRules/providerModelRules") + .and_then(serde_json::Value::as_array).is_some_and(Vec::is_empty) + && root.pointer("/config/modelConfigRules/manualProviderModelRules") + .and_then(serde_json::Value::as_array).is_some_and(Vec::is_empty) + && config.len() <= 3 + }); + if only_empty_scaffold && root.as_object().is_some_and(|root| root.len() <= 2) { + return Ok(None); + } + } + render_agent_json(root.as_object().cloned().ok_or("Invalid ZCode provider configuration")?, + "Restored ZCode provider configuration").map(Some) } pub(crate) fn build_restored_kimi_code_config( @@ -2677,7 +2659,7 @@ pub(crate) fn build_agent_session_restored_bytes_with_preference( _ => return Err("Invalid DeepSeek Harness restoration path index".to_string()), } } - AgentClient::ZCode => build_restored_zcode_config(current, original)?, + AgentClient::ZCode => build_restored_zcode_provider_config(current, original)?, AgentClient::WorkBuddy => build_restored_workbuddy_config(current, original)?, AgentClient::AntigravityCli => restore_antigravity_config(client, path, current, original)?, AgentClient::KimiCode => build_restored_kimi_code_config(current, original)?, @@ -3077,67 +3059,149 @@ pub(crate) fn build_zcode_agent_config( model: &str, available_models: &[AgentModelOption], ) -> Result { - build_zcode_config(existing, base_url, api_key, model, available_models, false) -} - -pub(crate) fn build_zcode_cli_agent_config( - existing: Option<&str>, - base_url: &str, - api_key: &str, - model: &str, - available_models: &[AgentModelOption], -) -> Result { - build_zcode_config(existing, base_url, api_key, model, available_models, true) -} - -fn build_zcode_config( - existing: Option<&str>, - base_url: &str, - api_key: &str, - model: &str, - available_models: &[AgentModelOption], - cli_config: bool, -) -> Result { - let mut root = parse_agent_json_object(existing, "ZCode config.json")?; - let providers = ensure_json_object_entry(&mut root, "provider"); - let models = ordered_agent_models(available_models, model) - .into_iter() - .map(|model| { - let display_name = model.alias.as_deref().unwrap_or(&model.name).to_string(); - let mut entry = serde_json::Map::new(); - entry.insert("name".to_string(), serde_json::json!(display_name)); - if let Some(context_window) = model.context_window { - entry.insert( - "limit".to_string(), - serde_json::json!({ "context": context_window }), - ); - } - (model.name, serde_json::Value::Object(entry)) - }) - .collect::>(); - let managed_provider = ensure_json_object_entry(providers, MANAGED_AGENT_PROVIDER_ID); - managed_provider.remove("npm"); - managed_provider.insert("enabled".to_string(), serde_json::json!(true)); - managed_provider.insert("name".to_string(), serde_json::json!("EasyCLIProxyAPI")); - managed_provider.insert("source".to_string(), serde_json::json!("custom")); - managed_provider.insert("kind".to_string(), serde_json::json!("anthropic")); - managed_provider.insert("defaultKind".to_string(), serde_json::json!("anthropic")); - managed_provider.insert( - "apiFormat".to_string(), - serde_json::json!("anthropic-messages"), - ); - let options = ensure_json_object_entry(managed_provider, "options"); - options.insert("baseURL".to_string(), serde_json::json!(base_url)); - options.insert("apiKey".to_string(), serde_json::json!(api_key)); - managed_provider.insert("models".to_string(), serde_json::Value::Object(models)); - let model = format!("{MANAGED_AGENT_PROVIDER_ID}/{model}"); - if cli_config { - ensure_json_object_entry(&mut root, "model") - .insert("main".to_string(), serde_json::json!(model)); - } else { - root.insert("model".to_string(), serde_json::json!(model)); + let mut root = parse_agent_json_object(existing, "ZCode provider_config.json")?; + if root.get("schemaVersion").is_some_and(|version| version != 1) { + return Err("Unsupported ZCode provider configuration schemaVersion".to_string()); } - render_agent_json(root, "ZCode configuration") + root.insert("schemaVersion".to_string(), serde_json::json!(1)); + let config = ensure_json_object_entry(&mut root, "config"); + let provider_rules = ensure_json_object_entry(config, "providerConfigRules"); + let rules = provider_rules + .entry("providerRules") + .or_insert_with(|| serde_json::json!([])) + .as_array_mut() + .ok_or("ZCode providerRules must be an array")?; + let managed_index = rules.iter().position(|rule| { + rule.get("providerId").and_then(serde_json::Value::as_str) + == Some(MANAGED_AGENT_PROVIDER_ID) + }); + let previous = managed_index.and_then(|index| rules.get(index)); + let mut managed = previous.cloned().unwrap_or_else(|| serde_json::json!({})); + let managed_obj = managed.as_object_mut().ok_or("ZCode managed provider must be an object")?; + managed_obj.insert("providerId".to_string(), serde_json::json!(MANAGED_AGENT_PROVIDER_ID)); + managed_obj.insert("providerName".to_string(), serde_json::json!("EasyCLIProxyAPI")); + managed_obj.insert("enabled".to_string(), serde_json::json!(true)); + let provider_config = ensure_json_object_entry(managed_obj, "config"); + provider_config.insert("group".to_string(), serde_json::json!("standard-personal")); + let access = ensure_json_object_entry(provider_config, "access"); + access.insert("type".to_string(), serde_json::json!("api-key")); + access.insert("apiKey".to_string(), serde_json::json!(api_key)); + let api = ensure_json_object_entry(provider_config, "api"); + api.insert("type".to_string(), serde_json::json!("anthropic-messages")); + api.insert("baseUrl".to_string(), serde_json::json!(base_url)); + let ordered = ordered_agent_models(available_models, model); + let model_names = ordered.iter().map(|option| option.name.as_str()).collect::>(); + provider_config.insert("personalModelIds".to_string(), serde_json::json!(model_names)); + provider_config.insert("modelOrder".to_string(), serde_json::json!(model_names)); + rules.retain(|rule| { + rule.get("providerId").and_then(serde_json::Value::as_str) + != Some(MANAGED_AGENT_PROVIDER_ID) + }); + rules.insert(managed_index.unwrap_or(rules.len()).min(rules.len()), managed); + + let model_rules = ensure_json_object_entry(config, "modelConfigRules"); + let manual_rules = model_rules + .entry("manualProviderModelRules") + .or_insert_with(|| serde_json::json!([])) + .as_array() + .ok_or("ZCode manualProviderModelRules must be an array")?; + let existing_manual_rules = manual_rules.iter().filter_map(|rule| { + if rule.get("providerId").and_then(serde_json::Value::as_str) + != Some(MANAGED_AGENT_PROVIDER_ID) { + return None; + } + Some((rule.get("modelId")?.as_str()?.to_string(), rule.clone())) + }).collect::>(); + let first_manual_index = manual_rules.iter().position(|rule| { + rule.get("providerId").and_then(serde_json::Value::as_str) + == Some(MANAGED_AGENT_PROVIDER_ID) + }).unwrap_or(manual_rules.len()); + let rules = model_rules + .entry("providerModelRules") + .or_insert_with(|| serde_json::json!([])) + .as_array_mut() + .ok_or("ZCode providerModelRules must be an array")?; + let existing_rules = rules.iter().filter_map(|rule| { + if rule.get("providerId").and_then(serde_json::Value::as_str) + != Some(MANAGED_AGENT_PROVIDER_ID) { + return None; + } + Some((rule.get("modelId")?.as_str()?.to_string(), rule.clone())) + }).collect::>(); + let first_managed_index = rules.iter().position(|rule| { + rule.get("providerId").and_then(serde_json::Value::as_str) + == Some(MANAGED_AGENT_PROVIDER_ID) + }).unwrap_or(rules.len()); + rules.retain(|rule| { + rule.get("providerId").and_then(serde_json::Value::as_str) + != Some(MANAGED_AGENT_PROVIDER_ID) + }); + let mut manual_updates = Vec::new(); + for option in ordered { + let manual = existing_manual_rules.get(&option.name); + if option.context_window.is_none() + && manual.is_none() + && !existing_rules.contains_key(&option.name) + { + continue; + } + let mut rule = manual.or_else(|| existing_rules.get(&option.name)) + .cloned().unwrap_or_else(|| serde_json::json!({})); + let entry = rule.as_object_mut().ok_or("ZCode model rule must be an object")?; + entry.insert("providerId".to_string(), serde_json::json!(MANAGED_AGENT_PROVIDER_ID)); + entry.insert("modelId".to_string(), serde_json::json!(option.name)); + let properties = ensure_json_object_entry(ensure_json_object_entry(entry, "config"), "properties"); + if let Some(context_window) = option.context_window { + properties.insert("contextWindow".to_string(), serde_json::json!(context_window)); + } else { + properties.remove("contextWindow"); + } + if properties.is_empty() { + entry.get_mut("config").and_then(serde_json::Value::as_object_mut) + .expect("ZCode model config was created").remove("properties"); + } + if manual.is_some() { + manual_updates.push(rule); + } else { + rules.insert((first_managed_index + rules.iter().filter(|entry| { + entry.get("providerId").and_then(serde_json::Value::as_str) + == Some(MANAGED_AGENT_PROVIDER_ID) + }).count()).min(rules.len()), rule); + } + } + let manual_rules = model_rules.get_mut("manualProviderModelRules") + .and_then(serde_json::Value::as_array_mut) + .expect("ZCode manual model rules were checked as an array"); + manual_rules.retain(|rule| { + rule.get("providerId").and_then(serde_json::Value::as_str) + != Some(MANAGED_AGENT_PROVIDER_ID) + }); + for (offset, rule) in manual_updates.into_iter().enumerate() { + manual_rules.insert((first_manual_index + offset).min(manual_rules.len()), rule); + } + if let Some(provider_order) = config.get_mut("providerOrder") { + let provider_order = provider_order.as_array_mut() + .ok_or("ZCode providerOrder must be an array")?; + if !provider_order.iter().any(|value| value.as_str() == Some(MANAGED_AGENT_PROVIDER_ID)) { + provider_order.push(serde_json::json!(MANAGED_AGENT_PROVIDER_ID)); + } + } + let mut selection = serde_json::json!({ + "providerId": MANAGED_AGENT_PROVIDER_ID, + "modelId": model, + }); + if let Some(previous) = config.get("defaultModelSelection") { + if previous.get("providerId").and_then(serde_json::Value::as_str) + == Some(MANAGED_AGENT_PROVIDER_ID) + && previous.get("modelId").and_then(serde_json::Value::as_str) == Some(model) + { + if let Some(options) = previous.get("options") { + selection["options"] = options.clone(); + } + } + } + config.insert("defaultModelSelection".to_string(), selection); + render_agent_json(root, "ZCode provider configuration") } pub(crate) fn ensure_toml_child_table<'a>( diff --git a/src-tauri/src/agents/discovery.rs b/src-tauri/src/agents/discovery.rs index 41ed055..9707c5a 100644 --- a/src-tauri/src/agents/discovery.rs +++ b/src-tauri/src/agents/discovery.rs @@ -49,10 +49,7 @@ pub(crate) fn agent_config_paths(client: AgentClient, home: &Path) -> Vec vec![workbuddy_home(home).join("models.json")], AgentClient::AntigravityCli => antigravity_config_paths(client, home), - AgentClient::ZCode => vec![ - home.join(".zcode/v2").join(ZCODE_CONFIG_FILE), - home.join(".zcode/cli").join(ZCODE_CONFIG_FILE), - ], + 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)], } @@ -1301,17 +1298,11 @@ pub(crate) fn inspect_agent_managed_config( AgentClient::DeepSeekHarness => inspect_deepseek_harness_config(paths, port, api_key) .map(|(configured, model)| (configured, model, false)), AgentClient::ZCode => { - if paths.len() != 2 { + if paths.len() != 1 { return Err("Invalid number of ZCode configuration paths".to_string()); } - let (app_configured, app_model) = inspect_zcode_agent_config(&paths[0], port, api_key)?; - let (cli_configured, cli_model) = inspect_zcode_agent_config(&paths[1], port, api_key)?; - let models_match = app_model.is_some() && app_model == cli_model; - Ok(( - app_configured && cli_configured && models_match, - cli_model.or(app_model), - false, - )) + inspect_zcode_agent_config(&paths[0], port, api_key) + .map(|(configured, model)| (configured, model, false)) } AgentClient::AntigravityCli => inspect_antigravity_config(client, paths, port, api_key).map(|(configured, model)| (configured, model, false)), AgentClient::WorkBuddy => inspect_workbuddy_agent_config(&paths[0], port, api_key) @@ -1525,29 +1516,19 @@ pub(crate) fn agent_has_managed_marker( AgentClient::WorkBuddy => workbuddy_has_managed_marker(&paths[0]), AgentClient::AntigravityCli => antigravity_has_marker(client, paths), AgentClient::ZCode => { - let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/"); - for path in paths { - if !path.is_file() { - continue; - } - let root = read_agent_json_or_empty(path, "ZCode configuration")?; - let provider_exists = root - .get("provider") - .and_then(|value| value.get(MANAGED_AGENT_PROVIDER_ID)) - .is_some(); - let model_selected = root - .get("model") - .and_then(|model| { - model - .as_str() - .or_else(|| model.get("main").and_then(serde_json::Value::as_str)) - }) - .is_some_and(|model| model.starts_with(&prefix)); - if provider_exists && model_selected { - return Ok(true); - } - } - Ok(false) + let Some(path) = paths.first() else { return Ok(false); }; + if !path.is_file() { return Ok(false); } + let root = read_agent_json_or_empty(path, "ZCode provider configuration")?; + let provider_exists = root.pointer("/config/providerConfigRules/providerRules") + .and_then(serde_json::Value::as_array) + .is_some_and(|rules| rules.iter().any(|rule| { + rule.get("providerId").and_then(serde_json::Value::as_str) + == Some(MANAGED_AGENT_PROVIDER_ID) + })); + let model_selected = root.pointer("/config/defaultModelSelection/providerId") + .and_then(serde_json::Value::as_str) + == Some(MANAGED_AGENT_PROVIDER_ID); + Ok(provider_exists && model_selected) } AgentClient::KimiCode => inspect_managed_toml_model_marker( &paths[0], @@ -3300,59 +3281,57 @@ pub(crate) fn inspect_zcode_agent_config( return Ok((false, None)); } let root: serde_json::Value = serde_json::from_str( - &fs::read_to_string(path).map_err(|error| format!("Failed to read ZCode configuration: {error}"))?, + &fs::read_to_string(path).map_err(|error| format!("Failed to read ZCode provider configuration: {error}"))?, ) - .map_err(|error| format!("Failed to parse ZCode configuration: {error}"))?; - let provider = root - .get("provider") - .and_then(|providers| providers.get(MANAGED_AGENT_PROVIDER_ID)); - let expected_base = managed_core_loopback_origin(port); - let api_format_matches = provider - .and_then(|provider| provider.get("apiFormat")) - .and_then(serde_json::Value::as_str) - .is_none_or(|api_format| api_format == "anthropic-messages"); - let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/"); - let model = root - .get("model") - .and_then(|model| { - model - .as_str() - .or_else(|| model.get("main").and_then(serde_json::Value::as_str)) + .map_err(|error| format!("Failed to parse ZCode provider configuration: {error}"))?; + if root.get("schemaVersion").and_then(serde_json::Value::as_u64) != Some(1) { + return Ok((false, None)); + } + let config = root.get("config"); + let provider = config + .and_then(|config| config.get("providerConfigRules")) + .and_then(|rules| rules.get("providerRules")) + .and_then(serde_json::Value::as_array) + .and_then(|rules| rules.iter().find(|rule| { + rule.get("providerId").and_then(serde_json::Value::as_str) + == Some(MANAGED_AGENT_PROVIDER_ID) + })); + let selected = config + .and_then(|config| config.get("defaultModelSelection")); + let model = selected + .filter(|selection| { + selection.get("providerId").and_then(serde_json::Value::as_str) + == Some(MANAGED_AGENT_PROVIDER_ID) }) - .map(str::trim) - .filter(|value| !value.is_empty()) - .and_then(|value| value.strip_prefix(&prefix)) + .and_then(|selection| selection.get("modelId")) + .and_then(serde_json::Value::as_str) .map(str::to_string); - let selected_model_exists = model.as_deref().is_some_and(|selected_model| { + let configured = model.as_deref().is_some_and(|model| { provider - .and_then(|provider| provider.get("models")) - .and_then(serde_json::Value::as_object) - .is_some_and(|models| { - models - .keys() - .any(|model| model.eq_ignore_ascii_case(selected_model)) - }) - }); - let configured = selected_model_exists - && api_format_matches + .and_then(|provider| provider.get("config")) + .and_then(|config| config.get("personalModelIds")) + .and_then(serde_json::Value::as_array) + .is_some_and(|models| models.iter().any(|value| value.as_str() == Some(model))) + }) && provider + .and_then(|provider| provider.get("enabled")) + .and_then(serde_json::Value::as_bool) == Some(true) && provider - .and_then(|provider| provider.get("enabled")) - .and_then(serde_json::Value::as_bool) - == Some(true) + .and_then(|provider| provider.get("config")) + .and_then(|config| config.get("group")) + .and_then(serde_json::Value::as_str) == Some("standard-personal") && provider - .and_then(|provider| provider.get("kind")) + .and_then(|provider| provider.pointer("/config/access/type")) + .and_then(serde_json::Value::as_str) == Some("api-key") + && provider + .and_then(|provider| provider.pointer("/config/access/apiKey")) + .and_then(serde_json::Value::as_str) == Some(api_key) + && provider + .and_then(|provider| provider.pointer("/config/api/type")) + .and_then(serde_json::Value::as_str) == Some("anthropic-messages") + && provider + .and_then(|provider| provider.pointer("/config/api/baseUrl")) .and_then(serde_json::Value::as_str) - == Some("anthropic") - && provider - .and_then(|provider| provider.get("options")) - .and_then(|options| options.get("baseURL")) - .and_then(serde_json::Value::as_str) - == Some(expected_base.as_str()) - && provider - .and_then(|provider| provider.get("options")) - .and_then(|options| options.get("apiKey")) - .and_then(serde_json::Value::as_str) - == Some(api_key); + == Some(managed_core_loopback_origin(port).as_str()); Ok((configured, model)) } diff --git a/src-tauri/src/agents/state.rs b/src-tauri/src/agents/state.rs index b54c19d..8618232 100644 --- a/src-tauri/src/agents/state.rs +++ b/src-tauri/src/agents/state.rs @@ -731,10 +731,9 @@ pub(crate) fn fresh_agent_contents_with_oauth( AgentClient::WorkBuddy => Ok(vec![build_workbuddy_agent_config( None, &openai_base, api_key, model, models, )?]), - AgentClient::ZCode => Ok(vec![ - build_zcode_agent_config(None, &root_base, api_key, model, models)?, - build_zcode_cli_agent_config(None, &root_base, api_key, model, models)?, - ]), + AgentClient::ZCode => Ok(vec![build_zcode_agent_config( + None, &root_base, api_key, model, models, + )?]), AgentClient::KimiCode => Ok(vec![build_kimi_code_agent_config( None, &openai_base, diff --git a/src-tauri/src/agents/transactions.rs b/src-tauri/src/agents/transactions.rs index 8c841e1..c85b2c9 100644 --- a/src-tauri/src/agents/transactions.rs +++ b/src-tauri/src/agents/transactions.rs @@ -675,7 +675,7 @@ fn preserve_model_extensions(client: &str, path: &Path, before: &Value, after: & let mut extensions = before.clone(); let owned: &[&str] = match client { "claude-desktop" => &["name", "labelOverride", "anthropicFamilyTier", "isFamilyDefault", "contextWindow", "supports1m", "prefer1m"], - "opencode" | "zcode" => &["name"], + "opencode" => &["name"], "openclaw" => &["id", "name", "alias"], "deepseek-harness" => &["id", "name", "contextWindow", "input", "maxTokens", "reasoningEfforts", "compat"], "kimi-code" => &[ @@ -703,9 +703,6 @@ fn preserve_model_extensions(client: &str, path: &Path, before: &Value, after: & object.remove(*key); } } - if client == "zcode" { - set(&mut extensions, &["limit".into(), "context".into()], None); - } fill_missing(&extensions, after); } fn inventory(client: &str, before: &Value, after: &mut Value) { @@ -735,7 +732,7 @@ fn preserve_model_extensions(client: &str, path: &Path, before: &Value, after: & &["/models"] } "claude-desktop" => &["/inferenceModels"], - "opencode" | "zcode" => &["/provider/cpa-gui/models"], + "opencode" => &["/provider/cpa-gui/models"], "openclaw" => &[ "/models/providers/cpa-gui/models", "/agents/defaults/models", diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 53a6efb..dca3d5a 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -162,7 +162,7 @@ const DEFAULT_ERROR_LOGS_MAX_FILES: u32 = 10; const DEFAULT_REDIS_USAGE_QUEUE_RETENTION_SECONDS: u32 = 60; const LEGACY_DEFAULT_MANAGEMENT_SECRET_KEY: &str = "123456"; const MANAGED_AGENT_PROVIDER_ID: &str = "cpa-gui"; -const ZCODE_CONFIG_FILE: &str = "config.json"; +const ZCODE_PROVIDER_CONFIG_FILE: &str = "provider_config.json"; const KIMI_CODE_CONFIG_FILE: &str = "config.toml"; const GROK_BUILD_CONFIG_FILE: &str = "config.toml"; const DEEPSEEK_HARNESS_PROVIDER_ID: &str = "easy-cliproxyapi"; diff --git a/src-tauri/src/tests/agent_configuration.rs b/src-tauri/src/tests/agent_configuration.rs index 1ef98e6..e477a5b 100644 --- a/src-tauri/src/tests/agent_configuration.rs +++ b/src-tauri/src/tests/agent_configuration.rs @@ -967,14 +967,14 @@ fn opencode_agent_config_accepts_jsonc_and_preserves_comments() { #[test] fn zcode_agent_config_preserves_other_providers_and_uses_anthropic_messages() { let home = agent_test_home("zcode-agent-config"); - let path = home.join(".zcode/v2/config.json"); + let path = home.join(".zcode/v2/provider_config.json"); fs::create_dir_all(path.parent().unwrap()).unwrap(); let mut models = test_agent_models(&["gpt-test", "deepseek-test"]); models[0].context_window = Some(372_000); models[1].context_window = Some(272_000); let rendered = build_zcode_agent_config( Some( - r#"{"locale":"zh-CN","provider":{"other":{"kind":"openai"},"cpa-gui":{"custom":"keep","npm":"@ai-sdk/anthropic","options":{"timeout":30}}}}"#, + r#"{"schemaVersion":1,"config":{"providerConfigRules":{"providerRules":[{"providerId":"other","providerName":"Other","config":{"group":"standard-personal","personalModelIds":["other-model"]}},{"providerId":"cpa-gui","config":{"access":{"type":"api-key","apiKeyManagementUrl":"https://example.com/keys"}}}]},"modelConfigRules":{"providerModelRules":[{"providerId":"other","modelId":"other-model","config":{"enabled":true}},{"providerId":"cpa-gui","modelId":"gpt-test","config":{"properties":{"supportsToolCall":true}}}],"manualProviderModelRules":[]},"providerOrder":["other","cpa-gui"]}}"#, ), "http://127.0.0.1:8317", DEFAULT_API_KEY, @@ -985,68 +985,47 @@ fn zcode_agent_config_preserves_other_providers_and_uses_anthropic_messages() { fs::write(&path, &rendered).unwrap(); let value: serde_json::Value = serde_json::from_str(&rendered).unwrap(); - assert_eq!(value["locale"], "zh-CN"); - assert_eq!(value["provider"]["other"]["kind"], "openai"); - assert_eq!( - value["provider"][MANAGED_AGENT_PROVIDER_ID]["custom"], - "keep" - ); - assert_eq!( - value["provider"][MANAGED_AGENT_PROVIDER_ID]["options"]["timeout"], - 30 - ); - assert_eq!( - value["provider"][MANAGED_AGENT_PROVIDER_ID]["kind"], - "anthropic" - ); - assert_eq!( - value["provider"][MANAGED_AGENT_PROVIDER_ID]["apiFormat"], - "anthropic-messages" - ); - assert!(value["provider"][MANAGED_AGENT_PROVIDER_ID] - .get("npm") - .is_none()); - assert_eq!( - value["provider"][MANAGED_AGENT_PROVIDER_ID]["options"]["baseURL"], - "http://127.0.0.1:8317" - ); - assert_eq!(value["model"], "cpa-gui/gpt-test"); - assert_eq!( - value["provider"][MANAGED_AGENT_PROVIDER_ID]["models"]["gpt-test"]["limit"]["context"], - 372_000 - ); - assert_eq!( - value["provider"][MANAGED_AGENT_PROVIDER_ID]["models"]["deepseek-test"]["limit"]["context"], - 272_000 - ); + assert_eq!(value["schemaVersion"], 1); + let rules = value["config"]["providerConfigRules"]["providerRules"].as_array().unwrap(); + assert_eq!(rules[0]["providerId"], "other"); + let managed = rules.iter().find(|rule| rule["providerId"] == MANAGED_AGENT_PROVIDER_ID).unwrap(); + assert_eq!(managed["config"]["access"]["apiKeyManagementUrl"], "https://example.com/keys"); + assert_eq!(managed["config"]["access"]["apiKey"], DEFAULT_API_KEY); + assert_eq!(managed["config"]["api"]["type"], "anthropic-messages"); + assert_eq!(managed["config"]["api"]["baseUrl"], "http://127.0.0.1:8317"); + assert_eq!(managed["config"]["personalModelIds"], serde_json::json!(["gpt-test", "deepseek-test"])); + assert_eq!(value["config"]["defaultModelSelection"], serde_json::json!({ + "providerId": MANAGED_AGENT_PROVIDER_ID, "modelId": "gpt-test" + })); + let model_rules = value["config"]["modelConfigRules"]["providerModelRules"].as_array().unwrap(); + assert_eq!(model_rules[0]["providerId"], "other"); + assert_eq!(model_rules[1]["config"]["properties"]["supportsToolCall"], true); + assert_eq!(model_rules[1]["config"]["properties"]["contextWindow"], 372_000); + assert_eq!(model_rules[2]["config"]["properties"]["contextWindow"], 272_000); assert_eq!( inspect_zcode_agent_config(&path, 8317, DEFAULT_API_KEY).unwrap(), (true, Some("gpt-test".to_string())) ); let mut normalized = value; - let provider = normalized["provider"][MANAGED_AGENT_PROVIDER_ID] - .as_object_mut() - .unwrap(); - provider.remove("apiFormat"); - provider.remove("defaultKind"); - provider.remove("npm"); + normalized["config"]["defaultModelSelection"]["providerId"] = serde_json::json!("other"); fs::write(&path, serde_json::to_string_pretty(&normalized).unwrap()).unwrap(); assert_eq!( inspect_zcode_agent_config(&path, 8317, DEFAULT_API_KEY).unwrap(), - (true, Some("gpt-test".to_string())) + (false, None) ); fs::remove_dir_all(home).unwrap(); } #[test] -fn zcode_cli_config_sets_main_model_and_both_configs_must_match() { - let home = agent_test_home("zcode-cli-default-model"); +fn zcode_desktop_and_cli_share_the_provider_configuration() { + let home = agent_test_home("zcode-shared-provider-config"); let paths = agent_config_paths(AgentClient::ZCode, &home); + assert_eq!(paths.len(), 1); + assert_eq!(paths[0], home.join(".zcode/v2/provider_config.json")); fs::create_dir_all(paths[0].parent().unwrap()).unwrap(); - fs::create_dir_all(paths[1].parent().unwrap()).unwrap(); let models = test_agent_models(&["gpt-test", "deepseek-test"]); - let app_config = build_zcode_agent_config( + let config = build_zcode_agent_config( None, "http://127.0.0.1:8317", DEFAULT_API_KEY, @@ -1054,47 +1033,53 @@ fn zcode_cli_config_sets_main_model_and_both_configs_must_match() { &models, ) .unwrap(); - let cli_config = build_zcode_cli_agent_config( - Some(r#"{"model":{"lite":"other/lite"},"plugins":{"keep":true}}"#), - "http://127.0.0.1:8317", - DEFAULT_API_KEY, - "gpt-test", - &models, - ) - .unwrap(); - let cli_value: serde_json::Value = serde_json::from_str(&cli_config).unwrap(); - - assert_eq!(cli_value["model"]["main"], "cpa-gui/gpt-test"); - assert_eq!(cli_value["model"]["lite"], "other/lite"); - assert_eq!(cli_value["plugins"]["keep"], true); - assert!(cli_value["provider"][MANAGED_AGENT_PROVIDER_ID] - .get("npm") - .is_none()); - fs::write(&paths[0], app_config).unwrap(); - fs::write(&paths[1], &cli_config).unwrap(); + fs::write(&paths[0], &config).unwrap(); assert_eq!( inspect_agent_managed_config(AgentClient::ZCode, &paths, 8317, DEFAULT_API_KEY).unwrap(), (true, Some("gpt-test".to_string()), false) ); - let mut mismatched = cli_value; - mismatched["model"]["main"] = serde_json::json!("cpa-gui/deepseek-test"); - fs::write( - &paths[1], - serde_json::to_string_pretty(&mismatched).unwrap(), - ) - .unwrap(); + let mut changed: serde_json::Value = serde_json::from_str(&config).unwrap(); + changed["config"]["defaultModelSelection"]["modelId"] = serde_json::json!("deepseek-test"); + fs::write(&paths[0], serde_json::to_string_pretty(&changed).unwrap()).unwrap(); assert_eq!( inspect_agent_managed_config(AgentClient::ZCode, &paths, 8317, DEFAULT_API_KEY).unwrap(), - (false, Some("deepseek-test".to_string()), false) + (true, Some("deepseek-test".to_string()), false) ); fs::remove_dir_all(home).unwrap(); } +#[test] +fn zcode_preserves_manual_model_rules_and_rejects_unknown_schema_versions() { + let existing = r#"{"schemaVersion":1,"config":{"providerConfigRules":{"providerRules":[]},"modelConfigRules":{"providerModelRules":[],"manualProviderModelRules":[{"providerId":"cpa-gui","modelId":"gpt-test","config":{"enabled":true,"properties":{"supportsToolCall":true}}}]}}}"#; + let mut models = test_agent_models(&["gpt-test", "without-context"]); + models[1].context_window = None; + let rendered = build_zcode_agent_config( + Some(existing), + "http://127.0.0.1:8317", + DEFAULT_API_KEY, + "gpt-test", + &models, + ).unwrap(); + let value: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + assert!(value["config"]["modelConfigRules"]["providerModelRules"] + .as_array().unwrap().is_empty()); + let manual = &value["config"]["modelConfigRules"]["manualProviderModelRules"][0]; + assert_eq!(manual["config"]["properties"]["supportsToolCall"], true); + assert_eq!(manual["config"]["properties"]["contextWindow"], 200_000); + assert!(build_zcode_agent_config( + Some(r#"{"schemaVersion":2,"config":{}}"#), + "http://127.0.0.1:8317", + DEFAULT_API_KEY, + "gpt-test", + &test_agent_models(&["gpt-test"]), + ).is_err()); +} + #[test] fn zcode_inspection_requires_the_default_model_to_belong_to_the_managed_provider() { let home = agent_test_home("zcode-default-model-inspection"); - let path = home.join(".zcode/v2/config.json"); + let path = home.join(".zcode/v2/provider_config.json"); fs::create_dir_all(path.parent().unwrap()).unwrap(); let rendered = build_zcode_agent_config( None, @@ -1105,7 +1090,7 @@ fn zcode_inspection_requires_the_default_model_to_belong_to_the_managed_provider ) .unwrap(); let mut value: serde_json::Value = serde_json::from_str(&rendered).unwrap(); - value["model"] = serde_json::json!("other/gpt-test"); + value["config"]["defaultModelSelection"]["providerId"] = serde_json::json!("other"); fs::write(&path, serde_json::to_string_pretty(&value).unwrap()).unwrap(); assert_eq!(