use super::*;
use crate::config::AuthState;
use std::{fs, sync::Arc};
use tempfile::TempDir;
#[test]
fn aggregated_catalog_includes_claude_code_static_models() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let catalog = load_aggregated_catalog(&paths, CachePreference::AllowStale);
let ids = catalog
.entries
.iter()
.map(|entry| entry.id.as_str())
.collect::<Vec<_>>();
assert!(ids.contains(&"claude-code/sonnet"));
assert!(ids.contains(&"claude-code/opus"));
assert!(ids.contains(&"claude-code/fable"));
}
#[test]
fn claude_code_static_catalog_has_token_metadata() {
let entries = claude_code_static_catalog();
for (model, max_output_tokens) in [("sonnet", 64_000), ("opus", 128_000), ("fable", 128_000)] {
let entry = entries.iter().find(|entry| entry.model == model).unwrap();
assert_eq!(entry.context_window, Some(1_000_000));
assert_eq!(entry.max_output_tokens, Some(max_output_tokens));
}
}
#[test]
fn switch_model_accepts_claude_code_static_model() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let auth = AuthState::Missing {
provider: providers::OPENAI_CODEX_PROVIDER.to_string(),
};
let (_provider, model, notice) = switch_model(&paths, &auth, "claude-code/sonnet").unwrap();
assert_eq!(model, "sonnet");
assert!(notice.is_none());
let saved = crate::config::read_settings(&paths).unwrap();
assert_eq!(
saved.selected_model.provider.as_deref(),
Some(providers::CLAUDE_CODE_PROVIDER)
);
assert_eq!(saved.selected_model.model.as_deref(), Some("sonnet"));
}
#[test]
fn claude_code_model_aliases_resolve_to_real_anthropic_model_ids() {
assert_eq!(
resolve_claude_code_model_alias("sonnet"),
"claude-sonnet-4-6"
);
assert_eq!(resolve_claude_code_model_alias("opus"), "claude-opus-4-8");
assert_eq!(resolve_claude_code_model_alias("fable"), "claude-fable-5");
assert_eq!(
resolve_claude_code_model_alias("claude-code/sonnet"),
"claude-sonnet-4-6"
);
assert_eq!(
resolve_claude_code_model_alias("custom-model"),
"custom-model"
);
}
#[test]
fn model_usage_mentions_claude_code() {
assert!(model_usage().contains("claude-code/sonnet"));
}
#[test]
fn model_catalog_parses_current_reasoning_options_and_none_as_default() {
let model = serde_json::json!({
"reasoning": true,
"reasoning_options": [{"type": "effort", "values": ["none", "low", "high", "high", "unknown", "xhigh", "max"]}]
});
assert_eq!(
explicit_reasoning_efforts(&model),
Some(vec![
ThinkingLevel::Default,
ThinkingLevel::Low,
ThinkingLevel::High,
ThinkingLevel::XHigh,
ThinkingLevel::Max
])
);
assert!(supports_reasoning(&model));
assert_eq!(
explicit_reasoning_efforts(&serde_json::json!({"reasoning_options": []})),
None
);
}
#[test]
fn current_reasoning_options_require_explicit_reasoning_capability() {
for reasoning in [None, Some(false)] {
let mut model = serde_json::json!({
"reasoning_options": [{"type": "effort", "values": ["low", "high"]}]
});
if let Some(reasoning) = reasoning {
model["reasoning"] = serde_json::Value::Bool(reasoning);
}
assert_eq!(explicit_reasoning_efforts(&model), None);
}
assert_eq!(
explicit_reasoning_efforts(&serde_json::json!({
"reasoning": true,
"reasoning_options": [{"type": "effort", "values": ["low", "high"]}]
})),
Some(vec![
ThinkingLevel::Default,
ThinkingLevel::Low,
ThinkingLevel::High
])
);
}
#[test]
fn legacy_reasoning_efforts_remain_explicit_compatibility_path() {
assert_eq!(
explicit_reasoning_efforts(&serde_json::json!({
"reasoning": {"efforts": ["low", "high"]}
})),
Some(vec![
ThinkingLevel::Default,
ThinkingLevel::Low,
ThinkingLevel::High
])
);
}
#[test]
fn model_catalog_parses_legacy_reasoning_efforts_in_canonical_order() {
let model = serde_json::json!({"reasoning": {"efforts": ["max", "unknown", "high", "high"]}});
assert_eq!(
explicit_reasoning_efforts(&model),
Some(vec![
ThinkingLevel::Default,
ThinkingLevel::High,
ThinkingLevel::Max
])
);
let model = serde_json::json!({"reasoning": {"efforts": ["medium", "low", "high"]}});
assert_eq!(
explicit_reasoning_efforts(&model),
Some(ThinkingLevel::EFFORT_GENERIC.to_vec())
);
assert_eq!(
explicit_reasoning_efforts(&serde_json::json!({"reasoning": {"efforts": ["unknown"]}})),
None
);
}
#[test]
fn model_catalog_reasoning_booleans_mark_support_without_synthesizing_efforts() {
for model in [
serde_json::json!({"reasoning": {"effort": true}}),
serde_json::json!({"reasoning": true}),
] {
assert_eq!(explicit_reasoning_efforts(&model), None);
assert!(supports_reasoning(&model));
}
}
#[test]
fn aggregated_catalog_reports_settings_and_auth_read_failures() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(&paths.settings_file, "not json").unwrap();
fs::write(&paths.auth_file, "not json").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut permissions = fs::metadata(&paths.auth_file).unwrap().permissions();
permissions.set_mode(0o600);
fs::set_permissions(&paths.auth_file, permissions).unwrap();
}
let catalog = load_aggregated_catalog(&paths, CachePreference::AllowStale);
assert!(
catalog
.entries
.iter()
.any(|entry| entry.id == "claude-code/sonnet")
);
assert!(
catalog
.notices
.iter()
.any(|notice| notice.contains("settings.json could not be read"))
);
assert!(
catalog
.notices
.iter()
.any(|notice| notice.contains("auth.json could not be read"))
);
let notices = catalog.notices.join("\n");
assert!(!notices.contains("sk-"));
assert!(!notices.contains("access_token"));
}
#[test]
fn aggregated_catalog_reuses_seeded_models_dev_lookup_across_custom_providers() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let (provider_a_url, provider_a_handle) = spawn_model_catalog_server("shared-model");
let (provider_b_url, provider_b_handle) = spawn_model_catalog_server("shared-model");
crate::config::write_settings(
&paths,
&crate::config::Settings {
custom_providers: std::collections::BTreeMap::from([
(
"provider-a".to_string(),
CustomProviderConfig {
label: "Provider A".to_string(),
base_url: provider_a_url,
api_key_env_var: None,
models_dev_provider: Some("provider-a-meta".to_string()),
use_responses_endpoint: false,
supports_text_verbosity: false,
reasoning_protocol: crate::config::CustomReasoningProtocol::default(),
extra_models: Vec::new(),
},
),
(
"provider-b".to_string(),
CustomProviderConfig {
label: "Provider B".to_string(),
base_url: provider_b_url,
api_key_env_var: None,
models_dev_provider: Some("provider-b-meta".to_string()),
use_responses_endpoint: false,
supports_text_verbosity: false,
reasoning_protocol: crate::config::CustomReasoningProtocol::default(),
extra_models: Vec::new(),
},
),
]),
..Default::default()
},
)
.unwrap();
let mut models_dev = ModelsDevLookup {
attempted: true,
value: Some(serde_json::json!({
"provider-a-meta": {"models": {"shared-model": {"description": "provider a metadata", "limit": {"context": 111_u64}}}},
"provider-b-meta": {"models": {"shared-model": {"description": "provider b metadata", "limit": {"context": 222_u64}}}}
})),
};
let catalog = load_aggregated_catalog_with_models_dev(
&paths,
CachePreference::AllowStale,
&mut models_dev,
);
provider_a_handle.join().unwrap();
provider_b_handle.join().unwrap();
assert!(
catalog
.entries
.iter()
.any(|entry| entry.id == "claude-code/sonnet")
);
assert_eq!(
catalog
.entries
.iter()
.filter(|entry| entry.provider == "provider-a" || entry.provider == "provider-b")
.count(),
2,
"notices={:?}",
catalog.notices
);
let provider_a = catalog
.entries
.iter()
.find(|entry| entry.provider == "provider-a")
.unwrap();
let provider_b = catalog
.entries
.iter()
.find(|entry| entry.provider == "provider-b")
.unwrap();
assert_eq!(
provider_a.description.as_deref(),
Some("provider a metadata")
);
assert_eq!(provider_a.context_window, Some(111));
assert_eq!(
provider_b.description.as_deref(),
Some("provider b metadata")
);
assert_eq!(provider_b.context_window, Some(222));
}
#[test]
fn aggregated_catalog_loads_custom_providers_in_parallel() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let (seen_tx, seen_rx) = std::sync::mpsc::channel();
let (provider_a_url, provider_a_handle) = spawn_delayed_model_catalog_server(
Some("parallel-a"),
200,
std::time::Duration::from_millis(300),
Some(seen_tx.clone()),
);
let (provider_b_url, provider_b_handle) = spawn_delayed_model_catalog_server(
Some("parallel-b"),
200,
std::time::Duration::from_millis(300),
Some(seen_tx),
);
crate::config::write_settings(
&paths,
&crate::config::Settings {
custom_providers: std::collections::BTreeMap::from([
(
"provider-a".to_string(),
CustomProviderConfig {
label: "Provider A".to_string(),
base_url: provider_a_url,
api_key_env_var: None,
models_dev_provider: Some("provider-a-meta".to_string()),
use_responses_endpoint: false,
supports_text_verbosity: false,
reasoning_protocol: crate::config::CustomReasoningProtocol::default(),
extra_models: Vec::new(),
},
),
(
"provider-b".to_string(),
CustomProviderConfig {
label: "Provider B".to_string(),
base_url: provider_b_url,
api_key_env_var: None,
models_dev_provider: Some("provider-b-meta".to_string()),
use_responses_endpoint: false,
supports_text_verbosity: false,
reasoning_protocol: crate::config::CustomReasoningProtocol::default(),
extra_models: Vec::new(),
},
),
]),
..Default::default()
},
)
.unwrap();
let mut models_dev = ModelsDevLookup {
attempted: true,
value: Some(serde_json::json!({})),
};
let started = std::time::Instant::now();
let catalog = load_aggregated_catalog_with_models_dev(
&paths,
CachePreference::AllowStale,
&mut models_dev,
);
let elapsed = started.elapsed();
provider_a_handle.join().unwrap();
provider_b_handle.join().unwrap();
let first_seen = seen_rx.recv().unwrap();
let second_seen = seen_rx.recv().unwrap();
assert!(
elapsed < std::time::Duration::from_millis(550),
"catalog discovery was not parallel enough: elapsed={elapsed:?}, notices={:?}",
catalog.notices
);
assert!(
second_seen.duration_since(first_seen) < std::time::Duration::from_millis(200),
"provider requests did not overlap: first={first_seen:?}, second={second_seen:?}"
);
for id in ["provider-a/parallel-a", "provider-b/parallel-b"] {
assert!(
catalog.entries.iter().any(|entry| entry.id == id),
"missing {id}; notices={:?}",
catalog.notices
);
}
}
#[test]
fn aggregated_catalog_bounds_custom_provider_worker_concurrency() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let peak = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let mut providers = std::collections::BTreeMap::new();
let mut handles = Vec::new();
for index in 0..(MAX_CUSTOM_PROVIDER_CATALOG_WORKERS + 3) {
let provider = format!("provider-{index}");
let model = format!("bounded-{index}");
let (url, handle) = spawn_counted_model_catalog_server(
&model,
std::time::Duration::from_millis(100),
Arc::clone(&active),
Arc::clone(&peak),
);
handles.push(handle);
providers.insert(
provider,
CustomProviderConfig {
label: format!("Provider {index}"),
base_url: url,
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(),
},
);
}
crate::config::write_settings(
&paths,
&crate::config::Settings {
custom_providers: providers,
..Default::default()
},
)
.unwrap();
let mut models_dev = ModelsDevLookup {
attempted: true,
value: Some(serde_json::json!({})),
};
let catalog = load_aggregated_catalog_with_models_dev(
&paths,
CachePreference::AllowStale,
&mut models_dev,
);
for handle in handles {
handle.join().unwrap();
}
assert!(
peak.load(std::sync::atomic::Ordering::SeqCst) <= MAX_CUSTOM_PROVIDER_CATALOG_WORKERS,
"peak custom provider requests exceeded cap; notices={:?}",
catalog.notices
);
for index in 0..(MAX_CUSTOM_PROVIDER_CATALOG_WORKERS + 3) {
let id = format!("provider-{index}/bounded-{index}");
assert!(
catalog.entries.iter().any(|entry| entry.id == id),
"missing {id}; notices={:?}",
catalog.notices
);
}
}
#[test]
fn aggregated_catalog_preserves_order_with_mixed_custom_success_and_failure() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let (provider_a_url, provider_a_handle) = spawn_delayed_model_catalog_server(
Some("success-a"),
200,
std::time::Duration::from_millis(50),
None,
);
let (provider_b_url, provider_b_handle) =
spawn_delayed_model_catalog_server(None, 500, std::time::Duration::from_millis(50), None);
crate::config::write_settings(
&paths,
&crate::config::Settings {
custom_providers: std::collections::BTreeMap::from([
(
"provider-a".to_string(),
CustomProviderConfig {
label: "Provider A".to_string(),
base_url: provider_a_url,
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(),
},
),
(
"provider-b".to_string(),
CustomProviderConfig {
label: "Provider B".to_string(),
base_url: provider_b_url,
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(),
},
),
]),
..Default::default()
},
)
.unwrap();
let mut models_dev = ModelsDevLookup {
attempted: true,
value: Some(serde_json::json!({})),
};
let catalog = load_aggregated_catalog_with_models_dev(
&paths,
CachePreference::AllowStale,
&mut models_dev,
);
provider_a_handle.join().unwrap();
provider_b_handle.join().unwrap();
let ids = catalog
.entries
.iter()
.map(|entry| entry.id.as_str())
.collect::<Vec<_>>();
assert_eq!(
&ids[..4],
[
"claude-code/sonnet",
"claude-code/opus",
"claude-code/fable",
"provider-a/success-a"
]
);
assert!(catalog.notices.iter().any(|notice| {
notice.contains("provider-b model discovery unavailable") && notice.contains("500")
}));
assert!(!ids.iter().any(|id| id.starts_with("provider-b/")));
}
fn spawn_model_catalog_server(model: &str) -> (String, std::thread::JoinHandle<()>) {
spawn_delayed_model_catalog_server(Some(model), 200, std::time::Duration::ZERO, None)
}
fn spawn_delayed_model_catalog_server(
model: Option<&str>,
status: u16,
delay: std::time::Duration,
seen: Option<std::sync::mpsc::Sender<std::time::Instant>>,
) -> (String, std::thread::JoinHandle<()>) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let model = model.map(str::to_string);
let handle = std::thread::spawn(move || {
use std::io::{Read, Write};
listener.set_nonblocking(true).unwrap();
let started = std::time::Instant::now();
let (mut stream, _) = loop {
match listener.accept() {
Ok(accepted) => break accepted,
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
if started.elapsed() > std::time::Duration::from_secs(5) {
return;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
Err(error) => panic!("model catalog server accept failed: {error}"),
}
};
stream.set_nonblocking(false).unwrap();
let mut request = [0_u8; 1024];
let _ = stream.read(&mut request);
if let Some(seen) = seen {
let _ = seen.send(std::time::Instant::now());
}
std::thread::sleep(delay);
let body = match model {
Some(model) if (200..300).contains(&status) => {
serde_json::json!({"data": [{"id": model}]}).to_string()
}
_ => serde_json::json!({"error": "catalog unavailable"}).to_string(),
};
write!(
stream,
"HTTP/1.1 {status} OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
body.len(),
body
)
.unwrap();
});
(format!("http://{address}/v1"), handle)
}
fn spawn_counted_model_catalog_server(
model: &str,
delay: std::time::Duration,
active: Arc<std::sync::atomic::AtomicUsize>,
peak: Arc<std::sync::atomic::AtomicUsize>,
) -> (String, std::thread::JoinHandle<()>) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let model = model.to_string();
let handle = std::thread::spawn(move || {
use std::io::{Read, Write};
let (mut stream, _) = listener.accept().unwrap();
let current = active.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
peak.fetch_max(current, std::sync::atomic::Ordering::SeqCst);
let mut request = [0_u8; 1024];
let _ = stream.read(&mut request);
std::thread::sleep(delay);
active.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
let body = serde_json::json!({"data": [{"id": model}]}).to_string();
write!(
stream,
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
body.len(),
body
)
.unwrap();
});
(format!("http://{address}/v1"), handle)
}
#[test]
fn models_dev_fetch_uses_observable_request_timeout() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let handle = std::thread::spawn(move || {
let _accepted = listener.accept();
std::thread::sleep(std::time::Duration::from_secs(2));
});
let started = std::time::Instant::now();
let error = fetch_models_dev_catalog_with_timeouts(
&format!("http://{address}/api.json"),
std::time::Duration::from_millis(100),
std::time::Duration::from_millis(100),
)
.unwrap_err();
assert!(started.elapsed() < std::time::Duration::from_secs(1));
assert_eq!(error, ModelsDevFetchError::Timeout);
handle.join().unwrap();
}
#[test]
fn models_dev_fetch_rejects_oversized_success_body() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let body = format!(
r#"{{"openai":{{"models":{{}}}},"padding":"{}"}}"#,
"x".repeat(MODELS_DEV_BODY_MAX_BYTES as usize + 1)
);
let handle = std::thread::spawn(move || {
use std::io::{Read, Write};
let (mut stream, _) = listener.accept().unwrap();
let mut request = [0_u8; 1024];
let _ = stream.read(&mut request);
write!(
stream,
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
body.len(),
body
)
.unwrap();
});
let error = fetch_models_dev_catalog_with_timeouts(
&format!("http://{address}/api.json"),
std::time::Duration::from_millis(100),
std::time::Duration::from_secs(5),
)
.unwrap_err();
handle.join().unwrap();
assert_eq!(error, ModelsDevFetchError::BodyLimit);
}
#[test]
fn models_dev_fetch_accepts_body_just_under_source_specific_limit() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let body = format!(
r#"{{"openai":{{"models":{{}}}},"padding":"{}"}}"#,
"x".repeat(MODELS_DEV_BODY_MAX_BYTES as usize - 128)
);
let handle = std::thread::spawn(move || {
use std::io::{Read, Write};
let (mut stream, _) = listener.accept().unwrap();
let mut request = [0_u8; 1024];
let _ = stream.read(&mut request);
write!(
stream,
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
body.len(), body
)
.unwrap();
});
let value = fetch_models_dev_catalog_with_timeouts(
&format!("http://{address}/api.json"),
std::time::Duration::from_millis(100),
std::time::Duration::from_secs(5),
)
.unwrap();
handle.join().unwrap();
assert_eq!(value["openai"]["models"], serde_json::json!({}));
}
#[test]
fn models_dev_fetch_rejects_http_error_before_decoding_json() {
let (url, handle) =
spawn_delayed_model_catalog_server(None, 503, std::time::Duration::ZERO, None);
let error = fetch_models_dev_catalog_with_timeouts(
&url,
std::time::Duration::from_millis(100),
std::time::Duration::from_secs(1),
)
.unwrap_err();
handle.join().unwrap();
assert_eq!(error, ModelsDevFetchError::HttpStatus(503));
}
#[test]
fn models_dev_failure_categories_are_granular_and_sanitized() {
assert_eq!(
ModelsDevFetchError::Transport.category(),
CatalogRefreshFailureCategory::ModelsDevTransport
);
assert_eq!(
ModelsDevFetchError::Timeout.category(),
CatalogRefreshFailureCategory::ModelsDevTimeout
);
assert_eq!(
ModelsDevFetchError::HttpStatus(503).category(),
CatalogRefreshFailureCategory::ModelsDevHttpStatus
);
assert_eq!(
ModelsDevFetchError::BodyLimit.category(),
CatalogRefreshFailureCategory::ModelsDevBodyLimit
);
assert_eq!(
ModelsDevFetchError::JsonDecode.category(),
CatalogRefreshFailureCategory::ModelsDevJsonDecode
);
assert!(!ModelsDevFetchError::Transport.to_string().contains("http"));
}
#[test]
fn catalog_cache_read_treats_oversized_file_as_stale() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let path = catalog_cache_path(&paths, providers::OPENAI_CODEX_PROVIDER).unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
"x".repeat(crate::http_body::DEFAULT_BOUNDED_BODY_MAX_BYTES as usize + 1),
)
.unwrap();
assert!(read_catalog_cache(&paths, providers::OPENAI_CODEX_PROVIDER).is_none());
}
#[test]
fn models_dev_lookup_fetches_at_most_once_per_instance() {
let calls = std::cell::Cell::new(0);
let mut lookup = ModelsDevLookup::default();
let first = lookup
.get_or_fetch(|| {
calls.set(calls.get() + 1);
Ok(serde_json::json!({"openai": {"models": {}}}))
})
.is_some();
let second = lookup
.get_or_fetch(|| panic!("models.dev fetch repeated after success"))
.is_some();
assert!(first);
assert!(second);
assert_eq!(calls.get(), 1);
let failed_calls = std::cell::Cell::new(0);
let mut failed_lookup = ModelsDevLookup::default();
assert!(
failed_lookup
.get_or_fetch(|| {
failed_calls.set(failed_calls.get() + 1);
anyhow::bail!("offline")
})
.is_none()
);
assert!(
failed_lookup
.get_or_fetch(|| {
failed_calls.set(failed_calls.get() + 1);
Ok(serde_json::json!({"openai": {"models": {}}}))
})
.is_some()
);
assert_eq!(failed_calls.get(), 2);
}
#[test]
fn model_catalog_model_id_parses_strict_provider_model_shape() {
let id = ModelId::parse("openai-codex/gpt-5.5").unwrap();
assert_eq!(id.provider(), "openai-codex");
assert_eq!(id.model(), "gpt-5.5");
assert_eq!(id.to_string(), "openai-codex/gpt-5.5");
assert_eq!(ModelId::parse("gpt-5.5"), Err(ModelIdError::MissingSlash));
assert_eq!(ModelId::parse("/gpt-5.5"), Err(ModelIdError::EmptyProvider));
assert_eq!(
ModelId::parse("openai-codex/"),
Err(ModelIdError::EmptyModel)
);
let slash_model = ModelId::parse("openai-codex/a/b").unwrap();
assert_eq!(slash_model.provider(), "openai-codex");
assert_eq!(slash_model.model(), "a/b");
let openai = ModelId::parse("openai/gpt-4").unwrap();
assert_eq!(openai.provider(), "openai");
assert_eq!(openai.model(), "gpt-4");
let foundry = ModelId::parse("foundry/minimaxai/minimax-m2.7").unwrap();
assert_eq!(foundry.provider(), "foundry");
assert_eq!(foundry.model(), "minimaxai/minimax-m2.7");
assert_eq!(foundry.to_string(), "foundry/minimaxai/minimax-m2.7");
let from_parts = ModelId::from_parts("foundry", "minimaxai/minimax-m2.7").unwrap();
assert_eq!(from_parts, foundry);
let entry = ModelCatalogEntry::new("foundry", "minimaxai/minimax-m2.7");
assert_eq!(entry.id, "foundry/minimaxai/minimax-m2.7");
}
#[test]
fn model_catalog_cache_round_trips_under_mc_cache_without_secrets() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let entries = vec![ModelCatalogEntry::new_codex("gpt-test")];
write_catalog_cache(&paths, providers::OPENAI_CODEX_PROVIDER, &entries).unwrap();
let path = catalog_cache_path(&paths, providers::OPENAI_CODEX_PROVIDER).unwrap();
assert!(path.starts_with(paths.cache.join("model-catalog")));
let text = fs::read_to_string(&path).unwrap();
for secret_word in [
"authorization",
"access",
"refresh",
"api_key",
"accountId",
"chatgpt-account-id",
] {
assert!(
!text.contains(secret_word),
"cache leaked {secret_word}: {text}"
);
}
let loaded = read_catalog_cache(&paths, providers::OPENAI_CODEX_PROVIDER).unwrap();
assert_eq!(loaded.entries, entries);
}
#[test]
fn custom_provider_catalog_enriches_from_explicit_models_dev_namespace() {
let fixture = serde_json::json!({
"openrouter": {
"models": {
"gpt-test": {
"description": "metadata description",
"limit": {"context": 128000_u64, "output": 4096_u64},
"reasoning": {"effort": true},
"modalities": ["text"]
}
}
}
});
let entry = ModelCatalogEntry::new("local-ai", "gpt-test");
let entries = enrich_with_models_dev_namespace(vec![entry], &fixture, "openrouter");
let enriched = &entries[0];
assert_eq!(enriched.provider, "local-ai");
assert_eq!(
enriched.description.as_deref(),
Some("metadata description")
);
assert_eq!(enriched.context_window, Some(128000));
assert_eq!(enriched.max_context_window, None);
assert_eq!(enriched.max_output_tokens, Some(4096));
assert_eq!(enriched.reasoning_efforts, None);
assert_eq!(enriched.supports_reasoning, Some(true));
assert_eq!(enriched.modalities, Some(vec!["text".to_string()]));
}
#[test]
fn custom_provider_openai_enriches_from_fixture_and_preserves_provider_id() {
let fixture = serde_json::json!({
"openai": {
"models": {
"gpt-5.5": {
"description": "fixture description",
"limit": {"context": 400000_u64, "output": 32768_u64},
"reasoning": {"effort": true},
"cost": {"input": 1, "output": 8},
"modalities": ["text", "image"]
}
}
}
});
let custom = CustomProviderConfig {
label: "OpenAI Compatible".to_string(),
base_url: "https://api.openai.com/v1".to_string(),
api_key_env_var: Some("OPENAI_COMPATIBLE_API_KEY".to_string()),
models_dev_provider: Some("openai".to_string()),
use_responses_endpoint: false,
supports_text_verbosity: false,
reasoning_protocol: crate::config::CustomReasoningProtocol::default(),
extra_models: Vec::new(),
};
let entry = ModelCatalogEntry::new("openai", "gpt-5.5");
let entries = enrich_custom_provider_catalog_with_models_dev_value(
vec![entry],
"openai",
&custom,
&fixture,
);
let enriched = &entries[0];
assert_eq!(enriched.provider, "openai");
assert_eq!(enriched.id, "openai/gpt-5.5");
assert_eq!(enriched.description.as_deref(), Some("fixture description"));
assert_eq!(enriched.context_window, Some(400000));
assert_eq!(enriched.max_output_tokens, Some(32768));
assert_eq!(enriched.reasoning_efforts, None);
assert_eq!(enriched.supports_reasoning, Some(true));
assert_eq!(enriched.input_cost.as_deref(), Some("1"));
assert_eq!(enriched.output_cost.as_deref(), Some("8"));
assert_eq!(
enriched.modalities,
Some(vec!["text".to_string(), "image".to_string()])
);
}
#[test]
fn custom_provider_openai_current_fixture_covers_all_gpt56_models() {
let models = ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"];
let fixture = serde_json::json!({
"openai": {
"models": {
"gpt-5.6-sol": {"reasoning": true, "reasoning_options": [{"type": "effort", "values": ["none", "low", "medium", "high", "xhigh", "max"]}]},
"gpt-5.6-terra": {"reasoning": true, "reasoning_options": [{"type": "effort", "values": ["none", "low", "medium", "high", "xhigh", "max"]}]},
"gpt-5.6-luna": {"reasoning": true, "reasoning_options": [{"type": "effort", "values": ["none", "low", "medium", "high", "xhigh", "max"]}]}
}
}
});
let entries = enrich_with_models_dev_namespace(
models
.iter()
.map(|model| ModelCatalogEntry::new("openai", *model))
.collect(),
&fixture,
"openai",
);
for entry in entries {
assert_eq!(entry.supports_reasoning, Some(true));
assert_eq!(
entry.reasoning_efforts,
Some(vec![
ThinkingLevel::Default,
ThinkingLevel::Low,
ThinkingLevel::Medium,
ThinkingLevel::High,
ThinkingLevel::XHigh,
ThinkingLevel::Max,
])
);
}
}
#[test]
fn custom_provider_missing_models_dev_provider_falls_back_to_provider_id() {
let fixture = serde_json::json!({
"hosted-provider": {
"models": {
"gpt-5.5": {
"description": "fixture description",
"limit": {"context": 400000_u64, "output": 32768_u64},
"reasoning": {"effort": true},
"modalities": ["text"]
}
}
}
});
let custom = CustomProviderConfig {
label: "Hosted Provider".to_string(),
base_url: "https://provider.example/v1".to_string(),
api_key_env_var: Some("HOSTED_PROVIDER_API_KEY".to_string()),
models_dev_provider: None,
use_responses_endpoint: false,
supports_text_verbosity: false,
reasoning_protocol: crate::config::CustomReasoningProtocol::default(),
extra_models: Vec::new(),
};
let entry = ModelCatalogEntry::new("hosted-provider", "gpt-5.5");
let entries = enrich_custom_provider_catalog_with_models_dev_value(
vec![entry],
"hosted-provider",
&custom,
&fixture,
);
let enriched = &entries[0];
assert_eq!(enriched.provider, "hosted-provider");
assert_eq!(enriched.id, "hosted-provider/gpt-5.5");
assert_eq!(enriched.description.as_deref(), Some("fixture description"));
assert_eq!(enriched.context_window, Some(400000));
assert_eq!(enriched.max_output_tokens, Some(32768));
assert_eq!(enriched.reasoning_efforts, None);
assert_eq!(enriched.supports_reasoning, Some(true));
assert_eq!(enriched.modalities, Some(vec!["text".to_string()]));
}
#[test]
fn custom_provider_invalid_base_url_falls_back_to_provider_id_namespace() {
let fixture = serde_json::json!({
"invalid-provider": {
"models": {
"gpt-5.5": {
"description": "fixture description",
"limit": {"context": 400000_u64}
}
}
}
});
let custom = CustomProviderConfig {
label: "Invalid Provider".to_string(),
base_url: "not a url".to_string(),
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(),
};
let entry = ModelCatalogEntry::new("invalid-provider", "gpt-5.5");
let entries = enrich_custom_provider_catalog_with_models_dev_value(
vec![entry],
"invalid-provider",
&custom,
&fixture,
);
let enriched = &entries[0];
assert_eq!(enriched.provider, "invalid-provider");
assert_eq!(enriched.id, "invalid-provider/gpt-5.5");
assert_eq!(enriched.description.as_deref(), Some("fixture description"));
assert_eq!(enriched.context_window, Some(400000));
}
#[test]
fn custom_provider_openai_base_does_not_override_provider_id_fallback() {
let fixture = serde_json::json!({
"openai": {
"models": {
"gpt-5.5": {
"description": "openai description",
"limit": {"context": 400000_u64}
}
}
},
"custom-openai": {
"models": {
"gpt-5.5": {
"description": "custom namespace description",
"limit": {"context": 123000_u64, "output": 4096_u64}
}
}
}
});
let custom = CustomProviderConfig {
label: "OpenAI Compatible".to_string(),
base_url: "https://api.openai.com/v1".to_string(),
api_key_env_var: Some("OPENAI_COMPATIBLE_API_KEY".to_string()),
models_dev_provider: None,
use_responses_endpoint: false,
supports_text_verbosity: false,
reasoning_protocol: crate::config::CustomReasoningProtocol::default(),
extra_models: Vec::new(),
};
let entry = ModelCatalogEntry::new("custom-openai", "gpt-5.5");
let entries = enrich_custom_provider_catalog_with_models_dev_value(
vec![entry],
"custom-openai",
&custom,
&fixture,
);
let enriched = &entries[0];
assert_eq!(enriched.provider, "custom-openai");
assert_eq!(enriched.id, "custom-openai/gpt-5.5");
assert_eq!(
enriched.description.as_deref(),
Some("custom namespace description")
);
assert_eq!(enriched.context_window, Some(123000));
assert_eq!(enriched.max_output_tokens, Some(4096));
}
#[test]
fn custom_provider_id_openai_falls_back_to_openai_namespace() {
let fixture = serde_json::json!({
"openai": {
"models": {
"gpt-5.5": {
"description": "fixture description",
"limit": {"context": 400000_u64, "output": 32768_u64},
"reasoning": {"effort": true},
"modalities": ["text", "image"]
}
}
}
});
let custom = CustomProviderConfig {
label: "OpenAI Compatible".to_string(),
base_url: "https://api.openai.com/v1".to_string(),
api_key_env_var: Some("OPENAI_COMPATIBLE_API_KEY".to_string()),
models_dev_provider: None,
use_responses_endpoint: false,
supports_text_verbosity: false,
reasoning_protocol: crate::config::CustomReasoningProtocol::default(),
extra_models: Vec::new(),
};
let entry = ModelCatalogEntry::new("openai", "gpt-5.5");
let entries = enrich_custom_provider_catalog_with_models_dev_value(
vec![entry],
"openai",
&custom,
&fixture,
);
let enriched = &entries[0];
assert_eq!(enriched.provider, "openai");
assert_eq!(enriched.id, "openai/gpt-5.5");
assert_eq!(enriched.description.as_deref(), Some("fixture description"));
assert_eq!(enriched.context_window, Some(400000));
assert_eq!(enriched.max_output_tokens, Some(32768));
assert_eq!(enriched.reasoning_efforts, None);
assert_eq!(enriched.supports_reasoning, Some(true));
assert_eq!(
enriched.modalities,
Some(vec!["text".to_string(), "image".to_string()])
);
}
#[test]
fn custom_provider_explicit_models_dev_provider_overrides_provider_id_fallback() {
let fixture = serde_json::json!({
"hosted-provider": {
"models": {
"gpt-5.5": {
"description": "provider-id description",
"limit": {"context": 400000_u64}
}
}
},
"openrouter": {
"models": {
"gpt-5.5": {
"description": "openrouter description",
"limit": {"context": 128000_u64}
}
}
}
});
let custom = CustomProviderConfig {
label: "Hosted Provider".to_string(),
base_url: "https://api.openai.com/v1".to_string(),
api_key_env_var: Some("HOSTED_PROVIDER_API_KEY".to_string()),
models_dev_provider: Some("openrouter".to_string()),
use_responses_endpoint: false,
supports_text_verbosity: false,
reasoning_protocol: crate::config::CustomReasoningProtocol::default(),
extra_models: Vec::new(),
};
let entry = ModelCatalogEntry::new("hosted-provider", "gpt-5.5");
let entries = enrich_custom_provider_catalog_with_models_dev_value(
vec![entry],
"hosted-provider",
&custom,
&fixture,
);
let enriched = &entries[0];
assert_eq!(enriched.provider, "hosted-provider");
assert_eq!(enriched.id, "hosted-provider/gpt-5.5");
assert_eq!(
enriched.description.as_deref(),
Some("openrouter description")
);
assert_eq!(enriched.context_window, Some(128000));
}
fn custom_provider_config_with_models_dev_provider(
models_dev_provider: Option<&str>,
) -> CustomProviderConfig {
CustomProviderConfig {
label: "Local AI".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(),
api_key_env_var: None,
models_dev_provider: models_dev_provider.map(str::to_string),
use_responses_endpoint: false,
supports_text_verbosity: false,
reasoning_protocol: crate::config::CustomReasoningProtocol::default(),
extra_models: Vec::new(),
}
}
#[test]
fn model_catalog_cache_ignores_custom_provider_endpoint_mode() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let chat_mode = custom_provider_config_with_models_dev_provider(None);
write_custom_provider_catalog_cache(
&paths,
"local-ai",
&chat_mode,
&[ModelCatalogEntry::new("local-ai", "cached-model")],
)
.unwrap();
let responses_mode = CustomProviderConfig {
use_responses_endpoint: true,
supports_text_verbosity: false,
reasoning_protocol: crate::config::CustomReasoningProtocol::default(),
extra_models: Vec::new(),
..chat_mode
};
let cached = read_custom_provider_catalog_cache(&paths, "local-ai", &responses_mode)
.expect("endpoint mode must not affect model catalog cache identity");
assert_eq!(cached.entries[0].model, "cached-model");
}
#[test]
fn custom_provider_cache_invalidates_when_models_dev_provider_added() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let unmapped = custom_provider_config_with_models_dev_provider(None);
write_custom_provider_catalog_cache(
&paths,
"local-ai",
&unmapped,
&[ModelCatalogEntry::new("local-ai", "cached-model")],
)
.unwrap();
let mapped = custom_provider_config_with_models_dev_provider(Some("openai"));
let result = load_custom_provider_catalog(
&paths,
"local-ai",
&mapped,
Some(&crate::config::ProviderCredential::NoAuth),
CachePreference::AllowStale,
);
assert!(
result.is_err(),
"fresh unmapped cache was reused after models_dev_provider was added: {result:?}"
);
let cached = read_custom_provider_catalog_cache(&paths, "local-ai", &unmapped).unwrap();
assert_eq!(cached.entries[0].model, "cached-model");
assert_eq!(unmapped.models_dev_provider, None);
}
#[test]
fn custom_provider_cache_rejects_legacy_none_fingerprint_for_provider_id_fallback() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let custom = CustomProviderConfig {
label: "OpenAI Compatible".to_string(),
base_url: "https://api.openai.com/v1".to_string(),
api_key_env_var: Some("OPENAI_COMPATIBLE_API_KEY".to_string()),
models_dev_provider: None,
use_responses_endpoint: false,
supports_text_verbosity: false,
reasoning_protocol: crate::config::CustomReasoningProtocol::default(),
extra_models: Vec::new(),
};
let legacy_fingerprint = CatalogCacheFingerprint {
enrichment_version: Some(ENRICHMENT_VERSION),
models_dev_provider: None,
extra_models: Vec::new(),
};
write_catalog_cache_with_fingerprint(
&paths,
"openai",
&legacy_fingerprint,
&[ModelCatalogEntry::new("openai", "cached-model")],
)
.unwrap();
assert!(read_catalog_cache(&paths, "openai").is_some());
assert!(read_custom_provider_catalog_cache(&paths, "openai", &custom).is_none());
}
#[test]
fn custom_provider_cache_rejects_old_openai_base_namespace_when_provider_id_differs() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let custom = CustomProviderConfig {
label: "OpenAI Compatible".to_string(),
base_url: "https://api.openai.com/v1".to_string(),
api_key_env_var: Some("OPENAI_COMPATIBLE_API_KEY".to_string()),
models_dev_provider: None,
use_responses_endpoint: false,
supports_text_verbosity: false,
reasoning_protocol: crate::config::CustomReasoningProtocol::default(),
extra_models: Vec::new(),
};
let old_fingerprint = CatalogCacheFingerprint {
enrichment_version: Some(ENRICHMENT_VERSION),
models_dev_provider: Some("openai".to_string()),
extra_models: Vec::new(),
};
write_catalog_cache_with_fingerprint(
&paths,
"custom-openai",
&old_fingerprint,
&[ModelCatalogEntry::new("custom-openai", "cached-model")],
)
.unwrap();
assert!(read_custom_provider_catalog_cache(&paths, "custom-openai", &custom).is_none());
let explicit_openai = CustomProviderConfig {
models_dev_provider: Some("openai".to_string()),
..custom
};
assert!(
read_custom_provider_catalog_cache(&paths, "custom-openai", &explicit_openai).is_some()
);
}
#[test]
fn custom_provider_cache_invalidates_when_models_dev_provider_removed_or_changed() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let path = catalog_cache_path(&paths, "local-ai").unwrap();
let mut entry = ModelCatalogEntry::new("local-ai", "cached-model");
entry.description = Some("stale enriched metadata".to_string());
let mapped = custom_provider_config_with_models_dev_provider(Some("openai"));
write_custom_provider_catalog_cache(&paths, "local-ai", &mapped, &[entry]).unwrap();
let text = fs::read_to_string(&path).unwrap();
assert!(text.contains("cache_fingerprint"));
assert!(text.contains("models_dev_provider"));
assert!(text.contains("openai"));
for custom in [
custom_provider_config_with_models_dev_provider(None),
custom_provider_config_with_models_dev_provider(Some("openrouter")),
] {
let result = load_custom_provider_catalog(
&paths,
"local-ai",
&custom,
Some(&crate::config::ProviderCredential::NoAuth),
CachePreference::AllowStale,
);
assert!(
result.is_err(),
"fresh mapped cache was reused after models_dev_provider became {:?}: {result:?}",
custom.models_dev_provider
);
}
}
#[test]
fn custom_provider_cache_invalidates_when_extra_models_change() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let without_extra = custom_provider_config_with_models_dev_provider(None);
write_custom_provider_catalog_cache(
&paths,
"local-ai",
&without_extra,
&[ModelCatalogEntry::new("local-ai", "cached-model")],
)
.unwrap();
let with_extra = CustomProviderConfig {
extra_models: vec!["glm-5.2".to_string()],
..without_extra.clone()
};
let result = load_custom_provider_catalog(
&paths,
"local-ai",
&with_extra,
Some(&crate::config::ProviderCredential::NoAuth),
CachePreference::AllowStale,
);
assert!(
result.is_err(),
"fresh cache without extra_models was reused after override was added: {result:?}"
);
write_custom_provider_catalog_cache(
&paths,
"local-ai",
&with_extra,
&[ModelCatalogEntry::new("local-ai", "glm-5.2")],
)
.unwrap();
let result = load_custom_provider_catalog(
&paths,
"local-ai",
&without_extra,
Some(&crate::config::ProviderCredential::NoAuth),
CachePreference::AllowStale,
);
assert!(
result.is_err(),
"fresh cache with extra_models was reused after override was removed: {result:?}"
);
}
#[test]
fn custom_provider_invalid_extra_models_do_not_reach_catalog_cache() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let invalid = CustomProviderConfig {
extra_models: vec!["sk-secret-looking".to_string()],
..custom_provider_config_with_models_dev_provider(None)
};
let error = load_custom_provider_catalog(
&paths,
"local-ai",
&invalid,
Some(&crate::config::ProviderCredential::NoAuth),
CachePreference::AllowStale,
)
.unwrap_err();
assert!(error.contains("custom provider 'local-ai'"), "{error}");
assert!(error.contains("extra_models"), "{error}");
assert!(!error.contains("sk-secret-looking"), "{error}");
assert!(catalog_cache_path(&paths, "local-ai").is_some_and(|path| !path.exists()));
}
#[test]
fn custom_provider_catalog_includes_extra_models_and_dedupes_live_models() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let (base_url, handle) = spawn_model_catalog_server("glm-5.1");
let custom = CustomProviderConfig {
label: "Z.ai".to_string(),
base_url,
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!["glm-5.2".to_string(), "glm-5.1".to_string()],
};
let models_dev = SharedModelsDevLookup::from_seeded(&ModelsDevLookup {
attempted: true,
value: Some(serde_json::json!({})),
});
let result = load_custom_provider_catalog_with_models_dev(
&paths,
"zai",
&custom,
Some(&crate::config::ProviderCredential::NoAuth),
CachePreference::AllowStale,
Some(&models_dev),
)
.unwrap();
handle.join().unwrap();
let ids = result
.entries
.iter()
.map(|entry| entry.id.as_str())
.collect::<Vec<_>>();
assert_eq!(ids, vec!["zai/glm-5.1", "zai/glm-5.2"]);
assert_eq!(
result
.entries
.iter()
.filter(|entry| entry.model == "glm-5.1")
.count(),
1
);
}
#[test]
fn switch_model_accepts_custom_provider_extra_model_from_fresh_cache() {
let temp = TempDir::new().unwrap();
let paths = 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 = 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 custom_provider_catalog_skips_unknown_models_dev_namespace() {
let fixture = serde_json::json!({
"openrouter": {"models": {"gpt-test": {"limit": {"context": 128000_u64}}}}
});
let custom = CustomProviderConfig {
label: "Missing Provider".to_string(),
base_url: "https://provider.example/v1".to_string(),
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(),
};
let entry = ModelCatalogEntry::new("missing-provider", "gpt-test");
let entries = enrich_custom_provider_catalog_with_models_dev_value(
vec![entry.clone()],
"missing-provider",
&custom,
&fixture,
);
assert_eq!(entries, vec![entry]);
}
#[test]
fn models_dev_output_limit_does_not_become_context_window() {
let fixture = serde_json::json!({
"openrouter": {"models": {"gpt-test": {"limit": {"output": 4096_u64}}}}
});
let entry = ModelCatalogEntry::new("local-ai", "gpt-test");
let entries = enrich_with_models_dev_namespace(vec![entry], &fixture, "openrouter");
let enriched = &entries[0];
assert_eq!(enriched.context_window, None);
assert_eq!(enriched.max_context_window, None);
assert_eq!(enriched.max_output_tokens, Some(4096));
}
#[test]
fn cached_model_context_window_ignores_output_token_limit() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let mut entry = ModelCatalogEntry::new("local-ai", "gpt-test");
entry.max_output_tokens = Some(4096);
write_catalog_cache(&paths, "local-ai", &[entry]).unwrap();
assert_eq!(
cached_model_context_window(&paths, "local-ai", "gpt-test"),
None
);
}
#[test]
fn custom_metadata_supported_model_exposes_thinking_levels() {
let temp = TempDir::new().unwrap();
let paths = 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(),
custom_provider_config_with_models_dev_provider(None),
)]),
..Default::default()
},
)
.unwrap();
let mut supported = ModelCatalogEntry::new("local-ai", "gpt-test");
supported.supports_reasoning = Some(true);
let unsupported = ModelCatalogEntry::new("local-ai", "plain-test");
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, &[supported, unsupported])
.unwrap();
let metadata = cached_model_thinking_metadata(&paths, "local-ai", "gpt-test").unwrap();
assert_eq!(metadata.reasoning_efforts, None);
assert_eq!(metadata.supports_reasoning, Some(true));
assert_eq!(
crate::thinking::available_thinking_levels(
"local-ai",
"gpt-test",
Some(&metadata),
crate::thinking::ThinkingCapabilityScope::BuiltIn
),
crate::thinking::ThinkingLevel::EFFORT_GENERIC
);
let metadata = cached_model_thinking_metadata(&paths, "local-ai", "plain-test").unwrap();
assert_eq!(metadata.reasoning_efforts, None);
assert_eq!(metadata.supports_reasoning, None);
assert_eq!(
crate::thinking::available_thinking_levels(
"local-ai",
"plain-test",
None,
crate::thinking::ThinkingCapabilityScope::BuiltIn
),
vec![crate::thinking::ThinkingLevel::Default]
);
}
#[test]
fn cached_model_context_window_reads_selected_catalog_entry() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let mut entry = ModelCatalogEntry::new_codex("gpt-test");
entry.context_window = Some(196_000);
write_catalog_cache(&paths, providers::OPENAI_CODEX_PROVIDER, &[entry]).unwrap();
assert_eq!(
cached_model_context_window(&paths, providers::OPENAI_CODEX_PROVIDER, "gpt-test"),
Some(196_000)
);
assert_eq!(
cached_model_context_window(&paths, providers::OPENAI_CODEX_PROVIDER, "missing"),
None
);
}
#[test]
fn cached_model_max_output_tokens_reads_selected_catalog_entry() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let mut entry = ModelCatalogEntry::new(providers::ANTHROPIC_PROVIDER, "claude-test");
entry.max_output_tokens = Some(8192);
write_catalog_cache(&paths, providers::ANTHROPIC_PROVIDER, &[entry]).unwrap();
assert_eq!(
cached_model_max_output_tokens(&paths, providers::ANTHROPIC_PROVIDER, "claude-test"),
Some(8192)
);
assert_eq!(
cached_model_max_output_tokens(&paths, providers::ANTHROPIC_PROVIDER, "missing"),
None
);
}
#[test]
fn cached_claude_code_metadata_uses_static_catalog_without_sidecar() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
assert_eq!(
cached_model_context_window(&paths, providers::CLAUDE_CODE_PROVIDER, "sonnet"),
Some(1_000_000)
);
assert_eq!(
cached_model_max_output_tokens(&paths, providers::CLAUDE_CODE_PROVIDER, "opus"),
Some(128_000)
);
assert_eq!(
cached_model_max_output_tokens(&paths, providers::CLAUDE_CODE_PROVIDER, "missing"),
None
);
assert!(
!catalog_cache_path(&paths, providers::CLAUDE_CODE_PROVIDER)
.unwrap()
.exists()
);
assert_eq!(
cached_model_thinking_metadata(&paths, providers::CLAUDE_CODE_PROVIDER, "sonnet"),
Some(crate::thinking::CatalogThinkingMetadata {
reasoning_efforts: None,
supports_reasoning: None,
})
);
}
#[test]
fn model_catalog_cache_rejects_bad_provider_path_corrupt_json_and_old_schema() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
assert!(catalog_cache_path(&paths, "../bad").is_none());
let path = catalog_cache_path(&paths, providers::OPENAI_CODEX_PROVIDER).unwrap();
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, "not json").unwrap();
assert!(read_catalog_cache(&paths, providers::OPENAI_CODEX_PROVIDER).is_none());
let now = Utc::now();
fs::write(
&path,
serde_json::json!({
"schema_version": 1,
"provider": providers::OPENAI_CODEX_PROVIDER,
"fetched_at": now,
"expires_at": now + Duration::hours(CATALOG_TTL_HOURS),
"entries": [ModelCatalogEntry::new_codex("gpt-5.2")]
})
.to_string(),
)
.unwrap();
assert!(read_catalog_cache(&paths, providers::OPENAI_CODEX_PROVIDER).is_none());
}
#[test]
fn model_catalog_switch_uses_fresh_cache_and_preserves_settings() {
let temp = TempDir::new().unwrap();
let paths = 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 = 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 = 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["selected_model"]["provider"],
providers::OPENAI_CODEX_PROVIDER
);
assert_eq!(value["selected_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 = 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 = 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_accepts_anthropic_catalog_model() {
let temp = TempDir::new().unwrap();
let paths = 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 = 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(),
crate::config::CustomProviderConfig {
label: "Local AI".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(),
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(),
},
),
(
"other-ai".to_string(),
crate::config::CustomProviderConfig {
label: "Other AI".to_string(),
base_url: "http://127.0.0.1:2/v1".to_string(),
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(),
},
),
]),
..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 = 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 = 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(),
crate::config::CustomProviderConfig {
label: "Local AI".to_string(),
base_url: "http://localhost:8080/v1".to_string(),
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(),
},
)]),
..Default::default()
},
)
.unwrap();
write_catalog_cache(
&paths,
"local-ai",
&[ModelCatalogEntry {
id: "local-ai/stale-model".to_string(),
provider: "local-ai".to_string(),
model: "stale-model".to_string(),
display_name: None,
description: None,
context_window: None,
max_context_window: None,
max_output_tokens: None,
reasoning_efforts: None,
supports_reasoning: None,
legacy_supports_reasoning_effort: None,
input_cost: None,
output_cost: None,
modalities: None,
}],
)
.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());
}
#[test]
fn models_dev_refresh_clears_removed_reasoning_capability() {
let mut entry = ModelCatalogEntry::new("local-ai", "gpt-test");
entry.supports_reasoning = Some(true);
entry.reasoning_efforts = Some(ThinkingLevel::EFFORT_GENERIC.to_vec());
let entries = enrich_with_models_dev_namespace(
vec![entry],
&serde_json::json!({"openai": {"models": {"gpt-test": {"reasoning": false}}}}),
"openai",
);
assert_eq!(entries[0].supports_reasoning, Some(false));
assert_eq!(entries[0].reasoning_efforts, None);
}
#[test]
fn models_dev_source_reports_partial_match_diagnostics() {
let lookup = SharedModelsDevLookup::from_seeded(&ModelsDevLookup {
attempted: true,
value: Some(serde_json::json!({"openai": {"models": {"gpt-test": {}}}})),
});
let source = models_dev_source(
&[
ModelCatalogEntry::new("local-ai", "gpt-test"),
ModelCatalogEntry::new("local-ai", "missing"),
],
Some(&lookup),
"openai",
);
assert!(matches!(source, CatalogSource::LivePartialMatch { .. }));
assert!(source.notice().unwrap().contains("missing"));
}
#[test]
fn automatic_refresh_failure_gate_bounds_retries_per_provider() {
let provider = "automatic-refresh-test-provider";
assert!(crate::model_catalog::automatic_refresh_allowed(provider));
crate::model_catalog::automatic_refresh_failed(provider);
assert!(!crate::model_catalog::automatic_refresh_allowed(provider));
assert!(crate::model_catalog::automatic_refresh_allowed(
"another-refresh-provider"
));
}
#[test]
fn automatic_refresh_outcome_tracks_selected_model_after_partial_match() {
let matched = {
let mut entry = ModelCatalogEntry::new("local-ai", "matched");
entry.supports_reasoning = Some(true);
entry
};
let unmatched = ModelCatalogEntry::new("local-ai", "selected-unmatched");
let entries = vec![matched, unmatched];
assert_eq!(
catalog_refresh_outcome_for_selected_model(&entries, "local-ai", "selected-unmatched"),
CatalogRefreshOutcome::NoMatch
);
assert_eq!(
catalog_refresh_outcome_for_selected_model(&entries, "local-ai", "matched"),
CatalogRefreshOutcome::Updated
);
let provider = "selected-model-partial-match-provider";
assert!(automatic_refresh_allowed(provider));
automatic_refresh_suppressed(provider);
assert!(!automatic_refresh_allowed(provider));
}
#[test]
fn automatic_refresh_no_match_gate_bounds_retries_without_affecting_explicit_refresh() {
let provider = "automatic-no-match-test-provider";
assert!(automatic_refresh_allowed(provider));
automatic_refresh_suppressed(provider);
assert!(!automatic_refresh_allowed(provider));
automatic_refresh_failed_at(provider, Instant::now() - AUTOMATIC_REFRESH_COOLDOWN);
assert!(automatic_refresh_allowed(provider));
}
#[test]
fn automatic_refresh_recovery_gate_is_deterministic_without_sleeping() {
let provider = "deterministic-refresh-provider";
let failed_at = Instant::now();
automatic_refresh_failed_at(provider, failed_at);
assert!(!automatic_refresh_allowed_at(provider, failed_at));
assert!(!automatic_refresh_allowed_at(
provider,
failed_at + AUTOMATIC_REFRESH_COOLDOWN - StdDuration::from_millis(1)
));
assert!(automatic_refresh_allowed_at(
provider,
failed_at + AUTOMATIC_REFRESH_COOLDOWN
));
}
#[test]
fn refresh_failure_reports_safe_settings_category() {
let temp = TempDir::new().unwrap();
fs::create_dir_all(temp.path().join("mc")).unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::write(&paths.settings_file, "not json").unwrap();
let error =
refresh_catalog_for_provider(&paths, "custom-provider", "selected-model").unwrap_err();
assert_eq!(error.category(), CatalogRefreshFailureCategory::Settings);
assert_eq!(
error.to_string(),
"settings refresh failed for provider 'custom-provider'"
);
}