From bd25b15aab6603e900db35cabe069bb4bb2243e6 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Fri, 20 Jun 2025 19:27:28 +0200 Subject: [PATCH] Add xAI Provider Support for Grok Models (#2976) Co-authored-by: jack --- .gitignore | 1 + crates/goose/src/model.rs | 3 + crates/goose/src/providers/factory.rs | 5 +- crates/goose/src/providers/mod.rs | 1 + crates/goose/src/providers/xai.rs | 181 ++++++++++++++++++ .../docs/getting-started/providers.md | 1 + .../settings/providers/ProviderRegistry.tsx | 19 ++ .../modal/subcomponents/ProviderLogo.tsx | 18 +- .../modal/subcomponents/icons/xai.png | Bin 0 -> 3775 bytes .../modal/subcomponents/icons/xai@3x.png | Bin 0 -> 3775 bytes 10 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 crates/goose/src/providers/xai.rs create mode 100644 ui/desktop/src/components/settings/providers/modal/subcomponents/icons/xai.png create mode 100644 ui/desktop/src/components/settings/providers/modal/subcomponents/icons/xai@3x.png diff --git a/.gitignore b/.gitignore index d9b45401c9..eb42363620 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ tmp/ # will have compiled files and executables debug/ target/ +.goose/ # These are backup files generated by rustfmt **/*.rs.bk diff --git a/crates/goose/src/model.rs b/crates/goose/src/model.rs index c9bcfe8e47..c8e28e47c9 100644 --- a/crates/goose/src/model.rs +++ b/crates/goose/src/model.rs @@ -30,6 +30,9 @@ static MODEL_SPECIFIC_LIMITS: Lazy> = Lazy::new(|| // Meta Llama models, https://github.com/meta-llama/llama-models/tree/main?tab=readme-ov-file#llama-models-1 map.insert("llama3.2", 128_000); map.insert("llama3.3", 128_000); + + // x.ai Grok models, https://docs.x.ai/docs/overview + map.insert("grok", 131_072); map }); diff --git a/crates/goose/src/providers/factory.rs b/crates/goose/src/providers/factory.rs index af4fc2e34f..11757aa9b8 100644 --- a/crates/goose/src/providers/factory.rs +++ b/crates/goose/src/providers/factory.rs @@ -17,6 +17,7 @@ use super::{ sagemaker_tgi::SageMakerTgiProvider, snowflake::SnowflakeProvider, venice::VeniceProvider, + xai::XaiProvider, }; use crate::model::ModelConfig; use anyhow::Result; @@ -52,6 +53,7 @@ pub fn providers() -> Vec { SageMakerTgiProvider::metadata(), VeniceProvider::metadata(), SnowflakeProvider::metadata(), + XaiProvider::metadata(), ] } @@ -128,6 +130,7 @@ fn create_provider(name: &str, model: ModelConfig) -> Result> "venice" => Ok(Arc::new(VeniceProvider::from_env(model)?)), "snowflake" => Ok(Arc::new(SnowflakeProvider::from_env(model)?)), "github_copilot" => Ok(Arc::new(GithubCopilotProvider::from_env(model)?)), + "xai" => Ok(Arc::new(XaiProvider::from_env(model)?)), _ => Err(anyhow::anyhow!("Unknown provider: {}", name)), } } @@ -259,7 +262,7 @@ mod tests { } // Set only the required lead model - env::set_var("GOOSE_LEAD_MODEL", "gpt-4o"); + env::set_var("GOOSE_LEAD_MODEL", "grok-3"); // This should use defaults for all other values let result = create("openai", ModelConfig::new("gpt-4o-mini".to_string())); diff --git a/crates/goose/src/providers/mod.rs b/crates/goose/src/providers/mod.rs index ccda29a431..decd346a6c 100644 --- a/crates/goose/src/providers/mod.rs +++ b/crates/goose/src/providers/mod.rs @@ -24,5 +24,6 @@ pub mod toolshim; pub mod utils; pub mod utils_universal_openai_stream; pub mod venice; +pub mod xai; pub use factory::{create, providers}; diff --git a/crates/goose/src/providers/xai.rs b/crates/goose/src/providers/xai.rs new file mode 100644 index 0000000000..7e91a23f8b --- /dev/null +++ b/crates/goose/src/providers/xai.rs @@ -0,0 +1,181 @@ +use super::errors::ProviderError; +use crate::message::Message; +use crate::model::ModelConfig; +use crate::providers::base::{ConfigKey, Provider, ProviderMetadata, ProviderUsage, Usage}; +use crate::providers::formats::openai::{create_request, get_usage, response_to_message}; +use crate::providers::utils::get_model; +use anyhow::Result; +use async_trait::async_trait; +use mcp_core::Tool; +use reqwest::{Client, StatusCode}; +use serde_json::Value; +use std::time::Duration; +use url::Url; + +pub const XAI_API_HOST: &str = "https://api.x.ai/v1"; +pub const XAI_DEFAULT_MODEL: &str = "grok-3"; +pub const XAI_KNOWN_MODELS: &[&str] = &[ + "grok-3", + "grok-3-fast", + "grok-3-mini", + "grok-3-mini-fast", + "grok-2-vision-1212", + "grok-2-image-1212", + "grok-2-1212", + "grok-3-latest", + "grok-3-fast-latest", + "grok-3-mini-latest", + "grok-3-mini-fast-latest", + "grok-2-vision", + "grok-2-vision-latest", + "grok-2-image", + "grok-2-image-latest", + "grok-2", + "grok-2-latest", +]; + +pub const XAI_DOC_URL: &str = "https://docs.x.ai/docs/overview"; + +#[derive(serde::Serialize)] +pub struct XaiProvider { + #[serde(skip)] + client: Client, + host: String, + api_key: String, + model: ModelConfig, +} + +impl Default for XaiProvider { + fn default() -> Self { + let model = ModelConfig::new(XaiProvider::metadata().default_model); + XaiProvider::from_env(model).expect("Failed to initialize xAI provider") + } +} + +impl XaiProvider { + pub fn from_env(model: ModelConfig) -> Result { + let config = crate::config::Config::global(); + let api_key: String = config.get_secret("XAI_API_KEY")?; + let host: String = config + .get_param("XAI_HOST") + .unwrap_or_else(|_| XAI_API_HOST.to_string()); + + let client = Client::builder() + .timeout(Duration::from_secs(600)) + .build()?; + + Ok(Self { + client, + host, + api_key, + model, + }) + } + + async fn post(&self, payload: Value) -> anyhow::Result { + // Ensure the host ends with a slash for proper URL joining + let host = if self.host.ends_with('/') { + self.host.clone() + } else { + format!("{}/", self.host) + }; + let base_url = Url::parse(&host) + .map_err(|e| ProviderError::RequestFailed(format!("Invalid base URL: {e}")))?; + let url = base_url.join("chat/completions").map_err(|e| { + ProviderError::RequestFailed(format!("Failed to construct endpoint URL: {e}")) + })?; + + tracing::debug!("xAI API URL: {}", url); + tracing::debug!("xAI request model: {:?}", self.model.model_name); + + let response = self + .client + .post(url) + .header("Authorization", format!("Bearer {}", self.api_key)) + .json(&payload) + .send() + .await?; + + let status = response.status(); + let payload: Option = response.json().await.ok(); + + match status { + StatusCode::OK => payload.ok_or_else( || ProviderError::RequestFailed("Response body is not valid JSON".to_string()) ), + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => { + Err(ProviderError::Authentication(format!("Authentication failed. Please ensure your API keys are valid and have the required permissions. \ + Status: {}. Response: {:?}", status, payload))) + } + StatusCode::PAYLOAD_TOO_LARGE => { + Err(ProviderError::ContextLengthExceeded(format!("{:?}", payload))) + } + StatusCode::TOO_MANY_REQUESTS => { + Err(ProviderError::RateLimitExceeded(format!("{:?}", payload))) + } + StatusCode::INTERNAL_SERVER_ERROR | StatusCode::SERVICE_UNAVAILABLE => { + Err(ProviderError::ServerError(format!("{:?}", payload))) + } + _ => { + tracing::debug!( + "{}", format!("Provider request failed with status: {}. Payload: {:?}", status, payload) + ); + Err(ProviderError::RequestFailed(format!("Request failed with status: {}", status))) + } + } + } +} + +#[async_trait] +impl Provider for XaiProvider { + fn metadata() -> ProviderMetadata { + ProviderMetadata::new( + "xai", + "xAI", + "Grok models from xAI, including reasoning and multimodal capabilities", + XAI_DEFAULT_MODEL, + XAI_KNOWN_MODELS.to_vec(), + XAI_DOC_URL, + vec![ + ConfigKey::new("XAI_API_KEY", true, true, None), + ConfigKey::new("XAI_HOST", false, false, Some(XAI_API_HOST)), + ], + ) + } + + fn get_model_config(&self) -> ModelConfig { + self.model.clone() + } + + #[tracing::instrument( + skip(self, system, messages, tools), + fields(model_config, input, output, input_tokens, output_tokens, total_tokens) + )] + async fn complete( + &self, + system: &str, + messages: &[Message], + tools: &[Tool], + ) -> anyhow::Result<(Message, ProviderUsage), ProviderError> { + let payload = create_request( + &self.model, + system, + messages, + tools, + &super::utils::ImageFormat::OpenAi, + )?; + + let response = self.post(payload.clone()).await?; + + let message = response_to_message(response.clone())?; + let usage = match get_usage(&response) { + Ok(usage) => usage, + Err(ProviderError::UsageError(e)) => { + tracing::debug!("Failed to get usage data: {}", e); + Usage::default() + } + Err(e) => return Err(e), + }; + let model = get_model(&response); + super::utils::emit_debug_trace(&self.model, &payload, &response, &usage); + Ok((message, ProviderUsage::new(model, usage))) + } +} diff --git a/documentation/docs/getting-started/providers.md b/documentation/docs/getting-started/providers.md index 1aff07bf69..1b8d1a5388 100644 --- a/documentation/docs/getting-started/providers.md +++ b/documentation/docs/getting-started/providers.md @@ -33,6 +33,7 @@ Goose relies heavily on tool calling capabilities and currently works best with | [OpenRouter](https://openrouter.ai/) | API gateway for unified access to various models with features like rate-limiting management. | `OPENROUTER_API_KEY` | | [Snowflake](https://docs.snowflake.com/user-guide/snowflake-cortex/aisql#choosing-a-model) | Access the latest models using Snowflake Cortex services, including Claude models. **Requires a Snowflake account and programmatic access token (PAT)**. | `SNOWFLAKE_HOST`, `SNOWFLAKE_TOKEN` | | [Venice AI](https://venice.ai/home) | Provides access to open source models like Llama, Mistral, and Qwen while prioritizing user privacy. **Requires an account and an [API key](https://docs.venice.ai/overview/guides/generating-api-key)**. | `VENICE_API_KEY`, `VENICE_HOST` (optional), `VENICE_BASE_PATH` (optional), `VENICE_MODELS_PATH` (optional) | +| [xAI](https://x.ai/) | Access to xAI's Grok models including grok-3, grok-3-mini, and grok-3-fast with 131,072 token context window. | `XAI_API_KEY`, `XAI_HOST` (optional) | ## Configure Provider diff --git a/ui/desktop/src/components/settings/providers/ProviderRegistry.tsx b/ui/desktop/src/components/settings/providers/ProviderRegistry.tsx index 7285f77817..56aa7ed76f 100644 --- a/ui/desktop/src/components/settings/providers/ProviderRegistry.tsx +++ b/ui/desktop/src/components/settings/providers/ProviderRegistry.tsx @@ -97,6 +97,25 @@ export const PROVIDER_REGISTRY: ProviderRegistry[] = [ ], }, }, + { + name: 'xAI', + details: { + id: 'xai', + name: 'xAI', + description: 'Access Grok models from xAI, including reasoning and multimodal capabilities', + parameters: [ + { + name: 'XAI_API_KEY', + is_secret: true, + }, + { + name: 'XAI_HOST', + is_secret: false, + default: 'https://api.x.ai/v1', + }, + ], + }, + }, { name: 'Google', details: { diff --git a/ui/desktop/src/components/settings/providers/modal/subcomponents/ProviderLogo.tsx b/ui/desktop/src/components/settings/providers/modal/subcomponents/ProviderLogo.tsx index 585b436c0d..8f18c721db 100644 --- a/ui/desktop/src/components/settings/providers/modal/subcomponents/ProviderLogo.tsx +++ b/ui/desktop/src/components/settings/providers/modal/subcomponents/ProviderLogo.tsx @@ -6,6 +6,7 @@ import OllamaLogo from './icons/ollama@3x.png'; import DatabricksLogo from './icons/databricks@3x.png'; import OpenRouterLogo from './icons/openrouter@3x.png'; import SnowflakeLogo from './icons/snowflake@3x.png'; +import XaiLogo from './icons/xai@3x.png'; import DefaultLogo from './icons/default@3x.png'; // Map provider names to their logos @@ -18,6 +19,7 @@ const providerLogos: Record = { databricks: DatabricksLogo, openrouter: OpenRouterLogo, snowflake: SnowflakeLogo, + xai: XaiLogo, default: DefaultLogo, }; @@ -30,10 +32,24 @@ export default function ProviderLogo({ providerName }: ProviderLogoProps) { const logoKey = providerName.toLowerCase(); const logo = providerLogos[logoKey] || DefaultLogo; + // Special handling for xAI logo + const isXai = logoKey === 'xai'; + const imageStyle = isXai ? { filter: 'invert(1)', opacity: 0.9 } : {}; + + // Use smaller size for xAI logo to fit better in circle + const imageClassName = isXai + ? 'w-8 h-8 object-contain' // Smaller size for xAI + : 'w-16 h-16 object-contain'; // Default size for others + return (
- {`${providerName} + {`${providerName}
); diff --git a/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/xai.png b/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/xai.png new file mode 100644 index 0000000000000000000000000000000000000000..14bfe48be7961a8e5305341741b99f5e72547efd GIT binary patch literal 3775 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 literal 0 HcmV?d00001 diff --git a/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/xai@3x.png b/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/xai@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..14bfe48be7961a8e5305341741b99f5e72547efd GIT binary patch literal 3775 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 literal 0 HcmV?d00001