diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 7140a4b257..c83438a4cc 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -751,12 +751,7 @@ pub struct ReasoningEffortOption { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] -pub struct ModelListResponse { - pub data: Vec, - /// Opaque cursor to pass to the next call to continue after the last item. - /// If None, there are no more items to return. - pub next_cursor: Option, -} +pub struct ModelListResponse {} #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 80e882adfc..e7c872ab4e 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -64,7 +64,7 @@ Example (from OpenAI's official VSCode extension): - `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`. - `review/start` — kick off Codex’s automated reviewer for a thread; responds like `turn/start` and emits `item/started`/`item/completed` notifications with `enteredReviewMode` and `exitedReviewMode` items, plus a final assistant `agentMessage` containing the review. - `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation). -- `model/list` — list available models (with reasoning effort options). +- `model/list` — request the available models; responds with `{}` and asynchronously emits `model/presets/updated` containing the catalog. - `skills/list` — list skills for one or more `cwd` values. - `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes. - `mcpServers/list` — enumerate configured MCP servers with their tools, resources, resource templates, and auth status; supports cursor+limit pagination. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index a6ceff06f7..63d461a246 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -232,15 +232,17 @@ pub(crate) enum ApiVersion { V2, } -async fn emit_model_presets_notification( +fn spawn_model_presets_notification( outgoing: Arc, conversation_manager: Arc, config: Arc, ) { - let models = supported_models(conversation_manager, &config).await; - let notification = - ServerNotification::ModelPresetsUpdated(ModelPresetsUpdatedNotification { models }); - outgoing.send_server_notification(notification).await; + tokio::spawn(async move { + let models = supported_models(conversation_manager, &config).await; + let notification = + ServerNotification::ModelPresetsUpdated(ModelPresetsUpdatedNotification { models }); + outgoing.send_server_notification(notification).await; + }); } impl CodexMessageProcessor { @@ -293,13 +295,12 @@ impl CodexMessageProcessor { } } - pub(crate) async fn send_model_presets_notification(&self) { - emit_model_presets_notification( + pub(crate) fn spawn_model_presets_notification(&self) { + spawn_model_presets_notification( self.outgoing.clone(), self.conversation_manager.clone(), self.config.clone(), - ) - .await; + ); } async fn load_latest_config(&self) -> Result { @@ -594,7 +595,7 @@ impl CodexMessageProcessor { self.outgoing .send_server_notification(ServerNotification::AuthStatusChange(payload)) .await; - self.send_model_presets_notification().await; + self.spawn_model_presets_notification(); } Err(error) => { self.outgoing.send_error(request_id, error).await; @@ -625,7 +626,7 @@ impl CodexMessageProcessor { self.outgoing .send_server_notification(ServerNotification::AccountUpdated(payload_v2)) .await; - self.send_model_presets_notification().await; + self.spawn_model_presets_notification(); } Err(error) => { self.outgoing.send_error(request_id, error).await; @@ -724,12 +725,11 @@ impl CodexMessageProcessor { payload, )) .await; - emit_model_presets_notification( + spawn_model_presets_notification( outgoing_clone, conversation_manager, config, - ) - .await; + ); } // Clear the active login if it matches this attempt. It may have been replaced or cancelled. @@ -822,12 +822,11 @@ impl CodexMessageProcessor { payload_v2, )) .await; - emit_model_presets_notification( + spawn_model_presets_notification( outgoing_clone, conversation_manager, config, - ) - .await; + ); } // Clear the active login if it matches this attempt. It may have been replaced or cancelled. @@ -947,7 +946,7 @@ impl CodexMessageProcessor { self.outgoing .send_server_notification(ServerNotification::AuthStatusChange(payload)) .await; - self.send_model_presets_notification().await; + self.spawn_model_presets_notification(); } Err(error) => { self.outgoing.send_error(request_id, error).await; @@ -968,7 +967,7 @@ impl CodexMessageProcessor { self.outgoing .send_server_notification(ServerNotification::AccountUpdated(payload_v2)) .await; - self.send_model_presets_notification().await; + self.spawn_model_presets_notification(); } Err(error) => { self.outgoing.send_error(request_id, error).await; @@ -1934,59 +1933,11 @@ impl CodexMessageProcessor { } async fn list_models(&self, request_id: RequestId, params: ModelListParams) { - let ModelListParams { limit, cursor } = params; - let models = supported_models(self.conversation_manager.clone(), &self.config).await; - let total = models.len(); - - if total == 0 { - let response = ModelListResponse { - data: Vec::new(), - next_cursor: None, - }; - self.outgoing.send_response(request_id, response).await; - return; - } - - let effective_limit = limit.unwrap_or(total as u32).max(1) as usize; - let effective_limit = effective_limit.min(total); - let start = match cursor { - Some(cursor) => match cursor.parse::() { - Ok(idx) => idx, - Err(_) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("invalid cursor: {cursor}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - }, - None => 0, - }; - - if start > total { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("cursor {start} exceeds total models {total}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - - let end = start.saturating_add(effective_limit).min(total); - let items = models[start..end].to_vec(); - let next_cursor = if end < total { - Some(end.to_string()) - } else { - None - }; - let response = ModelListResponse { - data: items, - next_cursor, - }; + let _ = params; + let response = ModelListResponse {}; self.outgoing.send_response(request_id, response).await; + + self.spawn_model_presets_notification(); } async fn mcp_server_oauth_login( diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index d754eb666d..ef4026e155 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -129,8 +129,7 @@ impl MessageProcessor { self.initialized = true; self.codex_message_processor - .send_model_presets_notification() - .await; + .spawn_model_presets_notification(); return; } diff --git a/codex-rs/app-server/tests/suite/v2/model_list.rs b/codex-rs/app-server/tests/suite/v2/model_list.rs index b406eda3f4..223fa3d590 100644 --- a/codex-rs/app-server/tests/suite/v2/model_list.rs +++ b/codex-rs/app-server/tests/suite/v2/model_list.rs @@ -1,27 +1,26 @@ use std::time::Duration; use anyhow::Result; -use anyhow::anyhow; use app_test_support::McpProcess; use app_test_support::to_response; use app_test_support::write_models_cache; -use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCNotification; 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::ReasoningEffortOption; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_protocol::openai_models::ReasoningEffort; use pretty_assertions::assert_eq; use tempfile::TempDir; use tokio::time::timeout; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); -const INVALID_REQUEST_ERROR_CODE: i64 = -32600; #[tokio::test] -async fn list_models_returns_all_models_with_large_limit() -> Result<()> { +async fn list_models_returns_empty_response_and_notification() -> Result<()> { let codex_home = TempDir::new()?; write_models_cache(codex_home.path())?; let mut mcp = McpProcess::new(codex_home.path()).await?; @@ -30,8 +29,8 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> { let request_id = mcp .send_list_models_request(ModelListParams { - limit: Some(100), - cursor: None, + limit: Some(1), + cursor: Some("ignored".to_string()), }) .await?; @@ -41,12 +40,24 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> { ) .await??; - let ModelListResponse { - data: items, - next_cursor, - } = to_response::(response)?; + let ModelListResponse {} = to_response::(response)?; - let expected_models = vec![ + let notification: JSONRPCNotification = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("model/presets/updated"), + ) + .await??; + let server_notification: ServerNotification = notification.try_into()?; + let ServerNotification::ModelPresetsUpdated(payload) = server_notification else { + unreachable!("expected model/presets/updated notification"); + }; + + assert_eq!(payload.models, expected_models()); + Ok(()) +} + +fn expected_models() -> Vec { + vec![ Model { id: "gpt-5.1-codex-max".to_string(), model: "gpt-5.1-codex-max".to_string(), @@ -176,156 +187,5 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> { default_reasoning_effort: ReasoningEffort::Medium, is_default: false, }, - ]; - - assert_eq!(items, expected_models); - assert!(next_cursor.is_none()); - Ok(()) -} - -#[tokio::test] -async fn list_models_pagination_works() -> Result<()> { - let codex_home = TempDir::new()?; - write_models_cache(codex_home.path())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; - - timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - - let first_request = mcp - .send_list_models_request(ModelListParams { - limit: Some(1), - cursor: None, - }) - .await?; - - let first_response: JSONRPCResponse = timeout( - DEFAULT_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(first_request)), - ) - .await??; - - let ModelListResponse { - data: first_items, - next_cursor: first_cursor, - } = to_response::(first_response)?; - - assert_eq!(first_items.len(), 1); - assert_eq!(first_items[0].id, "gpt-5.1-codex-max"); - let next_cursor = first_cursor.ok_or_else(|| anyhow!("cursor for second page"))?; - - let second_request = mcp - .send_list_models_request(ModelListParams { - limit: Some(1), - cursor: Some(next_cursor.clone()), - }) - .await?; - - let second_response: JSONRPCResponse = timeout( - DEFAULT_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(second_request)), - ) - .await??; - - let ModelListResponse { - data: second_items, - next_cursor: second_cursor, - } = to_response::(second_response)?; - - assert_eq!(second_items.len(), 1); - assert_eq!(second_items[0].id, "gpt-5.1-codex"); - let third_cursor = second_cursor.ok_or_else(|| anyhow!("cursor for third page"))?; - - let third_request = mcp - .send_list_models_request(ModelListParams { - limit: Some(1), - cursor: Some(third_cursor.clone()), - }) - .await?; - - let third_response: JSONRPCResponse = timeout( - DEFAULT_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(third_request)), - ) - .await??; - - let ModelListResponse { - data: third_items, - next_cursor: third_cursor, - } = to_response::(third_response)?; - - assert_eq!(third_items.len(), 1); - assert_eq!(third_items[0].id, "gpt-5.1-codex-mini"); - let fourth_cursor = third_cursor.ok_or_else(|| anyhow!("cursor for fourth page"))?; - - let fourth_request = mcp - .send_list_models_request(ModelListParams { - limit: Some(1), - cursor: Some(fourth_cursor.clone()), - }) - .await?; - - let fourth_response: JSONRPCResponse = timeout( - DEFAULT_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(fourth_request)), - ) - .await??; - - let ModelListResponse { - data: fourth_items, - next_cursor: fourth_cursor, - } = to_response::(fourth_response)?; - - assert_eq!(fourth_items.len(), 1); - assert_eq!(fourth_items[0].id, "gpt-5.2"); - let fifth_cursor = fourth_cursor.ok_or_else(|| anyhow!("cursor for fifth page"))?; - - let fifth_request = mcp - .send_list_models_request(ModelListParams { - limit: Some(1), - cursor: Some(fifth_cursor.clone()), - }) - .await?; - - let fifth_response: JSONRPCResponse = timeout( - DEFAULT_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(fifth_request)), - ) - .await??; - - let ModelListResponse { - data: fifth_items, - next_cursor: fifth_cursor, - } = to_response::(fifth_response)?; - - assert_eq!(fifth_items.len(), 1); - assert_eq!(fifth_items[0].id, "gpt-5.1"); - assert!(fifth_cursor.is_none()); - Ok(()) -} - -#[tokio::test] -async fn list_models_rejects_invalid_cursor() -> Result<()> { - let codex_home = TempDir::new()?; - write_models_cache(codex_home.path())?; - let mut mcp = McpProcess::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()), - }) - .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(()) + ] } diff --git a/codex-rs/docs/codex_mcp_interface.md b/codex-rs/docs/codex_mcp_interface.md index 1ae0e99876..ad49a49e8a 100644 --- a/codex-rs/docs/codex_mcp_interface.md +++ b/codex-rs/docs/codex_mcp_interface.md @@ -79,25 +79,21 @@ List/resume/archive: `listConversations`, `resumeConversation`, `archiveConversa ## Models -Fetch the catalog of models available in the current Codex build with `model/list`. The request accepts optional pagination inputs: +Request the catalog of models available in the current Codex build with `model/list`. The request accepts optional pagination inputs (currently ignored by the server): -- `pageSize` – number of models to return (defaults to a server-selected value) +- `pageSize` – number of models to return - `cursor` – opaque string from the previous response’s `nextCursor` -Each response yields: +The response is an empty JSON object `{}`. The server asynchronously emits a +`model/presets/updated` notification containing the full model catalog. The payload is: -- `items` – ordered list of models. A model includes: +- `models` – the full list of available models. Each model includes: - `id`, `model`, `displayName`, `description` - `supportedReasoningEfforts` – array of objects with: - `reasoningEffort` – one of `minimal|low|medium|high` - `description` – human-friendly label for the effort - `defaultReasoningEffort` – suggested effort for the UI - `isDefault` – whether the model is recommended for most users -- `nextCursor` – pass into the next request to continue paging (optional) - -The server also emits `model/presets/updated` notifications after initialization and after auth state changes (login/logout). The payload is: - -- `models` – the full list of available models, with the same shape as `model/list` items. ## Event stream