use std::time::Duration; use anyhow::Error; use anyhow::Result; use app_test_support::ChatGptAuthFixture; use app_test_support::TestAppServer; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; use app_test_support::write_models_cache; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::Model; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; use codex_app_server_protocol::ModelServiceTier; use codex_app_server_protocol::ModelUpgradeInfo; use codex_app_server_protocol::ReasoningEffortOption; use codex_app_server_protocol::RequestId; use codex_config::types::AuthCredentialsStoreMode; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelsResponse; use core_test_support::responses::mount_models_once; use pretty_assertions::assert_eq; use serde_json::json; use tempfile::TempDir; use tokio::time::timeout; use wiremock::MockServer; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); const INVALID_REQUEST_ERROR_CODE: i64 = -32600; fn model_from_preset(preset: &ModelPreset) -> Model { Model { id: preset.id.clone(), model: preset.model.clone(), upgrade: preset.upgrade.as_ref().map(|upgrade| upgrade.id.clone()), upgrade_info: preset.upgrade.as_ref().map(|upgrade| ModelUpgradeInfo { model: upgrade.id.clone(), upgrade_copy: upgrade.upgrade_copy.clone(), model_link: upgrade.model_link.clone(), migration_markdown: upgrade.migration_markdown.clone(), }), availability_nux: preset.availability_nux.clone().map(Into::into), display_name: preset.display_name.clone(), description: preset.description.clone(), hidden: !preset.show_in_picker, supported_reasoning_efforts: preset .supported_reasoning_efforts .iter() .map(|preset| ReasoningEffortOption { reasoning_effort: preset.effort.clone(), description: preset.description.clone(), }) .collect(), default_reasoning_effort: preset.default_reasoning_effort.clone(), input_modalities: preset.input_modalities.clone(), // `write_models_cache()` round-trips through a simplified ModelInfo fixture that does not // preserve personality placeholders in base instructions, so app-server list results from // cache report `supports_personality = false`. // todo(sayan): fix, maybe make roundtrip use ModelInfo only supports_personality: false, additional_speed_tiers: preset.additional_speed_tiers.clone(), service_tiers: preset .service_tiers .iter() .map(|service_tier| ModelServiceTier { id: service_tier.id.clone(), name: service_tier.name.clone(), description: service_tier.description.clone(), }) .collect(), default_service_tier: preset.default_service_tier.clone(), is_default: preset.is_default, } } fn expected_visible_models() -> Vec { // Filter by supported_in_api to support testing with both ChatGPT and non-ChatGPT auth modes. let mut presets = ModelPreset::filter_by_auth( codex_core::test_support::all_model_presets().clone(), /*chatgpt_mode*/ false, ); // Mirror `ModelsManager::build_available_models()` default selection after auth filtering. ModelPreset::mark_default_by_picker_visibility(&mut presets); presets .iter() .filter(|preset| preset.show_in_picker) .map(model_from_preset) .collect() } #[tokio::test] async fn list_models_returns_all_models_with_large_limit() -> Result<()> { let codex_home = TempDir::new()?; write_models_cache(codex_home.path())?; let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_list_models_request(ModelListParams { limit: Some(100), cursor: None, include_hidden: None, }) .await?; let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await??; let ModelListResponse { data: items, next_cursor, } = to_response::(response)?; let expected_models = expected_visible_models(); assert_eq!(items, expected_models); assert!(next_cursor.is_none()); Ok(()) } #[tokio::test] async fn list_models_includes_hidden_models() -> Result<()> { let codex_home = TempDir::new()?; write_models_cache(codex_home.path())?; let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_list_models_request(ModelListParams { limit: Some(100), cursor: None, include_hidden: Some(true), }) .await?; let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await??; let ModelListResponse { data: items, next_cursor, } = to_response::(response)?; assert!(items.iter().any(|item| item.hidden)); assert!(next_cursor.is_none()); Ok(()) } #[tokio::test] async fn list_models_uses_chatgpt_remote_catalog_as_source_of_truth() -> Result<()> { let server = MockServer::start().await; let remote_model: ModelInfo = serde_json::from_value(json!({ "slug": "chatgpt-remote-only", "display_name": "ChatGPT Remote Only", "description": "Remote-only model for app-server model/list coverage", "default_reasoning_level": "max", "supported_reasoning_levels": [ {"effort": "max", "description": "Maximum"}, {"effort": "low", "description": "Low"}, {"effort": "focused", "description": "Focused"} ], "shell_type": "shell_command", "visibility": "list", "minimal_client_version": [0, 1, 0], "supported_in_api": true, "priority": 0, "upgrade": null, "base_instructions": "base instructions", "supports_reasoning_summaries": false, "support_verbosity": false, "default_verbosity": null, "apply_patch_tool_type": null, "truncation_policy": {"mode": "bytes", "limit": 10_000}, "supports_parallel_tool_calls": false, "supports_image_detail_original": false, "context_window": 272_000, "max_context_window": 272_000, "experimental_supported_tools": [], }))?; let models_mock = mount_models_once( &server, ModelsResponse { models: vec![remote_model.clone()], }, ) .await; let codex_home = TempDir::new()?; let server_uri = server.uri(); std::fs::write( codex_home.path().join("config.toml"), format!( r#" model = "mock-model" approval_policy = "never" sandbox_mode = "read-only" openai_base_url = "{server_uri}/v1" "# ), )?; write_chatgpt_auth( codex_home.path(), ChatGptAuthFixture::new("chatgpt-access-token").plan_type("pro"), AuthCredentialsStoreMode::File, )?; let mut mcp = TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_list_models_request(ModelListParams { limit: Some(100), cursor: None, include_hidden: None, }) .await?; let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await??; let ModelListResponse { data: items, next_cursor, } = to_response::(response)?; let mut expected_presets: Vec = vec![remote_model.into()]; ModelPreset::mark_default_by_picker_visibility(&mut expected_presets); let mut expected_items = expected_presets .iter() .map(model_from_preset) .collect::>(); expected_items[0].supported_reasoning_efforts = vec![ ReasoningEffortOption { reasoning_effort: "max".parse().map_err(Error::msg)?, description: "Maximum".to_string(), }, ReasoningEffortOption { reasoning_effort: "low".parse().map_err(Error::msg)?, description: "Low".to_string(), }, ReasoningEffortOption { reasoning_effort: "focused".parse().map_err(Error::msg)?, description: "Focused".to_string(), }, ]; assert_eq!(items, expected_items); assert!(next_cursor.is_none()); assert_eq!( models_mock.requests().len(), 1, "expected a single /models request" ); Ok(()) } #[tokio::test] async fn list_models_pagination_works() -> Result<()> { let codex_home = TempDir::new()?; write_models_cache(codex_home.path())?; let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let expected_models = expected_visible_models(); let mut cursor = None; let mut items = Vec::new(); for _ in 0..expected_models.len() { let request_id = mcp .send_list_models_request(ModelListParams { limit: Some(1), cursor: cursor.clone(), include_hidden: None, }) .await?; let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await??; let ModelListResponse { data: page_items, next_cursor, } = to_response::(response)?; assert_eq!(page_items.len(), 1); items.extend(page_items); if let Some(next_cursor) = next_cursor { cursor = Some(next_cursor); } else { assert_eq!(items, expected_models); return Ok(()); } } panic!( "model pagination did not terminate after {} pages", expected_models.len() ); } #[tokio::test] async fn list_models_rejects_invalid_cursor() -> Result<()> { let codex_home = TempDir::new()?; write_models_cache(codex_home.path())?; let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_list_models_request(ModelListParams { limit: None, cursor: Some("invalid".to_string()), include_hidden: None, }) .await?; let error: JSONRPCError = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_error_message(RequestId::Integer(request_id)), ) .await??; assert_eq!(error.id, RequestId::Integer(request_id)); assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE); assert_eq!(error.error.message, "invalid cursor: invalid"); Ok(()) }