[codex] Generalize service tier slash commands (#21745)

## Why

`/fast` was wired as a one-off slash command even though model metadata
now exposes service tiers as catalog data. That meant adding another
tier, such as a slower/cheaper tier, would require more hardcoded TUI
plumbing instead of letting the model catalog drive the available
commands.

This change makes service-tier commands data-driven: each advertised
`service_tiers` entry becomes a `/name` command using the catalog
description, while the request path sends the tier `id` only when the
selected model supports it.

## What Changed

- Removed the hardcoded `/fast` slash-command variant and introduced
dynamic service-tier command items in the composer and command popup.
- Added toggle behavior for service-tier commands: invoking `/name`
selects that tier, and invoking it again clears the selection.
- Preserved the existing Fast-mode keybinding/status affordances by
resolving the current model tier whose name is `fast`, while still
sending the tier request value such as `priority`.
- Persisted service-tier selections as raw request strings so non-fast
tiers can round-trip through config.
- Updated the Bedrock catalog entry to advertise fast support through
`service_tiers` with `id: "priority"` and `name: "fast"`.
- Added defensive filtering in core so unsupported selected service
tiers are omitted from `/responses` requests.

## Validation

- Added/updated coverage for dynamic service-tier slash command lookup,
popup descriptions, composer dispatch, TUI fast toggling, and
unsupported-tier omission in core request construction.
- Local tests were not run per request.

---------

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Ahmed IbrahimandCodex authored and GitHub committed 2026-05-08 20:09:51 +03:00
1 parent 47f1d7b40b
commit 7c0e54bf59
24 files changed
+919 -412

No files matched your search

+13 -107
View File
@@ -150,7 +150,6 @@ use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::CollaborationModeMask;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::Settings;
#[cfg(target_os = "windows")]
use codex_protocol::config_types::WindowsSandboxLevel;
@@ -344,6 +343,7 @@ use self::plan_implementation::PLAN_IMPLEMENTATION_TITLE;
mod realtime;
use self::realtime::RealtimeConversationUiState;
mod reasoning_shortcuts;
mod service_tiers;
mod side;
mod status_surfaces;
use self::status_surfaces::CachedProjectRootName;
@@ -761,7 +761,7 @@ pub(crate) struct ChatWidget {
config: Config,
raw_output_mode: bool,
/// Runtime value resolved by core. `config.service_tier` remains the explicit user choice.
effective_service_tier: Option<ServiceTier>,
effective_service_tier: Option<String>,
/// The unmasked collaboration mode settings (always Default mode).
///
/// Masks are applied on top of this base mode to derive the effective mode.
@@ -2072,10 +2072,7 @@ impl ChatWidget {
self.current_rollout_path = session.rollout_path.clone();
self.current_cwd = Some(session.cwd.to_path_buf());
self.config.cwd = session.cwd.clone();
self.effective_service_tier = session
.service_tier
.as_deref()
.and_then(ServiceTier::from_request_value);
self.effective_service_tier = session.service_tier.clone();
if let Err(err) = self
.config
.permissions
@@ -2116,15 +2113,15 @@ impl ChatWidget {
}
self.refresh_model_display();
self.refresh_status_surfaces();
self.sync_fast_command_enabled();
self.sync_service_tier_commands();
self.sync_personality_command_enabled();
self.sync_plugins_command_enabled();
self.sync_goal_command_enabled();
self.refresh_plugin_mentions();
if display == SessionConfiguredDisplay::Normal {
let startup_tooltip_override = self.startup_tooltip_override.take();
let show_fast_status =
self.should_show_fast_status(&model_for_header, self.effective_service_tier);
let show_fast_status = self
.should_show_fast_status(&model_for_header, self.effective_service_tier.as_deref());
let session_info_cell = history_cell::new_session_info(
&self.config,
&model_for_header,
@@ -4893,10 +4890,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
.as_deref()
.and_then(ServiceTier::from_request_value);
let effective_service_tier = config.service_tier.clone();
let current_terminal_info = terminal_info();
let runtime_keymap = RuntimeKeymap::from_config(&config.tui_keymap).ok();
let default_keymap = RuntimeKeymap::defaults();
@@ -5088,7 +5082,7 @@ impl ChatWidget {
widget
.bottom_pane
.set_collaboration_modes_enabled(/*enabled*/ true);
widget.sync_fast_command_enabled();
widget.sync_service_tier_commands();
widget.sync_personality_command_enabled();
widget.sync_plugins_command_enabled();
widget.sync_goal_command_enabled();
@@ -5328,6 +5322,9 @@ impl ChatWidget {
InputResult::Command(cmd) => {
self.handle_slash_command_dispatch(cmd);
}
InputResult::ServiceTierCommand(command) => {
self.handle_service_tier_command_dispatch(command);
}
InputResult::CommandWithArgs(cmd, args, text_elements) => {
self.handle_slash_command_with_args_dispatch(cmd, args, text_elements);
}
@@ -9193,7 +9190,7 @@ impl ChatWidget {
}
}
if feature == Feature::FastMode {
self.sync_fast_command_enabled();
self.sync_service_tier_commands();
}
if feature == Feature::Personality {
self.sync_personality_command_enabled();
@@ -9309,28 +9306,6 @@ impl ChatWidget {
self.config.personality = Some(personality);
}
/// 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.map(|service_tier| service_tier.request_value().to_string());
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
.as_deref()
.and_then(ServiceTier::from_request_value)
}
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()
}
@@ -9365,26 +9340,6 @@ impl ChatWidget {
.set_connectors_enabled(self.connectors_enabled());
}
pub(crate) fn should_show_fast_status(
&self,
model: &str,
service_tier: Option<ServiceTier>,
) -> bool {
self.model_supports_fast_mode(model)
&& matches!(service_tier, Some(ServiceTier::Fast))
&& self.has_chatgpt_account
}
fn fast_mode_enabled(&self) -> bool {
self.config.features.enabled(Feature::FastMode)
}
pub(crate) fn can_toggle_fast_mode_from_keybinding(&self) -> bool {
self.fast_mode_enabled()
&& !self.is_user_turn_pending_or_running()
&& self.bottom_pane.no_modal_or_popup_active()
}
pub(crate) fn set_realtime_audio_device(
&mut self,
kind: RealtimeAudioDeviceKind,
@@ -9416,38 +9371,6 @@ impl ChatWidget {
self.refresh_model_dependent_surfaces();
}
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(
/*cwd*/ None,
/*approval_policy*/ None,
/*approvals_reviewer*/ None,
/*permission_profile*/ None,
/*windows_sandbox_level*/ None,
/*model*/ None,
/*effort*/ None,
/*summary*/ None,
Some(service_tier.map(|service_tier| service_tier.request_value().to_string())),
/*collaboration_mode*/ None,
/*personality*/ None,
)));
self.app_event_tx
.send(AppEvent::PersistServiceTierSelection { service_tier });
}
pub(crate) fn toggle_fast_mode_from_ui(&mut self) {
let next_tier = if matches!(self.current_service_tier(), Some(ServiceTier::Fast)) {
None
} else {
Some(ServiceTier::Fast)
};
self.set_service_tier_selection(next_tier);
}
pub(crate) fn current_model(&self) -> &str {
if !self.collaboration_modes_enabled() {
return self.current_collaboration_mode.model();
@@ -9474,11 +9397,6 @@ impl ChatWidget {
.unwrap_or_else(|| "System default".to_string())
}
fn sync_fast_command_enabled(&mut self) {
self.bottom_pane
.set_fast_command_enabled(self.fast_mode_enabled());
}
fn sync_personality_command_enabled(&mut self) {
self.bottom_pane
.set_personality_command_enabled(self.config.features.enabled(Feature::Personality));
@@ -9508,19 +9426,6 @@ impl ChatWidget {
.unwrap_or(false)
}
fn model_supports_fast_mode(&self, model: &str) -> bool {
self.model_catalog
.try_list_models()
.ok()
.and_then(|models| {
models
.into_iter()
.find(|preset| preset.model == model)
.map(|preset| preset.supports_fast_mode())
})
.unwrap_or(false)
}
/// Return whether the effective model currently advertises image-input support.
///
/// We intentionally default to `true` when model metadata cannot be read so transient catalog
@@ -9660,6 +9565,7 @@ impl ChatWidget {
self.session_header.set_model(effective.model());
// Keep composer paste affordances aligned with the currently effective model.
self.sync_image_paste_enabled();
self.sync_service_tier_commands();
self.refresh_terminal_title();
}