Merge remote-tracking branch 'origin/main' into jif/fork

# Conflicts:
#	codex-rs/core/src/codex.rs
This commit is contained in:
jif-oai
2025-11-19 20:21:19 +00:00
62 changed files with 1639 additions and 406 deletions
+1
View File
@@ -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::*;
@@ -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,
@@ -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;
@@ -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<Turn> {
let mut builder = ThreadHistoryBuilder::new();
for event in events {
builder.handle_event(event);
}
builder.finish()
}
struct ThreadHistoryBuilder {
turns: Vec<Turn>,
current_turn: Option<PendingTurn>,
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<Turn> {
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<UserInput> {
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<ThreadItem>,
status: TurnStatus,
}
impl From<PendingTurn> 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(),
}
);
}
}
@@ -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<Turn>,
}
#[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<ThreadItem>,
#[serde(flatten)]
pub status: TurnStatus,
@@ -989,6 +995,7 @@ pub struct AccountRateLimitsUpdatedNotification {
pub struct RateLimitSnapshot {
pub primary: Option<RateLimitWindow>,
pub secondary: Option<RateLimitWindow>,
pub credits: Option<CreditsSnapshot>,
}
impl From<CoreRateLimitSnapshot> for RateLimitSnapshot {
@@ -996,6 +1003,7 @@ impl From<CoreRateLimitSnapshot> 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<CoreRateLimitWindow> 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<String>,
}
impl From<CoreCreditsSnapshot> 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/")]
@@ -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(),
}
}
+1 -50
View File
@@ -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<OutgoingMessageSender>,
codex_message_processor: CodexMessageProcessor,
config: Arc<Config>,
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;
}
}
}
+3 -1
View File
@@ -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
}
},
}),
+2 -2
View File
@@ -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),
@@ -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"
"#,
)
@@ -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::<ModelListResponse>(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
@@ -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);
@@ -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::<ThreadResumeResponse>(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?;
+28 -9
View File
@@ -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<Box<CreditStatusDetails>>>) -> Option<CreditsSnapshot> {
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<i64> {
if seconds <= 0 {
return None;
+1
View File
@@ -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;
+29 -19
View File
@@ -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<Vec<ModelPreset>> = 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<Vec<ModelPreset>> = 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<Vec<ModelPreset>> = 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<Vec<ModelPreset>> = 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<Vec<ModelPreset>> = 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<Vec<ModelPreset>> = 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<AuthMode>) -> Vec<ModelPreset> {
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")
);
}
}
+33 -1
View File
@@ -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<RateLimitSnapshot> {
"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<CreditsSnapshot> {
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<f64> {
parse_header_str(headers, name)?
.parse::<f64>()
@@ -764,6 +785,17 @@ fn parse_header_i64(headers: &HeaderMap, name: &str) -> Option<i64> {
parse_header_str(headers, name)?.parse::<i64>().ok()
}
fn parse_header_bool(headers: &HeaderMap, name: &str) -> Option<bool> {
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()
}
+1 -1
View File
@@ -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,
},
];
+8 -5
View File
@@ -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<String>,
http_status_code: Option<StatusCode>,
) {
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;
+5 -9
View File
@@ -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;
}
}
+2 -5
View File
@@ -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<Session>, turn_context: Ar
async fn run_remote_compact_task_inner(sess: &Arc<Session>, turn_context: &Arc<TurnContext>) {
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;
}
}
+3 -3
View File
@@ -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);
}
+4 -7
View File
@@ -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,
+2 -2
View File
@@ -378,8 +378,8 @@ pub struct Notice {
pub hide_rate_limit_model_nudge: Option<bool>,
/// Tracks whether the user has seen the model migration prompt
pub hide_gpt5_1_migration_prompt: Option<bool>,
/// Tracks whether the user has seen the arcticfox migration prompt
pub hide_arcticfox_migration_prompt: Option<bool>,
/// Tracks whether the user has seen the gpt-5.1-codex-max migration prompt
pub hide_gpt_5_1_codex_max_migration_prompt: Option<bool>,
}
impl Notice {
+72
View File
@@ -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<T: std::any::Any>(&self) -> Option<&T> {
(self as &dyn std::any::Any).downcast_ref::<T>()
}
pub fn http_status_code(&self) -> Option<StatusCode> {
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<String>) -> 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<StatusCode>) -> Option<u16> {
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);
}
}
+5 -17
View File
@@ -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<ModelFamily> {
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<ModelFamily> {
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",
+1 -1
View File
@@ -72,7 +72,7 @@ pub(crate) fn get_model_info(model_family: &ModelFamily) -> Option<ModelInfo> {
_ 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))
}
+2 -13
View File
@@ -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]
+7 -4
View File
@@ -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
@@ -457,7 +457,7 @@ Output:
4
5
6
.*…45 tokens truncated….*
.*…46 tokens truncated….*
396
397
398
+2 -2
View File
@@ -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);
+1 -1
View File
@@ -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
+2 -1
View File
@@ -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::<String>();
let tail = (352..=400).map(|i| format!("{i}\n")).collect::<String>();
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!(
+1 -1
View File
@@ -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.
@@ -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(_) => {
@@ -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!(
+12
View File
@@ -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<u16>,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
@@ -796,6 +798,7 @@ pub struct TokenCountEvent {
pub struct RateLimitSnapshot {
pub primary: Option<RateLimitWindow>,
pub secondary: Option<RateLimitWindow>,
pub credits: Option<CreditsSnapshot>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)]
@@ -810,6 +813,13 @@ pub struct RateLimitWindow {
pub resets_at: Option<i64>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)]
pub struct CreditsSnapshot {
pub has_credits: bool,
pub unlimited: bool,
pub balance: Option<String>,
}
// 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<u16>,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
+29 -13
View File
@@ -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<bool> {
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<ReasoningEffortConfig>) -> &'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,
));
}
@@ -257,7 +257,7 @@ impl ChatComposer {
true
}
Err(err) => {
tracing::info!("ERR: {err}");
tracing::trace!("ERR: {err}");
false
}
}
+11 -5
View File
@@ -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<ReasoningEffortConfig>,
@@ -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"))]
@@ -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
@@ -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
+11 -9
View File
@@ -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,
}),
});
+162 -28
View File
@@ -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<Span<'static>>,
pub content: Vec<Line<'static>>,
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
));
}
}
@@ -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
+36 -20
View File
@@ -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);
}
}
+89 -7
View File
@@ -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<String>,
pub value: StatusRateLimitValue,
}
#[derive(Debug, Clone)]
pub(crate) enum StatusRateLimitValue {
Window {
percent_used: f64,
resets_at: Option<String>,
},
Text(String),
}
#[derive(Debug, Clone)]
@@ -56,6 +65,14 @@ pub(crate) struct RateLimitSnapshotDisplay {
pub captured_at: DateTime<Local>,
pub primary: Option<RateLimitWindowDisplay>,
pub secondary: Option<RateLimitWindowDisplay>,
pub credits: Option<CreditsSnapshotDisplay>,
}
#[derive(Debug, Clone)]
pub(crate) struct CreditsSnapshotDisplay {
pub has_credits: bool,
pub unlimited: bool,
pub balance: Option<String>,
}
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<StatusRateLimitRow> {
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<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
if let Ok(int_value) = trimmed.parse::<i64>()
&& int_value > 0
{
return Some(int_value.to_string());
}
if let Ok(value) = trimmed.parse::<f64>()
&& 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() {
@@ -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: <none> │
│ │
│ 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. │
╰─────────────────────────────────────────────────────────────────────╯
@@ -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: <none> │
│ │
│ 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 │
╰───────────────────────────────────────────────────────────────────╯
@@ -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 │
@@ -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: <none> │
│ │
│ 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: <none> │
│ │
│ 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) │
╰───────────────────────────────────────────────────────────────────────────╯
@@ -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: <none> │
│ │
│ 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: <none> │
│ │
│ Token usage: 750 total (500 input + 250 output) │
│ Context window: 100% left (750 used / 272K) │
│ Limits: data not available yet │
╰───────────────────────────────────────────────────────────────────────╯
@@ -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: <none> │
│ │
│ 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: <none> │
│ │
│ Token usage: 750 total (500 input + 250 output) │
│ Context window: 100% left (750 used / 272K) │
│ Limits: data not available yet │
╰───────────────────────────────────────────────────────────────────────╯
@@ -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: <none> │
│ │
│ 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: <none> │
│ │
│ 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. │
╰───────────────────────────────────────────────────────────────────────╯
@@ -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: <none> │
│ │
│ 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: <none> │
│ │
│ Token usage: 1.9K total (1K input + 900 output) │
│ Context window: 100% left (2.25K used / 272K) │
│ 5h limit: [██████░░░░░░░░░░░░░░] 28% left (resets 03:14) │
╰────────────────────────────────────────────────────────────────────╯
+278 -7
View File
@@ -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<chrono::Local>, 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);
+35 -37
View File
@@ -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<bool> {
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<Path>) -> String {
pub fn world_writable_warning_details(
codex_home: impl AsRef<Path>,
cwd: impl AsRef<Path>,
) -> Option<(Vec<String>, usize, bool)> {
let cwd = match std::env::current_dir() {
Ok(cwd) => cwd,
Err(_) => return Some((Vec::new(), 0, true)),
};
let env_map: HashMap<String, String> = 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<String> = 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::<ACE_HEADER>()
+ std::mem::size_of::<u32>()) as *mut c_void; // skip header + mask
let sid_ptr =
(base + std::mem::size_of::<ACE_HEADER>() + std::mem::size_of::<u32>()) 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;
+1
View File
@@ -467,6 +467,7 @@ mod stub {
pub fn world_writable_warning_details(
_codex_home: impl AsRef<Path>,
_cwd: impl AsRef<Path>,
) -> Option<(Vec<String>, usize, bool)> {
None
}
+4 -4
View File
@@ -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. |
+6 -6
View File
@@ -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"
+1 -1
View File
@@ -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"
```