mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[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:
committed by
GitHub
Unverified
parent
8ac304c299
commit
f6e529656f
@@ -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());
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user