2465 lines
89 KiB
Rust
2465 lines
89 KiB
Rust
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<Vec<AgentFileUpdate>, 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<Vec<AgentFileUpdate>, 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<Option<String>, 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<serde_norway::Mapping, String> {
|
|
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<serde_norway::Mapping, String> {
|
|
let Some(content) = content.map(str::trim).filter(|content| !content.is_empty()) else {
|
|
return Ok(serde_norway::Mapping::new());
|
|
};
|
|
serde_norway::from_str::<serde_norway::Value>(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<F>(
|
|
existing: Option<&str>,
|
|
label: &str,
|
|
update: F,
|
|
) -> Result<String, String>
|
|
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<serde_norway::Value, String> {
|
|
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::<Result<Vec<_>, 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<String, String> {
|
|
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<String, String> {
|
|
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<String, String> {
|
|
let mut root = match existing.map(str::trim).filter(|value| !value.is_empty()) {
|
|
Some(value) => serde_json::from_str::<serde_json::Value>(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<serde_json::Map<String, serde_json::Value>, String> {
|
|
let value = match existing.map(str::trim).filter(|value| !value.is_empty()) {
|
|
Some(value) => serde_json::from_str::<serde_json::Value>(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<String, serde_json::Value>,
|
|
key: &str,
|
|
) -> &'a mut serde_json::Map<String, serde_json::Value> {
|
|
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<String, serde_json::Value>,
|
|
key: &str,
|
|
) -> &'a mut Vec<serde_json::Value> {
|
|
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<String, serde_json::Value>,
|
|
label: &str,
|
|
) -> Result<String, String> {
|
|
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<AgentModelOption> {
|
|
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<String, String> {
|
|
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<String, String> {
|
|
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::<serde_json::Value>::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<String, String> {
|
|
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<F>(path: &Path, label: &str, update: F) -> Result<bool, String>
|
|
where
|
|
F: FnOnce(&mut serde_json::Map<String, serde_json::Value>) -> 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<Vec<String>, 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<Vec<String>, 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<Vec<String>, 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::<Document>()
|
|
.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::<toml::Value>(&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<Vec<String>, 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<F>(
|
|
path: &Path,
|
|
label: &str,
|
|
update: F,
|
|
) -> Result<bool, String>
|
|
where
|
|
F: FnOnce(&mut serde_json::Map<String, serde_json::Value>) -> 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::<serde_json::Value>(&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<Vec<String>, 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<Vec<String>, 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<Vec<String>, 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<Vec<String>, 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<String, serde_json::Value>,
|
|
original: Option<&serde_json::Map<String, serde_json::Value>>,
|
|
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<Option<serde_json::Map<String, serde_json::Value>>, String> {
|
|
content
|
|
.map(|content| parse_agent_json_object(Some(content), label))
|
|
.transpose()
|
|
}
|
|
|
|
pub(crate) fn render_restored_json(
|
|
root: serde_json::Map<String, serde_json::Value>,
|
|
original_existed: bool,
|
|
label: &str,
|
|
) -> Result<Option<String>, 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<Option<String>, 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<Option<String>, 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<Option<String>, 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<Option<String>, String> {
|
|
let current_value = json5::from_str::<serde_json::Value>(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::<serde_json::Value>(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<Option<String>, 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::<serde_norway::Value>(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<Option<String>, 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<Option<String>, 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::<toml::Value>(actual).ok()
|
|
== toml::from_str::<toml::Value>(expected).ok()
|
|
}
|
|
AgentClient::OpenClaw => {
|
|
json5::from_str::<serde_json::Value>(actual).ok()
|
|
== json5::from_str::<serde_json::Value>(expected).ok()
|
|
}
|
|
AgentClient::Hermes => {
|
|
serde_norway::from_str::<serde_norway::Value>(actual).ok()
|
|
== serde_norway::from_str::<serde_norway::Value>(expected).ok()
|
|
}
|
|
AgentClient::DeepSeekHarness => {
|
|
serde_norway::from_str::<serde_norway::Value>(actual).ok()
|
|
== serde_norway::from_str::<serde_norway::Value>(expected).ok()
|
|
}
|
|
_ => {
|
|
serde_json::from_str::<serde_json::Value>(actual).ok()
|
|
== serde_json::from_str::<serde_json::Value>(expected).ok()
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn build_agent_session_restored_bytes(
|
|
client: AgentClient,
|
|
paths: &[PathBuf],
|
|
path: &Path,
|
|
current: Option<&[u8]>,
|
|
original: Option<&[u8]>,
|
|
) -> Result<Option<Vec<u8>>, 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<String, String> {
|
|
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<String, String> {
|
|
use toml_edit::{value, Document, Item, Table};
|
|
|
|
let mut document = match existing.filter(|value| !value.trim().is_empty()) {
|
|
Some(value) => value
|
|
.parse::<Document>()
|
|
.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::<toml::Value>(&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<toml_edit::Document, String> {
|
|
use toml_edit::Document;
|
|
|
|
match content.filter(|value| !value.trim().is_empty()) {
|
|
Some(value) => value
|
|
.parse::<Document>()
|
|
.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<Option<String>, 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::<Vec<_>>();
|
|
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::<toml::Value>(&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<String, String> {
|
|
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::<serde_json::Map<_, _>>();
|
|
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<String, String> {
|
|
let mut root = match existing.map(str::trim).filter(|value| !value.is_empty()) {
|
|
Some(value) => json5::from_str::<serde_json::Value>(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<String> {
|
|
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<serde_norway::Value> {
|
|
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<String, String> {
|
|
let original = match existing.map(str::trim).filter(|value| !value.is_empty()) {
|
|
Some(value) => serde_norway::from_str::<serde_norway::Value>(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::<serde_json::Map<_, _>>();
|
|
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::<serde_norway::Value>(&rendered)
|
|
.map_err(|error| format!("验证 Hermes 配置失败: {error}"))?;
|
|
Ok(rendered)
|
|
}
|