Gate reasoning effort updates on explicit model support (#46530)

## Why

Using Responses Lite does not establish whether a model accepts reasoning-effort `configuration_update` items. Unsupported models need to use the selected request-level effort, including when resuming history containing saved updates.

## What changed

- Add `supports_reasoning_effort_updates` model metadata, defaulting to `false`.
- Require the override feature, an OpenAI provider, and explicit model support for reasoning effort updates, independently of `use_responses_lite`.
- Filter saved configuration updates from unsupported requests without changing persisted history.
- Clear the pinned effort baseline when sampling with an unsupported model so returning to a supported model establishes a fresh baseline. Compaction uses the selected effort without clearing the pin.

## Testing

Add coverage for supported models with either Responses mode, unsupported-model history filtering over HTTP and WebSocket, switching to an unsupported model and back, and compaction versus sampling pin behavior. Verify missing capability metadata defaults to `false`.

GitOrigin-RevId: 8a0fbc141964bd854dbcdd6f31e1c8e97697cf22
This commit is contained in:
felixxia-oai
2026-09-19 00:18:56 +00:00
committed by copyberry
parent 1537497e52
commit 78d4d983d3
17 changed files with 281 additions and 43 deletions
@@ -67,6 +67,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo {
supports_search_tool: false,
supports_experimental_context: false,
use_responses_lite: false,
supports_reasoning_effort_updates: false,
guardian: None,
node_repl_auto_review_required: false,
node_repl_disabled: false,
@@ -155,6 +155,7 @@ async fn models_client_hits_models_endpoint() {
supports_search_tool: false,
supports_experimental_context: false,
use_responses_lite: false,
supports_reasoning_effort_updates: true,
guardian: None,
node_repl_auto_review_required: true,
node_repl_disabled: true,
+5 -3
View File
@@ -528,8 +528,10 @@ impl ModelClient {
}
}
pub(crate) fn reasoning_effort_override_enabled(&self) -> bool {
pub(crate) fn reasoning_effort_override_enabled(&self, model_info: &ModelInfo) -> bool {
self.state.reasoning_effort_override_enabled
&& self.state.provider.info().is_openai()
&& model_info.supports_reasoning_effort_updates
}
pub(crate) fn with_restored_history(mut self, restored_history: bool) -> Self {
@@ -867,8 +869,8 @@ impl ModelClient {
responses_metadata: &CodexResponsesMetadata,
) -> Result<ResponsesApiRequest> {
let mut input = prompt.get_formatted_input_for_request(model_info);
if !self.state.reasoning_effort_override_enabled {
// Disabling overrides must also recover threads with saved updates.
if !self.reasoning_effort_override_enabled(model_info) {
// Unsupported models and disabled overrides must also accept saved history.
// Filter only the request copy; persisted history remains unchanged.
input.retain(|item| !matches!(item, ResponseItem::ConfigurationUpdate { .. }));
}
@@ -4,6 +4,7 @@
//! baseline while it is retained.
//! Successful compaction retires the overrides and allows a fresh request baseline.
//! Synchronous Guardian reviewers always use their selected request-level effort.
//! Unsupported models use selected request effort without rewriting saved updates.
use super::session::Session;
use super::step_context::StepContext;
@@ -26,7 +27,7 @@ impl Session {
/// Establishes the selected effort in surviving history, independent of replayed settings.
pub(crate) async fn record_reasoning_effort_override(&self, step_context: &StepContext) {
let settings = &step_context.settings;
let Some(effort) = self.effort_for_configuration_update(settings).await else {
let Some(effort) = self.effort_for_configuration_update(settings) else {
return;
};
let should_skip = {
@@ -99,8 +100,11 @@ impl Session {
if !self
.services
.model_client
.reasoning_effort_override_enabled()
.reasoning_effort_override_enabled(&settings.model_info)
{
if usage == RequestEffortUsage::Sampling {
self.state.lock().await.reasoning_effort_pin = ReasoningEffortPin::Unset;
}
return selected_effort;
}
if usage == RequestEffortUsage::Compaction
@@ -113,7 +117,7 @@ impl Session {
{
return Some(pinned);
}
let effort = self.effort_for_configuration_update(settings).await;
let effort = self.effort_for_configuration_update(settings);
let mut state = self.state.lock().await;
let Some(effort) = effort else {
if usage == RequestEffortUsage::Sampling {
@@ -130,16 +134,14 @@ impl Session {
})
}
async fn effort_for_configuration_update(
fn effort_for_configuration_update(
&self,
settings: &ResolvedStepSettings,
) -> Option<ReasoningEffort> {
if !self
.services
.model_client
.reasoning_effort_override_enabled()
|| !settings.model_info.use_responses_lite
|| !self.provider().await.is_openai()
.reasoning_effort_override_enabled(&settings.model_info)
{
return None;
}
@@ -33,7 +33,7 @@ async fn initial_replay_preserves_prewarmed_effort(history: InitialHistory) {
)
.await;
let mut model_info = Arc::clone(&turn_context.initial_settings.model_info);
Arc::make_mut(&mut model_info).use_responses_lite = true;
Arc::make_mut(&mut model_info).supports_reasoning_effort_updates = true;
let mut selected = turn_context.initial_settings.selected().clone();
selected.collaboration_mode.settings.reasoning_effort = Some(ReasoningEffort::Medium);
let prewarm_settings = ResolvedStepSettings::new(
@@ -87,7 +87,7 @@ async fn compaction_effort_lookup_preserves_pin_for_fallback_models() {
let effort = ReasoningEffort::Medium;
let model = Arc::make_mut(&mut settings.model_info);
model.slug = "fallback".to_string();
model.use_responses_lite = true;
model.supports_reasoning_effort_updates = true;
model.default_reasoning_level = Some(effort.clone());
assert_eq!(
@@ -106,3 +106,57 @@ async fn compaction_effort_lookup_preserves_pin_for_fallback_models() {
Some(ReasoningEffort::Low)
);
}
#[tokio::test]
async fn unsupported_model_compaction_uses_selected_effort_without_mutating_pin() {
let (session, turn_context, _events) = make_session_and_context_with_auth_and_config_and_rx(
CodexAuth::from_api_key("Test API Key"),
Vec::new(),
|config| {
config
.features
.enable(Feature::ReasoningEffortOverride)
.unwrap();
config.model_reasoning_effort = Some(ReasoningEffort::High);
},
)
.await;
let mut settings = (*turn_context.initial_settings).clone();
Arc::make_mut(&mut settings.model_info).supports_reasoning_effort_updates = false;
session
.state
.lock()
.await
.reasoning_effort_pin
.pin(&settings.model_info.slug, ReasoningEffort::Low);
assert_eq!(
session
.reasoning_effort_for_request(&settings, RequestEffortUsage::Compaction)
.await,
Some(ReasoningEffort::High),
);
assert_eq!(
session
.state
.lock()
.await
.reasoning_effort_pin
.get(&settings.model_info.slug),
Some(ReasoningEffort::Low),
);
// Sampling retires the old baseline, so a later supported request starts afresh.
assert_eq!(
session
.reasoning_effort_for_request(&settings, RequestEffortUsage::Sampling)
.await,
Some(ReasoningEffort::High),
);
Arc::make_mut(&mut settings.model_info).supports_reasoning_effort_updates = true;
assert_eq!(
session
.reasoning_effort_for_request(&settings, RequestEffortUsage::Sampling)
.await,
Some(ReasoningEffort::High),
);
}
+1
View File
@@ -615,6 +615,7 @@ fn remote_model_with_auto_review_override(slug: &str, review_model: &str) -> Mod
supports_search_tool: false,
supports_experimental_context: false,
use_responses_lite: false,
supports_reasoning_effort_updates: false,
node_repl_auto_review_required: false,
node_repl_disabled: false,
auto_review_model_override: Some(review_model.to_string()),
+1
View File
@@ -1038,6 +1038,7 @@ async fn reasoning_effort_override_remote_v2_compaction_resets_pinned_effort(
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_model_info_override("gpt-5.4", |model| {
model.use_responses_lite = true;
model.supports_reasoning_effort_updates = true;
})
.with_config(move |config| {
config.model_provider.stream_max_retries = Some(0);
@@ -126,6 +126,7 @@ fn test_model_info(
supports_search_tool: false,
supports_experimental_context: false,
use_responses_lite: false,
supports_reasoning_effort_updates: false,
guardian: None,
node_repl_auto_review_required: false,
node_repl_disabled: false,
@@ -1253,6 +1254,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result<
supports_search_tool: false,
supports_experimental_context: false,
use_responses_lite: false,
supports_reasoning_effort_updates: false,
guardian: None,
node_repl_auto_review_required: false,
node_repl_disabled: false,
@@ -679,6 +679,7 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo {
supports_search_tool: false,
supports_experimental_context: false,
use_responses_lite: false,
supports_reasoning_effort_updates: false,
guardian: None,
node_repl_auto_review_required: false,
node_repl_disabled: false,
@@ -43,6 +43,7 @@ fn override_builder() -> TestCodexBuilder {
test_codex()
.with_model_info_override("gpt-5.4", |model| {
model.use_responses_lite = true;
model.supports_reasoning_effort_updates = true;
})
.with_config(|config| {
config
@@ -354,9 +355,12 @@ async fn reasoning_effort_override_persistent_transitions(
Ok(())
}
#[test_case(true; "responses lite")]
#[test_case(false; "ordinary responses")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reasoning_effort_override_preserves_prefix_and_only_appends_on_change()
-> anyhow::Result<()> {
async fn reasoning_effort_override_preserves_prefix_and_only_appends_on_change(
use_responses_lite: bool,
) -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let mut mocks = Vec::new();
@@ -366,7 +370,12 @@ async fn reasoning_effort_override_preserves_prefix_and_only_appends_on_change()
.await,
);
}
let test = override_builder().build_with_auto_env(&server).await?;
let test = override_builder()
.with_model_info_override("gpt-5.4", move |model| {
model.use_responses_lite = use_responses_lite;
})
.build_with_auto_env(&server)
.await?;
test.submit_text_turn("first message").await?;
submit_thread_settings(
&test.codex,
@@ -718,8 +727,12 @@ async fn reasoning_effort_override_websocket_appends_then_replays_after_reconnec
Ok(())
}
#[test_case(OverrideUnavailable::FeatureDisabled; "feature disabled")]
#[test_case(OverrideUnavailable::ModelUnsupported; "unsupported model")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reasoning_effort_override_disabled_filters_websocket_history() -> anyhow::Result<()> {
async fn reasoning_effort_override_unavailable_filters_websocket_history(
unavailable: OverrideUnavailable,
) -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let initial_mock = responses::mount_sse_once(
@@ -747,11 +760,7 @@ async fn reasoning_effort_override_disabled_filters_websocket_history() -> anyho
]])
.await;
let base_url = format!("{}/v1", websocket.uri());
let mut builder = override_builder().with_config(move |config| {
config
.features
.disable(Feature::ReasoningEffortOverride)
.expect("disable overrides");
let mut builder = unavailable.builder().with_config(move |config| {
config.model_reasoning_effort = Some(ReasoningEffort::High);
config.model_provider.base_url = Some(base_url);
config.model_provider.supports_websockets = true;
@@ -788,12 +797,33 @@ async fn reasoning_effort_override_disabled_filters_websocket_history() -> anyho
enum OverrideUnavailable {
FeatureDisabled,
NonOpenAiProvider,
ResponsesLiteDisabled,
ModelUnsupported,
}
impl OverrideUnavailable {
fn builder(self) -> TestCodexBuilder {
match self {
Self::FeatureDisabled => override_builder().with_config(|config| {
config
.features
.disable(Feature::ReasoningEffortOverride)
.expect("disable overrides");
}),
Self::NonOpenAiProvider => override_builder().with_config(|config| {
config.model_provider.name = "unsupported provider".into();
}),
Self::ModelUnsupported => {
override_builder().with_model_info_override("gpt-5.4", |model| {
model.supports_reasoning_effort_updates = false;
})
}
}
}
}
#[test_case(OverrideUnavailable::FeatureDisabled; "feature disabled")]
#[test_case(OverrideUnavailable::NonOpenAiProvider; "unsupported provider")]
#[test_case(OverrideUnavailable::ResponsesLiteDisabled; "responses lite disabled")]
#[test_case(OverrideUnavailable::ModelUnsupported; "unsupported model")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reasoning_effort_override_unavailable_uses_request_effort(
unavailable: OverrideUnavailable,
@@ -810,20 +840,7 @@ async fn reasoning_effort_override_unavailable_uses_request_effort(
responses::sse(vec![responses::ev_completed("second")]),
)
.await;
let mut builder = match unavailable {
OverrideUnavailable::FeatureDisabled => override_builder().with_config(|config| {
config
.features
.disable(Feature::ReasoningEffortOverride)
.expect("disable overrides");
}),
OverrideUnavailable::NonOpenAiProvider => override_builder().with_config(|config| {
config.model_provider.name = "unsupported provider".into();
}),
OverrideUnavailable::ResponsesLiteDisabled => override_builder()
.with_model_info_override("gpt-5.4", |model| model.use_responses_lite = false),
};
let test = builder.build_with_auto_env(&server).await?;
let test = unavailable.builder().build_with_auto_env(&server).await?;
test.submit_text_turn("first").await?;
submit_thread_settings(
&test.codex,
@@ -846,8 +863,12 @@ async fn reasoning_effort_override_unavailable_uses_request_effort(
Ok(())
}
#[test_case(OverrideUnavailable::FeatureDisabled; "feature disabled")]
#[test_case(OverrideUnavailable::ModelUnsupported; "unsupported model")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reasoning_effort_override_disabled_recovers_saved_history() -> anyhow::Result<()> {
async fn reasoning_effort_override_unavailable_recovers_saved_history(
unavailable: OverrideUnavailable,
) -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let reply = |id, text| {
@@ -891,13 +912,10 @@ async fn reasoning_effort_override_disabled_recovers_saved_history() -> anyhow::
})
.await;
let chatgpt_base_url = format!("{}/backend-api", server.uri());
let mut builder = override_builder()
let mut builder = unavailable
.builder()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_config(move |config| {
config
.features
.disable(Feature::ReasoningEffortOverride)
.expect("disable overrides");
config.model_reasoning_effort = Some(ReasoningEffort::High);
config.chatgpt_base_url = chatgpt_base_url;
});
@@ -984,6 +1002,7 @@ async fn reasoning_effort_override_model_switch_reestablishes_selected_effort(
.with_model_info_override("gpt-5.5", |model| {
model.comp_hash = None;
model.use_responses_lite = true;
model.supports_reasoning_effort_updates = true;
})
.with_model("gpt-5.4")
.build_with_auto_env(&server)
@@ -1021,6 +1040,145 @@ async fn reasoning_effort_override_model_switch_reestablishes_selected_effort(
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reasoning_effort_override_unsupported_model_round_trip() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let test = override_builder()
.with_model_info_override("gpt-6-astra", |model| {
model.comp_hash = None;
model.use_responses_lite = true;
model.supports_reasoning_effort_updates = true;
})
.with_model_info_override("gpt-5.5", |model| {
model.comp_hash = None;
// Lite alone is not sufficient: unsupported models must still filter updates.
model.use_responses_lite = true;
model.supports_reasoning_effort_updates = false;
})
.with_model("gpt-6-astra")
.build_with_auto_env(&server)
.await?;
let turns = [
("gpt-6-astra", ReasoningEffort::Medium, "Start at medium."),
(
"gpt-6-astra",
ReasoningEffort::High,
"Raise effort to high.",
),
(
"gpt-5.5",
ReasoningEffort::High,
"Switch to the unsupported model.",
),
("gpt-5.5", ReasoningEffort::Low, "Lower effort to low."),
(
"gpt-6-astra",
ReasoningEffort::Low,
"Return to the supported model.",
),
("gpt-6-astra", ReasoningEffort::High, "Raise effort again."),
];
let mut requests = Vec::new();
for (index, (model, effort, prompt)) in turns.iter().enumerate() {
let id = format!("turn-{index}");
let mock = responses::mount_sse_once(
&server,
responses::sse(vec![
responses::ev_assistant_message(&id, "Acknowledged."),
responses::ev_completed(&id),
]),
)
.await;
submit_thread_settings(
&test.codex,
ThreadSettingsOverrides {
model: Some((*model).to_string()),
effort: Some(Some(effort.clone())),
..Default::default()
},
)
.await?;
test.submit_text_turn(prompt).await?;
requests.push(mock.single_request());
}
let initial_updates = vec![
effort_update(ReasoningEffort::Medium),
effort_update(ReasoningEffort::High),
];
let mut returned_updates = initial_updates.clone();
returned_updates.push(effort_update(ReasoningEffort::Low));
let mut final_updates = returned_updates.clone();
final_updates.push(effort_update(ReasoningEffort::High));
assert_eq!(
requests
.iter()
.map(|request| {
let body = request.body_json();
(
body["model"].clone(),
body["reasoning"]["effort"].clone(),
effort_updates(request),
)
})
.collect::<Vec<_>>(),
vec![
(
Value::from("gpt-6-astra"),
Value::from("medium"),
vec![effort_update(ReasoningEffort::Medium)]
),
(
Value::from("gpt-6-astra"),
Value::from("medium"),
initial_updates
),
(Value::from("gpt-5.5"), Value::from("high"), vec![]),
(Value::from("gpt-5.5"), Value::from("low"), vec![]),
(
Value::from("gpt-6-astra"),
Value::from("low"),
returned_updates
),
(
Value::from("gpt-6-astra"),
Value::from("low"),
final_updates.clone()
),
],
);
for (index, request) in requests.iter().enumerate() {
assert_eq!(
request
.message_input_texts("user")
.into_iter()
.filter(|text| { turns.iter().any(|(_, _, prompt)| text.as_str() == *prompt) })
.collect::<Vec<_>>(),
turns[..=index]
.iter()
.map(|(_, _, prompt)| prompt.to_string())
.collect::<Vec<_>>(),
);
}
test.codex.shutdown_and_wait().await?;
let saved_updates = test
.codex
.load_history(/*include_archived*/ false)
.await?
.items
.into_iter()
.filter_map(|item| match item {
RolloutItem::ResponseItem(ResponseItemEnvelope {
item: item @ ResponseItem::ConfigurationUpdate { .. },
..
}) => Some(serde_json::to_value(item).expect("serialize saved update")),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(saved_updates, final_updates);
Ok(())
}
#[test_case(ReasoningEffort::High; "unchanged effort")]
#[test_case(ReasoningEffort::Medium; "changed effort")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -1129,6 +1287,7 @@ async fn reasoning_effort_override_compaction_fallback_uses_each_models_effort()
.with_model_info_override("gpt-5.2", |model| {
model.comp_hash = Some("fallback".to_string());
model.use_responses_lite = true;
model.supports_reasoning_effort_updates = true;
})
.with_model("gpt-5.4")
.with_config(move |config| {
@@ -584,6 +584,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> {
supports_search_tool: false,
supports_experimental_context: false,
use_responses_lite: false,
supports_reasoning_effort_updates: false,
guardian: None,
node_repl_auto_review_required: false,
node_repl_disabled: false,
@@ -858,6 +859,7 @@ async fn remote_models_apply_legacy_instructions(auth: CodexAuth) -> Result<()>
supports_search_tool: false,
supports_experimental_context: false,
use_responses_lite: false,
supports_reasoning_effort_updates: false,
guardian: None,
node_repl_auto_review_required: false,
node_repl_disabled: false,
@@ -1440,6 +1442,7 @@ fn test_remote_model_with_policy(
supports_search_tool: false,
supports_experimental_context: false,
use_responses_lite: false,
supports_reasoning_effort_updates: false,
guardian: None,
node_repl_auto_review_required: false,
node_repl_disabled: false,
+1
View File
@@ -3008,6 +3008,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re
supports_search_tool: false,
supports_experimental_context: false,
use_responses_lite: false,
supports_reasoning_effort_updates: false,
guardian: None,
node_repl_auto_review_required: false,
node_repl_disabled: false,
@@ -92,6 +92,7 @@ fn test_model_info(
supports_search_tool: false,
supports_experimental_context: false,
use_responses_lite: false,
supports_reasoning_effort_updates: false,
guardian: None,
node_repl_auto_review_required: false,
node_repl_disabled: false,
+1
View File
@@ -1624,6 +1624,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an
supports_search_tool: false,
supports_experimental_context: false,
use_responses_lite: false,
supports_reasoning_effort_updates: false,
guardian: None,
node_repl_auto_review_required: false,
node_repl_disabled: false,
@@ -137,6 +137,7 @@ pub fn model_info_from_slug(slug: &str) -> ModelInfo {
supports_search_tool: false,
supports_experimental_context: false,
use_responses_lite: false,
supports_reasoning_effort_updates: false,
guardian: None,
node_repl_auto_review_required: false,
node_repl_disabled: false,
@@ -48,6 +48,7 @@ fn test_model(model_messages: Option<ModelMessages>) -> ModelInfo {
supports_search_tool: false,
supports_experimental_context: false,
use_responses_lite: false,
supports_reasoning_effort_updates: false,
guardian: None,
node_repl_auto_review_required: false,
node_repl_disabled: false,
+6
View File
@@ -481,6 +481,10 @@ pub struct ModelInfo {
pub supports_experimental_context: bool,
#[serde(default)]
pub use_responses_lite: bool,
/// Whether the model accepts reasoning-effort `configuration_update` items.
/// Missing metadata keeps effort changes on the ordinary request parameter.
#[serde(default)]
pub supports_reasoning_effort_updates: bool,
#[serde(default)]
pub node_repl_auto_review_required: bool,
#[serde(default)]
@@ -989,6 +993,7 @@ mod tests {
supports_search_tool: false,
supports_experimental_context: false,
use_responses_lite: false,
supports_reasoning_effort_updates: false,
guardian: None,
node_repl_auto_review_required: false,
node_repl_disabled: false,
@@ -1543,6 +1548,7 @@ mod tests {
assert!(!model.supports_search_tool);
assert!(!model.supports_experimental_context);
assert!(!model.use_responses_lite);
assert!(!model.supports_reasoning_effort_updates);
assert!(!model.node_repl_auto_review_required);
assert!(!model.node_repl_disabled);
assert_eq!(model.comp_hash, None);