Default Fast service tier for eligible ChatGPT plans (#19053)

## Why

Enterprise and business-like ChatGPT plans should get Codex's Fast
service tier by default when the user or caller has not made an explicit
service-tier choice. At the same time, callers need a durable way to
choose standard routing without adding a new persisted `standard`
service tier value. This keeps existing config compatibility while
letting core own the managed default policy.

## What changed

- Resolve the effective service tier in core at session creation:
explicit `fast` or `flex` wins, explicit null/clear or
`[notice].fast_default_opt_out = true` resolves to standard routing, and
otherwise eligible ChatGPT plans resolve to Fast when FastMode is
enabled.
- Add `[notice].fast_default_opt_out` as the persisted opt-out marker
for managed Fast defaults.
- Treat app-server/TUI `service_tier: null` as an explicit
standard/clear choice by preserving that intent through config loading.
- Update TUI rendering to use core's effective service tier for startup
and status surfaces while still keeping `config.service_tier` as the
explicit configured choice.
- Update `/fast off` to clear `service_tier`, persist the opt-out
marker, and send explicit standard for subsequent turns.

## Verification

- Added unit coverage for config override/notice handling, service-tier
resolution, runtime null clearing, and `/fast off` turn propagation.
- `cargo build -p codex-cli`

Full test suite was not run locally per author request.
This commit is contained in:
Shijie Rao
2026-04-23 00:54:44 -04:00
committed by GitHub
Unverified
parent 082fc4f632
commit 02170996e6
16 changed files with 289 additions and 33 deletions
+16 -6
View File
@@ -1029,14 +1029,24 @@ impl App {
AppEvent::PersistServiceTierSelection { service_tier } => {
self.refresh_status_line();
let profile = self.active_profile.as_deref();
match ConfigEditsBuilder::new(&self.config.codex_home)
self.config.service_tier = service_tier;
let mut edits = ConfigEditsBuilder::new(&self.config.codex_home)
.with_profile(profile)
.set_service_tier(service_tier)
.apply()
.await
{
.set_service_tier(service_tier);
if service_tier.is_none() {
self.config.notices.fast_default_opt_out = Some(true);
edits = edits.set_fast_default_opt_out(/*opted_out*/ true);
}
match edits.apply().await {
Ok(()) => {
let status = if service_tier.is_some() { "on" } else { "off" };
let status = if matches!(
service_tier,
Some(codex_protocol::config_types::ServiceTier::Fast)
) {
"on"
} else {
"off"
};
let mut message = format!("Fast mode set to {status}");
if let Some(profile) = profile {
message.push_str(" for ");
+2 -1
View File
@@ -622,7 +622,8 @@ impl App {
pub(super) fn fresh_session_config(&self) -> Config {
let mut config = self.config.clone();
config.service_tier = self.chat_widget.current_service_tier();
config.service_tier = self.chat_widget.configured_service_tier();
config.notices.fast_default_opt_out = self.chat_widget.fast_default_opt_out();
config
}
pub(super) async fn resume_target_session(
+22 -1
View File
@@ -776,6 +776,8 @@ pub(crate) struct ChatWidget {
/// where the overlay may briefly treat new tail content as already cached.
active_cell_revision: u64,
config: Config,
/// Runtime value resolved by core. `config.service_tier` remains the explicit user choice.
effective_service_tier: Option<ServiceTier>,
/// The unmasked collaboration mode settings (always Default mode).
///
/// Masks are applied on top of this base mode to derive the effective mode.
@@ -2120,6 +2122,7 @@ impl ChatWidget {
self.current_rollout_path = event.rollout_path.clone();
self.current_cwd = Some(event.cwd.to_path_buf());
self.config.cwd = event.cwd.clone();
self.effective_service_tier = event.service_tier;
if let Err(err) = self
.config
.permissions
@@ -5106,6 +5109,7 @@ impl ChatWidget {
let active_cell = Some(Self::placeholder_session_header_cell(&config));
let current_cwd = Some(config.cwd.to_path_buf());
let effective_service_tier = config.service_tier;
let queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info());
let mut widget = Self {
app_event_tx: app_event_tx.clone(),
@@ -5124,6 +5128,7 @@ impl ChatWidget {
active_cell,
active_cell_revision: 0,
config,
effective_service_tier,
skills_all: Vec::new(),
skills_initial_state: None,
current_collaboration_mode,
@@ -5942,7 +5947,11 @@ impl ChatWidget {
.personality
.filter(|_| self.config.features.enabled(Feature::Personality))
.filter(|_| self.current_model_supports_personality());
let service_tier = Some(self.config.service_tier);
let service_tier = match self.config.service_tier {
Some(service_tier) => Some(Some(service_tier)),
None if self.config.notices.fast_default_opt_out == Some(true) => Some(None),
None => None,
};
let op = AppCommand::user_turn(
items,
self.config.cwd.to_path_buf(),
@@ -9850,12 +9859,21 @@ impl ChatWidget {
/// Set Fast mode in the widget's config copy.
pub(crate) fn set_service_tier(&mut self, service_tier: Option<ServiceTier>) {
self.config.service_tier = service_tier;
self.effective_service_tier = service_tier;
}
pub(crate) fn current_service_tier(&self) -> Option<ServiceTier> {
self.effective_service_tier
}
pub(crate) fn configured_service_tier(&self) -> Option<ServiceTier> {
self.config.service_tier
}
pub(crate) fn fast_default_opt_out(&self) -> Option<bool> {
self.config.notices.fast_default_opt_out
}
pub(crate) fn status_account_display(&self) -> Option<&StatusAccountDisplay> {
self.status_account_display.as_ref()
}
@@ -9932,6 +9950,9 @@ impl ChatWidget {
}
fn set_service_tier_selection(&mut self, service_tier: Option<ServiceTier>) {
if service_tier.is_none() {
self.config.notices.fast_default_opt_out = Some(true);
}
self.set_service_tier(service_tier);
self.app_event_tx.send(AppEvent::CodexOp(
AppCommand::override_turn_context(
@@ -172,7 +172,7 @@ impl ChatWidget {
self.open_model_popup();
}
SlashCommand::Fast => {
let next_tier = if matches!(self.config.service_tier, Some(ServiceTier::Fast)) {
let next_tier = if matches!(self.current_service_tier(), Some(ServiceTier::Fast)) {
None
} else {
Some(ServiceTier::Fast)
@@ -527,12 +527,12 @@ impl ChatWidget {
"on" => self.set_service_tier_selection(Some(ServiceTier::Fast)),
"off" => self.set_service_tier_selection(/*service_tier*/ None),
"status" => {
let status = if matches!(self.config.service_tier, Some(ServiceTier::Fast))
{
"on"
} else {
"off"
};
let status =
if matches!(self.current_service_tier(), Some(ServiceTier::Fast)) {
"on"
} else {
"off"
};
self.add_info_message(
format!("Fast mode is {status}."),
/*hint*/ None,
@@ -482,7 +482,7 @@ impl ChatWidget {
)),
StatusLineItem::SessionId => self.thread_id.map(|id| id.to_string()),
StatusLineItem::FastMode => Some(
if matches!(self.config.service_tier, Some(ServiceTier::Fast)) {
if matches!(self.current_service_tier(), Some(ServiceTier::Fast)) {
"Fast on".to_string()
} else {
"Fast off".to_string()
@@ -603,7 +603,7 @@ impl ChatWidget {
fn model_with_reasoning_display_name(&self) -> String {
let label = Self::status_line_reasoning_effort_label(self.effective_reasoning_effort());
let fast_label =
if self.should_show_fast_status(self.current_model(), self.config.service_tier) {
if self.should_show_fast_status(self.current_model(), self.current_service_tier()) {
" fast"
} else {
""
@@ -181,6 +181,7 @@ pub(super) async fn make_chatwidget_manual(
};
let current_collaboration_mode = base_mode;
let active_collaboration_mask = collaboration_modes::default_mask(model_catalog.as_ref());
let effective_service_tier = cfg.service_tier;
let mut widget = ChatWidget {
app_event_tx,
codex_op_target: super::CodexOpTarget::Direct(op_tx),
@@ -188,6 +189,7 @@ pub(super) async fn make_chatwidget_manual(
active_cell: None,
active_cell_revision: 0,
config: cfg,
effective_service_tier,
current_collaboration_mode,
active_collaboration_mask,
has_chatgpt_account: false,
@@ -1550,7 +1550,7 @@ async fn queued_fast_slash_applies_before_next_queued_message() {
}
#[tokio::test]
async fn user_turn_clears_service_tier_after_fast_is_turned_off() {
async fn user_turn_sends_standard_override_after_fast_is_turned_off() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
chat.thread_id = Some(ThreadId::new());
set_chatgpt_auth(&mut chat);
@@ -1560,7 +1560,24 @@ async fn user_turn_clears_service_tier_after_fast_is_turned_off() {
let _events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
chat.dispatch_command_with_args(SlashCommand::Fast, "off".to_string(), Vec::new());
let _events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
assert!(
events.iter().any(|event| matches!(
event,
AppEvent::CodexOp(Op::OverrideTurnContext {
service_tier: Some(None),
..
})
)),
"expected fast-mode off override app event; events: {events:?}"
);
assert!(
events.iter().any(|event| matches!(
event,
AppEvent::PersistServiceTierSelection { service_tier: None }
)),
"expected fast-mode opt-out persistence app event; events: {events:?}"
);
chat.bottom_pane
.set_composer_text("hello".to_string(), Vec::new(), Vec::new());
@@ -1571,7 +1588,7 @@ async fn user_turn_clears_service_tier_after_fast_is_turned_off() {
service_tier: Some(None),
..
} => {}
other => panic!("expected Op::UserTurn to clear service tier, got {other:?}"),
other => panic!("expected Op::UserTurn with standard service tier override, got {other:?}"),
}
}
+1 -7
View File
@@ -50,7 +50,6 @@ use codex_config::types::McpServerTransportConfig;
use codex_mcp::qualified_mcp_tool_name_prefix;
use codex_otel::RuntimeMetricsSummary;
use codex_protocol::account::PlanType;
use codex_protocol::config_types::ServiceTier;
#[cfg(test)]
use codex_protocol::mcp::Resource;
#[cfg(test)]
@@ -1241,12 +1240,7 @@ pub(crate) fn new_session_info(
} else {
if config.show_tooltips
&& let Some(tooltips) = tooltip_override
.or_else(|| {
tooltips::get_tooltip(
auth_plan,
matches!(config.service_tier, Some(ServiceTier::Fast)),
)
})
.or_else(|| tooltips::get_tooltip(auth_plan, show_fast_status))
.map(|tip| TooltipHistoryCell::new(tip, &config.cwd))
{
parts.push(Box::new(tooltips));