diff --git a/crates/goose-provider-types/src/base.rs b/crates/goose-provider-types/src/base.rs index 0c219121b..143ddfdd0 100644 --- a/crates/goose-provider-types/src/base.rs +++ b/crates/goose-provider-types/src/base.rs @@ -472,10 +472,14 @@ pub trait Provider: Send + Sync { let mut models_with_dates: Vec<(String, Option)> = all_models .iter() .filter_map(|model| { - let canonical_id = map_to_canonical_model(provider_name, model, registry)?; - - let (provider, model_name) = canonical_id.split_once('/')?; - let canonical_model = registry.get(provider, model_name)?; + let canonical_model = map_to_canonical_model(provider_name, model, registry) + .and_then(|canonical_id| { + let (provider, model_name) = canonical_id.split_once('/')?; + registry.get(provider, model_name) + }); + let Some(canonical_model) = canonical_model else { + return Some((model.clone(), None)); + }; if !canonical_model .modalities @@ -595,6 +599,31 @@ mod tests { use super::*; use test_case::test_case; + struct ModelInventoryProvider { + models: Vec, + } + + #[async_trait] + impl Provider for ModelInventoryProvider { + fn get_name(&self) -> &str { + "xai" + } + + async fn stream( + &self, + _model_config: &ModelConfig, + _system: &str, + _messages: &[Message], + _tools: &[Tool], + ) -> Result { + unimplemented!() + } + + async fn fetch_supported_models(&self) -> Result, ProviderError> { + Ok(self.models.clone()) + } + } + fn content_from_str(s: String) -> MessageContentBlock { if let Some(img_data) = s.strip_prefix("*img:") { MessageContentBlock::image(format!("http://example.com/{}", img_data), "image/png") @@ -717,6 +746,23 @@ mod tests { assert_eq!(message.agent_visible_content().as_concat_text(), "private"); } + #[tokio::test] + async fn recommended_models_preserve_unknown_future_models() { + let provider = ModelInventoryProvider { + models: vec![ + "grok-4.5".to_string(), + "grok-future-unlisted".to_string(), + "grok-4.20-multi-agent".to_string(), + ], + }; + + let models = provider.fetch_recommended_models(false).await.unwrap(); + + assert!(models.contains(&"grok-4.5".to_string())); + assert!(models.contains(&"grok-future-unlisted".to_string())); + assert!(!models.contains(&"grok-4.20-multi-agent".to_string())); + } + #[test] fn test_model_info_creation() { // Test direct ModelInfo creation diff --git a/crates/goose-provider-types/src/formats/openai.rs b/crates/goose-provider-types/src/formats/openai.rs index af8d73999..b3acacfa1 100644 --- a/crates/goose-provider-types/src/formats/openai.rs +++ b/crates/goose-provider-types/src/formats/openai.rs @@ -1410,12 +1410,17 @@ pub fn create_request_with_options( let (model_name, legacy_reasoning_effort) = extract_reasoning_effort(&model_config.model_name); let is_reasoning_model = is_openai_responses_model(&model_name); + let supports_xai_effort = supports_xai_reasoning_effort(&model_name); let reasoning_effort = if is_reasoning_model { model_config .thinking_effort() .map_or(legacy_reasoning_effort, |effort| { openai_reasoning_effort_for_thinking(&model_name, effort) }) + } else if supports_xai_effort { + model_config + .thinking_effort() + .and_then(|effort| xai_reasoning_effort_for_thinking(&model_name, effort)) } else { None }; @@ -1446,7 +1451,7 @@ pub fn create_request_with_options( payload["tools"] = json!(tools_spec); } - if !is_reasoning_model { + if !is_reasoning_model && !supports_xai_effort { if let Some(temp) = model_config.temperature { payload["temperature"] = json!(temp); } @@ -1531,6 +1536,50 @@ pub fn is_openai_responses_model(model_name: &str) -> bool { re.is_match(model_name) } +/// Returns whether an xAI Chat Completions model accepts `reasoning_effort`. +pub fn supports_xai_reasoning_effort(model_name: &str) -> bool { + let model_name = model_name.to_ascii_lowercase(); + + model_name.starts_with("grok-4.5") + || model_name.starts_with("grok-4.3") + || model_name.starts_with("grok-3-mini") +} + +/// Returns whether an xAI model performs server-side reasoning. +pub fn is_xai_reasoning_model(model_name: &str) -> bool { + let model_name = model_name.to_ascii_lowercase(); + + if model_name.contains("non-reasoning") || model_name.contains("non_reasoning") { + return false; + } + + supports_xai_reasoning_effort(&model_name) + || model_name.starts_with("grok-4.20") + || model_name.starts_with("grok-4-0709") + || model_name.starts_with("grok-4-fast-reasoning") + || model_name.starts_with("grok-4-1-fast-reasoning") +} + +/// Maps Goose's effort levels to values accepted by xAI Chat Completions. +pub fn xai_reasoning_effort_for_thinking( + model_name: &str, + effort: ThinkingEffort, +) -> Option { + let model_name = model_name.to_ascii_lowercase(); + let supports_none = model_name.starts_with("grok-4.3"); + let supports_medium = !model_name.starts_with("grok-3-mini"); + + match effort { + ThinkingEffort::Off if supports_none => Some("none".to_string()), + ThinkingEffort::Off => Some("low".to_string()), + ThinkingEffort::Low => Some("low".to_string()), + ThinkingEffort::Medium if supports_medium => Some("medium".to_string()), + ThinkingEffort::Medium | ThinkingEffort::High | ThinkingEffort::Max => { + Some("high".to_string()) + } + } +} + pub fn openai_reasoning_effort_for_thinking( model_name: &str, effort: ThinkingEffort, @@ -4134,6 +4183,121 @@ data: [DONE]"#; } } + #[test] + fn test_xai_reasoning_model_capabilities_are_model_specific() { + for model in ["grok-4.5", "grok-4.3", "grok-3-mini"] { + assert!(supports_xai_reasoning_effort(model), "{model}"); + assert!(is_xai_reasoning_model(model), "{model}"); + } + + for model in [ + "grok-4.20", + "grok-4.20-0309-reasoning", + "grok-4.20-multi-agent", + "grok-4-0709", + "grok-4-fast-reasoning", + ] { + assert!(!supports_xai_reasoning_effort(model), "{model}"); + assert!(is_xai_reasoning_model(model), "{model}"); + } + + for model in [ + "grok-4.20-0309-non-reasoning", + "grok-4-fast-non-reasoning", + "grok-3", + "grok-build-0.1", + ] { + assert!(!supports_xai_reasoning_effort(model), "{model}"); + assert!(!is_xai_reasoning_model(model), "{model}"); + } + } + + #[test] + fn test_xai_reasoning_effort_uses_each_models_supported_levels() { + assert_eq!( + xai_reasoning_effort_for_thinking("grok-4.5", ThinkingEffort::Off), + Some("low".to_string()) + ); + assert_eq!( + xai_reasoning_effort_for_thinking("grok-4.5", ThinkingEffort::Medium), + Some("medium".to_string()) + ); + assert_eq!( + xai_reasoning_effort_for_thinking("grok-4.3", ThinkingEffort::Off), + Some("none".to_string()) + ); + assert_eq!( + xai_reasoning_effort_for_thinking("grok-3-mini", ThinkingEffort::Medium), + Some("high".to_string()) + ); + assert_eq!( + xai_reasoning_effort_for_thinking("grok-4.5", ThinkingEffort::Max), + Some("high".to_string()) + ); + } + + #[test] + fn test_create_request_applies_grok_4_5_reasoning_controls() { + let model_config = ModelConfig::new("grok-4.5") + .with_thinking_effort(ThinkingEffort::Medium) + .with_temperature(Some(0.7)); + + let payload = create_request( + &model_config, + "system prompt", + &[], + &[], + &ImageFormat::OpenAi, + false, + ) + .unwrap(); + + assert_eq!(payload.get("reasoning_effort"), Some(&json!("medium"))); + assert!(payload.get("temperature").is_none()); + } + + #[test] + fn test_create_request_maps_xai_off_to_supported_effort() { + for (model, expected) in [("grok-4.5", "low"), ("grok-4.3", "none")] { + let model_config = ModelConfig::new(model).with_thinking_effort(ThinkingEffort::Off); + let payload = create_request( + &model_config, + "system prompt", + &[], + &[], + &ImageFormat::OpenAi, + false, + ) + .unwrap(); + + assert_eq!( + payload.get("reasoning_effort"), + Some(&json!(expected)), + "{model}" + ); + } + } + + #[test] + fn test_create_request_keeps_grok_4_20_fixed_reasoning_parameters() { + let model_config = ModelConfig::new("grok-4.20-0309-reasoning") + .with_thinking_effort(ThinkingEffort::High) + .with_temperature(Some(0.7)); + + let payload = create_request( + &model_config, + "system prompt", + &[], + &[], + &ImageFormat::OpenAi, + false, + ) + .unwrap(); + + assert!(payload.get("reasoning_effort").is_none()); + assert_eq!(payload.get("temperature"), Some(&json!(0.7_f32))); + } + #[test] fn test_extract_reasoning_effort_for_responses_models() { for (model, expected_name, expected_effort) in [ diff --git a/crates/goose-provider-types/src/model.rs b/crates/goose-provider-types/src/model.rs index 17367ab13..0be50571a 100644 --- a/crates/goose-provider-types/src/model.rs +++ b/crates/goose-provider-types/src/model.rs @@ -1,4 +1,7 @@ -use crate::formats::openai::{extract_reasoning_effort, is_openai_responses_model}; +use crate::formats::openai::{ + extract_reasoning_effort, is_openai_responses_model, is_xai_reasoning_model, + supports_xai_reasoning_effort, +}; use crate::thinking::ThinkingEffort; use serde::de::Deserializer; use serde::{Deserialize, Serialize}; @@ -244,6 +247,7 @@ impl ModelConfig { self.is_openai_reasoning_model() || self.model_name.to_lowercase().contains("claude") || Self::is_gemini3_reasoning_model_name(&self.model_name) + || is_xai_reasoning_model(&self.model_name) } fn is_gemini3_reasoning_model_name(model_name: &str) -> bool { @@ -260,7 +264,7 @@ impl ModelConfig { } pub fn normalize_effort_suffix(&mut self) { - if !self.is_openai_reasoning_model() { + if !self.is_openai_reasoning_model() && !supports_xai_reasoning_effort(&self.model_name) { return; } let parts: Vec<&str> = self.model_name.split('-').collect(); @@ -541,6 +545,21 @@ mod tests { assert_eq!(config.model_name, "claude-sonnet-4-high"); } + #[test] + fn xai_reasoning_effort_suffix_is_normalized() { + 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("grok-4.5-high"); + assert_eq!(config.model_name, "grok-4.5"); + assert_eq!(config.thinking_effort(), Some(ThinkingEffort::High)); + } + #[test] fn parse_aliases() { assert_eq!("off".parse::(), Ok(ThinkingEffort::Off)); @@ -740,6 +759,9 @@ mod tests { assert!(ModelConfig::new("o3-mini").is_reasoning_model()); assert!(ModelConfig::new("claude-sonnet-4").is_reasoning_model()); assert!(ModelConfig::new("gemini-3-pro").is_reasoning_model()); + assert!(ModelConfig::new("grok-4.5").is_reasoning_model()); + assert!(ModelConfig::new("grok-4.20-0309-reasoning").is_reasoning_model()); + assert!(!ModelConfig::new("grok-4.20-0309-non-reasoning").is_reasoning_model()); } #[test] diff --git a/crates/goose/src/providers/init.rs b/crates/goose/src/providers/init.rs index 6105fc562..df88101b8 100644 --- a/crates/goose/src/providers/init.rs +++ b/crates/goose/src/providers/init.rs @@ -100,13 +100,19 @@ async fn init_registry() -> RwLock { false, Some(registrations::refresh_only()), ); - registry.register::(false); + registry.register_with_inventory::( + false, + Some(registrations::refresh_only()), + ); registry.register::(false); registry.register_with_inventory::( true, Some(registrations::gemini_oauth_inventory()), ); - registry.register::(false); + registry.register_with_inventory::( + false, + Some(registrations::refresh_only()), + ); registry.register_with_inventory::( true, Some(registrations::google_inventory()), @@ -115,7 +121,10 @@ async fn init_registry() -> RwLock { true, Some(registrations::huggingface_inventory()), ); - registry.register::(true); + registry.register_with_inventory::( + true, + Some(registrations::kimi_code_inventory()), + ); registry.register_with_inventory::( false, Some(registrations::refresh_only().with_configured(|| { @@ -128,7 +137,8 @@ async fn init_registry() -> RwLock { .is_ok() })), ); - registry.register::(true); + registry + .register_with_inventory::(true, Some(registrations::refresh_only())); registry.register_with_inventory::( true, Some(registrations::ollama_inventory()), @@ -153,8 +163,9 @@ async fn init_registry() -> RwLock { #[cfg(feature = "aws-providers")] registry.register::(false); registry.register::(false); - registry.register::(true); - registry.register::(false); + registry + .register_with_inventory::(true, Some(registrations::refresh_only())); + registry.register_with_inventory::(false, Some(registrations::refresh_only())); registry.register_with_inventory::( true, Some(registrations::xai_oauth_inventory()), @@ -498,6 +509,27 @@ mod tests { ); } + #[tokio::test] + async fn test_api_backed_model_providers_are_registered_for_refresh() { + for provider_name in [ + "gcp_vertex_ai", + "github_copilot", + "kimi_code", + "nano-gpt", + "tetrate", + "xai", + "xai_oauth", + ] { + let entry = get_from_registry(provider_name) + .await + .expect("dynamic model provider should be registered"); + assert!( + entry.supports_inventory_refresh(), + "{provider_name} must refresh its model inventory" + ); + } + } + #[tokio::test] async fn test_litellm_configured_without_api_key() { let _guard = env_lock::lock_env([ diff --git a/crates/goose/src/providers/inventory/registrations.rs b/crates/goose/src/providers/inventory/registrations.rs index 2c3ef5488..df7f0ed18 100644 --- a/crates/goose/src/providers/inventory/registrations.rs +++ b/crates/goose/src/providers/inventory/registrations.rs @@ -15,6 +15,7 @@ use crate::providers::gemini_oauth::TokenCache as GeminiOAuthTokenCache; use crate::providers::google::{GOOGLE_API_HOST, GOOGLE_PROVIDER_NAME}; use crate::providers::huggingface::HuggingFaceProvider; use crate::providers::huggingface_auth; +use crate::providers::kimicode::KIMI_CONFIGURED_MARKER; use crate::providers::ollama::OLLAMA_PROVIDER_NAME; use crate::providers::openai::{OPEN_AI_DEFAULT_BASE_PATH, OPEN_AI_PROVIDER_NAME}; use crate::providers::pi_acp::{PI_ACP_BINARY, PI_ACP_PROVIDER_NAME}; @@ -147,6 +148,14 @@ pub fn refresh_only() -> InventoryRegistration { } } +pub fn kimi_code_inventory() -> InventoryRegistration { + refresh_only().with_configured(|| { + Config::global() + .get_param::(KIMI_CONFIGURED_MARKER) + .unwrap_or(false) + }) +} + pub fn chatgpt_codex_inventory() -> InventoryRegistration { InventoryRegistration { supports_refresh: false, @@ -167,7 +176,7 @@ pub fn gemini_oauth_inventory() -> InventoryRegistration { pub fn xai_oauth_inventory() -> InventoryRegistration { InventoryRegistration { - supports_refresh: false, + supports_refresh: true, identity: default_inventory_identity_resolver(), configured: None, } diff --git a/crates/goose/src/providers/kimicode.rs b/crates/goose/src/providers/kimicode.rs index 0fc5e1c45..3d6251576 100644 --- a/crates/goose/src/providers/kimicode.rs +++ b/crates/goose/src/providers/kimicode.rs @@ -58,7 +58,7 @@ const DEFAULT_TOKEN_LIFETIME_SECS: i64 = 3600; /// Marker key written to the user config when OAuth completes successfully. /// `check_provider_configured` (server) keys off this when an OAuth-flow /// provider has no required secret env var. -const KIMI_CONFIGURED_MARKER: &str = "kimi_code_configured"; +pub(crate) const KIMI_CONFIGURED_MARKER: &str = "kimi_code_configured"; // ── Token persistence ──────────────────────────────────────────────────────── diff --git a/crates/goose/src/providers/xai.rs b/crates/goose/src/providers/xai.rs index 71cbce325..ef4378733 100644 --- a/crates/goose/src/providers/xai.rs +++ b/crates/goose/src/providers/xai.rs @@ -6,8 +6,13 @@ use futures::future::BoxFuture; const XAI_PROVIDER_NAME: &str = "xai"; pub const XAI_API_HOST: &str = "https://api.x.ai/v1"; -pub const XAI_DEFAULT_MODEL: &str = "grok-code-fast-1"; +pub const XAI_DEFAULT_MODEL: &str = "grok-4.5"; pub const XAI_KNOWN_MODELS: &[&str] = &[ + "grok-4.5", + "grok-4.3", + "grok-4.20-0309-reasoning", + "grok-4.20-0309-non-reasoning", + "grok-build-0.1", "grok-code-fast-1", "grok-4-0709", "grok-3", @@ -32,14 +37,23 @@ pub const XAI_DOC_URL: &str = "https://docs.x.ai/docs/overview"; pub struct XaiProvider; +pub fn xai_known_model_info() -> Vec { + XAI_KNOWN_MODELS + .iter() + .map(|model_name| { + goose_providers::base::model_info_for_provider_model(XAI_PROVIDER_NAME, model_name) + }) + .collect() +} + impl goose_providers::base::ProviderDescriptor for XaiProvider { fn metadata() -> ProviderMetadata { - ProviderMetadata::new( + ProviderMetadata::with_models( XAI_PROVIDER_NAME, "xAI", "Grok models from xAI, including reasoning and multimodal capabilities", XAI_DEFAULT_MODEL, - XAI_KNOWN_MODELS.to_vec(), + xai_known_model_info(), XAI_DOC_URL, vec![ ConfigKey::new("XAI_API_KEY", true, true, None, true), @@ -75,3 +89,29 @@ impl ProviderDef for XaiProvider { }) } } + +#[cfg(test)] +mod tests { + use super::*; + use goose_providers::base::ProviderDescriptor; + + #[test] + fn current_xai_models_use_canonical_metadata() { + let metadata = XaiProvider::metadata(); + + let grok_4_5 = metadata + .known_models + .iter() + .find(|model| model.name == "grok-4.5") + .expect("grok-4.5 should be a known xAI model"); + assert_eq!(grok_4_5.context_limit, 500_000); + assert!(grok_4_5.reasoning); + + let grok_4_20_non_reasoning = metadata + .known_models + .iter() + .find(|model| model.name == "grok-4.20-0309-non-reasoning") + .expect("grok-4.20 non-reasoning should be a known xAI model"); + assert!(!grok_4_20_non_reasoning.reasoning); + } +} diff --git a/crates/goose/src/providers/xai_oauth.rs b/crates/goose/src/providers/xai_oauth.rs index a6c2ec179..5e67c02eb 100644 --- a/crates/goose/src/providers/xai_oauth.rs +++ b/crates/goose/src/providers/xai_oauth.rs @@ -1,7 +1,7 @@ use super::api_client::{ApiClient, AuthMethod, AuthProvider}; use super::base::{ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata}; use super::openai_compatible::OpenAiCompatibleProvider; -use super::xai::{XAI_API_HOST, XAI_DEFAULT_MODEL, XAI_KNOWN_MODELS}; +use super::xai::{xai_known_model_info, XAI_API_HOST, XAI_DEFAULT_MODEL}; use crate::config::paths::Paths; use crate::conversation::message::Message; use crate::providers::private_file::write_private_file; @@ -755,12 +755,12 @@ impl Provider for XaiOAuthProvider { impl goose_providers::base::ProviderDescriptor for XaiOAuthProvider { fn metadata() -> ProviderMetadata { - ProviderMetadata::new( + ProviderMetadata::with_models( XAI_OAUTH_PROVIDER_NAME, "xAI (SuperGrok Subscription)", "Use your xAI SuperGrok subscription via OAuth instead of an API key. Falls back to a device-code flow on headless / remote machines.", XAI_DEFAULT_MODEL, - XAI_KNOWN_MODELS.to_vec(), + xai_known_model_info(), XAI_OAUTH_DOC_URL, vec![ ConfigKey::new_oauth("XAI_OAUTH_TOKEN", true, true, None, false), diff --git a/crates/goose/tests/acp_secret_cache_invalidation_test.rs b/crates/goose/tests/acp_secret_cache_invalidation_test.rs index ee4100ece..b5bcefa1b 100644 --- a/crates/goose/tests/acp_secret_cache_invalidation_test.rs +++ b/crates/goose/tests/acp_secret_cache_invalidation_test.rs @@ -175,13 +175,8 @@ fn acp_secret_mutations_and_inventory_refresh_invalidate_global_secret_cache() { assert_eq!( save_provider_config.get("refresh"), Some(&serde_json::json!({ - "started": [], - "skipped": [ - { - "providerId": "xai", - "reason": "does_not_support_refresh", - }, - ], + "started": ["xai"], + "skipped": [], })), "provider config save should return the inventory refresh acknowledgement" );