Skip to main content

codex_protocol/
openai_models.rs

1//! Shared model metadata types exchanged between Codex services and clients.
2//!
3//! These types are serialized across core, TUI, app-server, and SDK boundaries, so field defaults
4//! are used to preserve compatibility when older payloads omit newly introduced attributes.
5
6use std::fmt;
7use std::str::FromStr;
8
9use schemars::JsonSchema;
10use schemars::r#gen::SchemaGenerator;
11use schemars::schema::InstanceType;
12use schemars::schema::Metadata;
13use schemars::schema::Schema;
14use schemars::schema::SchemaObject;
15use schemars::schema::StringValidation;
16use serde::Deserialize;
17use serde::Deserializer;
18use serde::Serialize;
19use serde::Serializer;
20use serde::de::DeserializeOwned;
21use serde::de::Error;
22use strum_macros::Display;
23use strum_macros::EnumIter;
24use tracing::trace;
25use ts_rs::TS;
26
27use crate::config_types::Personality;
28use crate::config_types::ReasoningSummary;
29use crate::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
30use crate::config_types::ServiceTier;
31use crate::config_types::Verbosity;
32use crate::protocol::MultiAgentVersion;
33
34const PERSONALITY_PLACEHOLDER: &str = "{{ personality }}";
35pub const SPEED_TIER_FAST: &str = "fast";
36
37/// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning
38#[derive(Debug, Default, Clone, PartialEq, Eq, TS, Hash)]
39#[ts(type = "string")]
40pub enum ReasoningEffort {
41    None,
42    Minimal,
43    Low,
44    #[default]
45    Medium,
46    High,
47    XHigh,
48    Max,
49    Ultra,
50    /// A model-defined effort value that this client does not know yet.
51    Custom(String),
52}
53
54impl ReasoningEffort {
55    /// Returns the exact value used on the wire.
56    pub fn as_str(&self) -> &str {
57        match self {
58            Self::None => "none",
59            Self::Minimal => "minimal",
60            Self::Low => "low",
61            Self::Medium => "medium",
62            Self::High => "high",
63            Self::XHigh => "xhigh",
64            Self::Max => "max",
65            Self::Ultra => "ultra",
66            Self::Custom(effort) => effort,
67        }
68    }
69}
70
71impl fmt::Display for ReasoningEffort {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        f.write_str(self.as_str())
74    }
75}
76
77impl JsonSchema for ReasoningEffort {
78    fn schema_name() -> String {
79        "ReasoningEffort".to_string()
80    }
81
82    fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
83        Schema::Object(SchemaObject {
84            instance_type: Some(InstanceType::String.into()),
85            metadata: Some(Box::new(Metadata {
86                description: Some(
87                    "A non-empty reasoning effort value advertised by the model.".to_string(),
88                ),
89                ..Default::default()
90            })),
91            string: Some(Box::new(StringValidation {
92                min_length: Some(1),
93                ..Default::default()
94            })),
95            ..Default::default()
96        })
97    }
98}
99
100impl Serialize for ReasoningEffort {
101    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
102    where
103        S: Serializer,
104    {
105        serializer.serialize_str(self.as_str())
106    }
107}
108
109impl<'de> Deserialize<'de> for ReasoningEffort {
110    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
111    where
112        D: Deserializer<'de>,
113    {
114        let effort = String::deserialize(deserializer)?;
115        effort.parse().map_err(D::Error::custom)
116    }
117}
118
119impl FromStr for ReasoningEffort {
120    type Err = String;
121
122    fn from_str(s: &str) -> Result<Self, Self::Err> {
123        match s {
124            "none" => Ok(Self::None),
125            "minimal" => Ok(Self::Minimal),
126            "low" => Ok(Self::Low),
127            "medium" => Ok(Self::Medium),
128            "high" => Ok(Self::High),
129            "xhigh" => Ok(Self::XHigh),
130            "max" => Ok(Self::Max),
131            "ultra" => Ok(Self::Ultra),
132            "" => Err("reasoning_effort must not be empty".to_string()),
133            effort => Ok(Self::Custom(effort.to_string())),
134        }
135    }
136}
137
138/// Canonical user-input modality tags advertised by a model.
139#[derive(
140    Debug,
141    Serialize,
142    Deserialize,
143    Clone,
144    Copy,
145    PartialEq,
146    Eq,
147    Display,
148    JsonSchema,
149    TS,
150    EnumIter,
151    Hash,
152)]
153#[serde(rename_all = "lowercase")]
154#[strum(serialize_all = "lowercase")]
155pub enum InputModality {
156    /// Plain text turns and tool payloads.
157    Text,
158    /// Image attachments included in user turns.
159    Image,
160    /// Audio attachments included in user turns.
161    Audio,
162}
163
164/// Backward-compatible default when `input_modalities` is omitted on the wire.
165///
166/// Legacy payloads predate modality metadata, so we conservatively assume both text and images are
167/// accepted unless a preset explicitly narrows support.
168pub fn default_input_modalities() -> Vec<InputModality> {
169    vec![InputModality::Text, InputModality::Image]
170}
171
172/// A reasoning effort option that can be surfaced for a model.
173#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
174pub struct ReasoningEffortPreset {
175    /// Effort level that the model supports.
176    pub effort: ReasoningEffort,
177    /// Short human description shown next to the effort in UIs.
178    pub description: String,
179}
180
181#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
182pub struct ModelUpgrade {
183    pub id: String,
184    pub migration_config_key: String,
185    pub model_link: Option<String>,
186    pub upgrade_copy: Option<String>,
187    pub migration_markdown: Option<String>,
188}
189
190#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
191pub struct ModelAvailabilityNux {
192    pub message: String,
193}
194
195#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
196pub struct ModelServiceTier {
197    pub id: String,
198    pub name: String,
199    pub description: String,
200}
201
202/// Metadata describing a Codex-supported model.
203#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
204pub struct ModelPreset {
205    /// Stable identifier for the preset.
206    pub id: String,
207    /// Model slug (e.g., "gpt-5").
208    pub model: String,
209    /// Display name shown in UIs.
210    pub display_name: String,
211    /// Short human description shown in UIs.
212    pub description: String,
213    /// Reasoning effort applied when none is explicitly chosen.
214    pub default_reasoning_effort: ReasoningEffort,
215    /// Supported reasoning effort options.
216    pub supported_reasoning_efforts: Vec<ReasoningEffortPreset>,
217    /// Whether this model supports personality-specific instructions.
218    #[serde(default)]
219    pub supports_personality: bool,
220    /// Deprecated: use `service_tiers` instead.
221    #[serde(default)]
222    pub additional_speed_tiers: Vec<String>,
223    /// Service tiers this model can run with.
224    #[serde(default)]
225    pub service_tiers: Vec<ModelServiceTier>,
226    /// Catalog default service tier id for this model.
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub default_service_tier: Option<String>,
229    /// Whether this is the default model for new users.
230    pub is_default: bool,
231    /// recommended upgrade model
232    pub upgrade: Option<ModelUpgrade>,
233    /// Whether this preset should appear in the picker UI.
234    pub show_in_picker: bool,
235    /// Multi-agent backend selected when this model starts a new thread.
236    #[serde(default, skip_serializing, skip_deserializing)]
237    #[schemars(skip)]
238    #[ts(skip)]
239    pub multi_agent_version: Option<MultiAgentVersion>,
240    /// Availability NUX shown when this preset becomes accessible to the user.
241    pub availability_nux: Option<ModelAvailabilityNux>,
242    /// whether this model is supported in the api
243    pub supported_in_api: bool,
244    /// Input modalities accepted when composing user turns for this preset.
245    #[serde(default = "default_input_modalities")]
246    pub input_modalities: Vec<InputModality>,
247}
248
249/// Visibility of a model in the picker or APIs.
250#[derive(
251    Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, TS, JsonSchema, EnumIter, Display,
252)]
253#[serde(rename_all = "lowercase")]
254#[strum(serialize_all = "lowercase")]
255pub enum ModelVisibility {
256    List,
257    Hide,
258    None,
259}
260
261/// Shell execution capability for a model.
262#[derive(
263    Debug,
264    Serialize,
265    Deserialize,
266    Clone,
267    Copy,
268    PartialEq,
269    Eq,
270    TS,
271    JsonSchema,
272    EnumIter,
273    Display,
274    Hash,
275)]
276#[serde(rename_all = "snake_case")]
277#[strum(serialize_all = "snake_case")]
278pub enum ConfigShellToolType {
279    Default,
280    Local,
281    UnifiedExec,
282    Disabled,
283    ShellCommand,
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, TS, JsonSchema)]
287#[serde(rename_all = "snake_case")]
288pub enum ApplyPatchToolType {
289    Freeform,
290}
291
292#[derive(
293    Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, TS, JsonSchema, Default,
294)]
295#[serde(rename_all = "snake_case")]
296pub enum WebSearchToolType {
297    #[default]
298    Text,
299    TextAndImage,
300}
301
302/// Server-provided truncation policy metadata for a model.
303#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, TS, JsonSchema)]
304#[serde(rename_all = "snake_case")]
305pub enum TruncationMode {
306    Bytes,
307    Tokens,
308}
309
310#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, TS, JsonSchema)]
311#[serde(rename_all = "snake_case")]
312pub enum ToolMode {
313    Direct,
314    CodeMode,
315    CodeModeOnly,
316}
317
318fn deserialize_optional_model_selector<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
319where
320    D: Deserializer<'de>,
321    T: DeserializeOwned,
322{
323    let Some(value) = Option::<String>::deserialize(deserializer)? else {
324        return Ok(None);
325    };
326    Ok(serde_json::from_value(serde_json::Value::String(value)).ok())
327}
328
329#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, TS, JsonSchema)]
330pub struct TruncationPolicyConfig {
331    pub mode: TruncationMode,
332    pub limit: i64,
333}
334
335impl TruncationPolicyConfig {
336    pub const fn bytes(limit: i64) -> Self {
337        Self {
338            mode: TruncationMode::Bytes,
339            limit,
340        }
341    }
342
343    pub const fn tokens(limit: i64) -> Self {
344        Self {
345            mode: TruncationMode::Tokens,
346            limit,
347        }
348    }
349}
350
351/// Semantic version triple encoded as an array in JSON (e.g. [0, 62, 0]).
352#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, TS, JsonSchema)]
353pub struct ClientVersion(pub i32, pub i32, pub i32);
354
355const fn default_effective_context_window_percent() -> i64 {
356    95
357}
358
359const fn default_true() -> bool {
360    true
361}
362
363#[allow(clippy::trivially_copy_pass_by_ref)]
364const fn is_true(value: &bool) -> bool {
365    *value
366}
367
368/// Model metadata returned by the Codex backend `/models` endpoint.
369#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
370pub struct ModelInfo {
371    pub slug: String,
372    pub display_name: String,
373    pub description: Option<String>,
374    #[serde(default, skip_serializing_if = "Option::is_none")]
375    pub default_reasoning_level: Option<ReasoningEffort>,
376    pub supported_reasoning_levels: Vec<ReasoningEffortPreset>,
377    pub shell_type: ConfigShellToolType,
378    pub visibility: ModelVisibility,
379    pub supported_in_api: bool,
380    pub priority: i32,
381    #[serde(default)]
382    pub additional_speed_tiers: Vec<String>,
383    #[serde(default)]
384    pub service_tiers: Vec<ModelServiceTier>,
385    #[serde(default, skip_serializing_if = "Option::is_none")]
386    pub default_service_tier: Option<String>,
387    pub availability_nux: Option<ModelAvailabilityNux>,
388    pub upgrade: Option<ModelInfoUpgrade>,
389    pub base_instructions: String,
390    #[serde(default, skip_serializing_if = "Option::is_none")]
391    pub model_messages: Option<ModelMessages>,
392    #[serde(default)]
393    pub include_skills_usage_instructions: bool,
394    /// Whether the model accepts the Responses API `reasoning.summary` parameter.
395    #[serde(default = "default_true", skip_serializing_if = "is_true")]
396    pub supports_reasoning_summary_parameter: bool,
397    #[serde(default)]
398    pub default_reasoning_summary: ReasoningSummary,
399    pub support_verbosity: bool,
400    pub default_verbosity: Option<Verbosity>,
401    pub apply_patch_tool_type: Option<ApplyPatchToolType>,
402    #[serde(default)]
403    pub web_search_tool_type: WebSearchToolType,
404    pub truncation_policy: TruncationPolicyConfig,
405    pub supports_parallel_tool_calls: bool,
406    #[serde(default)]
407    pub supports_image_detail_original: bool,
408    #[serde(default, skip_serializing_if = "Option::is_none")]
409    pub context_window: Option<i64>,
410    /// Maximum context window allowed for config overrides.
411    #[serde(default, skip_serializing_if = "Option::is_none")]
412    pub max_context_window: Option<i64>,
413    /// Token threshold for automatic compaction. When omitted, core derives it
414    /// from `context_window` (90%). When provided, core clamps it to 90% of the
415    /// context window when available.
416    #[serde(default, skip_serializing_if = "Option::is_none")]
417    pub auto_compact_token_limit: Option<i64>,
418    /// Opaque identifier for compaction-compatible model configurations.
419    #[serde(default, skip_serializing_if = "Option::is_none")]
420    pub comp_hash: Option<String>,
421    /// Percentage of the context window considered usable for inputs, after
422    /// reserving headroom for system prompts, tool overhead, and model output.
423    #[serde(default = "default_effective_context_window_percent")]
424    pub effective_context_window_percent: i64,
425    pub experimental_supported_tools: Vec<String>,
426    /// Input modalities accepted by the backend for this model.
427    #[serde(default = "default_input_modalities")]
428    pub input_modalities: Vec<InputModality>,
429    /// Internal-only marker set by core when a model slug resolved to fallback metadata.
430    #[serde(default, skip_serializing, skip_deserializing)]
431    #[schemars(skip)]
432    #[ts(skip)]
433    pub used_fallback_model_metadata: bool,
434    #[serde(default)]
435    pub supports_search_tool: bool,
436    #[serde(default)]
437    pub use_responses_lite: bool,
438    #[serde(default, skip_serializing_if = "Option::is_none")]
439    pub auto_review_model_override: Option<String>,
440    #[serde(
441        default,
442        skip_serializing_if = "Option::is_none",
443        deserialize_with = "deserialize_optional_model_selector"
444    )]
445    pub tool_mode: Option<ToolMode>,
446    #[serde(
447        default,
448        skip_serializing_if = "Option::is_none",
449        deserialize_with = "deserialize_optional_model_selector"
450    )]
451    pub multi_agent_version: Option<MultiAgentVersion>,
452}
453
454impl ModelInfo {
455    pub fn resolved_context_window(&self) -> Option<i64> {
456        self.context_window.or(self.max_context_window)
457    }
458
459    pub fn auto_compact_token_limit(&self) -> Option<i64> {
460        let context_limit = self
461            .resolved_context_window()
462            .map(|context_window| (context_window * 9) / 10);
463        let config_limit = self.auto_compact_token_limit;
464        if let Some(context_limit) = context_limit {
465            return Some(
466                config_limit.map_or(context_limit, |limit| std::cmp::min(limit, context_limit)),
467            );
468        }
469        config_limit
470    }
471
472    pub fn supports_personality(&self) -> bool {
473        self.model_messages
474            .as_ref()
475            .is_some_and(ModelMessages::supports_personality)
476    }
477
478    pub fn get_model_instructions(&self, personality: Option<Personality>) -> String {
479        if let Some(model_messages) = &self.model_messages
480            && let Some(template) = &model_messages.instructions_template
481        {
482            // if we have a template, always use it
483            let personality_message = model_messages
484                .get_personality_message(personality)
485                .unwrap_or_default();
486            template.replace(PERSONALITY_PLACEHOLDER, personality_message.as_str())
487        } else {
488            match personality {
489                Some(personality @ (Personality::Friendly | Personality::Pragmatic)) => {
490                    trace!(
491                        model = %self.slug,
492                        %personality,
493                        "Model personality requested but model_messages is missing, falling back to base instructions."
494                    );
495                }
496                Some(Personality::None) | None => {}
497            }
498            self.base_instructions.clone()
499        }
500    }
501}
502
503/// A strongly-typed template for assembling model instructions and developer messages. If
504/// instructions_* is populated and valid, it will override base_instructions.
505#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
506pub struct ModelMessages {
507    pub instructions_template: Option<String>,
508    pub instructions_variables: Option<ModelInstructionsVariables>,
509    pub approvals: Option<ApprovalMessages>,
510    pub auto_review: Option<AutoReviewMessages>,
511    pub permissions: Option<PermissionMessages>,
512}
513
514#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
515pub struct ApprovalMessages {
516    pub on_request: Option<String>,
517    pub on_request_auto_review: Option<String>,
518    pub never: Option<String>,
519    pub unless_trusted: Option<String>,
520}
521
522#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
523pub struct AutoReviewMessages {
524    pub policy: Option<String>,
525    pub policy_template: Option<String>,
526}
527
528#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
529pub struct PermissionMessages {
530    pub danger_full_access: Option<String>,
531    pub workspace_write: Option<String>,
532    pub read_only: Option<String>,
533}
534
535impl ModelMessages {
536    fn has_personality_placeholder(&self) -> bool {
537        self.instructions_template
538            .as_ref()
539            .map(|spec| spec.contains(PERSONALITY_PLACEHOLDER))
540            .unwrap_or(false)
541    }
542
543    fn supports_personality(&self) -> bool {
544        self.has_personality_placeholder()
545            && self
546                .instructions_variables
547                .as_ref()
548                .is_some_and(ModelInstructionsVariables::is_complete)
549    }
550
551    pub fn get_personality_message(&self, personality: Option<Personality>) -> Option<String> {
552        self.instructions_variables
553            .as_ref()
554            .and_then(|variables| variables.get_personality_message(personality))
555    }
556}
557
558#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
559pub struct ModelInstructionsVariables {
560    pub personality_default: Option<String>,
561    pub personality_friendly: Option<String>,
562    pub personality_pragmatic: Option<String>,
563}
564
565impl ModelInstructionsVariables {
566    pub fn is_complete(&self) -> bool {
567        self.personality_default.is_some()
568            && self.personality_friendly.is_some()
569            && self.personality_pragmatic.is_some()
570    }
571
572    pub fn get_personality_message(&self, personality: Option<Personality>) -> Option<String> {
573        if let Some(personality) = personality {
574            match personality {
575                Personality::None => Some(String::new()),
576                Personality::Friendly => self.personality_friendly.clone(),
577                Personality::Pragmatic => self.personality_pragmatic.clone(),
578            }
579        } else {
580            self.personality_default.clone()
581        }
582    }
583}
584
585#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
586pub struct ModelInfoUpgrade {
587    pub model: String,
588    pub migration_markdown: String,
589}
590
591impl From<&ModelUpgrade> for ModelInfoUpgrade {
592    fn from(upgrade: &ModelUpgrade) -> Self {
593        ModelInfoUpgrade {
594            model: upgrade.id.clone(),
595            migration_markdown: upgrade.migration_markdown.clone().unwrap_or_default(),
596        }
597    }
598}
599
600/// Response wrapper for `/models`.
601#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema, Default)]
602pub struct ModelsResponse {
603    pub models: Vec<ModelInfo>,
604}
605
606// convert ModelInfo to ModelPreset
607impl From<ModelInfo> for ModelPreset {
608    fn from(info: ModelInfo) -> Self {
609        let supports_personality = info.supports_personality();
610        ModelPreset {
611            id: info.slug.clone(),
612            model: info.slug.clone(),
613            display_name: info.display_name,
614            description: info.description.unwrap_or_default(),
615            default_reasoning_effort: info
616                .default_reasoning_level
617                .unwrap_or(ReasoningEffort::None),
618            supported_reasoning_efforts: info.supported_reasoning_levels.clone(),
619            supports_personality,
620            additional_speed_tiers: info.additional_speed_tiers,
621            service_tiers: info.service_tiers,
622            default_service_tier: info.default_service_tier,
623            is_default: false, // default is the highest priority available model
624            upgrade: info.upgrade.as_ref().map(|upgrade| ModelUpgrade {
625                id: upgrade.model.clone(),
626                migration_config_key: info.slug.clone(),
627                // todo(aibrahim): add the model link here.
628                model_link: None,
629                upgrade_copy: None,
630                migration_markdown: Some(upgrade.migration_markdown.clone()),
631            }),
632            show_in_picker: info.visibility == ModelVisibility::List,
633            multi_agent_version: info.multi_agent_version,
634            availability_nux: info.availability_nux,
635            supported_in_api: info.supported_in_api,
636            input_modalities: info.input_modalities,
637        }
638    }
639}
640
641impl ModelPreset {
642    pub fn supports_fast_mode(&self) -> bool {
643        self.service_tiers
644            .iter()
645            .any(|tier| tier.id == ServiceTier::Fast.request_value())
646            || self
647                .additional_speed_tiers
648                .iter()
649                .any(|tier| tier == SPEED_TIER_FAST)
650    }
651}
652
653impl ModelInfo {
654    pub fn supports_service_tier(&self, service_tier: &str) -> bool {
655        self.service_tiers
656            .iter()
657            .any(|tier| tier.id == service_tier)
658    }
659
660    pub fn service_tier_for_request(&self, service_tier: Option<String>) -> Option<String> {
661        service_tier.filter(|service_tier| {
662            service_tier != SERVICE_TIER_DEFAULT_REQUEST_VALUE
663                && self.supports_service_tier(service_tier)
664        })
665    }
666}
667
668impl ModelPreset {
669    /// Filter models based on authentication mode.
670    ///
671    /// In ChatGPT mode, all models are visible. Otherwise, only API-supported models are shown.
672    pub fn filter_by_auth(models: Vec<ModelPreset>, chatgpt_mode: bool) -> Vec<ModelPreset> {
673        models
674            .into_iter()
675            .filter(|model| chatgpt_mode || model.supported_in_api)
676            .collect()
677    }
678
679    /// Recompute the single default preset using picker visibility.
680    ///
681    /// The first picker-visible model wins; if none are picker-visible, the first model wins.
682    pub fn mark_default_by_picker_visibility(models: &mut [ModelPreset]) {
683        for preset in models.iter_mut() {
684            preset.is_default = false;
685        }
686        if let Some(default) = models.iter_mut().find(|preset| preset.show_in_picker) {
687            default.is_default = true;
688        } else if let Some(default) = models.first_mut() {
689            default.is_default = true;
690        }
691    }
692}
693
694#[cfg(test)]
695mod tests {
696    use super::*;
697    use pretty_assertions::assert_eq;
698    use serde_json::from_str;
699    use serde_json::to_string;
700
701    fn test_model(spec: Option<ModelMessages>) -> ModelInfo {
702        ModelInfo {
703            slug: "test-model".to_string(),
704            display_name: "Test Model".to_string(),
705            description: None,
706            default_reasoning_level: None,
707            supported_reasoning_levels: vec![],
708            shell_type: ConfigShellToolType::ShellCommand,
709            visibility: ModelVisibility::List,
710            supported_in_api: true,
711            priority: 1,
712            additional_speed_tiers: Vec::new(),
713            service_tiers: Vec::new(),
714            default_service_tier: None,
715            availability_nux: None,
716            upgrade: None,
717            base_instructions: "base".to_string(),
718            model_messages: spec,
719            include_skills_usage_instructions: false,
720            supports_reasoning_summary_parameter: true,
721            default_reasoning_summary: ReasoningSummary::Auto,
722            support_verbosity: false,
723            default_verbosity: None,
724            apply_patch_tool_type: None,
725            web_search_tool_type: WebSearchToolType::Text,
726            truncation_policy: TruncationPolicyConfig::bytes(/*limit*/ 10_000),
727            supports_parallel_tool_calls: false,
728            supports_image_detail_original: false,
729            context_window: None,
730            max_context_window: None,
731            auto_compact_token_limit: None,
732            comp_hash: None,
733            effective_context_window_percent: 95,
734            experimental_supported_tools: vec![],
735            input_modalities: default_input_modalities(),
736            used_fallback_model_metadata: false,
737            supports_search_tool: false,
738            use_responses_lite: false,
739            auto_review_model_override: None,
740            tool_mode: None,
741            multi_agent_version: None,
742        }
743    }
744
745    fn personality_variables() -> ModelInstructionsVariables {
746        ModelInstructionsVariables {
747            personality_default: Some("default".to_string()),
748            personality_friendly: Some("friendly".to_string()),
749            personality_pragmatic: Some("pragmatic".to_string()),
750        }
751    }
752
753    #[test]
754    fn model_messages_deserialize_without_approvals() {
755        let messages: ModelMessages =
756            from_str(r#"{"instructions_template":null,"instructions_variables":null}"#)
757                .expect("model messages should deserialize");
758
759        assert_eq!(messages.approvals, None);
760        assert_eq!(messages.permissions, None);
761    }
762
763    #[test]
764    fn approval_messages_preserve_missing_and_empty_values() {
765        let messages: ModelMessages = from_str(
766            r#"{
767                "instructions_template": null,
768                "instructions_variables": null,
769                "approvals": {
770                    "on_request": "",
771                    "never": ""
772                }
773            }"#,
774        )
775        .expect("approval messages should deserialize");
776
777        assert_eq!(
778            messages.approvals,
779            Some(ApprovalMessages {
780                on_request: Some(String::new()),
781                on_request_auto_review: None,
782                never: Some(String::new()),
783                unless_trusted: None,
784            })
785        );
786    }
787
788    #[test]
789    fn auto_review_messages_preserve_missing_and_empty_template_values() {
790        let missing_template: ModelMessages = from_str(
791            r#"{
792                "instructions_template": null,
793                "instructions_variables": null,
794                "auto_review": {
795                    "policy": "policy"
796                }
797            }"#,
798        )
799        .expect("auto-review messages should deserialize without a policy template");
800        let empty_template: ModelMessages = from_str(
801            r#"{
802                "instructions_template": null,
803                "instructions_variables": null,
804                "auto_review": {
805                    "policy": "policy",
806                    "policy_template": ""
807                }
808            }"#,
809        )
810        .expect("auto-review messages should deserialize with an empty policy template");
811
812        assert_eq!(
813            missing_template.auto_review,
814            Some(AutoReviewMessages {
815                policy: Some("policy".to_string()),
816                policy_template: None,
817            })
818        );
819        assert_eq!(
820            empty_template.auto_review,
821            Some(AutoReviewMessages {
822                policy: Some("policy".to_string()),
823                policy_template: Some(String::new()),
824            })
825        );
826    }
827
828    #[test]
829    fn permission_messages_preserve_missing_and_empty_values() {
830        let messages: ModelMessages = from_str(
831            r#"{
832                "instructions_template": null,
833                "instructions_variables": null,
834                "permissions": {
835                    "workspace_write": ""
836                }
837            }"#,
838        )
839        .expect("permission messages should deserialize");
840
841        assert_eq!(
842            messages.permissions,
843            Some(PermissionMessages {
844                danger_full_access: None,
845                workspace_write: Some(String::new()),
846                read_only: None,
847            })
848        );
849    }
850
851    #[test]
852    fn reasoning_effort_accepts_known_and_custom_values() {
853        let custom = ReasoningEffort::Custom("future".to_string());
854        let deserialized = from_str::<ReasoningEffort>(r#""future""#)
855            .expect("custom reasoning effort should deserialize");
856        let serialized = to_string(&custom).expect("custom reasoning effort should serialize");
857        let serialized_max = to_string(&ReasoningEffort::Max).expect("Max should serialize");
858        let serialized_ultra = to_string(&ReasoningEffort::Ultra).expect("Ultra should serialize");
859
860        assert_eq!(
861            (
862                "high".parse(),
863                "max".parse(),
864                "ultra".parse(),
865                "future".parse(),
866                deserialized,
867                serialized,
868                serialized_max,
869                serialized_ultra,
870                custom.to_string(),
871            ),
872            (
873                Ok(ReasoningEffort::High),
874                Ok(ReasoningEffort::Max),
875                Ok(ReasoningEffort::Ultra),
876                Ok(custom.clone()),
877                custom,
878                r#""future""#.to_string(),
879                r#""max""#.to_string(),
880                r#""ultra""#.to_string(),
881                "future".to_string(),
882            )
883        );
884    }
885
886    #[test]
887    fn reasoning_effort_rejects_empty_values() {
888        assert_eq!(
889            "".parse::<ReasoningEffort>(),
890            Err("reasoning_effort must not be empty".to_string())
891        );
892    }
893
894    #[test]
895    fn reasoning_effort_json_schema_is_an_open_string() {
896        let mut effort_generator = SchemaGenerator::default();
897
898        assert_eq!(
899            ReasoningEffort::json_schema(&mut effort_generator),
900            Schema::Object(SchemaObject {
901                instance_type: Some(InstanceType::String.into()),
902                metadata: Some(Box::new(Metadata {
903                    description: Some(
904                        "A non-empty reasoning effort value advertised by the model.".to_string(),
905                    ),
906                    ..Default::default()
907                })),
908                string: Some(Box::new(StringValidation {
909                    min_length: Some(1),
910                    ..Default::default()
911                })),
912                ..Default::default()
913            })
914        );
915    }
916
917    #[test]
918    fn get_model_instructions_uses_template_when_placeholder_present() {
919        let model = test_model(Some(ModelMessages {
920            instructions_template: Some("Hello {{ personality }}".to_string()),
921            instructions_variables: Some(personality_variables()),
922            approvals: None,
923            auto_review: None,
924            permissions: None,
925        }));
926
927        let instructions = model.get_model_instructions(Some(Personality::Friendly));
928
929        assert_eq!(instructions, "Hello friendly");
930    }
931
932    #[test]
933    fn get_model_instructions_always_strips_placeholder() {
934        let model = test_model(Some(ModelMessages {
935            instructions_template: Some("Hello\n{{ personality }}".to_string()),
936            instructions_variables: Some(ModelInstructionsVariables {
937                personality_default: None,
938                personality_friendly: Some("friendly".to_string()),
939                personality_pragmatic: None,
940            }),
941            approvals: None,
942            auto_review: None,
943            permissions: None,
944        }));
945        assert_eq!(
946            model.get_model_instructions(Some(Personality::Friendly)),
947            "Hello\nfriendly"
948        );
949        assert_eq!(
950            model.get_model_instructions(Some(Personality::Pragmatic)),
951            "Hello\n"
952        );
953        assert_eq!(
954            model.get_model_instructions(Some(Personality::None)),
955            "Hello\n"
956        );
957        assert_eq!(
958            model.get_model_instructions(/*personality*/ None),
959            "Hello\n"
960        );
961
962        let model_no_personality = test_model(Some(ModelMessages {
963            instructions_template: Some("Hello\n{{ personality }}".to_string()),
964            instructions_variables: Some(ModelInstructionsVariables {
965                personality_default: None,
966                personality_friendly: None,
967                personality_pragmatic: None,
968            }),
969            approvals: None,
970            auto_review: None,
971            permissions: None,
972        }));
973        assert_eq!(
974            model_no_personality.get_model_instructions(Some(Personality::Friendly)),
975            "Hello\n"
976        );
977        assert_eq!(
978            model_no_personality.get_model_instructions(Some(Personality::Pragmatic)),
979            "Hello\n"
980        );
981        assert_eq!(
982            model_no_personality.get_model_instructions(Some(Personality::None)),
983            "Hello\n"
984        );
985        assert_eq!(
986            model_no_personality.get_model_instructions(/*personality*/ None),
987            "Hello\n"
988        );
989    }
990
991    #[test]
992    fn get_model_instructions_falls_back_when_template_is_missing() {
993        let model = test_model(Some(ModelMessages {
994            instructions_template: None,
995            instructions_variables: Some(ModelInstructionsVariables {
996                personality_default: None,
997                personality_friendly: None,
998                personality_pragmatic: None,
999            }),
1000            approvals: None,
1001            auto_review: None,
1002            permissions: None,
1003        }));
1004
1005        let instructions = model.get_model_instructions(Some(Personality::Friendly));
1006
1007        assert_eq!(instructions, "base");
1008    }
1009
1010    #[test]
1011    fn get_personality_message_returns_default_when_personality_is_none() {
1012        let personality_template = personality_variables();
1013        assert_eq!(
1014            personality_template.get_personality_message(/*personality*/ None),
1015            Some("default".to_string())
1016        );
1017    }
1018
1019    #[test]
1020    fn get_personality_message() {
1021        let personality_variables = personality_variables();
1022        assert_eq!(
1023            personality_variables.get_personality_message(Some(Personality::Friendly)),
1024            Some("friendly".to_string())
1025        );
1026        assert_eq!(
1027            personality_variables.get_personality_message(Some(Personality::Pragmatic)),
1028            Some("pragmatic".to_string())
1029        );
1030        assert_eq!(
1031            personality_variables.get_personality_message(Some(Personality::None)),
1032            Some(String::new())
1033        );
1034        assert_eq!(
1035            personality_variables.get_personality_message(/*personality*/ None),
1036            Some("default".to_string())
1037        );
1038
1039        let personality_variables = ModelInstructionsVariables {
1040            personality_default: Some("default".to_string()),
1041            personality_friendly: None,
1042            personality_pragmatic: None,
1043        };
1044        assert_eq!(
1045            personality_variables.get_personality_message(Some(Personality::Friendly)),
1046            None
1047        );
1048        assert_eq!(
1049            personality_variables.get_personality_message(Some(Personality::Pragmatic)),
1050            None
1051        );
1052        assert_eq!(
1053            personality_variables.get_personality_message(Some(Personality::None)),
1054            Some(String::new())
1055        );
1056        assert_eq!(
1057            personality_variables.get_personality_message(/*personality*/ None),
1058            Some("default".to_string())
1059        );
1060
1061        let personality_variables = ModelInstructionsVariables {
1062            personality_default: None,
1063            personality_friendly: Some("friendly".to_string()),
1064            personality_pragmatic: Some("pragmatic".to_string()),
1065        };
1066        assert_eq!(
1067            personality_variables.get_personality_message(Some(Personality::Friendly)),
1068            Some("friendly".to_string())
1069        );
1070        assert_eq!(
1071            personality_variables.get_personality_message(Some(Personality::Pragmatic)),
1072            Some("pragmatic".to_string())
1073        );
1074        assert_eq!(
1075            personality_variables.get_personality_message(Some(Personality::None)),
1076            Some(String::new())
1077        );
1078        assert_eq!(
1079            personality_variables.get_personality_message(/*personality*/ None),
1080            None
1081        );
1082    }
1083
1084    #[test]
1085    fn model_info_defaults_availability_nux_to_none_when_omitted() {
1086        let model: ModelInfo = serde_json::from_value(serde_json::json!({
1087            "slug": "test-model",
1088            "display_name": "Test Model",
1089            "description": null,
1090            "supported_reasoning_levels": [],
1091            "shell_type": "shell_command",
1092            "visibility": "list",
1093            "supported_in_api": true,
1094            "priority": 1,
1095            "upgrade": null,
1096            "base_instructions": "base",
1097            "model_messages": null,
1098            "default_reasoning_summary": "auto",
1099            "support_verbosity": false,
1100            "default_verbosity": null,
1101            "apply_patch_tool_type": null,
1102            "truncation_policy": {
1103                "mode": "bytes",
1104                "limit": 10000
1105            },
1106            "supports_parallel_tool_calls": false,
1107            "supports_image_detail_original": false,
1108            "context_window": null,
1109            "auto_compact_token_limit": null,
1110            "effective_context_window_percent": 95,
1111            "experimental_supported_tools": []
1112        }))
1113        .expect("deserialize model info");
1114
1115        assert_eq!(model.availability_nux, None);
1116        assert_eq!(
1117            model.input_modalities,
1118            vec![InputModality::Text, InputModality::Image]
1119        );
1120        assert!(!model.include_skills_usage_instructions);
1121        assert!(model.supports_reasoning_summary_parameter);
1122        assert!(!model.supports_image_detail_original);
1123        assert_eq!(model.web_search_tool_type, WebSearchToolType::Text);
1124        assert!(!model.supports_search_tool);
1125        assert!(!model.use_responses_lite);
1126        assert_eq!(model.comp_hash, None);
1127        assert_eq!(model.auto_review_model_override, None);
1128        assert_eq!(model.tool_mode, None);
1129    }
1130
1131    #[test]
1132    fn model_info_deserializes_known_tool_mode() {
1133        let mut value =
1134            serde_json::to_value(test_model(/*spec*/ None)).expect("serialize test model");
1135        let object = value
1136            .as_object_mut()
1137            .expect("model info should be an object");
1138        object.insert(
1139            "tool_mode".to_string(),
1140            serde_json::Value::String("code_mode_only".to_string()),
1141        );
1142        let model = serde_json::from_value::<ModelInfo>(value).expect("deserialize model info");
1143
1144        assert_eq!(model.tool_mode, Some(ToolMode::CodeModeOnly));
1145    }
1146
1147    #[test]
1148    fn model_info_treats_unknown_tool_mode_as_omitted() {
1149        let mut value =
1150            serde_json::to_value(test_model(/*spec*/ None)).expect("serialize test model");
1151        let object = value
1152            .as_object_mut()
1153            .expect("model info should be an object");
1154        object.insert(
1155            "tool_mode".to_string(),
1156            serde_json::Value::String("future_tool_mode".to_string()),
1157        );
1158        let model = serde_json::from_value::<ModelInfo>(value).expect("deserialize model info");
1159
1160        assert_eq!(model.tool_mode, None);
1161        let serialized = serde_json::to_value(model).expect("serialize model info");
1162        let object = serialized
1163            .as_object()
1164            .expect("model info should be an object");
1165        assert!(!object.contains_key("tool_mode"));
1166    }
1167
1168    #[test]
1169    fn model_info_treats_unknown_multi_agent_version_as_omitted() {
1170        let mut value =
1171            serde_json::to_value(test_model(/*spec*/ None)).expect("serialize test model");
1172        let object = value
1173            .as_object_mut()
1174            .expect("model info should be an object");
1175        object.insert(
1176            "multi_agent_version".to_string(),
1177            serde_json::Value::String("future_multi_agent_version".to_string()),
1178        );
1179        let model = serde_json::from_value::<ModelInfo>(value).expect("deserialize model info");
1180
1181        assert_eq!(model.multi_agent_version, None);
1182    }
1183
1184    #[test]
1185    fn resolved_context_window_prefers_context_window() {
1186        let model = ModelInfo {
1187            context_window: Some(273_000),
1188            max_context_window: Some(400_000),
1189            ..test_model(/*spec*/ None)
1190        };
1191
1192        assert_eq!(model.resolved_context_window(), Some(273_000));
1193    }
1194
1195    #[test]
1196    fn resolved_context_window_falls_back_to_max_context_window() {
1197        let model = ModelInfo {
1198            context_window: None,
1199            max_context_window: Some(400_000),
1200            ..test_model(/*spec*/ None)
1201        };
1202
1203        assert_eq!(model.resolved_context_window(), Some(400_000));
1204        assert_eq!(model.auto_compact_token_limit(), Some(360_000));
1205    }
1206
1207    #[test]
1208    fn model_preset_preserves_availability_nux() {
1209        let preset = ModelPreset::from(ModelInfo {
1210            availability_nux: Some(ModelAvailabilityNux {
1211                message: "Try Spark.".to_string(),
1212            }),
1213            additional_speed_tiers: vec![SPEED_TIER_FAST.to_string()],
1214            default_service_tier: Some(ServiceTier::Fast.request_value().to_string()),
1215            service_tiers: Vec::new(),
1216            ..test_model(/*spec*/ None)
1217        });
1218
1219        assert_eq!(
1220            preset.availability_nux,
1221            Some(ModelAvailabilityNux {
1222                message: "Try Spark.".to_string(),
1223            })
1224        );
1225        assert!(preset.supports_fast_mode());
1226        assert_eq!(
1227            preset.default_service_tier,
1228            Some(ServiceTier::Fast.request_value().to_string())
1229        );
1230    }
1231
1232    #[test]
1233    fn model_preset_supports_fast_mode_from_service_tiers() {
1234        let preset = ModelPreset::from(ModelInfo {
1235            service_tiers: vec![ModelServiceTier {
1236                id: ServiceTier::Fast.request_value().to_string(),
1237                name: "Fast".to_string(),
1238                description: "Priority processing.".to_string(),
1239            }],
1240            ..test_model(/*spec*/ None)
1241        });
1242
1243        assert!(preset.supports_fast_mode());
1244    }
1245
1246    #[test]
1247    fn service_tier_for_request_omits_explicit_default_tier() {
1248        let model = ModelInfo {
1249            default_service_tier: Some(ServiceTier::Fast.request_value().to_string()),
1250            service_tiers: vec![ModelServiceTier {
1251                id: ServiceTier::Fast.request_value().to_string(),
1252                name: "Fast".to_string(),
1253                description: "Priority processing.".to_string(),
1254            }],
1255            ..test_model(/*spec*/ None)
1256        };
1257
1258        assert_eq!(
1259            model.service_tier_for_request(Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string())),
1260            None
1261        );
1262    }
1263
1264    #[test]
1265    fn service_tier_for_request_filters_unsupported_tiers() {
1266        let model = ModelInfo {
1267            default_service_tier: Some(ServiceTier::Fast.request_value().to_string()),
1268            service_tiers: vec![ModelServiceTier {
1269                id: ServiceTier::Fast.request_value().to_string(),
1270                name: "Fast".to_string(),
1271                description: "Priority processing.".to_string(),
1272            }],
1273            ..test_model(/*spec*/ None)
1274        };
1275
1276        assert_eq!(
1277            model.service_tier_for_request(Some(ServiceTier::Fast.request_value().to_string())),
1278            Some(ServiceTier::Fast.request_value().to_string())
1279        );
1280        assert_eq!(
1281            model.service_tier_for_request(Some("unsupported".to_string())),
1282            None
1283        );
1284        assert_eq!(model.service_tier_for_request(/*service_tier*/ None), None);
1285    }
1286
1287    #[test]
1288    fn service_tier_for_request_does_not_apply_catalog_default() {
1289        let model = ModelInfo {
1290            default_service_tier: Some(ServiceTier::Fast.request_value().to_string()),
1291            service_tiers: vec![ModelServiceTier {
1292                id: ServiceTier::Fast.request_value().to_string(),
1293                name: "Fast".to_string(),
1294                description: "Priority processing.".to_string(),
1295            }],
1296            ..test_model(/*spec*/ None)
1297        };
1298
1299        assert_eq!(model.service_tier_for_request(/*service_tier*/ None), None);
1300    }
1301}