[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 Ibrahim
2026-05-08 20:09:51 +03:00
committed by GitHub
co-authored by Codex
parent 47f1d7b40b
commit 7c0e54bf59
24 changed files with 919 additions and 412 deletions
+9 -7
View File
@@ -182,10 +182,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
.as_deref()
.and_then(ServiceTier::from_request_value);
let effective_service_tier = cfg.service_tier.clone();
let mut widget = ChatWidget {
app_event_tx,
codex_op_target: super::CodexOpTarget::Direct(op_tx),
@@ -391,8 +388,12 @@ pub(crate) fn set_chatgpt_auth(chat: &mut ChatWidget) {
}
fn test_model_info(slug: &str, priority: i32, supports_fast_mode: bool) -> ModelInfo {
let additional_speed_tiers = if supports_fast_mode {
vec![codex_protocol::openai_models::SPEED_TIER_FAST]
let service_tiers = if supports_fast_mode {
vec![json!({
"id": ServiceTier::Fast.request_value(),
"name": "fast",
"description": "Fastest inference with increased plan usage"
})]
} else {
Vec::new()
};
@@ -406,7 +407,8 @@ fn test_model_info(slug: &str, priority: i32, supports_fast_mode: bool) -> Model
"visibility": "list",
"supported_in_api": true,
"priority": priority,
"additional_speed_tiers": additional_speed_tiers,
"additional_speed_tiers": [],
"service_tiers": service_tiers,
"availability_nux": null,
"upgrade": null,
"base_instructions": "base instructions",
@@ -1,6 +1,15 @@
use super::*;
use crate::bottom_pane::slash_commands::ServiceTierCommand;
use pretty_assertions::assert_eq;
fn fast_tier_command() -> ServiceTierCommand {
ServiceTierCommand {
id: ServiceTier::Fast.request_value().to_string(),
name: "fast".to_string(),
description: "Fastest inference with increased plan usage".to_string(),
}
}
fn complete_turn_with_message(chat: &mut ChatWidget, turn_id: &str, message: Option<&str>) {
if let Some(message) = message {
complete_assistant_message(
@@ -1023,9 +1032,8 @@ async fn slash_rename_without_existing_thread_name_starts_empty() {
#[tokio::test]
async fn usage_error_slash_command_is_available_from_local_recall() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true);
submit_composer_text(&mut chat, "/fast maybe");
submit_composer_text(&mut chat, "/raw maybe");
assert_eq!(chat.bottom_pane.composer_text(), "");
@@ -1036,10 +1044,10 @@ async fn usage_error_slash_command_is_available_from_local_recall() {
.collect::<Vec<_>>()
.join("\n");
assert!(
rendered.contains("Usage: /fast [on|off|status]"),
rendered.contains("Usage: /raw [on|off]"),
"expected usage message, got: {rendered:?}"
);
assert_eq!(recall_latest_after_clearing(&mut chat), "/fast maybe");
assert_eq!(recall_latest_after_clearing(&mut chat), "/raw maybe");
}
#[tokio::test]
@@ -1811,10 +1819,11 @@ async fn slash_rollout_handles_missing_path() {
#[tokio::test]
async fn fast_slash_command_updates_and_persists_local_service_tier() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
set_fast_mode_test_catalog(&mut chat);
chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true);
chat.dispatch_command(SlashCommand::Fast);
chat.handle_service_tier_command_dispatch(fast_tier_command());
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
assert!(
@@ -1831,8 +1840,9 @@ async fn fast_slash_command_updates_and_persists_local_service_tier() {
events.iter().any(|event| matches!(
event,
AppEvent::PersistServiceTierSelection {
service_tier: Some(ServiceTier::Fast),
service_tier: Some(service_tier),
}
if service_tier == ServiceTier::Fast.request_value()
)),
"expected fast-mode persistence app event; events: {events:?}"
);
@@ -1842,7 +1852,8 @@ async fn fast_slash_command_updates_and_persists_local_service_tier() {
#[tokio::test]
async fn fast_keybinding_toggle_uses_same_events_as_fast_slash_command() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
set_fast_mode_test_catalog(&mut chat);
chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true);
chat.toggle_fast_mode_from_ui();
@@ -1862,8 +1873,9 @@ async fn fast_keybinding_toggle_uses_same_events_as_fast_slash_command() {
events.iter().any(|event| matches!(
event,
AppEvent::PersistServiceTierSelection {
service_tier: Some(ServiceTier::Fast),
service_tier: Some(service_tier),
}
if service_tier == ServiceTier::Fast.request_value()
)),
"expected fast-mode persistence app event; events: {events:?}"
);
@@ -1873,7 +1885,8 @@ async fn fast_keybinding_toggle_uses_same_events_as_fast_slash_command() {
#[tokio::test]
async fn fast_keybinding_toggle_requires_feature_and_idle_surface() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
set_fast_mode_test_catalog(&mut chat);
chat.set_feature_enabled(Feature::FastMode, /*enabled*/ false);
assert!(!chat.can_toggle_fast_mode_from_keybinding());
@@ -1887,12 +1900,13 @@ async fn fast_keybinding_toggle_requires_feature_and_idle_surface() {
#[tokio::test]
async fn user_turn_carries_service_tier_after_fast_toggle() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
chat.thread_id = Some(ThreadId::new());
set_chatgpt_auth(&mut chat);
set_fast_mode_test_catalog(&mut chat);
chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true);
chat.dispatch_command(SlashCommand::Fast);
chat.handle_service_tier_command_dispatch(fast_tier_command());
let _events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
@@ -1911,13 +1925,14 @@ async fn user_turn_carries_service_tier_after_fast_toggle() {
#[tokio::test]
async fn queued_fast_slash_applies_before_next_queued_message() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
chat.thread_id = Some(ThreadId::new());
set_chatgpt_auth(&mut chat);
set_fast_mode_test_catalog(&mut chat);
chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true);
handle_turn_started(&mut chat, "turn-1");
queue_composer_text_with_tab(&mut chat, "/fast on");
queue_composer_text_with_tab(&mut chat, "/fast");
queue_composer_text_with_tab(&mut chat, "hello after fast");
complete_turn_with_message(&mut chat, "turn-1", Some("done"));
@@ -1952,15 +1967,16 @@ async fn queued_fast_slash_applies_before_next_queued_message() {
#[tokio::test]
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;
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
chat.thread_id = Some(ThreadId::new());
set_chatgpt_auth(&mut chat);
set_fast_mode_test_catalog(&mut chat);
chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true);
chat.dispatch_command(SlashCommand::Fast);
chat.handle_service_tier_command_dispatch(fast_tier_command());
let _events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
chat.dispatch_command_with_args(SlashCommand::Fast, "off".to_string(), Vec::new());
chat.handle_service_tier_command_dispatch(fast_tier_command());
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
assert!(
events.iter().any(|event| matches!(
@@ -1124,7 +1124,7 @@ async fn fast_status_indicator_requires_chatgpt_auth() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
set_fast_mode_test_catalog(&mut chat);
assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode());
chat.set_service_tier(Some(ServiceTier::Fast));
chat.set_service_tier(Some(ServiceTier::Fast.request_value().to_string()));
assert!(!chat.should_show_fast_status(chat.current_model(), chat.current_service_tier(),));
@@ -1140,7 +1140,7 @@ async fn fast_status_indicator_is_hidden_for_models_without_fast_support() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
set_fast_mode_test_catalog(&mut chat);
assert!(!get_available_model(&chat, "gpt-5.3-codex").supports_fast_mode());
chat.set_service_tier(Some(ServiceTier::Fast));
chat.set_service_tier(Some(ServiceTier::Fast.request_value().to_string()));
set_chatgpt_auth(&mut chat);
set_fast_mode_test_catalog(&mut chat);
assert!(!get_available_model(&chat, "gpt-5.3-codex").supports_fast_mode());
@@ -1533,7 +1533,7 @@ async fn status_line_fast_mode_renders_on_and_off() {
chat.refresh_status_line();
assert_eq!(status_line_text(&chat), Some("Fast off".to_string()));
chat.set_service_tier(Some(ServiceTier::Fast));
chat.set_service_tier(Some(ServiceTier::Fast.request_value().to_string()));
chat.refresh_status_line();
assert_eq!(status_line_text(&chat), Some("Fast on".to_string()));
}
@@ -1546,7 +1546,7 @@ async fn status_line_fast_mode_footer_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.show_welcome_banner = false;
chat.config.tui_status_line = Some(vec!["fast-mode".to_string()]);
chat.set_service_tier(Some(ServiceTier::Fast));
chat.set_service_tier(Some(ServiceTier::Fast.request_value().to_string()));
chat.refresh_status_line();
let width = 80;
@@ -1573,7 +1573,7 @@ async fn status_line_model_with_reasoning_includes_fast_for_fast_capable_models(
"current-dir".to_string(),
]);
chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh));
chat.set_service_tier(Some(ServiceTier::Fast));
chat.set_service_tier(Some(ServiceTier::Fast.request_value().to_string()));
set_chatgpt_auth(&mut chat);
set_fast_mode_test_catalog(&mut chat);
assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode());
@@ -1721,7 +1721,7 @@ async fn status_line_model_with_reasoning_fast_footer_snapshot() {
"current-dir".to_string(),
]);
chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh));
chat.set_service_tier(Some(ServiceTier::Fast));
chat.set_service_tier(Some(ServiceTier::Fast.request_value().to_string()));
set_chatgpt_auth(&mut chat);
set_fast_mode_test_catalog(&mut chat);
assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode());
@@ -1755,7 +1755,7 @@ async fn status_line_model_with_reasoning_context_remaining_footer_snapshot() {
"current-dir".to_string(),
]);
chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh));
chat.set_service_tier(Some(ServiceTier::Fast));
chat.set_service_tier(Some(ServiceTier::Fast.request_value().to_string()));
set_chatgpt_auth(&mut chat);
set_fast_mode_test_catalog(&mut chat);
assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode());