use super::super::cache::write_custom_provider_catalog_cache;
use super::super::{
CachePreference, ModelCatalogEntry, cached_model_context_window, load_aggregated_catalog,
switch_model, write_catalog_cache,
};
use super::custom_provider_config_with_models_dev_provider;
use crate::config::{AuthState, CustomProviderConfig, ProviderCredential};
use crate::providers;
use std::fs;
use tempfile::TempDir;
#[test]
fn switch_model_accepts_custom_provider_extra_model_from_fresh_cache() {
let temp = TempDir::new().unwrap();
let paths = super::super::McPaths::from_root(temp.path().join("mc"));
let custom = CustomProviderConfig {
extra_models: vec!["glm-5.2".to_string()],
..custom_provider_config_with_models_dev_provider(None)
};
crate::config::write_settings(
&paths,
&crate::config::Settings {
custom_providers: std::collections::BTreeMap::from([(
"zai".to_string(),
custom.clone(),
)]),
..Default::default()
},
)
.unwrap();
write_custom_provider_catalog_cache(
&paths,
"zai",
&custom,
&[
ModelCatalogEntry::new("zai", "glm-5.1"),
ModelCatalogEntry::new("zai", "glm-5.2"),
],
)
.unwrap();
let auth = AuthState::Missing {
provider: "zai".to_string(),
};
let (_provider, model, notice) = switch_model(&paths, &auth, "zai/glm-5.2").unwrap();
assert_eq!(model, "glm-5.2");
assert!(notice.is_none());
let saved = crate::config::read_settings(&paths).unwrap();
assert_eq!(saved.selected_model.provider.as_deref(), Some("zai"));
assert_eq!(saved.selected_model.model.as_deref(), Some("glm-5.2"));
}
#[test]
fn switch_model_rejects_missing_custom_provider_extra_model() {
let temp = TempDir::new().unwrap();
let paths = super::super::McPaths::from_root(temp.path().join("mc"));
crate::config::write_settings(
&paths,
&crate::config::Settings {
custom_providers: std::collections::BTreeMap::from([(
"zai".to_string(),
custom_provider_config_with_models_dev_provider(None),
)]),
..Default::default()
},
)
.unwrap();
let settings = crate::config::read_settings(&paths).unwrap();
let custom = settings.custom_providers.get("zai").unwrap();
write_custom_provider_catalog_cache(
&paths,
"zai",
custom,
&[ModelCatalogEntry::new("zai", "glm-5.1")],
)
.unwrap();
let auth = AuthState::Missing {
provider: "zai".to_string(),
};
let error = switch_model(&paths, &auth, "zai/glm-5.2").unwrap_err();
assert!(
error
.to_string()
.contains("model 'zai/glm-5.2' is not available"),
"{error}"
);
}
#[test]
fn model_catalog_switch_uses_fresh_cache_and_preserves_settings() {
let temp = TempDir::new().unwrap();
let paths = super::super::McPaths::from_root(temp.path().join("mc"));
let settings = crate::config::Settings {
selected_model: crate::config::SelectedModelSettings {
provider: Some(providers::OPENAI_CODEX_PROVIDER.to_string()),
..Default::default()
},
no_color: Some(true),
..Default::default()
};
crate::config::write_settings(&paths, &settings).unwrap();
write_catalog_cache(
&paths,
providers::OPENAI_CODEX_PROVIDER,
&[ModelCatalogEntry::new_codex("gpt-next")],
)
.unwrap();
let auth = AuthState::Missing {
provider: providers::OPENAI_CODEX_PROVIDER.to_string(),
};
let (_provider, model, notice) = switch_model(&paths, &auth, "openai-codex/gpt-next").unwrap();
assert_eq!(model, "gpt-next");
assert!(notice.is_none());
let saved = crate::config::read_settings(&paths).unwrap();
assert_eq!(
saved.selected_model.provider.as_deref(),
Some(providers::OPENAI_CODEX_PROVIDER)
);
assert_eq!(saved.selected_model.model.as_deref(), Some("gpt-next"));
assert_eq!(saved.no_color, Some(true));
}
#[test]
fn model_catalog_switch_normalizes_openai_codex_short_aliases() {
let temp = TempDir::new().unwrap();
let paths = super::super::McPaths::from_root(temp.path().join("mc"));
write_catalog_cache(
&paths,
providers::OPENAI_CODEX_PROVIDER,
&[
ModelCatalogEntry::new_codex("gpt-5.6-luna"),
ModelCatalogEntry::new_codex("gpt-5.6-sol"),
],
)
.unwrap();
let auth = AuthState::Missing {
provider: providers::OPENAI_CODEX_PROVIDER.to_string(),
};
let (_provider, model, notice) = switch_model(&paths, &auth, "openai-codex/luna").unwrap();
assert_eq!(model, "gpt-5.6-luna");
assert!(notice.is_none());
let saved = crate::config::read_settings(&paths).unwrap();
assert_eq!(saved.selected_model.model.as_deref(), Some("gpt-5.6-luna"));
let (_provider, model, notice) = switch_model(&paths, &auth, "openai-codex/gpt-5.6").unwrap();
assert_eq!(model, "gpt-5.6-sol");
assert!(notice.is_none());
}
#[test]
fn model_catalog_switch_preserves_unknown_top_level_settings_fields() {
let temp = TempDir::new().unwrap();
let paths = super::super::McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(
&paths.settings_file,
r#"{"provider":"openai-codex","future_setting":{"keep":true},"model":"old-model"}"#,
)
.unwrap();
write_catalog_cache(
&paths,
providers::OPENAI_CODEX_PROVIDER,
&[ModelCatalogEntry::new_codex("gpt-next")],
)
.unwrap();
let auth = AuthState::Missing {
provider: providers::OPENAI_CODEX_PROVIDER.to_string(),
};
let (_provider, model, notice) = switch_model(&paths, &auth, "openai-codex/gpt-next").unwrap();
assert_eq!(model, "gpt-next");
assert!(notice.is_none());
let value: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&paths.settings_file).unwrap()).unwrap();
assert_eq!(
value["agent"]["model"]["provider"],
providers::OPENAI_CODEX_PROVIDER
);
assert_eq!(value["agent"]["model"]["model"], "gpt-next");
assert_eq!(value["future_setting"]["keep"], true);
}
#[test]
fn model_catalog_switch_resolves_target_provider_not_active_provider() {
let temp = TempDir::new().unwrap();
let paths = super::super::McPaths::from_root(temp.path().join("mc"));
let settings = crate::config::Settings {
selected_model: crate::config::SelectedModelSettings {
provider: Some("openai".to_string()),
model: Some("gpt-4o".to_string()),
..Default::default()
},
..Default::default()
};
crate::config::write_settings(&paths, &settings).unwrap();
write_catalog_cache(
&paths,
providers::OPENAI_CODEX_PROVIDER,
&[ModelCatalogEntry::new_codex("gpt-next")],
)
.unwrap();
let auth = AuthState::Missing {
provider: "openai".to_string(),
};
let (_provider, model, notice) = switch_model(&paths, &auth, "openai-codex/gpt-next").unwrap();
assert_eq!(model, "gpt-next");
assert!(notice.is_none());
let saved = crate::config::read_settings(&paths).unwrap();
assert_eq!(
saved.selected_model.provider.as_deref(),
Some(providers::OPENAI_CODEX_PROVIDER)
);
assert_eq!(saved.selected_model.model.as_deref(), Some("gpt-next"));
}
#[test]
fn model_catalog_no_cache_failure_does_not_persist() {
let temp = TempDir::new().unwrap();
let paths = super::super::McPaths::from_root(temp.path().join("mc"));
let auth = AuthState::Missing {
provider: providers::OPENAI_CODEX_PROVIDER.to_string(),
};
let error = switch_model(&paths, &auth, "openai-codex/gpt-next").unwrap_err();
assert!(
error
.to_string()
.contains("openai-codex model discovery unavailable")
);
assert_eq!(
crate::config::read_settings(&paths)
.unwrap()
.selected_model
.model,
None
);
}
#[test]
fn switch_model_rejects_retired_claude_code_before_auth_or_catalog_guidance() {
let temp = TempDir::new().unwrap();
let paths = super::super::McPaths::from_root(temp.path().join("mc"));
let auth = AuthState::Missing {
provider: "claude-code".to_string(),
};
let error = switch_model(&paths, &auth, "claude-code/sonnet")
.unwrap_err()
.to_string();
assert!(
error.contains("stale provider selection 'claude-code/sonnet'"),
"{error}"
);
assert!(error.contains("select 'anthropic/sonnet'"), "{error}");
assert!(error.contains("ANTHROPIC_API_KEY"), "{error}");
assert!(!error.contains("missing auth"), "{error}");
assert!(!error.contains("custom provider"), "{error}");
assert!(!error.contains("model discovery"), "{error}");
}
#[test]
fn switch_model_rejects_retired_provider_before_disabled_model_check() {
let temp = TempDir::new().unwrap();
let paths = super::super::McPaths::from_root(temp.path().join("mc"));
crate::config::set_model_disabled_for_scope(
&paths,
crate::config::SettingsScope::Global,
"claude-code/sonnet",
true,
)
.unwrap();
let auth = AuthState::Missing {
provider: "claude-code".to_string(),
};
let error = switch_model(&paths, &auth, "claude-code/sonnet")
.unwrap_err()
.to_string();
assert!(
error.contains("stale provider selection 'claude-code/sonnet'"),
"{error}"
);
assert!(error.contains("select 'anthropic/sonnet'"), "{error}");
assert!(error.contains("ANTHROPIC_API_KEY"), "{error}");
assert!(!error.contains("cannot select disabled model"), "{error}");
}
#[test]
fn switch_model_rejects_disabled_anthropic_and_custom_models() {
for (provider, model) in [("anthropic", "claude-test"), ("local-ai", "local-test")] {
let temp = TempDir::new().unwrap();
let paths = super::super::McPaths::from_root(temp.path().join("mc"));
if provider == "local-ai" {
crate::config::write_settings(
&paths,
&crate::config::Settings {
custom_providers: std::collections::BTreeMap::from([(
provider.to_string(),
custom_provider_config_with_models_dev_provider(None),
)]),
..Default::default()
},
)
.unwrap();
}
let model_id = format!("{provider}/{model}");
crate::config::set_model_disabled_for_scope(
&paths,
crate::config::SettingsScope::Global,
&model_id,
true,
)
.unwrap();
let auth = AuthState::Missing {
provider: provider.to_string(),
};
let error = switch_model(&paths, &auth, &model_id)
.unwrap_err()
.to_string();
assert_eq!(error, format!("cannot select disabled model: {model_id}"));
}
}
#[test]
fn switch_model_checks_disabled_models_using_normalized_codex_id() {
let temp = TempDir::new().unwrap();
let paths = super::super::McPaths::from_root(temp.path().join("mc"));
write_catalog_cache(
&paths,
providers::OPENAI_CODEX_PROVIDER,
&[ModelCatalogEntry::new_codex("gpt-5.6-luna")],
)
.unwrap();
crate::config::set_model_disabled_for_scope(
&paths,
crate::config::SettingsScope::Global,
"openai-codex/gpt-5.6-luna",
true,
)
.unwrap();
let auth = AuthState::Missing {
provider: providers::OPENAI_CODEX_PROVIDER.to_string(),
};
let error = switch_model(&paths, &auth, "openai-codex/luna")
.unwrap_err()
.to_string();
assert_eq!(
error,
"cannot select disabled model: openai-codex/gpt-5.6-luna"
);
}
#[test]
fn switch_model_accepts_anthropic_catalog_model() {
let temp = TempDir::new().unwrap();
let paths = super::super::McPaths::from_root(temp.path().join("mc"));
let mut entry = ModelCatalogEntry::new(providers::ANTHROPIC_PROVIDER, "claude-test");
entry.context_window = Some(200_000);
write_catalog_cache(&paths, providers::ANTHROPIC_PROVIDER, &[entry]).unwrap();
let mut stored_auth = crate::config::Auth::default();
stored_auth.providers.insert(
providers::ANTHROPIC_PROVIDER.to_string(),
crate::config::AuthProviderRecord::ApiKey {
key: "anthropic-key".to_string(),
},
);
crate::config::write_auth(&paths, &stored_auth).unwrap();
let auth = AuthState::Ready {
provider: providers::ANTHROPIC_PROVIDER.to_string(),
credential: ProviderCredential::ApiKey {
key: "anthropic-key".to_string(),
},
};
let (_provider, model, notice) = switch_model(&paths, &auth, "anthropic/claude-test").unwrap();
assert_eq!(model, "claude-test");
assert!(notice.is_none());
let saved = crate::config::read_settings(&paths).unwrap();
assert_eq!(
saved.selected_model.provider.as_deref(),
Some(providers::ANTHROPIC_PROVIDER)
);
assert_eq!(
cached_model_context_window(&paths, providers::ANTHROPIC_PROVIDER, "claude-test"),
Some(200_000)
);
}
#[test]
fn custom_model_switch_uses_target_fresh_cache_without_live_discovery() {
let temp = TempDir::new().unwrap();
let paths = super::super::McPaths::from_root(temp.path().join("mc"));
crate::config::write_settings(
&paths,
&crate::config::Settings {
custom_providers: std::collections::BTreeMap::from([
(
"local-ai".to_string(),
CustomProviderConfig {
label: "Local AI".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(),
fast_mode: None,
api_key_env_var: None,
models_dev_provider: None,
use_responses_endpoint: false,
supports_text_verbosity: false,
reasoning_protocol: crate::config::CustomReasoningProtocol::default(),
extra_models: Vec::new(),
request_headers: Default::default(),
},
),
(
"other-ai".to_string(),
CustomProviderConfig {
label: "Other AI".to_string(),
base_url: "http://127.0.0.1:2/v1".to_string(),
fast_mode: None,
api_key_env_var: None,
models_dev_provider: None,
use_responses_endpoint: false,
supports_text_verbosity: false,
reasoning_protocol: crate::config::CustomReasoningProtocol::default(),
extra_models: Vec::new(),
request_headers: Default::default(),
},
),
]),
..Default::default()
},
)
.unwrap();
let settings = crate::config::read_settings(&paths).unwrap();
let custom = settings.custom_providers.get("local-ai").unwrap();
write_custom_provider_catalog_cache(
&paths,
"local-ai",
custom,
&[ModelCatalogEntry::new("local-ai", "cached-model")],
)
.unwrap();
let auth = AuthState::Missing {
provider: "local-ai".to_string(),
};
let (_provider, model, notice) = switch_model(&paths, &auth, "local-ai/cached-model").unwrap();
assert_eq!(model, "cached-model");
assert!(notice.is_none());
let saved = crate::config::read_settings(&paths).unwrap();
assert_eq!(saved.selected_model.provider.as_deref(), Some("local-ai"));
assert_eq!(saved.selected_model.model.as_deref(), Some("cached-model"));
}
#[test]
fn model_catalog_switch_rejects_uppercase_unsupported_provider() {
let temp = TempDir::new().unwrap();
let paths = super::super::McPaths::from_root(temp.path().join("mc"));
let auth = AuthState::Missing {
provider: "OpenAI".to_string(),
};
let error = switch_model(&paths, &auth, "OpenAI/gpt-5.5").unwrap_err();
assert!(
error.to_string().contains("unsupported provider 'OpenAI'"),
"{error}"
);
}
#[test]
fn custom_provider_missing_env_is_not_selectable_from_stale_cache() {
let temp = TempDir::new().unwrap();
let paths = super::super::McPaths::from_root(temp.path().join("mc"));
let env_var = "MC_TEST_MISSING_ENV";
crate::config::write_settings(
&paths,
&crate::config::Settings {
custom_providers: std::collections::BTreeMap::from([(
"local-ai".to_string(),
CustomProviderConfig {
label: "Local AI".to_string(),
base_url: "http://localhost:8080/v1".to_string(),
fast_mode: None,
api_key_env_var: Some(env_var.to_string()),
models_dev_provider: None,
use_responses_endpoint: false,
supports_text_verbosity: false,
reasoning_protocol: crate::config::CustomReasoningProtocol::default(),
extra_models: Vec::new(),
request_headers: Default::default(),
},
)]),
..Default::default()
},
)
.unwrap();
write_catalog_cache(
&paths,
"local-ai",
&[ModelCatalogEntry::new("local-ai", "stale-model")],
)
.unwrap();
let catalog = load_aggregated_catalog(&paths, CachePreference::AllowStale);
assert!(
catalog
.entries
.iter()
.all(|entry| entry.provider != "local-ai")
);
assert!(
catalog
.notices
.iter()
.any(|notice| notice.contains("configured but not ready") && notice.contains(env_var))
);
let auth = AuthState::Missing {
provider: "local-ai".to_string(),
};
let error = switch_model(&paths, &auth, "local-ai/stale-model").unwrap_err();
assert!(error.to_string().contains("configured but not ready"));
assert!(error.to_string().contains(env_var));
let saved = crate::config::read_settings(&paths).unwrap();
assert!(saved.selected_model.provider.is_none());
assert!(saved.selected_model.model.is_none());
}