Skip to main content

x_ai/
chat_compl.rs

1// Copyright 2026 Mahmoud Harmouch.
2//
3// Licensed under the MIT license
4// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
5// option. This file may not be copied, modified, or distributed
6// except according to those terms.
7
8//! Reference: [docs/api-reference#chat-completions](https://docs.x.ai/docs/api-reference#chat-completions)
9
10use crate::error::XaiError;
11use crate::error::check_for_model_error;
12use crate::traits::ChatCompletionsFetcher;
13use crate::traits::ClientConfig;
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16use std::fmt;
17
18/// A single part of a multi-modal message content array.
19#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
20#[serde(tag = "type", rename_all = "snake_case")]
21pub enum ContentPart {
22    Text { text: String },
23    ImageUrl { image_url: ImageUrl },
24}
25
26#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
27pub struct ImageUrl {
28    pub url: String,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub detail: Option<String>,
31}
32
33/// Message content, either a plain string or a list of content parts (text/image).
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35#[serde(untagged)]
36pub enum MessageContent {
37    Text(String),
38    Parts(Vec<ContentPart>),
39}
40
41impl fmt::Display for ContentPart {
42    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43        match self {
44            ContentPart::Text { text } => write!(f, "{}", text),
45            ContentPart::ImageUrl { image_url } => write!(f, "[Image: {}]", image_url.url),
46        }
47    }
48}
49
50impl fmt::Display for MessageContent {
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        match self {
53            MessageContent::Text(text) => write!(f, "{}", text),
54            MessageContent::Parts(parts) => {
55                for (i, part) in parts.iter().enumerate() {
56                    if i > 0 {
57                        write!(f, " ")?;
58                    }
59                    write!(f, "{}", part)?;
60                }
61                Ok(())
62            }
63        }
64    }
65}
66
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
68pub struct Message {
69    pub role: String,
70    pub content: MessageContent,
71    /// Reasoning trace returned by the model (assistant messages only).
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub reasoning_content: Option<String>,
74    /// Refusal reason returned by the model when it cannot respond.
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub refusal: Option<String>,
77    /// Tool calls requested by the model.
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub tool_calls: Option<Vec<serde_json::Value>>,
80}
81
82impl Message {
83    /// Convenience constructor for a plain-text message.
84    pub fn text(role: impl Into<String>, content: impl Into<String>) -> Self {
85        Self {
86            role: role.into(),
87            content: MessageContent::Text(content.into()),
88            reasoning_content: None,
89            refusal: None,
90            tool_calls: None,
91        }
92    }
93}
94
95/// Parameters controlling live-search behaviour.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct SearchParameters {
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub from_date: Option<String>,
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub to_date: Option<String>,
102    /// `"off"` | `"on"` | `"auto"`
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub mode: Option<String>,
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub return_citations: Option<bool>,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub max_search_results: Option<u32>,
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub sources: Option<Vec<serde_json::Value>>,
111}
112
113/// Options for streaming responses.
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct StreamOptions {
116    pub include_usage: bool,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct ChatCompletionRequest {
121    pub model: String,
122    pub messages: Vec<Message>,
123    pub stream: bool,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub temperature: Option<f32>,
126    /// Deprecated, use `max_completion_tokens` instead.
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub max_tokens: Option<u32>,
129    /// Preferred replacement for `max_tokens`.
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub max_completion_tokens: Option<u32>,
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub frequency_penalty: Option<f32>,
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub presence_penalty: Option<f32>,
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub n: Option<u32>,
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub stop: Option<Vec<String>>,
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub logprobs: Option<bool>,
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub top_p: Option<f32>,
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub top_logprobs: Option<u32>,
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub seed: Option<u32>,
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub user: Option<String>,
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub logit_bias: Option<HashMap<u32, f32>>,
152    /// Return a `request_id` for deferred retrieval instead of blocking.
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub deferred: Option<bool>,
155    /// Allow the model to execute multiple tool calls in parallel.
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub parallel_tool_calls: Option<bool>,
158    /// Reasoning effort for reasoning models: `"low"` | `"high"`.
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub reasoning_effort: Option<String>,
161    /// JSON schema or format object for structured output.
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub response_format: Option<serde_json::Value>,
164    /// Live-search configuration.
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub search_parameters: Option<SearchParameters>,
167    /// Streaming options (only relevant when `stream: true`).
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub stream_options: Option<StreamOptions>,
170    /// Controls which tool (if any) the model should call.
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub tool_choice: Option<serde_json::Value>,
173    /// List of tools the model may call (functions).
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub tools: Option<Vec<serde_json::Value>>,
176    /// OpenAI-compat web search options (mapped internally by xAI).
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub web_search_options: Option<serde_json::Value>,
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct Logprobs {
183    #[serde(skip_serializing_if = "Option::is_none")]
184    pub content: Option<Vec<serde_json::Value>>,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct Choice {
189    pub index: u32,
190    pub message: Message,
191    pub finish_reason: Option<String>,
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub logprobs: Option<Logprobs>,
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct PromptTokensDetails {
198    pub text_tokens: u32,
199    pub audio_tokens: u32,
200    pub image_tokens: u32,
201    pub cached_tokens: u32,
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct CompletionTokensDetails {
206    pub reasoning_tokens: u32,
207    pub audio_tokens: u32,
208    pub accepted_prediction_tokens: u32,
209    pub rejected_prediction_tokens: u32,
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct Usage {
214    pub prompt_tokens: u32,
215    pub completion_tokens: u32,
216    pub total_tokens: u32,
217    #[serde(skip_serializing_if = "Option::is_none")]
218    pub prompt_tokens_details: Option<PromptTokensDetails>,
219    #[serde(skip_serializing_if = "Option::is_none")]
220    pub completion_tokens_details: Option<CompletionTokensDetails>,
221    /// Cost in USD ticks (10 billion ticks = $1).
222    #[serde(skip_serializing_if = "Option::is_none")]
223    pub cost_in_usd_ticks: Option<i64>,
224    /// Number of live-search sources consumed.
225    #[serde(skip_serializing_if = "Option::is_none")]
226    pub num_sources_used: Option<u32>,
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct ChatCompletionResponse {
231    pub id: String,
232    pub object: String,
233    pub created: u64,
234    pub model: String,
235    pub choices: Vec<Choice>,
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub usage: Option<Usage>,
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub system_fingerprint: Option<String>,
240    /// Citations from live-search sources.
241    #[serde(skip_serializing_if = "Option::is_none")]
242    pub citations: Option<Vec<serde_json::Value>>,
243    /// Files generated during the response (e.g. code execution output).
244    #[serde(skip_serializing_if = "Option::is_none")]
245    pub output_files: Option<Vec<serde_json::Value>>,
246    /// Returned instead of the normal response when `deferred: true`.
247    #[serde(skip_serializing_if = "Option::is_none")]
248    pub request_id: Option<String>,
249}
250
251#[derive(Debug, Clone)]
252pub struct ChatCompletionsRequestBuilder<T: ClientConfig + Clone + Send + Sync> {
253    client: T,
254    request: ChatCompletionRequest,
255}
256
257impl<T> ChatCompletionsRequestBuilder<T>
258where
259    T: ClientConfig + Clone + Send + Sync,
260{
261    pub fn new(client: T, model: String, messages: Vec<Message>) -> Self {
262        Self {
263            client,
264            request: ChatCompletionRequest {
265                model,
266                messages,
267                temperature: None,
268                max_tokens: None,
269                max_completion_tokens: None,
270                frequency_penalty: None,
271                presence_penalty: None,
272                n: None,
273                stop: None,
274                stream: false,
275                logprobs: None,
276                top_p: None,
277                top_logprobs: None,
278                seed: None,
279                user: None,
280                logit_bias: None,
281                deferred: None,
282                parallel_tool_calls: None,
283                reasoning_effort: None,
284                response_format: None,
285                search_parameters: None,
286                stream_options: None,
287                tool_choice: None,
288                tools: None,
289                web_search_options: None,
290            },
291        }
292    }
293
294    pub fn temperature(mut self, temperature: f32) -> Self {
295        self.request.temperature = Some(temperature);
296        self
297    }
298
299    /// Deprecated. Prefer `max_completion_tokens`.
300    pub fn max_tokens(mut self, max_tokens: u32) -> Self {
301        self.request.max_tokens = Some(max_tokens);
302        self
303    }
304
305    pub fn max_completion_tokens(mut self, max_completion_tokens: u32) -> Self {
306        self.request.max_completion_tokens = Some(max_completion_tokens);
307        self
308    }
309
310    pub fn frequency_penalty(mut self, frequency_penalty: f32) -> Self {
311        self.request.frequency_penalty = Some(frequency_penalty);
312        self
313    }
314
315    pub fn presence_penalty(mut self, presence_penalty: f32) -> Self {
316        self.request.presence_penalty = Some(presence_penalty);
317        self
318    }
319
320    pub fn n(mut self, n: u32) -> Self {
321        self.request.n = Some(n);
322        self
323    }
324
325    pub fn stop(mut self, stop: Vec<String>) -> Self {
326        self.request.stop = Some(stop);
327        self
328    }
329
330    pub fn stream(mut self, stream: bool) -> Self {
331        self.request.stream = stream;
332        self
333    }
334
335    pub fn logprobs(mut self, logprobs: bool) -> Self {
336        self.request.logprobs = Some(logprobs);
337        self
338    }
339
340    pub fn top_p(mut self, top_p: f32) -> Self {
341        self.request.top_p = Some(top_p);
342        self
343    }
344
345    pub fn top_logprobs(mut self, top_logprobs: u32) -> Self {
346        self.request.top_logprobs = Some(top_logprobs);
347        self
348    }
349
350    pub fn seed(mut self, seed: u32) -> Self {
351        self.request.seed = Some(seed);
352        self
353    }
354
355    pub fn user(mut self, user: String) -> Self {
356        self.request.user = Some(user);
357        self
358    }
359
360    pub fn logit_bias(mut self, logit_bias: HashMap<u32, f32>) -> Self {
361        self.request.logit_bias = Some(logit_bias);
362        self
363    }
364
365    pub fn deferred(mut self, deferred: bool) -> Self {
366        self.request.deferred = Some(deferred);
367        self
368    }
369
370    pub fn parallel_tool_calls(mut self, parallel_tool_calls: bool) -> Self {
371        self.request.parallel_tool_calls = Some(parallel_tool_calls);
372        self
373    }
374
375    /// Reasoning effort for reasoning models: `"low"` or `"high"`.
376    /// Not supported by `grok-4` models.
377    pub fn reasoning_effort(mut self, effort: impl Into<String>) -> Self {
378        self.request.reasoning_effort = Some(effort.into());
379        self
380    }
381
382    pub fn response_format(mut self, response_format: serde_json::Value) -> Self {
383        self.request.response_format = Some(response_format);
384        self
385    }
386
387    pub fn search_parameters(mut self, search_parameters: SearchParameters) -> Self {
388        self.request.search_parameters = Some(search_parameters);
389        self
390    }
391
392    pub fn stream_options(mut self, stream_options: StreamOptions) -> Self {
393        self.request.stream_options = Some(stream_options);
394        self
395    }
396
397    pub fn tool_choice(mut self, tool_choice: serde_json::Value) -> Self {
398        self.request.tool_choice = Some(tool_choice);
399        self
400    }
401
402    pub fn tools(mut self, tools: Vec<serde_json::Value>) -> Self {
403        self.request.tools = Some(tools);
404        self
405    }
406
407    pub fn web_search_options(mut self, web_search_options: serde_json::Value) -> Self {
408        self.request.web_search_options = Some(web_search_options);
409        self
410    }
411
412    pub fn build(self) -> Result<ChatCompletionRequest, XaiError> {
413        Ok(self.request)
414    }
415}
416
417impl<T> ChatCompletionsFetcher for ChatCompletionsRequestBuilder<T>
418where
419    T: ClientConfig + Clone + Send + Sync,
420{
421    async fn create_chat_completion(
422        &self,
423        request: ChatCompletionRequest,
424    ) -> Result<ChatCompletionResponse, XaiError> {
425        let response = self
426            .client
427            .request(reqwest::Method::POST, "chat/completions")?
428            .json(&request)
429            .send()
430            .await?;
431
432        if response.status().is_success() {
433            let chat_completion = response.json::<ChatCompletionResponse>().await?;
434            Ok(chat_completion)
435        } else {
436            let error_body = response.text().await.unwrap_or_else(|_| "".to_string());
437
438            if let Some(model_error) = check_for_model_error(&error_body) {
439                return Err(model_error);
440            }
441
442            Err(XaiError::Http(error_body))
443        }
444    }
445}
446// Copyright 2026 Mahmoud Harmouch.
447//
448// Licensed under the MIT license
449// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
450// option. This file may not be copied, modified, or distributed
451// except according to those terms.