use std::collections::BTreeMap; use std::collections::HashMap; use std::collections::HashSet; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use strum::IntoEnumIterator; use strum_macros::Display; use strum_macros::EnumIter; use tracing::warn; use ts_rs::TS; use crate::config_types::Personality; use crate::config_types::Verbosity; const PERSONALITY_PLACEHOLDER: &str = "{{ personality_message }}"; /// 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")] pub enum ReasoningEffort { None, Minimal, Low, #[default] Medium, High, XHigh, } /// A reasoning effort option that can be surfaced for a model. #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)] pub struct ReasoningEffortPreset { /// Effort level that the model supports. pub effort: ReasoningEffort, /// Short human description shown next to the effort in UIs. pub description: String, } #[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, pub migration_markdown: Option, } /// Metadata describing a Codex-supported model. #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] pub struct ModelPreset { /// Stable identifier for the preset. pub id: String, /// Model slug (e.g., "gpt-5"). pub model: String, /// Display name shown in UIs. pub display_name: String, /// Short human description shown in UIs. pub description: String, /// Reasoning effort applied when none is explicitly chosen. pub default_reasoning_effort: ReasoningEffort, /// Supported reasoning effort options. pub supported_reasoning_efforts: Vec, /// Whether this is the default model for new users. pub is_default: bool, /// recommended upgrade model pub upgrade: Option, /// Whether this preset should appear in the picker UI. pub show_in_picker: bool, /// whether this model is supported in the api pub supported_in_api: bool, } /// Visibility of a model in the picker or APIs. #[derive( Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, TS, JsonSchema, EnumIter, Display, )] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] pub enum ModelVisibility { List, Hide, None, } /// Shell execution capability for a model. #[derive( Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, TS, JsonSchema, EnumIter, Display, Hash, )] #[serde(rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] pub enum ConfigShellToolType { Default, Local, UnifiedExec, Disabled, ShellCommand, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, TS, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum ApplyPatchToolType { Freeform, Function, } /// Server-provided truncation policy metadata for a model. #[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, TS, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum TruncationMode { Bytes, Tokens, } #[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, TS, JsonSchema)] pub struct TruncationPolicyConfig { pub mode: TruncationMode, pub limit: i64, } impl TruncationPolicyConfig { pub const fn bytes(limit: i64) -> Self { Self { mode: TruncationMode::Bytes, limit, } } pub const fn tokens(limit: i64) -> Self { Self { mode: TruncationMode::Tokens, limit, } } } /// Semantic version triple encoded as an array in JSON (e.g. [0, 62, 0]). #[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, TS, JsonSchema)] pub struct ClientVersion(pub i32, pub i32, pub i32); const fn default_effective_context_window_percent() -> i64 { 95 } /// Model metadata returned by the Codex backend `/models` endpoint. #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] pub struct ModelInfo { pub slug: String, pub display_name: String, pub description: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub default_reasoning_level: Option, pub supported_reasoning_levels: Vec, pub shell_type: ConfigShellToolType, pub visibility: ModelVisibility, pub supported_in_api: bool, pub priority: i32, pub upgrade: Option, pub base_instructions: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub model_instructions_template: Option, pub supports_reasoning_summaries: bool, pub support_verbosity: bool, pub default_verbosity: Option, pub apply_patch_tool_type: Option, pub truncation_policy: TruncationPolicyConfig, pub supports_parallel_tool_calls: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub context_window: Option, /// Token threshold for automatic compaction. When omitted, core derives it /// from `context_window` (90%). #[serde(default, skip_serializing_if = "Option::is_none")] pub auto_compact_token_limit: Option, /// Percentage of the context window considered usable for inputs, after /// reserving headroom for system prompts, tool overhead, and model output. #[serde(default = "default_effective_context_window_percent")] pub effective_context_window_percent: i64, pub experimental_supported_tools: Vec, } impl ModelInfo { pub fn auto_compact_token_limit(&self) -> Option { self.auto_compact_token_limit.or_else(|| { self.context_window .map(|context_window| (context_window * 9) / 10) }) } pub fn get_model_instructions(&self, personality: Option) -> String { if let Some(personality) = personality && let Some(template) = &self.model_instructions_template && template.has_personality_placeholder() && let Some(personality_messages) = &template.personality_messages && let Some(personality_message) = personality_messages.0.get(&personality) { template .template .replace(PERSONALITY_PLACEHOLDER, personality_message.as_str()) } else if let Some(personality) = personality { warn!( model = %self.slug, %personality, "Model personality requested but model_instructions_template is invalid, falling back to base instructions." ); self.base_instructions.clone() } else { self.base_instructions.clone() } } } /// A strongly-typed template for assembling model instructions. If populated and valid, will override /// base_instructions. #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] pub struct ModelInstructionsTemplate { pub template: String, pub personality_messages: Option, } impl ModelInstructionsTemplate { fn has_personality_placeholder(&self) -> bool { self.template.contains(PERSONALITY_PLACEHOLDER) } } // serializes as a dictionary from personality to message #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, TS, JsonSchema)] #[serde(transparent)] pub struct PersonalityMessages(pub BTreeMap); #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] pub struct ModelInfoUpgrade { pub model: String, pub migration_markdown: String, } impl From<&ModelUpgrade> for ModelInfoUpgrade { fn from(upgrade: &ModelUpgrade) -> Self { ModelInfoUpgrade { model: upgrade.id.clone(), migration_markdown: upgrade.migration_markdown.clone().unwrap_or_default(), } } } /// Response wrapper for `/models`. #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema, Default)] pub struct ModelsResponse { pub models: Vec, } // convert ModelInfo to ModelPreset impl From for ModelPreset { fn from(info: ModelInfo) -> Self { ModelPreset { id: info.slug.clone(), model: info.slug.clone(), display_name: info.display_name, description: info.description.unwrap_or_default(), default_reasoning_effort: info .default_reasoning_level .unwrap_or(ReasoningEffort::None), supported_reasoning_efforts: info.supported_reasoning_levels.clone(), 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, upgrade_copy: None, migration_markdown: Some(upgrade.migration_markdown.clone()), }), show_in_picker: info.visibility == ModelVisibility::List, supported_in_api: info.supported_in_api, } } } impl ModelPreset { /// Filter models based on authentication mode. /// /// In ChatGPT mode, all models are visible. Otherwise, only API-supported models are shown. pub fn filter_by_auth(models: Vec, chatgpt_mode: bool) -> Vec { models .into_iter() .filter(|model| chatgpt_mode || model.supported_in_api) .collect() } /// Merge remote presets with existing presets, preferring remote when slugs match. /// /// Remote presets take precedence. Existing presets not in remote are appended with `is_default` set to false. pub fn merge( remote_presets: Vec, existing_presets: Vec, ) -> Vec { if remote_presets.is_empty() { return existing_presets; } let remote_slugs: HashSet<&str> = remote_presets .iter() .map(|preset| preset.model.as_str()) .collect(); let mut merged_presets = remote_presets.clone(); for mut preset in existing_presets { if remote_slugs.contains(preset.model.as_str()) { continue; } preset.is_default = false; merged_presets.push(preset); } merged_presets } } 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).collect(); let mut map = HashMap::new(); for effort in ReasoningEffort::iter() { 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); supported .iter() .copied() .min_by_key(|candidate| (effort_rank(*candidate) - target_rank).abs()) .unwrap_or(target) } #[cfg(test)] mod tests { use super::*; use pretty_assertions::assert_eq; fn test_model(template: Option) -> ModelInfo { ModelInfo { slug: "test-model".to_string(), display_name: "Test Model".to_string(), description: None, default_reasoning_level: None, supported_reasoning_levels: vec![], shell_type: ConfigShellToolType::ShellCommand, visibility: ModelVisibility::List, supported_in_api: true, priority: 1, upgrade: None, base_instructions: "base".to_string(), model_instructions_template: template, supports_reasoning_summaries: false, support_verbosity: false, default_verbosity: None, apply_patch_tool_type: None, truncation_policy: TruncationPolicyConfig::bytes(10_000), supports_parallel_tool_calls: false, context_window: None, auto_compact_token_limit: None, effective_context_window_percent: 95, experimental_supported_tools: vec![], } } fn personality_messages() -> PersonalityMessages { PersonalityMessages(BTreeMap::from([( Personality::Friendly, "friendly".to_string(), )])) } #[test] fn get_model_instructions_uses_template_when_placeholder_present() { let model = test_model(Some(ModelInstructionsTemplate { template: "Hello {{ personality_message }}".to_string(), personality_messages: Some(personality_messages()), })); let instructions = model.get_model_instructions(Some(Personality::Friendly)); assert_eq!(instructions, "Hello friendly"); } #[test] fn get_model_instructions_falls_back_when_placeholder_missing() { let model = test_model(Some(ModelInstructionsTemplate { template: "Hello there".to_string(), personality_messages: Some(personality_messages()), })); let instructions = model.get_model_instructions(Some(Personality::Friendly)); assert_eq!(instructions, "base"); } }