diff --git a/codex-rs/app-server-protocol/src/lib.rs b/codex-rs/app-server-protocol/src/lib.rs index 9c02ea9246..06102083f4 100644 --- a/codex-rs/app-server-protocol/src/lib.rs +++ b/codex-rs/app-server-protocol/src/lib.rs @@ -7,5 +7,6 @@ pub use export::generate_ts; pub use export::generate_types; pub use jsonrpc_lite::*; pub use protocol::common::*; +pub use protocol::thread_history::*; pub use protocol::v1::*; pub use protocol::v2::*; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 088c2b4c3a..027c2561b0 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -531,7 +531,7 @@ mod tests { let request = ClientRequest::NewConversation { request_id: RequestId::Integer(42), params: v1::NewConversationParams { - model: Some("arcticfox".to_string()), + model: Some("gpt-5.1-codex-max".to_string()), model_provider: None, profile: None, cwd: None, @@ -549,7 +549,7 @@ mod tests { "method": "newConversation", "id": 42, "params": { - "model": "arcticfox", + "model": "gpt-5.1-codex-max", "modelProvider": null, "profile": null, "cwd": null, diff --git a/codex-rs/app-server-protocol/src/protocol/mod.rs b/codex-rs/app-server-protocol/src/protocol/mod.rs index 11edf04cc1..8e2d63e064 100644 --- a/codex-rs/app-server-protocol/src/protocol/mod.rs +++ b/codex-rs/app-server-protocol/src/protocol/mod.rs @@ -2,5 +2,6 @@ // Exposes protocol pieces used by `lib.rs` via `pub use protocol::common::*;`. pub mod common; +pub mod thread_history; pub mod v1; pub mod v2; diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs new file mode 100644 index 0000000000..04cd1190bc --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -0,0 +1,409 @@ +use crate::protocol::v2::ThreadItem; +use crate::protocol::v2::Turn; +use crate::protocol::v2::TurnStatus; +use crate::protocol::v2::UserInput; +use codex_protocol::protocol::AgentReasoningEvent; +use codex_protocol::protocol::AgentReasoningRawContentEvent; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::TurnAbortedEvent; +use codex_protocol::protocol::UserMessageEvent; + +/// Convert persisted [`EventMsg`] entries into a sequence of [`Turn`] values. +/// +/// The purpose of this is to convert the EventMsgs persisted in a rollout file +/// into a sequence of Turns and ThreadItems, which allows the client to render +/// the historical messages when resuming a thread. +pub fn build_turns_from_event_msgs(events: &[EventMsg]) -> Vec { + let mut builder = ThreadHistoryBuilder::new(); + for event in events { + builder.handle_event(event); + } + builder.finish() +} + +struct ThreadHistoryBuilder { + turns: Vec, + current_turn: Option, + next_turn_index: i64, + next_item_index: i64, +} + +impl ThreadHistoryBuilder { + fn new() -> Self { + Self { + turns: Vec::new(), + current_turn: None, + next_turn_index: 1, + next_item_index: 1, + } + } + + fn finish(mut self) -> Vec { + self.finish_current_turn(); + self.turns + } + + /// This function should handle all EventMsg variants that can be persisted in a rollout file. + /// See `should_persist_event_msg` in `codex-rs/core/rollout/policy.rs`. + fn handle_event(&mut self, event: &EventMsg) { + match event { + EventMsg::UserMessage(payload) => self.handle_user_message(payload), + EventMsg::AgentMessage(payload) => self.handle_agent_message(payload.message.clone()), + EventMsg::AgentReasoning(payload) => self.handle_agent_reasoning(payload), + EventMsg::AgentReasoningRawContent(payload) => { + self.handle_agent_reasoning_raw_content(payload) + } + EventMsg::TokenCount(_) => {} + EventMsg::EnteredReviewMode(_) => {} + EventMsg::ExitedReviewMode(_) => {} + EventMsg::UndoCompleted(_) => {} + EventMsg::TurnAborted(payload) => self.handle_turn_aborted(payload), + _ => {} + } + } + + fn handle_user_message(&mut self, payload: &UserMessageEvent) { + self.finish_current_turn(); + let mut turn = self.new_turn(); + let id = self.next_item_id(); + let content = self.build_user_inputs(payload); + turn.items.push(ThreadItem::UserMessage { id, content }); + self.current_turn = Some(turn); + } + + fn handle_agent_message(&mut self, text: String) { + if text.is_empty() { + return; + } + + let id = self.next_item_id(); + self.ensure_turn() + .items + .push(ThreadItem::AgentMessage { id, text }); + } + + fn handle_agent_reasoning(&mut self, payload: &AgentReasoningEvent) { + if payload.text.is_empty() { + return; + } + + // If the last item is a reasoning item, add the new text to the summary. + if let Some(ThreadItem::Reasoning { summary, .. }) = self.ensure_turn().items.last_mut() { + summary.push(payload.text.clone()); + return; + } + + // Otherwise, create a new reasoning item. + let id = self.next_item_id(); + self.ensure_turn().items.push(ThreadItem::Reasoning { + id, + summary: vec![payload.text.clone()], + content: Vec::new(), + }); + } + + fn handle_agent_reasoning_raw_content(&mut self, payload: &AgentReasoningRawContentEvent) { + if payload.text.is_empty() { + return; + } + + // If the last item is a reasoning item, add the new text to the content. + if let Some(ThreadItem::Reasoning { content, .. }) = self.ensure_turn().items.last_mut() { + content.push(payload.text.clone()); + return; + } + + // Otherwise, create a new reasoning item. + let id = self.next_item_id(); + self.ensure_turn().items.push(ThreadItem::Reasoning { + id, + summary: Vec::new(), + content: vec![payload.text.clone()], + }); + } + + fn handle_turn_aborted(&mut self, _payload: &TurnAbortedEvent) { + let Some(turn) = self.current_turn.as_mut() else { + return; + }; + turn.status = TurnStatus::Interrupted; + } + + fn finish_current_turn(&mut self) { + if let Some(turn) = self.current_turn.take() { + if turn.items.is_empty() { + return; + } + self.turns.push(turn.into()); + } + } + + fn new_turn(&mut self) -> PendingTurn { + PendingTurn { + id: self.next_turn_id(), + items: Vec::new(), + status: TurnStatus::Completed, + } + } + + fn ensure_turn(&mut self) -> &mut PendingTurn { + if self.current_turn.is_none() { + let turn = self.new_turn(); + return self.current_turn.insert(turn); + } + + if let Some(turn) = self.current_turn.as_mut() { + return turn; + } + + unreachable!("current turn must exist after initialization"); + } + + fn next_turn_id(&mut self) -> String { + let id = format!("turn-{}", self.next_turn_index); + self.next_turn_index += 1; + id + } + + fn next_item_id(&mut self) -> String { + let id = format!("item-{}", self.next_item_index); + self.next_item_index += 1; + id + } + + fn build_user_inputs(&self, payload: &UserMessageEvent) -> Vec { + let mut content = Vec::new(); + if !payload.message.trim().is_empty() { + content.push(UserInput::Text { + text: payload.message.clone(), + }); + } + if let Some(images) = &payload.images { + for image in images { + content.push(UserInput::Image { url: image.clone() }); + } + } + content + } +} + +struct PendingTurn { + id: String, + items: Vec, + status: TurnStatus, +} + +impl From for Turn { + fn from(value: PendingTurn) -> Self { + Self { + id: value.id, + items: value.items, + status: value.status, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_protocol::protocol::AgentMessageEvent; + use codex_protocol::protocol::AgentReasoningEvent; + use codex_protocol::protocol::AgentReasoningRawContentEvent; + use codex_protocol::protocol::TurnAbortReason; + use codex_protocol::protocol::TurnAbortedEvent; + use codex_protocol::protocol::UserMessageEvent; + use pretty_assertions::assert_eq; + + #[test] + fn builds_multiple_turns_with_reasoning_items() { + let events = vec![ + EventMsg::UserMessage(UserMessageEvent { + message: "First turn".into(), + images: Some(vec!["https://example.com/one.png".into()]), + }), + EventMsg::AgentMessage(AgentMessageEvent { + message: "Hi there".into(), + }), + EventMsg::AgentReasoning(AgentReasoningEvent { + text: "thinking".into(), + }), + EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { + text: "full reasoning".into(), + }), + EventMsg::UserMessage(UserMessageEvent { + message: "Second turn".into(), + images: None, + }), + EventMsg::AgentMessage(AgentMessageEvent { + message: "Reply two".into(), + }), + ]; + + let turns = build_turns_from_event_msgs(&events); + assert_eq!(turns.len(), 2); + + let first = &turns[0]; + assert_eq!(first.id, "turn-1"); + assert_eq!(first.status, TurnStatus::Completed); + assert_eq!(first.items.len(), 3); + assert_eq!( + first.items[0], + ThreadItem::UserMessage { + id: "item-1".into(), + content: vec![ + UserInput::Text { + text: "First turn".into(), + }, + UserInput::Image { + url: "https://example.com/one.png".into(), + } + ], + } + ); + assert_eq!( + first.items[1], + ThreadItem::AgentMessage { + id: "item-2".into(), + text: "Hi there".into(), + } + ); + assert_eq!( + first.items[2], + ThreadItem::Reasoning { + id: "item-3".into(), + summary: vec!["thinking".into()], + content: vec!["full reasoning".into()], + } + ); + + let second = &turns[1]; + assert_eq!(second.id, "turn-2"); + assert_eq!(second.items.len(), 2); + assert_eq!( + second.items[0], + ThreadItem::UserMessage { + id: "item-4".into(), + content: vec![UserInput::Text { + text: "Second turn".into() + }], + } + ); + assert_eq!( + second.items[1], + ThreadItem::AgentMessage { + id: "item-5".into(), + text: "Reply two".into(), + } + ); + } + + #[test] + fn splits_reasoning_when_interleaved() { + let events = vec![ + EventMsg::UserMessage(UserMessageEvent { + message: "Turn start".into(), + images: None, + }), + EventMsg::AgentReasoning(AgentReasoningEvent { + text: "first summary".into(), + }), + EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { + text: "first content".into(), + }), + EventMsg::AgentMessage(AgentMessageEvent { + message: "interlude".into(), + }), + EventMsg::AgentReasoning(AgentReasoningEvent { + text: "second summary".into(), + }), + ]; + + let turns = build_turns_from_event_msgs(&events); + assert_eq!(turns.len(), 1); + let turn = &turns[0]; + assert_eq!(turn.items.len(), 4); + + assert_eq!( + turn.items[1], + ThreadItem::Reasoning { + id: "item-2".into(), + summary: vec!["first summary".into()], + content: vec!["first content".into()], + } + ); + assert_eq!( + turn.items[3], + ThreadItem::Reasoning { + id: "item-4".into(), + summary: vec!["second summary".into()], + content: Vec::new(), + } + ); + } + + #[test] + fn marks_turn_as_interrupted_when_aborted() { + let events = vec![ + EventMsg::UserMessage(UserMessageEvent { + message: "Please do the thing".into(), + images: None, + }), + EventMsg::AgentMessage(AgentMessageEvent { + message: "Working...".into(), + }), + EventMsg::TurnAborted(TurnAbortedEvent { + reason: TurnAbortReason::Replaced, + }), + EventMsg::UserMessage(UserMessageEvent { + message: "Let's try again".into(), + images: None, + }), + EventMsg::AgentMessage(AgentMessageEvent { + message: "Second attempt complete.".into(), + }), + ]; + + let turns = build_turns_from_event_msgs(&events); + assert_eq!(turns.len(), 2); + + let first_turn = &turns[0]; + assert_eq!(first_turn.status, TurnStatus::Interrupted); + assert_eq!(first_turn.items.len(), 2); + assert_eq!( + first_turn.items[0], + ThreadItem::UserMessage { + id: "item-1".into(), + content: vec![UserInput::Text { + text: "Please do the thing".into() + }], + } + ); + assert_eq!( + first_turn.items[1], + ThreadItem::AgentMessage { + id: "item-2".into(), + text: "Working...".into(), + } + ); + + let second_turn = &turns[1]; + assert_eq!(second_turn.status, TurnStatus::Completed); + assert_eq!(second_turn.items.len(), 2); + assert_eq!( + second_turn.items[0], + ThreadItem::UserMessage { + id: "item-3".into(), + content: vec![UserInput::Text { + text: "Let's try again".into() + }], + } + ); + assert_eq!( + second_turn.items[1], + ThreadItem::AgentMessage { + id: "item-4".into(), + text: "Second attempt complete.".into(), + } + ); + } +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index fa1037dd14..3c82d908d3 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -11,6 +11,7 @@ use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent; use codex_protocol::items::TurnItem as CoreTurnItem; use codex_protocol::models::ResponseItem; use codex_protocol::parse_command::ParsedCommand as CoreParsedCommand; +use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot; use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow; use codex_protocol::user_input::UserInput as CoreUserInput; @@ -517,6 +518,10 @@ pub struct Thread { pub created_at: i64, /// [UNSTABLE] Path to the thread on disk. pub path: PathBuf, + /// Only populated on a `thread/resume` response. + /// For all other responses and notifications returning a Thread, + /// the turns field will be an empty list. + pub turns: Vec, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -531,8 +536,9 @@ pub struct AccountUpdatedNotification { #[ts(export_to = "v2/")] pub struct Turn { pub id: String, - /// This is currently only populated for resumed threads. - /// TODO: properly populate items for all turns. + /// Only populated on a `thread/resume` response. + /// For all other responses and notifications returning a Turn, + /// the items field will be an empty list. pub items: Vec, #[serde(flatten)] pub status: TurnStatus, @@ -989,6 +995,7 @@ pub struct AccountRateLimitsUpdatedNotification { pub struct RateLimitSnapshot { pub primary: Option, pub secondary: Option, + pub credits: Option, } impl From for RateLimitSnapshot { @@ -996,6 +1003,7 @@ impl From for RateLimitSnapshot { Self { primary: value.primary.map(RateLimitWindow::from), secondary: value.secondary.map(RateLimitWindow::from), + credits: value.credits.map(CreditsSnapshot::from), } } } @@ -1019,6 +1027,25 @@ impl From for RateLimitWindow { } } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CreditsSnapshot { + pub has_credits: bool, + pub unlimited: bool, + pub balance: Option, +} + +impl From for CreditsSnapshot { + fn from(value: CoreCreditsSnapshot) -> Self { + Self { + has_credits: value.has_credits, + unlimited: value.unlimited, + balance: value.balance, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index b53f88bd4e..c8f4c6bbd4 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -91,6 +91,8 @@ use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInfoResponse; use codex_app_server_protocol::UserInput as V2UserInput; use codex_app_server_protocol::UserSavedConfig; +use codex_app_server_protocol::WindowsWorldWritableWarningNotification; +use codex_app_server_protocol::build_turns_from_event_msgs; use codex_backend_client::Client as BackendClient; use codex_core::AuthManager; use codex_core::CodexConversation; @@ -1238,7 +1240,7 @@ impl CodexMessageProcessor { let overrides = ConfigOverrides { model, config_profile: profile, - cwd: cwd.map(PathBuf::from), + cwd: cwd.clone().map(PathBuf::from), approval_policy, sandbox_mode, model_provider, @@ -1253,7 +1255,7 @@ impl CodexMessageProcessor { // Persist windows sandbox feature. // TODO: persist default config in general. let mut cli_overrides = cli_overrides.unwrap_or_default(); - if cfg!(target_os = "windows") && self.config.features.enabled(Feature::WindowsSandbox) { + if cfg!(windows) && self.config.features.enabled(Feature::WindowsSandbox) { cli_overrides.insert( "features.enable_experimental_windows_sandbox".to_string(), serde_json::json!(true), @@ -1272,6 +1274,10 @@ impl CodexMessageProcessor { return; } }; + if cfg!(windows) && config.features.enabled(Feature::WindowsSandbox) { + self.handle_windows_world_writable_warning(config.cwd.clone()) + .await; + } match self.conversation_manager.new_conversation(config).await { Ok(conversation_id) => { @@ -1652,6 +1658,11 @@ impl CodexMessageProcessor { session_configured, .. }) => { + let SessionConfiguredEvent { + rollout_path, + initial_messages, + .. + } = session_configured; // Auto-attach a conversation listener when resuming a thread. if let Err(err) = self .attach_conversation_listener(conversation_id, false, ApiVersion::V2) @@ -1664,8 +1675,8 @@ impl CodexMessageProcessor { ); } - let thread = match read_summary_from_rollout( - session_configured.rollout_path.as_path(), + let mut thread = match read_summary_from_rollout( + rollout_path.as_path(), fallback_model_provider.as_str(), ) .await @@ -1676,13 +1687,17 @@ impl CodexMessageProcessor { request_id, format!( "failed to load rollout `{}` for conversation {conversation_id}: {err}", - session_configured.rollout_path.display() + rollout_path.display() ), ) .await; return; } }; + thread.turns = initial_messages + .as_deref() + .map_or_else(Vec::new, build_turns_from_event_msgs); + let response = ThreadResumeResponse { thread, model: session_configured.model, @@ -1692,6 +1707,7 @@ impl CodexMessageProcessor { sandbox: session_configured.sandbox_policy.into(), reasoning_effort: session_configured.reasoning_effort, }; + self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -1942,6 +1958,15 @@ impl CodexMessageProcessor { include_apply_patch_tool, } = overrides; + // Persist windows sandbox feature. + let mut cli_overrides = cli_overrides.unwrap_or_default(); + if cfg!(windows) && self.config.features.enabled(Feature::WindowsSandbox) { + cli_overrides.insert( + "features.enable_experimental_windows_sandbox".to_string(), + serde_json::json!(true), + ); + } + let overrides = ConfigOverrides { model, config_profile: profile, @@ -1957,7 +1982,7 @@ impl CodexMessageProcessor { ..Default::default() }; - derive_config_from_params(overrides, cli_overrides).await + derive_config_from_params(overrides, Some(cli_overrides)).await } None => Ok(self.config.as_ref().clone()), }; @@ -1972,6 +1997,10 @@ impl CodexMessageProcessor { return; } }; + if cfg!(windows) && config.features.enabled(Feature::WindowsSandbox) { + self.handle_windows_world_writable_warning(config.cwd.clone()) + .await; + } let conversation_history = if let Some(path) = path { match RolloutRecorder::get_rollout_history(&path).await { @@ -2831,6 +2860,53 @@ impl CodexMessageProcessor { Err(_) => None, } } + + /// On Windows, when using the experimental sandbox, we need to warn the user about world-writable directories. + async fn handle_windows_world_writable_warning(&self, cwd: PathBuf) { + if !cfg!(windows) { + return; + } + + if !self.config.features.enabled(Feature::WindowsSandbox) { + return; + } + + if !matches!( + self.config.sandbox_policy, + codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { .. } + | codex_protocol::protocol::SandboxPolicy::ReadOnly + ) { + return; + } + + if self + .config + .notices + .hide_world_writable_warning + .unwrap_or(false) + { + return; + } + + // This function is stubbed out to return None on non-Windows platforms + if let Some((sample_paths, extra_count, failed_scan)) = + codex_windows_sandbox::world_writable_warning_details( + self.config.codex_home.as_path(), + cwd, + ) + { + tracing::warn!("world writable warning: {sample_paths:?} {extra_count} {failed_scan}"); + self.outgoing + .send_server_notification(ServerNotification::WindowsWorldWritableWarning( + WindowsWorldWritableWarningNotification { + sample_paths, + extra_count, + failed_scan, + }, + )) + .await; + } + } } async fn derive_config_from_params( @@ -2984,6 +3060,7 @@ fn summary_to_thread(summary: ConversationSummary) -> Thread { model_provider, created_at: created_at.map(|dt| dt.timestamp()).unwrap_or(0), path, + turns: Vec::new(), } } diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 55f857351a..403263b896 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -11,14 +11,11 @@ use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCRequest; use codex_app_server_protocol::JSONRPCResponse; -use codex_app_server_protocol::ServerNotification; -use codex_app_server_protocol::WindowsWorldWritableWarningNotification; use codex_core::AuthManager; use codex_core::ConversationManager; use codex_core::config::Config; use codex_core::default_client::USER_AGENT_SUFFIX; use codex_core::default_client::get_codex_user_agent; -use codex_core::features::Feature; use codex_feedback::CodexFeedback; use codex_protocol::protocol::SessionSource; use std::sync::Arc; @@ -26,7 +23,6 @@ use std::sync::Arc; pub(crate) struct MessageProcessor { outgoing: Arc, codex_message_processor: CodexMessageProcessor, - config: Arc, initialized: bool, } @@ -54,14 +50,13 @@ impl MessageProcessor { conversation_manager, outgoing.clone(), codex_linux_sandbox_exe, - config.clone(), + config, feedback, ); Self { outgoing, codex_message_processor, - config, initialized: false, } } @@ -122,7 +117,6 @@ impl MessageProcessor { self.outgoing.send_response(request_id, response).await; self.initialized = true; - self.handle_windows_world_writable_warning().await; return; } @@ -162,47 +156,4 @@ impl MessageProcessor { pub(crate) fn process_error(&mut self, err: JSONRPCError) { tracing::error!("<- error: {:?}", err); } - - /// On Windows, when using the experimental sandbox, we need to warn the user about world-writable directories. - async fn handle_windows_world_writable_warning(&self) { - if !cfg!(windows) { - return; - } - - if !self.config.features.enabled(Feature::WindowsSandbox) { - return; - } - - if !matches!( - self.config.sandbox_policy, - codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { .. } - | codex_protocol::protocol::SandboxPolicy::ReadOnly - ) { - return; - } - - if self - .config - .notices - .hide_world_writable_warning - .unwrap_or(false) - { - return; - } - - // This function is stubbed out to return None on non-Windows platforms - if let Some((sample_paths, extra_count, failed_scan)) = - codex_windows_sandbox::world_writable_warning_details(self.config.codex_home.as_path()) - { - self.outgoing - .send_server_notification(ServerNotification::WindowsWorldWritableWarning( - WindowsWorldWritableWarningNotification { - sample_paths, - extra_count, - failed_scan, - }, - )) - .await; - } - } } diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 40260c8b94..b7f331c9d4 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -229,6 +229,7 @@ mod tests { resets_at: Some(123), }), secondary: None, + credits: None, }, }); @@ -243,7 +244,8 @@ mod tests { "windowDurationMins": 15, "resetsAt": 123 }, - "secondary": null + "secondary": null, + "credits": null } }, }), diff --git a/codex-rs/app-server/tests/suite/config.rs b/codex-rs/app-server/tests/suite/config.rs index 5d1c7f9d11..75dba57229 100644 --- a/codex-rs/app-server/tests/suite/config.rs +++ b/codex-rs/app-server/tests/suite/config.rs @@ -27,7 +27,7 @@ fn create_config_toml(codex_home: &Path) -> std::io::Result<()> { std::fs::write( config_toml, r#" -model = "arcticfox" +model = "gpt-5.1-codex-max" approval_policy = "on-request" sandbox_mode = "workspace-write" model_reasoning_summary = "detailed" @@ -87,7 +87,7 @@ async fn get_config_toml_parses_all_fields() -> Result<()> { }), forced_chatgpt_workspace_id: Some("12345678-0000-0000-0000-000000000000".into()), forced_login_method: Some(ForcedLoginMethod::Chatgpt), - model: Some("arcticfox".into()), + model: Some("gpt-5.1-codex-max".into()), model_reasoning_effort: Some(ReasoningEffort::High), model_reasoning_summary: Some(ReasoningSummary::Detailed), model_verbosity: Some(Verbosity::Medium), diff --git a/codex-rs/app-server/tests/suite/set_default_model.rs b/codex-rs/app-server/tests/suite/set_default_model.rs index 4d742a8434..b56c54dbd9 100644 --- a/codex-rs/app-server/tests/suite/set_default_model.rs +++ b/codex-rs/app-server/tests/suite/set_default_model.rs @@ -57,7 +57,7 @@ fn create_config_toml(codex_home: &Path) -> std::io::Result<()> { std::fs::write( config_toml, r#" -model = "arcticfox" +model = "gpt-5.1-codex-max" model_reasoning_effort = "medium" "#, ) 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 22c0bf6379..3c4844fed9 100644 --- a/codex-rs/app-server/tests/suite/v2/model_list.rs +++ b/codex-rs/app-server/tests/suite/v2/model_list.rs @@ -46,9 +46,9 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> { let expected_models = vec![ Model { - id: "arcticfox".to_string(), - model: "arcticfox".to_string(), - display_name: "arcticfox".to_string(), + id: "gpt-5.1-codex-max".to_string(), + model: "gpt-5.1-codex-max".to_string(), + display_name: "gpt-5.1-codex-max".to_string(), description: "Latest Codex-optimized flagship for deep and fast reasoning.".to_string(), supported_reasoning_efforts: vec![ ReasoningEffortOption { @@ -174,7 +174,7 @@ async fn list_models_pagination_works() -> Result<()> { } = to_response::(first_response)?; assert_eq!(first_items.len(), 1); - assert_eq!(first_items[0].id, "arcticfox"); + 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 diff --git a/codex-rs/app-server/tests/suite/v2/rate_limits.rs b/codex-rs/app-server/tests/suite/v2/rate_limits.rs index d0cba83667..7ddccf7a74 100644 --- a/codex-rs/app-server/tests/suite/v2/rate_limits.rs +++ b/codex-rs/app-server/tests/suite/v2/rate_limits.rs @@ -152,6 +152,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { window_duration_mins: Some(1440), resets_at: Some(secondary_reset_timestamp), }), + credits: None, }, }; assert_eq!(received, expected); diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 1a9c76979a..e22b711ea3 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -1,13 +1,17 @@ use anyhow::Result; use app_test_support::McpProcess; +use app_test_support::create_fake_rollout; use app_test_support::create_mock_chat_completions_server; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStatus; +use codex_app_server_protocol::UserInput; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use tempfile::TempDir; @@ -27,7 +31,7 @@ async fn thread_resume_returns_original_thread() -> Result<()> { // Start a thread. let start_id = mcp .send_thread_start_request(ThreadStartParams { - model: Some("arcticfox".to_string()), + model: Some("gpt-5.1-codex-max".to_string()), ..Default::default() }) .await?; @@ -58,6 +62,65 @@ async fn thread_resume_returns_original_thread() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_resume_returns_rollout_history() -> Result<()> { + let server = create_mock_chat_completions_server(vec![]).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let preview = "Saved user message"; + let conversation_id = create_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + preview, + Some("mock_provider"), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: conversation_id.clone(), + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { thread, .. } = to_response::(resume_resp)?; + + assert_eq!(thread.id, conversation_id); + assert_eq!(thread.preview, preview); + assert_eq!(thread.model_provider, "mock_provider"); + assert!(thread.path.is_absolute()); + + assert_eq!( + thread.turns.len(), + 1, + "expected rollouts to include one turn" + ); + let turn = &thread.turns[0]; + assert_eq!(turn.status, TurnStatus::Completed); + assert_eq!(turn.items.len(), 1, "expected user message item"); + match &turn.items[0] { + ThreadItem::UserMessage { content, .. } => { + assert_eq!( + content, + &vec![UserInput::Text { + text: preview.to_string() + }] + ); + } + other => panic!("expected user message item, got {other:?}"), + } + + Ok(()) +} + #[tokio::test] async fn thread_resume_prefers_path_over_thread_id() -> Result<()> { let server = create_mock_chat_completions_server(vec![]).await; @@ -69,7 +132,7 @@ async fn thread_resume_prefers_path_over_thread_id() -> Result<()> { let start_id = mcp .send_thread_start_request(ThreadStartParams { - model: Some("arcticfox".to_string()), + model: Some("gpt-5.1-codex-max".to_string()), ..Default::default() }) .await?; @@ -114,7 +177,7 @@ async fn thread_resume_supports_history_and_overrides() -> Result<()> { // Start a thread. let start_id = mcp .send_thread_start_request(ThreadStartParams { - model: Some("arcticfox".to_string()), + model: Some("gpt-5.1-codex-max".to_string()), ..Default::default() }) .await?; diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index 28a51598eb..0fb627ef0a 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -1,4 +1,5 @@ use crate::types::CodeTaskDetailsResponse; +use crate::types::CreditStatusDetails; use crate::types::PaginatedListTaskListItem; use crate::types::RateLimitStatusPayload; use crate::types::RateLimitWindowSnapshot; @@ -6,6 +7,7 @@ use crate::types::TurnAttemptsSiblingTurnsResponse; use anyhow::Result; use codex_core::auth::CodexAuth; use codex_core::default_client::get_codex_user_agent; +use codex_protocol::protocol::CreditsSnapshot; use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow; use reqwest::header::AUTHORIZATION; @@ -272,19 +274,23 @@ impl Client { // rate limit helpers fn rate_limit_snapshot_from_payload(payload: RateLimitStatusPayload) -> RateLimitSnapshot { - let Some(details) = payload + let rate_limit_details = payload .rate_limit - .and_then(|inner| inner.map(|boxed| *boxed)) - else { - return RateLimitSnapshot { - primary: None, - secondary: None, - }; + .and_then(|inner| inner.map(|boxed| *boxed)); + + let (primary, secondary) = if let Some(details) = rate_limit_details { + ( + Self::map_rate_limit_window(details.primary_window), + Self::map_rate_limit_window(details.secondary_window), + ) + } else { + (None, None) }; RateLimitSnapshot { - primary: Self::map_rate_limit_window(details.primary_window), - secondary: Self::map_rate_limit_window(details.secondary_window), + primary, + secondary, + credits: Self::map_credits(payload.credits), } } @@ -306,6 +312,19 @@ impl Client { }) } + fn map_credits(credits: Option>>) -> Option { + let details = match credits { + Some(Some(details)) => *details, + _ => return None, + }; + + Some(CreditsSnapshot { + has_credits: details.has_credits, + unlimited: details.unlimited, + balance: details.balance.and_then(|inner| inner), + }) + } + fn window_minutes_from_seconds(seconds: i32) -> Option { if seconds <= 0 { return None; diff --git a/codex-rs/backend-client/src/types.rs b/codex-rs/backend-client/src/types.rs index 9f196f9c2f..afeb231a18 100644 --- a/codex-rs/backend-client/src/types.rs +++ b/codex-rs/backend-client/src/types.rs @@ -1,3 +1,4 @@ +pub use codex_backend_openapi_models::models::CreditStatusDetails; pub use codex_backend_openapi_models::models::PaginatedListTaskListItem; pub use codex_backend_openapi_models::models::PlanType; pub use codex_backend_openapi_models::models::RateLimitStatusDetails; diff --git a/codex-rs/common/src/model_presets.rs b/codex-rs/common/src/model_presets.rs index dbbe1aa4e6..a031f23b1d 100644 --- a/codex-rs/common/src/model_presets.rs +++ b/codex-rs/common/src/model_presets.rs @@ -5,7 +5,8 @@ use codex_core::protocol_config_types::ReasoningEffort; use once_cell::sync::Lazy; pub const HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG: &str = "hide_gpt5_1_migration_prompt"; -pub const HIDE_ARCTICFOX_MIGRATION_PROMPT_CONFIG: &str = "hide_arcticfox_migration_prompt"; +pub const HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG: &str = + "hide_gpt-5.1-codex-max_migration_prompt"; /// A reasoning effort option that can be surfaced for a model. #[derive(Debug, Clone, Copy)] @@ -49,9 +50,9 @@ pub struct ModelPreset { static PRESETS: Lazy> = Lazy::new(|| { vec![ ModelPreset { - id: "arcticfox", - model: "arcticfox", - display_name: "arcticfox", + id: "gpt-5.1-codex-max", + model: "gpt-5.1-codex-max", + display_name: "gpt-5.1-codex-max", description: "Latest Codex-optimized flagship for deep and fast reasoning.", default_reasoning_effort: ReasoningEffort::Medium, supported_reasoning_efforts: &[ @@ -98,9 +99,9 @@ static PRESETS: Lazy> = Lazy::new(|| { ], is_default: false, upgrade: Some(ModelUpgrade { - id: "arcticfox", + id: "gpt-5.1-codex-max", reasoning_effort_mapping: None, - migration_config_key: HIDE_ARCTICFOX_MIGRATION_PROMPT_CONFIG, + migration_config_key: HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG, }), show_in_picker: true, }, @@ -121,7 +122,11 @@ static PRESETS: Lazy> = Lazy::new(|| { }, ], is_default: false, - upgrade: None, + upgrade: Some(ModelUpgrade { + id: "gpt-5.1-codex-max", + reasoning_effort_mapping: None, + migration_config_key: HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG, + }), show_in_picker: true, }, ModelPreset { @@ -145,7 +150,11 @@ static PRESETS: Lazy> = Lazy::new(|| { }, ], is_default: false, - upgrade: None, + upgrade: Some(ModelUpgrade { + id: "gpt-5.1-codex-max", + reasoning_effort_mapping: None, + migration_config_key: HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG, + }), show_in_picker: true, }, // Deprecated models. @@ -171,9 +180,9 @@ static PRESETS: Lazy> = Lazy::new(|| { ], is_default: false, upgrade: Some(ModelUpgrade { - id: "arcticfox", + id: "gpt-5.1-codex-max", reasoning_effort_mapping: None, - migration_config_key: HIDE_ARCTICFOX_MIGRATION_PROMPT_CONFIG, + migration_config_key: HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG, }), show_in_picker: false, }, @@ -227,12 +236,9 @@ static PRESETS: Lazy> = Lazy::new(|| { ], is_default: false, upgrade: Some(ModelUpgrade { - id: "gpt-5.1", - reasoning_effort_mapping: Some(HashMap::from([( - ReasoningEffort::Minimal, - ReasoningEffort::Low, - )])), - migration_config_key: HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG, + id: "gpt-5.1-codex-max", + reasoning_effort_mapping: None, + migration_config_key: HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG, }), show_in_picker: false, }, @@ -243,7 +249,7 @@ pub fn builtin_model_presets(auth_mode: Option) -> Vec { PRESETS .iter() .filter(|preset| match auth_mode { - Some(AuthMode::ApiKey) => preset.show_in_picker && preset.id != "arcticfox", + Some(AuthMode::ApiKey) => preset.show_in_picker && preset.id != "gpt-5.1-codex-max", _ => preset.show_in_picker, }) .cloned() @@ -266,8 +272,12 @@ mod tests { } #[test] - fn arcticfox_hidden_for_api_key_auth() { + fn gpt_5_1_codex_max_hidden_for_api_key_auth() { let presets = builtin_model_presets(Some(AuthMode::ApiKey)); - assert!(presets.iter().all(|preset| preset.id != "arcticfox")); + assert!( + presets + .iter() + .all(|preset| preset.id != "gpt-5.1-codex-max") + ); } } diff --git a/codex-rs/core/arcticfox_prompt.md b/codex-rs/core/gpt-5.1-codex-max_prompt.md similarity index 100% rename from codex-rs/core/arcticfox_prompt.md rename to codex-rs/core/gpt-5.1-codex-max_prompt.md diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 13c277a776..68bd30f4e7 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -56,6 +56,7 @@ use crate::model_family::ModelFamily; use crate::model_provider_info::ModelProviderInfo; use crate::model_provider_info::WireApi; use crate::openai_model_info::get_model_info; +use crate::protocol::CreditsSnapshot; use crate::protocol::RateLimitSnapshot; use crate::protocol::RateLimitWindow; use crate::protocol::TokenUsage; @@ -726,7 +727,13 @@ fn parse_rate_limit_snapshot(headers: &HeaderMap) -> Option { "x-codex-secondary-reset-at", ); - Some(RateLimitSnapshot { primary, secondary }) + let credits = parse_credits_snapshot(headers); + + Some(RateLimitSnapshot { + primary, + secondary, + credits, + }) } fn parse_rate_limit_window( @@ -753,6 +760,20 @@ fn parse_rate_limit_window( }) } +fn parse_credits_snapshot(headers: &HeaderMap) -> Option { + let has_credits = parse_header_bool(headers, "x-codex-credits-has-credits")?; + let unlimited = parse_header_bool(headers, "x-codex-credits-unlimited")?; + let balance = parse_header_str(headers, "x-codex-credits-balance") + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(std::string::ToString::to_string); + Some(CreditsSnapshot { + has_credits, + unlimited, + balance, + }) +} + fn parse_header_f64(headers: &HeaderMap, name: &str) -> Option { parse_header_str(headers, name)? .parse::() @@ -764,6 +785,17 @@ fn parse_header_i64(headers: &HeaderMap, name: &str) -> Option { parse_header_str(headers, name)?.parse::().ok() } +fn parse_header_bool(headers: &HeaderMap, name: &str) -> Option { + let raw = parse_header_str(headers, name)?; + if raw.eq_ignore_ascii_case("true") || raw == "1" { + Some(true) + } else if raw.eq_ignore_ascii_case("false") || raw == "0" { + Some(false) + } else { + None + } +} + fn parse_header_str<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { headers.get(name)?.to_str().ok() } diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index 3842ea7e93..21f6fc6576 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -422,7 +422,7 @@ mod tests { expects_apply_patch_instructions: false, }, InstructionsTestCase { - slug: "arcticfox", + slug: "gpt-5.1-codex-max", expects_apply_patch_instructions: false, }, ]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index c6024e7605..c5f0eec330 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -66,6 +66,7 @@ use crate::context_manager::ContextManager; use crate::environment_context::EnvironmentContext; use crate::error::CodexErr; use crate::error::Result as CodexResult; +use crate::error::http_status_code_value; #[cfg(test)] use crate::exec::StreamOutput; use crate::mcp::auth::compute_auth_statuses; @@ -79,7 +80,6 @@ use crate::protocol::ApplyPatchApprovalRequestEvent; use crate::protocol::AskForApproval; use crate::protocol::BackgroundEventEvent; use crate::protocol::DeprecationNoticeEvent; -use crate::protocol::ErrorEvent; use crate::protocol::Event; use crate::protocol::EventMsg; use crate::protocol::ExecApprovalRequestEvent; @@ -136,6 +136,7 @@ use codex_utils_readiness::Readiness; use codex_utils_readiness::ReadinessFlag; use codex_utils_tokenizer::warm_model_cache; use std::path::Path; +use reqwest::StatusCode; /// The high-level interface to the Codex system. /// It operates as a queue pair where you send submissions and receive events. @@ -1252,9 +1253,11 @@ impl Session { &self, turn_context: &TurnContext, message: impl Into, + http_status_code: Option, ) { let event = EventMsg::StreamError(StreamErrorEvent { message: message.into(), + http_status_code: http_status_code_value(http_status_code), }); self.send_event(turn_context, event).await; } @@ -1779,6 +1782,7 @@ mod handlers { id: sub_id.clone(), msg: EventMsg::Error(ErrorEvent { message: "Failed to shutdown rollout recorder".to_string(), + http_status_code: None, }), }; sess.send_event_raw(event).await; @@ -2032,10 +2036,8 @@ pub(crate) async fn run_task( } Err(e) => { info!("Turn error: {e:#}"); - let event = EventMsg::Error(ErrorEvent { - message: e.to_string(), - }); - sess.send_event(&turn_context, event).await; + sess.send_event(&turn_context, EventMsg::Error(e.to_error_event(None))) + .await; // let the user continue the conversation break; } @@ -2159,6 +2161,7 @@ async fn run_turn( sess.notify_stream_error( &turn_context, format!("Reconnecting... {retries}/{max_retries}"), + e.http_status_code(), ) .await; diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 8c38f93937..67b5b9342d 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -10,7 +10,6 @@ use crate::error::Result as CodexResult; use crate::features::Feature; use crate::protocol::AgentMessageEvent; use crate::protocol::CompactedItem; -use crate::protocol::ErrorEvent; use crate::protocol::EventMsg; use crate::protocol::TaskStartedEvent; use crate::protocol::TurnContextItem; @@ -128,10 +127,8 @@ async fn run_compact_task_inner( continue; } sess.set_total_tokens_full(turn_context.as_ref()).await; - let event = EventMsg::Error(ErrorEvent { - message: e.to_string(), - }); - sess.send_event(&turn_context, event).await; + sess.send_event(&turn_context, EventMsg::Error(e.to_error_event(None))) + .await; return; } Err(e) => { @@ -141,15 +138,14 @@ async fn run_compact_task_inner( sess.notify_stream_error( turn_context.as_ref(), format!("Reconnecting... {retries}/{max_retries}"), + e.http_status_code(), ) .await; tokio::time::sleep(delay).await; continue; } else { - let event = EventMsg::Error(ErrorEvent { - message: e.to_string(), - }); - sess.send_event(&turn_context, event).await; + sess.send_event(&turn_context, EventMsg::Error(e.to_error_event(None))) + .await; return; } } diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 0d2e0f138d..aa32942dce 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -6,7 +6,6 @@ use crate::codex::TurnContext; use crate::error::Result as CodexResult; use crate::protocol::AgentMessageEvent; use crate::protocol::CompactedItem; -use crate::protocol::ErrorEvent; use crate::protocol::EventMsg; use crate::protocol::RolloutItem; use crate::protocol::TaskStartedEvent; @@ -30,10 +29,8 @@ pub(crate) async fn run_remote_compact_task(sess: Arc, turn_context: Ar async fn run_remote_compact_task_inner(sess: &Arc, turn_context: &Arc) { if let Err(err) = run_remote_compact_task_inner_impl(sess, turn_context).await { - let event = EventMsg::Error(ErrorEvent { - message: format!("Error running remote compact task: {err}"), - }); - sess.send_event(turn_context, event).await; + let event = err.to_error_event(Some("Error running remote compact task".to_string())); + sess.send_event(turn_context, EventMsg::Error(event)).await; } } diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index d3bc5cf99b..bd7c16a0ae 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -846,7 +846,7 @@ hide_gpt5_1_migration_prompt = true } #[test] - fn blocking_set_hide_arcticfox_migration_prompt_preserves_table() { + fn blocking_set_hide_gpt_5_1_codex_max_migration_prompt_preserves_table() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); std::fs::write( @@ -860,7 +860,7 @@ existing = "value" codex_home, None, &[ConfigEdit::SetNoticeHideModelMigrationPrompt( - "hide_arcticfox_migration_prompt".to_string(), + "hide_gpt-5.1-codex-max_migration_prompt".to_string(), true, )], ) @@ -870,7 +870,7 @@ existing = "value" std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"[notice] existing = "value" -hide_arcticfox_migration_prompt = true +"hide_gpt-5.1-codex-max_migration_prompt" = true "#; assert_eq!(contents, expected); } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 36d4ef4dbc..4cdb1b0b03 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -61,12 +61,9 @@ pub mod edit; pub mod profile; pub mod types; -#[cfg(target_os = "windows")] -pub const OPENAI_DEFAULT_MODEL: &str = "arcticfox"; -#[cfg(not(target_os = "windows"))] -pub const OPENAI_DEFAULT_MODEL: &str = "arcticfox"; -const OPENAI_DEFAULT_REVIEW_MODEL: &str = "arcticfox"; -pub const GPT_5_CODEX_MEDIUM_MODEL: &str = "arcticfox"; +pub const OPENAI_DEFAULT_MODEL: &str = "gpt-5.1-codex"; +const OPENAI_DEFAULT_REVIEW_MODEL: &str = "gpt-5.1-codex"; +pub const GPT_5_CODEX_MEDIUM_MODEL: &str = "gpt-5.1-codex"; /// Maximum number of bytes of the documentation that will be embedded. Larger /// files are *silently truncated* to this size so we do not take up too much of @@ -81,7 +78,7 @@ pub struct Config { /// Optional override of model selection. pub model: String, - /// Model used specifically for review sessions. Defaults to "arcticfox". + /// Model used specifically for review sessions. Defaults to "gpt-5.1-codex-max". pub review_model: String, pub model_family: ModelFamily, diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index 45b2c1efbf..b7f16a9028 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -378,8 +378,8 @@ pub struct Notice { pub hide_rate_limit_model_nudge: Option, /// Tracks whether the user has seen the model migration prompt pub hide_gpt5_1_migration_prompt: Option, - /// Tracks whether the user has seen the arcticfox migration prompt - pub hide_arcticfox_migration_prompt: Option, + /// Tracks whether the user has seen the gpt-5.1-codex-max migration prompt + pub hide_gpt_5_1_codex_max_migration_prompt: Option, } impl Notice { diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index 9a42ec3d1b..d20d5cfb46 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -10,6 +10,7 @@ use chrono::Local; use chrono::Utc; use codex_async_utils::CancelErr; use codex_protocol::ConversationId; +use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::RateLimitSnapshot; use reqwest::StatusCode; use serde_json; @@ -430,6 +431,37 @@ impl CodexErr { pub fn downcast_ref(&self) -> Option<&T> { (self as &dyn std::any::Any).downcast_ref::() } + + pub fn http_status_code(&self) -> Option { + match self { + CodexErr::UnexpectedStatus(err) => Some(err.status), + CodexErr::RetryLimit(err) => Some(err.status), + CodexErr::UsageLimitReached(_) | CodexErr::UsageNotIncluded => { + Some(StatusCode::TOO_MANY_REQUESTS) + } + CodexErr::InternalServerError => Some(StatusCode::INTERNAL_SERVER_ERROR), + CodexErr::ResponseStreamFailed(err) => err.source.status(), + CodexErr::ConnectionFailed(err) => err.source.status(), + _ => None, + } + } + + pub fn to_error_event(&self, message_prefix: Option) -> ErrorEvent { + let error_message = self.to_string(); + let message: String = match message_prefix { + Some(prefix) => format!("{prefix}: {error_message}"), + None => error_message, + }; + + ErrorEvent { + message, + http_status_code: http_status_code_value(self.http_status_code()), + } + } +} + +pub fn http_status_code_value(http_status_code: Option) -> Option { + http_status_code.as_ref().map(StatusCode::as_u16) } pub fn get_error_message_ui(e: &CodexErr) -> String { @@ -499,6 +531,7 @@ mod tests { window_minutes: Some(120), resets_at: Some(secondary_reset_at), }), + credits: None, } } @@ -774,4 +807,43 @@ mod tests { assert_eq!(err.to_string(), expected); }); } + + #[test] + fn error_event_includes_http_status_code_when_available() { + let err = CodexErr::UnexpectedStatus(UnexpectedResponseError { + status: StatusCode::BAD_REQUEST, + body: "oops".to_string(), + request_id: Some("req-1".to_string()), + }); + let event = err.to_error_event(None); + + assert_eq!( + event.message, + "unexpected status 400 Bad Request: oops, request id: req-1" + ); + assert_eq!( + event.http_status_code, + Some(StatusCode::BAD_REQUEST.as_u16()) + ); + } + + #[test] + fn error_event_omits_http_status_code_when_unknown() { + let event = CodexErr::Fatal("boom".to_string()).to_error_event(None); + + assert_eq!(event.message, "Fatal error: boom"); + assert_eq!(event.http_status_code, None); + } + + #[test] + fn error_event_applies_message_wrapper() { + let event = CodexErr::Fatal("boom".to_string()) + .to_error_event(Some("Error running remote compact task".to_string())); + + assert_eq!( + event.message, + "Error running remote compact task: Fatal error: boom" + ); + assert_eq!(event.http_status_code, None); + } } diff --git a/codex-rs/core/src/model_family.rs b/codex-rs/core/src/model_family.rs index be57c53d9c..ef54a95841 100644 --- a/codex-rs/core/src/model_family.rs +++ b/codex-rs/core/src/model_family.rs @@ -12,7 +12,7 @@ const BASE_INSTRUCTIONS: &str = include_str!("../prompt.md"); const GPT_5_CODEX_INSTRUCTIONS: &str = include_str!("../gpt_5_codex_prompt.md"); const GPT_5_1_INSTRUCTIONS: &str = include_str!("../gpt_5_1_prompt.md"); -const ARCTICFOX_INSTRUCTIONS: &str = include_str!("../arcticfox_prompt.md"); +const GPT_5_1_CODEX_MAX_INSTRUCTIONS: &str = include_str!("../gpt-5.1-codex-max_prompt.md"); /// A model family is a group of models that share certain characteristics. #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -173,20 +173,20 @@ pub fn find_family_for_model(slug: &str) -> Option { support_verbosity: true, truncation_policy: TruncationPolicy::Tokens(10_000), ) - } else if slug.starts_with("arcticfox") { + + // Production models. + } else if slug.starts_with("gpt-5.1-codex-max") { model_family!( slug, slug, supports_reasoning_summaries: true, reasoning_summary_format: ReasoningSummaryFormat::Experimental, - base_instructions: ARCTICFOX_INSTRUCTIONS.to_string(), + base_instructions: GPT_5_1_CODEX_MAX_INSTRUCTIONS.to_string(), apply_patch_tool_type: Some(ApplyPatchToolType::Freeform), shell_type: ConfigShellToolType::ShellCommand, supports_parallel_tool_calls: true, support_verbosity: false, truncation_policy: TruncationPolicy::Tokens(10_000), ) - - // Production models. } else if slug.starts_with("gpt-5-codex") || slug.starts_with("gpt-5.1-codex") || slug.starts_with("codex-") @@ -202,18 +202,6 @@ pub fn find_family_for_model(slug: &str) -> Option { support_verbosity: false, truncation_policy: TruncationPolicy::Tokens(10_000), ) - } else if slug.starts_with("arcticfox") { - model_family!( - slug, slug, - supports_reasoning_summaries: true, - reasoning_summary_format: ReasoningSummaryFormat::Experimental, - base_instructions: BASE_INSTRUCTIONS.to_string(), - apply_patch_tool_type: Some(ApplyPatchToolType::Freeform), - shell_type: ConfigShellToolType::ShellCommand, - supports_parallel_tool_calls: true, - support_verbosity: false, - truncation_policy: TruncationPolicy::Tokens(10_000), - ) } else if slug.starts_with("gpt-5.1") { model_family!( slug, "gpt-5.1", diff --git a/codex-rs/core/src/openai_model_info.rs b/codex-rs/core/src/openai_model_info.rs index cd5d12f8a4..bd0ca9d31d 100644 --- a/codex-rs/core/src/openai_model_info.rs +++ b/codex-rs/core/src/openai_model_info.rs @@ -72,7 +72,7 @@ pub(crate) fn get_model_info(model_family: &ModelFamily) -> Option { _ if slug.starts_with("gpt-5-codex") || slug.starts_with("gpt-5.1-codex") - || slug.starts_with("arcticfox") => + || slug.starts_with("gpt-5.1-codex-max") => { Some(ModelInfo::new(CONTEXT_WINDOW_272K, MAX_OUTPUT_TOKENS_128K)) } diff --git a/codex-rs/core/src/truncate.rs b/codex-rs/core/src/truncate.rs index 6b997ac09f..7756b0c09a 100644 --- a/codex-rs/core/src/truncate.rs +++ b/codex-rs/core/src/truncate.rs @@ -282,22 +282,11 @@ fn truncate_on_boundary(input: &str, max_len: usize) -> &str { } fn pick_prefix_end(s: &str, left_budget: usize) -> usize { - if let Some(head) = s.get(..left_budget) - && let Some(i) = head.rfind('\n') - { - return i + 1; - } truncate_on_boundary(s, left_budget).len() } fn pick_suffix_start(s: &str, right_budget: usize) -> usize { let start_tail = s.len().saturating_sub(right_budget); - if let Some(tail) = s.get(start_tail..) - && let Some(i) = tail.find('\n') - { - return start_tail + i + 1; - } - let mut idx = start_tail.min(s.len()); while idx < s.len() && !s.is_char_boundary(idx) { idx += 1; @@ -420,7 +409,7 @@ mod tests { fn truncate_middle_tokens_handles_utf8_content() { let s = "😀😀😀😀😀😀😀😀😀😀\nsecond line with text\n"; let (out, tokens) = truncate_with_token_budget(s, TruncationPolicy::Tokens(8)); - assert_eq!(out, "😀😀😀😀…8 tokens truncated…"); + assert_eq!(out, "😀😀😀😀…8 tokens truncated… line with text\n"); assert_eq!(tokens, Some(16)); } @@ -428,7 +417,7 @@ mod tests { fn truncate_middle_bytes_handles_utf8_content() { let s = "😀😀😀😀😀😀😀😀😀😀\nsecond line with text\n"; let out = truncate_text(s, TruncationPolicy::Bytes(20)); - assert_eq!(out, "😀😀…31 chars truncated…"); + assert_eq!(out, "😀😀…21 chars truncated…with text\n"); } #[test] diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 1495fbf80b..59b7e34171 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1121,7 +1121,8 @@ async fn token_count_includes_rate_limits_snapshot() { "used_percent": 40.0, "window_minutes": 60, "resets_at": 1704074400 - } + }, + "credits": null } }) ); @@ -1155,7 +1156,7 @@ async fn token_count_includes_rate_limits_snapshot() { "reasoning_output_tokens": 0, "total_tokens": 123 }, - // Default model is arcticfox in tests → 95% usable context window + // Default model is gpt-5.1-codex-max in tests → 95% usable context window "model_context_window": 258400 }, "rate_limits": { @@ -1168,7 +1169,8 @@ async fn token_count_includes_rate_limits_snapshot() { "used_percent": 40.0, "window_minutes": 60, "resets_at": 1704074400 - } + }, + "credits": null } }) ); @@ -1238,7 +1240,8 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> { "used_percent": 87.5, "window_minutes": 60, "resets_at": null - } + }, + "credits": null }); let submission_id = codex diff --git a/codex-rs/core/tests/suite/shell_serialization.rs b/codex-rs/core/tests/suite/shell_serialization.rs index 7e15d2625e..24c90bde2b 100644 --- a/codex-rs/core/tests/suite/shell_serialization.rs +++ b/codex-rs/core/tests/suite/shell_serialization.rs @@ -457,7 +457,7 @@ Output: 4 5 6 -.*…45 tokens truncated….* +.*…46 tokens truncated….* 396 397 398 diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index b05acf5380..ed9e7cde3b 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -167,7 +167,7 @@ async fn tool_call_output_configured_limit_chars_type() -> Result<()> { "expected truncated shell output to be plain text" ); - assert_eq!(output.len(), 400094, "we should be almost 100k tokens"); + assert_eq!(output.len(), 400097, "we should be almost 100k tokens"); assert!( !output.contains("tokens truncated"), @@ -245,7 +245,7 @@ async fn tool_call_output_exceeds_limit_truncated_chars_limit() -> Result<()> { ); assert_eq!(output.len(), 9976); // ~10k characters - let truncated_pattern = r#"(?s)^Exit code: 0\nWall time: 0 seconds\nTotal output lines: 100000\n.*?…578898 chars truncated….*$"#; + let truncated_pattern = r#"(?s)^Exit code: 0\nWall time: 0 seconds\nTotal output lines: 100000\nOutput:\n.*?…\d+ chars truncated….*$"#; assert_regex_match(truncated_pattern, &output); diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index bf926ef96a..07a0b21a7f 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -1586,7 +1586,7 @@ PY let large_output = outputs.get(call_id).expect("missing large output summary"); let output_text = large_output.output.replace("\r\n", "\n"); - let truncated_pattern = r"(?s)^Total output lines: \d+\n\n(token token \n){5,}.*…\d+ tokens truncated…(token token \n){5,}$"; + let truncated_pattern = r"(?s)^Total output lines: \d+\n\n(token token \n){5,}.*…\d+ tokens truncated….*(token token \n){5,}$"; assert_regex_match(truncated_pattern, &output_text); let original_tokens = large_output diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index 635813d7a1..e6fbdee9e5 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -257,7 +257,8 @@ async fn user_shell_command_output_is_truncated_in_history() -> anyhow::Result<( let head = (1..=69).map(|i| format!("{i}\n")).collect::(); let tail = (352..=400).map(|i| format!("{i}\n")).collect::(); - let truncated_body = format!("Total output lines: 400\n\n{head}…273 tokens truncated…{tail}"); + let truncated_body = + format!("Total output lines: 400\n\n{head}70…273 tokens truncated…351\n{tail}"); let escaped_command = escape(&command); let escaped_truncated_body = escape(&truncated_body); let expected_pattern = format!( diff --git a/codex-rs/docs/protocol_v1.md b/codex-rs/docs/protocol_v1.md index f6405c9bc3..c6af7c9260 100644 --- a/codex-rs/docs/protocol_v1.md +++ b/codex-rs/docs/protocol_v1.md @@ -72,7 +72,7 @@ For complete documentation of the `Op` and `EventMsg` variants, refer to [protoc - `EventMsg::AgentMessage` – Messages from the `Model` - `EventMsg::ExecApprovalRequest` – Request approval from user to execute a command - `EventMsg::TaskComplete` – A task completed successfully - - `EventMsg::Error` – A task stopped with an error + - `EventMsg::Error` – A task stopped with an error (includes an optional `http_status_code` when available) - `EventMsg::Warning` – A non-fatal warning that the client should surface to the user - `EventMsg::TurnComplete` – Contains a `response_id` bookmark for last `response_id` executed by the task. This can be used to continue the task at a later point in time, perhaps with additional user input. diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index ca22ac09c8..f2ccf522a7 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -161,7 +161,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { fn process_event(&mut self, event: Event) -> CodexStatus { let Event { id: _, msg } = event; match msg { - EventMsg::Error(ErrorEvent { message }) => { + EventMsg::Error(ErrorEvent { message, .. }) => { let prefix = "ERROR:".style(self.red); ts_msg!(self, "{prefix} {message}"); } @@ -221,7 +221,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => { ts_msg!(self, "{}", message.style(self.dimmed)); } - EventMsg::StreamError(StreamErrorEvent { message }) => { + EventMsg::StreamError(StreamErrorEvent { message, .. }) => { ts_msg!(self, "{}", message.style(self.dimmed)); } EventMsg::TaskStarted(_) => { diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 5053f61921..444d32304a 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -539,6 +539,7 @@ fn error_event_produces_error() { "e1", EventMsg::Error(codex_core::protocol::ErrorEvent { message: "boom".to_string(), + http_status_code: Some(500), }), )); assert_eq!( @@ -578,6 +579,7 @@ fn stream_error_event_produces_error() { "e1", EventMsg::StreamError(codex_core::protocol::StreamErrorEvent { message: "retrying".to_string(), + http_status_code: Some(500), }), )); assert_eq!( @@ -596,6 +598,7 @@ fn error_followed_by_task_complete_produces_turn_failed() { "e1", EventMsg::Error(ErrorEvent { message: "boom".to_string(), + http_status_code: Some(500), }), ); assert_eq!( diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 8c3584a42e..61d2e55542 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -692,6 +692,8 @@ pub struct ExitedReviewModeEvent { #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ErrorEvent { pub message: String, + #[serde(default)] + pub http_status_code: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] @@ -796,6 +798,7 @@ pub struct TokenCountEvent { pub struct RateLimitSnapshot { pub primary: Option, pub secondary: Option, + pub credits: Option, } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] @@ -810,6 +813,13 @@ pub struct RateLimitWindow { pub resets_at: Option, } +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] +pub struct CreditsSnapshot { + pub has_credits: bool, + pub unlimited: bool, + pub balance: Option, +} + // Includes prompts, tools and space to call compact. const BASELINE_TOKENS: i64 = 12000; @@ -1371,6 +1381,8 @@ pub struct UndoCompletedEvent { #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct StreamErrorEvent { pub message: String, + #[serde(default)] + pub http_status_code: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 3e7b06c4af..749a145b75 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -21,7 +21,7 @@ use crate::tui::TuiEvent; use crate::update_action::UpdateAction; use codex_ansi_escape::ansi_escape_line; use codex_app_server_protocol::AuthMode; -use codex_common::model_presets::HIDE_ARCTICFOX_MIGRATION_PROMPT_CONFIG; +use codex_common::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; use codex_common::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; use codex_common::model_presets::ModelUpgrade; use codex_common::model_presets::all_model_presets; @@ -56,7 +56,7 @@ use tokio::select; use tokio::sync::mpsc::unbounded_channel; const GPT_5_1_MIGRATION_AUTH_MODES: [AuthMode; 2] = [AuthMode::ChatGPT, AuthMode::ApiKey]; -const ARCTICFOX_MIGRATION_AUTH_MODES: [AuthMode; 1] = [AuthMode::ChatGPT]; +const GPT_5_1_CODEX_MIGRATION_AUTH_MODES: [AuthMode; 1] = [AuthMode::ChatGPT]; #[derive(Debug, Clone)] pub struct AppExitInfo { @@ -105,7 +105,9 @@ fn should_show_model_migration_prompt( fn migration_prompt_hidden(config: &Config, migration_config_key: &str) -> Option { match migration_config_key { - HIDE_ARCTICFOX_MIGRATION_PROMPT_CONFIG => config.notices.hide_arcticfox_migration_prompt, + HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG => { + config.notices.hide_gpt_5_1_codex_max_migration_prompt + } HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG => config.notices.hide_gpt5_1_migration_prompt, _ => None, } @@ -169,6 +171,11 @@ async fn handle_model_migration_prompt_if_needed( effort: mapped_effort, }); } + ModelMigrationOutcome::Rejected => { + app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged { + migration_config: migration_config_key.to_string(), + }); + } ModelMigrationOutcome::Exit => { return Some(AppExitInfo { token_usage: TokenUsage::default(), @@ -661,19 +668,17 @@ impl App { .await { Ok(()) => { - let effort_label = effort - .map(|eff| format!(" with {eff} reasoning")) - .unwrap_or_else(|| " with default reasoning".to_string()); + let reasoning_label = Self::reasoning_label(effort); if let Some(profile) = profile { self.chat_widget.add_info_message( format!( - "Model changed to {model}{effort_label} for {profile} profile" + "Model changed to {model} {reasoning_label} for {profile} profile" ), None, ); } else { self.chat_widget.add_info_message( - format!("Model changed to {model}{effort_label}"), + format!("Model changed to {model} {reasoning_label}"), None, ); } @@ -841,6 +846,17 @@ impl App { Ok(true) } + fn reasoning_label(reasoning_effort: Option) -> &'static str { + match reasoning_effort { + Some(ReasoningEffortConfig::Minimal) => "minimal", + Some(ReasoningEffortConfig::Low) => "low", + Some(ReasoningEffortConfig::Medium) => "medium", + Some(ReasoningEffortConfig::High) => "high", + Some(ReasoningEffortConfig::XHigh) => "xhigh", + None | Some(ReasoningEffortConfig::None) => "default", + } + } + pub(crate) fn token_usage(&self) -> codex_core::protocol::TokenUsage { self.chat_widget.token_usage() } @@ -966,7 +982,7 @@ impl App { fn migration_prompt_allowed_auth_modes(migration_config_key: &str) -> Option<&'static [AuthMode]> { match migration_config_key { HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG => Some(&GPT_5_1_MIGRATION_AUTH_MODES), - HIDE_ARCTICFOX_MIGRATION_PROMPT_CONFIG => Some(&ARCTICFOX_MIGRATION_AUTH_MODES), + HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG => Some(&GPT_5_1_CODEX_MIGRATION_AUTH_MODES), _ => None, } } @@ -1054,7 +1070,7 @@ mod tests { )); assert!(should_show_model_migration_prompt( "gpt-5.1-codex", - "arcticfox", + "gpt-5.1-codex-max", None )); assert!(!should_show_model_migration_prompt( @@ -1201,14 +1217,14 @@ mod tests { } #[test] - fn arcticfox_migration_limits_to_chatgpt() { + fn gpt_5_1_codex_max_migration_limits_to_chatgpt() { assert!(migration_prompt_allows_auth_mode( Some(AuthMode::ChatGPT), - HIDE_ARCTICFOX_MIGRATION_PROMPT_CONFIG, + HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG, )); assert!(!migration_prompt_allows_auth_mode( Some(AuthMode::ApiKey), - HIDE_ARCTICFOX_MIGRATION_PROMPT_CONFIG, + HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG, )); } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 6a7a574268..d956c70c4f 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -257,7 +257,7 @@ impl ChatComposer { true } Err(err) => { - tracing::info!("ERR: {err}"); + tracing::trace!("ERR: {err}"); false } } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 8018ccab1a..fd66a3c078 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1655,7 +1655,7 @@ impl ChatWidget { self.on_rate_limit_snapshot(ev.rate_limits); } EventMsg::Warning(WarningEvent { message }) => self.on_warning(message), - EventMsg::Error(ErrorEvent { message }) => self.on_error(message), + EventMsg::Error(ErrorEvent { message, .. }) => self.on_error(message), EventMsg::McpStartupUpdate(ev) => self.on_mcp_startup_update(ev), EventMsg::McpStartupComplete(ev) => self.on_mcp_startup_complete(ev), EventMsg::SaveSessionResponse(ev) => { @@ -1704,7 +1704,9 @@ impl ChatWidget { } EventMsg::UndoStarted(ev) => self.on_undo_started(ev), EventMsg::UndoCompleted(ev) => self.on_undo_completed(ev), - EventMsg::StreamError(StreamErrorEvent { message }) => self.on_stream_error(message), + EventMsg::StreamError(StreamErrorEvent { message, .. }) => { + self.on_stream_error(message) + } EventMsg::UserMessage(ev) => { if from_replay { self.on_user_message_event(ev); @@ -2064,8 +2066,8 @@ impl ChatWidget { let effort_label = Self::reasoning_effort_label(effort); format!("⚠ {effort_label} reasoning effort can quickly consume Plus plan rate limits.") }); - let warn_for_model = - preset.model.starts_with("gpt-5.1-codex") || preset.model.starts_with("arcticfox"); + let warn_for_model = preset.model.starts_with("gpt-5.1-codex") + || preset.model.starts_with("gpt-5.1-codex-max"); struct EffortChoice { stored: Option, @@ -2334,7 +2336,11 @@ impl ChatWidget { { return None; } - codex_windows_sandbox::world_writable_warning_details(self.config.codex_home.as_path()) + let cwd = match std::env::current_dir() { + Ok(cwd) => cwd, + Err(_) => return Some((Vec::new(), 0, true)), + }; + codex_windows_sandbox::world_writable_warning_details(self.config.codex_home.as_path(), cwd) } #[cfg(not(target_os = "windows"))] diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap index 227278d05d..b4b89736a9 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap @@ -2,7 +2,7 @@ source: tui/src/chatwidget/tests.rs expression: popup --- - Select Reasoning Level for arcticfox + Select Reasoning Level for gpt-5.1-codex-max 1. Low Fast responses with lighter reasoning 2. Medium (default) Balances speed and reasoning depth for everyday tasks diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup_extra_high_warning.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup_extra_high_warning.snap index f630598322..c5332ff590 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup_extra_high_warning.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup_extra_high_warning.snap @@ -3,7 +3,7 @@ source: tui/src/chatwidget/tests.rs assertion_line: 1548 expression: popup --- - Select Reasoning Level for arcticfox + Select Reasoning Level for gpt-5.1-codex-max 1. Low Fast responses with lighter reasoning 2. Medium (default) Balances speed and reasoning depth for everyday diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 2a0ced7564..75ceb13a86 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -81,6 +81,7 @@ fn snapshot(percent: f64) -> RateLimitSnapshot { resets_at: None, }), secondary: None, + credits: None, } } @@ -1526,13 +1527,13 @@ fn startup_prompts_for_windows_sandbox_when_agent_requested() { fn model_reasoning_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); - chat.config.model = "arcticfox".to_string(); + chat.config.model = "gpt-5.1-codex-max".to_string(); chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::High); let preset = builtin_model_presets(None) .into_iter() - .find(|preset| preset.model == "arcticfox") - .expect("arcticfox preset"); + .find(|preset| preset.model == "gpt-5.1-codex-max") + .expect("gpt-5.1-codex-max preset"); chat.open_reasoning_popup(preset); let popup = render_bottom_popup(&chat, 80); @@ -1543,13 +1544,13 @@ fn model_reasoning_selection_popup_snapshot() { fn model_reasoning_selection_popup_extra_high_warning_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); - chat.config.model = "arcticfox".to_string(); + chat.config.model = "gpt-5.1-codex-max".to_string(); chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::XHigh); let preset = builtin_model_presets(None) .into_iter() - .find(|preset| preset.model == "arcticfox") - .expect("arcticfox preset"); + .find(|preset| preset.model == "gpt-5.1-codex-max") + .expect("gpt-5.1-codex-max preset"); chat.open_reasoning_popup(preset); let popup = render_bottom_popup(&chat, 80); @@ -1560,12 +1561,12 @@ fn model_reasoning_selection_popup_extra_high_warning_snapshot() { fn reasoning_popup_shows_extra_high_with_space() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); - chat.config.model = "arcticfox".to_string(); + chat.config.model = "gpt-5.1-codex-max".to_string(); let preset = builtin_model_presets(None) .into_iter() - .find(|preset| preset.model == "arcticfox") - .expect("arcticfox preset"); + .find(|preset| preset.model == "gpt-5.1-codex-max") + .expect("gpt-5.1-codex-max preset"); chat.open_reasoning_popup(preset); let popup = render_bottom_popup(&chat, 120); @@ -2501,6 +2502,7 @@ fn stream_error_updates_status_indicator() { id: "sub-1".into(), msg: EventMsg::StreamError(StreamErrorEvent { message: msg.to_string(), + http_status_code: None, }), }); diff --git a/codex-rs/tui/src/model_migration.rs b/codex-rs/tui/src/model_migration.rs index 1cb8543570..283007e028 100644 --- a/codex-rs/tui/src/model_migration.rs +++ b/codex-rs/tui/src/model_migration.rs @@ -1,11 +1,13 @@ +use crate::key_hint; use crate::render::Insets; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; use crate::render::renderable::RenderableExt as _; +use crate::selection_list::selection_option_row; use crate::tui::FrameRequester; use crate::tui::Tui; use crate::tui::TuiEvent; -use codex_common::model_presets::HIDE_ARCTICFOX_MIGRATION_PROMPT_CONFIG; +use codex_common::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; use codex_common::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -22,8 +24,10 @@ use ratatui::widgets::Wrap; use tokio_stream::StreamExt; /// Outcome of the migration prompt. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum ModelMigrationOutcome { Accepted, + Rejected, Exit, } @@ -31,13 +35,33 @@ pub(crate) enum ModelMigrationOutcome { pub(crate) struct ModelMigrationCopy { pub heading: Vec>, pub content: Vec>, + pub can_opt_out: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum MigrationMenuOption { + TryNewModel, + UseExistingModel, +} + +impl MigrationMenuOption { + fn all() -> [Self; 2] { + [Self::TryNewModel, Self::UseExistingModel] + } + + fn label(self) -> &'static str { + match self { + Self::TryNewModel => "Try new model", + Self::UseExistingModel => "Use existing model", + } + } } pub(crate) fn migration_copy_for_config(migration_config_key: &str) -> ModelMigrationCopy { match migration_config_key { HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG => gpt5_migration_copy(), - HIDE_ARCTICFOX_MIGRATION_PROMPT_CONFIG => arcticfox_migration_copy(), - _ => arcticfox_migration_copy(), + HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG => gpt_5_1_codex_max_migration_copy(), + _ => gpt_5_1_codex_max_migration_copy(), } } @@ -98,7 +122,8 @@ struct ModelMigrationScreen { request_frame: FrameRequester, copy: ModelMigrationCopy, done: bool, - should_exit: bool, + outcome: ModelMigrationOutcome, + highlighted_option: MigrationMenuOption, } impl ModelMigrationScreen { @@ -107,15 +132,47 @@ impl ModelMigrationScreen { request_frame, copy, done: false, - should_exit: false, + outcome: ModelMigrationOutcome::Accepted, + highlighted_option: MigrationMenuOption::TryNewModel, } } - fn accept(&mut self) { + fn finish_with(&mut self, outcome: ModelMigrationOutcome) { + self.outcome = outcome; self.done = true; self.request_frame.schedule_frame(); } + fn accept(&mut self) { + self.finish_with(ModelMigrationOutcome::Accepted); + } + + fn reject(&mut self) { + self.finish_with(ModelMigrationOutcome::Rejected); + } + + fn exit(&mut self) { + self.finish_with(ModelMigrationOutcome::Exit); + } + + fn confirm_selection(&mut self) { + if self.copy.can_opt_out { + match self.highlighted_option { + MigrationMenuOption::TryNewModel => self.accept(), + MigrationMenuOption::UseExistingModel => self.reject(), + } + } else { + self.accept(); + } + } + + fn highlight_option(&mut self, option: MigrationMenuOption) { + if self.highlighted_option != option { + self.highlighted_option = option; + self.request_frame.schedule_frame(); + } + } + fn handle_key(&mut self, key_event: KeyEvent) { if key_event.kind == KeyEventKind::Release { return; @@ -124,14 +181,36 @@ impl ModelMigrationScreen { if key_event.modifiers.contains(KeyModifiers::CONTROL) && matches!(key_event.code, KeyCode::Char('c') | KeyCode::Char('d')) { - self.should_exit = true; - self.done = true; - self.request_frame.schedule_frame(); + self.exit(); return; } - if matches!(key_event.code, KeyCode::Esc | KeyCode::Enter) { - self.accept(); + if !self.copy.can_opt_out { + if matches!(key_event.code, KeyCode::Esc | KeyCode::Enter) { + self.accept(); + } + return; + } + + match key_event.code { + KeyCode::Up | KeyCode::Char('k') => { + self.highlight_option(MigrationMenuOption::TryNewModel); + } + KeyCode::Down | KeyCode::Char('j') => { + self.highlight_option(MigrationMenuOption::UseExistingModel); + } + KeyCode::Char('1') => { + self.highlight_option(MigrationMenuOption::TryNewModel); + self.accept(); + } + KeyCode::Char('2') => { + self.highlight_option(MigrationMenuOption::UseExistingModel); + self.reject(); + } + KeyCode::Enter | KeyCode::Esc => { + self.confirm_selection(); + } + _ => {} } } @@ -140,11 +219,7 @@ impl ModelMigrationScreen { } fn outcome(&self) -> ModelMigrationOutcome { - if self.should_exit { - ModelMigrationOutcome::Exit - } else { - ModelMigrationOutcome::Accepted - } + self.outcome } } @@ -172,25 +247,56 @@ impl WidgetRef for &ModelMigrationScreen { ); } + if self.copy.can_opt_out { + column.push(Line::from("")); + column.push( + Paragraph::new("Choose how you'd like Codex to proceed.") + .wrap(Wrap { trim: false }) + .inset(Insets::tlbr(0, 2, 0, 0)), + ); + column.push(Line::from("")); + + for (idx, option) in MigrationMenuOption::all().into_iter().enumerate() { + column.push(selection_option_row( + idx, + option.label().to_string(), + self.highlighted_option == option, + )); + } + + column.push(Line::from("")); + column.push( + Line::from(vec![ + "Use ".dim(), + key_hint::plain(KeyCode::Up).into(), + "/".dim(), + key_hint::plain(KeyCode::Down).into(), + " to move, press ".dim(), + key_hint::plain(KeyCode::Enter).into(), + " to confirm".dim(), + ]) + .inset(Insets::tlbr(0, 2, 0, 0)), + ); + } + column.render(area, buf); } } -fn arcticfox_migration_copy() -> ModelMigrationCopy { +fn gpt_5_1_codex_max_migration_copy() -> ModelMigrationCopy { ModelMigrationCopy { - heading: vec!["Introducing arcticfox".bold()], + heading: vec!["Codex just got an upgrade. Introducing gpt-5.1-codex-max".bold()], content: vec![ - Line::from("We've upgraded our family of models supported in Codex to arcticfox."), Line::from( - "You can continue using legacy models by specifying them directly with the -m option or in your config.toml.", + "Codex is now powered by gpt-5.1-codex-max, our latest frontier agentic coding model. It is smarter and faster than its predecessors and capable of long-running project-scale work.", ), Line::from(vec![ "Learn more at ".into(), - "www.openai.com/index/arcticfox".cyan().underlined(), + "www.openai.com/index/gpt-5-1-codex-max".cyan().underlined(), ".".into(), ]), - Line::from(vec!["Press enter to continue".dim()]), ], + can_opt_out: true, } } @@ -211,13 +317,14 @@ fn gpt5_migration_copy() -> ModelMigrationCopy { ]), Line::from(vec!["Press enter to continue".dim()]), ], + can_opt_out: false, } } #[cfg(test)] mod tests { use super::ModelMigrationScreen; - use super::arcticfox_migration_copy; + use super::gpt_5_1_codex_max_migration_copy; use super::migration_copy_for_config; use crate::custom_terminal::Terminal; use crate::test_backend::VT100Backend; @@ -231,13 +338,15 @@ mod tests { #[test] fn prompt_snapshot() { let width: u16 = 60; - let height: u16 = 12; + let height: u16 = 20; let backend = VT100Backend::new(width, height); let mut terminal = Terminal::with_options(backend).expect("terminal"); terminal.set_viewport_area(Rect::new(0, 0, width, height)); - let screen = - ModelMigrationScreen::new(FrameRequester::test_dummy(), arcticfox_migration_copy()); + let screen = ModelMigrationScreen::new( + FrameRequester::test_dummy(), + gpt_5_1_codex_max_migration_copy(), + ); { let mut frame = terminal.get_frame(); @@ -304,8 +413,10 @@ mod tests { #[test] fn escape_key_accepts_prompt() { - let mut screen = - ModelMigrationScreen::new(FrameRequester::test_dummy(), arcticfox_migration_copy()); + let mut screen = ModelMigrationScreen::new( + FrameRequester::test_dummy(), + gpt_5_1_codex_max_migration_copy(), + ); // Simulate pressing Escape screen.handle_key(KeyEvent::new( @@ -319,4 +430,27 @@ mod tests { super::ModelMigrationOutcome::Accepted )); } + + #[test] + fn selecting_use_existing_model_rejects_upgrade() { + let mut screen = ModelMigrationScreen::new( + FrameRequester::test_dummy(), + gpt_5_1_codex_max_migration_copy(), + ); + + screen.handle_key(KeyEvent::new( + KeyCode::Down, + crossterm::event::KeyModifiers::NONE, + )); + screen.handle_key(KeyEvent::new( + KeyCode::Enter, + crossterm::event::KeyModifiers::NONE, + )); + + assert!(screen.is_done()); + assert!(matches!( + screen.outcome(), + super::ModelMigrationOutcome::Rejected + )); + } } diff --git a/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt.snap b/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt.snap index 2780fe7b78..5b3136803f 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt.snap @@ -2,14 +2,18 @@ source: tui/src/model_migration.rs expression: terminal.backend() --- -> Introducing arcticfox +> Codex just got an upgrade. Introducing gpt-5.1-codex-max - We've upgraded our family of models supported in Codex to - arcticfox. + Codex is now powered by gpt-5.1-codex-max, our latest + frontier agentic coding model. It is smarter and faster + than its predecessors and capable of long-running + project-scale work. - You can continue using legacy models by specifying them - directly with the -m option or in your config.toml. + Learn more at www.openai.com/index/gpt-5-1-codex-max. - Learn more at www.openai.com/index/arcticfox. + Choose how you'd like Codex to proceed. - Press enter to continue +› 1. Try new model + 2. Use existing model + + Use ↑/↓ to move, press enter to confirm diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index be10eb569b..d77a4d4946 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -28,6 +28,7 @@ use super::helpers::format_tokens_compact; use super::rate_limits::RateLimitSnapshotDisplay; use super::rate_limits::StatusRateLimitData; use super::rate_limits::StatusRateLimitRow; +use super::rate_limits::StatusRateLimitValue; use super::rate_limits::compose_rate_limit_data; use super::rate_limits::format_status_limit_summary; use super::rate_limits::render_status_limit_progress_bar; @@ -215,29 +216,44 @@ impl StatusHistoryCell { let mut lines = Vec::with_capacity(rows.len().saturating_mul(2)); for row in rows { - let percent_remaining = (100.0 - row.percent_used).clamp(0.0, 100.0); - let value_spans = vec![ - Span::from(render_status_limit_progress_bar(percent_remaining)), - Span::from(" "), - Span::from(format_status_limit_summary(percent_remaining)), - ]; - let base_spans = formatter.full_spans(row.label.as_str(), value_spans); - let base_line = Line::from(base_spans.clone()); + match &row.value { + StatusRateLimitValue::Window { + percent_used, + resets_at, + } => { + let percent_remaining = (100.0 - percent_used).clamp(0.0, 100.0); + let value_spans = vec![ + Span::from(render_status_limit_progress_bar(percent_remaining)), + Span::from(" "), + Span::from(format_status_limit_summary(percent_remaining)), + ]; + let base_spans = formatter.full_spans(row.label.as_str(), value_spans); + let base_line = Line::from(base_spans.clone()); - if let Some(resets_at) = row.resets_at.as_ref() { - let resets_span = Span::from(format!("(resets {resets_at})")).dim(); - let mut inline_spans = base_spans.clone(); - inline_spans.push(Span::from(" ").dim()); - inline_spans.push(resets_span.clone()); + if let Some(resets_at) = resets_at.as_ref() { + let resets_span = Span::from(format!("(resets {resets_at})")).dim(); + let mut inline_spans = base_spans.clone(); + inline_spans.push(Span::from(" ").dim()); + inline_spans.push(resets_span.clone()); - if line_display_width(&Line::from(inline_spans.clone())) <= available_inner_width { - lines.push(Line::from(inline_spans)); - } else { - lines.push(base_line); - lines.push(formatter.continuation(vec![resets_span])); + if line_display_width(&Line::from(inline_spans.clone())) + <= available_inner_width + { + lines.push(Line::from(inline_spans)); + } else { + lines.push(base_line); + lines.push(formatter.continuation(vec![resets_span])); + } + } else { + lines.push(base_line); + } + } + StatusRateLimitValue::Text(text) => { + let label = row.label.clone(); + let spans = + formatter.full_spans(label.as_str(), vec![Span::from(text.clone())]); + lines.push(Line::from(spans)); } - } else { - lines.push(base_line); } } diff --git a/codex-rs/tui/src/status/rate_limits.rs b/codex-rs/tui/src/status/rate_limits.rs index 50cbd97793..e8dc689a62 100644 --- a/codex-rs/tui/src/status/rate_limits.rs +++ b/codex-rs/tui/src/status/rate_limits.rs @@ -5,6 +5,7 @@ use chrono::DateTime; use chrono::Duration as ChronoDuration; use chrono::Local; use chrono::Utc; +use codex_core::protocol::CreditsSnapshot as CoreCreditsSnapshot; use codex_core::protocol::RateLimitSnapshot; use codex_core::protocol::RateLimitWindow; @@ -15,8 +16,16 @@ const STATUS_LIMIT_BAR_EMPTY: &str = "░"; #[derive(Debug, Clone)] pub(crate) struct StatusRateLimitRow { pub label: String, - pub percent_used: f64, - pub resets_at: Option, + pub value: StatusRateLimitValue, +} + +#[derive(Debug, Clone)] +pub(crate) enum StatusRateLimitValue { + Window { + percent_used: f64, + resets_at: Option, + }, + Text(String), } #[derive(Debug, Clone)] @@ -56,6 +65,14 @@ pub(crate) struct RateLimitSnapshotDisplay { pub captured_at: DateTime, pub primary: Option, pub secondary: Option, + pub credits: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct CreditsSnapshotDisplay { + pub has_credits: bool, + pub unlimited: bool, + pub balance: Option, } pub(crate) fn rate_limit_snapshot_display( @@ -72,6 +89,17 @@ pub(crate) fn rate_limit_snapshot_display( .secondary .as_ref() .map(|window| RateLimitWindowDisplay::from_window(window, captured_at)), + credits: snapshot.credits.as_ref().map(CreditsSnapshotDisplay::from), + } +} + +impl From<&CoreCreditsSnapshot> for CreditsSnapshotDisplay { + fn from(value: &CoreCreditsSnapshot) -> Self { + Self { + has_credits: value.has_credits, + unlimited: value.unlimited, + balance: value.balance.clone(), + } } } @@ -81,7 +109,7 @@ pub(crate) fn compose_rate_limit_data( ) -> StatusRateLimitData { match snapshot { Some(snapshot) => { - let mut rows = Vec::with_capacity(2); + let mut rows = Vec::with_capacity(3); if let Some(primary) = snapshot.primary.as_ref() { let label: String = primary @@ -91,8 +119,10 @@ pub(crate) fn compose_rate_limit_data( let label = capitalize_first(&label); rows.push(StatusRateLimitRow { label: format!("{label} limit"), - percent_used: primary.used_percent, - resets_at: primary.resets_at.clone(), + value: StatusRateLimitValue::Window { + percent_used: primary.used_percent, + resets_at: primary.resets_at.clone(), + }, }); } @@ -104,11 +134,19 @@ pub(crate) fn compose_rate_limit_data( let label = capitalize_first(&label); rows.push(StatusRateLimitRow { label: format!("{label} limit"), - percent_used: secondary.used_percent, - resets_at: secondary.resets_at.clone(), + value: StatusRateLimitValue::Window { + percent_used: secondary.used_percent, + resets_at: secondary.resets_at.clone(), + }, }); } + if let Some(credits) = snapshot.credits.as_ref() + && let Some(row) = credit_status_row(credits) + { + rows.push(row); + } + let is_stale = now.signed_duration_since(snapshot.captured_at) > ChronoDuration::minutes(RATE_LIMIT_STALE_THRESHOLD_MINUTES); @@ -140,6 +178,50 @@ pub(crate) fn format_status_limit_summary(percent_remaining: f64) -> String { format!("{percent_remaining:.0}% left") } +/// Builds a single `StatusRateLimitRow` for credits when the snapshot indicates +/// that the account has credit tracking enabled. When credits are unlimited we +/// show that fact explicitly; otherwise we render the rounded balance in +/// credits. Accounts with credits = 0 skip this section entirely. +fn credit_status_row(credits: &CreditsSnapshotDisplay) -> Option { + if !credits.has_credits { + return None; + } + if credits.unlimited { + return Some(StatusRateLimitRow { + label: "Credits".to_string(), + value: StatusRateLimitValue::Text("Unlimited".to_string()), + }); + } + let balance = credits.balance.as_ref()?; + let display_balance = format_credit_balance(balance)?; + Some(StatusRateLimitRow { + label: "Credits".to_string(), + value: StatusRateLimitValue::Text(format!("{display_balance} credits")), + }) +} + +fn format_credit_balance(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + + if let Ok(int_value) = trimmed.parse::() + && int_value > 0 + { + return Some(int_value.to_string()); + } + + if let Ok(value) = trimmed.parse::() + && value > 0.0 + { + let rounded = value.round() as i64; + return Some(rounded.to_string()); + } + + None +} + fn capitalize_first(label: &str) -> String { let mut chars = label.chars(); match chars.next() { diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_cached_limits_hide_credits_without_flag.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_cached_limits_hide_credits_without_flag.snap new file mode 100644 index 0000000000..dbb634bab1 --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_cached_limits_hide_credits_without_flag.snap @@ -0,0 +1,24 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +╭─────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Approval: on-request │ +│ Sandbox: read-only │ +│ Agents.md: │ +│ │ +│ Token usage: 1.05K total (700 input + 350 output) │ +│ Context window: 100% left (1.45K used / 272K) │ +│ 5h limit: [████████░░░░░░░░░░░░] 40% left (resets 11:32) │ +│ Weekly limit: [█████████████░░░░░░░] 65% left (resets 11:52) │ +│ Warning: limits may be stale - start new turn to refresh. │ +╰─────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_credits_and_limits.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_credits_and_limits.snap new file mode 100644 index 0000000000..1707a4c5fb --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_credits_and_limits.snap @@ -0,0 +1,24 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +╭───────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Approval: on-request │ +│ Sandbox: read-only │ +│ Agents.md: │ +│ │ +│ Token usage: 2K total (1.4K input + 600 output) │ +│ Context window: 100% left (2.2K used / 272K) │ +│ 5h limit: [███████████░░░░░░░░░] 55% left (resets 09:25) │ +│ Weekly limit: [██████████████░░░░░░] 70% left (resets 09:55) │ +│ Credits: 38 credits │ +╰───────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_monthly_limit.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_monthly_limit.snap index e0d752d5e4..3ecc4fa8ed 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_monthly_limit.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_monthly_limit.snap @@ -10,7 +10,7 @@ expression: sanitized │ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ │ information on rate limits and credits │ │ │ -│ Model: arcticfox (reasoning none, summaries auto) │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ │ Directory: [[workspace]] │ │ Approval: on-request │ │ Sandbox: read-only │ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap index d4be306410..c22577407e 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap @@ -4,20 +4,20 @@ expression: sanitized --- /status -╭───────────────────────────────────────────────────────────────────╮ -│ >_ OpenAI Codex (v0.0.0) │ -│ │ -│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ -│ information on rate limits and credits │ -│ │ -│ Model: arcticfox (reasoning high, summaries detailed) │ -│ Directory: [[workspace]] │ -│ Approval: on-request │ -│ Sandbox: workspace-write │ -│ Agents.md: │ -│ │ -│ Token usage: 1.9K total (1K input + 900 output) │ -│ Context window: 100% left (2.25K used / 272K) │ -│ 5h limit: [██████░░░░░░░░░░░░░░] 28% left (resets 03:14) │ -│ Weekly limit: [███████████░░░░░░░░░] 55% left (resets 03:24) │ -╰───────────────────────────────────────────────────────────────────╯ +╭───────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning high, summaries detailed) │ +│ Directory: [[workspace]] │ +│ Approval: on-request │ +│ Sandbox: workspace-write │ +│ Agents.md: │ +│ │ +│ Token usage: 1.9K total (1K input + 900 output) │ +│ Context window: 100% left (2.25K used / 272K) │ +│ 5h limit: [██████░░░░░░░░░░░░░░] 28% left (resets 03:14) │ +│ Weekly limit: [███████████░░░░░░░░░] 55% left (resets 03:24) │ +╰───────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_empty_limits_message.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_empty_limits_message.snap index 36d2a8b4da..f0e6b73445 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_empty_limits_message.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_empty_limits_message.snap @@ -4,19 +4,19 @@ expression: sanitized --- /status -╭───────────────────────────────────────────────────────────────╮ -│ >_ OpenAI Codex (v0.0.0) │ -│ │ -│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ -│ information on rate limits and credits │ -│ │ -│ Model: arcticfox (reasoning none, summaries auto) │ -│ Directory: [[workspace]] │ -│ Approval: on-request │ -│ Sandbox: read-only │ -│ Agents.md: │ -│ │ -│ Token usage: 750 total (500 input + 250 output) │ -│ Context window: 100% left (750 used / 272K) │ -│ Limits: data not available yet │ -╰───────────────────────────────────────────────────────────────╯ +╭───────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Approval: on-request │ +│ Sandbox: read-only │ +│ Agents.md: │ +│ │ +│ Token usage: 750 total (500 input + 250 output) │ +│ Context window: 100% left (750 used / 272K) │ +│ Limits: data not available yet │ +╰───────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap index 36d2a8b4da..f0e6b73445 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap @@ -4,19 +4,19 @@ expression: sanitized --- /status -╭───────────────────────────────────────────────────────────────╮ -│ >_ OpenAI Codex (v0.0.0) │ -│ │ -│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ -│ information on rate limits and credits │ -│ │ -│ Model: arcticfox (reasoning none, summaries auto) │ -│ Directory: [[workspace]] │ -│ Approval: on-request │ -│ Sandbox: read-only │ -│ Agents.md: │ -│ │ -│ Token usage: 750 total (500 input + 250 output) │ -│ Context window: 100% left (750 used / 272K) │ -│ Limits: data not available yet │ -╰───────────────────────────────────────────────────────────────╯ +╭───────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Approval: on-request │ +│ Sandbox: read-only │ +│ Agents.md: │ +│ │ +│ Token usage: 750 total (500 input + 250 output) │ +│ Context window: 100% left (750 used / 272K) │ +│ Limits: data not available yet │ +╰───────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap index 9163942dfe..a12be950bc 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap @@ -4,21 +4,21 @@ expression: sanitized --- /status -╭─────────────────────────────────────────────────────────────────────╮ -│ >_ OpenAI Codex (v0.0.0) │ -│ │ -│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ -│ information on rate limits and credits │ -│ │ -│ Model: arcticfox (reasoning none, summaries auto) │ -│ Directory: [[workspace]] │ -│ Approval: on-request │ -│ Sandbox: read-only │ -│ Agents.md: │ -│ │ -│ Token usage: 1.9K total (1K input + 900 output) │ -│ Context window: 100% left (2.25K used / 272K) │ -│ 5h limit: [██████░░░░░░░░░░░░░░] 28% left (resets 03:14) │ -│ Weekly limit: [████████████░░░░░░░░] 60% left (resets 03:34) │ -│ Warning: limits may be stale - start new turn to refresh. │ -╰─────────────────────────────────────────────────────────────────────╯ +╭───────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Approval: on-request │ +│ Sandbox: read-only │ +│ Agents.md: │ +│ │ +│ Token usage: 1.9K total (1K input + 900 output) │ +│ Context window: 100% left (2.25K used / 272K) │ +│ 5h limit: [██████░░░░░░░░░░░░░░] 28% left (resets 03:14) │ +│ Weekly limit: [████████████░░░░░░░░] 60% left (resets 03:34) │ +│ Warning: limits may be stale - start new turn to refresh. │ +╰───────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap index 5f4554bda0..02ba1adec9 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap @@ -4,19 +4,19 @@ expression: sanitized --- /status -╭───────────────────────────────────────────────────────────────────╮ -│ >_ OpenAI Codex (v0.0.0) │ -│ │ -│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ -│ information on rate limits and credits │ -│ │ -│ Model: arcticfox (reasoning high, summaries detailed) │ -│ Directory: [[workspace]] │ -│ Approval: on-request │ -│ Sandbox: read-only │ -│ Agents.md: │ -│ │ -│ Token usage: 1.9K total (1K input + 900 output) │ -│ Context window: 100% left (2.25K used / 272K) │ -│ 5h limit: [██████░░░░░░░░░░░░░░] 28% left (resets 03:14) │ -╰───────────────────────────────────────────────────────────────────╯ +╭────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning high, summaries de │ +│ Directory: [[workspace]] │ +│ Approval: on-request │ +│ Sandbox: read-only │ +│ Agents.md: │ +│ │ +│ Token usage: 1.9K total (1K input + 900 output) │ +│ Context window: 100% left (2.25K used / 272K) │ +│ 5h limit: [██████░░░░░░░░░░░░░░] 28% left (resets 03:14) │ +╰────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 0597a44112..ae379aae67 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -8,6 +8,7 @@ use codex_core::AuthManager; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; +use codex_core::protocol::CreditsSnapshot; use codex_core::protocol::RateLimitSnapshot; use codex_core::protocol::RateLimitWindow; use codex_core::protocol::SandboxPolicy; @@ -81,7 +82,7 @@ fn reset_at_from(captured_at: &chrono::DateTime, seconds: i64) -> fn status_snapshot_includes_reasoning_details() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "arcticfox".to_string(); + config.model = "gpt-5.1-codex-max".to_string(); config.model_provider_id = "openai".to_string(); config.model_reasoning_effort = Some(ReasoningEffort::High); config.model_reasoning_summary = ReasoningSummary::Detailed; @@ -118,6 +119,7 @@ fn status_snapshot_includes_reasoning_details() { window_minutes: Some(10080), resets_at: Some(reset_at_from(&captured_at, 1_200)), }), + credits: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); @@ -144,7 +146,7 @@ fn status_snapshot_includes_reasoning_details() { fn status_snapshot_includes_monthly_limit() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "arcticfox".to_string(); + config.model = "gpt-5.1-codex-max".to_string(); config.model_provider_id = "openai".to_string(); config.cwd = PathBuf::from("/workspace/tests"); @@ -168,6 +170,7 @@ fn status_snapshot_includes_monthly_limit() { resets_at: Some(reset_at_from(&captured_at, 86_400)), }), secondary: None, + credits: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); @@ -190,11 +193,159 @@ fn status_snapshot_includes_monthly_limit() { assert_snapshot!(sanitized); } +#[test] +fn status_snapshot_shows_unlimited_credits() { + let temp_home = TempDir::new().expect("temp home"); + let config = test_config(&temp_home); + let auth_manager = test_auth_manager(&config); + let usage = TokenUsage::default(); + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 2, 3, 4, 5, 6) + .single() + .expect("timestamp"); + let snapshot = RateLimitSnapshot { + primary: None, + secondary: None, + credits: Some(CreditsSnapshot { + has_credits: true, + unlimited: true, + balance: None, + }), + }; + let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + let composite = new_status_output( + &config, + &auth_manager, + &usage, + Some(&usage), + &None, + Some(&rate_display), + captured_at, + ); + let rendered = render_lines(&composite.display_lines(120)); + assert!( + rendered + .iter() + .any(|line| line.contains("Credits:") && line.contains("Unlimited")), + "expected Credits: Unlimited line, got {rendered:?}" + ); +} + +#[test] +fn status_snapshot_shows_positive_credits() { + let temp_home = TempDir::new().expect("temp home"); + let config = test_config(&temp_home); + let auth_manager = test_auth_manager(&config); + let usage = TokenUsage::default(); + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 3, 4, 5, 6, 7) + .single() + .expect("timestamp"); + let snapshot = RateLimitSnapshot { + primary: None, + secondary: None, + credits: Some(CreditsSnapshot { + has_credits: true, + unlimited: false, + balance: Some("12.5".to_string()), + }), + }; + let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + let composite = new_status_output( + &config, + &auth_manager, + &usage, + Some(&usage), + &None, + Some(&rate_display), + captured_at, + ); + let rendered = render_lines(&composite.display_lines(120)); + assert!( + rendered + .iter() + .any(|line| line.contains("Credits:") && line.contains("13 credits")), + "expected Credits line with rounded credits, got {rendered:?}" + ); +} + +#[test] +fn status_snapshot_hides_zero_credits() { + let temp_home = TempDir::new().expect("temp home"); + let config = test_config(&temp_home); + let auth_manager = test_auth_manager(&config); + let usage = TokenUsage::default(); + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 4, 5, 6, 7, 8) + .single() + .expect("timestamp"); + let snapshot = RateLimitSnapshot { + primary: None, + secondary: None, + credits: Some(CreditsSnapshot { + has_credits: true, + unlimited: false, + balance: Some("0".to_string()), + }), + }; + let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + let composite = new_status_output( + &config, + &auth_manager, + &usage, + Some(&usage), + &None, + Some(&rate_display), + captured_at, + ); + let rendered = render_lines(&composite.display_lines(120)); + assert!( + rendered.iter().all(|line| !line.contains("Credits:")), + "expected no Credits line, got {rendered:?}" + ); +} + +#[test] +fn status_snapshot_hides_when_has_no_credits_flag() { + let temp_home = TempDir::new().expect("temp home"); + let config = test_config(&temp_home); + let auth_manager = test_auth_manager(&config); + let usage = TokenUsage::default(); + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 5, 6, 7, 8, 9) + .single() + .expect("timestamp"); + let snapshot = RateLimitSnapshot { + primary: None, + secondary: None, + credits: Some(CreditsSnapshot { + has_credits: false, + unlimited: true, + balance: None, + }), + }; + let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + let composite = new_status_output( + &config, + &auth_manager, + &usage, + Some(&usage), + &None, + Some(&rate_display), + captured_at, + ); + let rendered = render_lines(&composite.display_lines(120)); + assert!( + rendered.iter().all(|line| !line.contains("Credits:")), + "expected no Credits line when has_credits is false, got {rendered:?}" + ); +} + #[test] fn status_card_token_usage_excludes_cached_tokens() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "arcticfox".to_string(); + config.model = "gpt-5.1-codex-max".to_string(); config.cwd = PathBuf::from("/workspace/tests"); let auth_manager = test_auth_manager(&config); @@ -232,7 +383,7 @@ fn status_card_token_usage_excludes_cached_tokens() { fn status_snapshot_truncates_in_narrow_terminal() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "arcticfox".to_string(); + config.model = "gpt-5.1-codex-max".to_string(); config.model_provider_id = "openai".to_string(); config.model_reasoning_effort = Some(ReasoningEffort::High); config.model_reasoning_summary = ReasoningSummary::Detailed; @@ -258,6 +409,7 @@ fn status_snapshot_truncates_in_narrow_terminal() { resets_at: Some(reset_at_from(&captured_at, 600)), }), secondary: None, + credits: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); @@ -285,7 +437,7 @@ fn status_snapshot_truncates_in_narrow_terminal() { fn status_snapshot_shows_missing_limits_message() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "arcticfox".to_string(); + config.model = "gpt-5.1-codex-max".to_string(); config.cwd = PathBuf::from("/workspace/tests"); let auth_manager = test_auth_manager(&config); @@ -321,11 +473,69 @@ fn status_snapshot_shows_missing_limits_message() { assert_snapshot!(sanitized); } +#[test] +fn status_snapshot_includes_credits_and_limits() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home); + config.model = "gpt-5.1-codex".to_string(); + config.cwd = PathBuf::from("/workspace/tests"); + + let auth_manager = test_auth_manager(&config); + let usage = TokenUsage { + input_tokens: 1_500, + cached_input_tokens: 100, + output_tokens: 600, + reasoning_output_tokens: 0, + total_tokens: 2_200, + }; + + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 7, 8, 9, 10, 11) + .single() + .expect("timestamp"); + let snapshot = RateLimitSnapshot { + primary: Some(RateLimitWindow { + used_percent: 45.0, + window_minutes: Some(300), + resets_at: Some(reset_at_from(&captured_at, 900)), + }), + secondary: Some(RateLimitWindow { + used_percent: 30.0, + window_minutes: Some(10_080), + resets_at: Some(reset_at_from(&captured_at, 2_700)), + }), + credits: Some(CreditsSnapshot { + has_credits: true, + unlimited: false, + balance: Some("37.5".to_string()), + }), + }; + let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + + let composite = new_status_output( + &config, + &auth_manager, + &usage, + Some(&usage), + &None, + Some(&rate_display), + captured_at, + ); + let mut rendered_lines = render_lines(&composite.display_lines(80)); + if cfg!(windows) { + for line in &mut rendered_lines { + *line = line.replace('\\', "/"); + } + } + let sanitized = sanitize_directory(rendered_lines).join("\n"); + assert_snapshot!(sanitized); +} + #[test] fn status_snapshot_shows_empty_limits_message() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "arcticfox".to_string(); + config.model = "gpt-5.1-codex-max".to_string(); config.cwd = PathBuf::from("/workspace/tests"); let auth_manager = test_auth_manager(&config); @@ -340,6 +550,7 @@ fn status_snapshot_shows_empty_limits_message() { let snapshot = RateLimitSnapshot { primary: None, secondary: None, + credits: None, }; let captured_at = chrono::Local .with_ymd_and_hms(2024, 6, 7, 8, 9, 10) @@ -370,7 +581,7 @@ fn status_snapshot_shows_empty_limits_message() { fn status_snapshot_shows_stale_limits_message() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "arcticfox".to_string(); + config.model = "gpt-5.1-codex-max".to_string(); config.cwd = PathBuf::from("/workspace/tests"); let auth_manager = test_auth_manager(&config); @@ -397,6 +608,66 @@ fn status_snapshot_shows_stale_limits_message() { window_minutes: Some(10_080), resets_at: Some(reset_at_from(&captured_at, 1_800)), }), + credits: None, + }; + let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + let now = captured_at + ChronoDuration::minutes(20); + + let composite = new_status_output( + &config, + &auth_manager, + &usage, + Some(&usage), + &None, + Some(&rate_display), + now, + ); + let mut rendered_lines = render_lines(&composite.display_lines(80)); + if cfg!(windows) { + for line in &mut rendered_lines { + *line = line.replace('\\', "/"); + } + } + let sanitized = sanitize_directory(rendered_lines).join("\n"); + assert_snapshot!(sanitized); +} + +#[test] +fn status_snapshot_cached_limits_hide_credits_without_flag() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home); + config.model = "gpt-5.1-codex".to_string(); + config.cwd = PathBuf::from("/workspace/tests"); + + let auth_manager = test_auth_manager(&config); + let usage = TokenUsage { + input_tokens: 900, + cached_input_tokens: 200, + output_tokens: 350, + reasoning_output_tokens: 0, + total_tokens: 1_450, + }; + + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 9, 10, 11, 12, 13) + .single() + .expect("timestamp"); + let snapshot = RateLimitSnapshot { + primary: Some(RateLimitWindow { + used_percent: 60.0, + window_minutes: Some(300), + resets_at: Some(reset_at_from(&captured_at, 1_200)), + }), + secondary: Some(RateLimitWindow { + used_percent: 35.0, + window_minutes: Some(10_080), + resets_at: Some(reset_at_from(&captured_at, 2_400)), + }), + credits: Some(CreditsSnapshot { + has_credits: false, + unlimited: false, + balance: Some("80".to_string()), + }), }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); let now = captured_at + ChronoDuration::minutes(20); diff --git a/codex-rs/windows-sandbox-rs/src/audit.rs b/codex-rs/windows-sandbox-rs/src/audit.rs index 383c652c7c..8873bc2e91 100644 --- a/codex-rs/windows-sandbox-rs/src/audit.rs +++ b/codex-rs/windows-sandbox-rs/src/audit.rs @@ -8,35 +8,35 @@ use std::path::Path; use std::path::PathBuf; use std::time::Duration; use std::time::Instant; +use windows_sys::Win32::Foundation::CloseHandle; use windows_sys::Win32::Foundation::LocalFree; use windows_sys::Win32::Foundation::ERROR_SUCCESS; use windows_sys::Win32::Foundation::HLOCAL; +use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; use windows_sys::Win32::Security::Authorization::GetNamedSecurityInfoW; use windows_sys::Win32::Security::Authorization::GetSecurityInfo; -use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; -use windows_sys::Win32::Foundation::CloseHandle; use windows_sys::Win32::Storage::FileSystem::CreateFileW; +use windows_sys::Win32::Storage::FileSystem::FILE_APPEND_DATA; use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_BACKUP_SEMANTICS; +use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_WRITE; use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_DELETE; use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ; use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_WRITE; -use windows_sys::Win32::Storage::FileSystem::OPEN_EXISTING; -use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_WRITE; -use windows_sys::Win32::Storage::FileSystem::FILE_WRITE_DATA; -use windows_sys::Win32::Storage::FileSystem::FILE_APPEND_DATA; -use windows_sys::Win32::Storage::FileSystem::FILE_WRITE_EA; use windows_sys::Win32::Storage::FileSystem::FILE_WRITE_ATTRIBUTES; +use windows_sys::Win32::Storage::FileSystem::FILE_WRITE_DATA; +use windows_sys::Win32::Storage::FileSystem::FILE_WRITE_EA; +use windows_sys::Win32::Storage::FileSystem::OPEN_EXISTING; const GENERIC_ALL_MASK: u32 = 0x1000_0000; const GENERIC_WRITE_MASK: u32 = 0x4000_0000; -use windows_sys::Win32::Security::ACL; -use windows_sys::Win32::Security::DACL_SECURITY_INFORMATION; -use windows_sys::Win32::Security::ACL_SIZE_INFORMATION; use windows_sys::Win32::Security::AclSizeInformation; -use windows_sys::Win32::Security::GetAclInformation; +use windows_sys::Win32::Security::EqualSid; use windows_sys::Win32::Security::GetAce; +use windows_sys::Win32::Security::GetAclInformation; use windows_sys::Win32::Security::ACCESS_ALLOWED_ACE; use windows_sys::Win32::Security::ACE_HEADER; -use windows_sys::Win32::Security::EqualSid; +use windows_sys::Win32::Security::ACL; +use windows_sys::Win32::Security::ACL_SIZE_INFORMATION; +use windows_sys::Win32::Security::DACL_SECURITY_INFORMATION; // Preflight scan limits const MAX_ITEMS_PER_DIR: i32 = 1000; @@ -162,7 +162,9 @@ unsafe fn path_has_world_write_allow(path: &Path) -> Result { let psid_world = world.as_mut_ptr() as *mut c_void; // Very fast mask-based check for world-writable grants (includes GENERIC_*). if !dacl_quick_world_write_mask_allows(p_dacl, psid_world) { - if !p_sd.is_null() { LocalFree(p_sd as HLOCAL); } + if !p_sd.is_null() { + LocalFree(p_sd as HLOCAL); + } return Ok(false); } // Quick detector flagged a write grant for Everyone: treat as writable. @@ -202,7 +204,9 @@ pub fn audit_everyone_writable( let has = unsafe { path_has_world_write_allow(&p)? }; if has { let key = normalize_path_key(&p); - if seen.insert(key) { flagged.push(p); } + if seen.insert(key) { + flagged.push(p); + } } } } @@ -218,7 +222,9 @@ pub fn audit_everyone_writable( let has_root = unsafe { path_has_world_write_allow(&root)? }; if has_root { let key = normalize_path_key(&root); - if seen.insert(key) { flagged.push(root.clone()); } + if seen.insert(key) { + flagged.push(root.clone()); + } } // one level down best-effort if let Ok(read) = std::fs::read_dir(&root) { @@ -240,13 +246,17 @@ pub fn audit_everyone_writable( // Skip noisy/irrelevant Windows system subdirectories let pl = p.to_string_lossy().to_ascii_lowercase(); let norm = pl.replace('\\', "/"); - if SKIP_DIR_SUFFIXES.iter().any(|s| norm.ends_with(s)) { continue; } + if SKIP_DIR_SUFFIXES.iter().any(|s| norm.ends_with(s)) { + continue; + } if ft.is_dir() { checked += 1; let has_child = unsafe { path_has_world_write_allow(&p)? }; if has_child { let key = normalize_path_key(&p); - if seen.insert(key) { flagged.push(p); } + if seen.insert(key) { + flagged.push(p); + } } } } @@ -258,20 +268,12 @@ pub fn audit_everyone_writable( for p in &flagged { list.push_str(&format!("\n - {}", p.display())); } - crate::logging::log_note( - &format!( - "AUDIT: world-writable scan FAILED; checked={checked}; duration_ms={elapsed_ms}; flagged:{}", - list - ), - logs_base_dir, - ); + return Ok(flagged); } // Log success once if nothing flagged crate::logging::log_note( - &format!( - "AUDIT: world-writable scan OK; checked={checked}; duration_ms={elapsed_ms}" - ), + &format!("AUDIT: world-writable scan OK; checked={checked}; duration_ms={elapsed_ms}"), logs_base_dir, ); Ok(Vec::new()) @@ -284,14 +286,10 @@ fn normalize_windows_path_for_display(p: impl AsRef) -> String { pub fn world_writable_warning_details( codex_home: impl AsRef, + cwd: impl AsRef, ) -> Option<(Vec, usize, bool)> { - let cwd = match std::env::current_dir() { - Ok(cwd) => cwd, - Err(_) => return Some((Vec::new(), 0, true)), - }; - let env_map: HashMap = std::env::vars().collect(); - match audit_everyone_writable(&cwd, &env_map, Some(codex_home.as_ref())) { + match audit_everyone_writable(cwd.as_ref(), &env_map, Some(codex_home.as_ref())) { Ok(paths) if paths.is_empty() => None, Ok(paths) => { let as_strings: Vec = paths @@ -329,16 +327,16 @@ unsafe fn dacl_quick_world_write_mask_allows(p_dacl: *mut ACL, psid_world: *mut continue; } let hdr = &*(p_ace as *const ACE_HEADER); - if hdr.AceType != 0 { // ACCESS_ALLOWED_ACE_TYPE + if hdr.AceType != 0 { + // ACCESS_ALLOWED_ACE_TYPE continue; } if (hdr.AceFlags & INHERIT_ONLY_ACE) != 0 { continue; } let base = p_ace as usize; - let sid_ptr = (base - + std::mem::size_of::() - + std::mem::size_of::()) as *mut c_void; // skip header + mask + let sid_ptr = + (base + std::mem::size_of::() + std::mem::size_of::()) as *mut c_void; // skip header + mask if EqualSid(sid_ptr, psid_world) != 0 { let ace = &*(p_ace as *const ACCESS_ALLOWED_ACE); let mask = ace.Mask; diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index 9d5e9aeae2..a2b4b6a538 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -467,6 +467,7 @@ mod stub { pub fn world_writable_warning_details( _codex_home: impl AsRef, + _cwd: impl AsRef, ) -> Option<(Vec, usize, bool)> { None } diff --git a/docs/config.md b/docs/config.md index fee86db30d..f47bf34643 100644 --- a/docs/config.md +++ b/docs/config.md @@ -64,7 +64,7 @@ Notes: The model that Codex should use. ```toml -model = "gpt-5.1" # overrides the default ("arcticfox" across platforms) +model = "gpt-5.1" # overrides the default ("gpt-5.1-codex-max" across platforms) ``` ### model_providers @@ -191,7 +191,7 @@ model = "mistral" ### model_reasoning_effort -If the selected model is known to support reasoning (for example: `o3`, `o4-mini`, `codex-*`, `arcticfox`, `gpt-5.1`, `gpt-5.1-codex`), reasoning is enabled by default when using the Responses API. As explained in the [OpenAI Platform documentation](https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning), this can be set to: +If the selected model is known to support reasoning (for example: `o3`, `o4-mini`, `codex-*`, `gpt-5.1-codex-max`, `gpt-5.1`, `gpt-5.1-codex`), reasoning is enabled by default when using the Responses API. As explained in the [OpenAI Platform documentation](https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning), this can be set to: - `"minimal"` - `"low"` @@ -835,7 +835,7 @@ Users can specify config values at multiple levels. Order of precedence is as fo 1. custom command-line argument, e.g., `--model o3` 2. as part of a profile, where the `--profile` is specified via a CLI (or in the config file itself) 3. as an entry in `config.toml`, e.g., `model = "o3"` -4. the default value that comes with Codex CLI (i.e., Codex CLI defaults to `arcticfox`) +4. the default value that comes with Codex CLI (i.e., Codex CLI defaults to `gpt-5.1-codex-max`) ### history @@ -938,7 +938,7 @@ Valid values: | Key | Type / Values | Notes | | ------------------------------------------------ | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -| `model` | string | Model to use (e.g., `arcticfox`). | +| `model` | string | Model to use (e.g., `gpt-5.1-codex-max`). | | `model_provider` | string | Provider id from `model_providers` (default: `openai`). | | `model_context_window` | number | Context window tokens. | | `model_max_output_tokens` | number | Max output tokens. | diff --git a/docs/example-config.md b/docs/example-config.md index 26a4b8b503..335a826e26 100644 --- a/docs/example-config.md +++ b/docs/example-config.md @@ -18,11 +18,11 @@ Use this example configuration as a starting point. For an explanation of each f # Core Model Selection ################################################################################ -# Primary model used by Codex. Default: "arcticfox" on all platforms. -model = "arcticfox" +# Primary model used by Codex. Default: "gpt-5.1-codex-max" on all platforms. +model = "gpt-5.1-codex-max" -# Model used by the /review feature (code reviews). Default: "arcticfox". -review_model = "arcticfox" +# Model used by the /review feature (code reviews). Default: "gpt-5.1-codex-max". +review_model = "gpt-5.1-codex-max" # Provider id selected from [model_providers]. Default: "openai". model_provider = "openai" @@ -32,7 +32,7 @@ model_provider = "openai" # model_context_window = 128000 # tokens; default: auto for model # model_max_output_tokens = 8192 # tokens; default: auto for model # model_auto_compact_token_limit = 0 # disable/override auto; default: model family specific -# tool_output_token_limit = 10000 # tokens stored per tool output; default: 10000 for arcticfox +# tool_output_token_limit = 10000 # tokens stored per tool output; default: 10000 for gpt-5.1-codex-max ################################################################################ # Reasoning & Verbosity (Responses API capable models) @@ -315,7 +315,7 @@ mcp_oauth_credentials_store = "auto" [profiles] # [profiles.default] -# model = "arcticfox" +# model = "gpt-5.1-codex-max" # model_provider = "openai" # approval_policy = "on-request" # sandbox_mode = "read-only" diff --git a/docs/exec.md b/docs/exec.md index 0f5808d0db..5a17155a82 100644 --- a/docs/exec.md +++ b/docs/exec.md @@ -99,7 +99,7 @@ codex exec resume --last "Fix use-after-free issues" Only the conversation context is preserved; you must still provide flags to customize Codex behavior. ```shell -codex exec --model arcticfox --json "Review the change, look for use-after-free issues" +codex exec --model gpt-5.1-codex-max --json "Review the change, look for use-after-free issues" codex exec --model gpt-5.1 --json resume --last "Fix use-after-free issues" ```