1use 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
20pub 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#[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#[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#[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 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}