use once_cell::sync::Lazy; use serde::de::Deserializer; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; use std::fmt; use std::str::FromStr; use thiserror::Error; use utoipa::ToSchema; pub const DEFAULT_CONTEXT_LIMIT: usize = 128_000; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "lowercase")] pub enum ThinkingEffort { Off, Low, Medium, High, Max, } impl FromStr for ThinkingEffort { type Err = String; fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { "off" | "disabled" | "none" => Ok(Self::Off), "low" => Ok(Self::Low), "medium" | "med" => Ok(Self::Medium), "high" => Ok(Self::High), "max" | "xhigh" => Ok(Self::Max), other => Err(format!("unknown thinking effort: '{other}'")), } } } impl fmt::Display for ThinkingEffort { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Off => write!(f, "off"), Self::Low => write!(f, "low"), Self::Medium => write!(f, "medium"), Self::High => write!(f, "high"), Self::Max => write!(f, "max"), } } } #[derive(Debug, Clone, Deserialize)] struct PredefinedModel { name: String, #[serde(default)] context_limit: Option, #[serde(default)] request_params: Option>, } fn get_predefined_models() -> Vec { static PREDEFINED_MODELS: Lazy> = Lazy::new(|| match std::env::var("GOOSE_PREDEFINED_MODELS") { Ok(json_str) => serde_json::from_str(&json_str).unwrap_or_else(|e| { tracing::warn!("Failed to parse GOOSE_PREDEFINED_MODELS: {}", e); Vec::new() }), Err(_) => Vec::new(), }); PREDEFINED_MODELS.clone() } fn find_predefined_model(model_name: &str) -> Option { get_predefined_models() .into_iter() .find(|m| m.name == model_name) } #[derive(Error, Debug)] pub enum ConfigError { #[error("Environment variable '{0}' not found")] EnvVarMissing(String), #[error("Invalid value for '{0}': '{1}' - {2}")] InvalidValue(String, String, String), #[error("Value for '{0}' is out of valid range: {1}")] InvalidRange(String, String), } #[derive(Debug, Clone, Default, Serialize, ToSchema)] pub struct ModelConfig { pub model_name: String, pub context_limit: Option, pub temperature: Option, pub max_tokens: Option, pub toolshim: bool, pub toolshim_model: Option, #[serde(skip)] pub fast_model_config: Option>, /// Provider-specific request parameters (e.g., anthropic_beta headers) #[serde(default, skip_serializing_if = "Option::is_none")] pub request_params: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub reasoning: Option, } impl<'de> Deserialize<'de> for ModelConfig { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { #[derive(Deserialize)] struct RawModelConfig { model_name: String, context_limit: Option, temperature: Option, max_tokens: Option, toolshim: bool, toolshim_model: Option, #[serde(default)] fast_model_config: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] request_params: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] reasoning: Option, } let raw = RawModelConfig::deserialize(deserializer)?; let mut config = Self { model_name: raw.model_name, context_limit: raw.context_limit, temperature: raw.temperature, max_tokens: raw.max_tokens, toolshim: raw.toolshim, toolshim_model: raw.toolshim_model, fast_model_config: raw.fast_model_config, request_params: raw.request_params, reasoning: raw.reasoning, }; config.normalize_effort_suffix(); Ok(config) } } impl ModelConfig { pub fn new(model_name: &str) -> Result { Self::new_base(model_name.to_string(), None) } pub fn new_with_context_env( model_name: String, provider_name: &str, context_env_var: Option<&str>, ) -> Result { let config = Self::new_base(model_name, context_env_var)?; Ok(config.with_canonical_limits(provider_name)) } fn new_base(model_name: String, context_env_var: Option<&str>) -> Result { // Check a provider-specific env var first (e.g. DATABRICKS_CONTEXT_LIMIT), // then fall back to GOOSE_CONTEXT_LIMIT. Using Config::global().get_param() // reads from both environment variables and config.yaml, so users can set // `GOOSE_CONTEXT_LIMIT: 1000000` in config.yaml instead of exporting an // env var. See #7839. let context_limit = if let Some(env_var) = context_env_var { if let Ok(val) = std::env::var(env_var) { Some(Self::validate_context_limit(&val, env_var)?) } else { None } } else { match crate::config::Config::global().get_param::("GOOSE_CONTEXT_LIMIT") { Ok(limit) => { if limit == 0 { return Err(ConfigError::InvalidRange( "GOOSE_CONTEXT_LIMIT".to_string(), "must be greater than 0".to_string(), )); } Some(limit) } Err(crate::config::ConfigError::NotFound(_)) => None, Err(e) => { return Err(ConfigError::InvalidValue( "GOOSE_CONTEXT_LIMIT".to_string(), String::new(), e.to_string(), )) } } }; let max_tokens = Self::parse_max_tokens()?; let temperature = Self::parse_temperature()?; let toolshim = Self::parse_toolshim()?; let toolshim_model = Self::parse_toolshim_model()?; // Pick up predefined model settings before legacy suffix normalization. let predefined = find_predefined_model(&model_name); let predefined_context_limit = predefined.as_ref().and_then(|pm| pm.context_limit); let request_params = predefined.and_then(|pm| pm.request_params); let mut config = Self { model_name, context_limit: context_limit.or(predefined_context_limit), temperature, max_tokens, toolshim, toolshim_model, fast_model_config: None, request_params, reasoning: None, }; config.normalize_effort_suffix(); Ok(config) } pub fn with_canonical_limits(mut self, provider_name: &str) -> Self { if let Some(pm) = find_predefined_model(&self.model_name) { if self.context_limit.is_none() { self.context_limit = pm.context_limit; } } // Try canonical lookup with the full model name first, then fall back // to the name with reasoning-effort suffixes stripped (e.g. // "databricks-gpt-5.4-high" → "databricks-gpt-5.4"). let canonical = crate::providers::canonical::maybe_get_canonical_model(provider_name, &self.model_name) .or_else(|| { let (base, _effort) = crate::providers::utils::extract_reasoning_effort(&self.model_name); if base != self.model_name { crate::providers::canonical::maybe_get_canonical_model(provider_name, &base) } else { None } }); if let Some(canonical) = canonical { if self.context_limit.is_none() { self.context_limit = Some(canonical.limit.context); } if self.max_tokens.is_none() { self.max_tokens = canonical .limit .output .filter(|&output| output < canonical.limit.context) .map(|output| output as i32); } if self.reasoning.is_none() { self.reasoning = canonical.reasoning; } } self } fn validate_context_limit(val: &str, env_var: &str) -> Result { let limit = val.parse::().map_err(|_| { ConfigError::InvalidValue( env_var.to_string(), val.to_string(), "must be a positive integer".to_string(), ) })?; if limit < 4 * 1024 { return Err(ConfigError::InvalidRange( env_var.to_string(), "must be greater than 4K".to_string(), )); } Ok(limit) } fn parse_temperature() -> Result, ConfigError> { if let Ok(val) = std::env::var("GOOSE_TEMPERATURE") { let temp = val.parse::().map_err(|_| { ConfigError::InvalidValue( "GOOSE_TEMPERATURE".to_string(), val.clone(), "must be a valid number".to_string(), ) })?; if temp < 0.0 { return Err(ConfigError::InvalidRange( "GOOSE_TEMPERATURE".to_string(), val, )); } Ok(Some(temp)) } else { Ok(None) } } fn parse_max_tokens() -> Result, ConfigError> { match crate::config::Config::global().get_param::("GOOSE_MAX_TOKENS") { Ok(tokens) => { if tokens <= 0 { return Err(ConfigError::InvalidRange( "goose_max_tokens".to_string(), "must be greater than 0".to_string(), )); } Ok(Some(tokens)) } Err(crate::config::ConfigError::NotFound(_)) => Ok(None), Err(e) => Err(ConfigError::InvalidValue( "goose_max_tokens".to_string(), String::new(), e.to_string(), )), } } fn parse_toolshim() -> Result { if let Ok(val) = std::env::var("GOOSE_TOOLSHIM") { match val.to_lowercase().as_str() { "1" | "true" | "yes" | "on" => Ok(true), "0" | "false" | "no" | "off" => Ok(false), _ => Err(ConfigError::InvalidValue( "GOOSE_TOOLSHIM".to_string(), val, "must be one of: 1, true, yes, on, 0, false, no, off".to_string(), )), } } else { Ok(false) } } fn parse_toolshim_model() -> Result, ConfigError> { match std::env::var("GOOSE_TOOLSHIM_OLLAMA_MODEL") { Ok(val) if val.trim().is_empty() => Err(ConfigError::InvalidValue( "GOOSE_TOOLSHIM_OLLAMA_MODEL".to_string(), val, "cannot be empty if set".to_string(), )), Ok(val) => Ok(Some(val)), Err(_) => Ok(None), } } pub fn with_context_limit(mut self, limit: Option) -> Self { if limit.is_some() { self.context_limit = limit; } self } pub fn with_temperature(mut self, temp: Option) -> Self { self.temperature = temp; self } pub fn with_max_tokens(mut self, tokens: Option) -> Self { self.max_tokens = tokens; self } pub fn with_toolshim(mut self, toolshim: bool) -> Self { self.toolshim = toolshim; self } pub fn with_toolshim_model(mut self, model: Option) -> Self { self.toolshim_model = model; self } pub fn with_fast( mut self, fast_model_name: &str, provider_name: &str, ) -> Result { // Create a full ModelConfig for the fast model with proper canonical lookup let fast_config = ModelConfig::new(fast_model_name)?.with_canonical_limits(provider_name); self.fast_model_config = Some(Box::new(fast_config)); Ok(self) } pub fn with_merged_request_params(mut self, params: HashMap) -> Self { match self.request_params.as_mut() { Some(existing) => { for (k, v) in params { existing.insert(k, v); } } None => { self.request_params = Some(params); } } self } pub fn use_fast_model(&self) -> Self { if let Some(fast_config) = &self.fast_model_config { *fast_config.clone() } else { self.clone() } } pub fn context_limit(&self) -> usize { self.context_limit.unwrap_or(DEFAULT_CONTEXT_LIMIT) } pub fn is_openai_reasoning_model(&self) -> bool { crate::providers::utils::is_openai_responses_model(&self.model_name) } pub fn is_reasoning_model(&self) -> bool { if let Some(reasoning) = self.reasoning { return reasoning; } self.is_openai_reasoning_model() || self.model_name.to_lowercase().contains("claude") || Self::is_gemini3_reasoning_model_name(&self.model_name) } fn is_gemini3_reasoning_model_name(model_name: &str) -> bool { let lower = model_name.to_lowercase(); lower.starts_with("gemini-3") || lower.contains("/gemini-3") || lower.contains("-gemini-3") } pub fn max_output_tokens(&self) -> i32 { if let Some(tokens) = self.max_tokens { return tokens; } 4_096 } pub fn normalize_effort_suffix(&mut self) { if !self.is_openai_reasoning_model() { return; } let parts: Vec<&str> = self.model_name.split('-').collect(); let last = match parts.last() { Some(l) => *l, None => return, }; let effort = match last { "none" => ThinkingEffort::Off, "low" => ThinkingEffort::Low, "medium" => ThinkingEffort::Medium, "high" => ThinkingEffort::High, "xhigh" => ThinkingEffort::Max, _ => return, }; self.model_name = parts[..parts.len() - 1].join("-"); let has_explicit_effort = self .request_params .as_ref() .and_then(|p| p.get("thinking_effort")) .is_some(); if !has_explicit_effort { let params = self.request_params.get_or_insert_with(HashMap::new); params.insert( "thinking_effort".to_string(), serde_json::json!(effort.to_string()), ); } } pub fn thinking_effort(&self) -> Option { self.get_config_param::("thinking_effort", "GOOSE_THINKING_EFFORT") .and_then(|s| s.parse::().ok()) .or_else(Self::legacy_thinking_effort) } fn legacy_thinking_effort() -> Option { let config = crate::config::Config::global(); if let Ok(value) = config.get_param::("CLAUDE_THINKING_TYPE") { if let Some(effort) = match value.to_lowercase().as_str() { "adaptive" | "enabled" => Some(ThinkingEffort::High), "disabled" => Some(ThinkingEffort::Off), _ => None, } { return Some(effort); } } if let Ok(enabled) = config.get_param::("CLAUDE_THINKING_ENABLED") { return Some(if enabled { ThinkingEffort::High } else { ThinkingEffort::Off }); } if let Ok(value) = config.get_param::("GEMINI3_THINKING_LEVEL") { if let Some(effort) = Self::legacy_gemini3_thinking_effort(&value) { return Some(effort); } } None } fn legacy_gemini3_thinking_effort(value: &str) -> Option { match value.to_lowercase().as_str() { "low" => Some(ThinkingEffort::Low), "high" => Some(ThinkingEffort::High), _ => None, } } pub fn get_config_param serde::Deserialize<'de>>( &self, request_key: &str, config_key: &str, ) -> Option { self.request_params .as_ref() .and_then(|params| params.get(request_key)) .and_then(|v| serde_json::from_value(v.clone()).ok()) .or_else(|| { crate::config::Config::global() .get_param::(config_key) .ok() }) } pub fn new_or_fail(model_name: &str) -> ModelConfig { ModelConfig::new(model_name) .unwrap_or_else(|_| panic!("Failed to create model config for {}", model_name)) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_max_tokens_valid() { let _guard = env_lock::lock_env([("GOOSE_MAX_TOKENS", Some("4096"))]); let result = ModelConfig::parse_max_tokens().unwrap(); assert_eq!(result, Some(4096)); } #[test] fn test_parse_max_tokens_not_set() { let _guard = env_lock::lock_env([("GOOSE_MAX_TOKENS", None::<&str>)]); let result = ModelConfig::parse_max_tokens().unwrap(); assert_eq!(result, None); } #[test] fn test_parse_max_tokens_invalid_string() { let _guard = env_lock::lock_env([("GOOSE_MAX_TOKENS", Some("not_a_number"))]); let result = ModelConfig::parse_max_tokens(); assert!(result.is_err()); assert!(matches!(result.unwrap_err(), ConfigError::InvalidValue(..))); } #[test] fn test_parse_max_tokens_zero() { let _guard = env_lock::lock_env([("GOOSE_MAX_TOKENS", Some("0"))]); let result = ModelConfig::parse_max_tokens(); assert!(result.is_err()); assert!(matches!(result.unwrap_err(), ConfigError::InvalidRange(..))); } #[test] fn test_parse_max_tokens_negative() { let _guard = env_lock::lock_env([("GOOSE_MAX_TOKENS", Some("-100"))]); let result = ModelConfig::parse_max_tokens(); assert!(result.is_err()); assert!(matches!(result.unwrap_err(), ConfigError::InvalidRange(..))); } #[test] fn test_model_config_with_max_tokens_env() { let _guard = env_lock::lock_env([ ("GOOSE_MAX_TOKENS", Some("8192")), ("GOOSE_TEMPERATURE", None::<&str>), ("GOOSE_CONTEXT_LIMIT", None::<&str>), ("GOOSE_TOOLSHIM", None::<&str>), ("GOOSE_TOOLSHIM_OLLAMA_MODEL", None::<&str>), ]); let config = ModelConfig::new("test-model").unwrap(); assert_eq!(config.max_tokens, Some(8192)); } #[test] fn test_model_config_without_max_tokens_env() { let _guard = env_lock::lock_env([ ("GOOSE_MAX_TOKENS", None::<&str>), ("GOOSE_TEMPERATURE", None::<&str>), ("GOOSE_CONTEXT_LIMIT", None::<&str>), ("GOOSE_TOOLSHIM", None::<&str>), ("GOOSE_TOOLSHIM_OLLAMA_MODEL", None::<&str>), ]); let config = ModelConfig::new("test-model").unwrap(); assert_eq!(config.max_tokens, None); } #[test] fn test_get_config_param() { let _guard = env_lock::lock_env([("GOOSE_THINKING_EFFORT", Some("high"))]); let mut params = HashMap::new(); params.insert("thinking_effort".to_string(), serde_json::json!("low")); let config_with_params = ModelConfig { model_name: "test".to_string(), request_params: Some(params), ..Default::default() }; let config_without_params = ModelConfig { request_params: None, ..config_with_params.clone() }; assert_eq!( config_with_params .get_config_param::("thinking_effort", "GOOSE_THINKING_EFFORT"), Some("low".to_string()) ); assert_eq!( config_without_params .get_config_param::("thinking_effort", "GOOSE_THINKING_EFFORT"), Some("high".to_string()) ); assert_eq!( config_without_params .get_config_param::("nonexistent", "NONEXISTENT_CONFIG_KEY"), None ); } #[test] fn test_deserialize_preserves_fast_model_config() { let config: ModelConfig = serde_json::from_value(serde_json::json!({ "model_name": "primary-model", "context_limit": null, "temperature": null, "max_tokens": null, "toolshim": false, "toolshim_model": null, "fast_model_config": { "model_name": "fast-model", "context_limit": 4096, "temperature": null, "max_tokens": 1024, "toolshim": false, "toolshim_model": null } })) .unwrap(); let fast_config = config.fast_model_config.as_ref().unwrap(); assert_eq!(fast_config.model_name, "fast-model"); assert_eq!(fast_config.context_limit, Some(4096)); assert_eq!(fast_config.max_tokens, Some(1024)); assert_eq!(config.use_fast_model().model_name, "fast-model"); } mod thinking_effort_tests { use super::*; #[test] fn from_request_params() { let _guard = env_lock::lock_env([("GOOSE_THINKING_EFFORT", None::<&str>)]); let mut params = HashMap::new(); params.insert("thinking_effort".to_string(), serde_json::json!("medium")); let config = ModelConfig { model_name: "test".to_string(), request_params: Some(params), ..Default::default() }; assert_eq!(config.thinking_effort(), Some(ThinkingEffort::Medium)); } #[test] fn from_env_var() { let _guard = env_lock::lock_env([("GOOSE_THINKING_EFFORT", Some("high"))]); let config = ModelConfig { model_name: "test".to_string(), ..Default::default() }; assert_eq!(config.thinking_effort(), Some(ThinkingEffort::High)); } #[test] fn request_params_override_env() { let _guard = env_lock::lock_env([("GOOSE_THINKING_EFFORT", Some("high"))]); let mut params = HashMap::new(); params.insert("thinking_effort".to_string(), serde_json::json!("low")); let config = ModelConfig { model_name: "test".to_string(), request_params: Some(params), ..Default::default() }; assert_eq!(config.thinking_effort(), Some(ThinkingEffort::Low)); } #[test] fn legacy_claude_thinking_type_fallback() { for value in ["enabled", "adaptive"] { let _guard = env_lock::lock_env([ ("GOOSE_THINKING_EFFORT", None::<&str>), ("CLAUDE_THINKING_TYPE", Some(value)), ("CLAUDE_THINKING_ENABLED", None::<&str>), ("GEMINI3_THINKING_LEVEL", None::<&str>), ("ANTHROPIC_THINKING_BUDGET", None::<&str>), ("CLAUDE_THINKING_BUDGET", None::<&str>), ("GEMINI25_THINKING_BUDGET", None::<&str>), ]); let config = ModelConfig { model_name: "test".to_string(), ..Default::default() }; assert_eq!(config.thinking_effort(), Some(ThinkingEffort::High)); } } #[test] fn legacy_gemini3_thinking_level_mapping() { assert_eq!( ModelConfig::legacy_gemini3_thinking_effort("low"), Some(ThinkingEffort::Low) ); assert_eq!( ModelConfig::legacy_gemini3_thinking_effort("high"), Some(ThinkingEffort::High) ); assert_eq!(ModelConfig::legacy_gemini3_thinking_effort("auto"), None); } #[test] fn legacy_gemini3_thinking_level_fallback() { let temp_dir = tempfile::tempdir().unwrap(); let temp_root = temp_dir.path().to_string_lossy().to_string(); let _guard = env_lock::lock_env([ ("GOOSE_PATH_ROOT", Some(temp_root.as_str())), ("GOOSE_THINKING_EFFORT", None::<&str>), ("CLAUDE_THINKING_TYPE", None::<&str>), ("CLAUDE_THINKING_ENABLED", None::<&str>), ("GEMINI3_THINKING_LEVEL", Some("high")), ("ANTHROPIC_THINKING_BUDGET", None::<&str>), ("CLAUDE_THINKING_BUDGET", None::<&str>), ("GEMINI25_THINKING_BUDGET", None::<&str>), ]); let config = ModelConfig { model_name: "gemini-3-pro".to_string(), ..Default::default() }; assert_eq!(config.thinking_effort(), Some(ThinkingEffort::High)); } #[test] fn effort_suffix_stripped_from_model_name() { let _guard = env_lock::lock_env([ ("GOOSE_THINKING_EFFORT", None::<&str>), ("GOOSE_MAX_TOKENS", None::<&str>), ("GOOSE_TEMPERATURE", None::<&str>), ("GOOSE_CONTEXT_LIMIT", None::<&str>), ("GOOSE_TOOLSHIM", None::<&str>), ("GOOSE_TOOLSHIM_OLLAMA_MODEL", None::<&str>), ]); let config = ModelConfig::new("o3-mini-high").unwrap(); assert_eq!(config.model_name, "o3-mini"); assert_eq!(config.thinking_effort(), Some(ThinkingEffort::High)); } #[test] fn none_suffix_stripped_from_model_name() { let _guard = env_lock::lock_env([ ("GOOSE_THINKING_EFFORT", Some("high")), ("GOOSE_MAX_TOKENS", None::<&str>), ("GOOSE_TEMPERATURE", None::<&str>), ("GOOSE_CONTEXT_LIMIT", None::<&str>), ("GOOSE_TOOLSHIM", None::<&str>), ("GOOSE_TOOLSHIM_OLLAMA_MODEL", None::<&str>), ]); let config = ModelConfig::new("o3-mini-none").unwrap(); assert_eq!(config.model_name, "o3-mini"); assert_eq!(config.thinking_effort(), Some(ThinkingEffort::Off)); } #[test] fn xhigh_suffix_stripped_from_model_name() { let _guard = env_lock::lock_env([ ("GOOSE_THINKING_EFFORT", Some("low")), ("GOOSE_MAX_TOKENS", None::<&str>), ("GOOSE_TEMPERATURE", None::<&str>), ("GOOSE_CONTEXT_LIMIT", None::<&str>), ("GOOSE_TOOLSHIM", None::<&str>), ("GOOSE_TOOLSHIM_OLLAMA_MODEL", None::<&str>), ]); let config = ModelConfig::new("gpt-5.4-xhigh").unwrap(); assert_eq!(config.model_name, "gpt-5.4"); assert_eq!(config.thinking_effort(), Some(ThinkingEffort::Max)); } #[test] fn effort_suffix_not_stripped_when_thinking_effort_set() { let _guard = env_lock::lock_env([ ("GOOSE_THINKING_EFFORT", None::<&str>), ("GOOSE_MAX_TOKENS", None::<&str>), ("GOOSE_TEMPERATURE", None::<&str>), ("GOOSE_CONTEXT_LIMIT", None::<&str>), ("GOOSE_TOOLSHIM", None::<&str>), ("GOOSE_TOOLSHIM_OLLAMA_MODEL", None::<&str>), ]); let mut params = HashMap::new(); params.insert("thinking_effort".to_string(), serde_json::json!("low")); let mut config = ModelConfig::new("o3-mini-high").unwrap(); // Suffix was already normalized during new(), but if request_params // were set before construction, the suffix would not be stripped. // Verify the normalized state: assert_eq!(config.model_name, "o3-mini"); // Now simulate setting explicit effort after construction config.request_params = Some(params); assert_eq!(config.thinking_effort(), Some(ThinkingEffort::Low)); } #[test] fn no_suffix_no_change() { let _guard = env_lock::lock_env([ ("GOOSE_THINKING_EFFORT", None::<&str>), ("GOOSE_MAX_TOKENS", None::<&str>), ("GOOSE_TEMPERATURE", None::<&str>), ("GOOSE_CONTEXT_LIMIT", None::<&str>), ("GOOSE_TOOLSHIM", None::<&str>), ("GOOSE_TOOLSHIM_OLLAMA_MODEL", None::<&str>), ]); let config = ModelConfig::new("o3-mini").unwrap(); assert_eq!(config.model_name, "o3-mini"); } #[test] fn non_reasoning_model_suffix_not_stripped() { let _guard = env_lock::lock_env([ ("GOOSE_THINKING_EFFORT", None::<&str>), ("GOOSE_MAX_TOKENS", None::<&str>), ("GOOSE_TEMPERATURE", None::<&str>), ("GOOSE_CONTEXT_LIMIT", None::<&str>), ("GOOSE_TOOLSHIM", None::<&str>), ("GOOSE_TOOLSHIM_OLLAMA_MODEL", None::<&str>), ]); let config = ModelConfig::new("claude-sonnet-4-high").unwrap(); assert_eq!(config.model_name, "claude-sonnet-4-high"); } #[test] fn parse_aliases() { assert_eq!("off".parse::(), Ok(ThinkingEffort::Off)); assert_eq!( "disabled".parse::(), Ok(ThinkingEffort::Off) ); assert_eq!("med".parse::(), Ok(ThinkingEffort::Medium)); assert_eq!("max".parse::(), Ok(ThinkingEffort::Max)); assert_eq!("xhigh".parse::(), Ok(ThinkingEffort::Max)); assert!("invalid".parse::().is_err()); } } mod with_canonical_limits { use super::*; #[test] fn sets_limits_from_canonical_model() { let _guard = env_lock::lock_env([ ("GOOSE_MAX_TOKENS", None::<&str>), ("GOOSE_CONTEXT_LIMIT", None::<&str>), ]); let config = ModelConfig::new_or_fail("gpt-4o").with_canonical_limits("openai"); assert_eq!(config.context_limit, Some(128_000)); assert_eq!(config.max_tokens, Some(16_384)); assert_eq!(config.reasoning, Some(false)); } #[test] fn does_not_override_existing_context_limit() { let _guard = env_lock::lock_env([ ("GOOSE_MAX_TOKENS", None::<&str>), ("GOOSE_CONTEXT_LIMIT", None::<&str>), ]); let mut config = ModelConfig::new_or_fail("gpt-4o"); config.context_limit = Some(64_000); let config = config.with_canonical_limits("openai"); assert_eq!(config.context_limit, Some(64_000)); } #[test] fn does_not_override_existing_max_tokens() { let _guard = env_lock::lock_env([ ("GOOSE_MAX_TOKENS", None::<&str>), ("GOOSE_CONTEXT_LIMIT", None::<&str>), ]); let mut config = ModelConfig::new_or_fail("gpt-4o"); config.max_tokens = Some(1_000); let config = config.with_canonical_limits("openai"); assert_eq!(config.max_tokens, Some(1_000)); } #[test] fn skips_canonical_output_limit_when_it_equals_context_limit() { let _guard = env_lock::lock_env([ ("GOOSE_MAX_TOKENS", None::<&str>), ("GOOSE_CONTEXT_LIMIT", None::<&str>), ]); let config = ModelConfig::new_or_fail("moonshotai/kimi-k2.6").with_canonical_limits("nvidia"); assert_eq!(config.context_limit, Some(262_144)); assert_eq!(config.max_tokens, None); assert_eq!(config.max_output_tokens(), 4_096); } #[test] fn unknown_model_leaves_fields_none() { let _guard = env_lock::lock_env([ ("GOOSE_MAX_TOKENS", None::<&str>), ("GOOSE_CONTEXT_LIMIT", None::<&str>), ]); let config = ModelConfig::new_or_fail("totally-unknown-model").with_canonical_limits("openai"); assert_eq!(config.context_limit, None); assert_eq!(config.max_tokens, None); assert_eq!(config.reasoning, None); } #[test] fn resolves_after_stripping_reasoning_effort_suffix() { let _guard = env_lock::lock_env([ ("GOOSE_MAX_TOKENS", None::<&str>), ("GOOSE_CONTEXT_LIMIT", None::<&str>), ]); // "databricks-gpt-5.4-high" should resolve via "databricks-gpt-5.4" let config = ModelConfig::new_or_fail("databricks-gpt-5.4-high") .with_canonical_limits("databricks"); assert_eq!(config.context_limit, Some(1_050_000)); // "gpt-5.4-xhigh" should resolve via "gpt-5.4" let config = ModelConfig::new_or_fail("gpt-5.4-xhigh").with_canonical_limits("openai"); assert_eq!(config.context_limit, Some(1_050_000)); // "gpt-5.4-nano-low" should resolve via "gpt-5.4-nano" let config = ModelConfig::new_or_fail("gpt-5.4-nano-low").with_canonical_limits("openai"); assert_eq!(config.context_limit, Some(400_000)); } } mod is_openai_reasoning_model { use super::*; const ENV_LOCK_KEYS: [(&str, Option<&str>); 5] = [ ("GOOSE_MAX_TOKENS", None), ("GOOSE_TEMPERATURE", None), ("GOOSE_CONTEXT_LIMIT", None), ("GOOSE_TOOLSHIM", None), ("GOOSE_TOOLSHIM_OLLAMA_MODEL", None), ]; #[test] fn bare_reasoning_models() { let _guard = env_lock::lock_env(ENV_LOCK_KEYS); assert!(ModelConfig::new_or_fail("o1").is_openai_reasoning_model()); assert!(ModelConfig::new_or_fail("o1-preview").is_openai_reasoning_model()); assert!(ModelConfig::new_or_fail("o3").is_openai_reasoning_model()); assert!(ModelConfig::new_or_fail("o3-mini").is_openai_reasoning_model()); assert!(ModelConfig::new_or_fail("o4-mini").is_openai_reasoning_model()); assert!(ModelConfig::new_or_fail("gpt-5").is_openai_reasoning_model()); assert!(ModelConfig::new_or_fail("gpt-5-3-codex").is_openai_reasoning_model()); } #[test] fn goose_prefixed_reasoning_models() { let _guard = env_lock::lock_env(ENV_LOCK_KEYS); assert!(ModelConfig::new_or_fail("goose-o3-mini").is_openai_reasoning_model()); assert!(ModelConfig::new_or_fail("goose-o4-mini").is_openai_reasoning_model()); assert!(ModelConfig::new_or_fail("goose-gpt-5").is_openai_reasoning_model()); } #[test] fn databricks_prefixed_reasoning_models() { let _guard = env_lock::lock_env(ENV_LOCK_KEYS); assert!(ModelConfig::new_or_fail("databricks-o3-mini").is_openai_reasoning_model()); assert!(ModelConfig::new_or_fail("databricks-o4-mini").is_openai_reasoning_model()); assert!(ModelConfig::new_or_fail("databricks-gpt-5").is_openai_reasoning_model()); } #[test] fn non_reasoning_models() { let _guard = env_lock::lock_env(ENV_LOCK_KEYS); assert!(!ModelConfig::new_or_fail("claude-sonnet-4").is_openai_reasoning_model()); assert!(!ModelConfig::new_or_fail("gpt-4o").is_openai_reasoning_model()); assert!( !ModelConfig::new_or_fail("databricks-claude-sonnet-4").is_openai_reasoning_model() ); assert!(!ModelConfig::new_or_fail("goose-claude-sonnet-4").is_openai_reasoning_model()); assert!(!ModelConfig::new_or_fail("llama-3-70b").is_openai_reasoning_model()); } } mod is_reasoning_model { use super::*; const ENV_LOCK_KEYS: [(&str, Option<&str>); 5] = [ ("GOOSE_MAX_TOKENS", None), ("GOOSE_TEMPERATURE", None), ("GOOSE_CONTEXT_LIMIT", None), ("GOOSE_TOOLSHIM", None), ("GOOSE_TOOLSHIM_OLLAMA_MODEL", None), ]; #[test] fn includes_reasoning_model_families() { let _guard = env_lock::lock_env(ENV_LOCK_KEYS); assert!(ModelConfig::new_or_fail("o3-mini").is_reasoning_model()); assert!(ModelConfig::new_or_fail("claude-sonnet-4").is_reasoning_model()); assert!(ModelConfig::new_or_fail("gemini-3-pro").is_reasoning_model()); } #[test] fn uses_explicit_metadata_first() { let _guard = env_lock::lock_env(ENV_LOCK_KEYS); let mut config = ModelConfig::new_or_fail("provider-alias"); config.reasoning = Some(true); assert!(config.is_reasoning_model()); let mut config = ModelConfig::new_or_fail("claude-sonnet-4"); config.reasoning = Some(false); assert!(!config.is_reasoning_model()); } } }