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
+45
View File
@@ -39,6 +39,7 @@ use codex_config::types::McpServerTransportConfig;
use codex_config::types::MemoriesConfig;
use codex_config::types::MemoriesToml;
use codex_config::types::ModelAvailabilityNuxConfig;
use codex_config::types::Notice;
use codex_config::types::NotificationCondition;
use codex_config::types::NotificationMethod;
use codex_config::types::Notifications;
@@ -5298,6 +5299,50 @@ async fn metrics_exporter_defaults_to_statsig_when_missing() -> std::io::Result<
Ok(())
}
#[tokio::test]
async fn explicit_null_service_tier_override_sets_fast_default_opt_out() -> std::io::Result<()> {
let fixture = create_test_fixture()?;
let config = Config::load_from_base_config_with_overrides(
fixture.cfg.clone(),
ConfigOverrides {
cwd: Some(fixture.cwd_path()),
service_tier: Some(None),
..Default::default()
},
fixture.codex_home(),
)
.await?;
assert_eq!(config.service_tier, None);
assert_eq!(config.notices.fast_default_opt_out, Some(true));
Ok(())
}
#[tokio::test]
async fn fast_default_opt_out_notice_config_is_respected() -> std::io::Result<()> {
let fixture = create_test_fixture()?;
let mut cfg = fixture.cfg.clone();
cfg.notice = Some(Notice {
fast_default_opt_out: Some(true),
..Default::default()
});
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides {
cwd: Some(fixture.cwd_path()),
..Default::default()
},
fixture.codex_home(),
)
.await?;
assert_eq!(config.service_tier, None);
assert_eq!(config.notices.fast_default_opt_out, Some(true));
Ok(())
}
#[tokio::test]
async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
let fixture = create_test_fixture()?;
+13
View File
@@ -37,6 +37,8 @@ pub enum ConfigEdit {
SetNoticeHideFullAccessWarning(bool),
/// Toggle the Windows world-writable directories warning acknowledgement flag.
SetNoticeHideWorldWritableWarning(bool),
/// Toggle the opt-out marker for Codex-managed fast defaults.
SetNoticeFastDefaultOptOut(bool),
/// Toggle the rate limit model nudge acknowledgement flag.
SetNoticeHideRateLimitModelNudge(bool),
/// Toggle the Windows onboarding acknowledgement flag.
@@ -436,6 +438,11 @@ impl ConfigDocument {
&[NOTICE_TABLE_KEY, "hide_world_writable_warning"],
value(*acknowledged),
)),
ConfigEdit::SetNoticeFastDefaultOptOut(opted_out) => Ok(self.write_value(
Scope::Global,
&[NOTICE_TABLE_KEY, "fast_default_opt_out"],
value(*opted_out),
)),
ConfigEdit::SetNoticeHideRateLimitModelNudge(acknowledged) => Ok(self.write_value(
Scope::Global,
&[NOTICE_TABLE_KEY, "hide_rate_limit_model_nudge"],
@@ -978,6 +985,12 @@ impl ConfigEditsBuilder {
self
}
pub fn set_fast_default_opt_out(mut self, opted_out: bool) -> Self {
self.edits
.push(ConfigEdit::SetNoticeFastDefaultOptOut(opted_out));
self
}
pub fn set_hide_rate_limit_model_nudge(mut self, acknowledged: bool) -> Self {
self.edits
.push(ConfigEdit::SetNoticeHideRateLimitModelNudge(acknowledged));
+14 -4
View File
@@ -2031,14 +2031,24 @@ impl Config {
let forced_login_method = cfg.forced_login_method;
let model = model.or(config_profile.model).or(cfg.model);
let service_tier = service_tier_override
.unwrap_or_else(|| config_profile.service_tier.or(cfg.service_tier));
let mut notices = cfg.notice.unwrap_or_default();
let service_tier = match service_tier_override {
Some(Some(service_tier)) => Some(service_tier),
Some(None) => {
// Preserve explicit standard/clear intent after the nested override
// collapses into `Config.service_tier = None`.
notices.fast_default_opt_out = Some(true);
None
}
None => config_profile.service_tier.or(cfg.service_tier),
};
let service_tier = match service_tier {
Some(ServiceTier::Fast) if features.enabled(Feature::FastMode) => {
Some(ServiceTier::Fast)
}
Some(ServiceTier::Fast) => None,
Some(ServiceTier::Flex) => Some(ServiceTier::Flex),
_ => None,
None => None,
};
let compact_prompt = compact_prompt.or(cfg.compact_prompt).and_then(|value| {
@@ -2414,7 +2424,7 @@ impl Config {
active_profile: active_profile_name,
active_project,
windows_wsl_setup_acknowledged: cfg.windows_wsl_setup_acknowledged.unwrap_or(false),
notices: cfg.notice.unwrap_or_default(),
notices,
check_for_update_on_startup,
disable_paste_burst: cfg.disable_paste_burst.unwrap_or(false),
analytics_enabled: config_profile
+32 -1
View File
@@ -77,6 +77,7 @@ use codex_otel::current_span_w3c_trace_context;
use codex_otel::set_parent_from_w3c_trace_context;
use codex_protocol::ThreadId;
use codex_protocol::ToolName;
use codex_protocol::account::PlanType as AccountPlanType;
use codex_protocol::approvals::ElicitationRequestEvent;
use codex_protocol::approvals::ExecPolicyAmendment;
use codex_protocol::approvals::NetworkPolicyAmendment;
@@ -600,11 +601,20 @@ impl Codex {
developer_instructions: None,
},
};
let account_plan_type = auth_manager
.auth_cached()
.and_then(|auth| auth.account_plan_type());
let service_tier = get_service_tier(
config.service_tier,
config.notices.fast_default_opt_out.unwrap_or(false),
account_plan_type,
config.features.enabled(Feature::FastMode),
);
let session_configuration = SessionConfiguration {
provider: config.model_provider.clone(),
collaboration_mode,
model_reasoning_summary: config.model_reasoning_summary,
service_tier: config.service_tier,
service_tier,
developer_instructions: config.developer_instructions.clone(),
user_instructions,
personality: config.personality,
@@ -785,6 +795,27 @@ impl Codex {
}
}
fn get_service_tier(
configured_service_tier: Option<ServiceTier>,
fast_default_opt_out: bool,
account_plan_type: Option<AccountPlanType>,
fast_mode_enabled: bool,
) -> Option<ServiceTier> {
if configured_service_tier.is_some() || fast_default_opt_out || !fast_mode_enabled {
return configured_service_tier;
}
account_plan_type
.is_some_and(is_enterprise_default_service_tier_plan)
.then_some(ServiceTier::Fast)
}
fn is_enterprise_default_service_tier_plan(plan_type: AccountPlanType) -> bool {
plan_type == AccountPlanType::Enterprise
|| plan_type.is_business_like()
|| plan_type.is_team_like()
}
#[cfg(test)]
pub(crate) fn completed_session_loop_termination() -> SessionLoopTermination {
futures::future::ready(()).boxed().shared()
+100
View File
@@ -26,6 +26,8 @@ use codex_models_manager::bundled_models_response;
use codex_models_manager::model_info;
use codex_protocol::AgentPath;
use codex_protocol::ThreadId;
use codex_protocol::account::PlanType as AccountPlanType;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::exec_output::ExecToolCallOutput;
use codex_protocol::models::FileSystemPermissions;
@@ -2643,6 +2645,104 @@ fn session_telemetry(
)
}
#[test]
fn get_service_tier_defaults_enterprise_accounts_to_fast() {
assert_eq!(
get_service_tier(
/*configured_service_tier*/ None,
/*fast_default_opt_out*/ false,
Some(AccountPlanType::Enterprise),
/*fast_mode_enabled*/ true,
),
Some(ServiceTier::Fast)
);
assert_eq!(
get_service_tier(
/*configured_service_tier*/ None,
/*fast_default_opt_out*/ false,
Some(AccountPlanType::EnterpriseCbpUsageBased),
/*fast_mode_enabled*/ true,
),
Some(ServiceTier::Fast)
);
assert_eq!(
get_service_tier(
/*configured_service_tier*/ None,
/*fast_default_opt_out*/ false,
Some(AccountPlanType::Business),
/*fast_mode_enabled*/ true,
),
Some(ServiceTier::Fast)
);
assert_eq!(
get_service_tier(
/*configured_service_tier*/ None,
/*fast_default_opt_out*/ false,
Some(AccountPlanType::Team),
/*fast_mode_enabled*/ true,
),
Some(ServiceTier::Fast)
);
assert_eq!(
get_service_tier(
/*configured_service_tier*/ None,
/*fast_default_opt_out*/ false,
Some(AccountPlanType::SelfServeBusinessUsageBased),
/*fast_mode_enabled*/ true,
),
Some(ServiceTier::Fast)
);
}
#[test]
fn get_service_tier_respects_fast_default_opt_out() {
assert_eq!(
get_service_tier(
/*configured_service_tier*/ None,
/*fast_default_opt_out*/ true,
Some(AccountPlanType::Enterprise),
/*fast_mode_enabled*/ true,
),
None
);
}
#[test]
fn get_service_tier_does_not_default_non_enterprise_or_disabled_fast_mode() {
assert_eq!(
get_service_tier(
/*configured_service_tier*/ None,
/*fast_default_opt_out*/ false,
Some(AccountPlanType::Pro),
/*fast_mode_enabled*/ true,
),
None
);
assert_eq!(
get_service_tier(
/*configured_service_tier*/ None,
/*fast_default_opt_out*/ false,
Some(AccountPlanType::Enterprise),
/*fast_mode_enabled*/ false,
),
None
);
}
#[tokio::test]
async fn session_settings_null_service_tier_update_clears_service_tier() {
let session_configuration = make_session_configuration_for_tests().await;
let updated = session_configuration
.apply(&SessionSettingsUpdate {
service_tier: Some(None),
..Default::default()
})
.expect("null service tier update should apply");
assert_eq!(updated.service_tier, None);
}
pub(crate) async fn make_session_configuration_for_tests() -> SessionConfiguration {
let codex_home = tempfile::tempdir().expect("create temp dir");
let config = build_test_config(codex_home.path()).await;