//! Shared model metadata types exchanged between Codex services and clients. //! //! 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::str::FromStr; 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::ReasoningSummary; use crate::config_types::Verbosity; 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")] pub enum ReasoningEffort { None, Minimal, Low, #[default] Medium, High, XHigh, } impl FromStr for ReasoningEffort { type Err = String; fn from_str(s: &str) -> Result { serde_json::from_value(serde_json::Value::String(s.to_string())) .map_err(|_| format!("invalid reasoning_effort: {s}")) } } /// Canonical user-input modality tags advertised by a model. #[derive( Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS, EnumIter, Hash, )] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] pub enum InputModality { /// Plain text turns and tool payloads. Text, /// Image attachments included in user turns. Image, } /// Backward-compatible default when `input_modalities` is omitted on the wire. /// /// Legacy payloads predate modality metadata, so we conservatively assume both text and images are /// accepted unless a preset explicitly narrows support. pub fn default_input_modalities() -> Vec { vec![InputModality::Text, InputModality::Image] } /// 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, } #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)] pub struct ModelAvailabilityNux { pub message: String, } /// 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 model supports personality-specific instructions. #[serde(default)] pub supports_personality: bool, /// Additional speed tiers this model can run with beyond the standard path. #[serde(default)] pub additional_speed_tiers: 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, /// Availability NUX shown when this preset becomes accessible to the user. pub availability_nux: Option, /// whether this model is supported in the api pub supported_in_api: bool, /// Input modalities accepted when composing user turns for this preset. #[serde(default = "default_input_modalities")] pub input_modalities: Vec, } /// 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, } #[derive( Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, TS, JsonSchema, Default, )] #[serde(rename_all = "snake_case")] pub enum WebSearchToolType { #[default] Text, TextAndImage, } /// 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, #[serde(default)] pub additional_speed_tiers: Vec, pub availability_nux: Option, pub upgrade: Option, pub base_instructions: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub model_messages: Option, pub supports_reasoning_summaries: bool, #[serde(default)] pub default_reasoning_summary: ReasoningSummary, pub support_verbosity: bool, pub default_verbosity: Option, pub apply_patch_tool_type: Option, #[serde(default)] pub web_search_tool_type: WebSearchToolType, pub truncation_policy: TruncationPolicyConfig, pub supports_parallel_tool_calls: bool, #[serde(default)] pub supports_image_detail_original: 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%). When provided, core clamps it to 90% of the /// context window when available. #[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, /// Input modalities accepted by the backend for this model. #[serde(default = "default_input_modalities")] pub input_modalities: Vec, /// Internal-only marker set by core when a model slug resolved to fallback metadata. #[serde(default, skip_serializing, skip_deserializing)] #[schemars(skip)] #[ts(skip)] pub used_fallback_model_metadata: bool, #[serde(default)] pub supports_search_tool: bool, } impl ModelInfo { pub fn auto_compact_token_limit(&self) -> Option { let context_limit = self .context_window .map(|context_window| (context_window * 9) / 10); let config_limit = self.auto_compact_token_limit; if let Some(context_limit) = context_limit { return Some( config_limit.map_or(context_limit, |limit| std::cmp::min(limit, context_limit)), ); } config_limit } pub fn supports_personality(&self) -> bool { self.model_messages .as_ref() .is_some_and(ModelMessages::supports_personality) } pub fn get_model_instructions(&self, personality: Option) -> String { if let Some(model_messages) = &self.model_messages && let Some(template) = &model_messages.instructions_template { // if we have a template, always use it let personality_message = model_messages .get_personality_message(personality) .unwrap_or_default(); template.replace(PERSONALITY_PLACEHOLDER, personality_message.as_str()) } else if let Some(personality) = personality { warn!( model = %self.slug, %personality, "Model personality requested but model_messages is missing, falling back to base instructions." ); self.base_instructions.clone() } else { self.base_instructions.clone() } } } /// A strongly-typed template for assembling model instructions and developer messages. If /// instructions_* is populated and valid, it will override base_instructions. #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] pub struct ModelMessages { pub instructions_template: Option, pub instructions_variables: Option, } impl ModelMessages { fn has_personality_placeholder(&self) -> bool { self.instructions_template .as_ref() .map(|spec| spec.contains(PERSONALITY_PLACEHOLDER)) .unwrap_or(false) } fn supports_personality(&self) -> bool { self.has_personality_placeholder() && self .instructions_variables .as_ref() .is_some_and(ModelInstructionsVariables::is_complete) } pub fn get_personality_message(&self, personality: Option) -> Option { self.instructions_variables .as_ref() .and_then(|variables| variables.get_personality_message(personality)) } } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] pub struct ModelInstructionsVariables { pub personality_default: Option, pub personality_friendly: Option, pub personality_pragmatic: Option, } impl ModelInstructionsVariables { pub fn is_complete(&self) -> bool { self.personality_default.is_some() && self.personality_friendly.is_some() && self.personality_pragmatic.is_some() } pub fn get_personality_message(&self, personality: Option) -> Option { if let Some(personality) = personality { match personality { Personality::None => Some(String::new()), Personality::Friendly => self.personality_friendly.clone(), Personality::Pragmatic => self.personality_pragmatic.clone(), } } else { self.personality_default.clone() } } } #[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 { let supports_personality = info.supports_personality(); 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(), supports_personality, additional_speed_tiers: info.additional_speed_tiers, 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, availability_nux: info.availability_nux, supported_in_api: info.supported_in_api, input_modalities: info.input_modalities, } } } impl ModelPreset { pub fn supports_fast_mode(&self) -> bool { self.additional_speed_tiers .iter() .any(|tier| tier == SPEED_TIER_FAST) } /// 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() } /// Recompute the single default preset using picker visibility. /// /// The first picker-visible model wins; if none are picker-visible, the first model wins. pub fn mark_default_by_picker_visibility(models: &mut [ModelPreset]) { for preset in models.iter_mut() { preset.is_default = false; } if let Some(default) = models.iter_mut().find(|preset| preset.show_in_picker) { default.is_default = true; } else if let Some(default) = models.first_mut() { default.is_default = true; } } } 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(spec: 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, additional_speed_tiers: Vec::new(), availability_nux: None, upgrade: None, base_instructions: "base".to_string(), model_messages: spec, supports_reasoning_summaries: false, default_reasoning_summary: ReasoningSummary::Auto, support_verbosity: false, default_verbosity: None, apply_patch_tool_type: None, web_search_tool_type: WebSearchToolType::Text, truncation_policy: TruncationPolicyConfig::bytes(/*limit*/ 10_000), supports_parallel_tool_calls: false, supports_image_detail_original: false, context_window: None, auto_compact_token_limit: None, effective_context_window_percent: 95, experimental_supported_tools: vec![], input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, } } fn personality_variables() -> ModelInstructionsVariables { ModelInstructionsVariables { personality_default: Some("default".to_string()), personality_friendly: Some("friendly".to_string()), personality_pragmatic: Some("pragmatic".to_string()), } } #[test] fn reasoning_effort_from_str_accepts_known_values() { assert_eq!("high".parse(), Ok(ReasoningEffort::High)); assert_eq!("minimal".parse(), Ok(ReasoningEffort::Minimal)); } #[test] fn reasoning_effort_from_str_rejects_unknown_values() { assert_eq!( "unsupported".parse::(), Err("invalid reasoning_effort: unsupported".to_string()) ); } #[test] fn get_model_instructions_uses_template_when_placeholder_present() { let model = test_model(Some(ModelMessages { instructions_template: Some("Hello {{ personality }}".to_string()), instructions_variables: Some(personality_variables()), })); let instructions = model.get_model_instructions(Some(Personality::Friendly)); assert_eq!(instructions, "Hello friendly"); } #[test] fn get_model_instructions_always_strips_placeholder() { let model = test_model(Some(ModelMessages { instructions_template: Some("Hello\n{{ personality }}".to_string()), instructions_variables: Some(ModelInstructionsVariables { personality_default: None, personality_friendly: Some("friendly".to_string()), personality_pragmatic: None, }), })); assert_eq!( model.get_model_instructions(Some(Personality::Friendly)), "Hello\nfriendly" ); assert_eq!( model.get_model_instructions(Some(Personality::Pragmatic)), "Hello\n" ); assert_eq!( model.get_model_instructions(Some(Personality::None)), "Hello\n" ); assert_eq!( model.get_model_instructions(/*personality*/ None), "Hello\n" ); let model_no_personality = test_model(Some(ModelMessages { instructions_template: Some("Hello\n{{ personality }}".to_string()), instructions_variables: Some(ModelInstructionsVariables { personality_default: None, personality_friendly: None, personality_pragmatic: None, }), })); assert_eq!( model_no_personality.get_model_instructions(Some(Personality::Friendly)), "Hello\n" ); assert_eq!( model_no_personality.get_model_instructions(Some(Personality::Pragmatic)), "Hello\n" ); assert_eq!( model_no_personality.get_model_instructions(Some(Personality::None)), "Hello\n" ); assert_eq!( model_no_personality.get_model_instructions(/*personality*/ None), "Hello\n" ); } #[test] fn get_model_instructions_falls_back_when_template_is_missing() { let model = test_model(Some(ModelMessages { instructions_template: None, instructions_variables: Some(ModelInstructionsVariables { personality_default: None, personality_friendly: None, personality_pragmatic: None, }), })); let instructions = model.get_model_instructions(Some(Personality::Friendly)); assert_eq!(instructions, "base"); } #[test] fn get_personality_message_returns_default_when_personality_is_none() { let personality_template = personality_variables(); assert_eq!( personality_template.get_personality_message(/*personality*/ None), Some("default".to_string()) ); } #[test] fn get_personality_message() { let personality_variables = personality_variables(); assert_eq!( personality_variables.get_personality_message(Some(Personality::Friendly)), Some("friendly".to_string()) ); assert_eq!( personality_variables.get_personality_message(Some(Personality::Pragmatic)), Some("pragmatic".to_string()) ); assert_eq!( personality_variables.get_personality_message(Some(Personality::None)), Some(String::new()) ); assert_eq!( personality_variables.get_personality_message(/*personality*/ None), Some("default".to_string()) ); let personality_variables = ModelInstructionsVariables { personality_default: Some("default".to_string()), personality_friendly: None, personality_pragmatic: None, }; assert_eq!( personality_variables.get_personality_message(Some(Personality::Friendly)), None ); assert_eq!( personality_variables.get_personality_message(Some(Personality::Pragmatic)), None ); assert_eq!( personality_variables.get_personality_message(Some(Personality::None)), Some(String::new()) ); assert_eq!( personality_variables.get_personality_message(/*personality*/ None), Some("default".to_string()) ); let personality_variables = ModelInstructionsVariables { personality_default: None, personality_friendly: Some("friendly".to_string()), personality_pragmatic: Some("pragmatic".to_string()), }; assert_eq!( personality_variables.get_personality_message(Some(Personality::Friendly)), Some("friendly".to_string()) ); assert_eq!( personality_variables.get_personality_message(Some(Personality::Pragmatic)), Some("pragmatic".to_string()) ); assert_eq!( personality_variables.get_personality_message(Some(Personality::None)), Some(String::new()) ); assert_eq!( personality_variables.get_personality_message(/*personality*/ None), None ); } #[test] fn model_info_defaults_availability_nux_to_none_when_omitted() { let model: ModelInfo = serde_json::from_value(serde_json::json!({ "slug": "test-model", "display_name": "Test Model", "description": null, "supported_reasoning_levels": [], "shell_type": "shell_command", "visibility": "list", "supported_in_api": true, "priority": 1, "upgrade": null, "base_instructions": "base", "model_messages": null, "supports_reasoning_summaries": false, "default_reasoning_summary": "auto", "support_verbosity": false, "default_verbosity": null, "apply_patch_tool_type": null, "truncation_policy": { "mode": "bytes", "limit": 10000 }, "supports_parallel_tool_calls": false, "supports_image_detail_original": false, "context_window": null, "auto_compact_token_limit": null, "effective_context_window_percent": 95, "experimental_supported_tools": [], "input_modalities": ["text", "image"] })) .expect("deserialize model info"); assert_eq!(model.availability_nux, None); assert!(!model.supports_image_detail_original); assert_eq!(model.web_search_tool_type, WebSearchToolType::Text); assert!(!model.supports_search_tool); } #[test] fn model_preset_preserves_availability_nux() { let preset = ModelPreset::from(ModelInfo { availability_nux: Some(ModelAvailabilityNux { message: "Try Spark.".to_string(), }), additional_speed_tiers: vec![SPEED_TIER_FAST.to_string()], ..test_model(/*spec*/ None) }); assert_eq!( preset.availability_nux, Some(ModelAvailabilityNux { message: "Try Spark.".to_string(), }) ); assert!(preset.supports_fast_mode()); } }