3550 lines
131 KiB
Rust
3550 lines
131 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 = managed_core_loopback_origin(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,
|
|
)
|
|
?;
|
|
Ok(vec![AgentFileUpdate {
|
|
path: paths[0].clone(),
|
|
after,
|
|
}])
|
|
}
|
|
AgentClient::ClaudeDesktop => {
|
|
if paths.len() != 4 {
|
|
return Err("Claude Desktop configuration path is unavailable on the current platform".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())
|
|
?,
|
|
},
|
|
AgentFileUpdate {
|
|
path: paths[1].clone(),
|
|
after: build_claude_desktop_deployment_config(threep_before.as_deref())
|
|
?,
|
|
},
|
|
AgentFileUpdate {
|
|
path: paths[2].clone(),
|
|
after: build_claude_desktop_profile(
|
|
profile_before.as_deref(),
|
|
&root_base,
|
|
api_key,
|
|
model,
|
|
models,
|
|
claude_desktop_model_mappings,
|
|
)
|
|
?,
|
|
},
|
|
AgentFileUpdate {
|
|
path: paths[3].clone(),
|
|
after: build_claude_desktop_meta(meta_before.as_deref())
|
|
?,
|
|
},
|
|
])
|
|
}
|
|
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,
|
|
)
|
|
?;
|
|
let mut updates = vec![AgentFileUpdate {
|
|
path: paths[0].clone(),
|
|
after,
|
|
}];
|
|
let catalog = codex_catalog.ok_or_else(|| "Unable to generate Codex model catalog".to_string())?;
|
|
validate_codex_catalog(catalog, model)?;
|
|
updates.push(AgentFileUpdate {
|
|
path: codex_model_catalog_path(home),
|
|
after: catalog.to_string(),
|
|
});
|
|
updates.push(build_codex_auth_update(home, api_key, oauth_configuration)?);
|
|
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,
|
|
)
|
|
?;
|
|
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,
|
|
)
|
|
?;
|
|
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)
|
|
?;
|
|
Ok(vec![AgentFileUpdate {
|
|
path: paths[0].clone(),
|
|
after,
|
|
}])
|
|
}
|
|
AgentClient::DeepSeekHarness => {
|
|
if paths.len() != 2 {
|
|
return Err("Invalid number of DeepSeek Harness configuration paths".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,
|
|
)?,
|
|
},
|
|
])
|
|
}
|
|
AgentClient::AntigravityCli => build_antigravity_updates(client, home, &root_base, api_key, model, false),
|
|
AgentClient::WorkBuddy => {
|
|
let before = read_optional_text(&paths[0])?;
|
|
Ok(vec![AgentFileUpdate {
|
|
path: paths[0].clone(),
|
|
after: build_workbuddy_agent_config(before.as_deref(), &openai_base, api_key, model, models)?,
|
|
}])
|
|
}
|
|
AgentClient::ZCode => {
|
|
let before = read_optional_text(&paths[0])?;
|
|
let after = build_zcode_agent_config(
|
|
before.as_deref(), &root_base, api_key, model, models,
|
|
)?;
|
|
Ok(vec![AgentFileUpdate { path: paths[0].clone(), after }])
|
|
}
|
|
AgentClient::KimiCode => {
|
|
let before = read_optional_text(&paths[0])?;
|
|
let after = build_kimi_code_agent_config(
|
|
before.as_deref(),
|
|
&openai_base,
|
|
api_key,
|
|
model,
|
|
models,
|
|
)
|
|
?;
|
|
Ok(vec![AgentFileUpdate {
|
|
path: paths[0].clone(),
|
|
after,
|
|
}])
|
|
}
|
|
AgentClient::GrokBuild => {
|
|
let before = read_optional_text(&paths[0])?;
|
|
let after = build_grok_build_agent_config(
|
|
before.as_deref(),
|
|
&openai_base,
|
|
api_key,
|
|
model,
|
|
models,
|
|
)
|
|
?;
|
|
Ok(vec![AgentFileUpdate {
|
|
path: paths[0].clone(),
|
|
after,
|
|
}])
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn read_optional_text(path: &Path) -> Result<Option<String>, String> {
|
|
read_agent_bytes(path)?.map(|bytes| String::from_utf8(bytes)
|
|
.map_err(|_| "Configuration is not UTF-8 text. Restore it from a manual backup or repair it with the base configuration template".to_string())).transpose()
|
|
}
|
|
|
|
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!("Failed to read {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!("Invalid {label} YAML format: {error}"))?
|
|
.as_mapping()
|
|
.cloned()
|
|
.ok_or_else(|| format!("{label} root must be a YAML mapping"))
|
|
}
|
|
|
|
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!("Invalid {label} YAML format: {error}"))?;
|
|
render_updated_core_yaml(&mut document, updated)
|
|
.map_err(|error| format!("Failed to update {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!("Failed to serialize Harness model context: {error}"))?,
|
|
);
|
|
}
|
|
if let Some(input) = model.input_modalities {
|
|
entry.insert(
|
|
yaml_key("input"),
|
|
serde_norway::to_value(input)
|
|
.map_err(|_| "Failed to serialize Harness model input types")?,
|
|
);
|
|
}
|
|
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> {
|
|
build_deepseek_harness_catalog_settings(
|
|
existing,
|
|
base_url,
|
|
model,
|
|
models,
|
|
&mut DeepSeekHarnessCatalogState::default(),
|
|
)
|
|
}
|
|
|
|
pub(crate) fn render_deepseek_harness_settings(
|
|
existing: Option<&str>,
|
|
base_url: &str,
|
|
model: &str,
|
|
published_models: serde_norway::Value,
|
|
) -> Result<String, String> {
|
|
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 must be a mapping".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 must be a mapping".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 must be a mapping".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 must be a mapping".to_string())?;
|
|
let same_model = selection.get(yaml_key("provider")).and_then(serde_norway::Value::as_str) == Some(DEEPSEEK_HARNESS_PROVIDER_ID)
|
|
&& selection.get(yaml_key("model")).and_then(serde_norway::Value::as_str) == Some(model);
|
|
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()),
|
|
);
|
|
if !same_model { 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| {
|
|
let refs = deepseek_harness_credentials_refs_mut(root, "DeepSeek Harness credentials")?;
|
|
refs.insert(
|
|
yaml_key(DEEPSEEK_HARNESS_CREDENTIAL),
|
|
serde_norway::Value::String(api_key.to_string()),
|
|
);
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
fn deepseek_harness_credentials_version_is_supported(value: &serde_norway::Value) -> bool {
|
|
value.as_u64() == Some(DEEPSEEK_HARNESS_CREDENTIALS_VERSION)
|
|
|| value.as_i64() == Some(DEEPSEEK_HARNESS_CREDENTIALS_VERSION as i64)
|
|
}
|
|
|
|
fn validate_deepseek_harness_versioned_credentials(
|
|
root: &serde_norway::Mapping,
|
|
label: &str,
|
|
) -> Result<(), String> {
|
|
let version =
|
|
yaml_mapping_value(root, "version").ok_or_else(|| format!("{label} is missing the version field"))?;
|
|
if !deepseek_harness_credentials_version_is_supported(version) {
|
|
return Err(format!(
|
|
"{label} version must be {DEEPSEEK_HARNESS_CREDENTIALS_VERSION}"
|
|
));
|
|
}
|
|
for key in root.keys() {
|
|
let Some(key) = key.as_str() else {
|
|
return Err(format!("Top-level field names in {label} must be strings"));
|
|
};
|
|
let _ = key;
|
|
}
|
|
for section in ["refs", "records"] {
|
|
if yaml_mapping_value(root, section).is_some_and(|value| !value.is_mapping()) {
|
|
return Err(format!("{label} {section} must be a mapping"));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn deepseek_harness_credentials_refs_mut<'a>(
|
|
root: &'a mut serde_norway::Mapping,
|
|
label: &str,
|
|
) -> Result<&'a mut serde_norway::Mapping, String> {
|
|
if root.is_empty() {
|
|
root.insert(
|
|
yaml_key("version"),
|
|
serde_norway::Value::Number(DEEPSEEK_HARNESS_CREDENTIALS_VERSION.into()),
|
|
);
|
|
} else {
|
|
validate_deepseek_harness_versioned_credentials(root, label)?;
|
|
root.remove(yaml_key(DEEPSEEK_HARNESS_CREDENTIAL));
|
|
}
|
|
|
|
root.entry(yaml_key("refs"))
|
|
.or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new()))
|
|
.as_mapping_mut()
|
|
.ok_or_else(|| format!("{label} refs must be a mapping"))
|
|
}
|
|
|
|
fn remove_deepseek_harness_managed_credential(
|
|
root: &mut serde_norway::Mapping,
|
|
label: &str,
|
|
) -> Result<bool, String> {
|
|
if root.is_empty() {
|
|
return Ok(false);
|
|
}
|
|
|
|
validate_deepseek_harness_versioned_credentials(root, label)?;
|
|
let mut changed = root.remove(yaml_key(DEEPSEEK_HARNESS_CREDENTIAL)).is_some();
|
|
let mut remove_refs = false;
|
|
if let Some(refs) = root
|
|
.get_mut(yaml_key("refs"))
|
|
.and_then(serde_norway::Value::as_mapping_mut)
|
|
{
|
|
changed |= refs.remove(yaml_key(DEEPSEEK_HARNESS_CREDENTIAL)).is_some();
|
|
remove_refs = refs.is_empty();
|
|
}
|
|
if remove_refs {
|
|
root.remove(yaml_key("refs"));
|
|
}
|
|
Ok(changed)
|
|
}
|
|
|
|
fn deepseek_harness_credentials_document_is_empty(root: &serde_norway::Mapping) -> bool {
|
|
root.iter().all(|(key, value)| match key.as_str() {
|
|
Some("version") => deepseek_harness_credentials_version_is_supported(value),
|
|
Some("refs" | "records") => value
|
|
.as_mapping()
|
|
.is_some_and(serde_norway::Mapping::is_empty),
|
|
_ => false,
|
|
})
|
|
}
|
|
|
|
fn deepseek_harness_original_credential(
|
|
root: &serde_norway::Mapping,
|
|
) -> Option<&serde_norway::Value> {
|
|
yaml_mapping_value(root, "refs")
|
|
.and_then(serde_norway::Value::as_mapping)
|
|
.and_then(|refs| yaml_mapping_value(refs, DEEPSEEK_HARNESS_CREDENTIAL))
|
|
}
|
|
|
|
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!(
|
|
"Failed to set DeepSeek Harness credential permissions {}: {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::Codex
|
|
&& matches!(path.file_name().and_then(|name| name.to_str()), Some(CODEX_NATIVE_OAUTH_STATE_FILE | "auth.json"))
|
|
{
|
|
return write_codex_private_file(path, content);
|
|
}
|
|
if client == AgentClient::AntigravityCli {
|
|
return write_codex_private_file(path, content);
|
|
}
|
|
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!("Invalid Claude Code settings.json format: {error}"))?,
|
|
None => serde_json::json!({}),
|
|
};
|
|
let root = root
|
|
.as_object_mut()
|
|
.ok_or_else(|| "Claude Code settings.json root must be an object".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_CODE_AUTO_MODE_SERVER_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(),
|
|
),
|
|
] {
|
|
env.insert(
|
|
key.to_string(),
|
|
serde_json::Value::String(value.to_string()),
|
|
);
|
|
}
|
|
env.entry("CLAUDE_CODE_SUBAGENT_MODEL".to_string())
|
|
.or_insert_with(|| serde_json::Value::String(subagent_model));
|
|
env.insert(
|
|
CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV.to_string(),
|
|
serde_json::Value::String(max_context_tokens.to_string()),
|
|
);
|
|
env.insert(
|
|
CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV.to_string(),
|
|
serde_json::Value::String("1".to_string()),
|
|
);
|
|
env.insert(
|
|
CLAUDE_CODE_AUTO_MODE_SERVER_ENV.to_string(),
|
|
serde_json::Value::String("0".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!("Failed to generate Claude Code configuration: {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!("Invalid {label} format: {error}"))?,
|
|
None => serde_json::json!({}),
|
|
};
|
|
value
|
|
.as_object()
|
|
.cloned()
|
|
.ok_or_else(|| format!("{label} root must be an object"))
|
|
}
|
|
|
|
pub(crate) fn parse_agent_json5_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) => json5::from_str::<serde_json::Value>(value)
|
|
.map_err(|error| format!("Invalid {label} format: {error}"))?,
|
|
None => serde_json::json!({}),
|
|
};
|
|
value
|
|
.as_object()
|
|
.cloned()
|
|
.ok_or_else(|| format!("{label} root must be an object"))
|
|
}
|
|
|
|
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!("Failed to generate {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 {
|
|
input_modalities: None,
|
|
harness_metadata: None,
|
|
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 configuration")?;
|
|
root.insert("deploymentMode".to_string(), serde_json::json!("3p"));
|
|
render_agent_json(root, "Claude Desktop configuration")
|
|
}
|
|
|
|
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 gateway configuration")?;
|
|
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 = if let Some(entries) = &mappings.desktop_models {
|
|
validate_claude_desktop_entries(entries)?;
|
|
let mut families = HashSet::new();
|
|
entries
|
|
.iter()
|
|
.map(|entry| {
|
|
let mut value = claude_desktop_inference_model(
|
|
entry.model_id(),
|
|
entry.source_or_alias(),
|
|
entry.context_1m,
|
|
models,
|
|
);
|
|
value["name"] = serde_json::json!(entry.model_id());
|
|
value["labelOverride"] = serde_json::json!(entry.source_or_alias());
|
|
if let Some(tier) = claude_desktop_family_tier(entry.model_id()) {
|
|
value["isFamilyDefault"] = serde_json::json!(families.insert(tier));
|
|
}
|
|
value
|
|
})
|
|
.collect()
|
|
} else {
|
|
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 gateway configuration")
|
|
}
|
|
|
|
pub(crate) fn claude_desktop_inference_model(
|
|
route_model: &str,
|
|
source_model: &str,
|
|
enable_1m: bool,
|
|
models: &[AgentModelOption],
|
|
) -> serde_json::Value {
|
|
let use_source = claude_desktop_uses_source_directly(source_model, models);
|
|
let mut entry = serde_json::Map::new();
|
|
entry.insert(
|
|
"name".to_string(),
|
|
serde_json::json!(if use_source {
|
|
source_model
|
|
} else {
|
|
route_model
|
|
}),
|
|
);
|
|
if !use_source {
|
|
entry.insert("labelOverride".to_string(), serde_json::json!(source_model));
|
|
}
|
|
if let Some(tier) = claude_desktop_family_tier(route_model) {
|
|
entry.insert("anthropicFamilyTier".to_string(), serde_json::json!(tier));
|
|
entry.insert("isFamilyDefault".to_string(), serde_json::json!(true));
|
|
}
|
|
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 claude_desktop_family_tier(route_model: &str) -> Option<&'static str> {
|
|
let lowered = route_model.to_ascii_lowercase();
|
|
if lowered.starts_with("claude-opus-") || lowered.contains("-opus-")
|
|
|| route_model.eq_ignore_ascii_case(CLAUDE_DESKTOP_OPUS_MODEL_ID)
|
|
|| route_model.eq_ignore_ascii_case(LEGACY_CLAUDE_DESKTOP_MODEL_IDS[0])
|
|
{
|
|
Some("opus")
|
|
} else if lowered.starts_with("claude-sonnet-") || lowered.contains("-sonnet-")
|
|
|| route_model.eq_ignore_ascii_case(CLAUDE_DESKTOP_SONNET_MODEL_ID)
|
|
|| route_model.eq_ignore_ascii_case(LEGACY_CLAUDE_DESKTOP_MODEL_IDS[1])
|
|
{
|
|
Some("sonnet")
|
|
} else if lowered.starts_with("claude-haiku-") || lowered.contains("-haiku-")
|
|
|| route_model.eq_ignore_ascii_case(CLAUDE_DESKTOP_HAIKU_MODEL_ID)
|
|
|| route_model.eq_ignore_ascii_case(LEGACY_CLAUDE_DESKTOP_MODEL_IDS[2])
|
|
{
|
|
Some("haiku")
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
pub(crate) fn build_claude_desktop_meta(existing: Option<&str>) -> Result<String, String> {
|
|
let mut root = parse_agent_json_object(existing, "Claude Desktop configuration index")?;
|
|
repair_claude_desktop_meta_names(&mut root);
|
|
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 configuration index")
|
|
}
|
|
|
|
pub(crate) fn repair_claude_desktop_meta_names(root: &mut serde_json::Map<String, serde_json::Value>) {
|
|
let Some(entries) = root.get_mut("entries").and_then(serde_json::Value::as_array_mut) else {
|
|
return;
|
|
};
|
|
for entry in entries {
|
|
let Some(id) = entry.get("id").and_then(serde_json::Value::as_str) else {
|
|
continue;
|
|
};
|
|
if entry.get("name").and_then(serde_json::Value::as_str).is_none() {
|
|
let name = format!("Configuration {id}");
|
|
entry["name"] = serde_json::json!(name);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn clear_agent_managed_configuration(
|
|
client: AgentClient,
|
|
home: &Path,
|
|
port: u16,
|
|
) -> Result<AgentConfigActionResult, String> {
|
|
if client == AgentClient::Codex {
|
|
return close_codex_configuration(home);
|
|
}
|
|
let paths = config_paths(client.id(), home)?;
|
|
let before = config_images(&paths)?;
|
|
validate_client_config_images(client.id(), &before)?;
|
|
let after = if let Some(after) = prepare_recorded_integration_restore(client, &paths, &before)? {
|
|
after
|
|
} else {
|
|
let mut after = before.clone();
|
|
for (path, bytes) in prepare_agent_managed_removal(client, &paths, port)? {
|
|
let entry = after
|
|
.iter_mut()
|
|
.find(|(candidate, _)| candidate == &path)
|
|
.ok_or("Configuration cleanup path mismatch")?;
|
|
entry.1 = bytes;
|
|
}
|
|
after
|
|
};
|
|
let mut result = commit_config(
|
|
client.id(),
|
|
&paths,
|
|
&before,
|
|
&after,
|
|
"clear-integration",
|
|
None,
|
|
)?;
|
|
result.enabled = false;
|
|
Ok(result)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn remove_agent_managed_configuration(
|
|
client: AgentClient,
|
|
paths: &[PathBuf],
|
|
) -> Result<Vec<String>, String> {
|
|
let updates = prepare_agent_managed_removal(client, paths, GuiConfigFile::default().port)?;
|
|
write_config_images(client.id(), &updates)?;
|
|
Ok(updates
|
|
.iter()
|
|
.map(|(path, _)| path_to_string(path))
|
|
.collect())
|
|
}
|
|
|
|
pub(crate) fn prepare_agent_json_removal<F>(
|
|
path: &Path,
|
|
label: &str,
|
|
update: F,
|
|
) -> Result<Option<FileSnapshot>, String>
|
|
where
|
|
F: FnOnce(&mut serde_json::Map<String, serde_json::Value>) -> bool,
|
|
{
|
|
if !path.is_file() {
|
|
return Ok(None);
|
|
}
|
|
let content = fs::read_to_string(path)
|
|
.map_err(|error| format!("Failed to read {label} {}: {error}", path_to_string(path)))?;
|
|
let mut root = parse_agent_json_object(Some(&content), label)?;
|
|
if !update(&mut root) {
|
|
return Ok(None);
|
|
}
|
|
let bytes = if root.is_empty() {
|
|
None
|
|
} else {
|
|
Some(render_agent_json(root, label)?.into_bytes())
|
|
};
|
|
Ok(Some((path.to_path_buf(), bytes)))
|
|
}
|
|
|
|
pub(crate) fn prepare_claude_desktop_managed_removal(paths: &[PathBuf]) -> Result<Images, String> {
|
|
if paths.len() != 4 {
|
|
return Err("Claude Desktop configuration path is unavailable on the current platform".to_string());
|
|
}
|
|
let meta = read_agent_json_or_empty(&paths[3], "Claude Desktop configuration index")?;
|
|
let applied_id = meta.get("appliedId").and_then(serde_json::Value::as_str);
|
|
let managed_active = applied_id == Some(CLAUDE_DESKTOP_PROFILE_ID)
|
|
|| (applied_id.is_none() && agent_has_managed_marker(AgentClient::ClaudeDesktop, paths)?);
|
|
let mut changed = Vec::new();
|
|
for (path, label) in [
|
|
(&paths[0], "Claude Desktop main configuration"),
|
|
(&paths[1], "Claude Desktop 3P configuration"),
|
|
] {
|
|
if let Some(update) = prepare_agent_json_removal(path, label, |root| {
|
|
if managed_active
|
|
&& root
|
|
.get("deploymentMode")
|
|
.and_then(serde_json::Value::as_str)
|
|
== Some("3p")
|
|
{
|
|
root.remove("deploymentMode");
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
})? {
|
|
changed.push(update);
|
|
}
|
|
}
|
|
|
|
if let Some(update) =
|
|
prepare_agent_json_removal(&paths[2], "Claude Desktop gateway configuration", |root| {
|
|
let mut updated = false;
|
|
for key in [
|
|
"disableDeploymentModeChooser",
|
|
"inferenceGatewayApiKey",
|
|
"inferenceGatewayAuthScheme",
|
|
"inferenceGatewayBaseUrl",
|
|
"inferenceProvider",
|
|
"inferenceModels",
|
|
] {
|
|
updated |= root.remove(key).is_some();
|
|
}
|
|
updated
|
|
})?
|
|
{
|
|
changed.push(update);
|
|
}
|
|
|
|
if let Some(update) =
|
|
prepare_agent_json_removal(&paths[3], "Claude Desktop configuration index", |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(update);
|
|
}
|
|
Ok(changed)
|
|
}
|
|
|
|
pub(crate) fn prepare_claude_code_managed_removal(
|
|
paths: &[PathBuf],
|
|
expected_base_url: &str,
|
|
) -> Result<Images, String> {
|
|
let Some(path) = paths.first() else {
|
|
return Err("Claude Code configuration path is unavailable on the current platform".to_string());
|
|
};
|
|
let expected_base_url = reqwest::Url::parse(expected_base_url)
|
|
.map_err(|_| "Invalid CPA access URL".to_string())?;
|
|
let updated = prepare_agent_json_removal(path, "Claude Code configuration", |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(|url| {
|
|
reqwest::Url::parse(url.trim()).is_ok_and(|url| url == expected_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_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV,
|
|
CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV,
|
|
CLAUDE_CODE_AUTO_MODE_SERVER_ENV,
|
|
CLAUDE_AUTOCOMPACT_PCT_OVERRIDE_ENV,
|
|
DISABLE_AUTO_COMPACT_ENV,
|
|
"CLAUDE_CODE_SUBAGENT_MODEL",
|
|
"CLAUDE_CODE_EFFORT_LEVEL",
|
|
"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME",
|
|
"ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION",
|
|
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME",
|
|
"ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION",
|
|
"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME",
|
|
"ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION",
|
|
"ANTHROPIC_DEFAULT_FABLE_MODEL_NAME",
|
|
"ANTHROPIC_DEFAULT_FABLE_MODEL_DESCRIPTION",
|
|
"ANTHROPIC_CUSTOM_MODEL_OPTION",
|
|
"ANTHROPIC_CUSTOM_MODEL_OPTION_NAME",
|
|
"ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION",
|
|
] {
|
|
env.remove(key);
|
|
}
|
|
env.is_empty()
|
|
} else {
|
|
false
|
|
};
|
|
if env_empty {
|
|
root.remove("env");
|
|
}
|
|
true
|
|
})?;
|
|
Ok(updated.into_iter().collect())
|
|
}
|
|
|
|
pub(crate) fn prepare_codex_managed_removal(paths: &[PathBuf]) -> Result<Images, String> {
|
|
use toml_edit::{Document, Item};
|
|
|
|
let Some(path) = paths.first() else {
|
|
return Err("Codex configuration path is unavailable on the current platform".to_string());
|
|
};
|
|
if !path.is_file() {
|
|
return Ok(Vec::new());
|
|
}
|
|
let content = fs::read_to_string(path)
|
|
.map_err(|error| format!("Failed to read Codex configuration {}: {error}", path_to_string(path)))?;
|
|
let mut document = content
|
|
.parse::<Document>()
|
|
.map_err(|error| format!("Failed to parse Codex configuration: {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");
|
|
}
|
|
let rendered = document.to_string();
|
|
toml::from_str::<toml::Value>(&rendered)
|
|
.map_err(|error| format!("Failed to validate restored Codex configuration: {error}"))?;
|
|
let mut changed = vec![(
|
|
path.clone(),
|
|
(!rendered.trim().is_empty()).then(|| rendered.into_bytes()),
|
|
)];
|
|
if managed_catalog {
|
|
changed.push((path.with_file_name(CODEX_MODEL_CATALOG_FILE), None));
|
|
}
|
|
Ok(changed)
|
|
}
|
|
|
|
pub(crate) fn prepare_opencode_managed_removal(paths: &[PathBuf]) -> Result<Images, String> {
|
|
let Some(path) = paths.first() else {
|
|
return Err("OpenCode configuration path is unavailable on the current platform".to_string());
|
|
};
|
|
let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/");
|
|
let updated = prepare_agent_json5_removal(path, "OpenCode configuration", |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.into_iter().collect())
|
|
}
|
|
|
|
pub(crate) fn prepare_zcode_managed_removal(paths: &[PathBuf]) -> Result<Images, String> {
|
|
let path = paths.first().ok_or("ZCode provider configuration path is unavailable")?;
|
|
let updated = prepare_agent_json_removal(path, "ZCode provider configuration", |root| {
|
|
let Some(config) = root.get_mut("config").and_then(serde_json::Value::as_object_mut) else {
|
|
return false;
|
|
};
|
|
let mut changed = false;
|
|
if config.get("defaultModelSelection")
|
|
.and_then(|selection| selection.get("providerId"))
|
|
.and_then(serde_json::Value::as_str) == Some(MANAGED_AGENT_PROVIDER_ID)
|
|
{
|
|
config.remove("defaultModelSelection");
|
|
changed = true;
|
|
}
|
|
if let Some(order) = config.get_mut("providerOrder").and_then(serde_json::Value::as_array_mut) {
|
|
let old_len = order.len();
|
|
order.retain(|item| item.as_str() != Some(MANAGED_AGENT_PROVIDER_ID));
|
|
changed |= order.len() != old_len;
|
|
}
|
|
if let Some(rules) = config.get_mut("providerConfigRules")
|
|
.and_then(|value| value.get_mut("providerRules"))
|
|
.and_then(serde_json::Value::as_array_mut)
|
|
{
|
|
let old_len = rules.len();
|
|
rules.retain(|rule| rule.get("providerId").and_then(serde_json::Value::as_str)
|
|
!= Some(MANAGED_AGENT_PROVIDER_ID));
|
|
changed |= rules.len() != old_len;
|
|
}
|
|
for key in ["providerModelRules", "manualProviderModelRules"] {
|
|
if let Some(rules) = config.get_mut("modelConfigRules")
|
|
.and_then(|value| value.get_mut(key))
|
|
.and_then(serde_json::Value::as_array_mut)
|
|
{
|
|
let old_len = rules.len();
|
|
rules.retain(|rule| rule.get("providerId").and_then(serde_json::Value::as_str)
|
|
!= Some(MANAGED_AGENT_PROVIDER_ID));
|
|
changed |= rules.len() != old_len;
|
|
}
|
|
}
|
|
changed
|
|
})?;
|
|
Ok(updated.into_iter().collect())
|
|
}
|
|
|
|
pub(crate) fn prepare_kimi_code_managed_removal(paths: &[PathBuf]) -> Result<Images, String> {
|
|
prepare_managed_toml_client_removal(
|
|
paths,
|
|
"Kimi Code",
|
|
Some("providers"),
|
|
"models",
|
|
None,
|
|
"default_model",
|
|
)
|
|
}
|
|
|
|
pub(crate) fn prepare_grok_build_managed_removal(paths: &[PathBuf]) -> Result<Images, String> {
|
|
prepare_managed_toml_client_removal(
|
|
paths,
|
|
"Grok Build",
|
|
None,
|
|
"model",
|
|
Some("models"),
|
|
"default",
|
|
)
|
|
}
|
|
|
|
pub(crate) fn prepare_managed_toml_client_removal(
|
|
paths: &[PathBuf],
|
|
label: &str,
|
|
provider_section: Option<&str>,
|
|
model_entry_section: &str,
|
|
default_section: Option<&str>,
|
|
default_key: &str,
|
|
) -> Result<Images, String> {
|
|
use toml_edit::{Document, Item};
|
|
|
|
let Some(path) = paths.first() else {
|
|
return Err(format!("{label} configuration path is unavailable on the current platform"));
|
|
};
|
|
if !path.is_file() {
|
|
return Ok(Vec::new());
|
|
}
|
|
let content = fs::read_to_string(path)
|
|
.map_err(|error| format!("Failed to read {label} configuration {}: {error}", path_to_string(path)))?;
|
|
let mut document = content
|
|
.parse::<Document>()
|
|
.map_err(|error| format!("Failed to parse {label} configuration: {error}"))?;
|
|
let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/");
|
|
let mut changed = false;
|
|
|
|
let default_table: Option<&mut dyn toml_edit::TableLike> =
|
|
if let Some(section) = default_section {
|
|
document
|
|
.as_table_mut()
|
|
.get_mut(section)
|
|
.and_then(Item::as_table_like_mut)
|
|
} else {
|
|
Some(document.as_table_mut())
|
|
};
|
|
if let Some(table) = default_table {
|
|
if table
|
|
.get(default_key)
|
|
.and_then(Item::as_str)
|
|
.is_some_and(|model| model.starts_with(&prefix))
|
|
{
|
|
table.remove(default_key);
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
if let Some(section) = provider_section {
|
|
if let Some(providers) = document
|
|
.as_table_mut()
|
|
.get_mut(section)
|
|
.and_then(Item::as_table_like_mut)
|
|
{
|
|
changed |= providers.remove(MANAGED_AGENT_PROVIDER_ID).is_some();
|
|
}
|
|
}
|
|
if let Some(models) = document
|
|
.as_table_mut()
|
|
.get_mut(model_entry_section)
|
|
.and_then(Item::as_table_like_mut)
|
|
{
|
|
let managed = models
|
|
.iter()
|
|
.map(|(key, _)| key.to_string())
|
|
.filter(|key| key.starts_with(&prefix))
|
|
.collect::<Vec<_>>();
|
|
changed |= !managed.is_empty();
|
|
for key in managed {
|
|
models.remove(&key);
|
|
}
|
|
}
|
|
for section in provider_section.into_iter().chain([model_entry_section]) {
|
|
let empty = document
|
|
.as_table()
|
|
.get(section)
|
|
.and_then(Item::as_table_like)
|
|
.is_some_and(|table| table.is_empty());
|
|
if empty {
|
|
document.as_table_mut().remove(section);
|
|
}
|
|
}
|
|
if let Some(section) = default_section {
|
|
let empty = document
|
|
.as_table()
|
|
.get(section)
|
|
.and_then(Item::as_table_like)
|
|
.is_some_and(|table| table.is_empty());
|
|
if empty {
|
|
document.as_table_mut().remove(section);
|
|
}
|
|
}
|
|
if !changed {
|
|
return Ok(Vec::new());
|
|
}
|
|
let rendered = document.to_string();
|
|
toml::from_str::<toml::Value>(&rendered)
|
|
.map_err(|error| format!("Failed to validate restored {label} configuration: {error}"))?;
|
|
Ok(vec![(
|
|
path.clone(),
|
|
(!rendered.trim().is_empty()).then(|| rendered.into_bytes()),
|
|
)])
|
|
}
|
|
|
|
pub(crate) fn prepare_agent_json5_removal<F>(
|
|
path: &Path,
|
|
label: &str,
|
|
update: F,
|
|
) -> Result<Option<FileSnapshot>, String>
|
|
where
|
|
F: FnOnce(&mut serde_json::Map<String, serde_json::Value>) -> bool,
|
|
{
|
|
if !path.is_file() {
|
|
return Ok(None);
|
|
}
|
|
let content = fs::read_to_string(path)
|
|
.map_err(|error| format!("Failed to read {label} {}: {error}", path_to_string(path)))?;
|
|
let value = json5::from_str::<serde_json::Value>(&content)
|
|
.map_err(|error| format!("Failed to parse {label}: {error}"))?;
|
|
let mut root = value
|
|
.as_object()
|
|
.cloned()
|
|
.ok_or_else(|| format!("{label} root must be an object"))?;
|
|
if !update(&mut root) {
|
|
return Ok(None);
|
|
}
|
|
let bytes = if root.is_empty() {
|
|
None
|
|
} else {
|
|
let rendered = render_agent_json(root, label)?;
|
|
let comments = extract_json5_comments(&content);
|
|
Some(
|
|
if comments.is_empty() {
|
|
rendered
|
|
} else {
|
|
format!("{}\n{rendered}", comments.join("\n"))
|
|
}
|
|
.into_bytes(),
|
|
)
|
|
};
|
|
Ok(Some((path.to_path_buf(), bytes)))
|
|
}
|
|
|
|
pub(crate) fn prepare_openclaw_managed_removal(paths: &[PathBuf]) -> Result<Images, String> {
|
|
let Some(path) = paths.first() else {
|
|
return Err("OpenClaw configuration path is unavailable on the current platform".to_string());
|
|
};
|
|
let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/");
|
|
let updated = prepare_agent_json5_removal(path, "OpenClaw configuration", |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.into_iter().collect())
|
|
}
|
|
|
|
pub(crate) fn prepare_hermes_managed_removal(paths: &[PathBuf]) -> Result<Images, String> {
|
|
let Some(path) = paths.first() else {
|
|
return Err("Hermes configuration path is unavailable on the current platform".to_string());
|
|
};
|
|
if !path.is_file() {
|
|
return Ok(Vec::new());
|
|
}
|
|
let content = fs::read_to_string(path)
|
|
.map_err(|error| format!("Failed to read Hermes configuration {}: {error}", path_to_string(path)))?;
|
|
let mut document = yaml_serde_edit::YamlValue::parse(&content)
|
|
.map_err(|error| format!("Failed to parse Hermes configuration: {error}"))?;
|
|
let mut updated = document.get().clone();
|
|
let root = updated
|
|
.as_mapping_mut()
|
|
.ok_or_else(|| "Hermes configuration root must be a mapping".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());
|
|
}
|
|
let bytes = if root.is_empty() {
|
|
None
|
|
} else {
|
|
Some(render_updated_core_yaml(&mut document, updated)?.into_bytes())
|
|
};
|
|
Ok(vec![(path.clone(), bytes)])
|
|
}
|
|
|
|
pub(crate) fn prepare_agent_managed_removal(
|
|
client: AgentClient,
|
|
paths: &[PathBuf],
|
|
port: u16,
|
|
) -> Result<Images, String> {
|
|
match client {
|
|
AgentClient::ClaudeCode => {
|
|
prepare_claude_code_managed_removal(paths, &managed_core_loopback_origin(port))
|
|
}
|
|
AgentClient::ClaudeDesktop => prepare_claude_desktop_managed_removal(paths),
|
|
AgentClient::Codex => prepare_codex_managed_removal(paths),
|
|
AgentClient::OpenCode => prepare_opencode_managed_removal(paths),
|
|
AgentClient::OpenClaw => prepare_openclaw_managed_removal(paths),
|
|
AgentClient::Hermes => prepare_hermes_managed_removal(paths),
|
|
AgentClient::DeepSeekHarness => prepare_deepseek_harness_managed_removal(paths),
|
|
AgentClient::ZCode => prepare_zcode_managed_removal(paths),
|
|
AgentClient::WorkBuddy => prepare_workbuddy_managed_removal(paths),
|
|
AgentClient::AntigravityCli => prepare_antigravity_removal(client, paths),
|
|
AgentClient::KimiCode => prepare_kimi_code_managed_removal(paths),
|
|
AgentClient::GrokBuild => prepare_grok_build_managed_removal(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 prepare_deepseek_harness_managed_removal(
|
|
paths: &[PathBuf],
|
|
) -> Result<Images, String> {
|
|
if paths.len() != 2 {
|
|
return Err("Invalid number of DeepSeek Harness configuration paths".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!("Failed to read 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")?;
|
|
changed_paths.push((
|
|
paths[0].clone(),
|
|
(!root.is_empty()).then(|| rendered.into_bytes()),
|
|
));
|
|
}
|
|
}
|
|
if paths[1].is_file() {
|
|
let current = fs::read_to_string(&paths[1])
|
|
.map_err(|error| format!("Failed to read DeepSeek Harness credentials: {error}"))?;
|
|
let mut changed = false;
|
|
let rendered = render_agent_yaml_mapping_update(
|
|
Some(¤t),
|
|
"DeepSeek Harness credentials",
|
|
|root| {
|
|
changed = remove_deepseek_harness_managed_credential(
|
|
root,
|
|
"DeepSeek Harness credentials",
|
|
)?;
|
|
Ok(())
|
|
},
|
|
)?;
|
|
if changed {
|
|
let root = parse_agent_yaml_mapping(Some(&rendered), "DeepSeek Harness credentials")?;
|
|
changed_paths.push((
|
|
paths[1].clone(),
|
|
(!deepseek_harness_credentials_document_is_empty(&root))
|
|
.then(|| rendered.into_bytes()),
|
|
));
|
|
}
|
|
}
|
|
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 parse_restored_json5_object(
|
|
content: Option<&str>,
|
|
label: &str,
|
|
) -> Result<Option<serde_json::Map<String, serde_json::Value>>, String> {
|
|
content
|
|
.map(|content| parse_agent_json5_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), "Current Claude Code configuration")?;
|
|
let original_root = parse_restored_json_object(original, "Original Claude Code configuration")?;
|
|
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_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV,
|
|
CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV,
|
|
CLAUDE_CODE_AUTO_MODE_SERVER_ENV,
|
|
CLAUDE_AUTOCOMPACT_PCT_OVERRIDE_ENV,
|
|
DISABLE_AUTO_COMPACT_ENV,
|
|
"CLAUDE_CODE_SUBAGENT_MODEL",
|
|
"CLAUDE_CODE_EFFORT_LEVEL",
|
|
"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME",
|
|
"ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION",
|
|
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME",
|
|
"ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION",
|
|
"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME",
|
|
"ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION",
|
|
"ANTHROPIC_DEFAULT_FABLE_MODEL_NAME",
|
|
"ANTHROPIC_DEFAULT_FABLE_MODEL_DESCRIPTION",
|
|
"ANTHROPIC_CUSTOM_MODEL_OPTION",
|
|
"ANTHROPIC_CUSTOM_MODEL_OPTION_NAME",
|
|
"ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION",
|
|
] {
|
|
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(), "Restored Claude Code configuration")
|
|
}
|
|
|
|
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), "Current Claude Desktop configuration")?;
|
|
let original_root = parse_restored_json_object(original, "Original Claude Desktop configuration")?;
|
|
match index {
|
|
0 | 1 => restore_json_key(&mut root, original_root.as_ref(), "deploymentMode"),
|
|
2 => {
|
|
for key in [
|
|
"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("Invalid Claude Desktop configuration file index".to_string()),
|
|
}
|
|
render_restored_json(root, original.is_some(), "Restored Claude Desktop configuration")
|
|
}
|
|
|
|
pub(crate) fn build_restored_opencode_config(
|
|
current: &str,
|
|
original: Option<&str>,
|
|
) -> Result<Option<String>, String> {
|
|
let mut root = parse_agent_json5_object(Some(current), "Current OpenCode configuration")?;
|
|
let original_root = parse_restored_json5_object(original, "Original OpenCode configuration")?;
|
|
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");
|
|
}
|
|
let restored = render_restored_json(root, original.is_some(), "Restored OpenCode configuration")?;
|
|
Ok(restored.map(|rendered| {
|
|
let comments = extract_json5_comments(current);
|
|
if comments.is_empty() {
|
|
rendered
|
|
} else {
|
|
format!("{}\n{rendered}", comments.join("\n"))
|
|
}
|
|
}))
|
|
}
|
|
|
|
pub(crate) fn build_restored_zcode_provider_config(
|
|
current: &str,
|
|
original: Option<&str>,
|
|
) -> Result<Option<String>, String> {
|
|
let mut root = serde_json::Value::Object(parse_agent_json_object(
|
|
Some(current), "Current ZCode provider configuration",
|
|
)?);
|
|
let original_root = parse_restored_json_object(original, "Original ZCode provider configuration")?
|
|
.map(serde_json::Value::Object);
|
|
if root.pointer("/config").is_none() {
|
|
return render_restored_json(
|
|
root.as_object().cloned().ok_or("Invalid ZCode provider configuration")?,
|
|
original.is_some(),
|
|
"Restored ZCode provider configuration",
|
|
);
|
|
}
|
|
let config = root.pointer_mut("/config").and_then(serde_json::Value::as_object_mut)
|
|
.ok_or("ZCode provider configuration has invalid config")?;
|
|
let original_config = original_root.as_ref().and_then(|value| value.pointer("/config"));
|
|
if original.is_none() || config.get("defaultModelSelection")
|
|
.and_then(|selection| selection.get("providerId"))
|
|
.and_then(serde_json::Value::as_str) == Some(MANAGED_AGENT_PROVIDER_ID)
|
|
{
|
|
if let Some(selection) = original_config.and_then(|value| value.get("defaultModelSelection")) {
|
|
config.insert("defaultModelSelection".to_string(), selection.clone());
|
|
} else {
|
|
config.remove("defaultModelSelection");
|
|
}
|
|
}
|
|
if let Some(order) = config.get_mut("providerOrder").and_then(serde_json::Value::as_array_mut) {
|
|
order.retain(|item| item.as_str() != Some(MANAGED_AGENT_PROVIDER_ID));
|
|
if let Some(original_order) = original_config
|
|
.and_then(|value| value.get("providerOrder"))
|
|
.and_then(serde_json::Value::as_array)
|
|
{
|
|
if let Some(index) = original_order.iter().position(|item| item.as_str() == Some(MANAGED_AGENT_PROVIDER_ID)) {
|
|
order.insert(index.min(order.len()), serde_json::json!(MANAGED_AGENT_PROVIDER_ID));
|
|
}
|
|
}
|
|
}
|
|
for pointer in [
|
|
"/providerConfigRules/providerRules",
|
|
"/modelConfigRules/providerModelRules",
|
|
"/modelConfigRules/manualProviderModelRules",
|
|
] {
|
|
let Some(rules) = root.pointer(&format!("/config{pointer}"))
|
|
.and_then(serde_json::Value::as_array).cloned() else {
|
|
continue;
|
|
};
|
|
let mut retained = rules.into_iter().filter(|rule| {
|
|
rule.get("providerId").and_then(serde_json::Value::as_str)
|
|
!= Some(MANAGED_AGENT_PROVIDER_ID)
|
|
}).collect::<Vec<_>>();
|
|
if let Some(original_rules) = original_config.and_then(|value| value.pointer(pointer))
|
|
.and_then(serde_json::Value::as_array)
|
|
{
|
|
for (index, rule) in original_rules.iter().enumerate() {
|
|
if rule.get("providerId").and_then(serde_json::Value::as_str)
|
|
== Some(MANAGED_AGENT_PROVIDER_ID)
|
|
{
|
|
retained.insert(index.min(retained.len()), rule.clone());
|
|
}
|
|
}
|
|
}
|
|
if let Some(target) = root.pointer_mut(&format!("/config{pointer}")) {
|
|
*target = serde_json::Value::Array(retained);
|
|
}
|
|
}
|
|
if original.is_none() {
|
|
let config = root.get("config");
|
|
let only_empty_scaffold = config
|
|
.and_then(serde_json::Value::as_object)
|
|
.is_some_and(|config| {
|
|
config.get("defaultModelSelection").is_none()
|
|
&& config.get("providerOrder").and_then(serde_json::Value::as_array)
|
|
.is_none_or(Vec::is_empty)
|
|
&& root.pointer("/config/providerConfigRules/providerRules")
|
|
.and_then(serde_json::Value::as_array).is_some_and(Vec::is_empty)
|
|
&& root.pointer("/config/modelConfigRules/providerModelRules")
|
|
.and_then(serde_json::Value::as_array).is_some_and(Vec::is_empty)
|
|
&& root.pointer("/config/modelConfigRules/manualProviderModelRules")
|
|
.and_then(serde_json::Value::as_array).is_some_and(Vec::is_empty)
|
|
&& config.len() <= 3
|
|
});
|
|
if only_empty_scaffold && root.as_object().is_some_and(|root| root.len() <= 2) {
|
|
return Ok(None);
|
|
}
|
|
}
|
|
render_agent_json(root.as_object().cloned().ok_or("Invalid ZCode provider configuration")?,
|
|
"Restored ZCode provider configuration").map(Some)
|
|
}
|
|
|
|
pub(crate) fn build_restored_kimi_code_config(
|
|
current: &str,
|
|
original: Option<&str>,
|
|
) -> Result<Option<String>, String> {
|
|
build_restored_managed_toml_client_config(
|
|
current,
|
|
original,
|
|
"Kimi Code",
|
|
Some(("providers", &["type", "base_url", "api_key"])),
|
|
"models",
|
|
None,
|
|
"default_model",
|
|
)
|
|
}
|
|
|
|
pub(crate) fn build_restored_grok_build_config(
|
|
current: &str,
|
|
original: Option<&str>,
|
|
) -> Result<Option<String>, String> {
|
|
build_restored_managed_toml_client_config(
|
|
current,
|
|
original,
|
|
"Grok Build",
|
|
None,
|
|
"model",
|
|
Some("models"),
|
|
"default",
|
|
)
|
|
}
|
|
|
|
pub(crate) fn build_restored_managed_toml_client_config(
|
|
current: &str,
|
|
original: Option<&str>,
|
|
label: &str,
|
|
provider: Option<(&str, &[&str])>,
|
|
model_entry_section: &str,
|
|
default_section: Option<&str>,
|
|
default_key: &str,
|
|
) -> Result<Option<String>, String> {
|
|
use toml_edit::Item;
|
|
|
|
let mut current_document = parse_codex_document(Some(current), &format!("Current {label} configuration"))?;
|
|
let original_document = original
|
|
.map(|content| parse_codex_document(Some(content), &format!("Original {label} configuration")))
|
|
.transpose()?;
|
|
if let Some(original_document) = original_document.as_ref() {
|
|
merge_missing_codex_table_items(
|
|
current_document.as_table_mut(),
|
|
original_document.as_table(),
|
|
);
|
|
}
|
|
|
|
if let Some(section) = default_section {
|
|
let original_table = original_document
|
|
.as_ref()
|
|
.and_then(|document| document.as_table().get(section))
|
|
.and_then(Item::as_table);
|
|
if let Some(current_table) = current_document
|
|
.as_table_mut()
|
|
.get_mut(section)
|
|
.and_then(Item::as_table_mut)
|
|
{
|
|
restore_codex_table_item(current_table, original_table, default_key);
|
|
}
|
|
} else {
|
|
restore_codex_table_item(
|
|
current_document.as_table_mut(),
|
|
original_document
|
|
.as_ref()
|
|
.map(|document| document.as_table()),
|
|
default_key,
|
|
);
|
|
}
|
|
|
|
if let Some((section, managed_keys)) = provider {
|
|
let original_provider = original_document
|
|
.as_ref()
|
|
.and_then(|document| document.as_table().get(section))
|
|
.and_then(Item::as_table)
|
|
.and_then(|providers| providers.get(MANAGED_AGENT_PROVIDER_ID))
|
|
.and_then(Item::as_table)
|
|
.cloned();
|
|
if let Some(providers) = current_document
|
|
.as_table_mut()
|
|
.get_mut(section)
|
|
.and_then(Item::as_table_mut)
|
|
{
|
|
if let Some(current_provider) = providers
|
|
.get_mut(MANAGED_AGENT_PROVIDER_ID)
|
|
.and_then(Item::as_table_mut)
|
|
{
|
|
for key in managed_keys {
|
|
restore_codex_table_item(current_provider, original_provider.as_ref(), key);
|
|
}
|
|
if current_provider.is_empty() {
|
|
providers.remove(MANAGED_AGENT_PROVIDER_ID);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/");
|
|
let original_models = original_document
|
|
.as_ref()
|
|
.and_then(|document| document.as_table().get(model_entry_section))
|
|
.and_then(Item::as_table)
|
|
.map(|models| {
|
|
models
|
|
.iter()
|
|
.filter(|(key, _)| key.starts_with(&prefix))
|
|
.map(|(key, item)| (key.to_string(), item.clone()))
|
|
.collect::<Vec<_>>()
|
|
})
|
|
.unwrap_or_default();
|
|
if let Some(models) = current_document
|
|
.as_table_mut()
|
|
.get_mut(model_entry_section)
|
|
.and_then(Item::as_table_mut)
|
|
{
|
|
let managed = models
|
|
.iter()
|
|
.map(|(key, _)| key.to_string())
|
|
.filter(|key| key.starts_with(&prefix))
|
|
.collect::<Vec<_>>();
|
|
for key in managed {
|
|
models.remove(&key);
|
|
}
|
|
for (key, item) in &original_models {
|
|
models.insert(key, item.clone());
|
|
}
|
|
} else if !original_models.is_empty() {
|
|
let models = ensure_toml_child_table(current_document.as_table_mut(), model_entry_section);
|
|
for (key, item) in &original_models {
|
|
models.insert(key, item.clone());
|
|
}
|
|
}
|
|
|
|
for section in provider
|
|
.map(|(section, _)| section)
|
|
.into_iter()
|
|
.chain([model_entry_section])
|
|
.chain(default_section)
|
|
{
|
|
let empty = current_document
|
|
.as_table()
|
|
.get(section)
|
|
.and_then(Item::as_table)
|
|
.is_some_and(toml_edit::Table::is_empty);
|
|
if empty {
|
|
current_document.as_table_mut().remove(section);
|
|
}
|
|
}
|
|
let rendered = current_document.to_string();
|
|
toml::from_str::<toml::Value>(&rendered)
|
|
.map_err(|error| format!("Failed to validate restored {label} configuration: {error}"))?;
|
|
if original.is_none() && rendered.trim().is_empty() {
|
|
Ok(None)
|
|
} else {
|
|
Ok(Some(rendered))
|
|
}
|
|
}
|
|
|
|
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!("Invalid current OpenClaw configuration format: {error}"))?;
|
|
let mut root = current_value
|
|
.as_object()
|
|
.cloned()
|
|
.ok_or_else(|| "Current OpenClaw configuration root must be an object".to_string())?;
|
|
let original_root = original
|
|
.map(|content| {
|
|
json5::from_str::<serde_json::Value>(content)
|
|
.map_err(|error| format!("Invalid original OpenClaw configuration format: {error}"))?
|
|
.as_object()
|
|
.cloned()
|
|
.ok_or_else(|| "Original OpenClaw configuration root must be an object".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, "Restored OpenClaw configuration")?;
|
|
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!("Invalid current Hermes configuration format: {error}"))?;
|
|
let mut updated = document.get().clone();
|
|
let root = updated
|
|
.as_mapping_mut()
|
|
.ok_or_else(|| "Current Hermes configuration root must be a mapping".to_string())?;
|
|
let original_value = original
|
|
.map(|content| {
|
|
serde_norway::from_str::<serde_norway::Value>(content)
|
|
.map_err(|error| format!("Invalid original Hermes configuration format: {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(|| "Current DeepSeek Harness llm-pi-ai must be a mapping".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(|| "Current DeepSeek Harness llm-pi-ai.providers must be a mapping".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, "Original DeepSeek Harness settings")?;
|
|
let rendered = render_agent_yaml_mapping_update(
|
|
Some(current),
|
|
"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), "Restored 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, "Original DeepSeek Harness credentials")?;
|
|
let original_credential = deepseek_harness_original_credential(&original_root).cloned();
|
|
let original_had_refs = yaml_mapping_value(&original_root, "refs").is_some();
|
|
let rendered = render_agent_yaml_mapping_update(
|
|
Some(current),
|
|
"Current DeepSeek Harness credentials",
|
|
|root| {
|
|
let refs =
|
|
deepseek_harness_credentials_refs_mut(root, "Current DeepSeek Harness credentials")?;
|
|
if let Some(value) = original_credential {
|
|
refs.insert(yaml_key(DEEPSEEK_HARNESS_CREDENTIAL), value);
|
|
} else {
|
|
refs.remove(yaml_key(DEEPSEEK_HARNESS_CREDENTIAL));
|
|
}
|
|
let remove_refs = refs.is_empty() && !original_had_refs;
|
|
if remove_refs {
|
|
root.remove(yaml_key("refs"));
|
|
}
|
|
Ok(())
|
|
},
|
|
)?;
|
|
let root = parse_agent_yaml_mapping(Some(&rendered), "Restored DeepSeek Harness credentials")?;
|
|
if deepseek_harness_credentials_document_is_empty(&root) {
|
|
if let Some(original) = original.filter(|_| original_root.is_empty()) {
|
|
return Ok(Some(original.to_string()));
|
|
}
|
|
if original.is_none() {
|
|
return Ok(None);
|
|
}
|
|
}
|
|
Ok(Some(rendered))
|
|
}
|
|
|
|
pub(crate) fn agent_config_semantically_equal(
|
|
client: AgentClient,
|
|
actual: &str,
|
|
expected: &str,
|
|
) -> bool {
|
|
match client {
|
|
AgentClient::Codex | AgentClient::KimiCode | AgentClient::GrokBuild => {
|
|
toml::from_str::<toml::Value>(actual).ok()
|
|
== toml::from_str::<toml::Value>(expected).ok()
|
|
}
|
|
AgentClient::OpenCode | 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> {
|
|
build_agent_session_restored_bytes_with_preference(
|
|
client, paths, path, current, original, true,
|
|
)
|
|
}
|
|
|
|
pub(crate) fn build_agent_session_restored_bytes_with_preference(
|
|
client: AgentClient,
|
|
paths: &[PathBuf],
|
|
path: &Path,
|
|
current: Option<&[u8]>,
|
|
original: Option<&[u8]>,
|
|
prefer_exact_original: bool,
|
|
) -> 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!("Current agent configuration is not UTF-8 text: {}", path_to_string(path)))?;
|
|
let original_bytes = original;
|
|
let original = original_bytes
|
|
.map(|bytes| {
|
|
std::str::from_utf8(bytes)
|
|
.map_err(|_| format!("Original agent configuration is not UTF-8 text: {}", 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 restoration path mismatch".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 restoration path mismatch".to_string())?;
|
|
match index {
|
|
0 => build_restored_deepseek_harness_settings(current, original)?,
|
|
1 => build_restored_deepseek_harness_credentials(current, original)?,
|
|
_ => return Err("Invalid DeepSeek Harness restoration path index".to_string()),
|
|
}
|
|
}
|
|
AgentClient::ZCode => build_restored_zcode_provider_config(current, original)?,
|
|
AgentClient::WorkBuddy => build_restored_workbuddy_config(current, original)?,
|
|
AgentClient::AntigravityCli => restore_antigravity_config(client, path, current, original)?,
|
|
AgentClient::KimiCode => build_restored_kimi_code_config(current, original)?,
|
|
AgentClient::GrokBuild => build_restored_grok_build_config(current, original)?,
|
|
};
|
|
if prefer_exact_original {
|
|
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!("Invalid Codex config.toml format: {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 must be a TOML table".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 must be a TOML table".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!("Failed to validate Codex configuration: {error}"))?;
|
|
Ok(rendered)
|
|
}
|
|
|
|
pub(crate) fn build_codex_api_auth(api_key: &str) -> Result<String, String> {
|
|
let mut rendered = serde_json::to_string_pretty(&serde_json::json!({
|
|
"auth_mode": "apikey",
|
|
"OPENAI_API_KEY": api_key,
|
|
}))
|
|
.map_err(|error| format!("Failed to generate Codex auth.json: {error}"))?;
|
|
rendered.push('\n');
|
|
Ok(rendered)
|
|
}
|
|
|
|
pub(crate) fn build_codex_auth_update(
|
|
home: &Path,
|
|
api_key: &str,
|
|
oauth_configuration: bool,
|
|
) -> Result<AgentFileUpdate, String> {
|
|
let path = codex_configuration_directory(home).join("auth.json");
|
|
let after = if oauth_configuration {
|
|
if validate_codex_oauth_login_at(&path).is_ok() {
|
|
read_optional_text(&path)?
|
|
.ok_or_else(|| "Codex OAuth login credentials were deleted before the application configuration was applied. Sign in again".to_string())?
|
|
} else {
|
|
available_codex_oauth_auth(home)?
|
|
}
|
|
} else {
|
|
let path_text = read_optional_text(&path)?;
|
|
let mut root = parse_agent_json_object(path_text.as_deref(), "Codex auth.json")?;
|
|
for key in ["tokens", "last_refresh"] { root.remove(key); }
|
|
root.insert("auth_mode".into(), serde_json::json!("apikey"));
|
|
root.insert("OPENAI_API_KEY".into(), serde_json::json!(api_key));
|
|
render_agent_json(root, "Codex auth.json")?
|
|
};
|
|
Ok(AgentFileUpdate { path, after })
|
|
}
|
|
|
|
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!("Invalid {label} format: {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 must be a TOML table".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 must be a TOML table".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> {
|
|
build_restored_codex_agent_config_with_policy(current, original, true)
|
|
}
|
|
|
|
pub(crate) fn build_restored_codex_agent_config_with_policy(
|
|
current: Option<&str>,
|
|
original: Option<&str>,
|
|
retain_dormant_provider: bool,
|
|
) -> Result<Option<String>, String> {
|
|
use toml_edit::Item;
|
|
|
|
let mut current_document = parse_codex_document(current, "Current Codex config.toml")?;
|
|
let original_document = original
|
|
.map(|content| parse_codex_document(Some(content), "Original 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 !retain_dormant_provider {
|
|
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!("Failed to validate restored Codex configuration: {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!("Invalid Codex model catalog format: {error}"))?;
|
|
let models = root
|
|
.get("models")
|
|
.and_then(serde_json::Value::as_array)
|
|
.filter(|models| !models.is_empty())
|
|
.ok_or_else(|| "Codex model catalog must contain a non-empty models array".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!("Default model {model} is not in the generated Codex model catalog"));
|
|
}
|
|
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_json5_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}")),
|
|
);
|
|
let rendered = render_agent_json(root, "OpenCode configuration")?;
|
|
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 build_zcode_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, "ZCode provider_config.json")?;
|
|
if root.get("schemaVersion").is_some_and(|version| version != 1) {
|
|
return Err("Unsupported ZCode provider configuration schemaVersion".to_string());
|
|
}
|
|
root.insert("schemaVersion".to_string(), serde_json::json!(1));
|
|
let config = ensure_json_object_entry(&mut root, "config");
|
|
let provider_rules = ensure_json_object_entry(config, "providerConfigRules");
|
|
let rules = provider_rules
|
|
.entry("providerRules")
|
|
.or_insert_with(|| serde_json::json!([]))
|
|
.as_array_mut()
|
|
.ok_or("ZCode providerRules must be an array")?;
|
|
let managed_index = rules.iter().position(|rule| {
|
|
rule.get("providerId").and_then(serde_json::Value::as_str)
|
|
== Some(MANAGED_AGENT_PROVIDER_ID)
|
|
});
|
|
let previous = managed_index.and_then(|index| rules.get(index));
|
|
let mut managed = previous.cloned().unwrap_or_else(|| serde_json::json!({}));
|
|
let managed_obj = managed.as_object_mut().ok_or("ZCode managed provider must be an object")?;
|
|
managed_obj.insert("providerId".to_string(), serde_json::json!(MANAGED_AGENT_PROVIDER_ID));
|
|
managed_obj.insert("providerName".to_string(), serde_json::json!("EasyCLIProxyAPI"));
|
|
managed_obj.insert("enabled".to_string(), serde_json::json!(true));
|
|
let provider_config = ensure_json_object_entry(managed_obj, "config");
|
|
provider_config.insert("group".to_string(), serde_json::json!("standard-personal"));
|
|
let access = ensure_json_object_entry(provider_config, "access");
|
|
access.insert("type".to_string(), serde_json::json!("api-key"));
|
|
access.insert("apiKey".to_string(), serde_json::json!(api_key));
|
|
let api = ensure_json_object_entry(provider_config, "api");
|
|
api.insert("type".to_string(), serde_json::json!("anthropic-messages"));
|
|
api.insert("baseUrl".to_string(), serde_json::json!(base_url));
|
|
let ordered = ordered_agent_models(available_models, model);
|
|
let model_names = ordered.iter().map(|option| option.name.as_str()).collect::<Vec<_>>();
|
|
provider_config.insert("personalModelIds".to_string(), serde_json::json!(model_names));
|
|
provider_config.insert("modelOrder".to_string(), serde_json::json!(model_names));
|
|
rules.retain(|rule| {
|
|
rule.get("providerId").and_then(serde_json::Value::as_str)
|
|
!= Some(MANAGED_AGENT_PROVIDER_ID)
|
|
});
|
|
rules.insert(managed_index.unwrap_or(rules.len()).min(rules.len()), managed);
|
|
|
|
let model_rules = ensure_json_object_entry(config, "modelConfigRules");
|
|
let manual_rules = model_rules
|
|
.entry("manualProviderModelRules")
|
|
.or_insert_with(|| serde_json::json!([]))
|
|
.as_array()
|
|
.ok_or("ZCode manualProviderModelRules must be an array")?;
|
|
let existing_manual_rules = manual_rules.iter().filter_map(|rule| {
|
|
if rule.get("providerId").and_then(serde_json::Value::as_str)
|
|
!= Some(MANAGED_AGENT_PROVIDER_ID) {
|
|
return None;
|
|
}
|
|
Some((rule.get("modelId")?.as_str()?.to_string(), rule.clone()))
|
|
}).collect::<std::collections::HashMap<_, _>>();
|
|
let first_manual_index = manual_rules.iter().position(|rule| {
|
|
rule.get("providerId").and_then(serde_json::Value::as_str)
|
|
== Some(MANAGED_AGENT_PROVIDER_ID)
|
|
}).unwrap_or(manual_rules.len());
|
|
let rules = model_rules
|
|
.entry("providerModelRules")
|
|
.or_insert_with(|| serde_json::json!([]))
|
|
.as_array_mut()
|
|
.ok_or("ZCode providerModelRules must be an array")?;
|
|
let existing_rules = rules.iter().filter_map(|rule| {
|
|
if rule.get("providerId").and_then(serde_json::Value::as_str)
|
|
!= Some(MANAGED_AGENT_PROVIDER_ID) {
|
|
return None;
|
|
}
|
|
Some((rule.get("modelId")?.as_str()?.to_string(), rule.clone()))
|
|
}).collect::<std::collections::HashMap<_, _>>();
|
|
let first_managed_index = rules.iter().position(|rule| {
|
|
rule.get("providerId").and_then(serde_json::Value::as_str)
|
|
== Some(MANAGED_AGENT_PROVIDER_ID)
|
|
}).unwrap_or(rules.len());
|
|
rules.retain(|rule| {
|
|
rule.get("providerId").and_then(serde_json::Value::as_str)
|
|
!= Some(MANAGED_AGENT_PROVIDER_ID)
|
|
});
|
|
let mut manual_updates = Vec::new();
|
|
for option in ordered {
|
|
let manual = existing_manual_rules.get(&option.name);
|
|
if option.context_window.is_none()
|
|
&& manual.is_none()
|
|
&& !existing_rules.contains_key(&option.name)
|
|
{
|
|
continue;
|
|
}
|
|
let mut rule = manual.or_else(|| existing_rules.get(&option.name))
|
|
.cloned().unwrap_or_else(|| serde_json::json!({}));
|
|
let entry = rule.as_object_mut().ok_or("ZCode model rule must be an object")?;
|
|
entry.insert("providerId".to_string(), serde_json::json!(MANAGED_AGENT_PROVIDER_ID));
|
|
entry.insert("modelId".to_string(), serde_json::json!(option.name));
|
|
let properties = ensure_json_object_entry(ensure_json_object_entry(entry, "config"), "properties");
|
|
if let Some(context_window) = option.context_window {
|
|
properties.insert("contextWindow".to_string(), serde_json::json!(context_window));
|
|
} else {
|
|
properties.remove("contextWindow");
|
|
}
|
|
if properties.is_empty() {
|
|
entry.get_mut("config").and_then(serde_json::Value::as_object_mut)
|
|
.expect("ZCode model config was created").remove("properties");
|
|
}
|
|
if manual.is_some() {
|
|
manual_updates.push(rule);
|
|
} else {
|
|
rules.insert((first_managed_index + rules.iter().filter(|entry| {
|
|
entry.get("providerId").and_then(serde_json::Value::as_str)
|
|
== Some(MANAGED_AGENT_PROVIDER_ID)
|
|
}).count()).min(rules.len()), rule);
|
|
}
|
|
}
|
|
let manual_rules = model_rules.get_mut("manualProviderModelRules")
|
|
.and_then(serde_json::Value::as_array_mut)
|
|
.expect("ZCode manual model rules were checked as an array");
|
|
manual_rules.retain(|rule| {
|
|
rule.get("providerId").and_then(serde_json::Value::as_str)
|
|
!= Some(MANAGED_AGENT_PROVIDER_ID)
|
|
});
|
|
for (offset, rule) in manual_updates.into_iter().enumerate() {
|
|
manual_rules.insert((first_manual_index + offset).min(manual_rules.len()), rule);
|
|
}
|
|
if let Some(provider_order) = config.get_mut("providerOrder") {
|
|
let provider_order = provider_order.as_array_mut()
|
|
.ok_or("ZCode providerOrder must be an array")?;
|
|
if !provider_order.iter().any(|value| value.as_str() == Some(MANAGED_AGENT_PROVIDER_ID)) {
|
|
provider_order.push(serde_json::json!(MANAGED_AGENT_PROVIDER_ID));
|
|
}
|
|
}
|
|
let mut selection = serde_json::json!({
|
|
"providerId": MANAGED_AGENT_PROVIDER_ID,
|
|
"modelId": model,
|
|
});
|
|
if let Some(previous) = config.get("defaultModelSelection") {
|
|
if previous.get("providerId").and_then(serde_json::Value::as_str)
|
|
== Some(MANAGED_AGENT_PROVIDER_ID)
|
|
&& previous.get("modelId").and_then(serde_json::Value::as_str) == Some(model)
|
|
{
|
|
if let Some(options) = previous.get("options") {
|
|
selection["options"] = options.clone();
|
|
}
|
|
}
|
|
}
|
|
config.insert("defaultModelSelection".to_string(), selection);
|
|
render_agent_json(root, "ZCode provider configuration")
|
|
}
|
|
|
|
pub(crate) fn ensure_toml_child_table<'a>(
|
|
parent: &'a mut toml_edit::Table,
|
|
key: &str,
|
|
) -> &'a mut toml_edit::Table {
|
|
use toml_edit::{Item, Table};
|
|
|
|
if !parent.get(key).is_some_and(Item::is_table) {
|
|
parent.remove(key);
|
|
parent.insert(key, Item::Table(Table::new()));
|
|
}
|
|
parent
|
|
.get_mut(key)
|
|
.and_then(Item::as_table_mut)
|
|
.expect("inserted TOML table must remain a table")
|
|
}
|
|
|
|
pub(crate) fn managed_model_alias(model: &str) -> String {
|
|
format!("{MANAGED_AGENT_PROVIDER_ID}/{model}")
|
|
}
|
|
|
|
pub(crate) fn build_kimi_code_agent_config(
|
|
existing: Option<&str>,
|
|
base_url: &str,
|
|
api_key: &str,
|
|
model: &str,
|
|
available_models: &[AgentModelOption],
|
|
) -> Result<String, String> {
|
|
use toml_edit::{value, Array, Item, Table};
|
|
|
|
let mut document = parse_codex_document(existing, "Kimi Code config.toml")?;
|
|
set_codex_table_item(
|
|
document.as_table_mut(),
|
|
"default_model",
|
|
value(managed_model_alias(model)),
|
|
);
|
|
|
|
let providers = ensure_toml_child_table(document.as_table_mut(), "providers");
|
|
let provider = ensure_toml_child_table(providers, MANAGED_AGENT_PROVIDER_ID);
|
|
set_codex_table_item(provider, "type", value("openai"));
|
|
set_codex_table_item(provider, "base_url", value(base_url));
|
|
set_codex_table_item(provider, "api_key", value(api_key));
|
|
|
|
let models = ensure_toml_child_table(document.as_table_mut(), "models");
|
|
let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/");
|
|
let stale = models
|
|
.iter()
|
|
.map(|(key, _)| key.to_string())
|
|
.filter(|key| key.starts_with(&prefix))
|
|
.collect::<Vec<_>>();
|
|
for key in stale {
|
|
models.remove(&key);
|
|
}
|
|
for option in ordered_agent_models(available_models, model) {
|
|
let mut entry = Table::new();
|
|
entry.insert("provider", value(MANAGED_AGENT_PROVIDER_ID));
|
|
entry.insert("model", value(option.name.clone()));
|
|
entry.insert(
|
|
"display_name",
|
|
value(option.alias.as_deref().unwrap_or(&option.name)),
|
|
);
|
|
let context = option
|
|
.context_window
|
|
.unwrap_or(200_000)
|
|
.min(i64::MAX as u64) as i64;
|
|
entry.insert("max_context_size", value(context));
|
|
let mut capabilities = Array::new();
|
|
capabilities.push("tool_use");
|
|
entry.insert("capabilities", value(capabilities));
|
|
models.insert(&managed_model_alias(&option.name), Item::Table(entry));
|
|
}
|
|
let rendered = document.to_string();
|
|
toml::from_str::<toml::Value>(&rendered)
|
|
.map_err(|error| format!("Failed to validate Kimi Code configuration: {error}"))?;
|
|
Ok(rendered)
|
|
}
|
|
|
|
pub(crate) fn build_grok_build_agent_config(
|
|
existing: Option<&str>,
|
|
base_url: &str,
|
|
api_key: &str,
|
|
model: &str,
|
|
available_models: &[AgentModelOption],
|
|
) -> Result<String, String> {
|
|
use toml_edit::{value, Item, Table};
|
|
|
|
let mut document = parse_codex_document(existing, "Grok Build config.toml")?;
|
|
let defaults = ensure_toml_child_table(document.as_table_mut(), "models");
|
|
set_codex_table_item(defaults, "default", value(managed_model_alias(model)));
|
|
|
|
let models = ensure_toml_child_table(document.as_table_mut(), "model");
|
|
let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/");
|
|
let stale = models
|
|
.iter()
|
|
.map(|(key, _)| key.to_string())
|
|
.filter(|key| key.starts_with(&prefix))
|
|
.collect::<Vec<_>>();
|
|
for key in stale {
|
|
models.remove(&key);
|
|
}
|
|
for option in ordered_agent_models(available_models, model) {
|
|
let mut entry = Table::new();
|
|
entry.insert("model", value(option.name.clone()));
|
|
entry.insert("base_url", value(base_url));
|
|
entry.insert(
|
|
"name",
|
|
value(option.alias.as_deref().unwrap_or(&option.name)),
|
|
);
|
|
entry.insert("api_key", value(api_key));
|
|
entry.insert("api_backend", value("chat_completions"));
|
|
let context = option
|
|
.context_window
|
|
.unwrap_or(200_000)
|
|
.min(i64::MAX as u64) as i64;
|
|
entry.insert("context_window", value(context));
|
|
models.insert(&managed_model_alias(&option.name), Item::Table(entry));
|
|
}
|
|
let rendered = document.to_string();
|
|
toml::from_str::<toml::Value>(&rendered)
|
|
.map_err(|error| format!("Failed to validate Grok Build configuration: {error}"))?;
|
|
Ok(rendered)
|
|
}
|
|
|
|
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!("Invalid OpenClaw openclaw.json format: {error}"))?,
|
|
None => serde_json::json!({}),
|
|
};
|
|
let root = root
|
|
.as_object_mut()
|
|
.ok_or_else(|| "OpenClaw openclaw.json root must be an object".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 configuration")?;
|
|
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!("Invalid Hermes config.yaml format: {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 root must be a mapping".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!("Failed to generate Hermes provider: {error}"))?;
|
|
let canonical_provider = canonical_provider
|
|
.as_mapping()
|
|
.ok_or_else(|| "Failed to generate Hermes provider: root is not a mapping".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)
|
|
.or_else(|_| serde_norway::to_string(&root).map_err(|_| "Failed to generate Hermes configuration".to_string()))?
|
|
} else {
|
|
serde_norway::to_string(&root).map_err(|error| format!("Failed to generate Hermes configuration: {error}"))?
|
|
};
|
|
serde_norway::from_str::<serde_norway::Value>(&rendered)
|
|
.map_err(|error| format!("Failed to validate Hermes configuration: {error}"))?;
|
|
Ok(rendered)
|
|
}
|