use super::super::cache::{
CATALOG_TTL_HOURS, CatalogCacheFingerprint, ENRICHMENT_VERSION, read_catalog_cache,
read_custom_provider_catalog_cache, write_catalog_cache_with_fingerprint,
write_custom_provider_catalog_cache,
};
use super::super::types::ModelCatalogServiceTier;
use super::super::{
CachePreference, ModelCatalogEntry, cached_codex_fast_service_tier,
cached_model_context_window, cached_model_max_output_tokens, cached_model_thinking_metadata,
load_custom_provider_catalog, write_catalog_cache,
};
use super::custom_provider_config_with_models_dev_provider;
use crate::{
config::CustomProviderConfig,
providers,
thinking::{self, ThinkingCapabilityScope, ThinkingLevel},
};
use chrono::{Duration, Utc};
use std::fs;
use tempfile::TempDir;
#[test]
fn catalog_cache_read_treats_oversized_file_as_stale() {
let temp = TempDir::new().unwrap();
let paths = super::super::McPaths::from_root(temp.path().join("mc"));
let path = super::super::catalog_cache_path(&paths, providers::OPENAI_CODEX_PROVIDER).unwrap();
fs::create_dir_all(path.parent().unwrap()).unwrap();
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 model_catalog_cache_round_trips_under_mc_cache_without_secrets() {
let temp = TempDir::new().unwrap();
let paths = super::super::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 = super::super::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 codex_catalog_cache_fingerprint_invalidates_pre_version_cache() {
let temp = TempDir::new().unwrap();
let paths = super::super::McPaths::from_root(temp.path().join("mc"));
let legacy_fingerprint = CatalogCacheFingerprint {
enrichment_version: Some(ENRICHMENT_VERSION),
..Default::default()
};
write_catalog_cache_with_fingerprint(
&paths,
providers::OPENAI_CODEX_PROVIDER,
&legacy_fingerprint,
&[ModelCatalogEntry::new_codex("gpt-test")],
)
.unwrap();
assert!(
read_catalog_cache(&paths, providers::OPENAI_CODEX_PROVIDER).is_none(),
"a Codex cache without client-version identity must not be reused"
);
write_catalog_cache(
&paths,
providers::OPENAI_CODEX_PROVIDER,
&[ModelCatalogEntry::new_codex("gpt-test")],
)
.unwrap();
let cached = read_catalog_cache(&paths, providers::OPENAI_CODEX_PROVIDER)
.expect("cache written with current Codex identity");
assert_eq!(
cached.cache_fingerprint.codex_client_version.as_deref(),
Some(crate::providers::CODEX_MODEL_CATALOG_CLIENT_VERSION)
);
}
#[test]
fn codex_catalog_cache_invalidates_previous_enrichment_version() {
let temp = TempDir::new().unwrap();
let paths = super::super::McPaths::from_root(temp.path().join("mc"));
let previous_fingerprint = CatalogCacheFingerprint {
enrichment_version: Some(ENRICHMENT_VERSION - 1),
codex_client_version: Some(
crate::providers::CODEX_MODEL_CATALOG_CLIENT_VERSION.to_string(),
),
..Default::default()
};
write_catalog_cache_with_fingerprint(
&paths,
providers::OPENAI_CODEX_PROVIDER,
&previous_fingerprint,
&[ModelCatalogEntry::new_codex("gpt-6-astra")],
)
.unwrap();
assert!(
read_catalog_cache(&paths, providers::OPENAI_CODEX_PROVIDER).is_none(),
"a catalog without provider reasoning metadata must not be reused"
);
}
#[test]
fn model_catalog_cache_ignores_custom_provider_endpoint_mode() {
let temp = TempDir::new().unwrap();
let paths = super::super::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 = super::super::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 = super::super::McPaths::from_root(temp.path().join("mc"));
let custom = CustomProviderConfig {
label: "OpenAI Compatible".to_string(),
base_url: "https://api.openai.com/v1".to_string(),
fast_mode: None,
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(),
request_headers: Default::default(),
};
let legacy_fingerprint = CatalogCacheFingerprint {
enrichment_version: Some(ENRICHMENT_VERSION),
models_dev_provider: None,
extra_models: Vec::new(),
..Default::default()
};
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 = super::super::McPaths::from_root(temp.path().join("mc"));
let custom = CustomProviderConfig {
label: "OpenAI Compatible".to_string(),
base_url: "https://api.openai.com/v1".to_string(),
fast_mode: None,
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(),
request_headers: Default::default(),
};
let old_fingerprint = CatalogCacheFingerprint {
enrichment_version: Some(ENRICHMENT_VERSION),
models_dev_provider: Some("openai".to_string()),
extra_models: Vec::new(),
..Default::default()
};
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 = super::super::McPaths::from_root(temp.path().join("mc"));
let path = super::super::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 = super::super::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 = super::super::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!(
super::super::catalog_cache_path(&paths, "local-ai").is_some_and(|path| !path.exists())
);
}
#[test]
fn cached_model_context_window_ignores_output_token_limit() {
let temp = TempDir::new().unwrap();
let paths = super::super::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 = 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(),
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!(
thinking::available_thinking_levels(
"local-ai",
"gpt-test",
Some(&metadata),
ThinkingCapabilityScope::BuiltIn
),
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!(
thinking::available_thinking_levels(
"local-ai",
"plain-test",
None,
ThinkingCapabilityScope::BuiltIn
),
vec![ThinkingLevel::Default]
);
}
#[test]
fn codex_catalog_reasoning_metadata_reaches_thinking_choices() {
let temp = TempDir::new().unwrap();
let paths = super::super::McPaths::from_root(temp.path().join("mc"));
let mut entry = ModelCatalogEntry::new_codex("gpt-6-astra");
entry.reasoning_efforts = Some(ThinkingLevel::ALL.to_vec());
entry.supports_reasoning = Some(true);
write_catalog_cache(&paths, providers::OPENAI_CODEX_PROVIDER, &[entry]).unwrap();
let metadata =
cached_model_thinking_metadata(&paths, providers::OPENAI_CODEX_PROVIDER, "gpt-6-astra")
.unwrap();
assert_eq!(
metadata.reasoning_efforts,
Some(ThinkingLevel::ALL.to_vec())
);
assert_eq!(
thinking::available_thinking_levels(
providers::OPENAI_CODEX_PROVIDER,
"gpt-6-astra",
Some(&metadata),
ThinkingCapabilityScope::BuiltIn,
),
ThinkingLevel::ALL.to_vec()
);
}
#[test]
fn cached_model_context_window_reads_selected_catalog_entry() {
let temp = TempDir::new().unwrap();
let paths = super::super::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 codex_fast_service_tier_resolution_honors_metadata_and_version_boundaries() {
let mut explicit = ModelCatalogEntry::new_codex("gpt-5.3");
explicit.service_tiers = Some(vec![ModelCatalogServiceTier {
id: "priority".to_string(),
name: "FaSt".to_string(),
description: None,
}]);
assert_eq!(
super::super::resolve_codex_fast_service_tier(
providers::OPENAI_CODEX_PROVIDER,
"gpt-5.3",
&explicit,
),
Some("priority".to_string())
);
let mut explicit_empty = ModelCatalogEntry::new_codex("gpt-5.6");
explicit_empty.service_tiers = Some(Vec::new());
assert_eq!(
super::super::resolve_codex_fast_service_tier(
providers::OPENAI_CODEX_PROVIDER,
"gpt-5.6",
&explicit_empty,
),
None
);
for (model, expected) in [
("gpt-5.3", None),
("gpt-5.4", Some("fast")),
("gpt-5.4-preview", Some("fast")),
("gpt-6.0-future", Some("fast")),
("sol", Some("fast")),
("terra", Some("fast")),
("luna", Some("fast")),
("gpt-5.6", Some("fast")),
("gpt-5", None),
("not-gpt-5.5", None),
("gpt-5.4x", None),
] {
let entry = ModelCatalogEntry::new_codex(model);
assert_eq!(
super::super::resolve_codex_fast_service_tier(
providers::OPENAI_CODEX_PROVIDER,
model,
&entry,
),
expected.map(str::to_string),
"model={model}"
);
}
let entry = ModelCatalogEntry::new("openai", "gpt-5.5");
assert_eq!(
super::super::resolve_codex_fast_service_tier("openai", "gpt-5.5", &entry),
None
);
}
#[test]
fn cached_codex_fast_service_tier_resolves_alias_from_catalog() {
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-sol")],
)
.unwrap();
assert_eq!(
cached_codex_fast_service_tier(&paths, "sol"),
Some("fast".to_string())
);
assert_eq!(cached_codex_fast_service_tier(&paths, "gpt-5.3"), None);
}
#[test]
fn cached_codex_fast_service_tier_falls_back_without_cache_or_matching_entry() {
let temp = TempDir::new().unwrap();
let paths = super::super::McPaths::from_root(temp.path().join("mc"));
assert_eq!(
cached_codex_fast_service_tier(&paths, "gpt-5.4"),
Some("fast".to_string())
);
assert_eq!(cached_codex_fast_service_tier(&paths, "gpt-5.3"), None);
write_catalog_cache(
&paths,
providers::OPENAI_CODEX_PROVIDER,
&[ModelCatalogEntry::new_codex("gpt-5.3")],
)
.unwrap();
assert_eq!(
cached_codex_fast_service_tier(&paths, "gpt-5.4"),
Some("fast".to_string())
);
let mut explicit_empty = ModelCatalogEntry::new_codex("gpt-5.4");
explicit_empty.service_tiers = Some(Vec::new());
write_catalog_cache(&paths, providers::OPENAI_CODEX_PROVIDER, &[explicit_empty]).unwrap();
assert_eq!(cached_codex_fast_service_tier(&paths, "gpt-5.4"), None);
}
#[test]
fn cached_model_max_output_tokens_reads_selected_catalog_entry() {
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.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 model_catalog_cache_rejects_bad_provider_path_corrupt_json_and_old_schema() {
let temp = TempDir::new().unwrap();
let paths = super::super::McPaths::from_root(temp.path().join("mc"));
assert!(super::super::catalog_cache_path(&paths, "../bad").is_none());
let path = super::super::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());
}