Enable conversation-scoped speed selection for Cloud turns (#61178)

## What changed

- Stage Cloud speed choices for the next new turn in the conversation, alongside pending model and effort selections. Update the displayed conversation speed after admission without changing the local default.
- Offer speed commands supported by the pending model. Clear an incompatible pending speed change with a warning when switching models.
- Carry `service_tier` through Cloud message requests, preserving omission to inherit the thread tier and explicit null to clear it. Translate `default` to null and reject tier overrides on queued requests.

## Testing

Add tests for Cloud speed toggling, model-dependent speed choices, admission-time updates, and preserving the conversation tier when selecting a model. Extend request and wire tests to cover service-tier values, explicit clearing, and queue rejection.

GitOrigin-RevId: b4daa701f1fe29593d90a938e60cc20cb980298a
This commit is contained in:
Benjamin Carlsson
2026-11-12 20:56:33 +00:00
committed by copyberry
parent 72532ecae5
commit b279cfb170
20 changed files with 583 additions and 73 deletions
@@ -76,6 +76,17 @@ fn user_input_uses_stable_id_and_the_existing_cloud_configuration() {
);
assert!(matches!(request.mode, cloud::SubmissionMode::StartIfIdle));
assert!(!request.shared_access);
assert_eq!(request.service_tier, None);
for (tier, expected) in [("ultrafast", Some("ultrafast")), ("default", None)] {
let request = cloud_message(protocol::TurnStartParams {
turn_trigger: Some("user".into()),
service_tier: Some(Some(tier.into())),
..message()
})
.unwrap();
assert_eq!(request.service_tier, Some(expected.map(str::to_owned)));
}
for tier in ["default", "priority"] {
assert!(
@@ -6,6 +6,7 @@ use super::send_notification;
use crate::CloudUserMessageReceipt;
use codex_app_server_protocol as protocol;
use codex_cloud_client as cloud;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use codex_protocol::models::ImageDetail;
use color_eyre::eyre::Result;
use color_eyre::eyre::bail;
@@ -314,10 +315,21 @@ fn cloud_message(
))?;
let input = std::mem::take(&mut params.input);
let turn_trigger = params.turn_trigger.take();
let (model, effort) = if turn_trigger.as_deref() == Some("user") {
(params.model.take(), params.effort.take())
let (model, effort, service_tier) = if turn_trigger.as_deref() == Some("user") {
let tier = match params.service_tier.take() {
Some(Some(tier)) if tier == SERVICE_TIER_DEFAULT_REQUEST_VALUE => Some(None),
Some(Some(tier))
if !matches!(tier.as_str(), "fast" | "priority" | "flex" | "ultrafast") =>
{
return Err(PreflightRejection(
"This service tier is unavailable in Cloud.",
));
}
tier => tier,
};
(params.model.take(), params.effort.take(), tier)
} else {
(None, None)
(None, None, None)
};
if params != protocol::TurnStartParams::default() {
return Err(PreflightRejection(
@@ -392,6 +404,7 @@ fn cloud_message(
turn_trigger,
model,
effort: effort.map(|effort| effort.to_string()),
service_tier,
mode: cloud::SubmissionMode::StartIfIdle,
shared_access: false,
})
+1
View File
@@ -32,6 +32,7 @@ async fn main() -> anyhow::Result<()> {
turn_trigger: None,
model: None,
effort: None,
service_tier: None,
mode: SubmissionMode::StartIfIdle,
shared_access: false,
}),
+14 -1
View File
@@ -30,6 +30,8 @@ pub struct AddUserMessageRequest {
/// Omit both to inherit the thread settings. A model alone uses its default effort.
pub model: Option<String>,
pub effort: Option<String>,
/// Omit to inherit the thread tier, or send an explicit null to clear it.
pub service_tier: Option<Option<String>>,
pub mode: SubmissionMode,
pub shared_access: bool,
}
@@ -128,7 +130,9 @@ impl Client {
) -> Result<Admission, MutationError> {
if request.thread_id.trim().is_empty()
|| request.client_user_message_id.trim().is_empty()
|| ((request.model.is_some() || request.effort.is_some())
|| ((request.model.is_some()
|| request.effort.is_some()
|| request.service_tier.is_some())
&& matches!(request.mode, SubmissionMode::Queue))
|| !request.input.iter().any(|input| match input {
UserMessageInput::Text { text, .. } => !text.trim().is_empty(),
@@ -213,6 +217,15 @@ impl Client {
}),
model: request.model.clone(),
effort: request.effort.clone(),
service_tier: request.service_tier.as_ref().map(|tier| {
use crate::activation_wire::nullable_string::Value;
crate::activation_wire::NullableString {
value: Some(match tier {
Some(tier) => Value::String(tier.clone()),
None => Value::Null(crate::activation_wire::Empty {}),
}),
}
}),
thread_id: request.thread_id.clone(),
turn_trigger: request.turn_trigger.clone(),
access: request.shared_access.then_some(1),
+16
View File
@@ -13,6 +13,7 @@ fn request() -> AddUserMessageRequest {
turn_trigger: None,
model: None,
effort: None,
service_tier: None,
mode: SubmissionMode::StartIfIdle,
shared_access: true,
}
@@ -45,12 +46,21 @@ async fn submission_preserves_admission_variants_and_wire_identity() {
let mut fixture = fixture(response(vec![frame(&payload)], tonic::Status::ok(""))).await;
let has_selection = !matches!(mode, SubmissionMode::Queue);
let turn_trigger = (mode_number == 3).then(|| "user".to_owned());
let (service_tier, wire_tier) = match (mode_number, outcome_number) {
(3, 1) => (
Some(Some("ultrafast".into())),
message(/*tag*/ 20, &message(/*tag*/ 1, b"ultrafast")),
),
(1, 2) => (Some(None), message(/*tag*/ 20, &message(/*tag*/ 2, b""))),
_ => (None, Vec::new()),
};
let result = fixture
.client
.add_user_message(&AddUserMessageRequest {
turn_trigger,
model: has_selection.then(|| "selected-model".into()),
effort: has_selection.then(|| "high".into()),
service_tier,
mode,
..request()
})
@@ -106,6 +116,7 @@ async fn submission_preserves_admission_variants_and_wire_identity() {
),
),
if has_selection { model } else { vec![] },
wire_tier,
message(/*tag*/ 23, b"t"),
if mode_number == 3 {
message(/*tag*/ 25, b"user")
@@ -295,6 +306,11 @@ async fn invalid_input_never_sends_and_diagnostics_redact() {
model: Some("selected-model".into()),
..request()
},
AddUserMessageRequest {
mode: SubmissionMode::Queue,
service_tier: Some(Some("ultrafast".into())),
..request()
},
AddUserMessageRequest {
input: vec![],
..request()
+2
View File
@@ -11,6 +11,8 @@ pub(super) struct AddRequest {
pub input: Option<Input>,
#[prost(string, optional, tag = "12")]
pub model: Option<String>,
#[prost(message, optional, tag = "20")]
pub service_tier: Option<crate::activation_wire::NullableString>,
#[prost(string, tag = "23")]
pub thread_id: String,
#[prost(string, optional, tag = "25")]
+33
View File
@@ -3016,6 +3016,39 @@ impl App {
AppEvent::PersistRealtimeVoiceSelection { voice } => {
self.persist_realtime_voice(app_server, voice).await;
}
AppEvent::ToggleCloudServiceTier { thread_id, tier } => {
if self.active_thread_id == Some(thread_id)
&& self.chat_widget.thread_id() == Some(thread_id)
&& app_server.thread_params_mode() == crate::ThreadParamsMode::Cloud
{
if !self.chat_widget.current_model_service_tier_commands().iter()
.any(|command| command.id == tier)
{
self.chat_widget.add_error_message(
"The selected Cloud model does not support that speed.".into(),
);
return Ok(AppRunControl::Continue);
}
let current = self.chat_widget.config_ref().service_tier.as_deref();
match app_server.toggle_cloud_service_tier(thread_id, current, tier) {
Some(tier) => {
let label = match tier.as_str() {
codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE => "standard",
"priority" => "fast",
tier => tier,
};
self.chat_widget.add_info_message(
format!("Selected {label} speed for the next new turn in this conversation"),
/*hint*/ None,
);
}
None => self.chat_widget.add_error_message(
"The Cloud account changed. Reopen the conversation before choosing a speed."
.into(),
),
}
}
}
AppEvent::PersistServiceTierSelection { service_tier } => {
self.refresh_status_line();
self.config.service_tier = service_tier.clone();
+28 -20
View File
@@ -103,36 +103,44 @@ impl App {
);
return;
};
if service_tier.is_some()
|| model == crate::model_catalog::LUNA_RESERVE_MODEL
|| !self.model_catalog.models.iter().any(|preset| {
preset.model == model
&& effort.as_ref().is_none_or(|effort| {
*effort == preset.default_reasoning_effort
|| preset
.supported_reasoning_efforts
.iter()
.any(|preset| &preset.effort == effort)
})
&& preset.model_specialty.as_deref() != Some(MODEL_SPECIALTY_CYBER)
})
{
let preset = self.model_catalog.models.iter().find(|preset| {
preset.model == model
&& effort.as_ref().is_none_or(|effort| {
*effort == preset.default_reasoning_effort
|| preset
.supported_reasoning_efforts
.iter()
.any(|preset| &preset.effort == effort)
})
&& preset.model_specialty.as_deref() != Some(MODEL_SPECIALTY_CYBER)
});
let Some(preset) = preset.filter(|_| {
service_tier.is_none() && model != crate::model_catalog::LUNA_RESERVE_MODEL
}) else {
self.chat_widget.add_error_message(
"Choose a supported Cloud model and effort at the current speed.".into(),
);
return;
}
};
let label = Self::reasoning_label_for(&model, effort.as_ref())
.map_or_else(String::new, |label| format!(" {label}"));
let message =
format!("Selected {model}{label} for the next new turn in this conversation");
if app_server.stage_cloud_model_selection(thread_id, model, effort) {
self.chat_widget.add_info_message(message, /*hint*/ None);
} else {
self.chat_widget.add_error_message(
match app_server.stage_cloud_model_selection(thread_id, preset, effort) {
Ok(dropped_tier) => {
self.chat_widget.sync_service_tier_commands();
self.chat_widget.add_info_message(message, /*hint*/ None);
if let Some(tier) = dropped_tier {
let tier = if tier == "priority" { "fast" } else { &tier };
self.chat_widget.add_warning_message(format!(
"{model} does not support {tier} speed. Cleared the pending speed change."
));
}
}
Err(()) => self.chat_widget.add_error_message(
"The Cloud account changed. Reopen the conversation before choosing a model."
.into(),
);
),
}
return;
}
+2
View File
@@ -327,6 +327,8 @@ impl App {
search_dir: PathBuf,
) {
self.chat_widget.snapshot_local_images = self.app_server_target.uses_remote_workspace();
self.chat_widget.thread_settings_owner = app_server.thread_params_mode().settings_owner();
self.chat_widget.pending_cloud_turn_settings = app_server.pending_cloud_turn_settings();
self.chat_widget.set_local_worktree_operations(
!crate::uses_remote_workspace_or_environment(
&self.app_server_target,
@@ -564,6 +564,9 @@ impl App {
chat_widget.config_panel_tui = self.chat_widget.config_panel_tui.clone();
chat_widget.remote_connection = self.chat_widget.remote_connection.clone();
chat_widget.snapshot_local_images = self.app_server_target.uses_remote_workspace();
chat_widget.thread_settings_owner = self.chat_widget.thread_settings_owner;
chat_widget.pending_cloud_turn_settings =
self.chat_widget.pending_cloud_turn_settings.clone();
chat_widget.set_local_worktree_operations(self.chat_widget.local_worktree_operations);
chat_widget.windows_sandbox_local_server = self.chat_widget.windows_sandbox_local_server;
chat_widget.windows_sandbox_host = WindowsSandboxHost::Unknown;
+2
View File
@@ -936,6 +936,8 @@ impl App {
}
chat_widget.remote_connection = remote_connection;
chat_widget.snapshot_local_images = app_server_target.uses_remote_workspace();
chat_widget.thread_settings_owner = app_server.thread_params_mode().settings_owner();
chat_widget.pending_cloud_turn_settings = app_server.pending_cloud_turn_settings();
chat_widget.set_local_worktree_operations(!crate::uses_remote_workspace_or_environment(
&app_server_target,
environment_manager.as_ref(),
@@ -156,6 +156,253 @@ async fn standard_speed_selection_falls_back_for_older_app_servers() -> Result<(
Ok(())
}
#[tokio::test]
async fn cloud_ultrafast_toggle_updates_the_conversation_on_admission() -> Result<()> {
for (current, expected) in [
(None, "ultrafast"),
(Some("default"), "ultrafast"),
(Some("ultrafast"), "default"),
] {
let (mut app, mut events, mut ops) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
let mut session = test_thread_session(thread_id, app.config.cwd.to_path_buf());
session.service_tier = current.map(str::to_owned);
app.chat_widget.handle_thread_session(session);
app.active_thread_id = Some(thread_id);
app.config.service_tier = Some("flex".into());
let (client, mut commands, _server_events) =
codex_app_server_client::CloudClient::channel(/*capacity*/ 1);
let mut server = AppServerSession::new(
crate::backend_id::BackendId::new(),
AppServerClient::Cloud(client),
crate::ThreadParamsMode::Cloud,
);
app.app_server_target = crate::AppServerTarget::Cloud;
app.bind_backend_workspace(&server, app.config.cwd.to_path_buf());
app.chat_widget
.set_feature_enabled(Feature::FastMode, /*enabled*/ false);
app.chat_widget
.set_feature_enabled(Feature::UltrafastMode, /*enabled*/ true);
let mut tui = crate::tui::test_support::make_test_tui()?;
while events.try_recv().is_ok() {}
app.chat_widget.open_model_popup();
let request_id = std::iter::from_fn(|| events.try_recv().ok())
.find_map(|event| match event {
AppEvent::FetchModels { request_id } => Some(request_id),
_ => None,
})
.expect("Cloud catalog request");
let mut model = app.model_catalog.models[0].clone();
model.model = app.chat_widget.current_model().to_owned();
app.chat_widget.local_settings.tui.terminal_title = Some(vec!["fast-mode".into()]);
model.default_service_tier = Some("ultrafast".into());
model.service_tiers = vec![codex_protocol::openai_models::ModelServiceTier {
id: "ultrafast".into(),
name: "Ultrafast".into(),
description: String::new(),
}];
assert!(
app.chat_widget
.on_models_loaded(request_id, Ok(vec![model]))
);
if current.is_none() {
insta::assert_snapshot!(app.chat_widget.last_terminal_title.as_deref().unwrap(), @"Standard");
}
app.chat_widget.handle_key_event(KeyCode::Esc.into());
while events.try_recv().is_ok() {}
app.chat_widget.apply_external_edit("/ultrafast".into());
app.chat_widget.handle_key_event(KeyCode::Esc.into());
app.chat_widget.handle_key_event(KeyCode::Enter.into());
if !app.chat_widget.composer_text_with_pending().is_empty() {
app.chat_widget.handle_key_event(KeyCode::Enter.into());
}
assert_eq!(app.chat_widget.composer_text_with_pending(), "");
let event = std::iter::from_fn(|| events.try_recv().ok())
.find(|event| matches!(event, AppEvent::ToggleCloudServiceTier { .. }))
.expect("Cloud service tier toggle");
Box::pin(app.handle_event(&mut tui, &mut server, event)).await?;
assert!(commands.try_recv().is_err());
assert_eq!(app.chat_widget.current_service_tier(), current);
let messages = std::iter::from_fn(|| events.try_recv().ok())
.filter_map(|event| match event {
AppEvent::InsertHistoryCell(cell) => {
Some(lines_to_single_string(&cell.transcript_lines(/*width*/ 80)))
}
_ => None,
})
.collect::<Vec<_>>();
if expected == "ultrafast" {
insta::allow_duplicates! {
insta::assert_snapshot!(messages.join("\n"), @"• Selected ultrafast speed for the next new turn in this conversation");
}
} else {
insta::assert_snapshot!(messages.join("\n"), @"• Selected standard speed for the next new turn in this conversation");
}
app.chat_widget.apply_external_edit("continue".into());
app.chat_widget.handle_key_event(KeyCode::Esc.into());
app.chat_widget.handle_key_event(KeyCode::Enter.into());
let op = next_user_turn_op(&mut ops);
let admit = async {
let command = commands.recv().await.expect("native admission request");
let CloudOperation::SubmitUserMessage(params) = command.operation else {
panic!("expected native admission");
};
assert_eq!(
(params.model, params.effort, params.service_tier),
(None, None, Some(Some(expected.into()))),
);
let turn = test_turn("cloud-turn", TurnStatus::InProgress, Vec::new());
let receipt = serde_json::to_value(CloudUserMessageReceipt::Started(turn)).unwrap();
command.response.send(Ok(Ok(receipt))).unwrap();
};
let (result, ()) = tokio::join!(
Box::pin(super::super::user_turn_submission::try_submit_user_turn(
&mut app,
&mut server,
thread_id,
&op,
)),
admit,
);
assert!(result?);
assert_eq!(
(
app.chat_widget.current_service_tier(),
app.config.service_tier.as_deref()
),
(Some(expected), Some("flex")),
);
}
Ok(())
}
#[tokio::test]
async fn cloud_speed_tracks_the_model_staged_for_the_next_turn() -> Result<()> {
for (initial, selected, expected_tier) in [
(
"standard-model",
"fast-model",
Some(Some("ultrafast".into())),
),
("fast-model", "standard-model", None),
] {
let (mut app, mut events, mut ops) = make_test_app_with_channels().await;
let (client, mut commands, _server_events) =
codex_app_server_client::CloudClient::channel(/*capacity*/ 1);
let mut server = AppServerSession::new(
crate::backend_id::BackendId::new(),
AppServerClient::Cloud(client),
crate::ThreadParamsMode::Cloud,
);
app.app_server_target = crate::AppServerTarget::Cloud;
app.bind_backend_workspace(&server, app.config.cwd.to_path_buf());
let thread_id = ThreadId::new();
let mut session = test_thread_session(thread_id, app.config.cwd.to_path_buf());
session.model = initial.into();
app.chat_widget.handle_thread_session(session);
app.active_thread_id = Some(thread_id);
app.chat_widget
.set_feature_enabled(Feature::UltrafastMode, /*enabled*/ true);
let mut tui = crate::tui::test_support::make_test_tui()?;
app.chat_widget.open_model_popup();
let request_id = std::iter::from_fn(|| events.try_recv().ok())
.find_map(|event| match event {
AppEvent::FetchModels { request_id } => Some(request_id),
_ => None,
})
.expect("Cloud catalog request");
let mut standard = app.model_catalog.models[0].clone();
standard.id = "standard-model".into();
standard.model = standard.id.clone();
standard.model_specialty = None;
standard.default_service_tier = None;
standard.service_tiers.clear();
let mut fast = standard.clone();
fast.id = "fast-model".into();
fast.model = fast.id.clone();
fast.service_tiers = vec![codex_protocol::openai_models::ModelServiceTier {
id: "ultrafast".into(),
name: "Ultrafast".into(),
description: String::new(),
}];
let event = AppEvent::ModelsLoaded {
request_id,
result: Ok(vec![standard, fast]),
};
Box::pin(app.handle_event(&mut tui, &mut server, event)).await?;
app.chat_widget.handle_key_event(KeyCode::Esc.into());
while events.try_recv().is_ok() {}
let select_model = || AppEvent::ApplyModelSelection {
model: selected.into(),
effort: None,
};
if selected == "fast-model" {
Box::pin(app.handle_event(&mut tui, &mut server, select_model())).await?;
}
app.chat_widget.apply_external_edit("/ultrafast".into());
app.chat_widget.handle_key_event(KeyCode::Esc.into());
app.chat_widget.handle_key_event(KeyCode::Enter.into());
if !app.chat_widget.composer_text_with_pending().is_empty() {
app.chat_widget.handle_key_event(KeyCode::Enter.into());
}
let event = std::iter::from_fn(|| events.try_recv().ok())
.find(|event| matches!(event, AppEvent::ToggleCloudServiceTier { .. }))
.expect("Cloud service tier toggle");
Box::pin(app.handle_event(&mut tui, &mut server, event)).await?;
if selected == "standard-model" {
Box::pin(app.handle_event(&mut tui, &mut server, select_model())).await?;
let warning = std::iter::from_fn(|| events.try_recv().ok())
.filter_map(|event| match event {
AppEvent::InsertHistoryCell(cell) => {
Some(lines_to_single_string(
&cell.transcript_lines(/*width*/ 120),
))
}
_ => None,
})
.find(|line| line.contains("Cleared the pending speed change."))
.expect("speed warning before submission");
insta::assert_snapshot!(warning, @"⚠ standard-model does not support ultrafast speed. Cleared the pending speed change.");
}
assert!(commands.try_recv().is_err());
assert!(app.chat_widget.cloud_picker_selection.is_none());
app.chat_widget.apply_external_edit("continue".into());
app.chat_widget.handle_key_event(KeyCode::Esc.into());
app.chat_widget.handle_key_event(KeyCode::Enter.into());
let op = next_user_turn_op(&mut ops);
let admit = async {
let command = commands.recv().await.expect("native admission request");
let CloudOperation::SubmitUserMessage(params) = command.operation else {
panic!("expected native admission");
};
assert_eq!(
(params.model.as_deref(), params.service_tier),
(Some(selected), expected_tier)
);
let turn = test_turn("cloud-turn", TurnStatus::InProgress, Vec::new());
let receipt = serde_json::to_value(CloudUserMessageReceipt::Started(turn)).unwrap();
command.response.send(Ok(Ok(receipt))).unwrap();
};
let submit = Box::pin(super::super::user_turn_submission::try_submit_user_turn(
&mut app,
&mut server,
thread_id,
&op,
));
let (result, ()) = tokio::time::timeout(std::time::Duration::from_secs(5), async {
tokio::join!(submit, admit)
})
.await?;
assert!(result?);
}
Ok(())
}
#[tokio::test]
async fn cloud_model_selection_waits_for_native_admission() -> Result<()> {
let turn = test_turn("cloud-turn", TurnStatus::InProgress, Vec::new());
@@ -182,6 +429,7 @@ async fn cloud_model_selection_waits_for_native_admission() -> Result<()> {
app.active_thread_id = Some(thread_id);
let original_model = app.chat_widget.current_model().to_string();
let original_effort = app.chat_widget.current_reasoning_effort();
let original_tier = app.chat_widget.current_service_tier().map(str::to_owned);
let (client, mut commands, _server_events) =
codex_app_server_client::CloudClient::channel(/*capacity*/ 1);
let mut server = AppServerSession::new(
@@ -192,6 +440,32 @@ async fn cloud_model_selection_waits_for_native_admission() -> Result<()> {
let mut tui = crate::tui::test_support::make_test_tui()?;
while events.try_recv().is_ok() {}
app.app_server_target = crate::AppServerTarget::Cloud;
app.bind_backend_workspace(&server, app.config.cwd.to_path_buf());
app.chat_widget
.set_feature_enabled(Feature::UltrafastMode, /*enabled*/ true);
app.chat_widget.open_model_popup();
let request_id = std::iter::from_fn(|| events.try_recv().ok())
.find_map(|event| match event {
AppEvent::FetchModels { request_id } => Some(request_id),
_ => None,
})
.expect("Cloud catalog request");
let mut models = app.model_catalog.models.clone();
let selected = models
.iter_mut()
.find(|model| model.model == "gpt-5.6-terra")
.expect("Cloud model");
selected.default_service_tier = Some("ultrafast".into());
selected.service_tiers = vec![codex_protocol::openai_models::ModelServiceTier {
id: "ultrafast".into(),
name: "Ultrafast".into(),
description: String::new(),
}];
assert!(app.chat_widget.on_models_loaded(request_id, Ok(models)));
app.chat_widget.handle_key_event(KeyCode::Esc.into());
while events.try_recv().is_ok() {}
let event = AppEvent::ApplyModelSelection {
model: "gpt-5.6-terra".into(),
effort: Some(ReasoningEffortConfig::High),
@@ -223,8 +497,12 @@ async fn cloud_model_selection_waits_for_native_admission() -> Result<()> {
panic!("expected StartOrSteer");
};
assert_eq!(
(params.model.as_deref(), params.effort),
(Some("gpt-5.6-terra"), Some(ReasoningEffortConfig::High))
(params.model.as_deref(), params.effort, params.service_tier),
(
Some("gpt-5.6-terra"),
Some(ReasoningEffortConfig::High),
None
)
);
let response = receipt.map(|receipt| serde_json::to_value(receipt).unwrap());
command.response.send(Ok(response)).unwrap();
@@ -244,8 +522,13 @@ async fn cloud_model_selection_waits_for_native_admission() -> Result<()> {
(
app.chat_widget.current_model(),
app.chat_widget.current_reasoning_effort(),
app.chat_widget.current_service_tier(),
),
(
original_model.as_str(),
original_effort,
original_tier.as_deref()
),
(original_model.as_str(), original_effort),
);
let input = app.chat_widget.capture_thread_input_state();
let held = input.is_some_and(|input| input.has_unconfirmed_messages());
+10 -2
View File
@@ -94,8 +94,16 @@ pub(super) async fn try_submit_user_turn(
match result {
Ok((admission, selection)) => {
if selection.is_some() {
app.chat_widget.cloud_picker_selection = selection;
if let Some(selection) = selection {
if selection.model.is_some() {
app.chat_widget.cloud_picker_selection = selection.model;
}
let service_tier = selection
.service_tier
.or_else(|| app.chat_widget.configured_service_tier());
app.chat_widget.set_service_tier(service_tier);
app.sync_active_thread_service_tier_to_cached_session()
.await;
}
if !matches!(&admission, UserMessageAdmission::Queued(_)) {
crate::startup_recovery::acknowledged(client_user_message_id);
+6
View File
@@ -1521,6 +1521,12 @@ pub(crate) enum AppEvent {
voice: codex_protocol::protocol::RealtimeVoice,
},
/// Toggle the service tier staged for the next Cloud submission.
ToggleCloudServiceTier {
thread_id: ThreadId,
tier: String,
},
/// Persist the selected service tier to the appropriate config.
PersistServiceTierSelection {
service_tier: Option<String>,
+4 -5
View File
@@ -413,7 +413,7 @@ pub(crate) struct AppServerSession {
pub(crate) model_provider_override: Option<String>,
history_support: ThreadHistorySupport,
pub(crate) thread_settings_update_supported: bool,
cloud_model_selections: HashMap<ThreadId, user_message_submission::CloudModelSelection>,
cloud_turn_settings: user_message_submission::PendingCloudTurnSettings,
default_model: Option<String>,
available_models: Vec<ModelPreset>,
prefetched_model_list: Option<RemoteHandle<Result<Vec<ModelPreset>>>>,
@@ -633,7 +633,7 @@ impl AppServerSession {
model_provider_override: None,
history_support: ThreadHistorySupport::Paginated,
thread_settings_update_supported: true,
cloud_model_selections: HashMap::new(),
cloud_turn_settings: Default::default(),
default_model: None,
available_models: Vec::new(),
prefetched_model_list: None,
@@ -1865,7 +1865,7 @@ impl AppServerSession {
collaboration_mode: Option<codex_protocol::config_types::CollaborationMode>,
output_schema: Option<serde_json::Value>,
cyber_access_program: Option<codex_app_server_protocol::CyberAccessProgram>,
cloud_selection: Option<&user_message_submission::CloudModelSelection>,
cloud_selection: Option<&user_message_submission::CloudTurnSettings>,
) -> Result<TurnStartResponse> {
let mut params = TurnStartParams {
thread_id: thread_id.to_string(),
@@ -1891,8 +1891,7 @@ impl AppServerSession {
params.summary = summary;
params.collaboration_mode = collaboration_mode;
} else if let Some(selection) = cloud_selection {
params.model = Some(selection.model.clone());
params.effort.clone_from(&selection.effort);
selection.apply(&mut params);
}
let request_id = self.next_request_id();
self.request_handle
@@ -1,6 +1,12 @@
//! Route composer input and distinguish admission, rejection, and uncertainty.
//! A duplicate or an unconfirmed response must never trigger an automatic resend.
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::MutexGuard;
use std::sync::PoisonError;
use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_client::CloudUserMessageReceipt;
use codex_app_server_client::TypedRequestError;
@@ -10,6 +16,8 @@ use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo;
use codex_app_server_protocol::TurnError as AppServerTurnError;
use codex_app_server_protocol::TurnStartParams;
use codex_protocol::ThreadId;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::turn_input::CyberAccessProgram;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -83,7 +91,50 @@ pub(crate) struct CloudModelSelection {
pub(crate) effort: Option<ReasoningEffort>,
}
#[derive(Clone, Default)]
pub(crate) struct CloudTurnSettings {
pub(crate) model: Option<CloudModelSelection>,
pub(crate) service_tier: Option<String>,
}
impl CloudTurnSettings {
pub(super) fn apply(&self, params: &mut TurnStartParams) {
if let Some(model) = &self.model {
params.model = Some(model.model.clone());
params.effort.clone_from(&model.effort);
}
params.service_tier = self.service_tier.clone().map(Some);
}
}
/// Pending choices are scoped to one Cloud backend and keyed by conversation.
#[derive(Clone, Default)]
pub(crate) struct PendingCloudTurnSettings(Arc<Mutex<HashMap<ThreadId, CloudTurnSettings>>>);
impl PendingCloudTurnSettings {
fn lock(&self) -> MutexGuard<'_, HashMap<ThreadId, CloudTurnSettings>> {
self.0.lock().unwrap_or_else(PoisonError::into_inner)
}
pub(crate) fn model(&self, thread_id: ThreadId) -> Option<String> {
self.lock()
.get(&thread_id)?
.model
.as_ref()
.map(|selection| selection.model.clone())
}
}
fn same_cloud_speed(first: &str, second: &str) -> bool {
first == second || matches!((first, second), ("fast", "priority") | ("priority", "fast"))
}
impl AppServerSession {
pub(crate) fn pending_cloud_turn_settings(&self) -> Option<PendingCloudTurnSettings> {
(self.thread_params_mode() == ThreadParamsMode::Cloud)
.then(|| self.cloud_turn_settings.clone())
}
fn cloud_model_catalog_is_current(&self) -> bool {
matches!(&self.request_handle,
AppServerRequestHandle::Cloud(handle) if handle.model_catalog_is_current()
@@ -93,15 +144,47 @@ impl AppServerSession {
pub(crate) fn stage_cloud_model_selection(
&mut self,
thread_id: ThreadId,
model: String,
preset: &ModelPreset,
effort: Option<ReasoningEffort>,
) -> bool {
) -> Result<Option<String>, ()> {
if !self.cloud_model_catalog_is_current() {
return false;
return Err(());
}
self.cloud_model_selections
.insert(thread_id, CloudModelSelection { model, effort });
true
let mut pending = self.cloud_turn_settings.lock();
let settings = pending.entry(thread_id).or_default();
settings.model = Some(CloudModelSelection {
model: preset.model.clone(),
effort,
});
let dropped_tier = settings.service_tier.take_if(|tier| {
tier.as_str() != SERVICE_TIER_DEFAULT_REQUEST_VALUE
&& !preset
.service_tiers
.iter()
.any(|available| same_cloud_speed(&available.id, tier))
});
Ok(dropped_tier)
}
pub(crate) fn toggle_cloud_service_tier(
&mut self,
thread_id: ThreadId,
current: Option<&str>,
tier: String,
) -> Option<String> {
if !self.cloud_model_catalog_is_current() {
return None;
}
let mut pending = self.cloud_turn_settings.lock();
let settings = pending.entry(thread_id).or_default();
let current = settings.service_tier.as_deref().or(current);
let selected = if current.is_some_and(|current| same_cloud_speed(current, &tier)) {
SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_owned()
} else {
tier
};
settings.service_tier = Some(selected.clone());
Some(selected)
}
pub(crate) async fn submit_user_message<'a>(
@@ -112,7 +195,7 @@ impl AppServerSession {
active_turn: &mut Option<String>,
preflight: impl FnOnce(&str) -> Result<Option<CyberAccessProgram>, String>,
start_settings: impl FnOnce() -> TurnStartSettings<'a>,
) -> Result<(UserMessageAdmission, Option<CloudModelSelection>), UserMessageSubmissionFailure>
) -> Result<(UserMessageAdmission, Option<CloudTurnSettings>), UserMessageSubmissionFailure>
{
let AppCommand::UserTurn {
turn_trigger,
@@ -180,46 +263,42 @@ impl AppServerSession {
let selection =
if cloud && *turn_trigger == "user" && intent != CloudSubmissionIntent::UseKnownTurn {
self.cloud_model_selections.get(&thread_id).cloned()
self.cloud_turn_settings.lock().get(&thread_id).cloned()
} else {
None
};
if selection.is_some() && !self.cloud_model_catalog_is_current() {
self.cloud_model_selections.remove(&thread_id);
self.cloud_turn_settings.lock().remove(&thread_id);
return Err(UserMessageSubmissionFailure::Preflight(
"The Cloud account changed. Reopen the conversation before sending.".into(),
));
}
let selected_model = selection
.as_ref()
.and_then(|selection| selection.model.as_ref())
.map_or(model.as_str(), |selection| selection.model.as_str());
let program = preflight(selected_model).map_err(UserMessageSubmissionFailure::Preflight)?;
let take_selection = |session: &mut Self| {
selection.as_ref()?;
session
.cloud_model_selections
.remove(&thread_id)
.filter(|_| session.cloud_model_catalog_is_current())
let settings = session.cloud_turn_settings.lock().remove(&thread_id);
settings.filter(|_| session.cloud_model_catalog_is_current())
};
if cloud
&& intent == CloudSubmissionIntent::StartOrSteer
&& let AppServerRequestHandle::Cloud(handle) = &self.request_handle
{
let (model, effort) = selection.as_ref().map_or((None, None), |selection| {
(Some(selection.model.clone()), selection.effort.clone())
});
let result = handle
.submit_user_message(TurnStartParams {
thread_id: thread_id.to_string(),
turn_trigger: model.as_ref().map(|_| turn_trigger.to_string()),
client_user_message_id: Some(client_user_message_id.clone()),
input: items.clone(),
output_schema: final_output_json_schema.clone(),
model,
effort,
..Default::default()
})
.await;
let mut params = TurnStartParams {
thread_id: thread_id.to_string(),
client_user_message_id: Some(client_user_message_id.clone()),
input: items.clone(),
output_schema: final_output_json_schema.clone(),
..Default::default()
};
if let Some(selection) = &selection {
params.turn_trigger = Some(turn_trigger.to_string());
selection.apply(&mut params);
}
let result = handle.submit_user_message(params).await;
let admission = match result {
Ok(CloudUserMessageReceipt::Started(turn)) => {
Ok(UserMessageAdmission::Started(turn.id))
+3
View File
@@ -554,6 +554,8 @@ pub(crate) struct ChatWidget {
model_popup_request_id: Option<uuid::Uuid>,
pub(crate) cloud_picker_selection:
Option<crate::app_server_session::user_message_submission::CloudModelSelection>,
pub(crate) pending_cloud_turn_settings:
Option<crate::app_server_session::user_message_submission::PendingCloudTurnSettings>,
/// An app event has been queued to open the permissions picker, but no view exists yet.
pub(crate) permission_popup_pending: bool,
permission_popup_request_id: Option<uuid::Uuid>,
@@ -570,6 +572,7 @@ pub(crate) struct ChatWidget {
pub(crate) remote_connection: Option<RemoteConnectionStatus>,
/// Remote app servers cannot read image paths on the TUI host.
pub(crate) snapshot_local_images: bool,
pub(crate) thread_settings_owner: crate::app_server_session::ThreadSettingsOwner,
pending_image_submission: Option<image_submission::PendingImageSubmission>,
pub(crate) local_worktree_operations: bool,
pub(crate) windows_sandbox_local_server: bool,
@@ -135,6 +135,7 @@ impl ChatWidget {
model_catalog,
model_popup_request_id: None,
cloud_picker_selection: None,
pending_cloud_turn_settings: None,
permission_popup_pending: false,
permission_popup_request_id: None,
permission_discovery: None,
@@ -148,6 +149,7 @@ impl ChatWidget {
status_account_display,
remote_connection: None,
snapshot_local_images: false,
thread_settings_owner: crate::app_server_session::ThreadSettingsOwner::Client,
pending_image_submission: None,
local_worktree_operations: true,
windows_sandbox_local_server: false,
+36 -10
View File
@@ -3,6 +3,7 @@
use super::ChatWidget;
use crate::app_command::AppCommand;
use crate::app_event::AppEvent;
use crate::app_server_session::ThreadSettingsOwner;
use crate::bottom_pane::slash_commands::ServiceTierCommand;
use crate::service_tier_resolution;
use codex_features::Feature;
@@ -48,15 +49,23 @@ impl ChatWidget {
let Some(fast_tier) = self.current_model_fast_service_tier() else {
return;
};
let next_tier = if self.current_service_tier() == Some(fast_tier.id.as_str()) {
Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string())
} else {
Some(fast_tier.id)
};
self.set_service_tier_selection(next_tier);
self.toggle_service_tier_from_ui(fast_tier);
}
pub(crate) fn toggle_service_tier_from_ui(&mut self, command: ServiceTierCommand) {
if self.thread_settings_owner == ThreadSettingsOwner::Server {
if let Some(thread_id) = self.thread_id {
self.app_event_tx.send(AppEvent::ToggleCloudServiceTier {
thread_id,
tier: command.id,
});
} else {
self.add_error_message(
"Open the Cloud conversation before choosing a speed.".into(),
);
}
return;
}
let next_tier = if self.current_service_tier() == Some(command.id.as_str()) {
Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string())
} else {
@@ -65,15 +74,20 @@ impl ChatWidget {
self.set_service_tier_selection(next_tier);
}
pub(super) fn sync_service_tier_commands(&mut self) {
pub(crate) fn sync_service_tier_commands(&mut self) {
let commands = self.current_model_service_tier_commands();
self.bottom_pane
.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<ServiceTierCommand> {
let model = self.current_model();
pub(crate) fn current_model_service_tier_commands(&self) -> Vec<ServiceTierCommand> {
let pending_model = self
.thread_id
.and_then(|id| self.pending_cloud_turn_settings.as_ref()?.model(id));
let model = pending_model
.as_deref()
.unwrap_or_else(|| self.service_tier_model());
self.model_catalog
.try_list_models()
.ok()
@@ -122,11 +136,23 @@ impl ChatWidget {
.find(|tier| tier.name.eq_ignore_ascii_case(SPEED_TIER_FAST))
}
pub(super) fn service_tier_model(&self) -> &str {
self.cloud_picker_selection.as_ref().map_or_else(
|| self.current_model(),
|selection| selection.model.as_str(),
)
}
pub(super) fn refresh_effective_service_tier(&mut self) {
if self.thread_settings_owner == ThreadSettingsOwner::Server {
self.effective_service_tier
.clone_from(&self.config.service_tier);
return;
}
self.effective_service_tier = service_tier_resolution::effective_service_tier(
&self.config,
&self.local_settings.notices,
self.current_model(),
self.service_tier_model(),
&self.model_catalog.try_list_models().unwrap_or_default(),
);
}
@@ -1002,7 +1002,7 @@ impl ChatWidget {
self.model_catalog
.models
.iter()
.find(|preset| preset.model == self.current_model())
.find(|preset| preset.model == self.service_tier_model())
.and_then(|preset| preset.service_tiers.iter().find(|tier| tier.id == id))
.map(|tier| tier.name.as_str())
.or_else(|| {