fix(providers): restore dynamic model discovery and current Grok support (#10756)

Co-authored-by: Michael Neale <14976+michaelneale@users.noreply.github.com>
This commit is contained in:
Jasper
2026-07-28 07:44:52 -05:00
committed by GitHub
parent 021b0db8db
commit ac1f125744
9 changed files with 336 additions and 28 deletions
+50 -4
View File
@@ -472,10 +472,14 @@ pub trait Provider: Send + Sync {
let mut models_with_dates: Vec<(String, Option<String>)> = 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<String>,
}
#[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<MessageStream, ProviderError> {
unimplemented!()
}
async fn fetch_supported_models(&self) -> Result<Vec<String>, 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
@@ -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<String> {
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 [
+24 -2
View File
@@ -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::<ThinkingEffort>(), 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]
+38 -6
View File
@@ -100,13 +100,19 @@ async fn init_registry() -> RwLock<ProviderRegistry> {
false,
Some(registrations::refresh_only()),
);
registry.register::<GcpVertexAIProvider>(false);
registry.register_with_inventory::<GcpVertexAIProvider>(
false,
Some(registrations::refresh_only()),
);
registry.register::<GeminiCliProvider>(false);
registry.register_with_inventory::<GeminiOAuthProvider>(
true,
Some(registrations::gemini_oauth_inventory()),
);
registry.register::<GithubCopilotProvider>(false);
registry.register_with_inventory::<GithubCopilotProvider>(
false,
Some(registrations::refresh_only()),
);
registry.register_with_inventory::<GoogleProviderDef>(
true,
Some(registrations::google_inventory()),
@@ -115,7 +121,10 @@ async fn init_registry() -> RwLock<ProviderRegistry> {
true,
Some(registrations::huggingface_inventory()),
);
registry.register::<KimiCodeProvider>(true);
registry.register_with_inventory::<KimiCodeProvider>(
true,
Some(registrations::kimi_code_inventory()),
);
registry.register_with_inventory::<LiteLLMProvider>(
false,
Some(registrations::refresh_only().with_configured(|| {
@@ -128,7 +137,8 @@ async fn init_registry() -> RwLock<ProviderRegistry> {
.is_ok()
})),
);
registry.register::<NanoGptProvider>(true);
registry
.register_with_inventory::<NanoGptProvider>(true, Some(registrations::refresh_only()));
registry.register_with_inventory::<OllamaProviderDef>(
true,
Some(registrations::ollama_inventory()),
@@ -153,8 +163,9 @@ async fn init_registry() -> RwLock<ProviderRegistry> {
#[cfg(feature = "aws-providers")]
registry.register::<SageMakerTgiProvider>(false);
registry.register::<SnowflakeProviderDef>(false);
registry.register::<TetrateProvider>(true);
registry.register::<XaiProvider>(false);
registry
.register_with_inventory::<TetrateProvider>(true, Some(registrations::refresh_only()));
registry.register_with_inventory::<XaiProvider>(false, Some(registrations::refresh_only()));
registry.register_with_inventory::<XaiOAuthProvider>(
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([
@@ -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::<bool>(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,
}
+1 -1
View File
@@ -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 ────────────────────────────────────────────────────────
+43 -3
View File
@@ -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<goose_providers::base::ModelInfo> {
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);
}
}
+3 -3
View File
@@ -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),
@@ -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"
);