Add a turn-scoped service tier override (#40616)

## What changed

- Add `serviceTierForTurn` to `turn/start` so a newly started turn can override the service tier without changing the thread's saved tier.
- Treat `"default"` as standard speed and omitted or `null` values as inheriting the thread tier.
- Keep `serviceTier` authoritative for subsequent turns when both fields are supplied, and ignore the turn-scoped override when steering an active turn.

## Testing

- Extend the app-server turn-start test to verify both the one-turn `"default"` override and inheritance on the following turn.

GitOrigin-RevId: 4c2c4db21d8d31617a7a6e6df65bf54834e72383
This commit is contained in:
chess
2026-08-25 14:04:32 +00:00
committed by copyberry
parent d7510aa4b4
commit ed42068c45
21 changed files with 86 additions and 3 deletions
@@ -5246,6 +5246,13 @@
"null"
]
},
"serviceTierForTurn": {
"description": "Override the service tier only when this request starts a new turn. Use \"default\" for standard speed. Omitted or null inherits the thread's tier. Does not change the thread's tier or a turn being steered.",
"type": [
"string",
"null"
]
},
"summary": {
"anyOf": [
{
@@ -23939,6 +23939,13 @@
"null"
]
},
"serviceTierForTurn": {
"description": "Override the service tier only when this request starts a new turn. Use \"default\" for standard speed. Omitted or null inherits the thread's tier. Does not change the thread's tier or a turn being steered.",
"type": [
"string",
"null"
]
},
"summary": {
"anyOf": [
{
@@ -21674,6 +21674,13 @@
"null"
]
},
"serviceTierForTurn": {
"description": "Override the service tier only when this request starts a new turn. Use \"default\" for standard speed. Omitted or null inherits the thread's tier. Does not change the thread's tier or a turn being steered.",
"type": [
"string",
"null"
]
},
"summary": {
"anyOf": [
{
@@ -655,6 +655,13 @@
"null"
]
},
"serviceTierForTurn": {
"description": "Override the service tier only when this request starts a new turn. Use \"default\" for standard speed. Omitted or null inherits the thread's tier. Does not change the thread's tier or a turn being steered.",
"type": [
"string",
"null"
]
},
"summary": {
"anyOf": [
{
@@ -30,6 +30,11 @@ model?: string | null, /**
* Override the service tier for this turn and subsequent turns.
*/
serviceTier?: string | null | null, /**
* Override the service tier only when this request starts a new turn.
* Use "default" for standard speed. Omitted or null inherits the thread's tier.
* Does not change the thread's tier or a turn being steered.
*/
serviceTierForTurn?: string | null, /**
* Override the reasoning effort for this turn and subsequent turns.
*/
effort?: ReasoningEffort | null, /**
@@ -4725,6 +4725,7 @@ fn turn_start_params_preserve_explicit_null_service_tier() {
permissions: None,
model: None,
service_tier: None,
service_tier_for_turn: None,
effort: None,
summary: None,
output_schema: None,
@@ -131,6 +131,11 @@ pub struct TurnStartParams {
)]
#[ts(optional = nullable)]
pub service_tier: Option<Option<String>>,
/// Override the service tier only when this request starts a new turn.
/// Use "default" for standard speed. Omitted or null inherits the thread's tier.
/// Does not change the thread's tier or a turn being steered.
#[ts(optional = nullable)]
pub service_tier_for_turn: Option<String>,
/// Override the reasoning effort for this turn and subsequent turns.
#[ts(optional = nullable)]
pub effort: Option<ReasoningEffort>,
+2
View File
@@ -903,6 +903,8 @@ The `audio` variant accepts data URLs. Other URL schemes are rejected. `localAud
You can optionally specify config overrides on the new turn. If specified, these settings become the default for subsequent turns on the same thread. `outputSchema` applies only to the current turn. Experimental `environments` is turn-scoped: omit it to inherit the thread's sticky environments, pass `[]` to run the turn with no environments, or pass explicit environment ids to override the sticky selection for this turn only.
`serviceTierForTurn` overrides the tier only when the request starts a new turn, without changing the thread's saved tier. Use `"default"` for standard speed, or omit it (or pass `null`) to inherit the thread's tier. It is ignored when the request steers an active turn. The existing `serviceTier` field still changes the tier for subsequent turns, including when both fields are supplied.
`approvalsReviewer` accepts:
- `"user"` — default. Review approval requests directly in the client.
@@ -673,6 +673,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> {
approvals_reviewer: None,
model: None,
service_tier: None,
service_tier_for_turn: None,
effort: None,
summary: None,
personality: None,
@@ -540,6 +540,7 @@ impl TurnRequestProcessor {
.with_thread_settings(thread_settings)
.on_start(TurnStartOptions {
final_output_json_schema: params.output_schema,
service_tier: params.service_tier_for_turn,
..Default::default()
})
.with_additional_context(additional_context)
@@ -709,7 +709,7 @@ async fn turn_start_sends_service_tier_id_to_model_request() -> Result<()> {
responses::ev_assistant_message("msg-1", "Done"),
responses::ev_completed("resp-1"),
]);
let response_mock = responses::mount_sse_once(&server, body).await;
let response_mock = responses::mount_sse_once(&server, body.clone()).await;
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
@@ -736,7 +736,7 @@ async fn turn_start_sends_service_tier_id_to_model_request() -> Result<()> {
.request(|request_id| ClientRequest::TurnStart {
request_id,
params: TurnStartParams {
thread_id: thread.id,
thread_id: thread.id.clone(),
service_tier: Some(Some(service_tier_id.clone())),
input: vec![V2UserInput::Text {
text: "Hello".to_string(),
@@ -757,6 +757,31 @@ async fn turn_start_sends_service_tier_id_to_model_request() -> Result<()> {
json!(service_tier_id)
);
for (service_tier_for_turn, expected_service_tier) in [
(Some("default".to_string()), None),
(None, Some(json!(service_tier_id))),
] {
let response_mock = responses::mount_sse_once(&server, body.clone()).await;
mcp.start_turn_and_wait_for_completion(TurnStartParams {
thread_id: thread.id.clone(),
service_tier_for_turn,
input: vec![V2UserInput::Text {
text: "Hello again".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
assert_eq!(
response_mock
.single_request()
.body_json()
.get("service_tier")
.cloned(),
expected_service_tier
);
}
Ok(())
}
@@ -2759,6 +2784,7 @@ async fn turn_start_explicit_local_environment_updates_legacy_cwd_between_turns(
effort: Some(ReasoningEffort::Medium),
summary: Some(ReasoningSummary::Auto),
service_tier: None,
service_tier_for_turn: None,
personality: None,
output_schema: None,
collaboration_mode: None,
@@ -2802,6 +2828,7 @@ async fn turn_start_explicit_local_environment_updates_legacy_cwd_between_turns(
effort: Some(ReasoningEffort::Medium),
summary: Some(ReasoningSummary::Auto),
service_tier: None,
service_tier_for_turn: None,
personality: None,
output_schema: None,
collaboration_mode: None,
+1
View File
@@ -214,6 +214,7 @@ pub(crate) async fn run_codex_thread_one_shot(
.submit_turn_input(
TurnInputRequest::user_input(input).on_start(TurnStartOptions {
final_output_json_schema,
service_tier: None,
parent_turn_id: Some(parent_turn_id),
root_turn_id,
}),
@@ -1179,6 +1179,7 @@ async fn run_review_on_session(
})
.on_start(TurnStartOptions {
final_output_json_schema: Some(params.schema.clone()),
service_tier: None,
parent_turn_id: Some(parent_turn.sub_id.clone()),
root_turn_id: parent_turn.turn_metadata_state.root_turn_id(),
}),
+1
View File
@@ -602,6 +602,7 @@ pub(crate) struct SessionSettingsUpdate {
pub(crate) collaboration_mode: Option<CollaborationMode>,
pub(crate) reasoning_summary: Option<ReasoningSummaryConfig>,
pub(crate) service_tier: Option<Option<String>>,
pub(crate) service_tier_for_turn: Option<String>,
pub(crate) final_output_json_schema: Option<Option<Value>>,
pub(crate) personality: Option<Personality>,
pub(crate) app_server_client_name: Option<String>,
+5 -1
View File
@@ -788,7 +788,7 @@ impl Session {
};
let (
session_configuration,
mut session_configuration,
mcp_inputs_changed,
permission_profile_changed,
previous_config,
@@ -817,6 +817,10 @@ impl Session {
self.refresh_managed_network_proxy_for_current_permission_profile()
.await;
}
// Apply the override only to the turn's copy, after persisting thread settings.
if let Some(service_tier) = updates.service_tier_for_turn {
session_configuration.service_tier = Some(service_tier);
}
Ok(self
.new_turn_from_configuration(
sub_id,
+2
View File
@@ -98,12 +98,14 @@ impl PreparedTurnInputSettings {
) -> CodexResult<Arc<TurnContext>> {
let TurnStartOptions {
final_output_json_schema,
service_tier,
parent_turn_id,
root_turn_id,
} = self.start_options;
let emit_thread_settings_applied = self.thread_settings_update.is_some();
let mut updates = self.thread_settings_update.unwrap_or_default();
updates.final_output_json_schema = Some(final_output_json_schema);
updates.service_tier_for_turn = service_tier;
// new_turn_with_sub_id already emits an error event when settings are invalid.
let turn_context = session
+1
View File
@@ -986,6 +986,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> {
permissions: None,
model: None,
service_tier: None,
service_tier_for_turn: None,
effort: default_effort,
summary: None,
personality: None,
+2
View File
@@ -146,6 +146,8 @@ pub struct TurnStartOptions {
/// Structured-output schema for a new turn. When steering, Core rejects
/// the input if the active turn uses a different schema.
pub final_output_json_schema: Option<Value>,
/// Service tier for a new turn, without changing the thread's preference.
pub service_tier: Option<String>,
/// Parent turn lineage recorded if this request starts a new turn.
pub parent_turn_id: Option<String>,
/// Causal root turn lineage recorded if this request starts a new turn.
+1
View File
@@ -1214,6 +1214,7 @@ impl AppServerSession {
permissions,
model: Some(model),
service_tier,
service_tier_for_turn: None,
effort,
summary,
personality,