[codex] Use model-advertised reasoning effort order (#26446)

## Summary
- preserve the model catalog order for app-server
`supportedReasoningEfforts` and document that client contract
- render TUI reasoning choices in the advertised order
- step reasoning shortcuts by adjacent list position instead of deriving
order from known effort names
- anchor unsupported configured values to the advertised default, or the
first option when needed
- remove canonical effort ordering helpers and the unused upgrade effort
mapping

## Validation
- `just fmt`
- Local tests and compilation were not run per request; relying on CI.

Stacked on #26444.
This commit is contained in:
Ahmed Ibrahim authored and GitHub committed 2026-06-04 14:01:14 -07:00
1 parent 8ac304c299
commit f6e529656f
10 files changed
+73 -166

No files matched your search

+1 -1
View File
@@ -188,7 +188,7 @@ Example with notification opt-out:
- `fs/watch` — subscribe this connection to filesystem change notifications for an absolute file or directory path and caller-provided `watchId`; returns the canonicalized `path`. - `fs/watch` — subscribe this connection to filesystem change notifications for an absolute file or directory path and caller-provided `watchId`; returns the canonicalized `path`.
- `fs/unwatch` — stop sending notifications for a prior `fs/watch`; returns `{}`. - `fs/unwatch` — stop sending notifications for a prior `fs/watch`; returns `{}`.
- `fs/changed` — notification emitted when watched paths change, including the `watchId` and `changedPaths`. - `fs/changed` — notification emitted when watched paths change, including the `watchId` and `changedPaths`.
- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with model-advertised string reasoning effort options, `additionalSpeedTiers`, `serviceTiers`, optional `defaultServiceTier`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. - `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with model-advertised string reasoning effort options in the catalog's intended progression order, `additionalSpeedTiers`, `serviceTiers`, optional `defaultServiceTier`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. Clients should preserve the `supportedReasoningEfforts` array order rather than deriving order from the effort names.
- `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider. - `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider.
- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. Pass `threadId` when showing feature state for an existing loaded thread so `enabled` is computed from that thread's refreshed config, including project-local config for the thread's cwd; if omitted, the server uses its default config resolution context. For non-beta flags, `displayName`/`description`/`announcement` are `null`. - `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. Pass `threadId` when showing feature state for an existing loaded thread so `enabled` is computed from that thread's refreshed config, including project-local config for the thread's cwd; if omitted, the server uses its default config resolution context. For non-beta flags, `displayName`/`description`/`announcement` are `null`.
- `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text, using cursor pagination. Pass `cwd` when the caller needs project-local `[permissions.<id>]` entries to be included in the current catalog view. - `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text, using cursor pagination. Pass `cwd` when the caller needs project-local `[permissions.<id>]` entries to be included in the current catalog view.
@@ -1,5 +1,6 @@
use std::time::Duration; use std::time::Duration;
use anyhow::Error;
use anyhow::Result; use anyhow::Result;
use app_test_support::ChatGptAuthFixture; use app_test_support::ChatGptAuthFixture;
use app_test_support::TestAppServer; use app_test_support::TestAppServer;
@@ -166,8 +167,9 @@ async fn list_models_uses_chatgpt_remote_catalog_as_source_of_truth() -> Result<
"description": "Remote-only model for app-server model/list coverage", "description": "Remote-only model for app-server model/list coverage",
"default_reasoning_level": "max", "default_reasoning_level": "max",
"supported_reasoning_levels": [ "supported_reasoning_levels": [
{"effort": "low", "description": "low"}, {"effort": "max", "description": "Maximum"},
{"effort": "max", "description": "Maximum"} {"effort": "low", "description": "Low"},
{"effort": "focused", "description": "Focused"}
], ],
"shell_type": "shell_command", "shell_type": "shell_command",
"visibility": "list", "visibility": "list",
@@ -238,10 +240,24 @@ openai_base_url = "{server_uri}/v1"
} = to_response::<ModelListResponse>(response)?; } = to_response::<ModelListResponse>(response)?;
let mut expected_presets: Vec<ModelPreset> = vec![remote_model.into()]; let mut expected_presets: Vec<ModelPreset> = vec![remote_model.into()];
ModelPreset::mark_default_by_picker_visibility(&mut expected_presets); ModelPreset::mark_default_by_picker_visibility(&mut expected_presets);
let expected_items = expected_presets let mut expected_items = expected_presets
.iter() .iter()
.map(model_from_preset) .map(model_from_preset)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
expected_items[0].supported_reasoning_efforts = vec![
ReasoningEffortOption {
reasoning_effort: "max".parse().map_err(Error::msg)?,
description: "Maximum".to_string(),
},
ReasoningEffortOption {
reasoning_effort: "low".parse().map_err(Error::msg)?,
description: "Low".to_string(),
},
ReasoningEffortOption {
reasoning_effort: "focused".parse().map_err(Error::msg)?,
description: "Focused".to_string(),
},
];
assert_eq!(items, expected_items); assert_eq!(items, expected_items);
assert!(next_cursor.is_none()); assert!(next_cursor.is_none());
-70
View File
@@ -3,7 +3,6 @@
//! These types are serialized across core, TUI, app-server, and SDK boundaries, so field defaults //! These types are serialized across core, TUI, app-server, and SDK boundaries, so field defaults
//! are used to preserve compatibility when older payloads omit newly introduced attributes. //! are used to preserve compatibility when older payloads omit newly introduced attributes.
use std::collections::HashMap;
use std::fmt; use std::fmt;
use std::str::FromStr; use std::str::FromStr;
@@ -63,32 +62,6 @@ impl ReasoningEffort {
Self::Custom(effort) => effort, Self::Custom(effort) => effort,
} }
} }
/// Returns the built-in effort values in ascending order.
pub fn known_values() -> impl DoubleEndedIterator<Item = Self> + ExactSizeIterator {
[
Self::None,
Self::Minimal,
Self::Low,
Self::Medium,
Self::High,
Self::XHigh,
]
.into_iter()
}
/// Returns the built-in ordering rank, or `None` for model-defined values.
pub const fn known_rank(&self) -> Option<usize> {
match self {
Self::None => Some(0),
Self::Minimal => Some(1),
Self::Low => Some(2),
Self::Medium => Some(3),
Self::High => Some(4),
Self::XHigh => Some(5),
Self::Custom(_) => None,
}
}
} }
impl fmt::Display for ReasoningEffort { impl fmt::Display for ReasoningEffort {
@@ -200,7 +173,6 @@ pub struct ReasoningEffortPreset {
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
pub struct ModelUpgrade { pub struct ModelUpgrade {
pub id: String, pub id: String,
pub reasoning_effort_mapping: Option<HashMap<ReasoningEffort, ReasoningEffort>>,
pub migration_config_key: String, pub migration_config_key: String,
pub model_link: Option<String>, pub model_link: Option<String>,
pub upgrade_copy: Option<String>, pub upgrade_copy: Option<String>,
@@ -593,9 +565,6 @@ impl From<ModelInfo> for ModelPreset {
is_default: false, // default is the highest priority available model is_default: false, // default is the highest priority available model
upgrade: info.upgrade.as_ref().map(|upgrade| ModelUpgrade { upgrade: info.upgrade.as_ref().map(|upgrade| ModelUpgrade {
id: upgrade.model.clone(), id: upgrade.model.clone(),
reasoning_effort_mapping: reasoning_effort_mapping_from_presets(
&info.supported_reasoning_levels,
),
migration_config_key: info.slug.clone(), migration_config_key: info.slug.clone(),
// todo(aibrahim): add the model link here. // todo(aibrahim): add the model link here.
model_link: None, model_link: None,
@@ -663,45 +632,6 @@ impl ModelPreset {
} }
} }
fn reasoning_effort_mapping_from_presets(
presets: &[ReasoningEffortPreset],
) -> Option<HashMap<ReasoningEffort, ReasoningEffort>> {
if presets.is_empty() {
return None;
}
// Map every canonical effort to the closest supported effort for the new model.
let supported: Vec<ReasoningEffort> = presets.iter().map(|p| p.effort.clone()).collect();
let mut map = HashMap::new();
for effort in ReasoningEffort::known_values() {
let nearest = nearest_effort(&effort, &supported);
map.insert(effort, nearest);
}
Some(map)
}
fn nearest_effort(target: &ReasoningEffort, supported: &[ReasoningEffort]) -> ReasoningEffort {
let Some(target_rank) = target.known_rank() else {
return supported
.iter()
.find(|candidate| *candidate == target)
.unwrap_or(target)
.clone();
};
supported
.iter()
.filter_map(|candidate| {
candidate
.known_rank()
.map(|rank| (rank.abs_diff(target_rank), candidate))
})
.min_by_key(|(distance, _)| *distance)
.map(|(_, effort)| effort)
.or_else(|| supported.first())
.unwrap_or(target)
.clone()
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
-1
View File
@@ -242,7 +242,6 @@ pub(super) async fn handle_model_migration_prompt_if_needed(
if let Some(ModelUpgrade { if let Some(ModelUpgrade {
id: target_model, id: target_model,
reasoning_effort_mapping: _,
migration_config_key, migration_config_key,
model_link, model_link,
upgrade_copy, upgrade_copy,
@@ -297,7 +297,6 @@ async fn model_migration_prompt_skips_when_target_missing_or_hidden() {
.expect("preset present"); .expect("preset present");
current.upgrade = Some(ModelUpgrade { current.upgrade = Some(ModelUpgrade {
id: "missing-target".to_string(), id: "missing-target".to_string(),
reasoning_effort_mapping: None,
migration_config_key: HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG.to_string(), migration_config_key: HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG.to_string(),
model_link: None, model_link: None,
upgrade_copy: None, upgrade_copy: None,
-1
View File
@@ -1204,7 +1204,6 @@ fn model_preset_from_api_model(model: ApiModel) -> ModelPreset {
let upgrade_info = model.upgrade_info.clone(); let upgrade_info = model.upgrade_info.clone();
ModelUpgrade { ModelUpgrade {
id: upgrade_id, id: upgrade_id,
reasoning_effort_mapping: None,
migration_config_key: model.model.clone(), migration_config_key: model.model.clone(),
model_link: upgrade_info model_link: upgrade_info
.as_ref() .as_ref()
+3 -8
View File
@@ -375,15 +375,10 @@ impl ChatWidget {
|| preset.model.starts_with("gpt-5.1-codex-max") || preset.model.starts_with("gpt-5.1-codex-max")
|| preset.model.starts_with("gpt-5.2"); || preset.model.starts_with("gpt-5.2");
let mut choices: Vec<ReasoningEffortConfig> = ReasoningEffortConfig::known_values() let mut choices: Vec<ReasoningEffortConfig> = supported
.filter(|effort| supported.iter().any(|option| option.effort == *effort)) .iter()
.map(|option| option.effort.clone())
.collect(); .collect();
choices.extend(
supported
.iter()
.filter(|option| option.effort.known_rank().is_none())
.map(|option| option.effort.clone()),
);
if choices.is_empty() { if choices.is_empty() {
choices.push(default_effort.clone()); choices.push(default_effort.clone());
} }
@@ -8,9 +8,9 @@
//! The shortcut state machine is deliberately narrow: it only handles key //! The shortcut state machine is deliberately narrow: it only handles key
//! presses when no modal or popup owns input, it anchors unset reasoning to the //! presses when no modal or popup owns input, it anchors unset reasoning to the
//! current model preset's default, and it walks only efforts advertised by the //! current model preset's default, and it walks only efforts advertised by the
//! active model. Unsupported known efforts move to the nearest advertised known //! active model. Unsupported efforts anchor to the model default, or the first
//! effort in the requested direction. Unknown efforts anchor to the model //! advertised effort when the default is absent, before stepping through the
//! default before stepping through the advertised order. //! advertised order.
use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::ModeKind;
use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelPreset;
@@ -130,21 +130,11 @@ impl ChatWidget {
} }
fn reasoning_choices(preset: &ModelPreset) -> Vec<ReasoningEffortConfig> { fn reasoning_choices(preset: &ModelPreset) -> Vec<ReasoningEffortConfig> {
let mut choices: Vec<ReasoningEffortConfig> = ReasoningEffortConfig::known_values() let mut choices: Vec<ReasoningEffortConfig> = preset
.filter(|effort| { .supported_reasoning_efforts
preset .iter()
.supported_reasoning_efforts .map(|option| option.effort.clone())
.iter()
.any(|option| option.effort == *effort)
})
.collect(); .collect();
choices.extend(
preset
.supported_reasoning_efforts
.iter()
.filter(|option| option.effort.known_rank().is_none())
.map(|option| option.effort.clone()),
);
if choices.is_empty() { if choices.is_empty() {
choices.push(preset.default_reasoning_effort.clone()); choices.push(preset.default_reasoning_effort.clone());
} }
@@ -167,42 +157,7 @@ fn next_reasoning_effort(
}; };
} }
let current_rank = current_effort.known_rank()?; None
let ranked_choice = match direction {
ReasoningShortcutDirection::Lower => choices
.iter()
.filter_map(|choice| choice.known_rank().map(|rank| (rank, choice)))
.filter(|(rank, _)| *rank < current_rank)
.max_by_key(|(rank, _)| *rank)
.map(|(_, choice)| choice.clone()),
ReasoningShortcutDirection::Raise => choices
.iter()
.filter_map(|choice| choice.known_rank().map(|rank| (rank, choice)))
.filter(|(rank, _)| *rank > current_rank)
.min_by_key(|(rank, _)| *rank)
.map(|(_, choice)| choice.clone()),
};
if let Some(ranked_choice) = ranked_choice {
return Some(ranked_choice);
}
let nearest_known_index = choices
.iter()
.enumerate()
.filter_map(|(index, choice)| {
choice
.known_rank()
.map(|rank| (rank.abs_diff(current_rank), index))
})
.min_by_key(|(distance, _)| *distance)
.map(|(_, index)| index)?;
match direction {
ReasoningShortcutDirection::Lower => nearest_known_index
.checked_sub(1)
.and_then(|index| choices.get(index))
.cloned(),
ReasoningShortcutDirection::Raise => choices.get(nearest_known_index + 1).cloned(),
}
} }
#[cfg(test)] #[cfg(test)]
@@ -248,39 +203,52 @@ mod tests {
} }
#[test] #[test]
fn next_reasoning_effort_skips_to_supported_level_from_unsupported_current() { fn next_reasoning_effort_does_not_infer_position_for_unsupported_current() {
let choices = vec![ReasoningEffortConfig::Low, ReasoningEffortConfig::High]; let choices = vec![ReasoningEffortConfig::Low, ReasoningEffortConfig::High];
assert_eq!( assert_eq!(
next_reasoning_effort( (
&choices, next_reasoning_effort(
Some(ReasoningEffortConfig::Medium), &choices,
ReasoningShortcutDirection::Raise, Some(ReasoningEffortConfig::Medium),
ReasoningShortcutDirection::Raise,
),
next_reasoning_effort(
&choices,
Some(ReasoningEffortConfig::Medium),
ReasoningShortcutDirection::Lower,
),
), ),
Some(ReasoningEffortConfig::High) (None, None)
);
assert_eq!(
next_reasoning_effort(
&choices,
Some(ReasoningEffortConfig::Medium),
ReasoningShortcutDirection::Lower,
),
Some(ReasoningEffortConfig::Low)
); );
} }
#[test] #[test]
fn next_reasoning_effort_reaches_custom_level_from_nearest_known_anchor() { fn next_reasoning_effort_uses_advertised_order_for_custom_levels() {
let custom_effort = ReasoningEffortConfig::Custom("max".to_string()); let custom_effort = ReasoningEffortConfig::Custom("max".to_string());
let choices = vec![ReasoningEffortConfig::Medium, custom_effort.clone()]; let choices = vec![
ReasoningEffortConfig::High,
ReasoningEffortConfig::Low,
custom_effort.clone(),
];
assert_eq!( assert_eq!(
next_reasoning_effort( (
&choices, next_reasoning_effort(
Some(ReasoningEffortConfig::High), &choices,
ReasoningShortcutDirection::Raise, Some(ReasoningEffortConfig::High),
ReasoningShortcutDirection::Raise,
),
next_reasoning_effort(
&choices,
Some(custom_effort),
ReasoningShortcutDirection::Lower,
),
), ),
Some(custom_effort) (
Some(ReasoningEffortConfig::Low),
Some(ReasoningEffortConfig::Low),
)
); );
} }
@@ -6,8 +6,8 @@ expression: popup
1. Low Fast responses with lighter reasoning 1. Low Fast responses with lighter reasoning
2. Medium (default) Balances speed and reasoning depth for everyday tasks 2. Medium (default) Balances speed and reasoning depth for everyday tasks
› 3. High (current) Greater reasoning depth for complex problems 3. max Maximum available reasoning
4. Extra high Extra high reasoning depth for complex problems › 4. High (current) Greater reasoning depth for complex problems
5. max Maximum available reasoning 5. Extra high Extra high reasoning depth for complex problems
Press enter to confirm or esc to go back Press enter to confirm or esc to go back
@@ -2406,12 +2406,13 @@ async fn model_reasoning_selection_popup_snapshot() {
chat.set_reasoning_effort(Some(ReasoningEffortConfig::High)); chat.set_reasoning_effort(Some(ReasoningEffortConfig::High));
let mut preset = get_available_model(&chat, "gpt-5.4"); let mut preset = get_available_model(&chat, "gpt-5.4");
preset preset.supported_reasoning_efforts.insert(
.supported_reasoning_efforts 2,
.push(ReasoningEffortPreset { ReasoningEffortPreset {
effort: ReasoningEffortConfig::Custom("max".to_string()), effort: ReasoningEffortConfig::Custom("max".to_string()),
description: "Maximum available reasoning".to_string(), description: "Maximum available reasoning".to_string(),
}); },
);
chat.open_reasoning_popup(preset); chat.open_reasoning_popup(preset);
let popup = render_bottom_popup(&chat, /*width*/ 80); let popup = render_bottom_popup(&chat, /*width*/ 80);