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,
})