[codex] Support model-defined reasoning efforts (#26444)

## Summary
- accept non-empty model-defined reasoning effort values while
preserving built-in effort behavior
- propagate the non-Copy effort type through core, app-server, TUI,
telemetry, and persistence call sites
- preserve string wire encoding and expose an open-string schema for
clients
- update model selection and shortcut behavior for model-advertised
effort values

## Root cause
`ReasoningEffort` gained a string-backed custom variant, so it could no
longer implement `Copy` or rely on derived closed-enum serialization.
Existing consumers still moved effort values from shared references and
assumed a fixed built-in value set.

## Validation
- `just fmt`
- Local tests and compilation were not run per request; relying on CI.
This commit is contained in:
Ahmed Ibrahim
2026-06-04 13:36:24 -07:00
committed by GitHub
Unverified
parent 4a70e0ac1b
commit 8ac304c299
97 changed files with 685 additions and 581 deletions
+6 -3
View File
@@ -635,7 +635,7 @@ impl CollaborationMode {
}
pub fn reasoning_effort(&self) -> Option<ReasoningEffort> {
self.settings_ref().reasoning_effort
self.settings_ref().reasoning_effort.clone()
}
/// Updates the collaboration mode with new model and/or effort values.
@@ -654,7 +654,7 @@ impl CollaborationMode {
let settings = self.settings_ref();
let updated_settings = Settings {
model: model.unwrap_or_else(|| settings.model.clone()),
reasoning_effort: effort.unwrap_or(settings.reasoning_effort),
reasoning_effort: effort.unwrap_or_else(|| settings.reasoning_effort.clone()),
developer_instructions: developer_instructions
.unwrap_or_else(|| settings.developer_instructions.clone()),
};
@@ -676,7 +676,10 @@ impl CollaborationMode {
mode: mask.mode.unwrap_or(self.mode),
settings: Settings {
model: mask.model.clone().unwrap_or_else(|| settings.model.clone()),
reasoning_effort: mask.reasoning_effort.unwrap_or(settings.reasoning_effort),
reasoning_effort: mask
.reasoning_effort
.clone()
.unwrap_or_else(|| settings.reasoning_effort.clone()),
developer_instructions: mask
.developer_instructions
.clone()
+182 -44
View File
@@ -4,14 +4,22 @@
//! are used to preserve compatibility when older payloads omit newly introduced attributes.
use std::collections::HashMap;
use std::fmt;
use std::str::FromStr;
use schemars::JsonSchema;
use schemars::r#gen::SchemaGenerator;
use schemars::schema::InstanceType;
use schemars::schema::Metadata;
use schemars::schema::Schema;
use schemars::schema::SchemaObject;
use schemars::schema::StringValidation;
use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
use serde::Serializer;
use serde::de::DeserializeOwned;
use strum::IntoEnumIterator;
use serde::de::Error;
use strum_macros::Display;
use strum_macros::EnumIter;
use tracing::warn;
@@ -28,23 +36,8 @@ const PERSONALITY_PLACEHOLDER: &str = "{{ personality }}";
pub const SPEED_TIER_FAST: &str = "fast";
/// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning
#[derive(
Debug,
Serialize,
Deserialize,
Default,
Clone,
Copy,
PartialEq,
Eq,
Display,
JsonSchema,
TS,
EnumIter,
Hash,
)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
#[derive(Debug, Default, Clone, PartialEq, Eq, TS, Hash)]
#[ts(type = "string")]
pub enum ReasoningEffort {
None,
Minimal,
@@ -53,14 +46,113 @@ pub enum ReasoningEffort {
Medium,
High,
XHigh,
/// A model-defined effort value that this client does not know yet.
Custom(String),
}
impl ReasoningEffort {
/// Returns the exact value used on the wire.
pub fn as_str(&self) -> &str {
match self {
Self::None => "none",
Self::Minimal => "minimal",
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::XHigh => "xhigh",
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 {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl JsonSchema for ReasoningEffort {
fn schema_name() -> String {
"ReasoningEffort".to_string()
}
fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
Schema::Object(SchemaObject {
instance_type: Some(InstanceType::String.into()),
metadata: Some(Box::new(Metadata {
description: Some(
"A non-empty reasoning effort value advertised by the model.".to_string(),
),
..Default::default()
})),
string: Some(Box::new(StringValidation {
min_length: Some(1),
..Default::default()
})),
..Default::default()
})
}
}
impl Serialize for ReasoningEffort {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for ReasoningEffort {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let effort = String::deserialize(deserializer)?;
effort.parse().map_err(D::Error::custom)
}
}
impl FromStr for ReasoningEffort {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
serde_json::from_value(serde_json::Value::String(s.to_string()))
.map_err(|_| format!("invalid reasoning_effort: {s}"))
match s {
"none" => Ok(Self::None),
"minimal" => Ok(Self::Minimal),
"low" => Ok(Self::Low),
"medium" => Ok(Self::Medium),
"high" => Ok(Self::High),
"xhigh" => Ok(Self::XHigh),
"" => Err("reasoning_effort must not be empty".to_string()),
effort => Ok(Self::Custom(effort.to_string())),
}
}
}
@@ -579,39 +671,43 @@ fn reasoning_effort_mapping_from_presets(
}
// Map every canonical effort to the closest supported effort for the new model.
let supported: Vec<ReasoningEffort> = presets.iter().map(|p| p.effort).collect();
let supported: Vec<ReasoningEffort> = presets.iter().map(|p| p.effort.clone()).collect();
let mut map = HashMap::new();
for effort in ReasoningEffort::iter() {
let nearest = nearest_effort(effort, &supported);
for effort in ReasoningEffort::known_values() {
let nearest = nearest_effort(&effort, &supported);
map.insert(effort, nearest);
}
Some(map)
}
fn effort_rank(effort: ReasoningEffort) -> i32 {
match effort {
ReasoningEffort::None => 0,
ReasoningEffort::Minimal => 1,
ReasoningEffort::Low => 2,
ReasoningEffort::Medium => 3,
ReasoningEffort::High => 4,
ReasoningEffort::XHigh => 5,
}
}
fn nearest_effort(target: ReasoningEffort, supported: &[ReasoningEffort]) -> ReasoningEffort {
let target_rank = effort_rank(target);
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()
.copied()
.min_by_key(|candidate| (effort_rank(*candidate) - target_rank).abs())
.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::*;
use pretty_assertions::assert_eq;
use serde_json::from_str;
use serde_json::to_string;
fn test_model(spec: Option<ModelMessages>) -> ModelInfo {
ModelInfo {
@@ -663,16 +759,58 @@ mod tests {
}
#[test]
fn reasoning_effort_from_str_accepts_known_values() {
assert_eq!("high".parse(), Ok(ReasoningEffort::High));
assert_eq!("minimal".parse(), Ok(ReasoningEffort::Minimal));
fn reasoning_effort_accepts_known_and_custom_values() {
let custom = ReasoningEffort::Custom("max".to_string());
let deserialized = from_str::<ReasoningEffort>(r#""max""#)
.expect("custom reasoning effort should deserialize");
let serialized = to_string(&custom).expect("custom reasoning effort should serialize");
assert_eq!(
(
"high".parse(),
"max".parse(),
deserialized,
serialized,
custom.to_string(),
),
(
Ok(ReasoningEffort::High),
Ok(custom.clone()),
custom,
r#""max""#.to_string(),
"max".to_string(),
)
);
}
#[test]
fn reasoning_effort_from_str_rejects_unknown_values() {
fn reasoning_effort_rejects_empty_values() {
assert_eq!(
"unsupported".parse::<ReasoningEffort>(),
Err("invalid reasoning_effort: unsupported".to_string())
"".parse::<ReasoningEffort>(),
Err("reasoning_effort must not be empty".to_string())
);
}
#[test]
fn reasoning_effort_json_schema_is_an_open_string() {
let mut effort_generator = SchemaGenerator::default();
assert_eq!(
ReasoningEffort::json_schema(&mut effort_generator),
Schema::Object(SchemaObject {
instance_type: Some(InstanceType::String.into()),
metadata: Some(Box::new(Metadata {
description: Some(
"A non-empty reasoning effort value advertised by the model.".to_string(),
),
..Default::default()
})),
string: Some(Box::new(StringValidation {
min_length: Some(1),
..Default::default()
})),
..Default::default()
})
);
}