use super::*; use crate::ModelsManagerConfig; use crate::cache::FileModelsCache; use crate::cache::ModelsCache; use crate::cache::ModelsCacheEntry; use crate::cache::ModelsCacheError; use crate::cache::ModelsCacheFuture; use chrono::Utc; use codex_http_client::HttpClientFactory; use codex_http_client::OutboundProxyPolicy; use codex_login::AuthCredentialsStoreMode; use codex_login::AuthKeyringBackendKind; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_login::ExternalAuth; use codex_login::ExternalAuthRefreshContext; use codex_login::TokenData; use codex_protocol::auth::AuthMode; use codex_protocol::openai_models::ModelAccessPrograms; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::turn_input::CyberAccessProgram; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::VecDeque; use std::path::Path; use std::sync::Arc; use std::sync::Mutex; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; use tempfile::tempdir; #[path = "api_key_discovery_tests.rs"] mod api_key_discovery_tests; #[path = "cache_identity_tests.rs"] mod cache_identity_tests; #[path = "model_info_overrides_tests.rs"] mod model_info_overrides_tests; const DEFAULT_HTTP_CLIENT_FACTORY: HttpClientFactory = HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault); fn remote_model(slug: &str, display: &str, priority: i32) -> ModelInfo { remote_model_with_visibility(slug, display, priority, "list") } fn remote_model_with_visibility( slug: &str, display: &str, priority: i32, visibility: &str, ) -> ModelInfo { serde_json::from_value(json!({ "slug": slug, "display_name": display, "description": format!("{display} desc"), "default_reasoning_level": "medium", "supported_reasoning_levels": [{"effort": "low", "description": "low"}, {"effort": "medium", "description": "medium"}], "shell_type": "shell_command", "visibility": visibility, "minimal_client_version": [0, 1, 0], "supported_in_api": true, "priority": priority, "upgrade": null, "model_messages": { "instructions_template": "base instructions", "instructions_variables": null, "approvals": null, "auto_review": null, "permissions": null }, "support_verbosity": false, "default_verbosity": null, "apply_patch_tool_type": null, "truncation_policy": {"mode": "bytes", "limit": 10_000}, "supports_image_detail_original": false, "context_window": 272_000, "max_context_window": 272_000, "experimental_supported_tools": [], })) .expect("valid model") } fn assert_models_contain(actual: &[ModelInfo], expected: &[ModelInfo]) { for model in expected { assert!( actual.iter().any(|candidate| candidate.slug == model.slug), "expected model {} in cached list", model.slug ); } } #[derive(Debug)] struct TestModelsEndpoint { has_command_auth: bool, uses_codex_backend: bool, responses: Mutex>>, etag: Option, fetch_count: AtomicUsize, observed_proxy_policy: Mutex>, } #[derive(Debug)] struct TestModelsCache { entry: Mutex>, load_error: bool, store_error: bool, stored_entries: Mutex>, } impl TestModelsCache { fn with_entry(entry: ModelsCacheEntry) -> Arc { Arc::new(Self { entry: Mutex::new(Some(entry)), load_error: false, store_error: false, stored_entries: Mutex::new(Vec::new()), }) } fn failing(load_error: bool, store_error: bool) -> Arc { Arc::new(Self { entry: Mutex::new(None), load_error, store_error, stored_entries: Mutex::new(Vec::new()), }) } fn stored_entries(&self) -> Vec { self.stored_entries .lock() .expect("stored entries lock should not be poisoned") .clone() } } impl ModelsCache for TestModelsCache { fn load<'a>( &'a self, _client_version: &'a str, ) -> ModelsCacheFuture<'a, Result, ModelsCacheError>> { Box::pin(async move { if self.load_error { return Err(ModelsCacheError::new("test load failure")); } Ok(self .entry .lock() .expect("cache entry lock should not be poisoned") .clone()) }) } fn store<'a>( &'a self, entry: &'a ModelsCacheEntry, ) -> ModelsCacheFuture<'a, Result<(), ModelsCacheError>> { Box::pin(async move { if self.store_error { return Err(ModelsCacheError::new("test store failure")); } self.stored_entries .lock() .expect("stored entries lock should not be poisoned") .push(entry.clone()); Ok(()) }) } fn refresh_ttl<'a>( &'a self, _client_version: &'a str, _identity: &'a str, _etag: &'a str, ) -> ModelsCacheFuture<'a, Result<(), ModelsCacheError>> { Box::pin(async move { let refreshed = { let mut entry = self .entry .lock() .expect("cache entry lock should not be poisoned"); let entry = entry .as_mut() .ok_or_else(|| ModelsCacheError::new("cache not found"))?; entry.fetched_at = Utc::now(); entry.clone() }; self.store(&refreshed).await }) } } impl TestModelsEndpoint { fn new(responses: Vec>) -> Arc { Arc::new(Self { has_command_auth: false, uses_codex_backend: true, responses: Mutex::new(responses.into()), etag: None, fetch_count: AtomicUsize::new(0), observed_proxy_policy: Mutex::new(None), }) } fn without_refresh(responses: Vec>) -> Arc { Arc::new(Self { has_command_auth: false, uses_codex_backend: false, responses: Mutex::new(responses.into()), etag: None, fetch_count: AtomicUsize::new(0), observed_proxy_policy: Mutex::new(None), }) } fn fetch_count(&self) -> usize { self.fetch_count.load(Ordering::SeqCst) } fn observed_proxy_policy(&self) -> Option { *self .observed_proxy_policy .lock() .expect("observed proxy policy lock should not be poisoned") } async fn list_models(&self) -> CoreResult { self.fetch_count.fetch_add(1, Ordering::SeqCst); let models = self .responses .lock() .expect("responses lock should not be poisoned") .pop_front() .unwrap_or_default(); Ok(ModelsEndpointResponse { models, etag: self.etag.clone(), identity: self.identity().expect("test endpoint identity"), }) } } #[derive(Debug)] struct TestExternalApiKeyAuth; impl ExternalAuth for TestExternalApiKeyAuth { fn resolve(&self) -> codex_login::ExternalAuthFuture<'_, CodexAuth> { Box::pin(async { Ok(CodexAuth::from_api_key("test-external-api-key")) }) } fn refresh( &self, _context: ExternalAuthRefreshContext, ) -> codex_login::ExternalAuthFuture<'_, CodexAuth> { Box::pin(async { Ok(CodexAuth::from_api_key("test-external-api-key")) }) } } #[derive(Debug)] struct TestUnresolvedExternalApiKeyAuth; impl ExternalAuth for TestUnresolvedExternalApiKeyAuth { fn resolve(&self) -> codex_login::ExternalAuthFuture<'_, CodexAuth> { Box::pin(async { Err(std::io::Error::other("unresolved test auth")) }) } fn refresh( &self, _context: ExternalAuthRefreshContext, ) -> codex_login::ExternalAuthFuture<'_, CodexAuth> { Box::pin(async { Err(std::io::Error::other("unresolved test auth")) }) } } impl ModelsEndpointClient for TestModelsEndpoint { fn supports_api_key_models(&self) -> bool { true } fn identity(&self) -> Option { Some("test-provider".to_string()) } fn has_command_auth(&self) -> bool { self.has_command_auth } fn uses_codex_backend(&self) -> ModelsEndpointFuture<'_, bool> { Box::pin(async { self.uses_codex_backend }) } fn list_models<'a>( &'a self, _client_version: &'a str, http_client_factory: HttpClientFactory, ) -> ModelsEndpointFuture<'a, CoreResult> { Box::pin(async move { *self .observed_proxy_policy .lock() .expect("observed proxy policy lock should not be poisoned") = Some(http_client_factory.outbound_proxy_policy()); TestModelsEndpoint::list_models(self).await }) } } fn openai_manager_for_tests( codex_home: std::path::PathBuf, endpoint_client: Arc, ) -> OpenAiModelsManager { openai_manager_for_tests_with_auth( codex_home, endpoint_client, Some(AuthManager::from_auth_for_testing( CodexAuth::create_dummy_chatgpt_auth_for_testing(), )), ) } fn openai_manager_for_tests_with_auth( codex_home: std::path::PathBuf, endpoint_client: Arc, auth_manager: Option>, ) -> OpenAiModelsManager { OpenAiModelsManager::new(codex_home, endpoint_client, auth_manager) } async fn mutate_file_cache_for_test(codex_home: &Path, f: F) where F: FnOnce(&mut ModelsCacheEntry), { let client_version = crate::client_version_to_whole(); let cache = FileModelsCache::new(codex_home.join(MODEL_CACHE_FILE), DEFAULT_MODEL_CACHE_TTL); let mut entry = cache .load(&client_version) .await .expect("cache load succeeds") .expect("cache entry exists"); f(&mut entry); cache.store(&entry).await.expect("cache store succeeds"); } fn static_manager_for_tests(model_catalog: ModelsResponse) -> StaticModelsManager { StaticModelsManager::new(/*auth_manager*/ None, model_catalog) } #[tokio::test] async fn file_cache_implements_models_cache_contract() { let codex_home = tempdir().expect("temp dir"); let cache = FileModelsCache::new( codex_home.path().join(MODEL_CACHE_FILE), DEFAULT_MODEL_CACHE_TTL, ); let client_version = crate::client_version_to_whole(); let entry = ModelsCacheEntry { identity: Some("test-provider".to_string()), fetched_at: Utc::now(), etag: Some("file-etag".to_string()), client_version: Some(client_version.clone()), models: vec![ModelInfo { available_access_programs: Some(ModelAccessPrograms { cyber: vec![ CyberAccessProgram::Standard, CyberAccessProgram::DaybreakBlue, ], }), ..remote_model("file-cached", "File Cached", /*priority*/ 0) }], }; cache.store(&entry).await.expect("cache store succeeds"); assert_eq!( cache .load(&client_version) .await .expect("cache load succeeds"), Some(entry) ); } #[tokio::test] async fn file_cache_refresh_ttl_renews_expired_entry_without_serving_it_stale() { let codex_home = tempdir().expect("temp dir"); let cache = FileModelsCache::new( codex_home.path().join(MODEL_CACHE_FILE), DEFAULT_MODEL_CACHE_TTL, ); let client_version = crate::client_version_to_whole(); let expired_at = Utc::now() - chrono::Duration::hours(1); let entry = ModelsCacheEntry { identity: Some("test-provider".to_string()), fetched_at: expired_at, etag: Some("expired-etag".to_string()), client_version: Some(client_version.clone()), models: vec![remote_model( "expired-file-cache", "Expired File Cache", /*priority*/ 0, )], }; cache.store(&entry).await.expect("cache store succeeds"); assert_eq!( cache .load(&client_version) .await .expect("cache load succeeds"), None, "an expired entry must not be returned before revalidation" ); cache .refresh_ttl( &client_version, entry.identity.as_deref().unwrap(), entry.etag.as_deref().unwrap(), ) .await .expect("TTL refresh succeeds"); let refreshed = cache .load(&client_version) .await .expect("cache load succeeds") .expect("revalidated entry is fresh"); assert!(refreshed.fetched_at > expired_at); let mut expected = entry; expected.fetched_at = refreshed.fetched_at; assert_eq!(refreshed, expected); } #[tokio::test] async fn manager_without_cache_fetches_on_every_refresh() { let remote_models = vec![remote_model("remote", "Remote", /*priority*/ 0)]; let endpoint = TestModelsEndpoint::new(vec![remote_models.clone(), remote_models.clone()]); let manager = OpenAiModelsManager::new_without_cache( endpoint.clone(), Some(AuthManager::from_auth_for_testing( CodexAuth::create_dummy_chatgpt_auth_for_testing(), )), ); let catalog = manager .raw_model_catalog( RefreshStrategy::OnlineIfUncached, DEFAULT_HTTP_CLIENT_FACTORY, ) .await; let second_catalog = manager .raw_model_catalog( RefreshStrategy::OnlineIfUncached, DEFAULT_HTTP_CLIENT_FACTORY, ) .await; assert_eq!(catalog.models, remote_models); assert_eq!(second_catalog, catalog); assert_eq!(manager.get_remote_models().await, remote_models); assert_eq!(endpoint.fetch_count(), 2); } #[tokio::test] async fn injected_cache_hit_avoids_remote_fetch() { let cached_models = vec![remote_model("cached", "Cached", /*priority*/ 0)]; let cache = TestModelsCache::with_entry(ModelsCacheEntry { identity: Some("test-provider".to_string()), fetched_at: Utc::now(), etag: Some("cached-etag".to_string()), client_version: Some(crate::client_version_to_whole()), models: cached_models.clone(), }); let endpoint = TestModelsEndpoint::new(vec![vec![remote_model( "remote", "Remote", /*priority*/ 0, )]]); let manager = OpenAiModelsManager::new_with_cache( cache, endpoint.clone(), Some(AuthManager::from_auth_for_testing( CodexAuth::create_dummy_chatgpt_auth_for_testing(), )), ); let catalog = manager .raw_model_catalog( RefreshStrategy::OnlineIfUncached, DEFAULT_HTTP_CLIENT_FACTORY, ) .await; assert_eq!(catalog.models, cached_models); assert_eq!(endpoint.fetch_count(), 0); } #[tokio::test] async fn injected_cache_read_error_falls_back_and_persists_remote_models() { let remote_models = vec![remote_model("remote", "Remote", /*priority*/ 0)]; let cache = TestModelsCache::failing(/*load_error*/ true, /*store_error*/ false); let endpoint = TestModelsEndpoint::new(vec![remote_models.clone()]); let manager = OpenAiModelsManager::new_with_cache( cache.clone(), endpoint.clone(), Some(AuthManager::from_auth_for_testing( CodexAuth::create_dummy_chatgpt_auth_for_testing(), )), ); let catalog = manager .raw_model_catalog( RefreshStrategy::OnlineIfUncached, DEFAULT_HTTP_CLIENT_FACTORY, ) .await; assert_eq!(catalog.models, remote_models); assert_eq!(endpoint.fetch_count(), 1); let stored_entries = cache.stored_entries(); assert_eq!( stored_entries, vec![ModelsCacheEntry { identity: Some("test-provider".to_string()), fetched_at: stored_entries[0].fetched_at, etag: None, client_version: Some(crate::client_version_to_whole()), models: remote_models, }] ); } #[tokio::test] async fn injected_cache_write_error_does_not_fail_remote_refresh() { let remote_models = vec![remote_model("remote", "Remote", /*priority*/ 0)]; let cache = TestModelsCache::failing(/*load_error*/ false, /*store_error*/ true); let endpoint = TestModelsEndpoint::new(vec![remote_models.clone()]); let manager = OpenAiModelsManager::new_with_cache( cache, endpoint.clone(), Some(AuthManager::from_auth_for_testing( CodexAuth::create_dummy_chatgpt_auth_for_testing(), )), ); let catalog = manager .raw_model_catalog( RefreshStrategy::OnlineIfUncached, DEFAULT_HTTP_CLIENT_FACTORY, ) .await; assert_eq!(catalog.models, remote_models); assert_eq!(endpoint.fetch_count(), 1); } #[tokio::test] async fn injected_cache_ttl_refresh_preserves_cached_payload() { let cached_models = vec![remote_model("cached", "Cached", /*priority*/ 0)]; let cached_at = Utc::now() - chrono::Duration::minutes(1); let cache = TestModelsCache::with_entry(ModelsCacheEntry { identity: Some("test-provider".to_string()), fetched_at: cached_at, etag: Some("cached-etag".to_string()), client_version: Some(crate::client_version_to_whole()), models: cached_models.clone(), }); let manager = OpenAiModelsManager::new_with_cache( cache.clone(), TestModelsEndpoint::new(Vec::new()), Some(AuthManager::from_auth_for_testing(CodexAuth::from_api_key( "test-api-key", ))), ); manager.set_api_key_model_discovery_enabled(/*enabled*/ true); manager .raw_model_catalog( RefreshStrategy::OnlineIfUncached, DEFAULT_HTTP_CLIENT_FACTORY, ) .await; manager .refresh_if_new_etag("cached-etag".to_string(), DEFAULT_HTTP_CLIENT_FACTORY) .await; let stored_entries = cache.stored_entries(); assert_eq!(stored_entries.len(), 1); assert!(stored_entries[0].fetched_at > cached_at); assert_eq!(stored_entries[0].etag.as_deref(), Some("cached-etag")); assert_eq!( stored_entries[0].client_version, Some(crate::client_version_to_whole()) ); assert_eq!(stored_entries[0].models, cached_models); } async fn chatgpt_auth_tokens_for_tests(codex_home: &Path) -> CodexAuth { let auth_dot_json = codex_login::AuthDotJson { auth_mode: Some(AuthMode::ChatgptAuthTokens), openai_api_key: None, tokens: Some(TokenData { id_token: codex_login::token_data::parse_chatgpt_jwt_claims( "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.\ eyJlbWFpbCI6InVzZXJAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9wbGFuX3R5cGUiOiJwcm8iLCJjaGF0Z3B0X3VzZXJfaWQiOiJ1c2VyLWlkIiwiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjb3VudC1pZCJ9fQ.\ c2ln", ) .expect("fake id token should parse"), access_token: "Access Token".to_string(), refresh_token: "test".to_string(), account_id: Some("account_id".to_string()), }), last_refresh: Some(Utc::now()), agent_identity: None, personal_access_token: None, bedrock_api_key: None, bedrock_access_keys: None, }; std::fs::create_dir_all(codex_home).expect("codex home should be created"); std::fs::write( codex_home.join("auth.json"), serde_json::to_string(&auth_dot_json).expect("auth should serialize"), ) .expect("auth.json should be written"); CodexAuth::from_auth_storage( codex_home, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), &codex_login::test_support::transport_default_auth_route_config(), ) .await .expect("auth should load") .expect("auth should be present") } #[tokio::test] async fn static_manager_preserves_supported_requested_model_when_fallback_is_allowed() { let manager = static_manager_for_tests(ModelsResponse { models: vec![ remote_model("provider-default", "Default", /*priority*/ 0), remote_model("provider-supported", "Supported", /*priority*/ 1), ], }); let requested_model = Some("provider-supported".to_string()); let model = manager .get_default_model( &requested_model, /*allow_provider_model_fallback*/ true, RefreshStrategy::Offline, DEFAULT_HTTP_CLIENT_FACTORY, ) .await; assert_eq!(model, "provider-supported"); } #[tokio::test] async fn static_manager_falls_back_from_unsupported_requested_model_when_allowed() { let manager = static_manager_for_tests(ModelsResponse { models: vec![ remote_model("provider-default", "Default", /*priority*/ 0), remote_model("provider-supported", "Supported", /*priority*/ 1), ], }); let requested_model = Some("unsupported".to_string()); let model = manager .get_default_model( &requested_model, /*allow_provider_model_fallback*/ true, RefreshStrategy::Offline, DEFAULT_HTTP_CLIENT_FACTORY, ) .await; assert_eq!(model, "provider-default"); } #[tokio::test] async fn static_manager_preserves_unsupported_requested_model_when_fallback_is_disabled() { let manager = static_manager_for_tests(ModelsResponse { models: vec![remote_model( "provider-default", "Default", /*priority*/ 0, )], }); let requested_model = Some("unsupported".to_string()); let model = manager .get_default_model( &requested_model, /*allow_provider_model_fallback*/ false, RefreshStrategy::Offline, DEFAULT_HTTP_CLIENT_FACTORY, ) .await; assert_eq!(model, "unsupported"); } #[tokio::test] async fn static_manager_uses_empty_default_when_fallback_is_allowed_and_catalog_is_empty() { let manager = static_manager_for_tests(ModelsResponse { models: Vec::new() }); let requested_model = Some("unsupported".to_string()); let model = manager .get_default_model( &requested_model, /*allow_provider_model_fallback*/ true, RefreshStrategy::Offline, DEFAULT_HTTP_CLIENT_FACTORY, ) .await; assert_eq!(model, ""); } #[tokio::test] async fn dynamic_manager_preserves_requested_model_when_fallback_is_allowed() { let codex_home = tempdir().expect("temp dir"); let endpoint = TestModelsEndpoint::new(Vec::new()); let manager = openai_manager_for_tests(codex_home.path().to_path_buf(), endpoint.clone()); let requested_model = Some("unsupported".to_string()); let model = manager .get_default_model( &requested_model, /*allow_provider_model_fallback*/ true, RefreshStrategy::Online, DEFAULT_HTTP_CLIENT_FACTORY, ) .await; assert_eq!(model, "unsupported"); assert_eq!(endpoint.fetch_count(), 0); } #[tokio::test] async fn get_model_info_tracks_fallback_usage() { let codex_home = tempdir().expect("temp dir"); let config = ModelsManagerConfig::default(); let manager = openai_manager_for_tests( codex_home.path().to_path_buf(), TestModelsEndpoint::new(Vec::new()), ); let known_slug = manager .get_remote_models() .await .first() .expect("bundled models should include at least one model") .slug .clone(); let known = manager.get_model_info(known_slug.as_str(), &config).await; assert!(!known.used_fallback_model_metadata); assert_eq!(known.slug, known_slug); let unknown = manager .get_model_info("model-that-does-not-exist", &config) .await; assert!(unknown.used_fallback_model_metadata); assert_eq!(unknown.slug, "model-that-does-not-exist"); } #[tokio::test] async fn get_model_info_applies_long_context_override_to_bundled_gpt_5_6_models() { let codex_home = tempdir().expect("temp dir"); let manager = openai_manager_for_tests( codex_home.path().to_path_buf(), TestModelsEndpoint::new(Vec::new()), ); let config = ModelsManagerConfig { model_context_window: Some(1_000_000), ..Default::default() }; for slug in ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"] { let model_info = manager.get_model_info(slug, &config).await; let mut expected = manager .get_model_info(slug, &ModelsManagerConfig::default()) .await; expected.context_window = Some(872_000); assert_eq!(model_info, expected); } } #[tokio::test] async fn get_model_info_uses_custom_catalog() { let config = ModelsManagerConfig::default(); let mut overlay = remote_model("gpt-overlay", "Overlay", /*priority*/ 0); overlay.supports_image_detail_original = true; let manager = static_manager_for_tests(ModelsResponse { models: vec![overlay], }); let model_info = manager .get_model_info("gpt-overlay-experiment", &config) .await; assert_eq!(model_info.slug, "gpt-overlay-experiment"); assert_eq!(model_info.display_name, "Overlay"); assert_eq!(model_info.context_window, Some(272_000)); assert!(model_info.supports_image_detail_original); assert!(!model_info.used_fallback_model_metadata); } #[tokio::test] async fn get_model_info_matches_namespaced_suffix() { let config = ModelsManagerConfig::default(); let mut remote = remote_model("gpt-image", "Image", /*priority*/ 0); remote.supports_image_detail_original = true; let manager = static_manager_for_tests(ModelsResponse { models: vec![remote], }); let namespaced_model = "custom/gpt-image".to_string(); let model_info = manager.get_model_info(&namespaced_model, &config).await; assert_eq!(model_info.slug, namespaced_model); assert!(model_info.supports_image_detail_original); assert!(!model_info.used_fallback_model_metadata); } #[tokio::test] async fn get_model_info_matches_hyphenated_provider_namespace_suffix() { let config = ModelsManagerConfig::default(); let remote = remote_model("gpt-image", "Image", /*priority*/ 0); let manager = static_manager_for_tests(ModelsResponse { models: vec![remote], }); let namespaced_model = "openai-codex/gpt-image".to_string(); let model_info = manager.get_model_info(&namespaced_model, &config).await; assert_eq!(model_info.slug, namespaced_model); assert!(!model_info.used_fallback_model_metadata); } #[tokio::test] async fn get_model_info_rejects_multi_segment_namespace_suffix_matching() { let codex_home = tempdir().expect("temp dir"); let config = ModelsManagerConfig::default(); let manager = openai_manager_for_tests( codex_home.path().to_path_buf(), TestModelsEndpoint::new(Vec::new()), ); let known_slug = manager .get_remote_models() .await .first() .expect("bundled models should include at least one model") .slug .clone(); let namespaced_model = format!("ns1/ns2/{known_slug}"); let model_info = manager.get_model_info(&namespaced_model, &config).await; assert_eq!(model_info.slug, namespaced_model); assert!(model_info.used_fallback_model_metadata); } #[tokio::test] async fn refresh_available_models_sorts_by_priority() { let remote_models = vec![ remote_model("priority-low", "Low", /*priority*/ 1), remote_model("priority-high", "High", /*priority*/ 0), ]; let codex_home = tempdir().expect("temp dir"); let endpoint = TestModelsEndpoint::new(vec![remote_models.clone()]); let manager = openai_manager_for_tests(codex_home.path().to_path_buf(), endpoint.clone()); let available = manager .list_models( RefreshStrategy::Online, HttpClientFactory::new(OutboundProxyPolicy::RespectSystemProxy), ) .await; assert_models_contain(&manager.get_remote_models().await, &remote_models); assert_eq!( endpoint.observed_proxy_policy(), Some(OutboundProxyPolicy::RespectSystemProxy) ); let high_idx = available .iter() .position(|model| model.model == "priority-high") .expect("priority-high should be listed"); let low_idx = available .iter() .position(|model| model.model == "priority-low") .expect("priority-low should be listed"); assert!( high_idx < low_idx, "higher priority should be listed before lower priority" ); assert_eq!(endpoint.fetch_count(), 1, "expected a single model fetch"); } #[tokio::test] async fn refresh_available_models_uses_remote_only_catalog_for_chatgpt_auth() { let remote_models = vec![remote_model( "chatgpt-visible-source-of-truth", "ChatGPT Visible", /*priority*/ 0, )]; let codex_home = tempdir().expect("temp dir"); let endpoint = TestModelsEndpoint::new(vec![remote_models.clone()]); let manager = openai_manager_for_tests(codex_home.path().to_path_buf(), endpoint.clone()); manager .refresh_available_models( RefreshStrategy::OnlineIfUncached, &DEFAULT_HTTP_CLIENT_FACTORY, ) .await .expect("refresh succeeds"); assert_eq!(manager.get_remote_models().await, remote_models); assert_eq!(endpoint.fetch_count(), 1, "expected a single model fetch"); } #[tokio::test] async fn refresh_available_models_uses_cached_remote_only_catalog_for_chatgpt_auth() { let remote_models = vec![remote_model( "chatgpt-cached-source-of-truth", "ChatGPT Cached", /*priority*/ 0, )]; let codex_home = tempdir().expect("temp dir"); let fetch_endpoint = TestModelsEndpoint::new(vec![remote_models.clone()]); let fetch_manager = openai_manager_for_tests(codex_home.path().to_path_buf(), fetch_endpoint.clone()); fetch_manager .refresh_available_models( RefreshStrategy::OnlineIfUncached, &DEFAULT_HTTP_CLIENT_FACTORY, ) .await .expect("initial refresh succeeds"); let cache_endpoint = TestModelsEndpoint::new(Vec::new()); let cache_manager = openai_manager_for_tests(codex_home.path().to_path_buf(), cache_endpoint.clone()); cache_manager .refresh_available_models( RefreshStrategy::OnlineIfUncached, &DEFAULT_HTTP_CLIENT_FACTORY, ) .await .expect("cached refresh succeeds"); assert_eq!(cache_manager.get_remote_models().await, remote_models); assert_eq!( cache_endpoint.fetch_count(), 0, "fresh cache should avoid a model fetch" ); } #[tokio::test] async fn get_model_info_uses_fallback_for_bundled_models_when_chatgpt_remote_is_authoritative() { let remote_models = vec![remote_model( "chatgpt-authoritative-model-info", "ChatGPT Model Info", /*priority*/ 0, )]; let codex_home = tempdir().expect("temp dir"); let endpoint = TestModelsEndpoint::new(vec![remote_models]); let manager = openai_manager_for_tests(codex_home.path().to_path_buf(), endpoint); let bundled_slug = load_remote_models_from_file() .expect("bundled models should parse") .first() .expect("bundled models should contain at least one model") .slug .clone(); manager .refresh_available_models( RefreshStrategy::OnlineIfUncached, &DEFAULT_HTTP_CLIENT_FACTORY, ) .await .expect("refresh succeeds"); let model_info = manager .get_model_info(&bundled_slug, &ModelsManagerConfig::default()) .await; assert_eq!(model_info.slug, bundled_slug); assert!(model_info.used_fallback_model_metadata); } #[tokio::test] async fn refresh_available_models_preserves_bundled_catalog_for_empty_chatgpt_remote() { let codex_home = tempdir().expect("temp dir"); let endpoint = TestModelsEndpoint::new(vec![Vec::new()]); let manager = openai_manager_for_tests(codex_home.path().to_path_buf(), endpoint); let expected = load_remote_models_from_file().expect("bundled models should parse"); manager .refresh_available_models( RefreshStrategy::OnlineIfUncached, &DEFAULT_HTTP_CLIENT_FACTORY, ) .await .expect("refresh succeeds"); assert_eq!(manager.get_remote_models().await, expected); } #[tokio::test] async fn refresh_available_models_merges_hidden_only_chatgpt_remote_with_bundled_catalog() { let hidden_remote = remote_model_with_visibility( "chatgpt-hidden-only", "ChatGPT Hidden", /*priority*/ 0, "hide", ); let codex_home = tempdir().expect("temp dir"); let endpoint = TestModelsEndpoint::new(vec![vec![hidden_remote.clone()]]); let manager = openai_manager_for_tests(codex_home.path().to_path_buf(), endpoint); let mut expected = load_remote_models_from_file().expect("bundled models should parse"); expected.push(hidden_remote); manager .refresh_available_models( RefreshStrategy::OnlineIfUncached, &DEFAULT_HTTP_CLIENT_FACTORY, ) .await .expect("refresh succeeds"); assert_eq!(manager.get_remote_models().await, expected); } #[tokio::test] async fn refresh_available_models_keeps_merging_for_custom_api_auth() { let remote_models = vec![remote_model( "api-auth-visible-remote", "API Auth Visible", /*priority*/ 0, )]; let codex_home = tempdir().expect("temp dir"); let endpoint = Arc::new(TestModelsEndpoint { has_command_auth: true, uses_codex_backend: false, responses: Mutex::new(vec![remote_models.clone()].into()), etag: None, fetch_count: AtomicUsize::new(0), observed_proxy_policy: Mutex::new(None), }); let manager = openai_manager_for_tests_with_auth( codex_home.path().to_path_buf(), endpoint.clone(), Some(AuthManager::from_auth_for_testing(CodexAuth::from_api_key( "test-api-key", ))), ); let mut expected = load_remote_models_from_file().expect("bundled models should parse"); expected.extend(remote_models); manager .refresh_available_models( RefreshStrategy::OnlineIfUncached, &DEFAULT_HTTP_CLIENT_FACTORY, ) .await .expect("refresh succeeds"); assert_eq!(manager.get_remote_models().await, expected); assert_eq!(endpoint.fetch_count(), 1, "expected a single model fetch"); } #[tokio::test] async fn refresh_available_models_uses_cache_when_fresh() { let remote_models = vec![remote_model("cached", "Cached", /*priority*/ 5)]; let codex_home = tempdir().expect("temp dir"); let endpoint = TestModelsEndpoint::new(vec![remote_models.clone()]); let manager = openai_manager_for_tests(codex_home.path().to_path_buf(), endpoint.clone()); manager .refresh_available_models( RefreshStrategy::OnlineIfUncached, &DEFAULT_HTTP_CLIENT_FACTORY, ) .await .expect("first refresh succeeds"); assert_models_contain(&manager.get_remote_models().await, &remote_models); // Second call should read from cache and avoid the network. manager .refresh_available_models( RefreshStrategy::OnlineIfUncached, &DEFAULT_HTTP_CLIENT_FACTORY, ) .await .expect("cached refresh succeeds"); assert_models_contain(&manager.get_remote_models().await, &remote_models); assert_eq!( endpoint.fetch_count(), 1, "cache hit should avoid a second model fetch" ); } #[tokio::test] async fn online_refresh_updates_access_programs_with_unchanged_etag() { let codex_home = tempdir().expect("temp dir"); let granted_model = ModelInfo { available_access_programs: Some(ModelAccessPrograms { cyber: vec![ CyberAccessProgram::Standard, CyberAccessProgram::DaybreakBlue, ], }), ..remote_model("access-programs", "Access Programs", /*priority*/ 0) }; let revoked_model = ModelInfo { available_access_programs: Some(ModelAccessPrograms { cyber: Vec::new() }), ..granted_model.clone() }; let responses = vec![vec![granted_model.clone()], vec![revoked_model.clone()]]; let endpoint = Arc::new(TestModelsEndpoint { has_command_auth: false, uses_codex_backend: true, responses: Mutex::new(responses.into()), etag: Some("stable-catalog-etag".to_string()), fetch_count: AtomicUsize::new(0), observed_proxy_policy: Mutex::new(None), }); let manager = openai_manager_for_tests(codex_home.path().to_path_buf(), endpoint.clone()); // Caller-specific access can change without changing the catalog ETag. for model in [granted_model, revoked_model] { let mut expected = ModelPreset::from(model.clone()); expected.is_default = true; assert_eq!( manager .list_models(RefreshStrategy::Online, DEFAULT_HTTP_CLIENT_FACTORY) .await, vec![expected.clone()] ); assert_eq!(manager.get_remote_models().await, vec![model]); assert_eq!( manager.remote_models.read().await.etag.as_deref(), Some("stable-catalog-etag") ); // A new manager must read the latest access metadata from the disk cache. let cache_endpoint = TestModelsEndpoint::new(Vec::new()); let cache_manager = openai_manager_for_tests(codex_home.path().to_path_buf(), cache_endpoint.clone()); assert_eq!( cache_manager .list_models( RefreshStrategy::OnlineIfUncached, DEFAULT_HTTP_CLIENT_FACTORY ) .await, vec![expected] ); assert_eq!(cache_endpoint.fetch_count(), 0); assert_eq!( cache_manager.remote_models.read().await.etag.as_deref(), Some("stable-catalog-etag") ); } assert_eq!(endpoint.fetch_count(), 2); } #[tokio::test] async fn refresh_available_models_refetches_when_cache_stale() { let initial_models = vec![remote_model("stale", "Stale", /*priority*/ 1)]; let codex_home = tempdir().expect("temp dir"); let updated_models = vec![remote_model("fresh", "Fresh", /*priority*/ 9)]; let endpoint = TestModelsEndpoint::new(vec![initial_models.clone(), updated_models.clone()]); let manager = openai_manager_for_tests(codex_home.path().to_path_buf(), endpoint.clone()); manager .refresh_available_models( RefreshStrategy::OnlineIfUncached, &DEFAULT_HTTP_CLIENT_FACTORY, ) .await .expect("initial refresh succeeds"); // Rewrite cache with an old timestamp so it is treated as stale. mutate_file_cache_for_test(codex_home.path(), |cache| { cache.fetched_at = Utc::now() - chrono::Duration::hours(1); }) .await; manager .refresh_available_models( RefreshStrategy::OnlineIfUncached, &DEFAULT_HTTP_CLIENT_FACTORY, ) .await .expect("second refresh succeeds"); assert_models_contain(&manager.get_remote_models().await, &updated_models); assert_eq!( endpoint.fetch_count(), 2, "stale cache refresh should fetch models again" ); } #[tokio::test] async fn refresh_available_models_refetches_when_version_mismatch() { let initial_models = vec![remote_model("old", "Old", /*priority*/ 1)]; let codex_home = tempdir().expect("temp dir"); let updated_models = vec![remote_model("new", "New", /*priority*/ 2)]; let endpoint = TestModelsEndpoint::new(vec![initial_models.clone(), updated_models.clone()]); let manager = openai_manager_for_tests(codex_home.path().to_path_buf(), endpoint.clone()); manager .refresh_available_models( RefreshStrategy::OnlineIfUncached, &DEFAULT_HTTP_CLIENT_FACTORY, ) .await .expect("initial refresh succeeds"); mutate_file_cache_for_test(codex_home.path(), |cache| { let client_version = crate::client_version_to_whole(); cache.client_version = Some(format!("{client_version}-mismatch")); }) .await; manager .refresh_available_models( RefreshStrategy::OnlineIfUncached, &DEFAULT_HTTP_CLIENT_FACTORY, ) .await .expect("second refresh succeeds"); assert_models_contain(&manager.get_remote_models().await, &updated_models); assert_eq!( endpoint.fetch_count(), 2, "version mismatch should fetch models again" ); } #[tokio::test] async fn refresh_available_models_drops_removed_remote_models() { let initial_models = vec![remote_model( "remote-old", "Remote Old", /*priority*/ 1, )]; let codex_home = tempdir().expect("temp dir"); let refreshed_models = vec![remote_model( "remote-new", "Remote New", /*priority*/ 1, )]; let endpoint = TestModelsEndpoint::new(vec![initial_models, refreshed_models]); let manager = OpenAiModelsManager::new_with_cache( Arc::new(FileModelsCache::new( codex_home.path().join(MODEL_CACHE_FILE), Duration::ZERO, )), endpoint.clone(), Some(AuthManager::from_auth_for_testing( CodexAuth::create_dummy_chatgpt_auth_for_testing(), )), ); manager .refresh_available_models( RefreshStrategy::OnlineIfUncached, &DEFAULT_HTTP_CLIENT_FACTORY, ) .await .expect("initial refresh succeeds"); manager .refresh_available_models( RefreshStrategy::OnlineIfUncached, &DEFAULT_HTTP_CLIENT_FACTORY, ) .await .expect("second refresh succeeds"); let available = manager .try_list_models() .expect("models should be available"); assert!( available.iter().any(|preset| preset.model == "remote-new"), "new remote model should be listed" ); assert!( !available.iter().any(|preset| preset.model == "remote-old"), "removed remote model should not be listed" ); assert_eq!( endpoint.fetch_count(), 2, "second refresh should fetch models again" ); } #[tokio::test] async fn refresh_available_models_skips_network_without_auth() { let dynamic_slug = "dynamic-model-only-for-test-noauth"; let codex_home = tempdir().expect("temp dir"); let endpoint = TestModelsEndpoint::without_refresh(vec![vec![remote_model( dynamic_slug, "No Auth", /*priority*/ 1, )]]); let manager = openai_manager_for_tests_with_auth( codex_home.path().to_path_buf(), endpoint.clone(), /*auth_manager*/ None, ); manager .refresh_available_models(RefreshStrategy::Online, &DEFAULT_HTTP_CLIENT_FACTORY) .await .expect("refresh should no-op without auth"); let cached_remote = manager.get_remote_models().await; assert!( !cached_remote .iter() .any(|candidate| candidate.slug == dynamic_slug), "remote refresh should be skipped without auth" ); assert_eq!( endpoint.fetch_count(), 0, "endpoint that cannot refresh should avoid model fetches" ); } #[derive(Debug)] struct TestAuthAwareModelsEndpoint { auth_manager: Option>, responses: Mutex>>, fetch_count: AtomicUsize, } impl TestAuthAwareModelsEndpoint { fn new(auth_manager: Option>, responses: Vec>) -> Arc { Arc::new(Self { auth_manager, responses: Mutex::new(responses.into()), fetch_count: AtomicUsize::new(0), }) } fn fetch_count(&self) -> usize { self.fetch_count.load(Ordering::SeqCst) } async fn uses_codex_backend(&self) -> bool { match self.auth_manager.as_ref() { Some(auth_manager) => auth_manager .auth() .await .as_ref() .is_some_and(CodexAuth::uses_codex_backend), None => false, } } async fn list_models(&self) -> CoreResult { self.fetch_count.fetch_add(1, Ordering::SeqCst); let models = self .responses .lock() .expect("responses lock should not be poisoned") .pop_front() .unwrap_or_default(); Ok(ModelsEndpointResponse { models, etag: None, identity: self.identity().expect("test endpoint identity"), }) } } impl ModelsEndpointClient for TestAuthAwareModelsEndpoint { fn supports_api_key_models(&self) -> bool { true } fn identity(&self) -> Option { self.auth_manager .as_ref() .and_then(|manager| manager.auth_mode()) .map(|mode| format!("{mode:?}")) } fn has_command_auth(&self) -> bool { false } fn uses_codex_backend(&self) -> ModelsEndpointFuture<'_, bool> { Box::pin(TestAuthAwareModelsEndpoint::uses_codex_backend(self)) } fn list_models<'a>( &'a self, _client_version: &'a str, _http_client_factory: HttpClientFactory, ) -> ModelsEndpointFuture<'a, CoreResult> { Box::pin(TestAuthAwareModelsEndpoint::list_models(self)) } } #[tokio::test] async fn refresh_available_models_fetches_when_external_api_key_overrides_chatgpt_auth() { let dynamic_slug = "dynamic-model-only-for-test-external-api-key"; let codex_home = tempdir().expect("temp dir"); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); auth_manager .set_external_auth(Arc::new(TestExternalApiKeyAuth)) .await .expect("external API key auth should resolve"); let endpoint = TestAuthAwareModelsEndpoint::new( Some(Arc::clone(&auth_manager)), vec![vec![remote_model( dynamic_slug, "External API Key", /*priority*/ 1, )]], ); let manager = openai_manager_for_tests_with_auth( codex_home.path().to_path_buf(), endpoint.clone(), Some(auth_manager), ); manager.set_api_key_model_discovery_enabled(/*enabled*/ true); manager .refresh_available_models(RefreshStrategy::Online, &DEFAULT_HTTP_CLIENT_FACTORY) .await .expect("refresh should fetch with API key auth"); let cached_remote = manager.get_remote_models().await; assert_eq!( cached_remote, vec![remote_model( dynamic_slug, "External API Key", /*priority*/ 1 )] ); assert_eq!(endpoint.fetch_count(), 1); } #[tokio::test] async fn refresh_available_models_uses_cached_chatgpt_when_external_api_key_is_unresolved() { let dynamic_slug = "dynamic-model-only-for-test-unresolved-external-api-key"; let codex_home = tempdir().expect("temp dir"); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); auth_manager .set_external_auth(Arc::new(TestUnresolvedExternalApiKeyAuth)) .await .expect_err("unresolved external auth should be rejected"); let endpoint = TestAuthAwareModelsEndpoint::new( Some(Arc::clone(&auth_manager)), vec![vec![remote_model( dynamic_slug, "Unresolved External API Key", /*priority*/ 1, )]], ); let manager = openai_manager_for_tests_with_auth( codex_home.path().to_path_buf(), endpoint.clone(), Some(auth_manager), ); manager.set_api_key_model_discovery_enabled(/*enabled*/ true); manager .refresh_available_models(RefreshStrategy::Online, &DEFAULT_HTTP_CLIENT_FACTORY) .await .expect("refresh should fall back to cached ChatGPT auth"); assert!( manager .get_remote_models() .await .iter() .any(|candidate| candidate.slug == dynamic_slug), "remote refresh should include models fetched with cached ChatGPT auth" ); assert_eq!( endpoint.fetch_count(), 1, "endpoint should fetch models when unresolved external API key falls back to ChatGPT auth" ); } #[tokio::test] async fn refresh_available_models_fetches_with_chatgpt_auth_tokens() { let dynamic_slug = "dynamic-model-only-for-test-chatgpt-auth-tokens"; let codex_home = tempdir().expect("temp dir"); let endpoint = TestModelsEndpoint::new(vec![vec![remote_model( dynamic_slug, "ChatGPT Auth Tokens", /*priority*/ 1, )]]); let auth = chatgpt_auth_tokens_for_tests(codex_home.path()).await; let manager = openai_manager_for_tests_with_auth( codex_home.path().to_path_buf(), endpoint.clone(), Some(AuthManager::from_auth_for_testing(auth)), ); manager .refresh_available_models(RefreshStrategy::Online, &DEFAULT_HTTP_CLIENT_FACTORY) .await .expect("refresh should fetch with ChatGPT auth tokens"); assert!( manager .get_remote_models() .await .iter() .any(|candidate| candidate.slug == dynamic_slug), "remote refresh should include models fetched with ChatGPT auth tokens" ); assert_eq!( endpoint.fetch_count(), 1, "endpoint should fetch models with ChatGPT auth tokens" ); } #[test] fn build_available_models_picks_default_after_hiding_hidden_models() { let manager = static_manager_for_tests(ModelsResponse { models: Vec::new() }); let hidden_model = remote_model_with_visibility("hidden", "Hidden", /*priority*/ 0, "hide"); let visible_model = remote_model_with_visibility("visible", "Visible", /*priority*/ 1, "list"); let expected_hidden = ModelPreset::from(hidden_model.clone()); let mut expected_visible = ModelPreset::from(visible_model.clone()); expected_visible.is_default = true; let available = manager.build_available_models(vec![hidden_model, visible_model]); assert_eq!(available, vec![expected_hidden, expected_visible]); } #[tokio::test] async fn static_manager_reads_latest_auth_mode() { let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); let chatgpt_only_model = { let mut model = remote_model("chatgpt-only", "ChatGPT Only", /*priority*/ 0); model.supported_in_api = false; model }; let api_model = remote_model("api-model", "API Model", /*priority*/ 1); let manager = StaticModelsManager::new( Some(Arc::clone(&auth_manager)), ModelsResponse { models: vec![chatgpt_only_model, api_model], }, ); let chatgpt_models = manager .list_models(RefreshStrategy::Online, DEFAULT_HTTP_CLIENT_FACTORY) .await; assert_eq!( chatgpt_models .iter() .map(|model| model.model.as_str()) .collect::>(), vec!["chatgpt-only", "api-model"] ); auth_manager .set_external_auth(Arc::new(TestExternalApiKeyAuth)) .await .expect("external API key auth should resolve"); let api_models = manager .list_models(RefreshStrategy::Online, DEFAULT_HTTP_CLIENT_FACTORY) .await; assert_eq!( api_models .iter() .map(|model| model.model.as_str()) .collect::>(), vec!["api-model"] ); } #[test] fn bundled_models_json_roundtrips() { let response = crate::bundled_models_response() .unwrap_or_else(|err| panic!("bundled models.json should parse: {err}")); let serialized = serde_json::to_string(&response).expect("bundled models.json should serialize"); let roundtripped: ModelsResponse = serde_json::from_str(&serialized).expect("serialized models.json should deserialize"); assert_eq!( response, roundtripped, "bundled models.json should round trip through serde" ); assert!( !response.models.is_empty(), "bundled models.json should contain at least one model" ); }