use super::*; #[cfg(test)] pub(crate) fn build_agent_updates( client: AgentClient, home: &Path, port: u16, api_key: &str, model: &str, models: &[AgentModelOption], codex_catalog: Option<&str>, ) -> Result, String> { build_agent_updates_with_oauth( client, home, port, api_key, model, AgentConfigurationOptions { models, codex_catalog, oauth_configuration: false, claude_code_model_mappings: None, claude_desktop_model_mappings: None, }, ) } pub(crate) fn build_agent_updates_with_oauth( client: AgentClient, home: &Path, port: u16, api_key: &str, model: &str, options: AgentConfigurationOptions<'_>, ) -> Result, String> { let AgentConfigurationOptions { models, codex_catalog, oauth_configuration, claude_code_model_mappings, claude_desktop_model_mappings, } = options; let paths = agent_config_paths(client, home); let root_base = format!("http://127.0.0.1:{port}"); let openai_base = format!("{root_base}/v1"); match client { AgentClient::ClaudeCode => { let before = read_optional_text(&paths[0])?; let after = build_claude_agent_config( before.as_deref(), &root_base, api_key, model, models, claude_code_model_mappings, ) .or_else(|_| { build_claude_agent_config( None, &root_base, api_key, model, models, claude_code_model_mappings, ) })?; Ok(vec![AgentFileUpdate { path: paths[0].clone(), after, }]) } AgentClient::ClaudeDesktop => { if paths.len() != 4 { return Err("Claude Desktop 当前平台配置路径不可用".to_string()); } let normal_before = read_optional_text(&paths[0])?; let threep_before = read_optional_text(&paths[1])?; let profile_before = read_optional_text(&paths[2])?; let meta_before = read_optional_text(&paths[3])?; Ok(vec![ AgentFileUpdate { path: paths[0].clone(), after: build_claude_desktop_deployment_config(normal_before.as_deref()) .or_else(|_| build_claude_desktop_deployment_config(None))?, }, AgentFileUpdate { path: paths[1].clone(), after: build_claude_desktop_deployment_config(threep_before.as_deref()) .or_else(|_| build_claude_desktop_deployment_config(None))?, }, AgentFileUpdate { path: paths[2].clone(), after: build_claude_desktop_profile( profile_before.as_deref(), &root_base, api_key, model, models, claude_desktop_model_mappings, ) .or_else(|_| { build_claude_desktop_profile( None, &root_base, api_key, model, models, claude_desktop_model_mappings, ) })?, }, AgentFileUpdate { path: paths[3].clone(), after: build_claude_desktop_meta(meta_before.as_deref()) .or_else(|_| build_claude_desktop_meta(None))?, }, ]) } AgentClient::Codex => { let before = read_optional_text(&paths[0])?; let after = build_codex_agent_config_with_oauth( before.as_deref(), &openai_base, api_key, model, oauth_configuration, ) .or_else(|_| { build_codex_agent_config_with_oauth( None, &openai_base, api_key, model, oauth_configuration, ) })?; let mut updates = vec![AgentFileUpdate { path: paths[0].clone(), after, }]; let catalog = codex_catalog.ok_or_else(|| "无法生成 Codex 模型目录".to_string())?; validate_codex_catalog(catalog, model)?; updates.push(AgentFileUpdate { path: codex_model_catalog_path(home), after: catalog.to_string(), }); Ok(updates) } AgentClient::OpenCode => { let before = read_optional_text(&paths[0])?; let after = build_opencode_agent_config( before.as_deref(), &openai_base, api_key, model, models, ) .or_else(|_| build_opencode_agent_config(None, &openai_base, api_key, model, models))?; Ok(vec![AgentFileUpdate { path: paths[0].clone(), after, }]) } AgentClient::OpenClaw => { let before = read_optional_text(&paths[0])?; let after = build_openclaw_agent_config( before.as_deref(), &openai_base, api_key, model, models, ) .or_else(|_| build_openclaw_agent_config(None, &openai_base, api_key, model, models))?; Ok(vec![AgentFileUpdate { path: paths[0].clone(), after, }]) } AgentClient::Hermes => { let before = read_optional_text(&paths[0])?; let after = build_hermes_agent_config(before.as_deref(), &openai_base, api_key, model, models) .or_else(|_| { build_hermes_agent_config(None, &openai_base, api_key, model, models) })?; Ok(vec![AgentFileUpdate { path: paths[0].clone(), after, }]) } AgentClient::DeepSeekHarness => { if paths.len() != 2 { return Err("DeepSeek Harness 配置路径数量无效".to_string()); } let settings_before = read_optional_text(&paths[0])?; let credentials_before = read_optional_text(&paths[1])?; Ok(vec![ AgentFileUpdate { path: paths[0].clone(), after: build_deepseek_harness_settings( settings_before.as_deref(), &openai_base, model, models, )?, }, AgentFileUpdate { path: paths[1].clone(), after: build_deepseek_harness_credentials( credentials_before.as_deref(), api_key, )?, }, ]) } } } pub(crate) fn read_optional_text(path: &Path) -> Result, String> { if !path.is_file() { return Ok(None); } fs::read_to_string(path) .map(Some) .map_err(|error| format!("读取配置失败 {}: {error}", path_to_string(path))) } pub(crate) fn read_agent_yaml_mapping_or_empty( path: &Path, label: &str, ) -> Result { if !path.is_file() { return Ok(serde_norway::Mapping::new()); } let content = fs::read_to_string(path) .map_err(|error| format!("读取 {label} 失败 {}: {error}", path_to_string(path)))?; parse_agent_yaml_mapping(Some(&content), label) } pub(crate) fn parse_agent_yaml_mapping( content: Option<&str>, label: &str, ) -> Result { let Some(content) = content.map(str::trim).filter(|content| !content.is_empty()) else { return Ok(serde_norway::Mapping::new()); }; serde_norway::from_str::(content) .map_err(|error| format!("{label} YAML 格式无效: {error}"))? .as_mapping() .cloned() .ok_or_else(|| format!("{label} 根节点必须是 YAML 映射")) } pub(crate) fn render_agent_yaml_mapping_update( existing: Option<&str>, label: &str, update: F, ) -> Result where F: FnOnce(&mut serde_norway::Mapping) -> Result<(), String>, { let original_mapping = parse_agent_yaml_mapping(existing, label)?; let original = serde_norway::Value::Mapping(original_mapping.clone()); let mut updated_mapping = original_mapping; update(&mut updated_mapping)?; let updated = serde_norway::Value::Mapping(updated_mapping); if updated == original { return Ok(existing.unwrap_or("{}\n").to_string()); } let source = existing .map(str::trim) .filter(|content| !content.is_empty()) .map(|_| existing.unwrap_or_default()) .unwrap_or("{}\n"); let mut document = yaml_serde_edit::YamlValue::parse(source) .map_err(|error| format!("{label} YAML 格式无效: {error}"))?; render_updated_core_yaml(&mut document, updated) .map_err(|error| format!("更新 {label} 失败: {error}")) } pub(crate) fn build_deepseek_harness_models( models: &[AgentModelOption], selected_model: &str, ) -> Result { let entries = ordered_agent_models(models, selected_model) .into_iter() .map(|model| { let mut entry = serde_norway::Mapping::new(); entry.insert(yaml_key("id"), serde_norway::Value::String(model.name)); if let Some(name) = model.alias.filter(|name| !name.trim().is_empty()) { entry.insert(yaml_key("name"), serde_norway::Value::String(name)); } if let Some(context_window) = model.context_window { entry.insert( yaml_key("contextWindow"), serde_norway::to_value(context_window) .map_err(|error| format!("序列化 Harness 模型上下文失败: {error}"))?, ); } Ok(serde_norway::Value::Mapping(entry)) }) .collect::, String>>()?; Ok(serde_norway::Value::Sequence(entries)) } pub(crate) fn build_deepseek_harness_settings( existing: Option<&str>, base_url: &str, model: &str, models: &[AgentModelOption], ) -> Result { let published_models = build_deepseek_harness_models(models, model)?; render_agent_yaml_mapping_update(existing, "DeepSeek Harness settings", |root| { let llm = root .entry(yaml_key("llm-pi-ai")) .or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new())) .as_mapping_mut() .ok_or_else(|| "DeepSeek Harness llm-pi-ai 必须是映射".to_string())?; let providers = llm .entry(yaml_key("providers")) .or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new())) .as_mapping_mut() .ok_or_else(|| "DeepSeek Harness llm-pi-ai.providers 必须是映射".to_string())?; let provider = providers .entry(yaml_key(DEEPSEEK_HARNESS_PROVIDER_ID)) .or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new())) .as_mapping_mut() .ok_or_else(|| "DeepSeek Harness EasyCLIProxyAPI provider 必须是映射".to_string())?; for (key, value) in [ ("displayName", "EasyCLIProxyAPI"), ("apiKeyEnv", DEEPSEEK_HARNESS_CREDENTIAL), ("api", "openai-completions"), ("baseURL", base_url), ] { provider.insert( yaml_key(key), serde_norway::Value::String(value.to_string()), ); } provider.insert(yaml_key("models"), published_models); let selection = root .entry(yaml_key("agent-default-model")) .or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new())) .as_mapping_mut() .ok_or_else(|| "DeepSeek Harness agent-default-model 必须是映射".to_string())?; selection.insert( yaml_key("provider"), serde_norway::Value::String(DEEPSEEK_HARNESS_PROVIDER_ID.to_string()), ); selection.insert( yaml_key("model"), serde_norway::Value::String(model.to_string()), ); selection.remove(yaml_key("reasoningEffort")); Ok(()) }) } pub(crate) fn build_deepseek_harness_credentials( existing: Option<&str>, api_key: &str, ) -> Result { render_agent_yaml_mapping_update(existing, "DeepSeek Harness credentials", |root| { root.insert( yaml_key(DEEPSEEK_HARNESS_CREDENTIAL), serde_norway::Value::String(api_key.to_string()), ); Ok(()) }) } pub(crate) fn write_deepseek_harness_file( path: &Path, content: &[u8], owner_only: bool, ) -> Result<(), String> { write_bytes_directly(path, content)?; #[cfg(unix)] if owner_only { use std::os::unix::fs::PermissionsExt; fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|error| { format!( "设置 DeepSeek Harness 凭据权限失败 {}: {error}", path_to_string(path) ) })?; } #[cfg(not(unix))] let _ = owner_only; Ok(()) } pub(crate) fn write_agent_configuration_file( client: AgentClient, path: &Path, content: &[u8], ) -> Result<(), String> { if client == AgentClient::DeepSeekHarness { let owner_only = path.file_name().and_then(|name| name.to_str()) == Some(DEEPSEEK_HARNESS_CREDENTIALS_FILE); write_deepseek_harness_file(path, content, owner_only) } else { write_bytes_directly(path, content) } } pub(crate) fn build_claude_agent_config( existing: Option<&str>, base_url: &str, api_key: &str, model: &str, models: &[AgentModelOption], mappings: Option<&ClaudeDesktopModelMappings>, ) -> Result { let mut root = match existing.map(str::trim).filter(|value| !value.is_empty()) { Some(value) => serde_json::from_str::(value) .map_err(|error| format!("Claude Code settings.json 格式无效: {error}"))?, None => serde_json::json!({}), }; let root = root .as_object_mut() .ok_or_else(|| "Claude Code settings.json 根节点必须是对象".to_string())?; let mappings = mappings .cloned() .unwrap_or_else(|| ClaudeDesktopModelMappings::all(model)); let max_context_tokens = mappings.max_context_tokens; let model_settings = claude_code_model_settings(&mappings); let subagent_model = model_settings.haiku.clone(); let effort_level = claude_code_model_effort_level(models, &mappings.sonnet)?; let env = ensure_json_object_entry(root, "env"); env.remove("ANTHROPIC_API_KEY"); env.remove("CLAUDE_CODE_EFFORT_LEVEL"); env.remove(CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV); env.remove(CLAUDE_AUTOCOMPACT_PCT_OVERRIDE_ENV); env.remove(DISABLE_AUTO_COMPACT_ENV); for (key, value) in [ ("ANTHROPIC_BASE_URL", base_url), ("ANTHROPIC_AUTH_TOKEN", api_key), ("ANTHROPIC_MODEL", model_settings.sonnet.as_str()), ( "ANTHROPIC_DEFAULT_HAIKU_MODEL", model_settings.haiku.as_str(), ), ( "ANTHROPIC_DEFAULT_SONNET_MODEL", model_settings.sonnet.as_str(), ), ("ANTHROPIC_DEFAULT_OPUS_MODEL", model_settings.opus.as_str()), ( "ANTHROPIC_DEFAULT_FABLE_MODEL", model_settings.sonnet.as_str(), ), ("CLAUDE_CODE_SUBAGENT_MODEL", subagent_model.as_str()), ] { env.insert( key.to_string(), serde_json::Value::String(value.to_string()), ); } env.insert( CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV.to_string(), serde_json::Value::String(max_context_tokens.to_string()), ); env.insert( CLAUDE_AUTOCOMPACT_PCT_OVERRIDE_ENV.to_string(), serde_json::Value::String(mappings.auto_compact_pct.to_string()), ); if mappings.disable_auto_compact { env.insert( DISABLE_AUTO_COMPACT_ENV.to_string(), serde_json::Value::String("1".to_string()), ); } for (key, value) in claude_code_model_presentation_environment(&mappings, models)? { env.insert(key, serde_json::Value::String(value)); } if let Some(effort_level) = effort_level { env.insert( "CLAUDE_CODE_EFFORT_LEVEL".to_string(), serde_json::Value::String(effort_level), ); } root.insert( "model".to_string(), serde_json::Value::String(model_settings.sonnet), ); let mut rendered = serde_json::to_string_pretty(&serde_json::Value::Object(root.clone())) .map_err(|error| format!("生成 Claude Code 配置失败: {error}"))?; rendered.push('\n'); Ok(rendered) } pub(crate) fn parse_agent_json_object( existing: Option<&str>, label: &str, ) -> Result, String> { let value = match existing.map(str::trim).filter(|value| !value.is_empty()) { Some(value) => serde_json::from_str::(value) .map_err(|error| format!("{label} 格式无效: {error}"))?, None => serde_json::json!({}), }; value .as_object() .cloned() .ok_or_else(|| format!("{label} 根节点必须是对象")) } pub(crate) fn ensure_json_object_entry<'a>( root: &'a mut serde_json::Map, key: &str, ) -> &'a mut serde_json::Map { if !root.get(key).is_some_and(serde_json::Value::is_object) { root.insert(key.to_string(), serde_json::json!({})); } root.get_mut(key) .and_then(serde_json::Value::as_object_mut) .expect("object entry was just normalized") } pub(crate) fn ensure_json_array_entry<'a>( root: &'a mut serde_json::Map, key: &str, ) -> &'a mut Vec { if !root.get(key).is_some_and(serde_json::Value::is_array) { root.insert(key.to_string(), serde_json::json!([])); } root.get_mut(key) .and_then(serde_json::Value::as_array_mut) .expect("array entry was just normalized") } pub(crate) fn render_agent_json( root: serde_json::Map, label: &str, ) -> Result { let mut rendered = serde_json::to_string_pretty(&serde_json::Value::Object(root)) .map_err(|error| format!("生成 {label} 失败: {error}"))?; rendered.push('\n'); Ok(rendered) } pub(crate) fn ordered_agent_models( models: &[AgentModelOption], selected_model: &str, ) -> Vec { let mut ordered = Vec::with_capacity(models.len().max(1)); if let Some(selected) = models .iter() .find(|model| model.name.eq_ignore_ascii_case(selected_model)) { ordered.push(selected.clone()); } else { ordered.push(AgentModelOption { name: selected_model.to_string(), alias: None, is_alias: false, context_window: None, }); } for model in models { if ordered .iter() .any(|existing| existing.name.eq_ignore_ascii_case(&model.name)) { continue; } ordered.push(model.clone()); } ordered } pub(crate) fn build_claude_desktop_deployment_config( existing: Option<&str>, ) -> Result { let mut root = parse_agent_json_object(existing, "Claude Desktop 配置")?; root.insert("deploymentMode".to_string(), serde_json::json!("3p")); render_agent_json(root, "Claude Desktop 配置") } pub(crate) fn build_claude_desktop_profile( existing: Option<&str>, base_url: &str, api_key: &str, model: &str, models: &[AgentModelOption], mappings: Option<&ClaudeDesktopModelMappings>, ) -> Result { let mut root = parse_agent_json_object(existing, "Claude Desktop 网关配置")?; root.remove("coworkEgressAllowedHosts"); root.insert( "disableDeploymentModeChooser".to_string(), serde_json::json!(true), ); root.insert( "inferenceGatewayApiKey".to_string(), serde_json::json!(api_key), ); root.insert( "inferenceGatewayAuthScheme".to_string(), serde_json::json!("bearer"), ); root.insert( "inferenceGatewayBaseUrl".to_string(), serde_json::json!(base_url), ); root.insert( "inferenceProvider".to_string(), serde_json::json!("gateway"), ); let mappings = mappings .cloned() .unwrap_or_else(|| ClaudeDesktopModelMappings::all(model)); let inference_models = vec![ claude_desktop_inference_model( CLAUDE_DESKTOP_OPUS_MODEL_ID, &mappings.opus, mappings.opus_1m, models, ), claude_desktop_inference_model( CLAUDE_DESKTOP_SONNET_MODEL_ID, &mappings.sonnet, mappings.sonnet_1m, models, ), claude_desktop_inference_model( CLAUDE_DESKTOP_HAIKU_MODEL_ID, &mappings.haiku, mappings.haiku_1m, models, ), ]; let mut deduplicated_models = Vec::::with_capacity(inference_models.len()); for entry in inference_models { let Some(name) = entry.get("name").and_then(serde_json::Value::as_str) else { continue; }; let existing = deduplicated_models.iter_mut().find(|existing| { existing .get("name") .and_then(serde_json::Value::as_str) .is_some_and(|existing_name| existing_name.eq_ignore_ascii_case(name)) }); if let Some(existing) = existing { if entry.get("supports1m").and_then(serde_json::Value::as_bool) == Some(true) { *existing = entry; } } else { deduplicated_models.push(entry); } } root.insert( "inferenceModels".to_string(), serde_json::Value::Array(deduplicated_models), ); render_agent_json(root, "Claude Desktop 网关配置") } pub(crate) fn claude_desktop_inference_model( route_model: &str, source_model: &str, enable_1m: bool, models: &[AgentModelOption], ) -> serde_json::Value { let selected = models .iter() .find(|model| model.name.eq_ignore_ascii_case(source_model)); let direct_alias = selected.is_some_and(|model| model.is_alias); let context_window = claude_effective_context_window(models, source_model, enable_1m); let mut entry = serde_json::Map::new(); entry.insert( "name".to_string(), serde_json::json!(if direct_alias { source_model } else { route_model }), ); if let Some(context_window) = context_window { entry.insert( "contextWindow".to_string(), serde_json::json!(context_window), ); if enable_1m { entry.insert("supports1m".to_string(), serde_json::json!(true)); entry.insert("prefer1m".to_string(), serde_json::json!(true)); } } serde_json::Value::Object(entry) } pub(crate) fn build_claude_desktop_meta(existing: Option<&str>) -> Result { let mut root = parse_agent_json_object(existing, "Claude Desktop 配置索引")?; let entries = ensure_json_array_entry(&mut root, "entries"); let mut managed_entry = None; let mut retained_entries = Vec::with_capacity(entries.len()); for entry in std::mem::take(entries) { match entry.get("id").and_then(serde_json::Value::as_str) { Some(id) if id == CLAUDE_DESKTOP_PROFILE_ID => { if managed_entry.is_none() { managed_entry = entry.as_object().cloned(); } } Some(_) => retained_entries.push(entry), None => {} } } let mut managed_entry = managed_entry.unwrap_or_default(); managed_entry.insert( "id".to_string(), serde_json::json!(CLAUDE_DESKTOP_PROFILE_ID), ); managed_entry.insert("name".to_string(), serde_json::json!("EasyCLIProxyAPI")); retained_entries.push(serde_json::Value::Object(managed_entry)); *entries = retained_entries; root.insert( "appliedId".to_string(), serde_json::json!(CLAUDE_DESKTOP_PROFILE_ID), ); render_agent_json(root, "Claude Desktop 配置索引") } pub(crate) fn update_agent_json_file(path: &Path, label: &str, update: F) -> Result where F: FnOnce(&mut serde_json::Map) -> bool, { if !path.is_file() { return Ok(false); } let content = fs::read_to_string(path) .map_err(|error| format!("读取 {label} 失败 {}: {error}", path_to_string(path)))?; let mut root = parse_agent_json_object(Some(&content), label)?; if !update(&mut root) { return Ok(false); } if root.is_empty() { fs::remove_file(path) .map_err(|error| format!("删除空的 {label} 失败 {}: {error}", path_to_string(path)))?; } else { let rendered = render_agent_json(root, label)?; write_bytes_directly(path, rendered.as_bytes())?; } Ok(true) } pub(crate) fn remove_claude_desktop_managed_configuration( paths: &[PathBuf], ) -> Result, String> { if paths.len() != 4 { return Err("Claude Desktop 当前平台配置路径不可用".to_string()); } let mut changed = Vec::new(); for (path, label) in [ (&paths[0], "Claude Desktop 主配置"), (&paths[1], "Claude Desktop 3P 配置"), ] { if update_agent_json_file(path, label, |root| { if root .get("deploymentMode") .and_then(serde_json::Value::as_str) == Some("3p") { root.remove("deploymentMode"); true } else { false } })? { changed.push(path_to_string(path)); } } if update_agent_json_file(&paths[2], "Claude Desktop 网关配置", |root| { let mut updated = false; for key in [ "coworkEgressAllowedHosts", "disableDeploymentModeChooser", "inferenceGatewayApiKey", "inferenceGatewayAuthScheme", "inferenceGatewayBaseUrl", "inferenceProvider", "inferenceModels", ] { updated |= root.remove(key).is_some(); } updated })? { changed.push(path_to_string(&paths[2])); } if update_agent_json_file(&paths[3], "Claude Desktop 配置索引", |root| { let mut updated = false; if root.get("appliedId").and_then(serde_json::Value::as_str) == Some(CLAUDE_DESKTOP_PROFILE_ID) { root.remove("appliedId"); updated = true; } let entries_empty = if let Some(entries) = root .get_mut("entries") .and_then(serde_json::Value::as_array_mut) { let previous_len = entries.len(); entries.retain(|entry| { entry.get("id").and_then(serde_json::Value::as_str) != Some(CLAUDE_DESKTOP_PROFILE_ID) }); updated |= entries.len() != previous_len; entries.is_empty() } else { false }; if entries_empty { root.remove("entries"); } updated })? { changed.push(path_to_string(&paths[3])); } Ok(changed) } pub(crate) fn remove_claude_code_managed_configuration( paths: &[PathBuf], ) -> Result, String> { let Some(path) = paths.first() else { return Err("Claude Code 当前平台配置路径不可用".to_string()); }; let updated = update_agent_json_file(path, "Claude Code 配置", |root| { let managed_model = root .get("env") .and_then(serde_json::Value::as_object) .and_then(|env| env.get("ANTHROPIC_MODEL")) .and_then(serde_json::Value::as_str) .map(str::to_string); let managed = root .get("env") .and_then(serde_json::Value::as_object) .and_then(|env| env.get("ANTHROPIC_BASE_URL")) .and_then(serde_json::Value::as_str) .is_some_and(is_managed_agent_base_url); if !managed { return false; } if root.get("model").and_then(serde_json::Value::as_str) == managed_model.as_deref() { root.remove("model"); } let env_empty = if let Some(env) = root .get_mut("env") .and_then(serde_json::Value::as_object_mut) { for key in [ "ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_FABLE_MODEL", CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV, CLAUDE_AUTOCOMPACT_PCT_OVERRIDE_ENV, DISABLE_AUTO_COMPACT_ENV, ] { env.remove(key); } env.is_empty() } else { false }; if env_empty { root.remove("env"); } true })?; Ok(updated.then(|| path_to_string(path)).into_iter().collect()) } pub(crate) fn remove_codex_managed_configuration(paths: &[PathBuf]) -> Result, String> { use toml_edit::{Document, Item}; let Some(path) = paths.first() else { return Err("Codex 当前平台配置路径不可用".to_string()); }; if !path.is_file() { return Ok(Vec::new()); } let content = fs::read_to_string(path) .map_err(|error| format!("读取 Codex 配置失败 {}: {error}", path_to_string(path)))?; let mut document = content .parse::() .map_err(|error| format!("解析 Codex 配置失败: {error}"))?; let managed_selected = document.get("model_provider").and_then(Item::as_str) == Some(MANAGED_AGENT_PROVIDER_ID); let managed_catalog = document.get("model_catalog_json").and_then(Item::as_str) == Some(CODEX_MODEL_CATALOG_FILE); let managed_provider = document .get("model_providers") .and_then(Item::as_table) .is_some_and(|providers| providers.contains_key(MANAGED_AGENT_PROVIDER_ID)); if !managed_selected && !managed_catalog && !managed_provider { return Ok(Vec::new()); } if managed_selected { document.remove("model_provider"); document.remove("model"); } if managed_catalog { document.remove("model_catalog_json"); } if let Some(providers) = document .get_mut("model_providers") .and_then(Item::as_table_mut) { providers.remove(MANAGED_AGENT_PROVIDER_ID); } if document .get("model_providers") .and_then(Item::as_table) .is_some_and(toml_edit::Table::is_empty) { document.remove("model_providers"); } let rendered = document.to_string(); toml::from_str::(&rendered) .map_err(|error| format!("验证恢复后的 Codex 配置失败: {error}"))?; if rendered.trim().is_empty() { fs::remove_file(path).map_err(|error| { format!("删除空的 Codex 配置失败 {}: {error}", path_to_string(path)) })?; } else { write_bytes_directly(path, rendered.as_bytes())?; } let mut changed = vec![path_to_string(path)]; if managed_catalog { let catalog_path = path.with_file_name(CODEX_MODEL_CATALOG_FILE); if catalog_path.is_file() { fs::remove_file(&catalog_path).map_err(|error| { format!( "删除 Codex CPA 模型目录失败 {}: {error}", path_to_string(&catalog_path) ) })?; changed.push(path_to_string(&catalog_path)); } } Ok(changed) } pub(crate) fn remove_opencode_managed_configuration( paths: &[PathBuf], ) -> Result, String> { let Some(path) = paths.first() else { return Err("OpenCode 当前平台配置路径不可用".to_string()); }; let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/"); let updated = update_agent_json_file(path, "OpenCode 配置", |root| { let mut changed = false; if root .get("model") .and_then(serde_json::Value::as_str) .is_some_and(|model| model.starts_with(&prefix)) { 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 })?; Ok(updated.then(|| path_to_string(path)).into_iter().collect()) } pub(crate) fn update_agent_json5_file( path: &Path, label: &str, update: F, ) -> Result where F: FnOnce(&mut serde_json::Map) -> bool, { if !path.is_file() { return Ok(false); } let content = fs::read_to_string(path) .map_err(|error| format!("读取 {label} 失败 {}: {error}", path_to_string(path)))?; let value = json5::from_str::(&content) .map_err(|error| format!("解析 {label} 失败: {error}"))?; let mut root = value .as_object() .cloned() .ok_or_else(|| format!("{label} 根节点必须是对象"))?; if !update(&mut root) { return Ok(false); } if root.is_empty() { fs::remove_file(path) .map_err(|error| format!("删除空的 {label} 失败 {}: {error}", path_to_string(path)))?; } else { let rendered = render_agent_json(root, label)?; let comments = extract_json5_comments(&content); let rendered = if comments.is_empty() { rendered } else { format!("{}\n{rendered}", comments.join("\n")) }; write_bytes_directly(path, rendered.as_bytes())?; } Ok(true) } pub(crate) fn remove_openclaw_managed_configuration( paths: &[PathBuf], ) -> Result, String> { let Some(path) = paths.first() else { return Err("OpenClaw 当前平台配置路径不可用".to_string()); }; let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/"); let updated = update_agent_json5_file(path, "OpenClaw 配置", |root| { let mut changed = false; if let Some(models) = root .get_mut("models") .and_then(serde_json::Value::as_object_mut) { let providers_empty = if let Some(providers) = models .get_mut("providers") .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 { models.remove("providers"); } } if let Some(defaults) = root .get_mut("agents") .and_then(serde_json::Value::as_object_mut) .and_then(|agents| agents.get_mut("defaults")) .and_then(serde_json::Value::as_object_mut) { let model_empty = if let Some(model) = defaults .get_mut("model") .and_then(serde_json::Value::as_object_mut) { if model .get("primary") .and_then(serde_json::Value::as_str) .is_some_and(|model| model.starts_with(&prefix)) { model.remove("primary"); changed = true; } model.is_empty() } else { false }; if model_empty { defaults.remove("model"); } let catalog_empty = if let Some(catalog) = defaults .get_mut("models") .and_then(serde_json::Value::as_object_mut) { let previous_len = catalog.len(); catalog.retain(|name, _| !name.starts_with(&prefix)); changed |= catalog.len() != previous_len; catalog.is_empty() } else { false }; if catalog_empty { defaults.remove("models"); } } changed })?; Ok(updated.then(|| path_to_string(path)).into_iter().collect()) } pub(crate) fn remove_hermes_managed_configuration( paths: &[PathBuf], ) -> Result, String> { let Some(path) = paths.first() else { return Err("Hermes 当前平台配置路径不可用".to_string()); }; if !path.is_file() { return Ok(Vec::new()); } let content = fs::read_to_string(path) .map_err(|error| format!("读取 Hermes 配置失败 {}: {error}", path_to_string(path)))?; let mut document = yaml_serde_edit::YamlValue::parse(&content) .map_err(|error| format!("解析 Hermes 配置失败: {error}"))?; let mut updated = document.get().clone(); let root = updated .as_mapping_mut() .ok_or_else(|| "Hermes 配置根节点必须是映射".to_string())?; let mut changed = false; let providers_empty = if let Some(providers) = root .get_mut(yaml_key("custom_providers")) .and_then(serde_norway::Value::as_sequence_mut) { let previous_len = providers.len(); providers.retain(|provider| { provider.get("name").and_then(serde_norway::Value::as_str) != Some(MANAGED_AGENT_PROVIDER_ID) }); changed |= providers.len() != previous_len; providers.is_empty() } else { false }; if providers_empty { root.remove(yaml_key("custom_providers")); } let model_empty = if let Some(model) = root .get_mut(yaml_key("model")) .and_then(serde_norway::Value::as_mapping_mut) { if model .get(yaml_key("provider")) .and_then(serde_norway::Value::as_str) == Some(MANAGED_AGENT_PROVIDER_ID) { model.remove(yaml_key("provider")); model.remove(yaml_key("default")); changed = true; } model.is_empty() } else { false }; if model_empty { root.remove(yaml_key("model")); } if !changed { return Ok(Vec::new()); } if root.is_empty() { fs::remove_file(path).map_err(|error| { format!("删除空的 Hermes 配置失败 {}: {error}", path_to_string(path)) })?; } else { let rendered = render_updated_core_yaml(&mut document, updated)?; write_bytes_directly(path, rendered.as_bytes())?; } Ok(vec![path_to_string(path)]) } pub(crate) fn remove_agent_managed_configuration( client: AgentClient, paths: &[PathBuf], ) -> Result, String> { match client { AgentClient::ClaudeCode => remove_claude_code_managed_configuration(paths), AgentClient::ClaudeDesktop => remove_claude_desktop_managed_configuration(paths), AgentClient::Codex => remove_codex_managed_configuration(paths), AgentClient::OpenCode => remove_opencode_managed_configuration(paths), AgentClient::OpenClaw => remove_openclaw_managed_configuration(paths), AgentClient::Hermes => remove_hermes_managed_configuration(paths), AgentClient::DeepSeekHarness => remove_deepseek_harness_managed_configuration(paths), } } pub(crate) fn remove_deepseek_harness_settings_fields(root: &mut serde_norway::Mapping) -> bool { let mut changed = false; let mut remove_llm = false; if let Some(llm) = root .get_mut(yaml_key("llm-pi-ai")) .and_then(serde_norway::Value::as_mapping_mut) { let mut remove_providers = false; if let Some(providers) = llm .get_mut(yaml_key("providers")) .and_then(serde_norway::Value::as_mapping_mut) { changed |= providers .remove(yaml_key(DEEPSEEK_HARNESS_PROVIDER_ID)) .is_some(); remove_providers = providers.is_empty(); } if remove_providers { llm.remove(yaml_key("providers")); } remove_llm = llm.is_empty(); } if remove_llm { root.remove(yaml_key("llm-pi-ai")); } let managed_selection = root .get(yaml_key("agent-default-model")) .and_then(serde_norway::Value::as_mapping) .and_then(|selection| yaml_mapping_value(selection, "provider")) .and_then(serde_norway::Value::as_str) == Some(DEEPSEEK_HARNESS_PROVIDER_ID); if managed_selection { root.remove(yaml_key("agent-default-model")); changed = true; } changed } pub(crate) fn remove_deepseek_harness_managed_configuration( paths: &[PathBuf], ) -> Result, String> { if paths.len() != 2 { return Err("DeepSeek Harness 配置路径数量无效".to_string()); } let mut changed_paths = Vec::new(); if paths[0].is_file() { let current = fs::read_to_string(&paths[0]) .map_err(|error| format!("读取 DeepSeek Harness settings 失败: {error}"))?; let mut changed = false; let rendered = render_agent_yaml_mapping_update( Some(¤t), "DeepSeek Harness settings", |root| { changed = remove_deepseek_harness_settings_fields(root); Ok(()) }, )?; if changed { let root = parse_agent_yaml_mapping(Some(&rendered), "DeepSeek Harness settings")?; if root.is_empty() { fs::remove_file(&paths[0]) .map_err(|error| format!("删除空的 DeepSeek Harness settings 失败: {error}"))?; } else { write_bytes_directly(&paths[0], rendered.as_bytes())?; } changed_paths.push(path_to_string(&paths[0])); } } if paths[1].is_file() { let current = fs::read_to_string(&paths[1]) .map_err(|error| format!("读取 DeepSeek Harness credentials 失败: {error}"))?; let mut changed = false; let rendered = render_agent_yaml_mapping_update( Some(¤t), "DeepSeek Harness credentials", |root| { changed = root.remove(yaml_key(DEEPSEEK_HARNESS_CREDENTIAL)).is_some(); Ok(()) }, )?; if changed { let root = parse_agent_yaml_mapping(Some(&rendered), "DeepSeek Harness credentials")?; if root.is_empty() { fs::remove_file(&paths[1]).map_err(|error| { format!("删除空的 DeepSeek Harness credentials 失败: {error}") })?; } else { write_deepseek_harness_file(&paths[1], rendered.as_bytes(), true)?; } changed_paths.push(path_to_string(&paths[1])); } } Ok(changed_paths) } pub(crate) fn restore_json_key( current: &mut serde_json::Map, original: Option<&serde_json::Map>, key: &str, ) { if let Some(value) = original.and_then(|root| root.get(key)).cloned() { current.insert(key.to_string(), value); } else { current.remove(key); } } pub(crate) fn parse_restored_json_object( content: Option<&str>, label: &str, ) -> Result>, String> { content .map(|content| parse_agent_json_object(Some(content), label)) .transpose() } pub(crate) fn render_restored_json( root: serde_json::Map, original_existed: bool, label: &str, ) -> Result, String> { if root.is_empty() && !original_existed { Ok(None) } else { render_agent_json(root, label).map(Some) } } pub(crate) fn build_restored_claude_code_config( current: &str, original: Option<&str>, ) -> Result, String> { let mut root = parse_agent_json_object(Some(current), "当前 Claude Code 配置")?; let original_root = parse_restored_json_object(original, "原始 Claude Code 配置")?; restore_json_key(&mut root, original_root.as_ref(), "model"); let original_env = original_root .as_ref() .and_then(|root| root.get("env")) .and_then(serde_json::Value::as_object); if root.get("env").is_some_and(serde_json::Value::is_object) { let env = root .get_mut("env") .and_then(serde_json::Value::as_object_mut) .expect("Claude Code env was checked as an object"); for key in [ "ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_FABLE_MODEL", CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV, CLAUDE_AUTOCOMPACT_PCT_OVERRIDE_ENV, DISABLE_AUTO_COMPACT_ENV, ] { restore_json_key(env, original_env, key); } if env.is_empty() && original_env.is_none() { root.remove("env"); } } else { restore_json_key(&mut root, original_root.as_ref(), "env"); } render_restored_json(root, original.is_some(), "恢复后的 Claude Code 配置") } pub(crate) fn build_restored_claude_desktop_config( index: usize, current: &str, original: Option<&str>, ) -> Result, String> { let mut root = parse_agent_json_object(Some(current), "当前 Claude Desktop 配置")?; let original_root = parse_restored_json_object(original, "原始 Claude Desktop 配置")?; match index { 0 | 1 => restore_json_key(&mut root, original_root.as_ref(), "deploymentMode"), 2 => { for key in [ "coworkEgressAllowedHosts", "disableDeploymentModeChooser", "inferenceGatewayApiKey", "inferenceGatewayAuthScheme", "inferenceGatewayBaseUrl", "inferenceProvider", "inferenceModels", ] { restore_json_key(&mut root, original_root.as_ref(), key); } } 3 => { restore_json_key(&mut root, original_root.as_ref(), "appliedId"); let mut entries = root .remove("entries") .and_then(|value| value.as_array().cloned()) .unwrap_or_default(); entries.retain(|entry| { entry.get("id").and_then(serde_json::Value::as_str) != Some(CLAUDE_DESKTOP_PROFILE_ID) }); let original_managed_entries = original_root .as_ref() .and_then(|root| root.get("entries")) .and_then(serde_json::Value::as_array) .into_iter() .flatten() .filter(|entry| { entry.get("id").and_then(serde_json::Value::as_str) == Some(CLAUDE_DESKTOP_PROFILE_ID) }) .cloned(); entries.extend(original_managed_entries); if !entries.is_empty() || original_root .as_ref() .and_then(|root| root.get("entries")) .is_some() { root.insert("entries".to_string(), serde_json::Value::Array(entries)); } } _ => return Err("Claude Desktop 配置文件索引无效".to_string()), } render_restored_json(root, original.is_some(), "恢复后的 Claude Desktop 配置") } pub(crate) fn build_restored_opencode_config( current: &str, original: Option<&str>, ) -> Result, String> { let mut root = parse_agent_json_object(Some(current), "当前 OpenCode 配置")?; let original_root = parse_restored_json_object(original, "原始 OpenCode 配置")?; for key in ["$schema", "model"] { restore_json_key(&mut root, original_root.as_ref(), key); } 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("OpenCode 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("OpenCode managed provider was checked as an object"); for key in ["npm", "name", "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("OpenCode 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"); } render_restored_json(root, original.is_some(), "恢复后的 OpenCode 配置") } pub(crate) fn build_restored_openclaw_config( current: &str, original: Option<&str>, ) -> Result, String> { let current_value = json5::from_str::(current) .map_err(|error| format!("当前 OpenClaw 配置格式无效: {error}"))?; let mut root = current_value .as_object() .cloned() .ok_or_else(|| "当前 OpenClaw 配置根节点必须是对象".to_string())?; let original_root = original .map(|content| { json5::from_str::(content) .map_err(|error| format!("原始 OpenClaw 配置格式无效: {error}"))? .as_object() .cloned() .ok_or_else(|| "原始 OpenClaw 配置根节点必须是对象".to_string()) }) .transpose()?; let original_models = original_root .as_ref() .and_then(|root| root.get("models")) .and_then(serde_json::Value::as_object); if root.get("models").is_some_and(serde_json::Value::is_object) { let models = root .get_mut("models") .and_then(serde_json::Value::as_object_mut) .expect("OpenClaw models were checked as an object"); restore_json_key(models, original_models, "mode"); let original_providers = original_models .and_then(|models| models.get("providers")) .and_then(serde_json::Value::as_object); if models .get("providers") .is_some_and(serde_json::Value::is_object) { let providers = models .get_mut("providers") .and_then(serde_json::Value::as_object_mut) .expect("OpenClaw providers were checked as an object"); let original_managed = original_providers .and_then(|providers| providers.get(MANAGED_AGENT_PROVIDER_ID)) .and_then(serde_json::Value::as_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("OpenClaw managed provider was checked as an object"); for key in ["baseUrl", "apiKey", "api", "models"] { restore_json_key(managed, original_managed, key); } if managed.is_empty() && original_managed.is_none() { providers.remove(MANAGED_AGENT_PROVIDER_ID); } } else if let Some(original_managed) = original_providers .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_providers.is_none() { models.remove("providers"); } } else { restore_json_key(models, original_models, "providers"); } if models.is_empty() && original_models.is_none() { root.remove("models"); } } else { restore_json_key(&mut root, original_root.as_ref(), "models"); } let original_defaults = original_root .as_ref() .and_then(|root| root.get("agents")) .and_then(|agents| agents.get("defaults")) .and_then(serde_json::Value::as_object); if let Some(defaults) = root .get_mut("agents") .and_then(serde_json::Value::as_object_mut) .and_then(|agents| agents.get_mut("defaults")) .and_then(serde_json::Value::as_object_mut) { let original_model = original_defaults .and_then(|defaults| defaults.get("model")) .and_then(serde_json::Value::as_object); if defaults .get("model") .is_some_and(serde_json::Value::is_object) { let model = defaults .get_mut("model") .and_then(serde_json::Value::as_object_mut) .expect("OpenClaw default model was checked as an object"); restore_json_key(model, original_model, "primary"); if model.is_empty() && original_model.is_none() { defaults.remove("model"); } } else { restore_json_key(defaults, original_defaults, "model"); } let original_catalog = original_defaults .and_then(|defaults| defaults.get("models")) .and_then(serde_json::Value::as_object); if defaults .get("models") .is_some_and(serde_json::Value::is_object) { let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/"); let catalog = defaults .get_mut("models") .and_then(serde_json::Value::as_object_mut) .expect("OpenClaw model catalog was checked as an object"); catalog.retain(|name, _| !name.starts_with(&prefix)); if let Some(original_catalog) = original_catalog { catalog.extend( original_catalog .iter() .filter(|(name, _)| name.starts_with(&prefix)) .map(|(name, value)| (name.clone(), value.clone())), ); } if catalog.is_empty() && original_catalog.is_none() { defaults.remove("models"); } } else { restore_json_key(defaults, original_defaults, "models"); } } let original_agents = original_root .as_ref() .and_then(|root| root.get("agents")) .and_then(serde_json::Value::as_object); if let Some(agents) = root .get_mut("agents") .and_then(serde_json::Value::as_object_mut) { let remove_defaults = agents .get("defaults") .and_then(serde_json::Value::as_object) .is_some_and(serde_json::Map::is_empty) && original_defaults.is_none(); if remove_defaults { agents.remove("defaults"); } if agents.is_empty() && original_agents.is_none() { root.remove("agents"); } } if root.is_empty() && original.is_none() { return Ok(None); } let rendered = render_agent_json(root, "恢复后的 OpenClaw 配置")?; let comments = extract_json5_comments(current); Ok(Some(if comments.is_empty() { rendered } else { format!("{}\n{rendered}", comments.join("\n")) })) } pub(crate) fn restore_yaml_key( current: &mut serde_norway::Mapping, original: Option<&serde_norway::Mapping>, key: &str, ) { if let Some(value) = original.and_then(|root| root.get(yaml_key(key))).cloned() { current.insert(yaml_key(key), value); } else { current.remove(yaml_key(key)); } } pub(crate) fn build_restored_hermes_config( current: &str, original: Option<&str>, ) -> Result, String> { let mut document = yaml_serde_edit::YamlValue::parse(current) .map_err(|error| format!("当前 Hermes 配置格式无效: {error}"))?; let mut updated = document.get().clone(); let root = updated .as_mapping_mut() .ok_or_else(|| "当前 Hermes 配置根节点必须是映射".to_string())?; let original_value = original .map(|content| { serde_norway::from_str::(content) .map_err(|error| format!("原始 Hermes 配置格式无效: {error}")) }) .transpose()?; let original_root = original_value .as_ref() .and_then(serde_norway::Value::as_mapping); let original_managed = original_root .and_then(|root| root.get(yaml_key("custom_providers"))) .and_then(serde_norway::Value::as_sequence) .and_then(|providers| { providers.iter().find(|provider| { provider.get("name").and_then(serde_norway::Value::as_str) == Some(MANAGED_AGENT_PROVIDER_ID) }) }); if let Some(providers) = root .get_mut(yaml_key("custom_providers")) .and_then(serde_norway::Value::as_sequence_mut) { let current_managed = providers .iter() .find(|provider| { provider.get("name").and_then(serde_norway::Value::as_str) == Some(MANAGED_AGENT_PROVIDER_ID) }) .and_then(serde_norway::Value::as_mapping) .cloned(); providers.retain(|provider| { provider.get("name").and_then(serde_norway::Value::as_str) != Some(MANAGED_AGENT_PROVIDER_ID) }); let mut managed = current_managed.unwrap_or_default(); let original_managed = original_managed.and_then(serde_norway::Value::as_mapping); for key in ["name", "base_url", "api_key", "api_mode", "model", "models"] { restore_yaml_key(&mut managed, original_managed, key); } if original_managed.is_some() { providers.push(serde_norway::Value::Mapping(managed)); } else if !managed.is_empty() { managed.insert( yaml_key("name"), serde_norway::Value::String(MANAGED_AGENT_PROVIDER_ID.to_string()), ); providers.push(serde_norway::Value::Mapping(managed)); } if providers.is_empty() && original_root .and_then(|root| root.get(yaml_key("custom_providers"))) .is_none() { root.remove(yaml_key("custom_providers")); } } else { restore_yaml_key(root, original_root, "custom_providers"); } let original_model = original_root .and_then(|root| root.get(yaml_key("model"))) .and_then(serde_norway::Value::as_mapping); if let Some(model) = root .get_mut(yaml_key("model")) .and_then(serde_norway::Value::as_mapping_mut) { for key in ["default", "provider"] { restore_yaml_key(model, original_model, key); } if model.is_empty() && original_model.is_none() { root.remove(yaml_key("model")); } } else { restore_yaml_key(root, original_root, "model"); } if root.is_empty() && original.is_none() { return Ok(None); } render_updated_core_yaml(&mut document, updated).map(Some) } pub(crate) fn restore_deepseek_harness_provider( current: &mut serde_norway::Mapping, original: Option<&serde_norway::Mapping>, ) -> Result<(), String> { let original_llm = original .and_then(|root| yaml_mapping_value(root, "llm-pi-ai")) .and_then(serde_norway::Value::as_mapping); let original_providers = original_llm .and_then(|llm| yaml_mapping_value(llm, "providers")) .and_then(serde_norway::Value::as_mapping); let original_provider = original_providers .and_then(|providers| yaml_mapping_value(providers, DEEPSEEK_HARNESS_PROVIDER_ID)) .cloned(); if let Some(original_provider) = original_provider { let llm = current .entry(yaml_key("llm-pi-ai")) .or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new())) .as_mapping_mut() .ok_or_else(|| "当前 DeepSeek Harness llm-pi-ai 必须是映射".to_string())?; let providers = llm .entry(yaml_key("providers")) .or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new())) .as_mapping_mut() .ok_or_else(|| "当前 DeepSeek Harness llm-pi-ai.providers 必须是映射".to_string())?; providers.insert(yaml_key(DEEPSEEK_HARNESS_PROVIDER_ID), original_provider); } else { let mut remove_llm = false; if let Some(llm) = current .get_mut(yaml_key("llm-pi-ai")) .and_then(serde_norway::Value::as_mapping_mut) { let mut remove_providers = false; if let Some(providers) = llm .get_mut(yaml_key("providers")) .and_then(serde_norway::Value::as_mapping_mut) { providers.remove(yaml_key(DEEPSEEK_HARNESS_PROVIDER_ID)); remove_providers = providers.is_empty() && original_providers.is_none(); } if remove_providers { llm.remove(yaml_key("providers")); } remove_llm = llm.is_empty() && original_llm.is_none(); } if remove_llm { current.remove(yaml_key("llm-pi-ai")); } } Ok(()) } pub(crate) fn build_restored_deepseek_harness_settings( current: &str, original: Option<&str>, ) -> Result, String> { let original_root = parse_agent_yaml_mapping(original, "原始 DeepSeek Harness settings")?; let rendered = render_agent_yaml_mapping_update( Some(current), "当前 DeepSeek Harness settings", |root| { restore_deepseek_harness_provider(root, Some(&original_root))?; restore_yaml_key(root, Some(&original_root), "agent-default-model"); Ok(()) }, )?; let root = parse_agent_yaml_mapping(Some(&rendered), "恢复后的 DeepSeek Harness settings")?; Ok((!root.is_empty() || original.is_some()).then_some(rendered)) } pub(crate) fn build_restored_deepseek_harness_credentials( current: &str, original: Option<&str>, ) -> Result, String> { let original_root = parse_agent_yaml_mapping(original, "原始 DeepSeek Harness credentials")?; let rendered = render_agent_yaml_mapping_update( Some(current), "当前 DeepSeek Harness credentials", |root| { restore_yaml_key(root, Some(&original_root), DEEPSEEK_HARNESS_CREDENTIAL); Ok(()) }, )?; let root = parse_agent_yaml_mapping(Some(&rendered), "恢复后的 DeepSeek Harness credentials")?; Ok((!root.is_empty() || original.is_some()).then_some(rendered)) } pub(crate) fn agent_config_semantically_equal( client: AgentClient, actual: &str, expected: &str, ) -> bool { match client { AgentClient::Codex => { toml::from_str::(actual).ok() == toml::from_str::(expected).ok() } AgentClient::OpenClaw => { json5::from_str::(actual).ok() == json5::from_str::(expected).ok() } AgentClient::Hermes => { serde_norway::from_str::(actual).ok() == serde_norway::from_str::(expected).ok() } AgentClient::DeepSeekHarness => { serde_norway::from_str::(actual).ok() == serde_norway::from_str::(expected).ok() } _ => { serde_json::from_str::(actual).ok() == serde_json::from_str::(expected).ok() } } } pub(crate) fn build_agent_session_restored_bytes( client: AgentClient, paths: &[PathBuf], path: &Path, current: Option<&[u8]>, original: Option<&[u8]>, ) -> Result>, String> { let Some(current) = current else { return Ok(original.map(ToOwned::to_owned)); }; if client == AgentClient::Codex && paths.first().is_none_or(|config| config != path) { return Ok(original.map(ToOwned::to_owned)); } let current = std::str::from_utf8(current) .map_err(|_| format!("当前智能体配置不是 UTF-8 文本: {}", path_to_string(path)))?; let original_bytes = original; let original = original_bytes .map(|bytes| { std::str::from_utf8(bytes) .map_err(|_| format!("原智能体配置不是 UTF-8 文本: {}", path_to_string(path))) }) .transpose()?; let restored = match client { AgentClient::ClaudeCode => build_restored_claude_code_config(current, original)?, AgentClient::ClaudeDesktop => { let index = paths .iter() .position(|candidate| candidate == path) .ok_or_else(|| "Claude Desktop 恢复路径不匹配".to_string())?; build_restored_claude_desktop_config(index, current, original)? } AgentClient::Codex => build_restored_codex_agent_config(Some(current), original)?, AgentClient::OpenCode => build_restored_opencode_config(current, original)?, AgentClient::OpenClaw => build_restored_openclaw_config(current, original)?, AgentClient::Hermes => build_restored_hermes_config(current, original)?, AgentClient::DeepSeekHarness => { let index = paths .iter() .position(|candidate| candidate == path) .ok_or_else(|| "DeepSeek Harness 恢复路径不匹配".to_string())?; match index { 0 => build_restored_deepseek_harness_settings(current, original)?, 1 => build_restored_deepseek_harness_credentials(current, original)?, _ => return Err("DeepSeek Harness 恢复路径索引无效".to_string()), } } }; if let (Some(restored), Some(original), Some(original_bytes)) = (restored.as_deref(), original, original_bytes) { if agent_config_semantically_equal(client, restored, original) { return Ok(Some(original_bytes.to_vec())); } } Ok(restored.map(String::into_bytes)) } #[cfg(test)] pub(crate) fn build_codex_agent_config( existing: Option<&str>, base_url: &str, api_key: &str, model: &str, ) -> Result { build_codex_agent_config_with_oauth(existing, base_url, api_key, model, false) } pub(crate) fn build_codex_agent_config_with_oauth( existing: Option<&str>, base_url: &str, api_key: &str, model: &str, oauth_configuration: bool, ) -> Result { use toml_edit::{value, Document, Item, Table}; let mut document = match existing.filter(|value| !value.trim().is_empty()) { Some(value) => value .parse::() .map_err(|error| format!("Codex config.toml 格式无效: {error}"))?, None => Document::new(), }; set_codex_table_item( document.as_table_mut(), "model_provider", value(MANAGED_AGENT_PROVIDER_ID), ); set_codex_table_item(document.as_table_mut(), "model", value(model)); set_codex_table_item( document.as_table_mut(), "model_catalog_json", value(CODEX_MODEL_CATALOG_FILE), ); if !document .get("model_providers") .is_some_and(toml_edit::Item::is_table) { document.remove("model_providers"); document["model_providers"] = Item::Table(Table::new()); } let providers = document["model_providers"] .as_table_mut() .ok_or_else(|| "Codex model_providers 必须是 TOML 表".to_string())?; if !providers .get(MANAGED_AGENT_PROVIDER_ID) .is_some_and(toml_edit::Item::is_table) { providers.remove(MANAGED_AGENT_PROVIDER_ID); providers.insert(MANAGED_AGENT_PROVIDER_ID, Item::Table(Table::new())); } let provider = providers .get_mut(MANAGED_AGENT_PROVIDER_ID) .and_then(Item::as_table_mut) .ok_or_else(|| "Codex cpa-gui provider 必须是 TOML 表".to_string())?; set_codex_table_item(provider, "name", value("EasyCLIProxyAPI")); set_codex_table_item(provider, "base_url", value(base_url)); set_codex_table_item(provider, "wire_api", value("responses")); set_codex_table_item(provider, "experimental_bearer_token", value(api_key)); if oauth_configuration { set_codex_table_item(provider, "requires_openai_auth", value(true)); } else { provider.remove("requires_openai_auth"); } let rendered = document.to_string(); toml::from_str::(&rendered) .map_err(|error| format!("验证 Codex 配置失败: {error}"))?; Ok(rendered) } const CODEX_MANAGED_ROOT_KEYS: [&str; 3] = ["model_provider", "model", "model_catalog_json"]; const CODEX_MANAGED_PROVIDER_KEYS: [&str; 5] = [ "name", "base_url", "wire_api", "experimental_bearer_token", "requires_openai_auth", ]; pub(crate) fn set_codex_table_item( table: &mut toml_edit::Table, key: &str, mut item: toml_edit::Item, ) { if let (Some(current), Some(next)) = ( table.get(key).and_then(toml_edit::Item::as_value), item.as_value_mut(), ) { *next.decor_mut() = current.decor().clone(); } *table.entry(key).or_insert(toml_edit::Item::None) = item; } pub(crate) fn restore_codex_table_item( current: &mut toml_edit::Table, original: Option<&toml_edit::Table>, key: &str, ) { let original_item = original.and_then(|table| table.get(key)).cloned(); if let Some(item) = original_item { let existed = current.contains_key(key); let original_key_decor = original.and_then(|table| table.key_decor(key)).cloned(); set_codex_table_item(current, key, item); if !existed { if let (Some(current_decor), Some(original_decor)) = (current.key_decor_mut(key), original_key_decor) { *current_decor = original_decor; } } } else { current.remove(key); } } pub(crate) fn parse_codex_document( content: Option<&str>, label: &str, ) -> Result { use toml_edit::Document; match content.filter(|value| !value.trim().is_empty()) { Some(value) => value .parse::() .map_err(|error| format!("{label} 格式无效: {error}")), None => Ok(Document::new()), } } pub(crate) fn codex_provider_table(document: &toml_edit::Document) -> Option<&toml_edit::Table> { document .as_table() .get("model_providers") .and_then(toml_edit::Item::as_table) .and_then(|providers| providers.get(MANAGED_AGENT_PROVIDER_ID)) .and_then(toml_edit::Item::as_table) } pub(crate) fn ensure_codex_provider_table( document: &mut toml_edit::Document, ) -> Result<&mut toml_edit::Table, String> { use toml_edit::{Item, Table}; let root = document.as_table_mut(); if !root.contains_key("model_providers") { root.insert("model_providers", Item::Table(Table::new())); } let providers = root .get_mut("model_providers") .and_then(Item::as_table_mut) .ok_or_else(|| "Codex model_providers 必须是 TOML 表".to_string())?; if !providers.contains_key(MANAGED_AGENT_PROVIDER_ID) { providers.insert(MANAGED_AGENT_PROVIDER_ID, Item::Table(Table::new())); } providers .get_mut(MANAGED_AGENT_PROVIDER_ID) .and_then(Item::as_table_mut) .ok_or_else(|| "Codex cpa-gui provider 必须是 TOML 表".to_string()) } pub(crate) fn merge_missing_codex_table_items( current: &mut toml_edit::Table, original: &toml_edit::Table, ) { for (key, original_item) in original.iter() { if !current.contains_key(key) { current.insert(key, original_item.clone()); continue; } if let (Some(current_table), Some(original_table)) = ( current.get_mut(key).and_then(toml_edit::Item::as_table_mut), original_item.as_table(), ) { merge_missing_codex_table_items(current_table, original_table); } } } pub(crate) fn build_restored_codex_agent_config( current: Option<&str>, original: Option<&str>, ) -> Result, String> { use toml_edit::Item; let mut current_document = parse_codex_document(current, "当前 Codex config.toml")?; let original_document = original .map(|content| parse_codex_document(Some(content), "原始 Codex config.toml")) .transpose()?; if let Some(original_document) = original_document.as_ref() { merge_missing_codex_table_items( current_document.as_table_mut(), original_document.as_table(), ); } for key in CODEX_MANAGED_ROOT_KEYS { restore_codex_table_item( current_document.as_table_mut(), original_document .as_ref() .map(|document| document.as_table()), key, ); } let original_provider_items = CODEX_MANAGED_PROVIDER_KEYS .into_iter() .map(|key| { ( key, original_document .as_ref() .and_then(codex_provider_table) .and_then(|provider| provider.get(key)) .cloned(), ) }) .collect::>(); let needs_provider = original_provider_items .iter() .any(|(_, item)| item.is_some()); if needs_provider { let provider = ensure_codex_provider_table(&mut current_document)?; let original_provider = original_document.as_ref().and_then(codex_provider_table); for (key, item) in &original_provider_items { if item.is_some() { restore_codex_table_item(provider, original_provider, key); } else { provider.remove(key); } } } else if let Some(providers) = current_document .as_table_mut() .get_mut("model_providers") .and_then(Item::as_table_mut) { if let Some(provider) = providers .get_mut(MANAGED_AGENT_PROVIDER_ID) .and_then(Item::as_table_mut) { for key in CODEX_MANAGED_PROVIDER_KEYS { provider.remove(key); } if provider.is_empty() { providers.remove(MANAGED_AGENT_PROVIDER_ID); } } } let remove_providers = current_document .as_table() .get("model_providers") .and_then(Item::as_table) .is_some_and(toml_edit::Table::is_empty); if remove_providers { current_document.as_table_mut().remove("model_providers"); } let rendered = current_document.to_string(); toml::from_str::(&rendered) .map_err(|error| format!("验证恢复后的 Codex 配置失败: {error}"))?; if original.is_none() && rendered.trim().is_empty() { Ok(None) } else { Ok(Some(rendered)) } } pub(crate) fn validate_codex_catalog(catalog: &str, model: &str) -> Result<(), String> { let root: serde_json::Value = serde_json::from_str(catalog) .map_err(|error| format!("Codex 模型目录格式无效: {error}"))?; let models = root .get("models") .and_then(serde_json::Value::as_array) .filter(|models| !models.is_empty()) .ok_or_else(|| "Codex 模型目录必须包含非空 models 数组".to_string())?; if !models.iter().any(|entry| { entry .get("slug") .and_then(serde_json::Value::as_str) .is_some_and(|slug| slug.eq_ignore_ascii_case(model)) }) { return Err(format!("默认模型 {model} 不在生成的 Codex 模型目录中")); } Ok(()) } pub(crate) fn build_opencode_agent_config( existing: Option<&str>, base_url: &str, api_key: &str, model: &str, available_models: &[AgentModelOption], ) -> Result { let mut root = parse_agent_json_object(existing, "OpenCode opencode.json")?; root.entry("$schema".to_string()) .or_insert_with(|| serde_json::json!("https://opencode.ai/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(); (model.name, serde_json::json!({ "name": display_name })) }) .collect::>(); let managed_provider = ensure_json_object_entry(providers, MANAGED_AGENT_PROVIDER_ID); managed_provider.insert( "npm".to_string(), serde_json::json!("@ai-sdk/openai-compatible"), ); managed_provider.insert("name".to_string(), serde_json::json!("EasyCLIProxyAPI")); 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)); root.insert( "model".to_string(), serde_json::json!(format!("{MANAGED_AGENT_PROVIDER_ID}/{model}")), ); render_agent_json(root, "OpenCode 配置") } pub(crate) fn build_openclaw_agent_config( existing: Option<&str>, base_url: &str, api_key: &str, model: &str, available_models: &[AgentModelOption], ) -> Result { let mut root = match existing.map(str::trim).filter(|value| !value.is_empty()) { Some(value) => json5::from_str::(value) .map_err(|error| format!("OpenClaw openclaw.json 格式无效: {error}"))?, None => serde_json::json!({}), }; let root = root .as_object_mut() .ok_or_else(|| "OpenClaw openclaw.json 根节点必须是对象".to_string())?; let ordered_models = ordered_agent_models(available_models, model); let models = ensure_json_object_entry(root, "models"); models .entry("mode".to_string()) .or_insert_with(|| serde_json::json!("merge")); let providers = ensure_json_object_entry(models, "providers"); let managed_provider = ensure_json_object_entry(providers, MANAGED_AGENT_PROVIDER_ID); managed_provider.insert("baseUrl".to_string(), serde_json::json!(base_url)); managed_provider.insert("apiKey".to_string(), serde_json::json!(api_key)); managed_provider.insert("api".to_string(), serde_json::json!("openai-completions")); managed_provider.insert( "models".to_string(), serde_json::Value::Array( ordered_models .iter() .map(|model| { serde_json::json!({ "id": model.name.clone(), "name": model.alias.as_deref().unwrap_or(&model.name), }) }) .collect(), ), ); let agents = ensure_json_object_entry(root, "agents"); let defaults = ensure_json_object_entry(agents, "defaults"); let default_model = ensure_json_object_entry(defaults, "model"); default_model.insert( "primary".to_string(), serde_json::json!(format!("{MANAGED_AGENT_PROVIDER_ID}/{model}")), ); let model_catalog = ensure_json_object_entry(defaults, "models"); let managed_prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/"); model_catalog.retain(|name, _| !name.starts_with(&managed_prefix)); for model in &ordered_models { let name = format!("{MANAGED_AGENT_PROVIDER_ID}/{}", model.name); let value = model .alias .as_deref() .map(|alias| serde_json::json!({ "alias": alias })) .unwrap_or_else(|| serde_json::json!({})); model_catalog.insert(name, value); } let rendered = render_agent_json(root.clone(), "OpenClaw 配置")?; let comments = existing.map(extract_json5_comments).unwrap_or_default(); if comments.is_empty() { Ok(rendered) } else { Ok(format!("{}\n{rendered}", comments.join("\n"))) } } pub(crate) fn extract_json5_comments(content: &str) -> Vec { let bytes = content.as_bytes(); let mut comments = Vec::new(); let mut index = 0; let mut quote = None; let mut escaped = false; while index < bytes.len() { let byte = bytes[index]; if let Some(active_quote) = quote { if escaped { escaped = false; } else if byte == b'\\' { escaped = true; } else if byte == active_quote { quote = None; } index += 1; continue; } if matches!(byte, b'\'' | b'"') { quote = Some(byte); index += 1; continue; } if byte == b'/' && bytes.get(index + 1) == Some(&b'/') { let start = index; index += 2; while index < bytes.len() && !matches!(bytes[index], b'\r' | b'\n') { index += 1; } comments.push(content[start..index].to_string()); continue; } if byte == b'/' && bytes.get(index + 1) == Some(&b'*') { let start = index; index += 2; while index + 1 < bytes.len() && !(bytes[index] == b'*' && bytes[index + 1] == b'/') { index += 1; } index = (index + 2).min(bytes.len()); comments.push(content[start..index].to_string()); continue; } index += 1; } comments } pub(crate) fn ensure_yaml_mapping_entry<'a>( root: &'a mut serde_norway::Mapping, key: &str, ) -> &'a mut serde_norway::Mapping { let key_value = serde_norway::Value::String(key.to_string()); if !root .get(&key_value) .is_some_and(|value| value.as_mapping().is_some()) { root.insert( key_value.clone(), serde_norway::Value::Mapping(serde_norway::Mapping::new()), ); } root.get_mut(&key_value) .and_then(serde_norway::Value::as_mapping_mut) .expect("mapping entry was just normalized") } pub(crate) fn ensure_yaml_sequence_entry<'a>( root: &'a mut serde_norway::Mapping, key: &str, ) -> &'a mut Vec { let key_value = serde_norway::Value::String(key.to_string()); if !root .get(&key_value) .is_some_and(|value| value.as_sequence().is_some()) { root.insert(key_value.clone(), serde_norway::Value::Sequence(Vec::new())); } root.get_mut(&key_value) .and_then(serde_norway::Value::as_sequence_mut) .expect("sequence entry was just normalized") } pub(crate) fn build_hermes_agent_config( existing: Option<&str>, base_url: &str, api_key: &str, model: &str, available_models: &[AgentModelOption], ) -> Result { let original = match existing.map(str::trim).filter(|value| !value.is_empty()) { Some(value) => serde_norway::from_str::(value) .map_err(|error| format!("Hermes config.yaml 格式无效: {error}"))?, None => serde_norway::Value::Mapping(serde_norway::Mapping::new()), }; let mut root = original.clone(); let mapping = root .as_mapping_mut() .ok_or_else(|| "Hermes config.yaml 根节点必须是映射".to_string())?; let providers = ensure_yaml_sequence_entry(mapping, "custom_providers"); let mut managed_provider = None; let mut retained_providers = Vec::with_capacity(providers.len()); for provider in std::mem::take(providers) { match provider.get("name").and_then(serde_norway::Value::as_str) { Some(name) if name == MANAGED_AGENT_PROVIDER_ID => { if managed_provider.is_none() { managed_provider = provider.as_mapping().cloned(); } } Some(_) => retained_providers.push(provider), None => {} } } let provider_models = ordered_agent_models(available_models, model) .into_iter() .map(|model| (model.name, serde_json::json!({}))) .collect::>(); let canonical_provider = serde_norway::to_value(serde_json::json!({ "name": MANAGED_AGENT_PROVIDER_ID, "base_url": base_url, "api_key": api_key, "api_mode": "chat_completions", "model": model, "models": provider_models })) .map_err(|error| format!("生成 Hermes provider 失败: {error}"))?; let canonical_provider = canonical_provider .as_mapping() .ok_or_else(|| "生成 Hermes provider 失败: 根节点不是映射".to_string())?; let mut managed_provider = managed_provider.unwrap_or_default(); for (key, value) in canonical_provider { managed_provider.insert(key.clone(), value.clone()); } retained_providers.push(serde_norway::Value::Mapping(managed_provider)); *providers = retained_providers; let model_config = ensure_yaml_mapping_entry(mapping, "model"); model_config.insert( serde_norway::Value::String("default".to_string()), serde_norway::Value::String(model.to_string()), ); model_config.insert( serde_norway::Value::String("provider".to_string()), serde_norway::Value::String(MANAGED_AGENT_PROVIDER_ID.to_string()), ); let rendered = if let Some(existing) = existing.filter(|value| !value.trim().is_empty()) { render_yaml_value_changes(existing, &original, &root)? } else { serde_norway::to_string(&root).map_err(|error| format!("生成 Hermes 配置失败: {error}"))? }; serde_norway::from_str::(&rendered) .map_err(|error| format!("验证 Hermes 配置失败: {error}"))?; Ok(rendered) }