1use serde_json::{Map, Value};
2use vtcode_config::constants::{env_vars, models, urls};
3
4use super::extract_reasoning_trace;
5use super::openai_compat::{OpenAiCompatCore, OpenAiCompatSpec, SystemPromptPlacement, impl_openai_compat_provider};
6
7pub struct XaiSpec;
8
9fn xai_reasoning(message: &Value, _choice: &Value) -> Option<String> {
10 message.get("reasoning_content").and_then(extract_reasoning_trace)
11}
12
13impl OpenAiCompatSpec for XaiSpec {
14 const NAME: &'static str = "xAI";
15 const KEY: &'static str = "xai";
16 const API_KEY_ENV: &'static str = "XAI_API_KEY";
17 const DEFAULT_MODEL: &'static str = models::xai::DEFAULT_MODEL;
18 const DEFAULT_BASE_URL: &'static str = urls::XAI_API_BASE;
19 const BASE_URL_ENV: Option<&'static str> = Some(env_vars::XAI_BASE_URL);
20 const LISTED_MODELS: &'static [&'static str] = models::xai::SUPPORTED_MODELS;
21 const VALIDATION_ALLOWLIST: Option<&'static [&'static str]> = Some(models::xai::SUPPORTED_MODELS);
22
23 const SYSTEM_PROMPT: SystemPromptPlacement = SystemPromptPlacement::FirstMessage;
24 const STREAM_OPTIONS_INCLUDE_USAGE: bool = true;
25 const INCLUDE_USER_ID: bool = true;
26 const RESPONSE_REASONING_EXTRACTOR: Option<super::openai_compat::ReasoningExtractor> = Some(xai_reasoning);
27
28 fn insert_reasoning(
29 _core: &OpenAiCompatCore<Self>,
30 request: &crate::provider::LLMRequest,
31 payload: &mut Map<String, Value>,
32 ) -> Result<(), crate::provider::LLMError> {
33 if let Some(effort) = request.reasoning_effort {
34 if !matches!(
35 effort,
36 vtcode_config::types::ReasoningEffortLevel::None | vtcode_config::types::ReasoningEffortLevel::Unknown
37 ) {
38 let value = match effort {
42 vtcode_config::types::ReasoningEffortLevel::Minimal
43 | vtcode_config::types::ReasoningEffortLevel::Low => "low",
44 vtcode_config::types::ReasoningEffortLevel::Max => "xhigh",
45 other => other.as_str(),
46 };
47 payload.insert("reasoning_effort".to_owned(), serde_json::json!(value));
48 }
49 }
50 Ok(())
51 }
52
53 fn response_cache_metrics(core: &OpenAiCompatCore<Self>) -> bool {
54 core.prompt_cache_enabled
55 }
56
57 fn stream_cache_metrics(_core: &OpenAiCompatCore<Self>) -> bool {
58 true
59 }
60
61 fn finish_payload(
65 _core: &OpenAiCompatCore<Self>,
66 request: &crate::provider::LLMRequest,
67 payload: &mut Map<String, Value>,
68 ) -> Result<(), crate::provider::LLMError> {
69 if let Some(lineage) =
70 crate::providers::shared::session_lineage_from_prompt_cache_key(request.prompt_cache_key.as_deref())
71 {
72 payload.insert("prompt_cache_key".to_owned(), Value::String(lineage.clone()));
73 }
74 Ok(())
75 }
76
77 fn session_affinity_header() -> Option<&'static str> {
78 Some("x-grok-conv-id")
79 }
80}
81
82impl XAIProvider {
83 fn xai_compact_model(&self, model: &str) -> bool {
88 let resolved = if model.trim().is_empty() {
89 self.core.model.as_str()
90 } else {
91 model
92 };
93 models::xai::SUPPORTED_MODELS.contains(&resolved) && self.core.base_url.contains("api.x.ai")
94 }
95
96 fn compact_client(&self, model: &str) -> crate::providers::openresponses::OpenResponsesProvider {
97 crate::providers::openresponses::OpenResponsesProvider::compact_endpoint_client(
98 &self.core.model,
99 &self.core.base_url,
100 &self.core.api_key,
101 model,
102 )
103 }
104}
105
106impl_openai_compat_provider!(XAIProvider, XaiSpec, {
107 fn supports_reasoning(&self, model: &str) -> bool {
108 let requested = if model.trim().is_empty() {
109 &self.core.model
110 } else {
111 model
112 };
113 self.core
114 .model_behavior
115 .as_ref()
116 .and_then(|b| b.model_supports_reasoning)
117 .unwrap_or(false)
118 || models::xai::REASONING_MODELS.contains(&requested)
119 }
120
121 fn supports_reasoning_effort(&self, model: &str) -> bool {
122 let requested = if model.trim().is_empty() {
123 &self.core.model
124 } else {
125 model
126 };
127 self.core
128 .model_behavior
129 .as_ref()
130 .and_then(|b| b.model_supports_reasoning_effort)
131 .unwrap_or_else(|| {
132 vtcode_config::models::model_catalog_entry("xai", requested)
133 .is_some_and(|entry| !entry.reasoning_efforts.is_empty())
134 })
135 }
136
137 fn supports_responses_compaction(&self, model: &str) -> bool {
138 self.xai_compact_model(model)
139 }
140
141 fn supports_manual_openai_compaction(&self, model: &str) -> bool {
142 self.xai_compact_model(model)
143 }
144
145 async fn compact_history(
146 &self,
147 model: &str,
148 history: &[crate::provider::Message],
149 ) -> Result<Vec<crate::provider::Message>, crate::provider::LLMError> {
150 if !self.xai_compact_model(model) {
151 return Err(crate::provider::LLMError::Provider {
152 message: "xAI compaction is only supported for curated Grok models on the xAI API".to_string(),
153 metadata: None,
154 });
155 }
156 self.compact_client(model).compact_history_request(model, history).await
157 }
158
159 async fn compact_history_with_options(
160 &self,
161 model: &str,
162 history: &[crate::provider::Message],
163 _options: &crate::provider::ResponsesCompactionOptions,
164 ) -> Result<Vec<crate::provider::Message>, crate::provider::LLMError> {
165 self.compact_history(model, history).await
166 }
167});
168
169#[cfg(test)]
170mod tests {
171 #[test]
172 fn xai_advertises_grok_session_affinity_header() {
173 use super::XaiSpec;
174 use crate::providers::openai_compat::OpenAiCompatSpec;
175 assert_eq!(XaiSpec::session_affinity_header(), Some("x-grok-conv-id"));
176 }
177
178 #[test]
179 fn xai_finish_payload_injects_lineage_prompt_cache_key() {
180 let provider = XAIProvider::new("test-key".to_string());
181
182 let mut request = base_request();
183 request.prompt_cache_key = Some("vtcode:xai:session-lineage-1".to_string());
184 let payload = provider.core.convert_request(&request).unwrap();
185 assert_eq!(payload["prompt_cache_key"].as_str(), Some("session-lineage-1"));
186
187 let mut blank = base_request();
188 blank.prompt_cache_key = Some(" ".to_string());
189 let payload = provider.core.convert_request(&blank).unwrap();
190 assert!(payload.get("prompt_cache_key").is_none(), "blank lineage must omit prompt_cache_key");
191 }
192
193 #[test]
194 fn xai_dispatch_header_name_is_pinned_for_lineage_routing() {
195 use super::XaiSpec;
196 use crate::providers::openai_compat::OpenAiCompatSpec;
197 assert_eq!(XaiSpec::session_affinity_header(), Some("x-grok-conv-id"));
200 }
201
202 use super::XAIProvider;
203 use crate::provider::{LLMRequest, Message, ToolChoice};
204 use std::sync::Arc;
205 use vtcode_config::constants::models;
206 use vtcode_config::types::ReasoningEffortLevel;
207
208 fn base_request() -> LLMRequest {
209 LLMRequest {
210 messages: vec![Message::user("hello".to_string())].into(),
211 system_prompt: Some(Arc::from("system guidance")),
212 model: models::xai::DEFAULT_MODEL.to_string(),
213 max_tokens: Some(512),
214 temperature: Some(0.5),
215 top_p: Some(0.25),
216 stream: true,
217 tool_choice: Some(ToolChoice::Auto),
218 ..Default::default()
219 }
220 }
221
222 #[test]
223 fn golden_payload_basic_shape() {
224 let provider = XAIProvider::new("test-key".to_string());
225 let payload = provider.core.convert_request(&base_request()).unwrap();
226
227 assert_eq!(payload["model"], models::xai::DEFAULT_MODEL);
228 let messages = payload["messages"].as_array().unwrap();
229 assert_eq!(messages.len(), 2);
230 assert_eq!(messages[0]["role"], "system");
231 assert_eq!(messages[0]["content"], "system guidance");
232 assert_eq!(messages[1]["role"], "user");
233 assert_eq!(payload["max_tokens"], 512);
234 assert_eq!(payload["temperature"], 0.5);
235 assert_eq!(payload["top_p"], 0.25);
236 assert_eq!(payload["stream"], true);
237 assert_eq!(payload["stream_options"]["include_usage"], true);
238 assert_eq!(payload["tool_choice"], "auto");
239 }
240
241 #[test]
242 fn golden_payload_with_reasoning_effort() {
243 let provider = XAIProvider::new("test-key".to_string());
244
245 let mut request = base_request();
246 request.reasoning_effort = Some(ReasoningEffortLevel::High);
247 let payload = provider.core.convert_request(&request).unwrap();
248 assert_eq!(payload["reasoning_effort"], "high");
249 }
250
251 #[test]
252 fn max_effort_clamps_to_xhigh() {
253 let provider = XAIProvider::new("test-key".to_string());
254
255 let mut request = base_request();
256 request.reasoning_effort = Some(ReasoningEffortLevel::Max);
257 let payload = provider.core.convert_request(&request).unwrap();
258 assert_eq!(payload["reasoning_effort"], "xhigh");
260
261 request.reasoning_effort = Some(ReasoningEffortLevel::Minimal);
263 let payload = provider.core.convert_request(&request).unwrap();
264 assert_eq!(payload["reasoning_effort"], "low");
265 }
266
267 #[test]
268 fn compat_payload_forwards_ultrafast_service_tier() {
269 let provider = XAIProvider::new("test-key".to_string());
270
271 let mut request = base_request();
272 request.service_tier = Some("ultrafast".to_string());
273 let payload = provider.core.convert_request(&request).unwrap();
274 assert_eq!(payload["service_tier"], "ultrafast");
275
276 let mut blank = base_request();
277 blank.service_tier = Some(" ".to_string());
278 let payload = provider.core.convert_request(&blank).unwrap();
279 assert!(payload.get("service_tier").is_none(), "blank tier must be omitted");
280 }
281
282 #[test]
283 fn compaction_support_is_curated_grok_models_on_xai_api_only() {
284 use crate::provider::LLMProvider;
285
286 let provider = XAIProvider::new("test-key".to_string());
288 assert!(provider.supports_responses_compaction(models::xai::DEFAULT_MODEL));
289 assert!(provider.supports_manual_openai_compaction(models::xai::DEFAULT_MODEL));
290 for model in models::xai::SUPPORTED_MODELS {
291 assert!(provider.supports_manual_openai_compaction(model), "compact support for {model}");
292 }
293 assert!(!provider.supports_native_inline_compaction(models::xai::DEFAULT_MODEL));
294 }
295
296 #[tokio::test]
297 async fn compact_history_posts_to_xai_compact_endpoint() {
298 use wiremock::matchers::{method, path};
299 use wiremock::{Mock, MockServer, ResponseTemplate};
300
301 let server = MockServer::start().await;
302 Mock::given(method("POST"))
303 .and(path("/v1/responses/compact"))
304 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
305 "id": "cmp_01HZ9P0V8M2YQK3F7C4G6N5R2A",
306 "object": "response.compaction",
307 "created_at": 1748895600,
308 "model": "grok-4.6",
309 "output": [
310 {
311 "id": "msg_000",
312 "type": "message",
313 "status": "completed",
314 "role": "user",
315 "content": [{ "type": "input_text", "text": "Summarize the auth work." }]
316 },
317 {
318 "id": "cmp_001",
319 "type": "compaction",
320 "encrypted_content": "opaque-blob"
321 }
322 ]
323 })))
324 .mount(&server)
325 .await;
326
327 let provider = XAIProvider::new_with_client(
330 "test-key".to_string(),
331 models::xai::DEFAULT_MODEL.to_string(),
332 reqwest::Client::builder().no_proxy().build().expect("test client should build"),
333 format!("{}/v1", server.uri()),
334 vtcode_config::TimeoutsConfig::default(),
335 );
336 let history = vec![Message::user("Summarize the auth work.".to_string())];
337 let compacted = provider
338 .compact_client(models::xai::DEFAULT_MODEL)
339 .compact_history_request(models::xai::DEFAULT_MODEL, &history)
340 .await
341 .expect("xAI compaction should succeed");
342 assert!(!compacted.is_empty());
343 assert!(
344 compacted
345 .iter()
346 .any(|message| message.content.as_text().contains("Summarize the auth work.")),
347 "retained xAI input must survive compaction"
348 );
349 }
350
351 #[tokio::test]
352 async fn compact_history_rejects_unlisted_models() {
353 use crate::provider::LLMProvider;
354
355 let provider = XAIProvider::new("test-key".to_string());
356 assert!(!provider.supports_manual_openai_compaction("gpt-5"));
357 let history = vec![Message::user("hello".to_string())];
358 provider
359 .compact_history("gpt-5", &history)
360 .await
361 .expect_err("unlisted models must stay on local compaction");
362 }
363}