diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index a789c61af..6d705afa9 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -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.]` entries to be included in the current catalog view. diff --git a/codex-rs/app-server/tests/suite/v2/model_list.rs b/codex-rs/app-server/tests/suite/v2/model_list.rs index eba04b54b..d2538629c 100644 --- a/codex-rs/app-server/tests/suite/v2/model_list.rs +++ b/codex-rs/app-server/tests/suite/v2/model_list.rs @@ -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::(response)?; let mut expected_presets: Vec = 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::>(); + 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()); diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index fc4065ed9..4c04edbb5 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -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 + 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 { - 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>, pub migration_config_key: String, pub model_link: Option, pub upgrade_copy: Option, @@ -593,9 +565,6 @@ impl From 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> { - if presets.is_empty() { - return None; - } - - // Map every canonical effort to the closest supported effort for the new model. - let supported: Vec = 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::*; diff --git a/codex-rs/tui/src/app/startup_prompts.rs b/codex-rs/tui/src/app/startup_prompts.rs index 307c69bbc..a027dcd50 100644 --- a/codex-rs/tui/src/app/startup_prompts.rs +++ b/codex-rs/tui/src/app/startup_prompts.rs @@ -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, diff --git a/codex-rs/tui/src/app/tests/model_catalog.rs b/codex-rs/tui/src/app/tests/model_catalog.rs index 7d2eb9c12..961c65159 100644 --- a/codex-rs/tui/src/app/tests/model_catalog.rs +++ b/codex-rs/tui/src/app/tests/model_catalog.rs @@ -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, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 04f81fa83..ccd6304d7 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -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() diff --git a/codex-rs/tui/src/chatwidget/model_popups.rs b/codex-rs/tui/src/chatwidget/model_popups.rs index 8ddecaa3d..90fd6c2cc 100644 --- a/codex-rs/tui/src/chatwidget/model_popups.rs +++ b/codex-rs/tui/src/chatwidget/model_popups.rs @@ -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::known_values() - .filter(|effort| supported.iter().any(|option| option.effort == *effort)) + let mut choices: Vec = 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()); } diff --git a/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs b/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs index a5102888b..404bf8702 100644 --- a/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs +++ b/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs @@ -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 { - let mut choices: Vec = ReasoningEffortConfig::known_values() - .filter(|effort| { - preset - .supported_reasoning_efforts - .iter() - .any(|option| option.effort == *effort) - }) + let mut choices: Vec = 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), + ) ); } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap index 8a37596df..a43613657 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap @@ -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 diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index ae19fd6f3..89c332a4e 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -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);