feat(claude-desktop): support custom model aliases
- Add editable model lists, alias suggestions, and usage guidance - Ignore unselected rows and validate alias conflicts and naming rules - Fix alias routing through disabled or excluded providers - Preserve model routes when restoring legacy backups - Repair legacy metadata and remove unsupported context fields - Add regression tests for validation and configuration recovery
This commit is contained in:
1 parent
300476043b
commit
81597fcf91
30 files changed
+2077
-193
No files matched your search
@@ -33,11 +33,16 @@ fn local_restore_plan(client: &str, home: &Path, id: &str) -> Result<RestorePlan
|
||||
})
|
||||
}
|
||||
|
||||
fn desktop_restore_models(plan: &RestorePlan) -> Result<Option<Vec<AgentModelOption>>, String> {
|
||||
fn desktop_restore_configuration(
|
||||
plan: &RestorePlan,
|
||||
) -> Result<Option<(ClaudeDesktopModelMappings, Vec<AgentModelOption>)>, String> {
|
||||
if plan.version.client != "claude-desktop" {
|
||||
return Ok(None);
|
||||
}
|
||||
if !desktop_profile_needs_mapping(&plan.after)? {
|
||||
if !desktop_profile_needs_mapping(&plan.after)?
|
||||
&& plan.version.mappings.as_ref()
|
||||
.and_then(|m| m.desktop_models.as_ref()).is_none()
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let (path, bytes) = &plan.after[2];
|
||||
@@ -49,30 +54,56 @@ fn desktop_restore_models(plan: &RestorePlan) -> Result<Option<Vec<AgentModelOpt
|
||||
.flatten()
|
||||
.filter_map(|m| m.get("name").and_then(Value::as_str))
|
||||
.collect::<Vec<_>>();
|
||||
let routes = [
|
||||
CLAUDE_DESKTOP_OPUS_MODEL_ID,
|
||||
CLAUDE_DESKTOP_SONNET_MODEL_ID,
|
||||
CLAUDE_DESKTOP_HAIKU_MODEL_ID,
|
||||
];
|
||||
let mappings =
|
||||
plan.version.mappings.as_ref().ok_or(
|
||||
let mut mappings = plan.version.mappings.as_ref().ok_or(
|
||||
"此备份版本缺少 Claude Desktop 模型映射,无法安全恢复内核路由,请重新配置模型",
|
||||
)?;
|
||||
Ok(Some(
|
||||
routes
|
||||
.into_iter()
|
||||
.zip([&mappings.opus, &mappings.sonnet, &mappings.haiku])
|
||||
.map(|(route, source)| AgentModelOption {
|
||||
input_modalities: None,
|
||||
harness_metadata: None,
|
||||
name: source.clone(),
|
||||
alias: None,
|
||||
is_alias: source != route
|
||||
&& names.iter().any(|name| name.eq_ignore_ascii_case(source)),
|
||||
context_window: None,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
)?.clone();
|
||||
if mappings.desktop_models.is_none() {
|
||||
let roles = [
|
||||
(CLAUDE_DESKTOP_OPUS_MODEL_ID, LEGACY_CLAUDE_DESKTOP_MODEL_IDS[0], &mappings.opus, mappings.opus_1m),
|
||||
(CLAUDE_DESKTOP_SONNET_MODEL_ID, LEGACY_CLAUDE_DESKTOP_MODEL_IDS[1], &mappings.sonnet, mappings.sonnet_1m),
|
||||
(CLAUDE_DESKTOP_HAIKU_MODEL_ID, LEGACY_CLAUDE_DESKTOP_MODEL_IDS[2], &mappings.haiku, mappings.haiku_1m),
|
||||
];
|
||||
let mut entries = Vec::<ClaudeDesktopModelMapping>::new();
|
||||
for id in names {
|
||||
let Some((_, _, source, context_1m)) = roles.iter()
|
||||
.find(|(_, _, source, _)| source.eq_ignore_ascii_case(id))
|
||||
.or_else(|| roles.iter().find(|(current, legacy, _, _)| {
|
||||
current.eq_ignore_ascii_case(id) || legacy.eq_ignore_ascii_case(id)
|
||||
}))
|
||||
else {
|
||||
return Err("此备份中的模型 ID 缺少对应来源,无法安全恢复内核路由".into());
|
||||
};
|
||||
if let Some(existing) = entries.iter_mut()
|
||||
.find(|entry| entry.model_id().eq_ignore_ascii_case(id))
|
||||
{
|
||||
existing.context_1m |= *context_1m;
|
||||
} else {
|
||||
entries.push(ClaudeDesktopModelMapping {
|
||||
model: source.to_string(),
|
||||
alias: if source.eq_ignore_ascii_case(id) {
|
||||
String::new()
|
||||
} else {
|
||||
id.into()
|
||||
},
|
||||
context_1m: *context_1m,
|
||||
});
|
||||
}
|
||||
}
|
||||
mappings.desktop_models = Some(entries);
|
||||
}
|
||||
let entries = mappings.desktop_models.as_ref().ok_or("缺少备份模型映射")?;
|
||||
validate_claude_desktop_entries(entries)?;
|
||||
let models = entries.iter()
|
||||
.map(|entry| AgentModelOption {
|
||||
name: entry.source_or_alias().to_string(),
|
||||
alias: None,
|
||||
is_alias: false,
|
||||
context_window: None,
|
||||
input_modalities: None,
|
||||
harness_metadata: None,
|
||||
})
|
||||
.collect();
|
||||
Ok(Some((mappings, models)))
|
||||
}
|
||||
|
||||
fn attach_core_restore(
|
||||
@@ -84,14 +115,31 @@ fn attach_core_restore(
|
||||
serde_norway::from_str::<serde_norway::Value>(&before).map_err(|e| e.to_string())?;
|
||||
let target =
|
||||
serde_norway::from_str::<serde_norway::Value>(&after).map_err(|e| e.to_string())?;
|
||||
for route in [
|
||||
let mut routes = vec![
|
||||
CLAUDE_DESKTOP_OPUS_MODEL_ID,
|
||||
CLAUDE_DESKTOP_SONNET_MODEL_ID,
|
||||
CLAUDE_DESKTOP_HAIKU_MODEL_ID,
|
||||
] {
|
||||
LEGACY_CLAUDE_DESKTOP_MODEL_IDS[0],
|
||||
LEGACY_CLAUDE_DESKTOP_MODEL_IDS[1],
|
||||
LEGACY_CLAUDE_DESKTOP_MODEL_IDS[2],
|
||||
]
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
for root in [&original, &target]
|
||||
.into_iter()
|
||||
.filter_map(serde_norway::Value::as_mapping)
|
||||
{
|
||||
for alias in managed_claude_desktop_aliases(root) {
|
||||
if !routes.contains(&alias) {
|
||||
routes.push(alias);
|
||||
}
|
||||
}
|
||||
}
|
||||
for route in routes {
|
||||
let source = |root: &serde_norway::Value| {
|
||||
root.as_mapping()
|
||||
.and_then(|root| configured_model_client_identity(root, route))
|
||||
.and_then(|root| configured_model_client_identity(root, &route))
|
||||
.map(|(source, _)| source)
|
||||
};
|
||||
let old = source(&original);
|
||||
@@ -125,18 +173,22 @@ pub(crate) async fn prepare_restore_plan(
|
||||
id: &str,
|
||||
) -> Result<RestorePlan, String> {
|
||||
let mut plan = local_restore_plan(client, home, id)?;
|
||||
if let Some(models) = desktop_restore_models(&plan)? {
|
||||
let mappings = plan.version.mappings.as_ref().ok_or("缺少备份模型映射")?;
|
||||
if let Some((mappings, models)) = desktop_restore_configuration(&plan)? {
|
||||
let before = fetch_management_config_yaml(config)
|
||||
.await
|
||||
.map_err(agent_core_error)?;
|
||||
let after = match ensure_claude_desktop_model_aliases_in_yaml(&before, mappings, &models) {
|
||||
let after = match ensure_claude_desktop_model_aliases_with_oauth_definitions_in_yaml(
|
||||
&before,
|
||||
&mappings,
|
||||
&models,
|
||||
&[],
|
||||
) {
|
||||
Ok(after) => after,
|
||||
Err(_) => {
|
||||
let definitions = fetch_oauth_model_definitions(config).await;
|
||||
ensure_claude_desktop_model_aliases_with_oauth_definitions_in_yaml(
|
||||
&before,
|
||||
mappings,
|
||||
&mappings,
|
||||
&models,
|
||||
&definitions,
|
||||
)
|
||||
@@ -226,15 +278,13 @@ pub(super) fn desktop_profile_needs_mapping(images: &Images) -> Result<bool, Str
|
||||
.flatten()
|
||||
.filter_map(|model| model.get("name").and_then(Value::as_str))
|
||||
.collect::<Vec<_>>();
|
||||
let routes = [
|
||||
CLAUDE_DESKTOP_OPUS_MODEL_ID,
|
||||
CLAUDE_DESKTOP_SONNET_MODEL_ID,
|
||||
CLAUDE_DESKTOP_HAIKU_MODEL_ID,
|
||||
];
|
||||
if !names
|
||||
.iter()
|
||||
.any(|name| routes.iter().any(|route| route.eq_ignore_ascii_case(name)))
|
||||
{
|
||||
let labeled_mapping = profile.get("inferenceModels").and_then(Value::as_array)
|
||||
.into_iter().flatten().any(|entry| {
|
||||
let name = entry.get("name").and_then(Value::as_str);
|
||||
let label = entry.get("labelOverride").and_then(Value::as_str);
|
||||
matches!((name, label), (Some(name), Some(label)) if name != label)
|
||||
});
|
||||
if !labeled_mapping && !names.iter().any(|name| is_claude_desktop_route_id(name)) {
|
||||
return Ok(false);
|
||||
}
|
||||
Ok(true)
|
||||
|
||||
@@ -798,6 +798,50 @@ fn pi_template_only_writes_configuration_and_package_references() {
|
||||
assert_eq!(list_backups("pi", &home.0).unwrap().versions.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_setup_repairs_legacy_names_without_changing_other_profiles() {
|
||||
if !AgentClient::ClaudeDesktop.supported_platform() {
|
||||
return;
|
||||
}
|
||||
let home = Home::new();
|
||||
let paths = config_paths("claude-desktop", &home.0).unwrap();
|
||||
let legacy_id = "fbd7eef8-4705-47c7-a5a7-a44b2952afe1";
|
||||
let named_id = "d186251a-0529-4dc7-bab8-81f391d975ce";
|
||||
let initial = serde_json::json!({
|
||||
"appliedId": legacy_id,
|
||||
"custom": {"keep": true},
|
||||
"entries": [
|
||||
{"id": legacy_id, "custom": [1, 2]},
|
||||
{"id": named_id, "name": "My gateway", "custom": true}
|
||||
]
|
||||
});
|
||||
save(&paths[3], initial.to_string());
|
||||
let other_profile = paths[3].parent().unwrap().join(format!("{legacy_id}.json"));
|
||||
let other_content = "{\"inferenceGatewayBaseUrl\":\"https://example.test\"}\n";
|
||||
save(&other_profile, other_content);
|
||||
|
||||
apply(&home.0, AgentClient::ClaudeDesktop, "gpt-one").unwrap();
|
||||
let repaired: Value = serde_json::from_slice(&fs::read(&paths[3]).unwrap()).unwrap();
|
||||
assert!(repaired["entries"].as_array().unwrap().iter().all(|entry| {
|
||||
entry["id"].is_string() && entry["name"].is_string()
|
||||
}));
|
||||
assert_eq!(repaired["entries"][0]["name"], format!("Configuration {legacy_id}"));
|
||||
assert_eq!(repaired["entries"][0]["custom"], initial["entries"][0]["custom"]);
|
||||
assert_eq!(repaired["entries"][1], initial["entries"][1]);
|
||||
assert_eq!(repaired["custom"], initial["custom"]);
|
||||
assert_eq!(fs::read_to_string(other_profile).unwrap(), other_content);
|
||||
|
||||
let before = config_images(&paths).unwrap();
|
||||
apply(&home.0, AgentClient::ClaudeDesktop, "gpt-one").unwrap();
|
||||
assert_eq!(before, config_images(&paths).unwrap());
|
||||
for index in [0, 1] {
|
||||
let mut changed = repaired.clone();
|
||||
changed["entries"][index]["name"] = serde_json::json!("Unexpected rename");
|
||||
let updates = vec![AgentFileUpdate { path: paths[3].clone(), after: changed.to_string() }];
|
||||
assert!(prepare_config_updates("claude-desktop", &paths, &before, &updates, false).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_current_mapping_survives_backup_deletion_and_rejects_unreliable_profile() {
|
||||
if !AgentClient::ClaudeDesktop.supported_platform() {
|
||||
@@ -813,7 +857,7 @@ fn desktop_current_mapping_survives_backup_deletion_and_rejects_unreliable_profi
|
||||
.0
|
||||
.revision;
|
||||
apply(&home.0, AgentClient::ClaudeDesktop, "gpt-two").unwrap();
|
||||
assert_eq!(original, config_images(&paths).unwrap());
|
||||
assert_ne!(original, config_images(&paths).unwrap());
|
||||
assert_ne!(
|
||||
revision,
|
||||
preview("claude-desktop", &paths, &backup.id)
|
||||
@@ -857,6 +901,7 @@ fn desktop_model_extensions_do_not_resurrect_disabled_one_million_context_flags(
|
||||
.unwrap();
|
||||
let mut value: Value = serde_json::from_slice(&fs::read(&paths[2]).unwrap()).unwrap();
|
||||
value["inferenceModels"][0]["custom"] = serde_json::json!({"keep":true});
|
||||
value["inferenceModels"][0]["contextWindow"] = serde_json::json!(1_000_000);
|
||||
save(&paths[2], value.to_string());
|
||||
let next_mappings = ClaudeDesktopModelMappings::all("gpt-one");
|
||||
apply_agent_configuration_with_oauth(
|
||||
@@ -870,6 +915,7 @@ fn desktop_model_extensions_do_not_resurrect_disabled_one_million_context_flags(
|
||||
.unwrap();
|
||||
let value: Value = serde_json::from_slice(&fs::read(&paths[2]).unwrap()).unwrap();
|
||||
assert_eq!(value["inferenceModels"][0]["custom"]["keep"], true);
|
||||
assert!(value["inferenceModels"][0].get("contextWindow").is_none());
|
||||
assert!(value["inferenceModels"][0].get("supports1m").is_none());
|
||||
assert!(value["inferenceModels"][0].get("prefer1m").is_none());
|
||||
}
|
||||
|
||||
@@ -960,6 +960,27 @@ pub(crate) fn agent_uses_cpa_runtime_context_windows(client: AgentClient) -> boo
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_claude_desktop_source_model(models: &[AgentModelOption], value: &str) -> Result<String, String> {
|
||||
let model = validate_agent_model(value)?;
|
||||
Ok(models.iter().find(|entry| entry.name.eq_ignore_ascii_case(&model))
|
||||
.map(|entry| entry.name.clone()).unwrap_or(model))
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_agent_configuration_model(
|
||||
client: AgentClient,
|
||||
models: &[AgentModelOption],
|
||||
model: &str,
|
||||
desktop_mappings: Option<&ClaudeDesktopModelMappings>,
|
||||
) -> Result<String, String> {
|
||||
if client == AgentClient::ClaudeDesktop {
|
||||
if let Some(entries) = desktop_mappings.and_then(|mappings| mappings.desktop_models.as_ref()) {
|
||||
validate_claude_desktop_entries(entries)?;
|
||||
return resolve_claude_desktop_source_model(models, entries[0].source_or_alias());
|
||||
}
|
||||
}
|
||||
resolve_available_agent_model(models, &validate_agent_model(model)?)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_claude_desktop_model_mappings(
|
||||
client: AgentClient,
|
||||
models: &[AgentModelOption],
|
||||
@@ -969,10 +990,26 @@ pub(crate) fn resolve_claude_desktop_model_mappings(
|
||||
if client != AgentClient::ClaudeDesktop {
|
||||
return Ok(None);
|
||||
}
|
||||
let requested = requested.ok_or("请重新选择 Claude Desktop 的模型映射")?;
|
||||
let mut requested = requested.ok_or("请重新配置 Claude Desktop 的模型与别名")?;
|
||||
let resolve =
|
||||
|model: &str| resolve_available_agent_model(models, &validate_agent_model(model)?);
|
||||
if let Some(entries) = requested.desktop_models.as_mut() {
|
||||
validate_claude_desktop_entries(entries)?;
|
||||
for entry in entries.iter_mut() {
|
||||
entry.model = resolve_claude_desktop_source_model(models, entry.source_or_alias())?;
|
||||
entry.alias = if entry.alias.trim().eq_ignore_ascii_case(&entry.model) {
|
||||
String::new()
|
||||
} else {
|
||||
entry.alias.trim().to_string()
|
||||
};
|
||||
}
|
||||
requested.sonnet = entries[0].source_or_alias().to_string();
|
||||
requested.opus.clear();
|
||||
requested.haiku.clear();
|
||||
return Ok(Some(requested));
|
||||
}
|
||||
Ok(Some(ClaudeDesktopModelMappings {
|
||||
desktop_models: None,
|
||||
opus: resolve(&requested.opus)?,
|
||||
sonnet: resolve(&requested.sonnet)?,
|
||||
haiku: resolve(&requested.haiku)?,
|
||||
@@ -1009,6 +1046,7 @@ pub(crate) fn resolve_claude_code_model_mappings(
|
||||
let resolve =
|
||||
|model: &str| resolve_available_agent_model(models, &validate_agent_model(model)?);
|
||||
Ok(Some(ClaudeDesktopModelMappings {
|
||||
desktop_models: None,
|
||||
opus: resolve(&requested.opus)?,
|
||||
sonnet: resolve(&requested.sonnet)?,
|
||||
haiku: resolve(&requested.haiku)?,
|
||||
@@ -1061,7 +1099,9 @@ pub(crate) async fn apply_agent_config(
|
||||
}
|
||||
validate_agent_can_enable(client, &home, config.port, api_key)?;
|
||||
let prepared = fetch_prepared_agent_models(client, &config).await?;
|
||||
let model = resolve_available_agent_model(&prepared.models, &validate_agent_model(&model)?)?;
|
||||
let model = resolve_agent_configuration_model(
|
||||
client, &prepared.models, &model, claude_desktop_model_mappings.as_ref(),
|
||||
)?;
|
||||
let claude_code_model_mappings = resolve_claude_code_model_mappings(
|
||||
client,
|
||||
&prepared.models,
|
||||
@@ -1130,8 +1170,9 @@ pub(crate) async fn set_agent_config_enabled(
|
||||
let api_key = effective_agent_api_key(&config);
|
||||
validate_agent_can_enable(client, &home, port, api_key)?;
|
||||
let prepared = fetch_prepared_agent_models(client, &config).await?;
|
||||
let model =
|
||||
resolve_available_agent_model(&prepared.models, &validate_agent_model(&model)?)?;
|
||||
let model = resolve_agent_configuration_model(
|
||||
client, &prepared.models, &model, claude_desktop_model_mappings.as_ref(),
|
||||
)?;
|
||||
let claude_code_model_mappings = resolve_claude_code_model_mappings(
|
||||
client,
|
||||
&prepared.models,
|
||||
@@ -1380,7 +1421,13 @@ pub(crate) fn mark_configured_agent_model_aliases(
|
||||
let configured_models = configured_models
|
||||
.as_sequence()
|
||||
.ok_or_else(|| format!("{section}.models 必须是数组"))?;
|
||||
mark_agent_model_aliases_from_sequence(models, configured_models);
|
||||
for configured in configured_models {
|
||||
if configured_model_identity(configured).is_some_and(|(_, client_model, _)| {
|
||||
configured_provider_model_is_enabled(provider, &client_model)
|
||||
}) {
|
||||
mark_agent_model_aliases_from_sequence(models, std::slice::from_ref(configured));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -779,26 +779,48 @@ pub(crate) fn build_claude_desktop_profile(
|
||||
let mappings = mappings
|
||||
.cloned()
|
||||
.unwrap_or_else(|| ClaudeDesktopModelMappings::all(model));
|
||||
let inference_models = vec![
|
||||
claude_desktop_inference_model(
|
||||
CLAUDE_DESKTOP_OPUS_MODEL_ID,
|
||||
&mappings.opus,
|
||||
mappings.opus_1m,
|
||||
models,
|
||||
),
|
||||
claude_desktop_inference_model(
|
||||
CLAUDE_DESKTOP_SONNET_MODEL_ID,
|
||||
&mappings.sonnet,
|
||||
mappings.sonnet_1m,
|
||||
models,
|
||||
),
|
||||
claude_desktop_inference_model(
|
||||
CLAUDE_DESKTOP_HAIKU_MODEL_ID,
|
||||
&mappings.haiku,
|
||||
mappings.haiku_1m,
|
||||
models,
|
||||
),
|
||||
];
|
||||
let 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 {
|
||||
@@ -831,35 +853,55 @@ pub(crate) fn claude_desktop_inference_model(
|
||||
enable_1m: bool,
|
||||
models: &[AgentModelOption],
|
||||
) -> serde_json::Value {
|
||||
let selected = models
|
||||
.iter()
|
||||
.find(|model| model.name.eq_ignore_ascii_case(source_model));
|
||||
let direct_alias = selected.is_some_and(|model| model.is_alias);
|
||||
let context_window = claude_effective_context_window(models, source_model, enable_1m);
|
||||
let 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 direct_alias {
|
||||
serde_json::json!(if use_source {
|
||||
source_model
|
||||
} else {
|
||||
route_model
|
||||
}),
|
||||
);
|
||||
if let Some(context_window) = context_window {
|
||||
entry.insert(
|
||||
"contextWindow".to_string(),
|
||||
serde_json::json!(context_window),
|
||||
);
|
||||
if enable_1m {
|
||||
entry.insert("supports1m".to_string(), serde_json::json!(true));
|
||||
entry.insert("prefer1m".to_string(), serde_json::json!(true));
|
||||
}
|
||||
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 配置索引")?;
|
||||
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());
|
||||
@@ -889,6 +931,21 @@ pub(crate) fn build_claude_desktop_meta(existing: Option<&str>) -> Result<String
|
||||
render_agent_json(root, "Claude Desktop 配置索引")
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -2801,6 +2801,7 @@ pub(crate) fn inspect_claude_code_model_mappings(
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|value| value == "1" || value.eq_ignore_ascii_case("true"));
|
||||
Ok(Some(ClaudeDesktopModelMappings {
|
||||
desktop_models: None,
|
||||
opus,
|
||||
sonnet,
|
||||
haiku,
|
||||
@@ -2926,6 +2927,7 @@ pub(crate) fn claude_code_model_settings(
|
||||
mappings: &ClaudeDesktopModelMappings,
|
||||
) -> ClaudeDesktopModelMappings {
|
||||
ClaudeDesktopModelMappings {
|
||||
desktop_models: None,
|
||||
opus: claude_code_model_setting(&mappings.opus, mappings.opus_1m),
|
||||
sonnet: claude_code_model_setting(&mappings.sonnet, mappings.sonnet_1m),
|
||||
haiku: claude_code_model_setting(&mappings.haiku, mappings.haiku_1m),
|
||||
|
||||
@@ -92,6 +92,21 @@ pub(crate) fn agent_core_error(error: String) -> String {
|
||||
"内核别名或智能体配置写入失败,已恢复原配置,请检查连接和模型映射后重试".into()
|
||||
} else if error.contains("配置已变化") {
|
||||
"内核配置已变化,请重新预览后重试".into()
|
||||
} else if error.contains("已被其他模型使用") {
|
||||
"模型别名已被其他模型占用,请切换其他别名后重试".into()
|
||||
} else if error.contains("无法确定模型") && error.contains("CPA 配置来源") {
|
||||
"无法找到原模型的有效接入来源,请确认接入已启用且未屏蔽原模型或别名,刷新模型列表后重试".into()
|
||||
} else if error.starts_with("更新后的内核配置与预期值不一致")
|
||||
|| error.starts_with("验证更新后的内核配置失败")
|
||||
{
|
||||
"内核配置格式兼容性校验失败,已取消别名同步,原配置未写入".into()
|
||||
} else if error.starts_with("解析内核 YAML 配置失败") {
|
||||
"内核 YAML 配置格式无效,请检查配置格式后重试".into()
|
||||
} else if error.starts_with("管理 API 错误 (401)")
|
||||
|| error.starts_with("管理 API 错误 (403)")
|
||||
|| error.starts_with("管理接口不可用")
|
||||
{
|
||||
"内核管理接口认证失败,请检查管理密钥后重试".into()
|
||||
} else {
|
||||
"内核模型别名同步失败,请检查内核连接和模型映射".into()
|
||||
}
|
||||
@@ -137,7 +152,9 @@ async fn prepare_template_plan(
|
||||
} else {
|
||||
let parsed = AgentClient::parse(client)?;
|
||||
let prepared = fetch_prepared_agent_models(parsed, config).await?;
|
||||
let model = resolve_available_agent_model(&prepared.models, &validate_agent_model(model)?)?;
|
||||
let model = resolve_agent_configuration_model(
|
||||
parsed, &prepared.models, model, claude_desktop_model_mappings.as_ref(),
|
||||
)?;
|
||||
let code_mappings = resolve_claude_code_model_mappings(
|
||||
parsed,
|
||||
&prepared.models,
|
||||
|
||||
@@ -16,6 +16,24 @@ fn core_failure_messages_expose_outcomes_without_secret_source_text() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_failure_messages_distinguish_alias_and_configuration_failures() {
|
||||
for (detail, expected) in [
|
||||
("别名 claude-sonnet-5 已被其他模型使用,请更换别名 secret-token", "请切换其他别名"),
|
||||
("无法确定模型 private-model 的 CPA 配置来源,无法创建 Claude Desktop 别名 secret-token", "有效接入来源"),
|
||||
("更新后的内核配置与预期值不一致(路径: secret-token),已拒绝写入", "格式兼容性校验失败"),
|
||||
("验证更新后的内核配置失败: secret-token", "原配置未写入"),
|
||||
("解析内核 YAML 配置失败: secret-token", "YAML 配置格式无效"),
|
||||
("管理 API 错误 (401): secret-token", "认证失败"),
|
||||
("管理 API 错误 (403): secret-token", "认证失败"),
|
||||
] {
|
||||
let rendered = agent_core_error(detail.into());
|
||||
assert!(rendered.contains(expected), "{rendered}");
|
||||
assert!(!rendered.contains("secret-token"));
|
||||
assert!(!rendered.contains("private-model"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn template_confirmation_is_bound_to_files_and_generated_content() {
|
||||
let home = std::env::temp_dir().join(format!(
|
||||
|
||||
@@ -389,7 +389,7 @@ fn preserve_model_extensions(client: &str, path: &Path, before: &Value, after: &
|
||||
fn merge_entry(client: &str, before: &Value, after: &mut Value) {
|
||||
let mut extensions = before.clone();
|
||||
let owned: &[&str] = match client {
|
||||
"claude-desktop" => &["name", "contextWindow", "supports1m", "prefer1m"],
|
||||
"claude-desktop" => &["name", "labelOverride", "anthropicFamilyTier", "isFamilyDefault", "contextWindow", "supports1m", "prefer1m"],
|
||||
"opencode" | "zcode" => &["name"],
|
||||
"openclaw" => &["id", "name", "alias"],
|
||||
"deepseek-harness" => &["id", "name", "contextWindow", "input", "maxTokens", "reasoningEfforts", "compat"],
|
||||
@@ -549,6 +549,11 @@ fn validate_unmanaged_preserved(
|
||||
) -> Result<(), String> {
|
||||
let project = |value: &Value| -> Result<Value, String> {
|
||||
let mut value = value.clone();
|
||||
if client == "claude-desktop" && paths.get(3).is_some_and(|p| p == path) {
|
||||
if let Some(root) = value.as_object_mut() {
|
||||
repair_claude_desktop_meta_names(root);
|
||||
}
|
||||
}
|
||||
if client == "claude-code" {
|
||||
if let Some(env) = value.get_mut("env").and_then(Value::as_object_mut) {
|
||||
env.retain(|key, _| {
|
||||
|
||||
@@ -341,6 +341,26 @@ pub(crate) fn ensure_claude_desktop_model_aliases_with_oauth_definitions_in_yaml
|
||||
mappings: &ClaudeDesktopModelMappings,
|
||||
models: &[AgentModelOption],
|
||||
oauth_model_definitions: &[OAuthModelDefinitions],
|
||||
) -> Result<String, String> {
|
||||
ensure_claude_desktop_model_aliases_with_oauth_definitions_and_routes_in_yaml(
|
||||
content,
|
||||
mappings,
|
||||
models,
|
||||
oauth_model_definitions,
|
||||
[
|
||||
CLAUDE_DESKTOP_OPUS_MODEL_ID,
|
||||
CLAUDE_DESKTOP_SONNET_MODEL_ID,
|
||||
CLAUDE_DESKTOP_HAIKU_MODEL_ID,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_claude_desktop_model_aliases_with_oauth_definitions_and_routes_in_yaml(
|
||||
content: &str,
|
||||
mappings: &ClaudeDesktopModelMappings,
|
||||
models: &[AgentModelOption],
|
||||
oauth_model_definitions: &[OAuthModelDefinitions],
|
||||
routes: [&str; 3],
|
||||
) -> Result<String, String> {
|
||||
let mut document = yaml_serde_edit::YamlValue::parse(content)
|
||||
.map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?;
|
||||
@@ -350,11 +370,76 @@ pub(crate) fn ensure_claude_desktop_model_aliases_with_oauth_definitions_in_yaml
|
||||
.ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?;
|
||||
|
||||
let sources = root.clone();
|
||||
if let Some(entries) = &mappings.desktop_models {
|
||||
validate_claude_desktop_entries(entries)?;
|
||||
let managed_aliases = managed_claude_desktop_aliases(&sources);
|
||||
for entry in entries {
|
||||
if !entry.has_mapping() {
|
||||
continue;
|
||||
}
|
||||
let occupied = configured_model_client_identity(&sources, &entry.alias).is_some()
|
||||
|| models
|
||||
.iter()
|
||||
.any(|model| model.name.eq_ignore_ascii_case(&entry.alias));
|
||||
let unmanaged_collision = claude_desktop_configured_models(&sources)
|
||||
.into_iter()
|
||||
.any(|model| {
|
||||
configured_model_identity(model).is_some_and(|(_, alias, _)| {
|
||||
alias.eq_ignore_ascii_case(&entry.alias)
|
||||
&& !is_managed_claude_model_alias(model, &alias)
|
||||
})
|
||||
});
|
||||
if unmanaged_collision
|
||||
|| (occupied && !managed_aliases.iter()
|
||||
.any(|alias| alias.eq_ignore_ascii_case(&entry.alias)))
|
||||
{
|
||||
return Err(format!("别名 {} 已被其他模型使用,请更换别名", entry.alias));
|
||||
}
|
||||
}
|
||||
for alias in managed_aliases {
|
||||
if !entries.iter().any(|entry| entry.source_or_alias().eq_ignore_ascii_case(&alias)) {
|
||||
remove_managed_claude_model_alias(root, &alias)?;
|
||||
}
|
||||
}
|
||||
for entry in entries {
|
||||
if entry.has_mapping() {
|
||||
ensure_claude_desktop_model_alias(
|
||||
root,
|
||||
&sources,
|
||||
&entry.model,
|
||||
&entry.alias,
|
||||
oauth_model_definitions,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
if *root == sources {
|
||||
return Ok(content.to_string());
|
||||
}
|
||||
return render_updated_core_yaml(&mut document, updated);
|
||||
}
|
||||
for alias in managed_claude_desktop_aliases(&sources) {
|
||||
if !is_claude_desktop_route_id(&alias)
|
||||
&& ![&mappings.opus, &mappings.sonnet, &mappings.haiku]
|
||||
.iter()
|
||||
.any(|model| model.eq_ignore_ascii_case(&alias))
|
||||
{
|
||||
remove_managed_claude_model_alias(root, &alias)?;
|
||||
}
|
||||
}
|
||||
for (alias, source_model) in [
|
||||
(CLAUDE_DESKTOP_OPUS_MODEL_ID, mappings.opus.as_str()),
|
||||
(CLAUDE_DESKTOP_SONNET_MODEL_ID, mappings.sonnet.as_str()),
|
||||
(CLAUDE_DESKTOP_HAIKU_MODEL_ID, mappings.haiku.as_str()),
|
||||
(routes[0], mappings.opus.as_str()),
|
||||
(routes[1], mappings.sonnet.as_str()),
|
||||
(routes[2], mappings.haiku.as_str()),
|
||||
] {
|
||||
if let Some(paired_alias) = paired_claude_desktop_alias(alias) {
|
||||
remove_managed_claude_model_alias(root, paired_alias)?;
|
||||
}
|
||||
if claude_desktop_uses_source_directly(source_model, models) {
|
||||
if !source_model.eq_ignore_ascii_case(alias) {
|
||||
remove_managed_claude_model_alias(root, alias)?;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let direct_alias = models
|
||||
.iter()
|
||||
.any(|model| model.name.eq_ignore_ascii_case(source_model) && model.is_alias);
|
||||
@@ -372,9 +457,102 @@ pub(crate) fn ensure_claude_desktop_model_aliases_with_oauth_definitions_in_yaml
|
||||
)?;
|
||||
}
|
||||
}
|
||||
if *root == sources {
|
||||
return Ok(content.to_string());
|
||||
}
|
||||
render_updated_core_yaml(&mut document, updated)
|
||||
}
|
||||
|
||||
pub(crate) fn validate_claude_desktop_entries(
|
||||
entries: &[ClaudeDesktopModelMapping],
|
||||
) -> Result<(), String> {
|
||||
if entries.is_empty() {
|
||||
return Err("请至少添加一个 Claude Desktop 模型".into());
|
||||
}
|
||||
let mut ids = HashSet::new();
|
||||
for entry in entries {
|
||||
validate_agent_model(entry.source_or_alias())?;
|
||||
if entry.source_or_alias().chars().any(char::is_whitespace) {
|
||||
return Err("原模型 ID 不能包含空白字符".into());
|
||||
}
|
||||
let alias = entry.alias.trim();
|
||||
if !alias.is_empty() && !alias.eq_ignore_ascii_case(entry.source_or_alias()) && !valid_claude_desktop_alias(alias) {
|
||||
return Err(format!("Claude Desktop 别名 {alias} 必须以 claude- 开头,只能包含小写字母、数字、连字符和小数点,且不能包含 gpt、grok、gemini、deepseek 等其他模型系列名称"));
|
||||
}
|
||||
let id = entry.model_id();
|
||||
if !ids.insert(id.to_ascii_lowercase()) {
|
||||
return Err(format!("Claude Desktop 模型 ID 重复: {id}"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn valid_claude_desktop_alias(alias: &str) -> bool {
|
||||
claude_desktop_alias_has_valid_syntax(alias) && !claude_desktop_has_other_model_family(alias)
|
||||
}
|
||||
|
||||
fn claude_desktop_alias_has_valid_syntax(alias: &str) -> bool {
|
||||
alias.len() <= 128
|
||||
&& alias.strip_prefix("claude-").is_some_and(|rest| {
|
||||
!rest.is_empty()
|
||||
&& rest.split(['-', '.']).all(|part| {
|
||||
!part.is_empty()
|
||||
&& part.bytes().all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn claude_desktop_has_other_model_family(name: &str) -> bool {
|
||||
static OTHER_MODEL_FAMILY: LazyLock<regex::Regex> = LazyLock::new(|| {
|
||||
let rules: serde_json::Value = serde_json::from_str(include_str!(
|
||||
"../../../src/services/claudeDesktopModelRules.json"
|
||||
))
|
||||
.expect("valid bundled Claude Desktop model rules");
|
||||
regex::RegexBuilder::new(
|
||||
rules["otherModelFamilyPattern"]
|
||||
.as_str()
|
||||
.expect("Claude Desktop model-family pattern"),
|
||||
)
|
||||
.unicode(false)
|
||||
.build()
|
||||
.expect("valid Claude Desktop model-family pattern")
|
||||
});
|
||||
OTHER_MODEL_FAMILY.is_match(&name.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
fn claude_desktop_configured_models(root: &serde_norway::Mapping) -> Vec<&serde_norway::Value> {
|
||||
let mut models = Vec::new();
|
||||
for section in MODEL_ALIAS_CONFIG_SECTIONS {
|
||||
for provider in yaml_mapping_value(root, section)
|
||||
.and_then(serde_norway::Value::as_sequence)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if let Some(entries) = provider.as_mapping()
|
||||
.and_then(|provider| yaml_mapping_value(provider, "models"))
|
||||
.and_then(serde_norway::Value::as_sequence)
|
||||
{
|
||||
models.extend(entries);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(channels) = yaml_mapping_value(root, "oauth-model-alias")
|
||||
.and_then(serde_norway::Value::as_mapping)
|
||||
{
|
||||
for entries in channels.values().filter_map(serde_norway::Value::as_sequence) {
|
||||
models.extend(entries);
|
||||
}
|
||||
}
|
||||
models
|
||||
}
|
||||
|
||||
pub(crate) fn managed_claude_desktop_aliases(root: &serde_norway::Mapping) -> Vec<String> {
|
||||
claude_desktop_configured_models(root).into_iter().filter_map(|model| {
|
||||
let (_, alias, _) = configured_model_identity(model)?;
|
||||
is_managed_claude_model_alias(model, &alias).then_some(alias)
|
||||
}).collect::<BTreeSet<_>>().into_iter().collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn remove_managed_claude_model_aliases_in_yaml(content: &str) -> Result<String, String> {
|
||||
let mut document = yaml_serde_edit::YamlValue::parse(content)
|
||||
@@ -388,6 +566,9 @@ pub(crate) fn remove_managed_claude_model_aliases_in_yaml(content: &str) -> Resu
|
||||
CLAUDE_DESKTOP_OPUS_MODEL_ID,
|
||||
CLAUDE_DESKTOP_SONNET_MODEL_ID,
|
||||
CLAUDE_DESKTOP_HAIKU_MODEL_ID,
|
||||
LEGACY_CLAUDE_DESKTOP_MODEL_IDS[0],
|
||||
LEGACY_CLAUDE_DESKTOP_MODEL_IDS[1],
|
||||
LEGACY_CLAUDE_DESKTOP_MODEL_IDS[2],
|
||||
] {
|
||||
changed |= remove_managed_claude_model_alias(root, alias)?;
|
||||
}
|
||||
@@ -451,10 +632,7 @@ pub(crate) fn remove_existing_claude_model_alias(
|
||||
.as_sequence_mut()
|
||||
.ok_or_else(|| format!("{section}.models 必须是数组"))?;
|
||||
let before = models.len();
|
||||
models.retain(|model| {
|
||||
configured_model_identity(model)
|
||||
.is_none_or(|(_, client_model, _)| !client_model.eq_ignore_ascii_case(alias))
|
||||
});
|
||||
models.retain(|model| !occupies_claude_client_alias(model, alias));
|
||||
changed |= models.len() != before;
|
||||
}
|
||||
}
|
||||
@@ -485,8 +663,7 @@ pub(crate) fn remove_oauth_claude_model_alias(
|
||||
if managed_only {
|
||||
!is_managed_claude_model_alias(entry, alias)
|
||||
} else {
|
||||
configured_model_identity(entry)
|
||||
.is_none_or(|(_, client_model, _)| !client_model.eq_ignore_ascii_case(alias))
|
||||
!occupies_claude_client_alias(entry, alias)
|
||||
}
|
||||
});
|
||||
if entries.len() != before {
|
||||
@@ -507,17 +684,94 @@ pub(crate) fn remove_oauth_claude_model_alias(
|
||||
}
|
||||
|
||||
pub(crate) fn managed_claude_alias_display_name(alias: &str) -> Option<&'static str> {
|
||||
if alias.eq_ignore_ascii_case(CLAUDE_DESKTOP_OPUS_MODEL_ID) {
|
||||
if alias.eq_ignore_ascii_case(CLAUDE_DESKTOP_OPUS_MODEL_ID)
|
||||
|| alias.eq_ignore_ascii_case(LEGACY_CLAUDE_DESKTOP_MODEL_IDS[0])
|
||||
{
|
||||
Some(MANAGED_CLAUDE_OPUS_ALIAS_DISPLAY_NAME)
|
||||
} else if alias.eq_ignore_ascii_case(CLAUDE_DESKTOP_SONNET_MODEL_ID) {
|
||||
} else if alias.eq_ignore_ascii_case(CLAUDE_DESKTOP_SONNET_MODEL_ID)
|
||||
|| alias.eq_ignore_ascii_case(LEGACY_CLAUDE_DESKTOP_MODEL_IDS[1])
|
||||
{
|
||||
Some(MANAGED_CLAUDE_SONNET_ALIAS_DISPLAY_NAME)
|
||||
} else if alias.eq_ignore_ascii_case(CLAUDE_DESKTOP_HAIKU_MODEL_ID) {
|
||||
} else if alias.eq_ignore_ascii_case(CLAUDE_DESKTOP_HAIKU_MODEL_ID)
|
||||
|| alias.eq_ignore_ascii_case(LEGACY_CLAUDE_DESKTOP_MODEL_IDS[2])
|
||||
{
|
||||
Some(MANAGED_CLAUDE_HAIKU_ALIAS_DISPLAY_NAME)
|
||||
} else if claude_desktop_alias_has_valid_syntax(alias) {
|
||||
Some("EasyCLIProxyAPI managed Claude Desktop mapping")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn claude_desktop_uses_source_directly(
|
||||
source_model: &str,
|
||||
models: &[AgentModelOption],
|
||||
) -> bool {
|
||||
models
|
||||
.iter()
|
||||
.any(|model| model.name.eq_ignore_ascii_case(source_model) && model.is_alias)
|
||||
|| is_claude_native_model_id(source_model)
|
||||
}
|
||||
|
||||
pub(crate) fn is_claude_native_model_id(name: &str) -> bool {
|
||||
let name = name.trim();
|
||||
if name.is_empty() || claude_desktop_has_other_model_family(name) {
|
||||
return false;
|
||||
}
|
||||
let lowered = name.to_ascii_lowercase();
|
||||
if lowered.starts_with("claude-")
|
||||
|| lowered.starts_with("anthropic.")
|
||||
|| lowered.contains("anthropic/")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
["opus", "sonnet", "haiku", "fable", "mythos"]
|
||||
.into_iter()
|
||||
.any(|family| {
|
||||
lowered == family
|
||||
|| lowered
|
||||
.strip_prefix(&format!("{family}-"))
|
||||
.is_some_and(|rest| {
|
||||
!rest.is_empty()
|
||||
&& rest
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_digit() || ch == '.')
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn is_claude_desktop_route_id(name: &str) -> bool {
|
||||
claude_desktop_route_pairs()
|
||||
.iter()
|
||||
.any(|(current, legacy)| current.eq_ignore_ascii_case(name) || legacy.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
fn claude_desktop_route_pairs() -> [(&'static str, &'static str); 3] {
|
||||
[
|
||||
(CLAUDE_DESKTOP_OPUS_MODEL_ID, LEGACY_CLAUDE_DESKTOP_MODEL_IDS[0]),
|
||||
(CLAUDE_DESKTOP_SONNET_MODEL_ID, LEGACY_CLAUDE_DESKTOP_MODEL_IDS[1]),
|
||||
(CLAUDE_DESKTOP_HAIKU_MODEL_ID, LEGACY_CLAUDE_DESKTOP_MODEL_IDS[2]),
|
||||
]
|
||||
}
|
||||
|
||||
fn paired_claude_desktop_alias(alias: &str) -> Option<&'static str> {
|
||||
claude_desktop_route_pairs().into_iter().find_map(|(current, legacy)| {
|
||||
if alias.eq_ignore_ascii_case(current) {
|
||||
Some(legacy)
|
||||
} else if alias.eq_ignore_ascii_case(legacy) {
|
||||
Some(current)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn occupies_claude_client_alias(model: &serde_norway::Value, alias: &str) -> bool {
|
||||
configured_model_identity(model).is_some_and(|(source, client_model, _)| {
|
||||
client_model.eq_ignore_ascii_case(alias) && !source.eq_ignore_ascii_case(&client_model)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn is_managed_claude_model_alias(model: &serde_norway::Value, alias: &str) -> bool {
|
||||
let Some(expected_display_name) = managed_claude_alias_display_name(alias) else {
|
||||
return false;
|
||||
@@ -547,9 +801,7 @@ pub(crate) fn configured_managed_claude_alias_matches(
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(serde_norway::Value::as_mapping)
|
||||
.filter(|provider| {
|
||||
yaml_mapping_value(provider, "disabled") != Some(&serde_norway::Value::Bool(true))
|
||||
})
|
||||
.filter(|provider| configured_provider_model_is_enabled(provider, alias))
|
||||
.filter_map(|provider| yaml_mapping_value(provider, "models"))
|
||||
.filter_map(serde_norway::Value::as_sequence)
|
||||
.flatten()
|
||||
@@ -575,7 +827,7 @@ pub(crate) fn ensure_claude_desktop_model_alias(
|
||||
if configured_managed_claude_alias_matches(root, alias, source_model) {
|
||||
return Ok(());
|
||||
}
|
||||
let source = resolve_claude_desktop_alias_source(sources, source_model)?;
|
||||
let source = resolve_claude_desktop_alias_source(sources, source_model, alias)?;
|
||||
if let Some(source) = source {
|
||||
remove_existing_claude_model_alias(root, alias)?;
|
||||
append_claude_desktop_model_alias(root, source, alias)?;
|
||||
@@ -665,9 +917,49 @@ enum ClaudeDesktopAliasSource {
|
||||
},
|
||||
}
|
||||
|
||||
pub(crate) fn configured_provider_model_is_enabled(provider: &serde_norway::Mapping, model: &str) -> bool {
|
||||
if yaml_mapping_value(provider, "disabled") == Some(&serde_norway::Value::Bool(true)) {
|
||||
return false;
|
||||
}
|
||||
let model = model.trim().to_lowercase();
|
||||
!yaml_mapping_value(provider, "excluded-models")
|
||||
.and_then(serde_norway::Value::as_sequence)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(serde_norway::Value::as_str)
|
||||
.any(|pattern| {
|
||||
let pattern = pattern.trim().to_lowercase();
|
||||
if pattern.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let Some((prefix, rest)) = pattern.split_once('*') else {
|
||||
return pattern == model;
|
||||
};
|
||||
let Some(mut remaining) = model.strip_prefix(prefix) else {
|
||||
return false;
|
||||
};
|
||||
let mut parts = rest.rsplitn(2, '*');
|
||||
let suffix = parts.next().unwrap_or_default();
|
||||
let Some(middle) = remaining.strip_suffix(suffix) else {
|
||||
return false;
|
||||
};
|
||||
remaining = middle;
|
||||
if let Some(parts) = parts.next() {
|
||||
for part in parts.split('*').filter(|part| !part.is_empty()) {
|
||||
let Some(index) = remaining.find(part) else {
|
||||
return false;
|
||||
};
|
||||
remaining = &remaining[index + part.len()..];
|
||||
}
|
||||
}
|
||||
true
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_claude_desktop_alias_source(
|
||||
root: &serde_norway::Mapping,
|
||||
source_model: &str,
|
||||
alias: &str,
|
||||
) -> Result<Option<ClaudeDesktopAliasSource>, String> {
|
||||
for upstream in [false, true] {
|
||||
let matching_model = |model: &serde_norway::Value| {
|
||||
@@ -691,7 +983,7 @@ fn resolve_claude_desktop_alias_source(
|
||||
let Some(provider) = provider.as_mapping() else {
|
||||
continue;
|
||||
};
|
||||
if yaml_mapping_value(provider, "disabled") == Some(&serde_norway::Value::Bool(true)) {
|
||||
if !configured_provider_model_is_enabled(provider, alias) {
|
||||
continue;
|
||||
}
|
||||
let Some(models) = yaml_mapping_value(provider, "models") else {
|
||||
@@ -700,7 +992,13 @@ fn resolve_claude_desktop_alias_source(
|
||||
let models = models
|
||||
.as_sequence()
|
||||
.ok_or_else(|| format!("{section}.models 必须是数组"))?;
|
||||
if let Some(model) = models.iter().find_map(matching_model) {
|
||||
if let Some(model) = models.iter().find_map(|model| {
|
||||
let (_, client_model, _) = configured_model_identity(model)?;
|
||||
if !configured_provider_model_is_enabled(provider, &client_model) {
|
||||
return None;
|
||||
}
|
||||
matching_model(model)
|
||||
}) {
|
||||
return Ok(Some(ClaudeDesktopAliasSource::Provider {
|
||||
section,
|
||||
provider_index,
|
||||
|
||||
+46
-3
@@ -164,9 +164,11 @@ const PI_AGENT_SETTINGS_FILE: &str = "settings.json";
|
||||
const CODEX_MODEL_CATALOG_FILE: &str = "cpa-gui-model-catalog.json";
|
||||
const CODEX_OAUTH_LOGIN_REQUIRED_ERROR: &str = "CODEX_OAUTH_LOGIN_REQUIRED";
|
||||
const CLAUDE_DESKTOP_PROFILE_ID: &str = "00000000-0000-4000-8000-000000831700";
|
||||
const CLAUDE_DESKTOP_OPUS_MODEL_ID: &str = "claude-opus-5";
|
||||
const CLAUDE_DESKTOP_SONNET_MODEL_ID: &str = "claude-sonnet-4-6";
|
||||
const CLAUDE_DESKTOP_HAIKU_MODEL_ID: &str = "claude-haiku-4-5";
|
||||
const CLAUDE_DESKTOP_OPUS_MODEL_ID: &str = "claude-opus-5-cpa";
|
||||
const CLAUDE_DESKTOP_SONNET_MODEL_ID: &str = "claude-sonnet-5-cpa";
|
||||
const CLAUDE_DESKTOP_HAIKU_MODEL_ID: &str = "claude-haiku-4-5-cpa";
|
||||
const LEGACY_CLAUDE_DESKTOP_MODEL_IDS: [&str; 3] =
|
||||
["claude-opus-5", "claude-sonnet-4-6", "claude-haiku-4-5"];
|
||||
const MANAGED_CLAUDE_OPUS_ALIAS_DISPLAY_NAME: &str = "EasyCLIProxyAPI managed Claude Opus mapping";
|
||||
const MANAGED_CLAUDE_SONNET_ALIAS_DISPLAY_NAME: &str =
|
||||
"EasyCLIProxyAPI managed Claude Sonnet mapping";
|
||||
@@ -1131,8 +1133,13 @@ struct AgentModelOption {
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ClaudeDesktopModelMappings {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
desktop_models: Option<Vec<ClaudeDesktopModelMapping>>,
|
||||
#[serde(default)]
|
||||
opus: String,
|
||||
#[serde(default)]
|
||||
sonnet: String,
|
||||
#[serde(default)]
|
||||
haiku: String,
|
||||
#[serde(default)]
|
||||
opus_1m: bool,
|
||||
@@ -1148,6 +1155,41 @@ struct ClaudeDesktopModelMappings {
|
||||
disable_auto_compact: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ClaudeDesktopModelMapping {
|
||||
#[serde(default)]
|
||||
model: String,
|
||||
#[serde(default)]
|
||||
alias: String,
|
||||
#[serde(default)]
|
||||
context_1m: bool,
|
||||
}
|
||||
|
||||
impl ClaudeDesktopModelMapping {
|
||||
fn model_id(&self) -> &str {
|
||||
if self.alias.trim().is_empty() {
|
||||
self.model.trim()
|
||||
} else {
|
||||
self.alias.trim()
|
||||
}
|
||||
}
|
||||
|
||||
fn source_or_alias(&self) -> &str {
|
||||
if self.model.trim().is_empty() {
|
||||
self.alias.trim()
|
||||
} else {
|
||||
self.model.trim()
|
||||
}
|
||||
}
|
||||
|
||||
fn has_mapping(&self) -> bool {
|
||||
!self.model.trim().is_empty()
|
||||
&& !self.alias.trim().is_empty()
|
||||
&& !self.model.trim().eq_ignore_ascii_case(self.alias.trim())
|
||||
}
|
||||
}
|
||||
|
||||
fn default_claude_code_max_context_tokens() -> u64 {
|
||||
DEFAULT_CLAUDE_CONTEXT_WINDOW
|
||||
}
|
||||
@@ -1159,6 +1201,7 @@ fn default_claude_auto_compact_pct() -> u8 {
|
||||
impl ClaudeDesktopModelMappings {
|
||||
fn all(model: &str) -> Self {
|
||||
Self {
|
||||
desktop_models: None,
|
||||
opus: model.to_string(),
|
||||
sonnet: model.to_string(),
|
||||
haiku: model.to_string(),
|
||||
|
||||
@@ -116,6 +116,7 @@ fn claude_mapping_legacy_json_defaults_1m_preferences_off() {
|
||||
#[test]
|
||||
fn claude_code_role_mappings_drive_settings() {
|
||||
let mappings = ClaudeDesktopModelMappings {
|
||||
desktop_models: None,
|
||||
opus: "gpt-opus".to_string(),
|
||||
sonnet: "gpt-sonnet".to_string(),
|
||||
haiku: "gpt-haiku".to_string(),
|
||||
@@ -184,6 +185,7 @@ fn claude_code_role_mappings_drive_settings() {
|
||||
#[test]
|
||||
fn claude_code_runtime_settings_keep_per_role_1m_suffixes() {
|
||||
let mappings = ClaudeDesktopModelMappings {
|
||||
desktop_models: None,
|
||||
opus: "custom-pro".to_string(),
|
||||
sonnet: "custom-pro".to_string(),
|
||||
haiku: "custom-flash".to_string(),
|
||||
@@ -261,7 +263,7 @@ fn claude_code_runtime_settings_keep_per_role_1m_suffixes() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_desktop_keeps_original_context_when_1m_is_off() {
|
||||
fn claude_desktop_omits_unsupported_context_window_when_1m_is_off() {
|
||||
let models = vec![AgentModelOption {
|
||||
input_modalities: None,
|
||||
harness_metadata: None,
|
||||
@@ -277,7 +279,7 @@ fn claude_desktop_keeps_original_context_when_1m_is_off() {
|
||||
&models,
|
||||
);
|
||||
|
||||
assert_eq!(entry["contextWindow"], 1_000_000);
|
||||
assert!(entry.get("contextWindow").is_none());
|
||||
assert!(entry.get("supports1m").is_none());
|
||||
assert!(entry.get("prefer1m").is_none());
|
||||
}
|
||||
@@ -559,9 +561,11 @@ fn claude_desktop_config_builds_gateway_profile_and_index() {
|
||||
assert_eq!(
|
||||
profile["inferenceModels"],
|
||||
serde_json::json!([
|
||||
{ "name": CLAUDE_DESKTOP_OPUS_MODEL_ID },
|
||||
{ "name": CLAUDE_DESKTOP_SONNET_MODEL_ID },
|
||||
{ "name": CLAUDE_DESKTOP_HAIKU_MODEL_ID }
|
||||
{
|
||||
"name": "claude-sonnet-test",
|
||||
"anthropicFamilyTier": "opus",
|
||||
"isFamilyDefault": true
|
||||
}
|
||||
])
|
||||
);
|
||||
assert_eq!(meta["appliedId"], CLAUDE_DESKTOP_PROFILE_ID);
|
||||
@@ -628,20 +632,29 @@ fn claude_desktop_profile_keeps_non_claude_models_internal() {
|
||||
serde_json::json!([
|
||||
{
|
||||
"name": CLAUDE_DESKTOP_OPUS_MODEL_ID,
|
||||
"contextWindow": 1000000,
|
||||
"labelOverride": "gpt-5.6-sol",
|
||||
"anthropicFamilyTier": "opus",
|
||||
"isFamilyDefault": true,
|
||||
"supports1m": true,
|
||||
"prefer1m": true
|
||||
},
|
||||
{ "name": CLAUDE_DESKTOP_SONNET_MODEL_ID, "contextWindow": 272000 },
|
||||
{
|
||||
"name": CLAUDE_DESKTOP_SONNET_MODEL_ID,
|
||||
"labelOverride": "gpt-5.6",
|
||||
"anthropicFamilyTier": "sonnet",
|
||||
"isFamilyDefault": true
|
||||
},
|
||||
{
|
||||
"name": CLAUDE_DESKTOP_HAIKU_MODEL_ID,
|
||||
"contextWindow": 1000000,
|
||||
"labelOverride": "gpt-5.6-mini",
|
||||
"anthropicFamilyTier": "haiku",
|
||||
"isFamilyDefault": true,
|
||||
"supports1m": true,
|
||||
"prefer1m": true
|
||||
}
|
||||
])
|
||||
);
|
||||
assert!(!profile.to_string().contains("gpt-"));
|
||||
assert_eq!(profile["inferenceModels"][0]["labelOverride"], "gpt-5.6-sol");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -1794,7 +1807,7 @@ fn claude_desktop_aliases_expose_role_routes_only() {
|
||||
);
|
||||
assert!(!rendered.contains("models: [{"));
|
||||
assert!(rendered.contains("\n - name: gpt-5.6-sol\n"));
|
||||
assert!(rendered.contains("\n alias: claude-opus-5\n"));
|
||||
assert!(rendered.contains(&format!("\n alias: {CLAUDE_DESKTOP_OPUS_MODEL_ID}\n")));
|
||||
assert!(
|
||||
rendered.contains("\n display-name: EasyCLIProxyAPI managed Claude Opus mapping\n")
|
||||
);
|
||||
@@ -1891,22 +1904,7 @@ fn claude_desktop_aliases_support_claude_oauth_models() {
|
||||
.unwrap();
|
||||
let value: serde_norway::Value = serde_norway::from_str(&rendered).unwrap();
|
||||
let root = value.as_mapping().unwrap();
|
||||
let aliases = yaml_mapping_value(root, "oauth-model-alias")
|
||||
.and_then(serde_norway::Value::as_mapping)
|
||||
.and_then(|channels| yaml_mapping_value(channels, "claude"))
|
||||
.and_then(serde_norway::Value::as_sequence)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(aliases.len(), 3);
|
||||
assert!(aliases.iter().all(|entry| {
|
||||
configured_model_identity(entry).is_some_and(|(source, _, _)| source == "claude-sonnet-4-6")
|
||||
}));
|
||||
assert!(aliases.iter().all(|entry| {
|
||||
entry
|
||||
.as_mapping()
|
||||
.and_then(|entry| yaml_mapping_value(entry, "fork"))
|
||||
== Some(&serde_norway::Value::Bool(true))
|
||||
}));
|
||||
assert!(yaml_mapping_value(root, "oauth-model-alias").is_none());
|
||||
assert_eq!(
|
||||
ensure_claude_desktop_model_aliases_with_oauth_definitions_in_yaml(
|
||||
&rendered,
|
||||
@@ -2148,7 +2146,8 @@ fn claude_desktop_uses_selected_alias_directly_with_original_context() {
|
||||
profile["inferenceModels"],
|
||||
serde_json::json!([{
|
||||
"name": "gpt-high",
|
||||
"contextWindow": 1_000_000,
|
||||
"anthropicFamilyTier": "sonnet",
|
||||
"isFamilyDefault": true,
|
||||
"supports1m": true,
|
||||
"prefer1m": true
|
||||
}])
|
||||
|
||||
@@ -340,13 +340,28 @@ struct DesktopRestoreFixture {
|
||||
|
||||
impl DesktopRestoreFixture {
|
||||
fn new() -> Self {
|
||||
Self::build(false)
|
||||
}
|
||||
|
||||
fn build(custom: bool) -> Self {
|
||||
Self::build_with_direct_model(custom, false)
|
||||
}
|
||||
|
||||
fn build_with_direct_model(custom: bool, direct: bool) -> Self {
|
||||
let home = super::support::agent_test_home("desktop-restore");
|
||||
let models = super::support::test_agent_models(&["model-a", "model-b"]);
|
||||
let initial = "openai-compatibility:\n - name: provider\n models: [{name: model-a}, {name: model-b}]\n";
|
||||
let mut id = None;
|
||||
let mut core_versions = Vec::new();
|
||||
for name in ["model-a", "model-b"] {
|
||||
let mappings = ClaudeDesktopModelMappings::all(name);
|
||||
let mut mappings = ClaudeDesktopModelMappings::all(name);
|
||||
if custom {
|
||||
mappings.desktop_models = Some(vec![ClaudeDesktopModelMapping {
|
||||
model: name.into(),
|
||||
alias: if direct && name == "model-a" { String::new() } else { format!("claude-sonnet-4-6-{name}") }, context_1m: true,
|
||||
}]);
|
||||
mappings.sonnet = mappings.desktop_models.as_ref().unwrap()[0].source_or_alias().to_string();
|
||||
}
|
||||
core_versions.push(
|
||||
ensure_claude_desktop_model_aliases_in_yaml(initial, &mappings, &models).unwrap(),
|
||||
);
|
||||
@@ -406,6 +421,142 @@ async fn desktop_backup_restore_updates_core_routes_and_mapping_metadata() {
|
||||
assert_eq!(persisted, yaml_json(&fixture.core_a));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn desktop_legacy_backup_restores_the_routes_referenced_by_its_profile() {
|
||||
for source in ["claude-sonnet-test", LEGACY_CLAUDE_DESKTOP_MODEL_IDS[1]] {
|
||||
let fixture = DesktopRestoreFixture::new();
|
||||
let paths = agent_config_paths(AgentClient::ClaudeDesktop, &fixture.home);
|
||||
let before = config_images(&paths).unwrap();
|
||||
let mut after = before.clone();
|
||||
let mut profile: serde_json::Value = serde_json::from_slice(after[2].1.as_ref().unwrap()).unwrap();
|
||||
profile["inferenceModels"] = serde_json::json!(LEGACY_CLAUDE_DESKTOP_MODEL_IDS
|
||||
.map(|name| serde_json::json!({"name": name})));
|
||||
after[2].1 = Some(serde_json::to_vec(&profile).unwrap());
|
||||
let mappings = ClaudeDesktopModelMappings::all(source);
|
||||
commit_config_with_mappings("claude-desktop", &paths, &before, &after,
|
||||
"update", Some(source.into()), Some(mappings.clone())).unwrap();
|
||||
let backup = create_backup("claude-desktop", &fixture.home).unwrap();
|
||||
for routes_exist in [false, true] {
|
||||
let initial = format!("{}claude-api-key:\n - models: [{{name: {source}}}]\n", fixture.core_b);
|
||||
let mut initial = yaml_json(&initial);
|
||||
if routes_exist {
|
||||
for route in LEGACY_CLAUDE_DESKTOP_MODEL_IDS {
|
||||
if route != source {
|
||||
initial["claude-api-key"][0]["models"].as_array_mut().unwrap()
|
||||
.push(serde_json::json!({
|
||||
"name": source,
|
||||
"alias": route,
|
||||
"display-name": managed_claude_alias_display_name(route).unwrap(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
let core = MockCore::new(&serde_norway::to_string(&initial).unwrap(), Failure::None);
|
||||
let plan = prepare_restore_plan(&core.config, "claude-desktop", &fixture.home, &backup.id)
|
||||
.await.unwrap();
|
||||
let revision = plan.preview.revision.clone();
|
||||
execute_restore_plan(&core.config, plan, &revision).await.unwrap();
|
||||
assert_eq!(config_images(&paths).unwrap(), after);
|
||||
assert_eq!(current_desktop_mappings(&fixture.home).unwrap(), mappings);
|
||||
let (persisted, _) = core.finish();
|
||||
let models = persisted["claude-api-key"][0]["models"].as_array().unwrap();
|
||||
for route in LEGACY_CLAUDE_DESKTOP_MODEL_IDS {
|
||||
assert!(models.iter().any(|model| model["name"] == source
|
||||
&& model.get("alias").unwrap_or(&model["name"]) == route), "missing {route}: {persisted}");
|
||||
}
|
||||
assert!(models.iter().any(|model| model["name"] == source && model.get("alias").is_none()));
|
||||
assert!(persisted["openai-compatibility"][0]["models"].as_array().unwrap()
|
||||
.iter().all(|model| model.get("alias").is_none()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn desktop_backup_keeps_a_direct_legacy_id_alongside_a_new_family_route() {
|
||||
let fixture = DesktopRestoreFixture::new();
|
||||
let paths = agent_config_paths(AgentClient::ClaudeDesktop, &fixture.home);
|
||||
let before = config_images(&paths).unwrap();
|
||||
let mut after = before.clone();
|
||||
let direct = LEGACY_CLAUDE_DESKTOP_MODEL_IDS[0];
|
||||
let mut profile: serde_json::Value = serde_json::from_slice(after[2].1.as_ref().unwrap()).unwrap();
|
||||
profile["inferenceModels"] = serde_json::json!([
|
||||
{"name": CLAUDE_DESKTOP_OPUS_MODEL_ID, "labelOverride": "gpt-one"},
|
||||
{"name": direct},
|
||||
{"name": CLAUDE_DESKTOP_HAIKU_MODEL_ID, "labelOverride": "gpt-two"},
|
||||
]);
|
||||
after[2].1 = Some(serde_json::to_vec(&profile).unwrap());
|
||||
let mappings = ClaudeDesktopModelMappings {
|
||||
opus: "gpt-one".into(),
|
||||
sonnet: direct.into(),
|
||||
haiku: "gpt-two".into(),
|
||||
..ClaudeDesktopModelMappings::all("")
|
||||
};
|
||||
commit_config_with_mappings("claude-desktop", &paths, &before, &after,
|
||||
"update", Some(direct.into()), Some(mappings)).unwrap();
|
||||
let backup = create_backup("claude-desktop", &fixture.home).unwrap();
|
||||
let initial = format!("openai-compatibility:\n - name: provider\n models: [{{name: gpt-one}}, {{name: gpt-two}}, {{name: {direct}}}]\n");
|
||||
let core = MockCore::new(&initial, Failure::None);
|
||||
let plan = prepare_restore_plan(&core.config, "claude-desktop", &fixture.home, &backup.id)
|
||||
.await.unwrap();
|
||||
let revision = plan.preview.revision.clone();
|
||||
execute_restore_plan(&core.config, plan, &revision).await.unwrap();
|
||||
assert_eq!(config_images(&paths).unwrap(), after);
|
||||
let (persisted, _) = core.finish();
|
||||
let models = persisted["openai-compatibility"][0]["models"].as_array().unwrap();
|
||||
assert!(models.iter().any(|model| model["name"] == direct && model.get("alias").is_none()));
|
||||
for (alias, source) in [(CLAUDE_DESKTOP_OPUS_MODEL_ID, "gpt-one"), (CLAUDE_DESKTOP_HAIKU_MODEL_ID, "gpt-two")] {
|
||||
assert!(models.iter().any(|model| model["name"] == source && model["alias"] == alias));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn desktop_custom_backup_restores_aliases_profile_and_mapping_metadata() {
|
||||
let fixture = DesktopRestoreFixture::build(true);
|
||||
let core = MockCore::new(&fixture.core_b, Failure::None);
|
||||
let plan = prepare_restore_plan(&core.config, "claude-desktop", &fixture.home, &fixture.id)
|
||||
.await.unwrap();
|
||||
assert!(plan.preview.differences.iter().any(|difference|
|
||||
difference.field == "modelMappings.claude-sonnet-4-6-model-a"));
|
||||
assert!(plan.preview.differences.iter().any(|difference|
|
||||
difference.field == "modelMappings.claude-sonnet-4-6-model-b"));
|
||||
let revision = plan.preview.revision.clone();
|
||||
execute_restore_plan(&core.config, plan, &revision).await.unwrap();
|
||||
let mappings = current_desktop_mappings(&fixture.home).unwrap();
|
||||
let entries = mappings.desktop_models.unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].model, "model-a");
|
||||
assert_eq!(entries[0].alias, "claude-sonnet-4-6-model-a");
|
||||
assert!(entries[0].context_1m);
|
||||
let paths = agent_config_paths(AgentClient::ClaudeDesktop, &fixture.home);
|
||||
let profile: serde_json::Value = serde_json::from_slice(&fs::read(&paths[2]).unwrap()).unwrap();
|
||||
assert_eq!(profile["inferenceModels"][0]["name"], entries[0].alias);
|
||||
assert_eq!(profile["inferenceModels"][0]["labelOverride"], "model-a");
|
||||
let (persisted, _) = core.finish();
|
||||
assert_eq!(persisted, yaml_json(&fixture.core_a));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn desktop_direct_model_backup_restores_without_an_alias_route() {
|
||||
let fixture = DesktopRestoreFixture::build_with_direct_model(true, true);
|
||||
let core = MockCore::new(&fixture.core_b, Failure::None);
|
||||
let plan = prepare_restore_plan(&core.config, "claude-desktop", &fixture.home, &fixture.id)
|
||||
.await.unwrap();
|
||||
let revision = plan.preview.revision.clone();
|
||||
execute_restore_plan(&core.config, plan, &revision).await.unwrap();
|
||||
let mappings = current_desktop_mappings(&fixture.home).unwrap();
|
||||
let entry = &mappings.desktop_models.as_ref().unwrap()[0];
|
||||
assert!(entry.alias.is_empty());
|
||||
assert_eq!(entry.model, "model-a");
|
||||
assert_eq!(mappings.sonnet, entry.model);
|
||||
let paths = agent_config_paths(AgentClient::ClaudeDesktop, &fixture.home);
|
||||
let profile: serde_json::Value = serde_json::from_slice(&fs::read(&paths[2]).unwrap()).unwrap();
|
||||
assert_eq!(profile["inferenceModels"][0]["name"], entry.model);
|
||||
assert_eq!(profile["inferenceModels"][0]["labelOverride"], entry.model);
|
||||
let (persisted, _) = core.finish();
|
||||
assert_eq!(persisted, yaml_json(&fixture.core_a));
|
||||
assert!(!persisted.to_string().contains("\"alias\""));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn desktop_backup_restore_rejects_core_changes_since_preview() {
|
||||
let fixture = DesktopRestoreFixture::new();
|
||||
|
||||
@@ -5,6 +5,229 @@ fn json(content: &str) -> serde_json::Value {
|
||||
serde_json::to_value(serde_norway::from_str::<serde_norway::Value>(content).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn custom_mappings(entries: &[(&str, &str)]) -> ClaudeDesktopModelMappings {
|
||||
ClaudeDesktopModelMappings {
|
||||
desktop_models: Some(entries.iter().map(|(model, alias)| ClaudeDesktopModelMapping {
|
||||
model: (*model).into(), alias: (*alias).into(), context_1m: false,
|
||||
}).collect()),
|
||||
..ClaudeDesktopModelMappings::all("")
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_aliases_reject_other_model_families() {
|
||||
for alias in ["claude-grok-4.6", "claude-opus-gpt-5", "claude-gemini",
|
||||
"claude-deepseek", "claude-opus-phi4", "claude-opus-k2.5", "claude-m2.1",
|
||||
"claude-ling", "claude-unic", "claude-ds-test"] {
|
||||
assert!(!valid_claude_desktop_alias(alias), "{alias}");
|
||||
assert!(!is_claude_native_model_id(alias), "{alias}");
|
||||
let mappings = custom_mappings(&[("gpt-one", alias)]);
|
||||
assert!(validate_claude_desktop_entries(mappings.desktop_models.as_ref().unwrap()).is_err());
|
||||
}
|
||||
for alias in ["claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5",
|
||||
"claude-fable-5-1", "claude-opus-personal", "claude-linguist",
|
||||
"claude-unicorn", "claude-ranking2.1"] {
|
||||
assert!(valid_claude_desktop_alias(alias), "{alias}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_replacing_a_previously_allowed_alias_removes_its_old_route() {
|
||||
let input = "codex-api-key:\n - models:\n - name: gpt-one\n - name: gpt-one\n alias: claude-grok-4.6\n display-name: EasyCLIProxyAPI managed Claude Desktop mapping\n";
|
||||
let mappings = custom_mappings(&[("gpt-one", "claude-opus-personal")]);
|
||||
let after = json(&ensure_claude_desktop_model_aliases_in_yaml(input, &mappings, &[]).unwrap());
|
||||
let models = after["codex-api-key"][0]["models"].as_array().unwrap();
|
||||
assert_eq!(models.len(), 2);
|
||||
assert_eq!(models[1]["alias"], "claude-opus-personal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_unchanged_alias_sync_preserves_legacy_yaml_verbatim() {
|
||||
let input = "# Existing core configuration\nrouting:\n strategy: round-robin\n# Retention duration\n\n session-affinity-ttl: ''\n# Inherit parent sessions\n\n session-affinity-subagents: true\ncodex-api-key:\n - api-key: preserved-key\n models:\n - name: model-a\n - name: model-a\n alias: claude-opus-custom\n display-name: EasyCLIProxyAPI managed Claude Desktop mapping\n";
|
||||
for mappings in [
|
||||
custom_mappings(&[("model-a", "claude-opus-custom")]),
|
||||
custom_mappings(&[("claude-opus-custom", "")]),
|
||||
ClaudeDesktopModelMappings::all("claude-opus-custom"),
|
||||
] {
|
||||
let updated = ensure_claude_desktop_model_aliases_in_yaml(input, &mappings, &[]).unwrap();
|
||||
assert_eq!(updated, input);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_empty_alias_uses_original_id_without_creating_a_mapping() {
|
||||
let mappings = custom_mappings(&[(" claude-sonnet-custom-7 ", " ")]);
|
||||
let model = resolve_agent_configuration_model(
|
||||
AgentClient::ClaudeDesktop, &[], "", Some(&mappings),
|
||||
).unwrap();
|
||||
assert_eq!(model, "claude-sonnet-custom-7");
|
||||
let mappings = resolve_claude_desktop_model_mappings(
|
||||
AgentClient::ClaudeDesktop, &[], &model, Some(mappings),
|
||||
).unwrap().unwrap();
|
||||
assert_eq!(mappings.sonnet, model);
|
||||
assert_eq!(mappings.desktop_models.as_ref().unwrap()[0].model, model);
|
||||
assert!(mappings.desktop_models.as_ref().unwrap()[0].alias.is_empty());
|
||||
let profile: serde_json::Value = serde_json::from_str(&build_claude_desktop_profile(
|
||||
None, "http://localhost:8317", "key", &model, &[], Some(&mappings),
|
||||
).unwrap()).unwrap();
|
||||
assert_eq!(profile["inferenceModels"][0]["name"], model);
|
||||
assert_eq!(profile["inferenceModels"][0]["labelOverride"], model);
|
||||
let input = "codex-api-key:\n - models: [{name: gpt-one}]\n";
|
||||
let updated = ensure_claude_desktop_model_aliases_in_yaml(input, &mappings, &[]).unwrap();
|
||||
assert_eq!(json(&updated), json(input));
|
||||
assert!(resolve_agent_configuration_model(
|
||||
AgentClient::ClaudeCode, &[], &model, Some(&mappings),
|
||||
).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_legacy_alias_only_entry_resolves_to_original_with_empty_alias() {
|
||||
let mappings = custom_mappings(&[("", "claude-sonnet-custom-7")]);
|
||||
let model = resolve_agent_configuration_model(
|
||||
AgentClient::ClaudeDesktop, &[], "", Some(&mappings),
|
||||
).unwrap();
|
||||
let resolved = resolve_claude_desktop_model_mappings(
|
||||
AgentClient::ClaudeDesktop, &[], &model, Some(mappings),
|
||||
).unwrap().unwrap();
|
||||
let entry = &resolved.desktop_models.as_ref().unwrap()[0];
|
||||
assert_eq!(entry.model, "claude-sonnet-custom-7");
|
||||
assert!(entry.alias.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_clearing_alias_removes_the_old_mapping_and_keeps_original_and_other_rows() {
|
||||
let input = "codex-api-key:\n - models: [{name: gpt-one}, {name: gpt-two}, {name: claude-opus-4-6}]\n";
|
||||
let models = test_agent_models(&["gpt-one", "gpt-two", "claude-opus-4-6"]);
|
||||
let mapped = custom_mappings(&[("gpt-one", "claude-sonnet-4-6"), ("gpt-two", "claude-haiku-4-5")]);
|
||||
let before = ensure_claude_desktop_model_aliases_in_yaml(input, &mapped, &models).unwrap();
|
||||
let direct = custom_mappings(&[("gpt-one", ""), ("gpt-two", "claude-haiku-4-5"), ("claude-opus-4-6", "")]);
|
||||
let updated = ensure_claude_desktop_model_aliases_in_yaml(&before, &direct, &models).unwrap();
|
||||
let entries = json(&updated)["codex-api-key"][0]["models"].as_array().unwrap().clone();
|
||||
assert_eq!(entries.len(), 4);
|
||||
assert!(!entries.iter().any(|entry| entry["alias"] == "claude-sonnet-4-6"));
|
||||
assert!(entries.iter().any(|entry| entry["name"] == "gpt-one" && entry.get("alias").is_none()));
|
||||
assert!(entries.iter().any(|entry| entry["name"] == "gpt-two" && entry["alias"] == "claude-haiku-4-5"));
|
||||
assert!(entries.iter().any(|entry| entry["name"] == "claude-opus-4-6"));
|
||||
let profile: serde_json::Value = serde_json::from_str(&build_claude_desktop_profile(
|
||||
None, "http://localhost:8317", "key", "gpt-one", &models, Some(&direct),
|
||||
).unwrap()).unwrap();
|
||||
assert_eq!(profile["inferenceModels"][0]["name"], "gpt-one");
|
||||
assert_eq!(profile["inferenceModels"][0]["labelOverride"], "gpt-one");
|
||||
assert_eq!(profile["inferenceModels"][1]["name"], "claude-haiku-4-5");
|
||||
assert_eq!(profile["inferenceModels"][2]["name"], "claude-opus-4-6");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_custom_list_routes_every_selected_model_and_displays_original_names() {
|
||||
let mappings = custom_mappings(&[
|
||||
("gpt-one", "claude-opus-4-6"), ("gpt-two", "claude-sonnet-4-6"),
|
||||
("gpt-one", "claude-haiku-4-5"), ("gpt-two", "claude-sonnet-4-5"),
|
||||
]);
|
||||
let models = test_agent_models(&["gpt-one", "gpt-two"]);
|
||||
let profile: serde_json::Value = serde_json::from_str(&build_claude_desktop_profile(
|
||||
None, "http://localhost:8317", "test-key", "gpt-one", &models, Some(&mappings),
|
||||
).unwrap()).unwrap();
|
||||
let entries = profile["inferenceModels"].as_array().unwrap();
|
||||
assert_eq!(entries.len(), 4);
|
||||
assert_eq!(entries[0]["name"], "claude-opus-4-6");
|
||||
assert_eq!(entries[0]["labelOverride"], "gpt-one");
|
||||
assert_eq!(entries[1]["isFamilyDefault"], true);
|
||||
assert_eq!(entries[3]["isFamilyDefault"], false);
|
||||
let input = "codex-api-key:\n - models: [{name: gpt-one}, {name: gpt-two}]\n";
|
||||
let routed = ensure_claude_desktop_model_aliases_in_yaml(input, &mappings, &models).unwrap();
|
||||
let after = json(&routed);
|
||||
assert_eq!(after["codex-api-key"][0]["models"].as_array().unwrap().len(), 6);
|
||||
for entry in mappings.desktop_models.unwrap() {
|
||||
assert!(after["codex-api-key"][0]["models"].as_array().unwrap().iter()
|
||||
.any(|model| model["alias"] == entry.alias && model["name"] == entry.model));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_custom_alias_edits_remove_old_managed_routes_and_preserve_user_routes() {
|
||||
let input = "codex-api-key:\n - models: [{name: gpt-one}, {name: gpt-two}, {name: gpt-one, alias: claude-user-model}]\n";
|
||||
let models = test_agent_models(&["gpt-one", "gpt-two", "claude-user-model"]);
|
||||
let first = ensure_claude_desktop_model_aliases_in_yaml(input,
|
||||
&custom_mappings(&[("gpt-one", "claude-opus-4-6"), ("gpt-two", "claude-sonnet-4-6")]), &models).unwrap();
|
||||
let second = ensure_claude_desktop_model_aliases_in_yaml(&first,
|
||||
&custom_mappings(&[("gpt-two", "claude-haiku-4-5")]), &models).unwrap();
|
||||
let entries = json(&second)["codex-api-key"][0]["models"].as_array().unwrap().clone();
|
||||
assert_eq!(entries.len(), 4);
|
||||
assert!(entries.iter().any(|m| m["alias"] == "claude-user-model"));
|
||||
assert!(!entries.iter().any(|m| m["alias"] == "claude-opus-4-6" || m["alias"] == "claude-sonnet-4-6"));
|
||||
assert!(ensure_claude_desktop_model_aliases_in_yaml(input,
|
||||
&custom_mappings(&[("gpt-two", "claude-user-model")]), &models).unwrap_err().contains("已被其他模型使用"));
|
||||
assert!(ensure_claude_desktop_model_aliases_in_yaml(input,
|
||||
&custom_mappings(&[("gpt-two", "claude-native-model")]),
|
||||
&test_agent_models(&["gpt-two", "claude-native-model"])).is_err());
|
||||
let duplicate = format!("{first}openai-compatibility:\n - models: [{{name: claude-opus-4-6}}]\n");
|
||||
assert!(ensure_claude_desktop_model_aliases_in_yaml(&duplicate,
|
||||
&custom_mappings(&[("gpt-two", "claude-opus-4-6")]), &models).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_custom_mapping_validates_aliases_and_accepts_one_model() {
|
||||
let models = test_agent_models(&["gpt-one"]);
|
||||
let resolved = resolve_claude_desktop_model_mappings(AgentClient::ClaudeDesktop, &models, "gpt-one",
|
||||
Some(custom_mappings(&[("gpt-one", " claude-sonnet-4-6 ")]))).unwrap().unwrap();
|
||||
assert_eq!(resolved.sonnet, "gpt-one");
|
||||
assert_eq!(resolved.desktop_models.as_ref().unwrap()[0].alias, "claude-sonnet-4-6");
|
||||
for entries in [vec![], vec![("", "")], vec![("gpt-one", "gpt-alias")], vec![("two words", "claude-sonnet-4-6")],
|
||||
vec![("claude-sonnet-4-6", ""), ("gpt-one", "claude-sonnet-4-6")],
|
||||
vec![("gpt-one", "claude-sonnet-4-6"), ("gpt-one", "claude-sonnet-4-6")]] {
|
||||
assert!(resolve_claude_desktop_model_mappings(AgentClient::ClaudeDesktop, &models, "gpt-one",
|
||||
Some(custom_mappings(&entries))).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_manual_source_can_create_an_alias_when_missing_from_the_loaded_model_list() {
|
||||
let mappings = custom_mappings(&[(" manual-model ", "claude-opus-5")]);
|
||||
let model = resolve_agent_configuration_model(
|
||||
AgentClient::ClaudeDesktop, &[], "", Some(&mappings),
|
||||
).unwrap();
|
||||
assert_eq!(model, "manual-model");
|
||||
let mappings = resolve_claude_desktop_model_mappings(
|
||||
AgentClient::ClaudeDesktop, &[], &model, Some(mappings),
|
||||
).unwrap().unwrap();
|
||||
let input = "openai-compatibility:\n - name: manual-provider\n models: [{name: manual-model}]\n";
|
||||
let routed = json(&ensure_claude_desktop_model_aliases_in_yaml(input, &mappings, &[]).unwrap());
|
||||
assert!(routed["openai-compatibility"][0]["models"].as_array().unwrap().iter()
|
||||
.any(|entry| entry["name"] == model && entry["alias"] == "claude-opus-5"));
|
||||
assert!(ensure_claude_desktop_model_aliases_in_yaml("{}", &mappings, &[])
|
||||
.unwrap_err().contains("无法确定模型 manual-model"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_same_original_and_alias_is_normalized_to_no_alias() {
|
||||
let mappings = custom_mappings(&[("anthropic/claude-opus-5", " ANTHROPIC/CLAUDE-OPUS-5 ")]);
|
||||
let resolved = resolve_claude_desktop_model_mappings(
|
||||
AgentClient::ClaudeDesktop, &[], "", Some(mappings),
|
||||
).unwrap().unwrap();
|
||||
let entry = &resolved.desktop_models.as_ref().unwrap()[0];
|
||||
assert_eq!(entry.model, "anthropic/claude-opus-5");
|
||||
assert!(entry.alias.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_custom_aliases_preserve_source_alias_options_and_context() {
|
||||
let input = "codex-api-key:\n - models: [{name: gpt-one, alias: gpt-high, reasoning-effort: high}]\n";
|
||||
let mut models = test_agent_models(&["gpt-high"]);
|
||||
models[0].is_alias = true;
|
||||
models[0].alias = Some("gpt-one".into());
|
||||
let mut mappings = custom_mappings(&[("gpt-high", "claude-opus-4-6")]);
|
||||
mappings.desktop_models.as_mut().unwrap()[0].context_1m = true;
|
||||
let after = json(&ensure_claude_desktop_model_aliases_in_yaml(input, &mappings, &models).unwrap());
|
||||
let entry = &after["codex-api-key"][0]["models"][1];
|
||||
assert_eq!(entry["name"], "gpt-one");
|
||||
assert_eq!(entry["reasoning-effort"], "high");
|
||||
let profile: serde_json::Value = serde_json::from_str(&build_claude_desktop_profile(
|
||||
None, "http://localhost:8317", "key", "gpt-high", &models, Some(&mappings)).unwrap()).unwrap();
|
||||
assert_eq!(profile["inferenceModels"][0]["name"], "claude-opus-4-6");
|
||||
assert_eq!(profile["inferenceModels"][0]["labelOverride"], "gpt-high");
|
||||
assert_eq!(profile["inferenceModels"][0]["supports1m"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_routes_preserve_api_access_when_switching_models() {
|
||||
let input = "codex-api-key:\n - api-key: codex-test-key\n base-url: https://codex.example.test\n proxy-url: socks5://127.0.0.1:1080\n models: [{name: model-a}, {name: model-b}]\nopenai-compatibility:\n - name: other\n disabled: true\n api-key-entries: [{api-key: other-test-key}]\n models: [{name: other-model}]\npayload:\n override:\n - models: [{name: other-model}]\n params: {custom: retained}\n";
|
||||
@@ -34,6 +257,91 @@ fn desktop_routes_preserve_api_access_when_switching_models() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_route_name_collision_keeps_the_existing_real_model() {
|
||||
let input = format!(
|
||||
"codex-api-key:\n - models: [{{name: {route}}}]\n",
|
||||
route = CLAUDE_DESKTOP_OPUS_MODEL_ID,
|
||||
);
|
||||
let models = test_agent_models(&[
|
||||
CLAUDE_DESKTOP_OPUS_MODEL_ID,
|
||||
CLAUDE_DESKTOP_SONNET_MODEL_ID,
|
||||
CLAUDE_DESKTOP_HAIKU_MODEL_ID,
|
||||
]);
|
||||
let rendered = ensure_claude_desktop_model_aliases_in_yaml(
|
||||
&input,
|
||||
&ClaudeDesktopModelMappings {
|
||||
opus: CLAUDE_DESKTOP_OPUS_MODEL_ID.to_string(),
|
||||
sonnet: CLAUDE_DESKTOP_SONNET_MODEL_ID.to_string(),
|
||||
haiku: CLAUDE_DESKTOP_HAIKU_MODEL_ID.to_string(),
|
||||
..ClaudeDesktopModelMappings::all("")
|
||||
},
|
||||
&models,
|
||||
)
|
||||
.unwrap();
|
||||
let after = json(&rendered);
|
||||
let entries = after["codex-api-key"][0]["models"].as_array().unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0]["name"], CLAUDE_DESKTOP_OPUS_MODEL_ID);
|
||||
assert!(entries[0].get("alias").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_route_name_collision_keeps_existing_legacy_claude_model() {
|
||||
let input = format!(
|
||||
"codex-api-key:\n - models: [{{name: {legacy}}}]\n",
|
||||
legacy = LEGACY_CLAUDE_DESKTOP_MODEL_IDS[0],
|
||||
);
|
||||
let models = test_agent_models(&[LEGACY_CLAUDE_DESKTOP_MODEL_IDS[0]]);
|
||||
let rendered = ensure_claude_desktop_model_aliases_in_yaml(
|
||||
&input,
|
||||
&ClaudeDesktopModelMappings::all(LEGACY_CLAUDE_DESKTOP_MODEL_IDS[0]),
|
||||
&models,
|
||||
)
|
||||
.unwrap();
|
||||
let after = json(&rendered);
|
||||
let entries = after["codex-api-key"][0]["models"].as_array().unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0]["name"], LEGACY_CLAUDE_DESKTOP_MODEL_IDS[0]);
|
||||
assert!(entries[0].get("alias").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_routes_skip_excluded_sources_and_aliases() {
|
||||
let mappings = custom_mappings(&[("model-a", "claude-opus-5")]);
|
||||
for excluded in ["*", "model-a", "model-*", "claude-opus-5", " CLAUDE-* ", "*-opus-*", "claude-*-5"] {
|
||||
let input = format!("codex-api-key:\n - api-key: excluded-key\n excluded-models: ['{excluded}']\n models: [{{name: model-a}}]\n - api-key: active-key\n models: [{{name: model-a, context-length: 123456}}]\n");
|
||||
let updated = ensure_claude_desktop_model_aliases_in_yaml(&input, &mappings, &[]).unwrap();
|
||||
let after = json(&updated);
|
||||
assert_eq!(after["codex-api-key"][0], json(&input)["codex-api-key"][0], "{excluded}");
|
||||
assert_eq!(after["codex-api-key"][1]["models"][1]["alias"], "claude-opus-5", "{excluded}");
|
||||
assert_eq!(after["codex-api-key"][1]["models"][1]["context-length"], 123456);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_routes_repair_managed_aliases_on_excluded_providers() {
|
||||
for mappings in [
|
||||
custom_mappings(&[("model-a", "claude-opus-5")]),
|
||||
ClaudeDesktopModelMappings::all("model-a"),
|
||||
] {
|
||||
let initial = "codex-api-key:\n - api-key: old-key\n models: [{name: model-a}]\n";
|
||||
let configured = ensure_claude_desktop_model_aliases_in_yaml(initial, &mappings, &[]).unwrap();
|
||||
for excluded in ["*", "claude-*"] {
|
||||
let blocked = configured.replacen("api-key: old-key", &format!("api-key: old-key\n excluded-models: ['{excluded}']"), 1);
|
||||
assert!(ensure_claude_desktop_model_aliases_in_yaml(&blocked, &mappings, &[]).is_err());
|
||||
let input = format!("{blocked} - api-key: active-key\n models: [{{name: model-a}}]\n");
|
||||
let repaired = ensure_claude_desktop_model_aliases_in_yaml(&input, &mappings, &[]).unwrap();
|
||||
let after = json(&repaired);
|
||||
assert_eq!(after["codex-api-key"][0]["excluded-models"], serde_json::json!([excluded]));
|
||||
assert_eq!(after["codex-api-key"][0]["models"], serde_json::json!([{"name": "model-a"}]));
|
||||
let expected_count = if mappings.desktop_models.is_some() { 2 } else { 4 };
|
||||
assert_eq!(after["codex-api-key"][1]["models"].as_array().unwrap().len(), expected_count);
|
||||
assert_eq!(ensure_claude_desktop_model_aliases_in_yaml(&repaired, &mappings, &[]).unwrap(), repaired);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_routes_move_from_disabled_provider_to_enabled_source() {
|
||||
let models = test_agent_models(&["grok-4.6"]);
|
||||
@@ -111,10 +419,12 @@ fn desktop_routes_reject_sources_available_only_in_disabled_providers() {
|
||||
|
||||
#[test]
|
||||
fn disabled_provider_alias_does_not_mark_an_active_real_model_as_an_alias() {
|
||||
let input = "openai-compatibility:\n - name: disabled-provider\n disabled: true\n models: [{name: other-model, alias: model-a}]\ncodex-api-key:\n - models: [{name: model-a}]\n";
|
||||
let mut models = test_agent_models(&["model-a"]);
|
||||
mark_configured_agent_model_aliases(&mut models, input).unwrap();
|
||||
assert!(!models[0].is_alias);
|
||||
for exclusions in ["disabled: true", "excluded-models: ['*']", "excluded-models: ['MODEL-*']"] {
|
||||
let input = format!("codex-api-key:\n - {exclusions}\n models: [{{name: other-model, alias: model-a}}]\n - models: [{{name: model-a}}]\n");
|
||||
let mut models = test_agent_models(&["model-a"]);
|
||||
mark_configured_agent_model_aliases(&mut models, &input).unwrap();
|
||||
assert!(!models[0].is_alias, "{exclusions}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user