1use 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#[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 Custom(String),
52}
53
54impl ReasoningEffort {
55 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#[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 Text,
158 Image,
160 Audio,
162}
163
164pub fn default_input_modalities() -> Vec<InputModality> {
169 vec![InputModality::Text, InputModality::Image]
170}
171
172#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
174pub struct ReasoningEffortPreset {
175 pub effort: ReasoningEffort,
177 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#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
204pub struct ModelPreset {
205 pub id: String,
207 pub model: String,
209 pub display_name: String,
211 pub description: String,
213 pub default_reasoning_effort: ReasoningEffort,
215 pub supported_reasoning_efforts: Vec<ReasoningEffortPreset>,
217 #[serde(default)]
219 pub supports_personality: bool,
220 #[serde(default)]
222 pub additional_speed_tiers: Vec<String>,
223 #[serde(default)]
225 pub service_tiers: Vec<ModelServiceTier>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub default_service_tier: Option<String>,
229 pub is_default: bool,
231 pub upgrade: Option<ModelUpgrade>,
233 pub show_in_picker: bool,
235 #[serde(default, skip_serializing, skip_deserializing)]
237 #[schemars(skip)]
238 #[ts(skip)]
239 pub multi_agent_version: Option<MultiAgentVersion>,
240 pub availability_nux: Option<ModelAvailabilityNux>,
242 pub supported_in_api: bool,
244 #[serde(default = "default_input_modalities")]
246 pub input_modalities: Vec<InputModality>,
247}
248
249#[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#[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#[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#[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#[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 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
412 pub max_context_window: Option<i64>,
413 #[serde(default, skip_serializing_if = "Option::is_none")]
417 pub auto_compact_token_limit: Option<i64>,
418 #[serde(default, skip_serializing_if = "Option::is_none")]
420 pub comp_hash: Option<String>,
421 #[serde(default = "default_effective_context_window_percent")]
424 pub effective_context_window_percent: i64,
425 pub experimental_supported_tools: Vec<String>,
426 #[serde(default = "default_input_modalities")]
428 pub input_modalities: Vec<InputModality>,
429 #[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 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#[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#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema, Default)]
602pub struct ModelsResponse {
603 pub models: Vec<ModelInfo>,
604}
605
606impl 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, upgrade: info.upgrade.as_ref().map(|upgrade| ModelUpgrade {
625 id: upgrade.model.clone(),
626 migration_config_key: info.slug.clone(),
627 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 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 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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(None)
1297 };
1298
1299 assert_eq!(model.service_tier_for_request(None), None);
1300 }
1301}