Skip to main content

rig_core/providers/xai/
completion.rs

1//! xAI Completion Integration
2//!
3//! Uses the xAI Responses API: <https://docs.x.ai/docs/guides/chat>
4
5use bytes::Bytes;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use tracing::{Instrument, Level, enabled, info_span};
9
10use super::api::{ApiResponse, Message, ToolDefinition};
11use super::client::Client;
12use crate::OneOrMany;
13use crate::completion::{self, CompletionError, CompletionRequest, GetTokenUsage};
14use crate::http_client::HttpClientExt;
15use crate::providers::openai::responses_api::ToolChoice;
16use crate::providers::openai::responses_api::streaming::StreamingCompletionResponse;
17use crate::providers::openai::responses_api::{Output, ResponsesUsage};
18use crate::streaming::StreamingCompletionResponse as BaseStreamingCompletionResponse;
19
20/// xAI completion models as of 2025-06-04
21pub const GROK_2_1212: &str = "grok-2-1212";
22pub const GROK_2_VISION_1212: &str = "grok-2-vision-1212";
23pub const GROK_3: &str = "grok-3";
24pub const GROK_3_FAST: &str = "grok-3-fast";
25pub const GROK_3_MINI: &str = "grok-3-mini";
26pub const GROK_3_MINI_FAST: &str = "grok-3-mini-fast";
27pub const GROK_2_IMAGE_1212: &str = "grok-2-image-1212";
28pub const GROK_4: &str = "grok-4-0709";
29
30// ================================================================
31// Request Types
32// ================================================================
33
34#[derive(Debug, Serialize, Deserialize)]
35pub(super) struct XAICompletionRequest {
36    model: String,
37    pub input: Vec<Message>,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    temperature: Option<f64>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    max_output_tokens: Option<u64>,
42    #[serde(skip_serializing_if = "Vec::is_empty")]
43    tools: Vec<Value>,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    tool_choice: Option<ToolChoice>,
46    #[serde(flatten, skip_serializing_if = "Option::is_none")]
47    pub additional_params: Option<serde_json::Value>,
48}
49
50impl TryFrom<(&str, CompletionRequest)> for XAICompletionRequest {
51    type Error = CompletionError;
52
53    fn try_from((model, req): (&str, CompletionRequest)) -> Result<Self, Self::Error> {
54        let chat_history = req.chat_history_with_documents();
55        if req.output_schema.is_some() {
56            tracing::warn!("Structured outputs currently not supported for xAI");
57        }
58        let model = req.model.clone().unwrap_or_else(|| model.to_string());
59        let mut input: Vec<Message> = req
60            .preamble
61            .as_ref()
62            .map_or_else(Vec::new, |p| vec![Message::system(p)]);
63
64        let mut additional_params_payload = req.additional_params.unwrap_or(Value::Null);
65
66        for msg in chat_history {
67            let msg: Vec<Message> = msg.try_into()?;
68            input.extend(msg);
69        }
70
71        let tool_choice = req.tool_choice.map(ToolChoice::try_from).transpose()?;
72        let mut additional_tools =
73            extract_tools_from_additional_params(&mut additional_params_payload)?;
74        let mut tools = req
75            .tools
76            .into_iter()
77            .map(ToolDefinition::from)
78            .map(serde_json::to_value)
79            .collect::<Result<Vec<_>, _>>()?;
80        tools.append(&mut additional_tools);
81        let additional_params = if additional_params_payload.is_null() {
82            None
83        } else {
84            Some(additional_params_payload)
85        };
86
87        Ok(Self {
88            model: model.to_string(),
89            input,
90            temperature: req.temperature,
91            max_output_tokens: req.max_tokens,
92            tools,
93            tool_choice,
94            additional_params,
95        })
96    }
97}
98
99fn extract_tools_from_additional_params(
100    additional_params: &mut Value,
101) -> Result<Vec<Value>, CompletionError> {
102    if let Some(map) = additional_params.as_object_mut()
103        && let Some(raw_tools) = map.remove("tools")
104    {
105        return serde_json::from_value::<Vec<Value>>(raw_tools).map_err(|err| {
106            CompletionError::RequestError(
107                format!("Invalid xAI `additional_params.tools` payload: {err}").into(),
108            )
109        });
110    }
111
112    Ok(Vec::new())
113}
114
115// ================================================================
116// Response Types
117// ================================================================
118
119#[derive(Debug, Deserialize, Serialize)]
120pub struct CompletionResponse {
121    pub id: String,
122    pub model: String,
123    pub output: Vec<Output>,
124    #[serde(default)]
125    pub created: i64,
126    #[serde(default)]
127    pub object: String,
128    #[serde(default)]
129    pub status: Option<String>,
130    pub usage: Option<ResponsesUsage>,
131}
132
133impl TryFrom<CompletionResponse> for completion::CompletionResponse<CompletionResponse> {
134    type Error = CompletionError;
135
136    fn try_from(response: CompletionResponse) -> Result<Self, Self::Error> {
137        let content: Vec<completion::AssistantContent> = response
138            .output
139            .iter()
140            .cloned()
141            .flat_map(<Vec<completion::AssistantContent>>::from)
142            .collect();
143
144        let choice = OneOrMany::many(content).map_err(|_| {
145            CompletionError::ResponseError("Response contained no output".to_owned())
146        })?;
147
148        let usage = response
149            .usage
150            .as_ref()
151            .map(GetTokenUsage::token_usage)
152            .unwrap_or_default();
153        let message_id = response.output.iter().find_map(|item| match item {
154            Output::Message(message) => Some(message.id.clone()),
155            _ => None,
156        });
157
158        Ok(completion::CompletionResponse {
159            choice,
160            usage,
161            raw_response: response,
162            message_id,
163        })
164    }
165}
166
167// ================================================================
168// Completion Model
169// ================================================================
170
171#[derive(Clone)]
172pub struct CompletionModel<T = reqwest::Client> {
173    pub(crate) client: Client<T>,
174    pub model: String,
175}
176
177impl<T> CompletionModel<T> {
178    pub fn new(client: Client<T>, model: impl Into<String>) -> Self {
179        Self {
180            client,
181            model: model.into(),
182        }
183    }
184}
185
186impl<T> completion::CompletionModel for CompletionModel<T>
187where
188    T: HttpClientExt + Clone + Default + std::fmt::Debug + Send + 'static,
189{
190    type Response = CompletionResponse;
191    type StreamingResponse = StreamingCompletionResponse;
192
193    type Client = Client<T>;
194
195    fn make(client: &Self::Client, model: impl Into<String>) -> Self {
196        Self::new(client.clone(), model)
197    }
198
199    async fn completion(
200        &self,
201        completion_request: completion::CompletionRequest,
202    ) -> Result<completion::CompletionResponse<CompletionResponse>, CompletionError> {
203        let span = if tracing::Span::current().is_disabled() {
204            info_span!(
205                target: "rig::completions",
206                "chat",
207                gen_ai.operation.name = "chat",
208                gen_ai.provider.name = "xai",
209                gen_ai.request.model = self.model,
210                gen_ai.system_instructions = tracing::field::Empty,
211                gen_ai.response.id = tracing::field::Empty,
212                gen_ai.response.model = tracing::field::Empty,
213                gen_ai.usage.output_tokens = tracing::field::Empty,
214                gen_ai.usage.input_tokens = tracing::field::Empty,
215                gen_ai.usage.cache_read.input_tokens = tracing::field::Empty,
216            )
217        } else {
218            tracing::Span::current()
219        };
220
221        span.record("gen_ai.system_instructions", &completion_request.preamble);
222
223        let request =
224            XAICompletionRequest::try_from((self.model.to_string().as_ref(), completion_request))?;
225
226        if enabled!(Level::TRACE) {
227            tracing::trace!(target: "rig::completions",
228                "xAI completion request: {}",
229                serde_json::to_string_pretty(&request)?
230            );
231        }
232
233        let body = serde_json::to_vec(&request)?;
234        let req = self
235            .client
236            .post("/v1/responses")?
237            .body(body)
238            .map_err(|e| CompletionError::HttpError(e.into()))?;
239
240        async move {
241            let response = self.client.send::<_, Bytes>(req).await?;
242            let status = response.status();
243            let response_body = response.into_body().into_future().await?.to_vec();
244
245            if status.is_success() {
246                match serde_json::from_slice::<ApiResponse<CompletionResponse>>(&response_body)? {
247                    ApiResponse::Ok(response) => {
248                        if enabled!(Level::TRACE) {
249                            tracing::trace!(target: "rig::completions",
250                                "xAI completion response: {}",
251                                serde_json::to_string_pretty(&response)?
252                            );
253                        }
254
255                        response.try_into()
256                    }
257                    ApiResponse::Error(error) => {
258                        tracing::warn!(message = %error.message(), "provider returned an error response");
259                        Err(CompletionError::from_http_response(
260                            status,
261                            String::from_utf8_lossy(&response_body),
262                        ))
263                    }
264                }
265            } else {
266                Err(CompletionError::from_http_response(
267                    status,
268                    String::from_utf8_lossy(&response_body),
269                ))
270            }
271        }
272        .instrument(span)
273        .await
274    }
275
276    async fn stream(
277        &self,
278        request: CompletionRequest,
279    ) -> Result<BaseStreamingCompletionResponse<Self::StreamingResponse>, CompletionError> {
280        self.stream(request).await
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::XAICompletionRequest;
287    use crate::OneOrMany;
288    use crate::completion::request::Document;
289    use crate::completion::{CompletionRequest, CompletionRequestBuilder, Message, ToolDefinition};
290    use crate::message::ToolChoice;
291    use crate::test_utils::MockCompletionModel;
292
293    #[test]
294    fn xai_request_includes_normalized_documents() {
295        let request =
296            CompletionRequestBuilder::new(MockCompletionModel::default(), "What is glarb-glarb?")
297                .message(Message::system("Use the provided context."))
298                .document(Document {
299                    id: "doc_1".to_string(),
300                    text: "Definition of glarb-glarb: an ancient tool.".to_string(),
301                    additional_props: Default::default(),
302                })
303                .build();
304
305        let xai_request = XAICompletionRequest::try_from(("grok-4-0709", request))
306            .expect("request conversion should succeed");
307        let serialized = serde_json::to_value(xai_request).expect("serialization should succeed");
308        let input = serialized["input"]
309            .as_array()
310            .expect("xAI request input should be an array");
311
312        assert!(
313            input
314                .iter()
315                .any(|message| message.to_string().contains("glarb-glarb")),
316            "normalized documents should be forwarded into xAI input"
317        );
318    }
319
320    #[test]
321    fn xai_direct_request_keeps_documents_after_system_messages() {
322        let request = CompletionRequest {
323            model: None,
324            preamble: None,
325            chat_history: OneOrMany::many(vec![
326                Message::system("System prompt"),
327                Message::assistant("Earlier assistant turn"),
328                Message::system("Mid-conversation instruction"),
329                Message::user("What is glarb-glarb?"),
330            ])
331            .unwrap(),
332            documents: vec![Document {
333                id: "doc_1".to_string(),
334                text: "Definition of glarb-glarb: an ancient tool.".to_string(),
335                additional_props: Default::default(),
336            }],
337            tools: vec![],
338            temperature: None,
339            max_tokens: None,
340            tool_choice: None,
341            additional_params: None,
342            output_schema: None,
343        };
344
345        let xai_request = XAICompletionRequest::try_from(("grok-4-0709", request))
346            .expect("request conversion should succeed");
347        let serialized = serde_json::to_value(xai_request).expect("serialization should succeed");
348        let input = serialized["input"]
349            .as_array()
350            .expect("xAI request input should be an array");
351
352        assert_eq!(input.len(), 5);
353        assert_eq!(input[0]["role"], "system");
354        assert_eq!(input[1]["role"], "user");
355        assert!(
356            input[1].to_string().contains("<file id: doc_1>"),
357            "document input should follow leading system input: {input:?}"
358        );
359        assert_eq!(input[2]["role"], "assistant");
360        assert_eq!(input[3]["role"], "system");
361        assert_eq!(input[4]["role"], "user");
362        assert_eq!(
363            input
364                .iter()
365                .filter(|message| message.to_string().contains("<file id: doc_1>"))
366                .count(),
367            1,
368            "document input should appear exactly once: {input:?}"
369        );
370    }
371
372    #[test]
373    fn xai_request_uses_responses_tool_choice_for_specific_tool() {
374        let request = CompletionRequestBuilder::new(MockCompletionModel::default(), "Use a tool.")
375            .tool(ToolDefinition {
376                name: "alpha".to_string(),
377                description: "Alpha tool".to_string(),
378                parameters: serde_json::json!({
379                    "type": "object",
380                    "properties": {},
381                    "required": []
382                }),
383            })
384            .tool(ToolDefinition {
385                name: "beta".to_string(),
386                description: "Beta tool".to_string(),
387                parameters: serde_json::json!({
388                    "type": "object",
389                    "properties": {},
390                    "required": []
391                }),
392            })
393            .tool_choice(ToolChoice::Specific {
394                function_names: vec!["beta".to_string()],
395            })
396            .build();
397
398        let xai_request = XAICompletionRequest::try_from(("grok-4.3", request))
399            .expect("xAI Responses API should support specific tool choice");
400        let serialized = serde_json::to_value(xai_request).expect("serialization should succeed");
401
402        assert_eq!(
403            serialized["tool_choice"],
404            serde_json::json!({"type": "function", "name": "beta"})
405        );
406    }
407
408    #[test]
409    fn xai_response_preserves_message_id_and_reasoning_token_usage() {
410        let raw: super::CompletionResponse = serde_json::from_value(serde_json::json!({
411            "id": "resp_123",
412            "model": "grok-4.3",
413            "output": [
414                {
415                    "type": "reasoning",
416                    "id": "rs_123",
417                    "summary": [{ "type": "summary_text", "text": "thinking" }],
418                    "status": "completed"
419                },
420                {
421                    "type": "message",
422                    "id": "msg_123",
423                    "role": "assistant",
424                    "status": "completed",
425                    "content": [
426                        { "type": "output_text", "text": "done", "annotations": [] }
427                    ]
428                }
429            ],
430            "usage": {
431                "input_tokens": 10,
432                "input_tokens_details": { "cached_tokens": 3 },
433                "output_tokens": 8,
434                "output_tokens_details": { "reasoning_tokens": 5 },
435                "total_tokens": 18
436            }
437        }))
438        .expect("fixture should deserialize");
439
440        let converted = crate::completion::CompletionResponse::try_from(raw)
441            .expect("xAI response should convert");
442
443        assert_eq!(converted.message_id.as_deref(), Some("msg_123"));
444        assert_eq!(converted.usage.input_tokens, 10);
445        assert_eq!(converted.usage.cached_input_tokens, 3);
446        assert_eq!(converted.usage.output_tokens, 8);
447        assert_eq!(converted.usage.reasoning_tokens, 5);
448    }
449
450    #[tokio::test]
451    async fn completion_non_success_preserves_status_and_body() {
452        use crate::client::CompletionClient;
453        use crate::completion::{CompletionError, CompletionModel as _};
454        use crate::test_utils::RecordingHttpClient;
455
456        let body = r#"{"error":"boom","code":"503"}"#;
457        let http_client =
458            RecordingHttpClient::with_error_response(http::StatusCode::SERVICE_UNAVAILABLE, body);
459        let client = crate::providers::xai::Client::builder()
460            .api_key("test-key")
461            .http_client(http_client)
462            .build()
463            .expect("build client");
464        let model = client.completion_model(crate::providers::xai::completion::GROK_4);
465        let request = model.completion_request("hello").build();
466
467        let error = model
468            .completion(request)
469            .await
470            .expect_err("should fail with non-success status");
471
472        assert!(matches!(error, CompletionError::HttpError(_)));
473        assert_eq!(
474            error.provider_response_status(),
475            Some(http::StatusCode::SERVICE_UNAVAILABLE)
476        );
477        assert_eq!(error.provider_response_body(), Some(body));
478    }
479
480    #[tokio::test]
481    async fn completion_2xx_error_envelope_preserves_status_and_body() {
482        use crate::client::CompletionClient;
483        use crate::completion::{CompletionError, CompletionModel as _};
484        use crate::test_utils::RecordingHttpClient;
485
486        // Deserializes to `ApiResponse::Error(ApiError { error, code })` on a 200 OK.
487        let body = r#"{"error":"boom","code":"503"}"#;
488        let http_client = RecordingHttpClient::new(body);
489        let client = crate::providers::xai::Client::builder()
490            .api_key("test-key")
491            .http_client(http_client)
492            .build()
493            .expect("build client");
494        let model = client.completion_model(crate::providers::xai::completion::GROK_4);
495        let request = model.completion_request("hello").build();
496
497        let error = model
498            .completion(request)
499            .await
500            .expect_err("should fail with provider error envelope");
501
502        match &error {
503            CompletionError::ProviderResponse(stored) => {
504                assert_eq!(stored.body, body);
505                assert_eq!(stored.status, Some(http::StatusCode::OK));
506            }
507            other => panic!("expected ProviderResponse, got {other:?}"),
508        }
509    }
510}