[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
2026-06-04 14:01:14 -07:00
committed by GitHub
parent 8ac304c299
commit f6e529656f
10 changed files with 73 additions and 166 deletions
+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/unwatch` — stop sending notifications for a prior `fs/watch`; returns `{}`.
- `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.
- `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.
@@ -1,5 +1,6 @@
use std::time::Duration;
use anyhow::Error;
use anyhow::Result;
use app_test_support::ChatGptAuthFixture;
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",
"default_reasoning_level": "max",
"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",
"visibility": "list",
@@ -238,10 +240,24 @@ openai_base_url = "{server_uri}/v1"
} = to_response::<ModelListResponse>(response)?;
let mut expected_presets: Vec<ModelPreset> = vec![remote_model.into()];
ModelPreset::mark_default_by_picker_visibility(&mut expected_presets);
let expected_items = expected_presets
let mut expected_items = expected_presets
.iter()
.map(model_from_preset)
.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!(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
//! are used to preserve compatibility when older payloads omit newly introduced attributes.
use std::collections::HashMap;
use std::fmt;
use std::str::FromStr;
@@ -63,32 +62,6 @@ impl ReasoningEffort {
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 {
@@ -200,7 +173,6 @@ pub struct ReasoningEffortPreset {
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
pub struct ModelUpgrade {
pub id: String,
pub reasoning_effort_mapping: Option<HashMap<ReasoningEffort, ReasoningEffort>>,
pub migration_config_key: String,
pub model_link: 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
upgrade: info.upgrade.as_ref().map(|upgrade| ModelUpgrade {
id: upgrade.model.clone(),
reasoning_effort_mapping: reasoning_effort_mapping_from_presets(
&info.supported_reasoning_levels,
),
migration_config_key: info.slug.clone(),
// todo(aibrahim): add the model link here.
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)]
mod tests {
use super::*;
-1
View File
@@ -242,7 +242,6 @@ pub(super) async fn handle_model_migration_prompt_if_needed(
if let Some(ModelUpgrade {
id: target_model,
reasoning_effort_mapping: _,
migration_config_key,
model_link,
upgrade_copy,
@@ -297,7 +297,6 @@ async fn model_migration_prompt_skips_when_target_missing_or_hidden() {
.expect("preset present");
current.upgrade = Some(ModelUpgrade {
id: "missing-target".to_string(),
reasoning_effort_mapping: None,
migration_config_key: HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG.to_string(),
model_link: 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();
ModelUpgrade {
id: upgrade_id,
reasoning_effort_mapping: None,
migration_config_key: model.model.clone(),
model_link: upgrade_info
.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.2");
let mut choices: Vec<ReasoningEffortConfig> = ReasoningEffortConfig::known_values()
.filter(|effort| supported.iter().any(|option| option.effort == *effort))
let mut choices: Vec<ReasoningEffortConfig> = supported
.iter()
.map(|option| option.effort.clone())
.collect();
choices.extend(
supported
.iter()
.filter(|option| option.effort.known_rank().is_none())
.map(|option| option.effort.clone()),
);
if choices.is_empty() {
choices.push(default_effort.clone());
}
@@ -8,9 +8,9 @@
//! 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
//! current model preset's default, and it walks only efforts advertised by the
//! active model. Unsupported known efforts move to the nearest advertised known
//! effort in the requested direction. Unknown efforts anchor to the model
//! default before stepping through the advertised order.
//! active model. Unsupported efforts anchor to the model default, or the first
//! advertised effort when the default is absent, before stepping through the
//! advertised order.
use codex_protocol::config_types::ModeKind;
use codex_protocol::openai_models::ModelPreset;
@@ -130,21 +130,11 @@ impl ChatWidget {
}
fn reasoning_choices(preset: &ModelPreset) -> Vec<ReasoningEffortConfig> {
let mut choices: Vec<ReasoningEffortConfig> = ReasoningEffortConfig::known_values()
.filter(|effort| {
preset
.supported_reasoning_efforts
.iter()
.any(|option| option.effort == *effort)
})
let mut choices: Vec<ReasoningEffortConfig> = preset
.supported_reasoning_efforts
.iter()
.map(|option| option.effort.clone())
.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() {
choices.push(preset.default_reasoning_effort.clone());
}
@@ -167,42 +157,7 @@ fn next_reasoning_effort(
};
}
let current_rank = current_effort.known_rank()?;
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(),
}
None
}
#[cfg(test)]
@@ -248,39 +203,52 @@ mod tests {
}
#[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];
assert_eq!(
next_reasoning_effort(
&choices,
Some(ReasoningEffortConfig::Medium),
ReasoningShortcutDirection::Raise,
(
next_reasoning_effort(
&choices,
Some(ReasoningEffortConfig::Medium),
ReasoningShortcutDirection::Raise,
),
next_reasoning_effort(
&choices,
Some(ReasoningEffortConfig::Medium),
ReasoningShortcutDirection::Lower,
),
),
Some(ReasoningEffortConfig::High)
);
assert_eq!(
next_reasoning_effort(
&choices,
Some(ReasoningEffortConfig::Medium),
ReasoningShortcutDirection::Lower,
),
Some(ReasoningEffortConfig::Low)
(None, None)
);
}
#[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 choices = vec![ReasoningEffortConfig::Medium, custom_effort.clone()];
let choices = vec![
ReasoningEffortConfig::High,
ReasoningEffortConfig::Low,
custom_effort.clone(),
];
assert_eq!(
next_reasoning_effort(
&choices,
Some(ReasoningEffortConfig::High),
ReasoningShortcutDirection::Raise,
(
next_reasoning_effort(
&choices,
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
2. Medium (default) Balances speed and reasoning depth for everyday tasks
› 3. High (current) Greater reasoning depth for complex problems
4. Extra high Extra high reasoning depth for complex problems
5. max Maximum available reasoning
3. max Maximum available reasoning
› 4. High (current) Greater reasoning depth for complex problems
5. Extra high Extra high reasoning depth for complex problems
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));
let mut preset = get_available_model(&chat, "gpt-5.4");
preset
.supported_reasoning_efforts
.push(ReasoningEffortPreset {
preset.supported_reasoning_efforts.insert(
2,
ReasoningEffortPreset {
effort: ReasoningEffortConfig::Custom("max".to_string()),
description: "Maximum available reasoning".to_string(),
});
},
);
chat.open_reasoning_popup(preset);
let popup = render_bottom_popup(&chat, /*width*/ 80);