| |
| |
| |
| |
|
|
| use std::fmt; |
| use std::str::FromStr; |
|
|
| use chrono::DateTime; |
| use chrono::Utc; |
| use schemars::JsonSchema; |
| use schemars::r#gen::SchemaGenerator; |
| use schemars::schema::InstanceType; |
| use schemars::schema::Metadata; |
| use schemars::schema::Schema; |
| use schemars::schema::SchemaObject; |
| use schemars::schema::StringValidation; |
| use serde::Deserialize; |
| use serde::Deserializer; |
| use serde::Serialize; |
| use serde::Serializer; |
| use serde::de::DeserializeOwned; |
| use serde::de::Error; |
| 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::SERVICE_TIER_DEFAULT_REQUEST_VALUE; |
| use crate::config_types::ServiceTier; |
| use crate::config_types::Verbosity; |
| use crate::protocol::MultiAgentVersion; |
|
|
| mod access_programs; |
| #[path = "openai_models/guardian.rs"] |
| mod guardian; |
| pub use guardian::GuardianModelPolicy; |
| pub use guardian::GuardianReviewMode; |
| pub use guardian::GuardianScope; |
|
|
| #[path = "openai_models/guardian_v2.rs"] |
| mod guardian_v2; |
| #[path = "openai_models/reasoning_effort.rs"] |
| mod reasoning_effort; |
|
|
| pub use access_programs::ModelAccessPrograms; |
| pub use guardian_v2::GuardianV2ModelConfig; |
| pub use guardian_v2::GuardianV2TranscriptModelConfig; |
|
|
| |
| pub const MODEL_SPECIALTY_CYBER: &str = "cyber"; |
| pub const SPEED_TIER_FAST: &str = "fast"; |
|
|
| |
| #[derive(Debug, Default, Clone, PartialEq, Eq, TS, Hash)] |
| #[ts(type = "string")] |
| pub enum ReasoningEffort { |
| None, |
| Minimal, |
| Low, |
| #[default] |
| Medium, |
| High, |
| XHigh, |
| Max, |
| Ultra, |
| Persistent, |
| |
| Custom(String), |
| } |
|
|
| impl ReasoningEffort { |
| |
| pub fn as_str(&self) -> &str { |
| match self { |
| Self::None => "none", |
| Self::Minimal => "minimal", |
| Self::Low => "low", |
| Self::Medium => "medium", |
| Self::High => "high", |
| Self::XHigh => "xhigh", |
| Self::Max => "max", |
| Self::Ultra => "ultra", |
| Self::Persistent => "persistent", |
| Self::Custom(effort) => effort, |
| } |
| } |
| } |
|
|
| impl fmt::Display for ReasoningEffort { |
| fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| f.write_str(self.as_str()) |
| } |
| } |
|
|
| impl JsonSchema for ReasoningEffort { |
| fn schema_name() -> String { |
| "ReasoningEffort".to_string() |
| } |
|
|
| fn json_schema(_generator: &mut SchemaGenerator) -> Schema { |
| Schema::Object(SchemaObject { |
| instance_type: Some(InstanceType::String.into()), |
| metadata: Some(Box::new(Metadata { |
| description: Some( |
| "A non-empty reasoning effort value advertised by the model.".to_string(), |
| ), |
| ..Default::default() |
| })), |
| string: Some(Box::new(StringValidation { |
| min_length: Some(1), |
| ..Default::default() |
| })), |
| ..Default::default() |
| }) |
| } |
| } |
|
|
| impl Serialize for ReasoningEffort { |
| fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> |
| where |
| S: Serializer, |
| { |
| serializer.serialize_str(self.as_str()) |
| } |
| } |
|
|
| impl<'de> Deserialize<'de> for ReasoningEffort { |
| fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> |
| where |
| D: Deserializer<'de>, |
| { |
| let effort = String::deserialize(deserializer)?; |
| effort.parse().map_err(D::Error::custom) |
| } |
| } |
|
|
| impl FromStr for ReasoningEffort { |
| type Err = String; |
|
|
| fn from_str(s: &str) -> Result<Self, Self::Err> { |
| match s { |
| "none" => Ok(Self::None), |
| "minimal" => Ok(Self::Minimal), |
| "low" => Ok(Self::Low), |
| "medium" => Ok(Self::Medium), |
| "high" => Ok(Self::High), |
| "xhigh" => Ok(Self::XHigh), |
| "max" => Ok(Self::Max), |
| "ultra" => Ok(Self::Ultra), |
| "persistent" => Ok(Self::Persistent), |
| "" => Err("reasoning_effort must not be empty".to_string()), |
| effort => Ok(Self::Custom(effort.to_string())), |
| } |
| } |
| } |
|
|
| |
| #[derive( |
| Debug, |
| Serialize, |
| Deserialize, |
| Clone, |
| Copy, |
| PartialEq, |
| Eq, |
| Display, |
| JsonSchema, |
| TS, |
| EnumIter, |
| Hash, |
| )] |
| #[serde(rename_all = "lowercase")] |
| #[strum(serialize_all = "lowercase")] |
| pub enum InputModality { |
| |
| Text, |
| |
| Image, |
| |
| Audio, |
| } |
|
|
| |
| |
| |
| |
| pub fn default_input_modalities() -> Vec<InputModality> { |
| vec![InputModality::Text, InputModality::Image] |
| } |
|
|
| |
| #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)] |
| pub struct ReasoningEffortPreset { |
| |
| pub effort: ReasoningEffort, |
| |
| pub description: String, |
| } |
|
|
| #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] |
| pub struct ModelUpgrade { |
| pub id: String, |
| pub migration_config_key: String, |
| pub model_link: Option<String>, |
| pub upgrade_copy: Option<String>, |
| pub migration_markdown: Option<String>, |
| |
| #[serde( |
| default, |
| skip_serializing_if = "Option::is_none", |
| deserialize_with = "deserialize_optional_rfc3339_timestamp" |
| )] |
| #[schemars(with = "Option<String>")] |
| #[ts(type = "string", optional)] |
| pub retirement_at: Option<DateTime<Utc>>, |
| } |
|
|
| #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)] |
| pub struct ModelAvailabilityNux { |
| pub message: String, |
| } |
|
|
| #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)] |
| pub struct ModelServiceTier { |
| pub id: String, |
| pub name: String, |
| pub description: String, |
| } |
|
|
| |
| #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] |
| pub struct ModelPreset { |
| |
| pub id: String, |
| |
| pub model: String, |
| |
| pub display_name: String, |
| |
| pub description: String, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| pub model_specialty: Option<String>, |
| |
| pub default_reasoning_effort: ReasoningEffort, |
| |
| pub supported_reasoning_efforts: Vec<ReasoningEffortPreset>, |
| |
| #[serde(default)] |
| pub supports_personality: bool, |
| |
| #[serde(default)] |
| pub additional_speed_tiers: Vec<String>, |
| |
| #[serde(default)] |
| pub service_tiers: Vec<ModelServiceTier>, |
| |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| pub default_service_tier: Option<String>, |
| |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| pub available_access_programs: Option<ModelAccessPrograms>, |
| |
| pub is_default: bool, |
| |
| pub upgrade: Option<ModelUpgrade>, |
| |
| pub show_in_picker: bool, |
| |
| #[serde(default, skip_serializing, skip_deserializing)] |
| #[schemars(skip)] |
| #[ts(skip)] |
| pub multi_agent_version: Option<MultiAgentVersion>, |
| |
| pub availability_nux: Option<ModelAvailabilityNux>, |
| |
| pub supported_in_api: bool, |
| |
| #[serde(default = "default_input_modalities")] |
| pub input_modalities: Vec<InputModality>, |
| } |
|
|
| |
| #[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, |
| } |
|
|
| |
| #[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 { |
| #[serde(alias = "default", alias = "local", alias = "shell_command")] |
| UnifiedExec, |
| Disabled, |
| } |
|
|
| #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, TS, JsonSchema)] |
| #[serde(rename_all = "snake_case")] |
| pub enum ApplyPatchToolType { |
| Freeform, |
| } |
|
|
| #[derive( |
| Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, TS, JsonSchema, Default, |
| )] |
| #[serde(rename_all = "snake_case")] |
| pub enum WebSearchToolType { |
| #[default] |
| Text, |
| TextAndImage, |
| } |
|
|
| |
| #[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)] |
| #[serde(rename_all = "snake_case")] |
| pub enum ToolMode { |
| Direct, |
| CodeMode, |
| CodeModeOnly, |
| } |
|
|
| fn deserialize_optional_model_selector<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error> |
| where |
| D: Deserializer<'de>, |
| T: DeserializeOwned, |
| { |
| let Some(value) = Option::<String>::deserialize(deserializer)? else { |
| return Ok(None); |
| }; |
| Ok(serde_json::from_value(serde_json::Value::String(value)).ok()) |
| } |
|
|
| #[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, |
| } |
| } |
| } |
|
|
| |
| #[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 |
| } |
|
|
| const fn default_true() -> bool { |
| true |
| } |
|
|
| #[allow(clippy::trivially_copy_pass_by_ref)] |
| const fn is_true(value: &bool) -> bool { |
| *value |
| } |
|
|
| |
| #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] |
| pub struct ModelInfo { |
| |
| |
| |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| pub guardian: Option<GuardianModelPolicy>, |
| pub slug: String, |
| pub display_name: String, |
| pub description: Option<String>, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| pub default_reasoning_level: Option<ReasoningEffort>, |
| pub supported_reasoning_levels: Vec<ReasoningEffortPreset>, |
| pub shell_type: ConfigShellToolType, |
| pub visibility: ModelVisibility, |
| pub supported_in_api: bool, |
| pub priority: i32, |
| #[serde(default)] |
| pub additional_speed_tiers: Vec<String>, |
| #[serde(default)] |
| pub service_tiers: Vec<ModelServiceTier>, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| pub default_service_tier: Option<String>, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| pub available_access_programs: Option<ModelAccessPrograms>, |
| pub availability_nux: Option<ModelAvailabilityNux>, |
| pub upgrade: Option<ModelInfoUpgrade>, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| pub model_messages: Option<ModelMessages>, |
| #[serde(default)] |
| pub include_skills_usage_instructions: bool, |
| #[serde(default)] |
| pub include_plugin_usage_instructions: bool, |
| #[serde(default = "default_true")] |
| pub include_apps_usage_instructions: bool, |
| |
| #[serde(default = "default_true", skip_serializing_if = "is_true")] |
| pub supports_reasoning_summary_parameter: bool, |
| #[serde(default)] |
| pub default_reasoning_summary: ReasoningSummary, |
| pub support_verbosity: bool, |
| pub default_verbosity: Option<Verbosity>, |
| pub apply_patch_tool_type: Option<ApplyPatchToolType>, |
| #[serde(default)] |
| pub web_search_tool_type: WebSearchToolType, |
| pub truncation_policy: TruncationPolicyConfig, |
| #[serde(default)] |
| pub supports_image_detail_original: bool, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| pub context_window: Option<i64>, |
| |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| pub max_context_window: Option<i64>, |
| |
| |
| |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| pub auto_compact_token_limit: Option<i64>, |
| |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| pub comp_hash: Option<String>, |
| |
| |
| #[serde(default = "default_effective_context_window_percent")] |
| pub effective_context_window_percent: i64, |
| pub experimental_supported_tools: Vec<String>, |
| |
| #[serde(default = "default_input_modalities")] |
| pub input_modalities: Vec<InputModality>, |
| |
| #[serde(default, skip_serializing, skip_deserializing)] |
| #[schemars(skip)] |
| #[ts(skip)] |
| pub used_fallback_model_metadata: bool, |
| #[serde(default)] |
| pub supports_search_tool: bool, |
| |
| #[serde(default)] |
| pub supports_experimental_context: bool, |
| #[serde(default)] |
| pub use_responses_lite: bool, |
| #[serde(default)] |
| pub node_repl_auto_review_required: bool, |
| #[serde(default)] |
| pub node_repl_disabled: bool, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| pub auto_review_model_override: Option<String>, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| pub model_specialty: Option<String>, |
| #[serde( |
| default, |
| skip_serializing_if = "Option::is_none", |
| deserialize_with = "deserialize_optional_model_selector" |
| )] |
| pub tool_mode: Option<ToolMode>, |
| #[serde( |
| default, |
| skip_serializing_if = "Option::is_none", |
| deserialize_with = "deserialize_optional_model_selector" |
| )] |
| pub multi_agent_version: Option<MultiAgentVersion>, |
| |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| pub multi_agent_reasoning_effort: Option<ReasoningEffort>, |
| } |
|
|
| impl ModelInfo { |
| pub fn resolved_context_window(&self) -> Option<i64> { |
| self.context_window.or(self.max_context_window) |
| } |
|
|
| |
| pub fn usable_context_window(&self) -> Option<i64> { |
| self.resolved_context_window().map(|context_window| { |
| context_window.saturating_mul(self.effective_context_window_percent) / 100 |
| }) |
| } |
|
|
| pub fn auto_compact_token_limit(&self) -> Option<i64> { |
| let context_limit = self |
| .resolved_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 get_model_instructions(&self, _personality: Option<Personality>) -> String { |
| if let Some(model_messages) = &self.model_messages |
| && let Some(template) = &model_messages.instructions_template |
| { |
| template.clone() |
| } else { |
| warn!( |
| model = %self.slug, |
| "Model has no instruction template; returning empty instructions." |
| ); |
| String::new() |
| } |
| } |
| } |
|
|
| |
| |
| |
| |
| #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] |
| pub struct ModelMessages { |
| |
| |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| pub persistent_instructions: Option<String>, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| pub tools: Option<ToolMessages>, |
| pub instructions_template: Option<String>, |
| pub instructions_variables: Option<ModelInstructionsVariables>, |
| pub approvals: Option<ApprovalMessages>, |
| pub collaboration_modes: Option<CollaborationModeMessages>, |
| pub auto_review: Option<AutoReviewMessages>, |
| pub permissions: Option<PermissionMessages>, |
| pub multi_agent: Option<MultiAgentMessages>, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| pub token_budget: Option<ModelTokenBudgetConfig>, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| pub guardian_v2: Option<GuardianV2ModelConfig>, |
| |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| pub confirmation_policies: Option<ConfirmationPolicies>, |
| } |
|
|
| |
| #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] |
| pub struct ConfirmationPolicies { |
| |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| pub browser_use: Option<String>, |
| |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| pub computer_use: Option<String>, |
| } |
|
|
| |
| #[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] |
| pub struct ToolMessages { |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| pub send_user_message_async: Option<ToolMessage>, |
| } |
|
|
| |
| #[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] |
| pub struct ToolMessage { |
| |
| |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| pub description: Option<String>, |
| } |
|
|
| |
| #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] |
| pub struct ModelTokenBudgetConfig { |
| #[serde(default)] |
| pub enabled: bool, |
| #[serde(default)] |
| pub use_history_notes_extension: bool, |
| pub reminder_threshold_tokens: i64, |
| pub reminder_message_template: String, |
| pub guidance_message: String, |
| pub auto_compact_fallback_prompt: String, |
| pub auto_compact_fallback_buffer_tokens: i64, |
| } |
|
|
| #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] |
| pub struct ApprovalMessages { |
| pub on_request: Option<String>, |
| pub on_request_auto_review: Option<String>, |
| pub never: Option<String>, |
| pub unless_trusted: Option<String>, |
| } |
|
|
| #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] |
| pub struct CollaborationModeMessages { |
| pub default: Option<String>, |
| pub plan: Option<String>, |
| } |
|
|
| #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] |
| pub struct AutoReviewMessages { |
| pub policy: Option<String>, |
| pub policy_template: Option<String>, |
| |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| pub node_repl_policy: Option<String>, |
| pub rejection_instructions: Option<String>, |
| pub timeout_instructions: Option<String>, |
| } |
|
|
| #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] |
| pub struct PermissionMessages { |
| pub danger_full_access: Option<String>, |
| pub workspace_write: Option<String>, |
| pub read_only: Option<String>, |
| } |
|
|
| #[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] |
| pub struct MultiAgentMessages { |
| pub role: Option<MultiAgentRoleMessages>, |
| pub mode: Option<MultiAgentModeMessages>, |
| } |
|
|
| #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] |
| pub struct MultiAgentRoleMessages { |
| pub root: Option<String>, |
| pub subagent: Option<String>, |
| } |
|
|
| #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] |
| pub struct MultiAgentModeMessages { |
| pub explicit: Option<String>, |
| |
| |
| pub proactive: Option<String>, |
| pub hint_text: Option<String>, |
| } |
|
|
| #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] |
| pub struct ModelInstructionsVariables { |
| pub personality_default: Option<String>, |
| pub personality_friendly: Option<String>, |
| pub personality_pragmatic: Option<String>, |
| } |
|
|
| #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] |
| pub struct ModelInfoUpgrade { |
| pub model: String, |
| pub migration_markdown: String, |
| |
| #[serde( |
| default, |
| skip_serializing_if = "Option::is_none", |
| deserialize_with = "deserialize_optional_rfc3339_timestamp" |
| )] |
| #[schemars(with = "Option<String>")] |
| #[ts(type = "string", optional)] |
| pub retirement_at: Option<DateTime<Utc>>, |
| } |
|
|
| fn deserialize_optional_rfc3339_timestamp<'de, D>( |
| deserializer: D, |
| ) -> Result<Option<DateTime<Utc>>, D::Error> |
| where |
| D: Deserializer<'de>, |
| { |
| let value = Option::<serde_json::Value>::deserialize(deserializer)?; |
| Ok(value |
| .as_ref() |
| .and_then(serde_json::Value::as_str) |
| .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) |
| .map(|value| value.with_timezone(&Utc))) |
| } |
|
|
| impl From<&ModelUpgrade> for ModelInfoUpgrade { |
| fn from(upgrade: &ModelUpgrade) -> Self { |
| ModelInfoUpgrade { |
| model: upgrade.id.clone(), |
| migration_markdown: upgrade.migration_markdown.clone().unwrap_or_default(), |
| retirement_at: upgrade.retirement_at, |
| } |
| } |
| } |
|
|
| |
| #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema, Default)] |
| pub struct ModelsResponse { |
| #[serde( |
| serialize_with = "serialize_model_infos_with_legacy_base", |
| deserialize_with = "deserialize_model_infos_with_legacy_base" |
| )] |
| pub models: Vec<ModelInfo>, |
| } |
|
|
| #[derive(Serialize)] |
| struct ModelInfoWithLegacyBaseInstructionsRef<'a> { |
| #[serde(flatten)] |
| model: &'a ModelInfo, |
| base_instructions: String, |
| } |
|
|
| #[derive(Deserialize)] |
| struct ModelInfoWithLegacyBaseInstructions { |
| #[serde(default)] |
| base_instructions: Option<String>, |
| #[serde(flatten)] |
| model: ModelInfo, |
| } |
|
|
| |
| |
| #[doc(hidden)] |
| pub fn serialize_model_infos_with_legacy_base<S>( |
| models: &[ModelInfo], |
| serializer: S, |
| ) -> Result<S::Ok, S::Error> |
| where |
| S: Serializer, |
| { |
| models |
| .iter() |
| .map(|model| ModelInfoWithLegacyBaseInstructionsRef { |
| model, |
| base_instructions: model.get_model_instructions( None), |
| }) |
| .collect::<Vec<_>>() |
| .serialize(serializer) |
| } |
|
|
| |
| |
| #[doc(hidden)] |
| pub fn deserialize_model_infos_with_legacy_base<'de, D>( |
| deserializer: D, |
| ) -> Result<Vec<ModelInfo>, D::Error> |
| where |
| D: Deserializer<'de>, |
| { |
| let models = Vec::<ModelInfoWithLegacyBaseInstructions>::deserialize(deserializer)?; |
| models |
| .into_iter() |
| .map(|legacy_model| { |
| let ModelInfoWithLegacyBaseInstructions { |
| base_instructions, |
| mut model, |
| } = legacy_model; |
| if let Some(base_instructions) = base_instructions |
| && model |
| .model_messages |
| .as_ref() |
| .and_then(|messages| messages.instructions_template.as_ref()) |
| .is_none() |
| { |
| let messages = model.model_messages.get_or_insert(ModelMessages { |
| persistent_instructions: None, |
| tools: None, |
| instructions_template: None, |
| instructions_variables: None, |
| approvals: None, |
| collaboration_modes: None, |
| auto_review: None, |
| permissions: None, |
| multi_agent: None, |
| token_budget: None, |
| confirmation_policies: None, |
| guardian_v2: None, |
| }); |
| messages.instructions_template = Some(base_instructions); |
| } |
| if model |
| .model_messages |
| .as_ref() |
| .and_then(|messages| messages.instructions_template.as_ref()) |
| .is_none() |
| { |
| let model_slug = &model.slug; |
| return Err(D::Error::custom(format!( |
| "model `{model_slug}` is missing both `base_instructions` and \ |
| `model_messages.instructions_template`" |
| ))); |
| } |
| Ok(model) |
| }) |
| .collect() |
| } |
|
|
| |
| impl From<ModelInfo> for ModelPreset { |
| fn from(info: ModelInfo) -> Self { |
| ModelPreset { |
| id: info.slug.clone(), |
| model: info.slug.clone(), |
| display_name: info.display_name, |
| description: info.description.unwrap_or_default(), |
| model_specialty: info.model_specialty, |
| default_reasoning_effort: info |
| .default_reasoning_level |
| .unwrap_or(ReasoningEffort::None), |
| supported_reasoning_efforts: info.supported_reasoning_levels.clone(), |
| supports_personality: false, |
| additional_speed_tiers: info.additional_speed_tiers, |
| service_tiers: info.service_tiers, |
| default_service_tier: info.default_service_tier, |
| available_access_programs: info.available_access_programs, |
| is_default: false, |
| upgrade: info.upgrade.as_ref().map(|upgrade| ModelUpgrade { |
| id: upgrade.model.clone(), |
| migration_config_key: info.slug.clone(), |
| |
| model_link: None, |
| upgrade_copy: None, |
| migration_markdown: Some(upgrade.migration_markdown.clone()), |
| retirement_at: upgrade.retirement_at, |
| }), |
| show_in_picker: info.visibility == ModelVisibility::List, |
| multi_agent_version: info.multi_agent_version, |
| 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.service_tiers |
| .iter() |
| .any(|tier| tier.id == ServiceTier::Fast.request_value()) |
| || self |
| .additional_speed_tiers |
| .iter() |
| .any(|tier| tier == SPEED_TIER_FAST) |
| } |
| } |
|
|
| impl ModelInfo { |
| pub fn supports_service_tier(&self, service_tier: &str) -> bool { |
| self.service_tiers |
| .iter() |
| .any(|tier| tier.id == service_tier) |
| } |
|
|
| pub fn service_tier_for_request(&self, service_tier: Option<String>) -> Option<String> { |
| service_tier.filter(|service_tier| { |
| service_tier != SERVICE_TIER_DEFAULT_REQUEST_VALUE |
| && self.supports_service_tier(service_tier) |
| }) |
| } |
| } |
|
|
| impl ModelPreset { |
| |
| |
| |
| pub fn filter_by_auth(models: Vec<ModelPreset>, chatgpt_mode: bool) -> Vec<ModelPreset> { |
| models |
| .into_iter() |
| .filter(|model| chatgpt_mode || model.supported_in_api) |
| .collect() |
| } |
|
|
| |
| |
| |
| 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; |
| } |
| } |
| } |
|
|
| #[cfg(test)] |
| mod tests { |
| use super::*; |
| use pretty_assertions::assert_eq; |
| use serde_json::from_str; |
| use serde_json::to_string; |
|
|
| #[test] |
| fn legacy_shell_model_metadata_deserializes_as_unified_exec() { |
| for legacy_shell_type in ["default", "local", "shell_command"] { |
| assert_eq!( |
| from_str::<ConfigShellToolType>(&format!("\"{legacy_shell_type}\"")) |
| .expect("legacy shell type"), |
| ConfigShellToolType::UnifiedExec |
| ); |
| } |
| assert_eq!( |
| to_string(&ConfigShellToolType::UnifiedExec).expect("serialize unified shell type"), |
| "\"unified_exec\"" |
| ); |
| } |
|
|
| pub(super) fn test_model(spec: Option<ModelMessages>) -> 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::UnifiedExec, |
| visibility: ModelVisibility::List, |
| supported_in_api: true, |
| priority: 1, |
| additional_speed_tiers: Vec::new(), |
| service_tiers: Vec::new(), |
| default_service_tier: None, |
| available_access_programs: None, |
| availability_nux: None, |
| upgrade: None, |
| model_messages: spec, |
| include_skills_usage_instructions: false, |
| include_plugin_usage_instructions: false, |
| include_apps_usage_instructions: false, |
| supports_reasoning_summary_parameter: true, |
| 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( 10_000), |
| supports_image_detail_original: false, |
| context_window: None, |
| max_context_window: None, |
| auto_compact_token_limit: None, |
| comp_hash: None, |
| effective_context_window_percent: 95, |
| experimental_supported_tools: vec![], |
| input_modalities: default_input_modalities(), |
| used_fallback_model_metadata: false, |
| supports_search_tool: false, |
| supports_experimental_context: false, |
| use_responses_lite: false, |
| guardian: None, |
| node_repl_auto_review_required: false, |
| node_repl_disabled: false, |
| auto_review_model_override: None, |
| model_specialty: None, |
| tool_mode: None, |
| multi_agent_version: None, |
| multi_agent_reasoning_effort: None, |
| } |
| } |
| #[test] |
| fn model_messages_deserialize_without_optional_sections() { |
| let messages: ModelMessages = from_str( |
| r#"{"instructions_template":null,"instructions_variables":null,"persistent_instructions":null}"#, |
| ) |
| .expect("model messages should deserialize"); |
|
|
| assert_eq!( |
| messages, |
| ModelMessages { |
| persistent_instructions: None, |
| tools: None, |
| instructions_template: None, |
| instructions_variables: None, |
| approvals: None, |
| collaboration_modes: None, |
| auto_review: None, |
| permissions: None, |
| multi_agent: None, |
| token_budget: None, |
| confirmation_policies: None, |
| guardian_v2: None, |
| } |
| ); |
| } |
|
|
| #[test] |
| fn send_user_message_async_description_preserves_missing_null_and_empty_values() { |
| for (value, expected) in [ |
| (serde_json::json!({}), None), |
| (serde_json::json!({"tools": null}), None), |
| (serde_json::json!({"tools": {}}), Some(None)), |
| ( |
| serde_json::json!({"tools": {"send_user_message_async": null}}), |
| Some(None), |
| ), |
| ( |
| serde_json::json!({"tools": {"send_user_message_async": {}}}), |
| Some(Some(ToolMessage::default())), |
| ), |
| ( |
| serde_json::json!({"tools": {"send_user_message_async": {"description": null}}}), |
| Some(Some(ToolMessage::default())), |
| ), |
| ( |
| serde_json::json!({"tools": {"send_user_message_async": {"description": ""}}}), |
| Some(Some(ToolMessage { |
| description: Some(String::new()), |
| })), |
| ), |
| ( |
| serde_json::json!({"tools": {"send_user_message_async": {"description": "Catalog description"}}}), |
| Some(Some(ToolMessage { |
| description: Some("Catalog description".to_string()), |
| })), |
| ), |
| ] { |
| let messages: ModelMessages = |
| serde_json::from_value(value).expect("model messages should deserialize"); |
| let serialized = |
| serde_json::to_value(&messages).expect("model messages should serialize"); |
| assert_eq!( |
| ( |
| messages.tools, |
| serialized |
| .get("tools") |
| .map(|tools| tools.get("send_user_message_async").cloned()), |
| ), |
| ( |
| expected.as_ref().map(|tool| ToolMessages { |
| send_user_message_async: tool.clone(), |
| }), |
| expected.map(|tool| tool.map(|tool| match tool.description { |
| Some(description) => serde_json::json!({"description": description}), |
| None => serde_json::json!({}), |
| })), |
| ) |
| ); |
| } |
| } |
|
|
| #[test] |
| fn approval_messages_preserve_missing_and_empty_values() { |
| let messages: ModelMessages = from_str( |
| r#"{ |
| "instructions_template": null, |
| "instructions_variables": null, |
| "approvals": { |
| "on_request": "", |
| "never": "" |
| } |
| }"#, |
| ) |
| .expect("approval messages should deserialize"); |
|
|
| assert_eq!( |
| messages.approvals, |
| Some(ApprovalMessages { |
| on_request: Some(String::new()), |
| on_request_auto_review: None, |
| never: Some(String::new()), |
| unless_trusted: None, |
| }) |
| ); |
| } |
|
|
| #[test] |
| fn auto_review_messages_preserve_missing_and_empty_values() { |
| let missing_template: ModelMessages = from_str( |
| r#"{ |
| "instructions_template": null, |
| "instructions_variables": null, |
| "auto_review": { |
| "policy": "policy" |
| } |
| }"#, |
| ) |
| .expect("auto-review messages should deserialize without a policy template"); |
| let empty_template: ModelMessages = from_str( |
| r#"{ |
| "instructions_template": null, |
| "instructions_variables": null, |
| "auto_review": { |
| "policy": "policy", |
| "policy_template": "", |
| "node_repl_policy": "", |
| "rejection_instructions": "", |
| "timeout_instructions": "" |
| } |
| }"#, |
| ) |
| .expect("auto-review messages should deserialize with an empty policy template"); |
|
|
| assert_eq!( |
| missing_template.auto_review, |
| Some(AutoReviewMessages { |
| policy: Some("policy".to_string()), |
| policy_template: None, |
| node_repl_policy: None, |
| rejection_instructions: None, |
| timeout_instructions: None, |
| }) |
| ); |
| assert_eq!( |
| empty_template.auto_review, |
| Some(AutoReviewMessages { |
| policy: Some("policy".to_string()), |
| policy_template: Some(String::new()), |
| node_repl_policy: Some(String::new()), |
| rejection_instructions: Some(String::new()), |
| timeout_instructions: Some(String::new()), |
| }) |
| ); |
| } |
|
|
| #[test] |
| fn permission_messages_preserve_missing_and_empty_values() { |
| let messages: ModelMessages = from_str( |
| r#"{ |
| "instructions_template": null, |
| "instructions_variables": null, |
| "permissions": { |
| "workspace_write": "" |
| } |
| }"#, |
| ) |
| .expect("permission messages should deserialize"); |
|
|
| assert_eq!( |
| messages.permissions, |
| Some(PermissionMessages { |
| danger_full_access: None, |
| workspace_write: Some(String::new()), |
| read_only: None, |
| }) |
| ); |
| } |
|
|
| #[test] |
| fn multi_agent_messages_preserve_missing_and_empty_values() { |
| let messages: ModelMessages = from_str( |
| r#"{"instructions_template":null,"instructions_variables":null,"multi_agent":{"role":{"root":"","subagent":"subagent base"},"mode":{"explicit":"explicit mode","proactive":"","hint_text":""}}}"#, |
| ) |
| .expect("multi-agent messages should deserialize"); |
|
|
| assert_eq!( |
| messages.multi_agent, |
| Some(MultiAgentMessages { |
| role: Some(MultiAgentRoleMessages { |
| root: Some(String::new()), |
| subagent: Some("subagent base".to_string()), |
| }), |
| mode: Some(MultiAgentModeMessages { |
| explicit: Some("explicit mode".to_string()), |
| proactive: Some(String::new()), |
| hint_text: Some(String::new()), |
| }), |
| }) |
| ); |
| } |
|
|
| #[test] |
| fn collaboration_mode_messages_preserve_missing_and_empty_values() { |
| let messages: ModelMessages = from_str( |
| r#"{ |
| "instructions_template": null, |
| "instructions_variables": null, |
| "collaboration_modes": { |
| "default": "" |
| } |
| }"#, |
| ) |
| .expect("collaboration mode messages should deserialize"); |
|
|
| assert_eq!( |
| messages, |
| ModelMessages { |
| persistent_instructions: None, |
| tools: None, |
| instructions_template: None, |
| instructions_variables: None, |
| approvals: None, |
| collaboration_modes: Some(CollaborationModeMessages { |
| default: Some(String::new()), |
| plan: None, |
| }), |
| auto_review: None, |
| permissions: None, |
| multi_agent: None, |
| token_budget: None, |
| confirmation_policies: None, |
| guardian_v2: None, |
| } |
| ); |
| } |
|
|
| #[test] |
| fn reasoning_effort_accepts_known_and_custom_values() { |
| let custom = ReasoningEffort::Custom("future".to_string()); |
| let deserialized = from_str::<ReasoningEffort>(r#""future""#) |
| .expect("custom reasoning effort should deserialize"); |
| let serialized = to_string(&custom).expect("custom reasoning effort should serialize"); |
| let serialized_max = to_string(&ReasoningEffort::Max).expect("Max should serialize"); |
| let serialized_ultra = to_string(&ReasoningEffort::Ultra).expect("Ultra should serialize"); |
| let serialized_persistent = |
| to_string(&ReasoningEffort::Persistent).expect("Persistent should serialize"); |
|
|
| assert_eq!( |
| ( |
| "high".parse(), |
| "max".parse(), |
| "ultra".parse(), |
| "persistent".parse(), |
| "future".parse(), |
| deserialized, |
| serialized, |
| serialized_max, |
| serialized_ultra, |
| serialized_persistent, |
| custom.to_string(), |
| ), |
| ( |
| Ok(ReasoningEffort::High), |
| Ok(ReasoningEffort::Max), |
| Ok(ReasoningEffort::Ultra), |
| Ok(ReasoningEffort::Persistent), |
| Ok(custom.clone()), |
| custom, |
| r#""future""#.to_string(), |
| r#""max""#.to_string(), |
| r#""ultra""#.to_string(), |
| r#""persistent""#.to_string(), |
| "future".to_string(), |
| ) |
| ); |
| } |
|
|
| #[test] |
| fn reasoning_effort_rejects_empty_values() { |
| assert_eq!( |
| "".parse::<ReasoningEffort>(), |
| Err("reasoning_effort must not be empty".to_string()) |
| ); |
| } |
|
|
| #[test] |
| fn reasoning_effort_json_schema_is_an_open_string() { |
| let mut effort_generator = SchemaGenerator::default(); |
|
|
| assert_eq!( |
| ReasoningEffort::json_schema(&mut effort_generator), |
| Schema::Object(SchemaObject { |
| instance_type: Some(InstanceType::String.into()), |
| metadata: Some(Box::new(Metadata { |
| description: Some( |
| "A non-empty reasoning effort value advertised by the model.".to_string(), |
| ), |
| ..Default::default() |
| })), |
| string: Some(Box::new(StringValidation { |
| min_length: Some(1), |
| ..Default::default() |
| })), |
| ..Default::default() |
| }) |
| ); |
| } |
|
|
| #[test] |
| fn get_model_instructions_ignores_legacy_personality_variables() { |
| let model = test_model(Some(ModelMessages { |
| persistent_instructions: None, |
| tools: None, |
| instructions_template: Some("Hello {{ personality }}".to_string()), |
| instructions_variables: Some(ModelInstructionsVariables { |
| personality_default: Some("default".to_string()), |
| personality_friendly: Some("friendly".to_string()), |
| personality_pragmatic: Some("pragmatic".to_string()), |
| }), |
| approvals: None, |
| collaboration_modes: None, |
| auto_review: None, |
| permissions: None, |
| multi_agent: None, |
| token_budget: None, |
| confirmation_policies: None, |
| guardian_v2: None, |
| })); |
|
|
| let instructions = "Hello {{ personality }}"; |
| for personality in [ |
| Some(Personality::Friendly), |
| Some(Personality::Pragmatic), |
| Some(Personality::None), |
| None, |
| ] { |
| assert_eq!(model.get_model_instructions(personality), instructions); |
| } |
| } |
|
|
| #[test] |
| fn get_model_instructions_is_empty_when_template_is_missing() { |
| let model = test_model(Some(ModelMessages { |
| persistent_instructions: None, |
| tools: None, |
| instructions_template: None, |
| instructions_variables: Some(ModelInstructionsVariables { |
| personality_default: None, |
| personality_friendly: None, |
| personality_pragmatic: None, |
| }), |
| approvals: None, |
| collaboration_modes: None, |
| auto_review: None, |
| permissions: None, |
| multi_agent: None, |
| token_budget: None, |
| confirmation_policies: None, |
| guardian_v2: None, |
| })); |
|
|
| let instructions = model.get_model_instructions(Some(Personality::Friendly)); |
|
|
| assert_eq!(instructions, ""); |
| } |
|
|
| #[test] |
| fn get_model_instructions_is_empty_when_model_messages_is_missing() { |
| let model = test_model( None); |
|
|
| assert_eq!( |
| model.get_model_instructions(Some(Personality::Friendly)), |
| "" |
| ); |
| } |
|
|
| #[test] |
| fn models_response_promotes_legacy_base_instructions() { |
| let mut value = serde_json::to_value(ModelsResponse { |
| models: vec![test_model( None)], |
| }) |
| .expect("serialize models response"); |
| value["models"][0]["base_instructions"] = serde_json::json!("legacy instructions"); |
|
|
| let response: ModelsResponse = |
| serde_json::from_value(value).expect("deserialize legacy models response"); |
| let model = &response.models[0]; |
|
|
| assert_eq!( |
| model.model_messages, |
| Some(ModelMessages { |
| persistent_instructions: None, |
| tools: None, |
| instructions_template: Some("legacy instructions".to_string()), |
| instructions_variables: None, |
| approvals: None, |
| collaboration_modes: None, |
| auto_review: None, |
| permissions: None, |
| multi_agent: None, |
| token_budget: None, |
| confirmation_policies: None, |
| guardian_v2: None, |
| }) |
| ); |
| assert_eq!( |
| model.get_model_instructions( None), |
| "legacy instructions" |
| ); |
| let serialized = serde_json::to_value(response).expect("serialize canonical response"); |
| assert_eq!( |
| serialized["models"][0]["base_instructions"], |
| "legacy instructions" |
| ); |
| } |
|
|
| #[test] |
| fn models_response_rejects_model_without_instruction_source() { |
| let mut value = serde_json::to_value(ModelsResponse { |
| models: vec![test_model( None)], |
| }) |
| .expect("serialize models response"); |
| value["models"][0] |
| .as_object_mut() |
| .expect("model should serialize as an object") |
| .remove("base_instructions") |
| .expect("serialized model should include legacy base instructions"); |
|
|
| let error = serde_json::from_value::<ModelsResponse>(value) |
| .expect_err("model without instructions should be rejected"); |
|
|
| assert_eq!( |
| error.to_string(), |
| "model `test-model` is missing both `base_instructions` and \ |
| `model_messages.instructions_template`" |
| ); |
| } |
|
|
| #[test] |
| fn models_response_serializes_literal_legacy_base_instructions() { |
| let response = ModelsResponse { |
| models: vec![test_model(Some(ModelMessages { |
| persistent_instructions: None, |
| tools: None, |
| instructions_template: Some("before {{ personality }} after".to_string()), |
| instructions_variables: Some(ModelInstructionsVariables { |
| personality_default: Some("default".to_string()), |
| personality_friendly: Some("friendly".to_string()), |
| personality_pragmatic: Some("pragmatic".to_string()), |
| }), |
| approvals: None, |
| collaboration_modes: None, |
| auto_review: None, |
| permissions: None, |
| multi_agent: None, |
| token_budget: None, |
| confirmation_policies: None, |
| guardian_v2: None, |
| }))], |
| }; |
|
|
| let serialized = serde_json::to_value(response).expect("serialize models response"); |
|
|
| assert_eq!( |
| serialized["models"][0]["base_instructions"], |
| "before {{ personality }} after" |
| ); |
| } |
|
|
| #[test] |
| fn models_response_prefers_template_and_preserves_message_siblings() { |
| let messages = ModelMessages { |
| persistent_instructions: Some("Persistent catalog instructions".to_string()), |
| tools: Some(ToolMessages { |
| send_user_message_async: Some(ToolMessage { |
| description: Some("Catalog description".to_string()), |
| }), |
| }), |
| instructions_template: None, |
| instructions_variables: None, |
| approvals: Some(ApprovalMessages { |
| on_request: Some("approval".to_string()), |
| on_request_auto_review: None, |
| never: Some("never approval".to_string()), |
| unless_trusted: Some("unless-trusted approval".to_string()), |
| }), |
| collaboration_modes: Some(CollaborationModeMessages { |
| default: Some("default collaboration".to_string()), |
| plan: Some("plan collaboration".to_string()), |
| }), |
| auto_review: Some(AutoReviewMessages { |
| policy: Some("policy".to_string()), |
| policy_template: None, |
| node_repl_policy: None, |
| rejection_instructions: Some("rejection instructions".to_string()), |
| timeout_instructions: Some("timeout instructions".to_string()), |
| }), |
| permissions: Some(PermissionMessages { |
| danger_full_access: None, |
| workspace_write: Some("workspace".to_string()), |
| read_only: None, |
| }), |
| multi_agent: None, |
| token_budget: None, |
| confirmation_policies: Some(ConfirmationPolicies { |
| browser_use: Some( |
| "# Browser confirmations\n\nKeep {{literal_markdown}}.\n".to_string(), |
| ), |
| computer_use: Some( |
| " # Native confirmations\r\n\nKeep ${native_markdown}.\n".to_string(), |
| ), |
| }), |
| guardian_v2: Some(GuardianV2ModelConfig { |
| classifier_instructions: Some("Guardian classification".to_string()), |
| review_threshold_basis_points: Some(7_500), |
| reasoning_effort: Some(ReasoningEffort::Minimal), |
| transcript: Some(GuardianV2TranscriptModelConfig { |
| sources: Some(vec!["reasoning".to_string()]), |
| max_tool_entry_tokens: Some(500), |
| ..Default::default() |
| }), |
| ..Default::default() |
| }), |
| }; |
| let mut value = serde_json::to_value(ModelsResponse { |
| models: vec![test_model(Some(messages.clone()))], |
| }) |
| .expect("serialize models response"); |
| assert_eq!( |
| value["models"][0]["model_messages"]["confirmation_policies"], |
| serde_json::json!({ |
| "browser_use": "# Browser confirmations\n\nKeep {{literal_markdown}}.\n", |
| "computer_use": " # Native confirmations\r\n\nKeep ${native_markdown}.\n", |
| }) |
| ); |
| value["models"][0]["base_instructions"] = serde_json::json!("legacy instructions"); |
|
|
| let response: ModelsResponse = |
| serde_json::from_value(value).expect("deserialize legacy models response"); |
| let mut expected_messages = messages; |
| expected_messages.instructions_template = Some("legacy instructions".to_string()); |
| assert_eq!(response.models[0].model_messages, Some(expected_messages)); |
|
|
| let canonical_messages = ModelMessages { |
| persistent_instructions: Some(String::new()), |
| tools: Some(ToolMessages { |
| send_user_message_async: Some(ToolMessage { |
| description: Some(String::new()), |
| }), |
| }), |
| instructions_template: Some("canonical instructions".to_string()), |
| instructions_variables: None, |
| approvals: None, |
| collaboration_modes: None, |
| auto_review: None, |
| permissions: None, |
| multi_agent: None, |
| token_budget: None, |
| confirmation_policies: None, |
| guardian_v2: None, |
| }; |
| let mut value = serde_json::to_value(ModelsResponse { |
| models: vec![test_model(Some(canonical_messages.clone()))], |
| }) |
| .expect("serialize models response"); |
| value["models"][0]["base_instructions"] = serde_json::json!("legacy instructions"); |
|
|
| let response: ModelsResponse = |
| serde_json::from_value(value).expect("deserialize mixed models response"); |
| assert_eq!(response.models[0].model_messages, Some(canonical_messages)); |
| } |
|
|
| #[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": "unified_exec", |
| "visibility": "list", |
| "supported_in_api": true, |
| "priority": 1, |
| "upgrade": null, |
| "model_messages": null, |
| "default_reasoning_summary": "auto", |
| "support_verbosity": false, |
| "default_verbosity": null, |
| "apply_patch_tool_type": null, |
| "truncation_policy": { |
| "mode": "bytes", |
| "limit": 10000 |
| }, |
| "supports_image_detail_original": false, |
| "context_window": null, |
| "auto_compact_token_limit": null, |
| "effective_context_window_percent": 95, |
| "experimental_supported_tools": [] |
| })) |
| .expect("deserialize model info"); |
|
|
| assert_eq!(model.availability_nux, None); |
| assert_eq!( |
| model.input_modalities, |
| vec![InputModality::Text, InputModality::Image] |
| ); |
| assert!(!model.include_skills_usage_instructions); |
| assert!(!model.include_plugin_usage_instructions); |
| assert!(model.include_apps_usage_instructions); |
| assert!(model.supports_reasoning_summary_parameter); |
| assert!(!model.supports_image_detail_original); |
| assert_eq!(model.web_search_tool_type, WebSearchToolType::Text); |
| assert!(!model.supports_search_tool); |
| assert!(!model.supports_experimental_context); |
| assert!(!model.use_responses_lite); |
| assert!(!model.node_repl_auto_review_required); |
| assert!(!model.node_repl_disabled); |
| assert_eq!(model.comp_hash, None); |
| assert_eq!(model.auto_review_model_override, None); |
| assert_eq!(model.tool_mode, None); |
| assert_eq!(model.multi_agent_reasoning_effort, None); |
| } |
|
|
| #[test] |
| fn model_info_deserializes_multi_agent_reasoning_effort() { |
| let mut value = |
| serde_json::to_value(test_model( None)).expect("serialize test model"); |
| value |
| .as_object_mut() |
| .expect("model info should be an object") |
| .insert( |
| "multi_agent_reasoning_effort".to_string(), |
| serde_json::Value::String("high".to_string()), |
| ); |
|
|
| let model = serde_json::from_value::<ModelInfo>(value) |
| .expect("deserialize multi-agent reasoning effort"); |
|
|
| assert_eq!( |
| model.multi_agent_reasoning_effort, |
| Some(ReasoningEffort::High) |
| ); |
| } |
|
|
| #[test] |
| fn model_info_deserializes_optional_upgrade_retirement_at() { |
| let base = serde_json::to_value(test_model( None)) |
| .expect("serialize test model without retirement time"); |
|
|
| let mut absent = base.clone(); |
| absent |
| .as_object_mut() |
| .expect("model info should be an object") |
| .insert( |
| "upgrade".to_string(), |
| serde_json::json!({ |
| "model": "replacement-model", |
| "migration_markdown": "Use the replacement model." |
| }), |
| ); |
| let absent = serde_json::from_value::<ModelInfo>(absent) |
| .expect("deserialize model info without upgrade retirement time"); |
| assert_eq!( |
| absent |
| .upgrade |
| .as_ref() |
| .and_then(|upgrade| upgrade.retirement_at.as_ref()) |
| .map(DateTime::timestamp), |
| None |
| ); |
|
|
| let mut null = base.clone(); |
| null.as_object_mut() |
| .expect("model info should be an object") |
| .insert( |
| "upgrade".to_string(), |
| serde_json::json!({ |
| "model": "replacement-model", |
| "migration_markdown": "Use the replacement model.", |
| "retirement_at": null |
| }), |
| ); |
| let null = serde_json::from_value::<ModelInfo>(null) |
| .expect("deserialize model info with null upgrade retirement time"); |
| assert_eq!( |
| null.upgrade |
| .as_ref() |
| .and_then(|upgrade| upgrade.retirement_at.as_ref()) |
| .map(DateTime::timestamp), |
| None |
| ); |
|
|
| let mut populated = base; |
| populated |
| .as_object_mut() |
| .expect("model info should be an object") |
| .insert( |
| "upgrade".to_string(), |
| serde_json::json!({ |
| "model": "replacement-model", |
| "migration_markdown": "Use the replacement model.", |
| "retirement_at": "2030-01-01T00:00:00Z" |
| }), |
| ); |
| let populated = serde_json::from_value::<ModelInfo>(populated) |
| .expect("deserialize model info with upgrade retirement time"); |
| assert_eq!( |
| populated |
| .upgrade |
| .as_ref() |
| .and_then(|upgrade| upgrade.retirement_at.as_ref()) |
| .map(DateTime::timestamp), |
| Some(1_893_456_000) |
| ); |
|
|
| let mut malformed = serde_json::to_value(test_model( None)) |
| .expect("serialize test model for malformed retirement time"); |
| malformed |
| .as_object_mut() |
| .expect("model info should be an object") |
| .insert( |
| "upgrade".to_string(), |
| serde_json::json!({ |
| "model": "replacement-model", |
| "migration_markdown": "Use the replacement model.", |
| "retirement_at": "not-a-timestamp" |
| }), |
| ); |
| let malformed = serde_json::from_value::<ModelInfo>(malformed) |
| .expect("tolerate malformed upgrade retirement time"); |
| assert_eq!( |
| malformed |
| .upgrade |
| .as_ref() |
| .and_then(|upgrade| upgrade.retirement_at.as_ref()) |
| .map(DateTime::timestamp), |
| None |
| ); |
| } |
|
|
| #[test] |
| fn model_info_preserves_explicit_apps_guidance_opt_out() { |
| let value = serde_json::to_value(test_model( None)) |
| .expect("serialize model info with explicit apps guidance opt-out"); |
| assert_eq!(value["include_apps_usage_instructions"], false); |
|
|
| let model = serde_json::from_value::<ModelInfo>(value).expect("deserialize model info"); |
| assert!(!model.include_apps_usage_instructions); |
| } |
|
|
| #[test] |
| fn model_info_deserializes_known_tool_mode() { |
| let mut value = |
| serde_json::to_value(test_model( None)).expect("serialize test model"); |
| let object = value |
| .as_object_mut() |
| .expect("model info should be an object"); |
| object.insert( |
| "tool_mode".to_string(), |
| serde_json::Value::String("code_mode_only".to_string()), |
| ); |
| let model = serde_json::from_value::<ModelInfo>(value).expect("deserialize model info"); |
|
|
| assert_eq!(model.tool_mode, Some(ToolMode::CodeModeOnly)); |
| } |
|
|
| #[test] |
| fn model_info_treats_unknown_tool_mode_as_omitted() { |
| let mut value = |
| serde_json::to_value(test_model( None)).expect("serialize test model"); |
| let object = value |
| .as_object_mut() |
| .expect("model info should be an object"); |
| object.insert( |
| "tool_mode".to_string(), |
| serde_json::Value::String("future_tool_mode".to_string()), |
| ); |
| let model = serde_json::from_value::<ModelInfo>(value).expect("deserialize model info"); |
|
|
| assert_eq!(model.tool_mode, None); |
| let serialized = serde_json::to_value(model).expect("serialize model info"); |
| let object = serialized |
| .as_object() |
| .expect("model info should be an object"); |
| assert!(!object.contains_key("tool_mode")); |
| } |
|
|
| #[test] |
| fn model_info_treats_unknown_multi_agent_version_as_omitted() { |
| let mut value = |
| serde_json::to_value(test_model( None)).expect("serialize test model"); |
| let object = value |
| .as_object_mut() |
| .expect("model info should be an object"); |
| object.insert( |
| "multi_agent_version".to_string(), |
| serde_json::Value::String("future_multi_agent_version".to_string()), |
| ); |
| let model = serde_json::from_value::<ModelInfo>(value).expect("deserialize model info"); |
|
|
| assert_eq!(model.multi_agent_version, None); |
| } |
|
|
| #[test] |
| fn resolved_context_window_prefers_context_window() { |
| let model = ModelInfo { |
| context_window: Some(273_000), |
| max_context_window: Some(400_000), |
| ..test_model( None) |
| }; |
|
|
| assert_eq!(model.resolved_context_window(), Some(273_000)); |
| } |
|
|
| #[test] |
| fn resolved_context_window_falls_back_to_max_context_window() { |
| let model = ModelInfo { |
| context_window: None, |
| max_context_window: Some(400_000), |
| ..test_model( None) |
| }; |
|
|
| assert_eq!(model.resolved_context_window(), Some(400_000)); |
| assert_eq!(model.usable_context_window(), Some(380_000)); |
| assert_eq!(model.auto_compact_token_limit(), Some(360_000)); |
| } |
|
|
| #[test] |
| fn model_context_window_limits_preserve_their_distinct_meanings() { |
| let model = ModelInfo { |
| context_window: Some(272_000), |
| max_context_window: Some(400_000), |
| auto_compact_token_limit: Some(250_000), |
| effective_context_window_percent: 95, |
| ..test_model( None) |
| }; |
|
|
| assert_eq!( |
| ( |
| model.resolved_context_window(), |
| model.usable_context_window(), |
| model.auto_compact_token_limit(), |
| ), |
| (Some(272_000), Some(258_400), Some(244_800)) |
| ); |
| } |
|
|
| #[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()], |
| default_service_tier: Some(ServiceTier::Fast.request_value().to_string()), |
| service_tiers: Vec::new(), |
| ..test_model( None) |
| }); |
|
|
| assert_eq!( |
| preset.availability_nux, |
| Some(ModelAvailabilityNux { |
| message: "Try Spark.".to_string(), |
| }) |
| ); |
| assert!(preset.supports_fast_mode()); |
| assert_eq!( |
| preset.default_service_tier, |
| Some(ServiceTier::Fast.request_value().to_string()) |
| ); |
| } |
|
|
| #[test] |
| fn model_access_programs_preserve_explicit_selection_availability() { |
| use crate::turn_input::CyberAccessProgram; |
|
|
| for (metadata, expected) in [ |
| (None, None), |
| (Some(serde_json::Value::Null), None), |
| ( |
| Some(serde_json::json!({ "cyber": [] })), |
| Some(ModelAccessPrograms { cyber: vec![] }), |
| ), |
| ( |
| Some(serde_json::json!({ |
| "cyber": ["standard", "daybreak_blue", "future_program", "daybreak_red"] |
| })), |
| Some(ModelAccessPrograms { |
| cyber: vec![ |
| CyberAccessProgram::Standard, |
| CyberAccessProgram::DaybreakBlue, |
| CyberAccessProgram::DaybreakRed, |
| ], |
| }), |
| ), |
| ] { |
| let mut value = serde_json::to_value(test_model( None)).unwrap(); |
| if let Some(metadata) = metadata { |
| value["available_access_programs"] = metadata; |
| } |
| let model: ModelInfo = serde_json::from_value(value).unwrap(); |
| assert_eq!(model.available_access_programs, expected); |
| assert_eq!(ModelPreset::from(model).available_access_programs, expected); |
| } |
| } |
|
|
| #[test] |
| fn model_preset_supports_fast_mode_from_service_tiers() { |
| let preset = ModelPreset::from(ModelInfo { |
| service_tiers: vec![ModelServiceTier { |
| id: ServiceTier::Fast.request_value().to_string(), |
| name: "Fast".to_string(), |
| description: "Priority processing.".to_string(), |
| }], |
| ..test_model( None) |
| }); |
|
|
| assert!(preset.supports_fast_mode()); |
| } |
|
|
| #[test] |
| fn service_tier_for_request_omits_explicit_default_tier() { |
| let model = ModelInfo { |
| default_service_tier: Some(ServiceTier::Fast.request_value().to_string()), |
| service_tiers: vec![ModelServiceTier { |
| id: ServiceTier::Fast.request_value().to_string(), |
| name: "Fast".to_string(), |
| description: "Priority processing.".to_string(), |
| }], |
| ..test_model( None) |
| }; |
|
|
| assert_eq!( |
| model.service_tier_for_request(Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string())), |
| None |
| ); |
| } |
|
|
| #[test] |
| fn service_tier_for_request_filters_unsupported_tiers() { |
| let model = ModelInfo { |
| default_service_tier: Some(ServiceTier::Fast.request_value().to_string()), |
| service_tiers: vec![ModelServiceTier { |
| id: ServiceTier::Fast.request_value().to_string(), |
| name: "Fast".to_string(), |
| description: "Priority processing.".to_string(), |
| }], |
| ..test_model( None) |
| }; |
|
|
| assert_eq!( |
| model.service_tier_for_request(Some(ServiceTier::Fast.request_value().to_string())), |
| Some(ServiceTier::Fast.request_value().to_string()) |
| ); |
| assert_eq!( |
| model.service_tier_for_request(Some("unsupported".to_string())), |
| None |
| ); |
| assert_eq!(model.service_tier_for_request( None), None); |
| } |
| } |
|
|