asimov_xai_module/
lib.rs

1// This is free and unencumbered software released into the public domain.
2
3#![no_std]
4#![forbid(unsafe_code)]
5
6use asimov_module::{
7    prelude::*,
8    secrecy::{ExposeSecret, SecretString},
9    tracing,
10};
11use core::error::Error;
12use serde_json::{Value, json};
13
14#[derive(Clone, Debug, bon::Builder)]
15#[builder(on(String, into))]
16pub struct Options {
17    #[builder(default = "https://api.x.ai")]
18    pub endpoint: String,
19
20    #[builder(default = "grok-3-mini")]
21    pub model: String,
22
23    pub max_tokens: Option<usize>,
24
25    #[builder(into)]
26    pub api_key: SecretString,
27}
28
29pub fn generate(input: impl AsRef<str>, options: &Options) -> Result<Vec<String>, Box<dyn Error>> {
30    let mut req = json!({
31        "model": options.model,
32        "input": input.as_ref(),
33    });
34
35    if let Some(max_tokens) = options.max_tokens {
36        req["max_output_tokens"] = max_tokens.into();
37    }
38
39    let mut resp = ureq::Agent::config_builder()
40        .http_status_as_error(false)
41        .user_agent("asimov-xai-module")
42        .build()
43        .new_agent()
44        .post(format!("{}/v1/responses", options.endpoint))
45        .header(
46            "Authorization",
47            format!("Bearer {}", options.api_key.expose_secret()),
48        )
49        .header("content-type", "application/json")
50        .send_json(&req)
51        .inspect_err(|e| tracing::error!("HTTP request failed: {e}"))?;
52    tracing::debug!(response = ?resp);
53
54    let status = resp.status();
55    tracing::debug!(status = status.to_string());
56
57    let resp: Value = resp
58        .body_mut()
59        .read_json()
60        .inspect_err(|e| tracing::error!("unable to read HTTP response body: {e}"))?;
61    tracing::debug!(body = resp.to_string());
62
63    if !status.is_success() {
64        tracing::error!("Received an error response: {status}");
65
66        // {
67        //   "code": "Client specified an invalid argument",
68        //   "error": "Incorrect API key provided: fo***ar. You can obtain an API key from https://console.x.ai."
69        // }
70        if let Some(message) = resp["error"].as_str() {
71            return Err(message.into());
72        }
73        if let Some(message) = resp.as_str() {
74            return Err(message.into());
75        }
76    }
77
78    let mut responses = Vec::new();
79
80    // {
81    //   "created_at": 1758188599,
82    //   "id": "...",
83    //   "incomplete_details": null,
84    //   "max_output_tokens": null,
85    //   "metadata": {},
86    //   "model": "grok-3-mini",
87    //   "object": "response",
88    //   "output": [
89    //     {
90    //       "id": "rs_...",
91    //       "status": "completed",
92    //       "summary": [
93    //         {
94    //           "text": "...",
95    //           "type": "summary_text"
96    //         }
97    //       ],
98    //       "type": "reasoning"
99    //     },
100    //     {
101    //       "content": [
102    //         {
103    //           "annotations": [],
104    //           "logprobs": null,
105    //           "text": "...",
106    //           "type": "output_text"
107    //         }
108    //       ],
109    //       "id": "msg_...",
110    //       "role": "assistant",
111    //       "status": "completed",
112    //       "type": "message"
113    //     }
114    //   ],
115    //   "parallel_tool_calls": true,
116    //   "previous_response_id": null,
117    //   "reasoning": {
118    //     "effort": "medium",
119    //     "summary": "detailed"
120    //   },
121    //   "status": "completed",
122    //   "store": true,
123    //   "temperature": null,
124    //   "text": {
125    //     "format": {
126    //       "type": "text"
127    //     }
128    //   },
129    //   "tool_choice": "auto",
130    //   "tools": [],
131    //   "top_p": null,
132    //   "usage": {
133    //     "input_tokens": 8,
134    //     "input_tokens_details": {
135    //       "cached_tokens": 7
136    //     },
137    //     "output_tokens": 199,
138    //     "output_tokens_details": {
139    //       "reasoning_tokens": 188
140    //     },
141    //     "total_tokens": 207
142    //   },
143    //   "user": null
144    // }
145
146    if let Some(chunks) = resp["output"].as_array() {
147        for chunk in chunks {
148            if chunk["type"].as_str().is_none_or(|t| t != "message") {
149                tracing::debug!("skipping non-message chunk in response: {chunk}");
150                continue;
151            }
152            if chunk["role"].as_str().is_none_or(|r| r != "assistant") {
153                tracing::debug!("skipping output chunk not from assistant: {chunk}");
154                continue;
155            }
156
157            if let Some(chunk_contents) = chunk["content"].as_array() {
158                for content in chunk_contents {
159                    if content["type"].as_str().is_none_or(|r| r != "output_text") {
160                        tracing::debug!("skipping non-text message chunk in response: {chunk}");
161                        continue;
162                    }
163
164                    if let Some(text) = content["text"].as_str() {
165                        responses.push(text.to_string());
166                    }
167                }
168            };
169
170            if let Some(status) = chunk["status"].as_str() {
171                tracing::debug!(status);
172            }
173        }
174    }
175
176    Ok(responses)
177}