diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index f2a55aff69..0525c96a99 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -10218,6 +10218,14 @@ } ], "description": "Null if no requirements are configured (e.g. no requirements.toml/MDM entries)." + }, + "supportsIndependentSpeedModes": { + "default": null, + "description": "Whether Fast and Ultra Fast requirements are enforced independently. Older servers omit this field and use Fast mode as a shared speed gate.", + "type": [ + "boolean", + "null" + ] } }, "title": "ConfigRequirementsReadResponse", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 926d0578df..6a6e3dad5d 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -6054,6 +6054,14 @@ } ], "description": "Null if no requirements are configured (e.g. no requirements.toml/MDM entries)." + }, + "supportsIndependentSpeedModes": { + "default": null, + "description": "Whether Fast and Ultra Fast requirements are enforced independently. Older servers omit this field and use Fast mode as a shared speed gate.", + "type": [ + "boolean", + "null" + ] } }, "title": "ConfigRequirementsReadResponse", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index 8dbca4e59b..f2faa53496 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -1138,6 +1138,14 @@ } ], "description": "Null if no requirements are configured (e.g. no requirements.toml/MDM entries)." + }, + "supportsIndependentSpeedModes": { + "default": null, + "description": "Whether Fast and Ultra Fast requirements are enforced independently. Older servers omit this field and use Fast mode as a shared speed gate.", + "type": [ + "boolean", + "null" + ] } }, "title": "ConfigRequirementsReadResponse", diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index 4e3aa5a77f..aa9d93c0a8 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index 3f816c600c..f64e9f9227 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirementsReadResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirementsReadResponse.ts index f2de11d98e..0a452dd282 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirementsReadResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirementsReadResponse.ts @@ -4,6 +4,11 @@ import type { ConfigRequirements } from "./ConfigRequirements"; export type ConfigRequirementsReadResponse = { +/** + * Whether Fast and Ultra Fast requirements are enforced independently. + * Older servers omit this field and use Fast mode as a shared speed gate. + */ +supportsIndependentSpeedModes: boolean | null, /** * Null if no requirements are configured (e.g. no requirements.toml/MDM entries). */ diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index 3689fffd92..2953911602 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -760,6 +760,10 @@ pub enum ResidencyRequirement { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct ConfigRequirementsReadResponse { + /// Whether Fast and Ultra Fast requirements are enforced independently. + /// Older servers omit this field and use Fast mode as a shared speed gate. + #[serde(default)] + pub supports_independent_speed_modes: Option, /// Null if no requirements are configured (e.g. no requirements.toml/MDM entries). #[experimental(nested)] pub requirements: Option, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index b4f29ab453..a40c0c9d25 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -211,6 +211,12 @@ Codex home must support compressed rollout files, including shared histories. ## Managed model provider requirements +`configRequirements/read` reports `supportsIndependentSpeedModes: true` when +`features.fast_mode` and `features.ultrafast_mode` independently control Fast and +Ultra Fast. Older servers omit this field and use Fast mode as a shared speed +gate. Missing individual feature requirements impose no additional restriction; +the model catalog still determines which service tiers a model supports. + Existing threads retain their provider configuration. Input RPCs reject requests when managed `model_provider` or `model_providers` requirements no longer match that configuration, or cannot be loaded. This covers turn start/steer, review, compaction, manual queue start, and active goal diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index e03f96685a..914abf4fd8 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -143,7 +143,10 @@ impl ConfigRequestProcessor { self.thread_manager.auth_manager().allowed_login_methods(), ); - Ok(ConfigRequirementsReadResponse { requirements }) + Ok(ConfigRequirementsReadResponse { + supports_independent_speed_modes: Some(true), + requirements, + }) } pub(crate) async fn value_write( diff --git a/codex-rs/app-server/tests/suite/v2/config_requirements_application.rs b/codex-rs/app-server/tests/suite/v2/config_requirements_application.rs index 635bc2066b..ed4692bcc7 100644 --- a/codex-rs/app-server/tests/suite/v2/config_requirements_application.rs +++ b/codex-rs/app-server/tests/suite/v2/config_requirements_application.rs @@ -60,7 +60,7 @@ async fn application_network_defaults_and_null_base_url_are_exposed() -> Result< let mut server = start_server(&home).await?; assert_eq!( read_requirements(&mut server).await?, - json!({"requirements": null}) + json!({"requirements": null, "supportsIndependentSpeedModes": true}) ); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/config_requirements_browser_use_tests.rs b/codex-rs/app-server/tests/suite/v2/config_requirements_browser_use_tests.rs index a690d72ae9..6476079b65 100644 --- a/codex-rs/app-server/tests/suite/v2/config_requirements_browser_use_tests.rs +++ b/codex-rs/app-server/tests/suite/v2/config_requirements_browser_use_tests.rs @@ -66,7 +66,11 @@ async fn config_requirements_read_preserves_webmcp_policy() -> Result<()> { "{contents}", ); } else { - assert_eq!(wire, json!({ "requirements": null }), "{contents}"); + assert_eq!( + wire, + json!({ "requirements": null, "supportsIndependentSpeedModes": true }), + "{contents}" + ); } } Ok(()) diff --git a/codex-rs/app-server/tests/suite/v2/config_requirements_in_app_browser.rs b/codex-rs/app-server/tests/suite/v2/config_requirements_in_app_browser.rs index 44f5d2fb35..76a80d2f07 100644 --- a/codex-rs/app-server/tests/suite/v2/config_requirements_in_app_browser.rs +++ b/codex-rs/app-server/tests/suite/v2/config_requirements_in_app_browser.rs @@ -63,7 +63,10 @@ async fn config_requirements_read_preserves_import_policy() -> Result<()> { if let Some(requirements) = wire["requirements"].as_object() { assert_eq!(requirements.get("inAppBrowser"), Some(&expected_wire)); } else { - assert_eq!(wire, json!({ "requirements": null })); + assert_eq!( + wire, + json!({ "requirements": null, "supportsIndependentSpeedModes": true }) + ); } let response: ConfigRequirementsReadResponse = serde_json::from_value(wire)?; let actual = response diff --git a/codex-rs/app-server/tests/suite/v2/config_requirements_login.rs b/codex-rs/app-server/tests/suite/v2/config_requirements_login.rs index 679271935a..a3e2c487aa 100644 --- a/codex-rs/app-server/tests/suite/v2/config_requirements_login.rs +++ b/codex-rs/app-server/tests/suite/v2/config_requirements_login.rs @@ -47,7 +47,7 @@ async fn config_requirements_read_preserves_unrestricted_default(config: &str) - let (_home, mut server) = start_server(config, /*requirements*/ None).await?; assert_eq!( read_requirements(&mut server).await?, - json!({"requirements": null}) + json!({"requirements": null, "supportsIndependentSpeedModes": true}) ); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index 751324a173..c4ba5937d1 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -66,6 +66,46 @@ fn write_config(codex_home: &TempDir, contents: &str) -> Result<()> { )?) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_requirements_read_exposes_independent_speed_policy() -> Result<()> { + for (fast_enabled, ultrafast_enabled) in + [(false, false), (false, true), (true, false), (true, true)] + { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("requirements.toml"), + format!( + "[features]\nfast_mode = {fast_enabled}\nultrafast_mode = {ultrafast_enabled}\n" + ), + )?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build_initialized_with_timeout(DEFAULT_READ_TIMEOUT) + .await?; + let request_id = app_server.send_config_requirements_read_request().await?; + let response: ConfigRequirementsReadResponse = + timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(request_id)).await??; + assert_eq!( + ( + response.supports_independent_speed_modes, + response + .requirements + .expect("speed requirements") + .feature_requirements + ), + ( + Some(true), + Some(std::collections::BTreeMap::from([ + ("fast_mode".to_string(), fast_enabled), + ("ultrafast_mode".to_string(), ultrafast_enabled), + ])) + ), + ); + } + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn managed_auth_settings_are_exposed_enforced_and_read_only() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/app-server/tests/suite/v2/skills_list.rs b/codex-rs/app-server/tests/suite/v2/skills_list.rs index 1b3d2948e0..a0a08c257c 100644 --- a/codex-rs/app-server/tests/suite/v2/skills_list.rs +++ b/codex-rs/app-server/tests/suite/v2/skills_list.rs @@ -713,7 +713,10 @@ async fn config_reads_complete_alongside_skills_list_request() -> Result<()> { assert!(config.layers.is_none()); assert_eq!( requirements, - ConfigRequirementsReadResponse { requirements: None } + ConfigRequirementsReadResponse { + supports_independent_speed_modes: Some(true), + requirements: None, + } ); assert!(!permission_profiles.data.is_empty()); diff --git a/codex-rs/config/src/cloud_config_bundle.rs b/codex-rs/config/src/cloud_config_bundle.rs index 3a8898dd4f..62b65ecb20 100644 --- a/codex-rs/config/src/cloud_config_bundle.rs +++ b/codex-rs/config/src/cloud_config_bundle.rs @@ -62,6 +62,24 @@ impl CloudRequirementsTomlBundle { .into_iter() .rev() .map(|fragment| { + // Older backends used this fragment to deny every accelerated tier. + // Preserve that meaning through mixed deployments and rollbacks. + if fragment.id == "rbac-fast-mode" + && let Ok(mut contents) = toml::from_str::(&fragment.contents) + && let Some(features) = contents + .get_mut("features") + .and_then(toml::Value::as_table_mut) + && features.get("fast_mode").and_then(toml::Value::as_bool) == Some(false) + { + features.insert("ultrafast_mode".to_string(), toml::Value::Boolean(false)); + return RequirementsLayerEntry::from_toml_value( + RequirementSource::EnterpriseManaged { + id: fragment.id, + name: fragment.name, + }, + contents, + ); + } RequirementsLayerEntry::from_toml( RequirementSource::EnterpriseManaged { id: fragment.id, diff --git a/codex-rs/config/src/cloud_config_bundle_tests.rs b/codex-rs/config/src/cloud_config_bundle_tests.rs index 2871c2bfc3..7fafb64654 100644 --- a/codex-rs/config/src/cloud_config_bundle_tests.rs +++ b/codex-rs/config/src/cloud_config_bundle_tests.rs @@ -18,6 +18,77 @@ use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; use tempfile::tempdir; +#[test] +fn legacy_speed_denial_is_normalized_without_changing_other_fragments() { + for (id, contents, expected_ultrafast) in [ + ( + "rbac-fast-mode", + "[features]\nfast_mode = false", + Some(false), + ), + ("rbac-fast-mode", "[features]\nfast_mode = true", None), + ("admin-layer", "[features]\nfast_mode = false", None), + ] { + let requirements = compose_requirements( + CloudRequirementsTomlBundle { + enterprise_managed: vec![CloudRequirementsFragment { + id: id.to_string(), + name: id.to_string(), + contents: contents.to_string(), + }], + } + .into_layers(), + ) + .expect("valid cloud requirements") + .expect("requirements layer") + .into_toml(); + assert_eq!( + requirements + .feature_requirements + .expect("feature requirements") + .entries + .get("ultrafast_mode") + .copied(), + expected_ultrafast, + "fragment={id}", + ); + } +} + +#[test] +fn enterprise_speed_override_still_takes_precedence_over_legacy_baseline() { + let requirements = compose_requirements( + CloudRequirementsTomlBundle { + enterprise_managed: vec![ + CloudRequirementsFragment { + id: "admin-layer".to_string(), + name: "Admin".to_string(), + contents: "[features]\nultrafast_mode = true".to_string(), + }, + CloudRequirementsFragment { + id: "rbac-fast-mode".to_string(), + name: "Legacy RBAC".to_string(), + contents: "[features]\nfast_mode = false".to_string(), + }, + ], + } + .into_layers(), + ) + .expect("valid cloud requirements") + .expect("requirements layers") + .into_toml(); + assert_eq!( + requirements + .feature_requirements + .expect("feature requirements") + .entries, + std::collections::BTreeMap::from([ + ("fast_mode".to_string(), false), + ("ultrafast_mode".to_string(), true), + ]), + ); +} + #[tokio::test] async fn shared_future_runs_once() { let counter = Arc::new(AtomicUsize::new(0)); diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 8931d9c789..f22c202af0 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1205,6 +1205,9 @@ "tui_app_server": { "type": "boolean" }, + "ultrafast_mode": { + "type": "boolean" + }, "unavailable_dummy_tools": { "type": "boolean" }, @@ -7381,6 +7384,9 @@ "tui_app_server": { "type": "boolean" }, + "ultrafast_mode": { + "type": "boolean" + }, "unavailable_dummy_tools": { "type": "boolean" }, diff --git a/codex-rs/core/src/agent/role.rs b/codex-rs/core/src/agent/role.rs index 494ec0d30c..cf43be3b40 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -206,6 +206,10 @@ mod role_overrides { .enabled(Feature::FastMode) .then(|| ServiceTier::Fast.request_value().to_string()), Some(ServiceTier::Flex) => Some(ServiceTier::Flex.request_value().to_string()), + None if service_tier == "ultrafast" => next_config + .features + .enabled(Feature::UltrafastMode) + .then(|| service_tier.clone()), None => Some(service_tier.clone()), }; } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index edca5d2f2e..f00e09f612 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -3989,6 +3989,9 @@ impl Config { .enabled(Feature::FastMode) .then(|| ServiceTier::Fast.request_value().to_string()), Some(ServiceTier::Flex) => Some(ServiceTier::Flex.request_value().to_string()), + None if service_tier == "ultrafast" => features + .enabled(Feature::UltrafastMode) + .then_some(service_tier), None => Some(service_tier), } }); diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index d343409813..427dfdcbd6 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -82,6 +82,7 @@ use codex_extension_api::PromptSlot; use codex_extension_api::TurnContextContributionInput; use codex_features::FEATURES; use codex_features::Feature; +use codex_features::Features; use codex_features::unstable_features_warning_event; use codex_history::RolloutItem; use codex_hooks::Hooks; @@ -834,14 +835,13 @@ impl Session { /*developer_instructions*/ None, ) }); - let fast_mode_enabled = config.features.enabled(Feature::FastMode); let initial_service_tier_warning = unsupported_service_tier_warning( config.service_tier.as_deref(), - fast_mode_enabled, + &config.features, &model_info, ); let service_tier = - get_service_tier(config.service_tier.clone(), fast_mode_enabled, &model_info); + get_service_tier(config.service_tier.clone(), &config.features, &model_info); let storage_originator = AuthStorageOriginator::from_client_name(&originator); let session_configuration = SessionConfiguration { turn_extension_init, @@ -1124,14 +1124,14 @@ pub(crate) fn new_submission_id() -> String { pub(crate) fn get_service_tier( configured_service_tier: Option, - fast_mode_enabled: bool, + features: &Features, model_info: &ModelInfo, ) -> Option { let service_tier = configured_service_tier?; if service_tier == ServiceTier::Flex.request_value() { return Some(service_tier); } - if fast_mode_enabled + if features.service_tier_enabled(&service_tier) && (service_tier == SERVICE_TIER_DEFAULT_REQUEST_VALUE || model_info.supports_service_tier(&service_tier)) { @@ -1142,11 +1142,11 @@ pub(crate) fn get_service_tier( fn unsupported_service_tier_warning( configured_service_tier: Option<&str>, - fast_mode_enabled: bool, + features: &Features, model_info: &ModelInfo, ) -> Option { let service_tier = configured_service_tier.filter(|service_tier| { - fast_mode_enabled + features.service_tier_enabled(service_tier) && *service_tier != SERVICE_TIER_DEFAULT_REQUEST_VALUE && !model_info.supports_service_tier(service_tier) })?; @@ -3733,7 +3733,7 @@ impl Session { let mut inherited_settings = ResolvedStepSettings::new( Arc::new(selected), Arc::clone(&settings.model_info), - self.features.enabled(Feature::FastMode), + &self.features, ); inherited_settings.mcp_approvals_reviewer_override = settings.mcp_approvals_reviewer_override; diff --git a/codex-rs/core/src/session/reasoning_effort_tests.rs b/codex-rs/core/src/session/reasoning_effort_tests.rs index d9ff4ba24e..2eb91a075c 100644 --- a/codex-rs/core/src/session/reasoning_effort_tests.rs +++ b/codex-rs/core/src/session/reasoning_effort_tests.rs @@ -40,7 +40,7 @@ async fn initial_replay_preserves_prewarmed_effort(history: InitialHistory) { let prewarm_settings = ResolvedStepSettings::new( Arc::new(selected.clone()), Arc::clone(&model_info), - /*fast_mode_enabled*/ false, + &codex_features::Features::default(), ); // Force the ordering where prewarm pins its request before initial replay finishes. assert_eq!( @@ -55,7 +55,7 @@ async fn initial_replay_preserves_prewarmed_effort(history: InitialHistory) { let turn_settings = ResolvedStepSettings::new( Arc::new(selected), model_info, - /*fast_mode_enabled*/ false, + &codex_features::Features::default(), ); assert_eq!( session diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index d1080745c2..44ec95ff60 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -99,7 +99,7 @@ pub(super) async fn spawn_review_thread( let step_settings = Arc::new(ResolvedStepSettings::new( Arc::new(selected), Arc::new(model_info.clone()), - review_features.enabled(Feature::FastMode), + &review_features, )); per_turn_config.model = Some(model); per_turn_config.model_reasoning_effort = reasoning_effort; diff --git a/codex-rs/core/src/session/startup_prewarm.rs b/codex-rs/core/src/session/startup_prewarm.rs index b59308436d..e74e4668af 100644 --- a/codex-rs/core/src/session/startup_prewarm.rs +++ b/codex-rs/core/src/session/startup_prewarm.rs @@ -347,7 +347,7 @@ async fn schedule_startup_prewarm_inner( ) { crate::session::get_service_tier( session.services.agent_control.service_tier(), - session.features().enabled(Feature::FastMode), + &session.features(), &preconnect_model_info, ) } else { diff --git a/codex-rs/core/src/session/step_activation.rs b/codex-rs/core/src/session/step_activation.rs index 8070b08ec6..6a972b8ba7 100644 --- a/codex-rs/core/src/session/step_activation.rs +++ b/codex-rs/core/src/session/step_activation.rs @@ -412,7 +412,7 @@ impl Session { &constraints, self.services.models_manager.as_ref(), &overrides, - self.features.enabled(Feature::FastMode), + &self.features, ) .await .map_err(|error| error.to_string()) diff --git a/codex-rs/core/src/session/step_activation_tests.rs b/codex-rs/core/src/session/step_activation_tests.rs index 0202c02075..8d5647a4a7 100644 --- a/codex-rs/core/src/session/step_activation_tests.rs +++ b/codex-rs/core/src/session/step_activation_tests.rs @@ -1062,11 +1062,8 @@ async fn activation_must_match_the_retained_turn_authority(change: ManagedPolicy model_info.slug = "step-settings-policy-destination".to_string(); let mut selected = current.selected().clone(); selected.collaboration_mode.settings.model = model_info.slug.clone(); - let destination = ResolvedStepSettings::new( - Arc::new(selected), - Arc::new(model_info), - session.features.enabled(Feature::FastMode), - ); + let destination = + ResolvedStepSettings::new(Arc::new(selected), Arc::new(model_info), &session.features); let mut live = session.state.lock().await.session_configuration.clone(); live.original_config_do_not_use = Arc::clone(&prepared.config); assert_eq!( diff --git a/codex-rs/core/src/session/step_settings.rs b/codex-rs/core/src/session/step_settings.rs index 701d480d4a..83b1edd04c 100644 --- a/codex-rs/core/src/session/step_settings.rs +++ b/codex-rs/core/src/session/step_settings.rs @@ -4,6 +4,7 @@ use crate::config::Constrained; use crate::config::ConstraintError; use crate::config::ConstraintResult; use codex_config::ConfigRequirements; +use codex_features::Features; use codex_models_manager::ModelsManagerConfig; use codex_models_manager::manager::ModelsManager; use codex_otel::SessionTelemetry; @@ -57,16 +58,13 @@ impl ResolvedStepSettings { pub(super) fn new( selected: Arc, model_info: Arc, - fast_mode_enabled: bool, + features: &Features, ) -> Self { let reasoning_summary = selected .reasoning_summary .unwrap_or(model_info.default_reasoning_summary); - let service_tier = super::get_service_tier( - selected.service_tier.clone(), - fast_mode_enabled, - &model_info, - ); + let service_tier = + super::get_service_tier(selected.service_tier.clone(), features, &model_info); Self { selected, model_info, @@ -129,7 +127,7 @@ impl ResolvedStepSettings { constraints: &StepSettingsConstraints<'_>, models_manager: &dyn ModelsManager, overrides: &ModelInfoOverrides, - fast_mode_enabled: bool, + features: &Features, ) -> ConstraintResult { let selected = self.selected.apply(update, constraints)?; let model_info = @@ -138,7 +136,7 @@ impl ResolvedStepSettings { } else { Arc::new(selected.resolve_model_info(models_manager, overrides).await) }; - let mut next = Self::new(Arc::new(selected), model_info, fast_mode_enabled); + let mut next = Self::new(Arc::new(selected), model_info, features); next.mcp_approvals_reviewer_override = update .approvals_reviewer .or(self.mcp_approvals_reviewer_override); diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index d5f52a97e2..a9bcb0477d 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -4703,7 +4703,7 @@ async fn turn_context_with_model_updates_model_fields() { *settings = ResolvedStepSettings::new( Arc::new(selected), Arc::clone(&settings.model_info), - /*fast_mode_enabled*/ true, + &codex_features::Features::with_defaults(), ); }); Arc::make_mut(&mut turn_context.config).service_tier = @@ -4719,7 +4719,7 @@ async fn turn_context_with_model_updates_model_fields() { let current = Arc::new(ResolvedStepSettings::new( Arc::new(current_selection), Arc::clone(turn_context.model_info()), - /*fast_mode_enabled*/ true, + &codex_features::Features::with_defaults(), )); turn_context.next_step_settings.store(Arc::clone(¤t)); let updated = turn_context @@ -4968,7 +4968,7 @@ fn get_service_tier_does_not_use_model_default_when_absent_and_fast_mode_enabled assert_eq!( get_service_tier( /*configured_service_tier*/ None, - /*fast_mode_enabled*/ true, + &codex_features::Features::with_defaults(), &model_info, ), None @@ -4982,7 +4982,7 @@ fn get_service_tier_does_not_use_model_default_when_fast_mode_disabled() { assert_eq!( get_service_tier( /*configured_service_tier*/ None, - /*fast_mode_enabled*/ false, + &codex_features::Features::default(), &model_info, ), None @@ -4996,7 +4996,7 @@ fn get_service_tier_keeps_supported_explicit_tier() { assert_eq!( get_service_tier( Some(ServiceTier::Fast.request_value().to_string()), - /*fast_mode_enabled*/ true, + &codex_features::Features::with_defaults(), &model_info, ), Some(ServiceTier::Fast.request_value().to_string()) @@ -5010,7 +5010,7 @@ fn get_service_tier_does_not_default_when_model_has_no_default() { assert_eq!( get_service_tier( /*configured_service_tier*/ None, - /*fast_mode_enabled*/ true, + &codex_features::Features::with_defaults(), &model_info, ), None @@ -5024,7 +5024,7 @@ fn get_service_tier_drops_unsupported_configured_tier_when_fast_mode_enabled() { assert_eq!( get_service_tier( Some("unsupported".to_string()), - /*fast_mode_enabled*/ true, + &codex_features::Features::with_defaults(), &model_info, ), None @@ -5032,7 +5032,7 @@ fn get_service_tier_drops_unsupported_configured_tier_when_fast_mode_enabled() { assert_eq!( get_service_tier( Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()), - /*fast_mode_enabled*/ true, + &codex_features::Features::with_defaults(), &model_info, ), Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()) @@ -5043,10 +5043,12 @@ fn get_service_tier_drops_unsupported_configured_tier_when_fast_mode_enabled() { fn get_service_tier_preserves_flex_without_catalog_support_or_fast_mode() { let model_info = model_with_default_service_tier(/*default_service_tier*/ None); for fast_mode_enabled in [false, true] { + let mut features = codex_features::Features::with_defaults(); + features.set_enabled(Feature::FastMode, fast_mode_enabled); assert_eq!( get_service_tier( Some(ServiceTier::Flex.request_value().to_string()), - fast_mode_enabled, + &features, &model_info, ), Some(ServiceTier::Flex.request_value().to_string()) @@ -5061,7 +5063,7 @@ fn get_service_tier_ignores_non_flex_tiers_when_fast_mode_disabled() { assert_eq!( get_service_tier( Some(ServiceTier::Fast.request_value().to_string()), - /*fast_mode_enabled*/ false, + &codex_features::Features::default(), &model_info, ), None @@ -5069,7 +5071,7 @@ fn get_service_tier_ignores_non_flex_tiers_when_fast_mode_disabled() { assert_eq!( get_service_tier( Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()), - /*fast_mode_enabled*/ false, + &codex_features::Features::default(), &model_info, ), None @@ -5077,7 +5079,7 @@ fn get_service_tier_ignores_non_flex_tiers_when_fast_mode_disabled() { assert_eq!( get_service_tier( Some("unsupported".to_string()), - /*fast_mode_enabled*/ false, + &codex_features::Features::default(), &model_info, ), None @@ -5085,7 +5087,7 @@ fn get_service_tier_ignores_non_flex_tiers_when_fast_mode_disabled() { assert_eq!( get_service_tier( /*configured_service_tier*/ None, - /*fast_mode_enabled*/ false, + &codex_features::Features::default(), &model_info, ), None @@ -7036,7 +7038,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { Arc::new(super::step_settings::ResolvedStepSettings::new( Arc::clone(&session_configuration.step_settings), Arc::new(model_info), - config.features.enabled(Feature::FastMode), + &config.features, )), &models_manager, /*network*/ None, @@ -9302,7 +9304,7 @@ where Arc::new(super::step_settings::ResolvedStepSettings::new( Arc::clone(&session_configuration.step_settings), Arc::new(model_info), - config.features.enabled(Feature::FastMode), + &config.features, )), &models_manager, /*network*/ None, @@ -11326,7 +11328,7 @@ async fn build_initial_context_uses_retained_step_after_model_change() { .store(Arc::new(ResolvedStepSettings::new( Arc::new(selected_b), Arc::new(model_b), - /*fast_mode_enabled*/ false, + &codex_features::Features::default(), ))); let step_b = session .capture_step_context(Arc::clone(&turn_context), &CancellationToken::new()) diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index e8f9abb256..d58b7cd888 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -822,7 +822,7 @@ impl TurnContext { let step_settings = Arc::new(ResolvedStepSettings::new( Arc::new(selected), model_info, - config.features.enabled(Feature::FastMode), + &config.features, )); config.service_tier = step_settings.service_tier.clone(); let session_telemetry = step_settings.telemetry(&self.session_telemetry); @@ -1395,7 +1395,7 @@ impl Session { let step_settings = Arc::new(ResolvedStepSettings::new( Arc::clone(&session_configuration.step_settings), Arc::new(model_info), - self.features.enabled(Feature::FastMode), + &self.features, )); let mut turn_context: TurnContext = Self::make_turn_context( self.thread_id(), diff --git a/codex-rs/core/tests/suite/agent_websocket.rs b/codex-rs/core/tests/suite/agent_websocket.rs index 2262343cf1..28d0554e54 100644 --- a/codex-rs/core/tests/suite/agent_websocket.rs +++ b/codex-rs/core/tests/suite/agent_websocket.rs @@ -27,11 +27,15 @@ use std::time::Duration; const WS_V2_BETA_HEADER_VALUE: &str = "responses_websockets=2026-02-06"; -#[test_case::test_case(true; "fast mode enabled")] -#[test_case::test_case(false; "fast mode disabled")] +#[test_case::test_case("priority", true, false; "fast mode enabled")] +#[test_case::test_case("priority", false, true; "fast mode disabled")] +#[test_case::test_case("ultrafast", false, true; "ultrafast mode enabled")] +#[test_case::test_case("ultrafast", true, false; "ultrafast mode disabled")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn spawned_agent_prewarm_handshake_inherits_effective_root_service_tier( + tier: &'static str, fast_mode_enabled: bool, + ultrafast_mode_enabled: bool, ) -> Result<()> { skip_if_no_network!(Ok(())); @@ -63,7 +67,11 @@ async fn spawned_agent_prewarm_handshake_inherits_effective_root_service_tier( ], ]) .await; - let tier = ServiceTier::Fast.request_value(); + let tier_enabled = if tier == "ultrafast" { + ultrafast_mode_enabled + } else { + fast_mode_enabled + }; let test = test_codex() .with_model("gpt-5.4") .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) @@ -89,7 +97,11 @@ async fn spawned_agent_prewarm_handshake_inherits_effective_root_service_tier( .disable(Feature::FastMode) .expect("fast mode"); } - config.service_tier = Some(ServiceTier::Fast.request_value().to_string()); + config + .features + .set_enabled(Feature::UltrafastMode, ultrafast_mode_enabled) + .expect("configure Ultra Fast mode"); + config.service_tier = Some(tier.to_string()); }) .build_with_websocket_server(&server) .await?; @@ -122,7 +134,7 @@ async fn spawned_agent_prewarm_handshake_inherits_effective_root_service_tier( "child startup should connect before its first turn" ); let handshakes = server.handshakes(); - let expected_hint = if fast_mode_enabled { + let expected_hint = if tier_enabled { format!("model=gpt-5.4;tier={tier}") } else { "model=gpt-5.4".to_string() @@ -140,7 +152,7 @@ async fn spawned_agent_prewarm_handshake_inherits_effective_root_service_tier( assert_eq!(child_warmup["generate"], false); assert_eq!( child_warmup["service_tier"].as_str(), - fast_mode_enabled.then_some(tier) + tier_enabled.then_some(tier) ); assert_eq!(server.handshakes().len(), 2); child.shutdown_and_wait().await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index c232097e72..4f45445509 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -107,6 +107,63 @@ const INSTALLATION_ID_FILENAME: &str = "installation_id"; const TEST_WINDOW_ID: &str = "test-thread:0"; const TEST_INSTALLATION_ID: &str = "11111111-1111-4111-8111-111111111111"; +#[test_case::test_case(false, false)] +#[test_case::test_case(false, true)] +#[test_case::test_case(true, false)] +#[test_case::test_case(true, true)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_request_enforces_independent_speed_requirements( + fast_enabled: bool, + ultrafast_enabled: bool, +) -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + for (tier, enabled) in [("priority", fast_enabled), ("ultrafast", ultrafast_enabled)] { + for configure_at_start in [false, true] { + let server = start_mock_server().await; + let response_mock = mount_sse_once(&server, sse(vec![ev_completed("done")])).await; + let test = test_codex() + .with_model("gpt-5.4") + .with_model_info_override("gpt-5.4", move |model| { + model.service_tiers = vec![codex_protocol::openai_models::ModelServiceTier { + id: tier.to_string(), + name: tier.to_string(), + description: String::new(), + }]; + }) + .with_config(move |config| { + config + .features + .set_enabled(Feature::FastMode, fast_enabled) + .expect("configure Fast mode"); + config + .features + .set_enabled(Feature::UltrafastMode, ultrafast_enabled) + .expect("configure Ultra Fast mode"); + config.service_tier = configure_at_start.then(|| tier.to_string()); + }) + .build_with_auto_env(&server) + .await?; + if !configure_at_start { + core_test_support::submit_thread_settings( + &test.codex, + ThreadSettingsOverrides { + service_tier: Some(Some(tier.to_string())), + ..Default::default() + }, + ) + .await?; + } + test.submit_turn("hello").await?; + assert_eq!( + response_mock.single_request().body_json()["service_tier"].as_str(), + enabled.then_some(tier), + "tier={tier}, configure_at_start={configure_at_start}", + ); + } + } + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn responses_request_preserves_flex_without_catalog_support_or_fast_mode() -> anyhow::Result<()> { diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 5d3804662f..44d86eb43f 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -401,6 +401,8 @@ pub enum Feature { Artifact, /// Enable Fast mode selection in the TUI and request layer. FastMode, + /// Enable Ultra Fast mode independently of Fast mode. + UltrafastMode, /// Enable explicitly requested model changes for later step captures. StepModelSwitching, /// Enable voice conversations in the TUI. @@ -552,6 +554,15 @@ impl Features { self.enabled.contains(&f) } + /// Whether a routing tier is enabled, independently of model catalog support. + pub fn service_tier_enabled(&self, service_tier: &str) -> bool { + match service_tier { + "flex" => true, + "ultrafast" => self.enabled(Feature::UltrafastMode), + _ => self.enabled(Feature::FastMode), + } + } + /// Returns whether persistent execution is enabled for the selected effort. pub fn persistent_execution_enabled(&self, reasoning_effort: Option<&ReasoningEffort>) -> bool { reasoning_effort == Some(&ReasoningEffort::Persistent) @@ -1892,6 +1903,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::UltrafastMode, + key: "ultrafast_mode", + stage: Stage::Stable, + default_enabled: true, + }, FeatureSpec { id: Feature::StepModelSwitching, key: "step_model_switching", diff --git a/codex-rs/tui/src/app/agents_overview.rs b/codex-rs/tui/src/app/agents_overview.rs index 07f56a1275..d069a6c129 100644 --- a/codex-rs/tui/src/app/agents_overview.rs +++ b/codex-rs/tui/src/app/agents_overview.rs @@ -1081,7 +1081,8 @@ impl App { ); if server_model_cleared && config.model.is_none() - && config.features.enabled(Feature::FastMode) + && (config.features.enabled(Feature::FastMode) + || config.features.enabled(Feature::UltrafastMode)) { // Bootstrap's fallback model may be seeded from the client. Resolve tiers // against the server catalog when config/read cleared the model. diff --git a/codex-rs/tui/src/app/tests/luna_reserve_recovery_tests.rs b/codex-rs/tui/src/app/tests/luna_reserve_recovery_tests.rs index f56c31af5c..8dbceb0cda 100644 --- a/codex-rs/tui/src/app/tests/luna_reserve_recovery_tests.rs +++ b/codex-rs/tui/src/app/tests/luna_reserve_recovery_tests.rs @@ -188,6 +188,8 @@ async fn luna_reserve_recovery_restores_task_and_pending_turn_after_fresh_backen configure_reserve_catalog(&mut app); app.chat_widget .set_feature_enabled(Feature::FastMode, /*enabled*/ false); + app.chat_widget + .set_feature_enabled(Feature::UltrafastMode, /*enabled*/ false); app.chat_widget .set_reasoning_effort(Some(ReasoningEffortConfig::High)); let original_mode = app.chat_widget.effective_collaboration_mode(); diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 25a1d20a54..475b8a0773 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -718,15 +718,19 @@ impl AppServerSession { }, async { Ok(crate::collaboration_modes::list(self.request_handle()).await) }, )?; - self.managed_new_thread_defaults = requirements - .requirements - .and_then(|requirements| requirements.models) - .and_then(|models| models.new_thread); - let available_models = models + let mut available_models = models .data .into_iter() .map(model_preset_from_api_model) .collect::>(); + crate::service_tier_resolution::constrain_server_service_tiers( + &mut available_models, + &requirements, + ); + self.managed_new_thread_defaults = requirements + .requirements + .and_then(|requirements| requirements.models) + .and_then(|models| models.new_thread); let default_model = config .model .clone() diff --git a/codex-rs/tui/src/chatwidget/service_tiers.rs b/codex-rs/tui/src/chatwidget/service_tiers.rs index ad48da4495..6ff2786510 100644 --- a/codex-rs/tui/src/chatwidget/service_tiers.rs +++ b/codex-rs/tui/src/chatwidget/service_tiers.rs @@ -66,10 +66,10 @@ impl ChatWidget { } pub(super) fn sync_service_tier_commands(&mut self) { + let commands = self.current_model_service_tier_commands(); self.bottom_pane - .set_service_tier_commands_enabled(self.fast_mode_enabled()); - self.bottom_pane - .set_service_tier_commands(self.current_model_service_tier_commands()); + .set_service_tier_commands_enabled(!commands.is_empty()); + self.bottom_pane.set_service_tier_commands(commands); } pub(super) fn current_model_service_tier_commands(&self) -> Vec { @@ -85,6 +85,7 @@ impl ChatWidget { preset .service_tiers .into_iter() + .filter(|tier| self.config.features.service_tier_enabled(&tier.id)) .map(|tier| ServiceTierCommand { id: tier.id, name: tier.name.to_lowercase(), diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index c85b798769..a5ddae2160 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -116,7 +116,7 @@ impl ChatWidget { self.set_daybreak_enabled(self.daybreak_enabled); self.refresh_status_surfaces(); } - if feature == Feature::FastMode { + if matches!(feature, Feature::FastMode | Feature::UltrafastMode) { self.refresh_effective_service_tier(); self.sync_service_tier_commands(); } diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index d45776cbb9..182a696b24 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -1241,7 +1241,7 @@ impl ChatWidget { plugins_command_enabled: self.config.features.enabled(Feature::Plugins), token_activity_command_enabled: self.has_codex_backend_auth, goal_command_enabled: self.config.features.enabled(Feature::Goals), - service_tier_commands_enabled: self.fast_mode_enabled(), + service_tier_commands_enabled: !self.current_model_service_tier_commands().is_empty(), daybreak_command_description: self.daybreak_command_description(), voice_command_enabled: self.realtime_conversation_available_for_thread, worktrees_enabled: self.config.features.enabled(Feature::Worktrees) diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 439a368784..3589c3df26 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -106,6 +106,56 @@ fn next_copy_selection( selection } +#[tokio::test] +async fn service_tier_commands_and_saved_selection_respect_independent_speed_policy() { + for (fast_enabled, ultrafast_enabled) in [(false, true), (true, false)] { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.5")).await; + let mut preset = get_available_model(&chat, "gpt-5.5"); + preset.service_tiers = ["priority", "ultrafast"] + .into_iter() + .map(|tier| codex_protocol::openai_models::ModelServiceTier { + id: tier.to_string(), + name: tier.to_string(), + description: format!("{tier} processing"), + }) + .collect(); + preset.default_service_tier = Some("ultrafast".to_string()); + chat.model_catalog = std::sync::Arc::new(ModelCatalog::new(vec![preset])); + chat.set_feature_enabled(Feature::FastMode, fast_enabled); + chat.set_feature_enabled(Feature::UltrafastMode, ultrafast_enabled); + chat.set_service_tier(Some("ultrafast".to_string())); + assert_eq!( + chat.current_service_tier(), + ultrafast_enabled.then_some("ultrafast") + ); + assert_eq!( + chat.current_model_service_tier_commands() + .into_iter() + .map(|tier| tier.id) + .collect::>(), + vec![ + if ultrafast_enabled { + "ultrafast" + } else { + "priority" + } + .to_string() + ], + ); + chat.bottom_pane + .set_composer_text("/ultra".to_string(), Vec::new(), Vec::new()); + let snapshot_name = if ultrafast_enabled { + "ultrafast_command_allowed" + } else { + "ultrafast_command_denied" + }; + insta::assert_snapshot!( + snapshot_name, + normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 80)) + ); + } +} + #[tokio::test] async fn service_tier_commands_lowercase_catalog_names() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.5")).await; diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__ultrafast_command_allowed.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__ultrafast_command_allowed.snap new file mode 100644 index 0000000000..6505ef3a24 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__ultrafast_command_allowed.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/chatwidget/tests/slash_commands.rs +expression: "render_bottom_popup(&chat, 80)" +--- +› /ultrafast ultrafast processing + +› /ultra + + GPT-5.5 default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__ultrafast_command_denied.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__ultrafast_command_denied.snap new file mode 100644 index 0000000000..25210e0ac9 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__ultrafast_command_denied.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/chatwidget/tests/slash_commands.rs +expression: "render_bottom_popup(&chat, 80)" +--- +› /ultra + + GPT-5.5 default · /tmp/project diff --git a/codex-rs/tui/src/service_tier_resolution.rs b/codex-rs/tui/src/service_tier_resolution.rs index 47f017cb38..223d615d7a 100644 --- a/codex-rs/tui/src/service_tier_resolution.rs +++ b/codex-rs/tui/src/service_tier_resolution.rs @@ -4,6 +4,38 @@ use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ModelPreset; +pub(crate) fn constrain_server_service_tiers( + models: &mut [ModelPreset], + requirements: &codex_app_server_protocol::ConfigRequirementsReadResponse, +) { + let Some(features) = requirements + .requirements + .as_ref() + .and_then(|requirements| requirements.feature_requirements.as_ref()) + else { + return; + }; + let fast_enabled = features.get("fast_mode") != Some(&false); + // Older servers enforce Ultra Fast through their shared Fast mode gate. + let ultrafast_enabled = features.get("ultrafast_mode") != Some(&false) + && (requirements.supports_independent_speed_modes == Some(true) || fast_enabled); + let tier_enabled = |tier: &str| match tier { + "flex" => true, + "ultrafast" => ultrafast_enabled, + _ => fast_enabled, + }; + for model in models { + model.service_tiers.retain(|tier| tier_enabled(&tier.id)); + if model + .default_service_tier + .as_deref() + .is_some_and(|tier| !tier_enabled(tier)) + { + model.default_service_tier = None; + } + } +} + pub(crate) fn configured_service_tier( config: &Config, notices: &codex_config::types::Notice, @@ -24,12 +56,8 @@ pub(crate) fn effective_service_tier( if configured.as_deref() == Some(ServiceTier::Flex.request_value()) { return configured; } - if !config.features.enabled(Feature::FastMode) { - return None; - } - let Some(preset) = models.iter().find(|preset| preset.model == model) else { - return configured; + return configured.filter(|tier| config.features.service_tier_enabled(tier)); }; match configured.as_deref() { @@ -41,6 +69,7 @@ pub(crate) fn effective_service_tier( .clone() .filter(|service_tier| model_supports_service_tier(preset, service_tier)), } + .filter(|tier| config.features.service_tier_enabled(tier)) } pub(crate) fn service_tier_update_for_core( @@ -54,7 +83,9 @@ pub(crate) fn service_tier_update_for_core( return Some(Some(service_tier)); } - if !config.features.enabled(Feature::FastMode) { + if !config.features.enabled(Feature::FastMode) + && !config.features.enabled(Feature::UltrafastMode) + { return None; } diff --git a/sdk/python/src/openai_codex/generated/v2_all.py b/sdk/python/src/openai_codex/generated/v2_all.py index 58f45ffcf2..4a5cd1f191 100644 --- a/sdk/python/src/openai_codex/generated/v2_all.py +++ b/sdk/python/src/openai_codex/generated/v2_all.py @@ -11840,6 +11840,13 @@ class ConfigRequirementsReadResponse(BaseModel): description="Null if no requirements are configured (e.g. no requirements.toml/MDM entries)." ), ] = None + supports_independent_speed_modes: Annotated[ + bool | None, + Field( + alias="supportsIndependentSpeedModes", + description="Whether Fast and Ultra Fast requirements are enforced independently. Older servers omit this field and use Fast mode as a shared speed gate.", + ), + ] = None class ExternalAgentConfigDetectResponse(BaseModel):