[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
Unverified
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);