1use std::collections::HashSet;
2
3use serde::Serialize;
4use serde_json::Value;
5
6use crate::anthropic::schema::{Message, MessagesRequest};
7use crate::config::GrokToolImageMode;
8use crate::providers::translate_shared::{ImageSource, image_source_to_url, parallel_tool_calls};
9
10#[derive(Debug, Clone, Serialize)]
11pub struct GrokResponsesRequest {
12 pub model: String,
13 #[serde(skip_serializing_if = "Option::is_none")]
14 pub instructions: Option<String>,
15 pub input: Vec<GrokInputItem>,
16 #[serde(skip_serializing_if = "Option::is_none")]
17 pub tools: Option<Vec<GrokTool>>,
18 #[serde(skip_serializing_if = "Option::is_none")]
19 pub tool_choice: Option<GrokToolChoice>,
20 #[serde(skip_serializing_if = "Option::is_none")]
21 pub parallel_tool_calls: Option<bool>,
22 pub store: bool,
23 pub stream: bool,
24 #[serde(skip_serializing_if = "Option::is_none")]
25 pub max_output_tokens: Option<u32>,
26}
27
28#[derive(Debug, Clone, Serialize)]
29#[serde(tag = "type")]
30pub enum GrokInputItem {
31 #[serde(rename = "message")]
32 Message {
33 role: String,
34 content: Vec<GrokContentPart>,
35 },
36 #[serde(rename = "function_call")]
37 FunctionCall {
38 call_id: String,
39 name: String,
40 arguments: String,
41 },
42 #[serde(rename = "function_call_output")]
43 FunctionCallOutput {
44 call_id: String,
45 output: GrokToolOutput,
46 },
47}
48
49#[derive(Debug, Clone, Serialize)]
53#[serde(untagged)]
54pub enum GrokToolOutput {
55 Text(String),
56 Parts(Vec<GrokContentPart>),
57}
58
59#[derive(Debug, Clone, Serialize)]
60#[serde(tag = "type")]
61pub enum GrokContentPart {
62 #[serde(rename = "input_text")]
63 InputText { text: String },
64 #[serde(rename = "output_text")]
65 OutputText { text: String },
66 #[serde(rename = "input_image")]
67 InputImage { image_url: String },
68}
69
70#[derive(Debug, Clone, Serialize)]
71pub struct GrokTool {
72 #[serde(rename = "type")]
73 pub kind: String,
74 #[serde(skip_serializing_if = "Option::is_none")]
75 pub name: Option<String>,
76 #[serde(skip_serializing_if = "Option::is_none")]
77 pub description: Option<String>,
78 #[serde(skip_serializing_if = "Option::is_none")]
79 pub parameters: Option<Value>,
80 #[serde(skip_serializing_if = "Option::is_none")]
81 pub allowed_x_handles: Option<Vec<String>>,
82 #[serde(skip_serializing_if = "Option::is_none")]
83 pub excluded_x_handles: Option<Vec<String>>,
84 #[serde(skip_serializing_if = "Option::is_none")]
85 pub from_date: Option<String>,
86 #[serde(skip_serializing_if = "Option::is_none")]
87 pub to_date: Option<String>,
88}
89
90impl GrokTool {
91 fn hosted(kind: &str) -> Self {
92 Self {
93 kind: kind.into(),
94 name: None,
95 description: None,
96 parameters: None,
97 allowed_x_handles: None,
98 excluded_x_handles: None,
99 from_date: None,
100 to_date: None,
101 }
102 }
103
104 fn function(name: &str, description: Option<String>, parameters: Value) -> Self {
105 Self {
106 kind: "function".into(),
107 name: Some(name.into()),
108 description,
109 parameters: Some(parameters),
110 allowed_x_handles: None,
111 excluded_x_handles: None,
112 from_date: None,
113 to_date: None,
114 }
115 }
116}
117
118#[derive(Debug, Clone, Serialize)]
119#[serde(untagged)]
120pub enum GrokToolChoice {
121 Auto(String),
122 Required(String),
123 None(String),
124 Function { r#type: String, name: String },
125}
126
127pub fn translate_request(
128 req: &MessagesRequest,
129 model: String,
130) -> anyhow::Result<GrokResponsesRequest> {
131 translate_request_with_mode(req, model, crate::config::grok_tool_image_mode())
132}
133
134pub fn translate_request_with_mode(
135 req: &MessagesRequest,
136 model: String,
137 image_mode: GrokToolImageMode,
138) -> anyhow::Result<GrokResponsesRequest> {
139 reject_unknown_top_level(req)?;
140 let mut instructions = parse_system(req.extra.get("system"))?;
141 let mut tools = parse_tools(req.extra.get("tools"))?;
142 let hosted_web_search = tools
143 .as_ref()
144 .is_some_and(|tools| tools.iter().any(|tool| tool.kind == "web_search"));
145 let dedicated_x_search = tools
146 .as_ref()
147 .is_some_and(|tools| tools.iter().any(|tool| tool.kind == "x_search"));
148 let x_search_intent = requests_x_search(req);
149 let force_x_search = dedicated_x_search || x_search_intent;
150 let force_web_search = !force_x_search && hosted_web_search && requests_web_search(req);
151 if force_x_search {
152 tools = Some(vec![GrokTool::hosted("x_search")]);
153 } else if force_web_search {
154 tools = Some(vec![GrokTool::hosted("web_search")]);
155 } else {
156 let tools = tools.get_or_insert_default();
157 if !tools.iter().any(|tool| tool.kind == "x_search") {
158 tools.push(GrokTool::hosted("x_search"));
159 }
160 }
161 if hosted_web_search {
162 append_guidance(
163 &mut instructions,
164 "For general web searches, use the hosted web_search tool. Do not use shell commands, HTTP clients, or local tools to search the web.",
165 );
166 }
167 append_guidance(
168 &mut instructions,
169 "For requests to search X or Twitter, use the hosted x_search tool. XSearch accepts a query and supports allowed_x_handles, excluded_x_handles, from_date, and to_date filters. Do not use Bash, curl, HTTP clients, or general web_search for X searches.",
170 );
171 let tool_choice = if force_x_search || force_web_search {
172 Some(GrokToolChoice::Required("required".into()))
173 } else {
174 parse_tool_choice(req.extra.get("tool_choice"), tools.as_ref())?
175 };
176 let mut call_ids = HashSet::new();
177 let mut input = Vec::new();
178 let mut budget = ReattachBudget::new(&req.messages, image_mode);
179 for message in &req.messages {
180 parse_message(message, &mut input, &mut call_ids, image_mode, &mut budget)?;
181 }
182 Ok(GrokResponsesRequest {
183 model,
184 instructions,
185 input,
186 tools,
187 tool_choice,
188 parallel_tool_calls: parallel_tool_calls(req),
189 store: false,
190 stream: true,
191 max_output_tokens: req.max_tokens,
192 })
193}
194
195struct ReattachBudget {
199 drops: std::collections::HashMap<String, usize>,
202}
203
204impl ReattachBudget {
205 fn new(messages: &[Message], image_mode: GrokToolImageMode) -> Self {
206 let mut drops: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
207 if matches!(
208 image_mode,
209 GrokToolImageMode::Reattach | GrokToolImageMode::Inline
210 ) {
211 let passing: Vec<String> = messages
212 .iter()
213 .flat_map(candidate_image_blocks)
214 .filter_map(|block| {
215 let source = parse_image_source(block)?;
216 gate_image(&source)
217 .ok()
218 .map(|()| image_source_to_url(&source))
219 })
220 .collect();
221 for url in passing
223 .iter()
224 .take(passing.len().saturating_sub(MAX_REATTACHED_IMAGES))
225 {
226 *drops.entry(url.clone()).or_insert(0) += 1;
227 }
228 }
229 Self { drops }
230 }
231
232 fn admit(&mut self, image_url: &str) -> bool {
235 let Some(drops) = self.drops.get_mut(image_url) else {
236 return true;
237 };
238 *drops -= 1;
239 if *drops == 0 {
240 self.drops.remove(image_url);
241 }
242 false
243 }
244}
245
246fn candidate_image_blocks(message: &Message) -> Vec<&serde_json::Map<String, Value>> {
249 let mut blocks = Vec::new();
250 if let Value::Array(items) = &message.content {
251 for item in items {
252 let Some(object) = item.as_object() else {
253 continue;
254 };
255 match object.get("type").and_then(Value::as_str) {
256 Some("image") => blocks.push(object),
257 Some("tool_result") => {
258 if let Some(Value::Array(parts)) = object.get("content") {
259 for part in parts {
260 if let Some(part) = part.as_object()
261 && part.get("type").and_then(Value::as_str) == Some("image")
262 {
263 blocks.push(part);
264 }
265 }
266 }
267 }
268 _ => {}
269 }
270 }
271 }
272 blocks
273}
274
275fn append_guidance(instructions: &mut Option<String>, guidance: &str) {
276 *instructions = Some(match instructions.take() {
277 Some(existing) if !existing.is_empty() => format!("{existing}\n\n{guidance}"),
278 _ => guidance.into(),
279 });
280}
281
282fn latest_user_text(req: &MessagesRequest) -> Option<String> {
283 let message = req
284 .messages
285 .iter()
286 .rev()
287 .find(|message| message.role == "user")?;
288 match &message.content {
289 Value::String(text) => Some(text.to_ascii_lowercase()),
290 Value::Array(blocks) => Some(
291 blocks
292 .iter()
293 .filter_map(|block| block.get("text").and_then(Value::as_str))
294 .collect::<Vec<_>>()
295 .join(" ")
296 .to_ascii_lowercase(),
297 ),
298 _ => None,
299 }
300}
301
302fn requests_x_search(req: &MessagesRequest) -> bool {
303 let Some(text) = latest_user_text(req) else {
304 return false;
305 };
306 [
307 "search x for",
308 "search on x",
309 "search twitter",
310 "search tweets",
311 "x search",
312 "posts on x",
313 "posts from x",
314 "tweets about",
315 "twitter posts",
316 ]
317 .iter()
318 .any(|phrase| text.contains(phrase))
319}
320
321fn requests_web_search(req: &MessagesRequest) -> bool {
322 let Some(text) = latest_user_text(req) else {
323 return false;
324 };
325 [
326 "search online",
327 "search the web",
328 "web search",
329 "look up online",
330 "look up on the web",
331 ]
332 .iter()
333 .any(|phrase| text.contains(phrase))
334}
335
336fn reject_unknown_top_level(req: &MessagesRequest) -> anyhow::Result<()> {
337 for key in req.extra.keys() {
338 if ![
339 "system",
340 "tools",
341 "tool_choice",
342 "context_management",
343 "diagnostics",
344 "metadata",
345 "output_config",
346 "thinking",
347 "temperature",
348 "top_p",
349 "top_k",
350 "stop_sequences",
351 "service_tier",
352 ]
353 .contains(&key.as_str())
354 {
355 anyhow::bail!("unsupported Grok request field: {key}");
356 }
357 }
358 if !valid_diagnostics(req.extra.get("diagnostics")) {
359 anyhow::bail!("unsupported diagnostics");
360 }
361 Ok(())
362}
363
364fn valid_diagnostics(value: Option<&Value>) -> bool {
365 let Some(value) = value else { return true };
366 let Some(object) = value.as_object() else {
367 return value.is_null();
368 };
369 object.keys().all(|key| key == "previous_message_id")
370 && object.get("previous_message_id").is_none_or(|id| {
371 id.is_null()
372 || id
373 .as_str()
374 .is_some_and(|previous_message_id| !previous_message_id.is_empty())
375 })
376}
377
378fn parse_system(value: Option<&Value>) -> anyhow::Result<Option<String>> {
379 let Some(value) = value else { return Ok(None) };
380 match value {
381 Value::String(text) => Ok(Some(text.clone())),
382 Value::Array(blocks) => {
383 let mut text = String::new();
384 for block in blocks {
385 let object = block
386 .as_object()
387 .ok_or_else(|| anyhow::anyhow!("system content must contain text blocks"))?;
388 if object
389 .keys()
390 .any(|key| !["type", "text", "cache_control"].contains(&key.as_str()))
391 || object.get("type").and_then(Value::as_str) != Some("text")
392 || !valid_cache_control(object.get("cache_control"))
393 {
394 anyhow::bail!("unsupported system block");
395 }
396 let part = object
397 .get("text")
398 .and_then(Value::as_str)
399 .ok_or_else(|| anyhow::anyhow!("system text is invalid"))?;
400 text.push_str(part);
401 }
402 Ok(Some(text))
403 }
404 _ => anyhow::bail!("system must be text"),
405 }
406}
407
408fn parse_tools(value: Option<&Value>) -> anyhow::Result<Option<Vec<GrokTool>>> {
409 let Some(value) = value else { return Ok(None) };
410 let tools = value
411 .as_array()
412 .ok_or_else(|| anyhow::anyhow!("tools must be an array"))?;
413 let mut names = HashSet::new();
414 let mut out = Vec::new();
415 for tool in tools {
416 let obj = tool
417 .as_object()
418 .ok_or_else(|| anyhow::anyhow!("tool must be an object"))?;
419 for key in obj.keys() {
420 if ![
421 "name",
422 "description",
423 "input_schema",
424 "cache_control",
425 "eager_input_streaming",
426 ]
427 .contains(&key.as_str())
428 {
429 anyhow::bail!("unsupported tool field: {key}");
430 }
431 }
432 if !valid_cache_control(obj.get("cache_control")) {
433 anyhow::bail!("unsupported tool cache_control");
434 }
435 if obj
436 .get("eager_input_streaming")
437 .is_some_and(|value| !value.is_null() && !value.is_boolean())
438 {
439 anyhow::bail!("tool eager_input_streaming must be boolean");
440 }
441 let name = obj
442 .get("name")
443 .and_then(Value::as_str)
444 .filter(|s| !s.is_empty())
445 .ok_or_else(|| anyhow::anyhow!("tool name is invalid"))?;
446 if !names.insert(name.to_string()) {
447 anyhow::bail!("duplicate tool name");
448 }
449 if name == "WebSearch" {
450 out.push(GrokTool::hosted("web_search"));
451 continue;
452 }
453 if name == "XSearch" {
454 out.push(GrokTool::hosted("x_search"));
455 continue;
456 }
457 let parameters = obj
458 .get("input_schema")
459 .filter(|value| value.is_object())
460 .cloned()
461 .ok_or_else(|| anyhow::anyhow!("tool input_schema must be an object"))?;
462 out.push(GrokTool::function(
463 name,
464 obj.get("description")
465 .and_then(Value::as_str)
466 .map(str::to_string),
467 parameters,
468 ));
469 }
470 Ok(Some(out))
471}
472
473fn parse_tool_choice(
474 value: Option<&Value>,
475 tools: Option<&Vec<GrokTool>>,
476) -> anyhow::Result<Option<GrokToolChoice>> {
477 let Some(value) = value else { return Ok(None) };
478 let obj = value
479 .as_object()
480 .ok_or_else(|| anyhow::anyhow!("tool_choice must be an object"))?;
481 let kind = obj
482 .get("type")
483 .and_then(Value::as_str)
484 .ok_or_else(|| anyhow::anyhow!("tool_choice type is invalid"))?;
485 let valid_policy = obj
486 .get("disable_parallel_tool_use")
487 .is_none_or(Value::is_boolean);
488 match kind {
489 "auto" | "any" | "none"
490 if valid_policy
491 && obj
492 .keys()
493 .all(|key| ["type", "disable_parallel_tool_use"].contains(&key.as_str())) =>
494 {
495 Ok(Some(match kind {
496 "auto" => GrokToolChoice::Auto("auto".into()),
497 "any" => GrokToolChoice::Required("required".into()),
498 "none" => GrokToolChoice::None("none".into()),
499 _ => unreachable!(),
500 }))
501 }
502 "tool"
503 if valid_policy
504 && obj.keys().all(|key| {
505 ["type", "name", "disable_parallel_tool_use"].contains(&key.as_str())
506 }) =>
507 {
508 let name = obj
509 .get("name")
510 .and_then(Value::as_str)
511 .ok_or_else(|| anyhow::anyhow!("tool_choice name is invalid"))?;
512 if !tools
513 .is_some_and(|items| items.iter().any(|tool| tool.name.as_deref() == Some(name)))
514 {
515 anyhow::bail!("tool_choice references an unknown tool");
516 }
517 Ok(Some(GrokToolChoice::Function {
518 r#type: "function".into(),
519 name: name.into(),
520 }))
521 }
522 _ => anyhow::bail!("unsupported tool_choice"),
523 }
524}
525
526fn parse_message(
527 message: &Message,
528 out: &mut Vec<GrokInputItem>,
529 calls: &mut HashSet<String>,
530 image_mode: GrokToolImageMode,
531 budget: &mut ReattachBudget,
532) -> anyhow::Result<()> {
533 if !["system", "user", "assistant"].contains(&message.role.as_str()) {
534 anyhow::bail!("unsupported message role");
535 }
536 let blocks: Vec<Value> = match &message.content {
537 Value::String(text) => vec![serde_json::json!({"type":"text", "text":text})],
538 Value::Array(items) => items.clone(),
539 _ => anyhow::bail!("message content must be text or blocks"),
540 };
541 let mut content = Vec::new();
542 for block in blocks {
543 let object = block
544 .as_object()
545 .ok_or_else(|| anyhow::anyhow!("content block must be an object"))?;
546 let typ = object
547 .get("type")
548 .and_then(Value::as_str)
549 .ok_or_else(|| anyhow::anyhow!("content block type is invalid"))?;
550 match (message.role.as_str(), typ) {
551 (_, "thinking") | (_, "redacted_thinking") => {}
552 (_, "text") => {
553 if object
554 .keys()
555 .any(|key| !["type", "text", "cache_control"].contains(&key.as_str()))
556 || !valid_cache_control(object.get("cache_control"))
557 {
558 anyhow::bail!("unsupported text block field");
559 }
560 let text = object
561 .get("text")
562 .and_then(Value::as_str)
563 .ok_or_else(|| anyhow::anyhow!("text block is invalid"))?;
564 content.push(if message.role == "assistant" {
565 GrokContentPart::OutputText { text: text.into() }
566 } else {
567 GrokContentPart::InputText { text: text.into() }
568 });
569 }
570 ("assistant", "server_tool_use") => {
571 let name = object.get("name").and_then(Value::as_str);
572 if !matches!(name, Some("web_search" | "x_search")) {
573 anyhow::bail!("unsupported server tool use");
574 }
575 }
576 ("assistant", "web_search_tool_result" | "x_search_tool_result")
577 | ("user", "web_search_tool_result" | "x_search_tool_result") => {}
578 ("user", "image") => {
579 if object
580 .keys()
581 .any(|key| !["type", "source", "cache_control"].contains(&key.as_str()))
582 || !valid_cache_control(object.get("cache_control"))
583 {
584 anyhow::bail!("unsupported image block field");
585 }
586 match image_mode {
587 GrokToolImageMode::Reject => {
588 anyhow::bail!("unsupported content block: image");
589 }
590 GrokToolImageMode::Omit => {
591 let placeholder = image_placeholder(object)
592 .ok_or_else(|| anyhow::anyhow!("image source is invalid"))?;
593 content.push(GrokContentPart::InputText { text: placeholder });
594 }
595 GrokToolImageMode::Reattach | GrokToolImageMode::Inline => {
596 let source = parse_image_source(object)
597 .ok_or_else(|| anyhow::anyhow!("image source is invalid"))?;
598 match gate_image(&source) {
599 Ok(()) => {
600 let image_url = image_source_to_url(&source);
601 if budget.admit(&image_url) {
602 content.push(GrokContentPart::InputImage { image_url });
603 } else {
604 content.push(GrokContentPart::InputText {
605 text: omit_with_reason(&source, &cap_reason()),
606 });
607 }
608 }
609 Err(reason) => {
610 content.push(GrokContentPart::InputText {
611 text: omit_with_reason(&source, &reason),
612 });
613 }
614 }
615 }
616 }
617 }
618 ("assistant", "tool_use") => {
619 if object.keys().any(|key| {
620 !["type", "id", "name", "input", "cache_control"].contains(&key.as_str())
621 }) || !valid_cache_control(object.get("cache_control"))
622 {
623 anyhow::bail!("unsupported tool_use field");
624 }
625 flush_message(&message.role, &mut content, out);
626 let id = object
627 .get("id")
628 .and_then(Value::as_str)
629 .filter(|s| !s.is_empty())
630 .ok_or_else(|| anyhow::anyhow!("tool call id is invalid"))?;
631 let name = object
632 .get("name")
633 .and_then(Value::as_str)
634 .filter(|s| !s.is_empty())
635 .ok_or_else(|| anyhow::anyhow!("tool call name is invalid"))?;
636 let input = object
637 .get("input")
638 .filter(|value| value.is_object())
639 .ok_or_else(|| anyhow::anyhow!("tool call input must be an object"))?;
640 if !calls.insert(id.into()) {
641 anyhow::bail!("duplicate tool call id");
642 }
643 out.push(GrokInputItem::FunctionCall {
644 call_id: id.into(),
645 name: name.into(),
646 arguments: serde_json::to_string(input)?,
647 });
648 }
649 ("user", "tool_result") => {
650 if object.keys().any(|key| {
651 ![
652 "type",
653 "tool_use_id",
654 "content",
655 "is_error",
656 "cache_control",
657 ]
658 .contains(&key.as_str())
659 }) {
660 anyhow::bail!("unsupported tool_result field");
661 }
662 if let Some(is_error) = object.get("is_error")
663 && !is_error.is_boolean()
664 {
665 anyhow::bail!("tool result is_error must be boolean");
666 }
667 flush_message(&message.role, &mut content, out);
668 let id = object
669 .get("tool_use_id")
670 .and_then(Value::as_str)
671 .filter(|s| !s.is_empty())
672 .ok_or_else(|| anyhow::anyhow!("tool result id is invalid"))?;
673 if !calls.remove(id) {
674 anyhow::bail!("tool result references an unknown or resolved tool call");
675 }
676 let value = object
677 .get("content")
678 .ok_or_else(|| anyhow::anyhow!("tool result content is required"))?;
679 let mut tool_result_images: Vec<String> = Vec::new();
680 let output = match value {
681 Value::String(text) => GrokToolOutput::Text(text.clone()),
682 Value::Array(parts) => {
683 let mut texts = Vec::new();
684 let mut inline_parts: Vec<GrokContentPart> = Vec::new();
685 for part in parts {
686 let part = part.as_object().ok_or_else(|| {
687 anyhow::anyhow!("tool result child must be an object")
688 })?;
689 if part.get("type").and_then(Value::as_str) == Some("tool_reference") {
690 if part.keys().any(|key| {
691 !["type", "tool_name", "cache_control"].contains(&key.as_str())
692 }) || part
693 .get("tool_name")
694 .and_then(Value::as_str)
695 .is_none_or(str::is_empty)
696 || !valid_cache_control(part.get("cache_control"))
697 {
698 anyhow::bail!("unsupported tool_reference child");
699 }
700 continue;
701 }
702 if part.get("type").and_then(Value::as_str) == Some("image") {
703 if part.keys().any(|key| {
704 !["type", "source", "cache_control"].contains(&key.as_str())
705 }) || !valid_cache_control(part.get("cache_control"))
706 {
707 anyhow::bail!("unsupported image child");
708 }
709 match image_mode {
710 GrokToolImageMode::Reject => {
711 anyhow::bail!("tool result supports text children only");
712 }
713 GrokToolImageMode::Omit => {
714 let placeholder =
715 image_placeholder(part).ok_or_else(|| {
716 anyhow::anyhow!(
717 "tool result image source is invalid"
718 )
719 })?;
720 texts.push(placeholder);
721 }
722 GrokToolImageMode::Reattach => {
723 let source = parse_image_source(part).ok_or_else(|| {
724 anyhow::anyhow!("tool result image source is invalid")
725 })?;
726 match gate_image(&source) {
727 Ok(()) => {
728 let image_url = image_source_to_url(&source);
729 if budget.admit(&image_url) {
730 texts.push(image_placeholder(part).ok_or_else(
731 || {
732 anyhow::anyhow!(
733 "tool result image source is invalid"
734 )
735 },
736 )?);
737 tool_result_images.push(image_url);
738 } else {
739 texts.push(omit_with_reason(
740 &source,
741 &cap_reason(),
742 ));
743 }
744 }
745 Err(reason) => {
746 texts.push(omit_with_reason(&source, &reason));
747 }
748 }
749 }
750 GrokToolImageMode::Inline => {
751 let source = parse_image_source(part).ok_or_else(|| {
752 anyhow::anyhow!("tool result image source is invalid")
753 })?;
754 match gate_image(&source) {
755 Ok(()) => {
756 let image_url = image_source_to_url(&source);
757 if budget.admit(&image_url) {
758 inline_parts.push(
759 GrokContentPart::InputImage { image_url },
760 );
761 } else {
762 inline_parts.push(GrokContentPart::InputText {
763 text: omit_with_reason(
764 &source,
765 &cap_reason(),
766 ),
767 });
768 }
769 }
770 Err(reason) => {
771 inline_parts.push(GrokContentPart::InputText {
772 text: omit_with_reason(&source, &reason),
773 });
774 }
775 }
776 }
777 }
778 continue;
779 }
780 if part.get("type").and_then(Value::as_str) != Some("text")
781 || part.keys().any(|key| {
782 !["type", "text", "cache_control"].contains(&key.as_str())
783 })
784 || !valid_cache_control(part.get("cache_control"))
785 {
786 anyhow::bail!("tool result supports text children only");
787 }
788 let text = part
789 .get("text")
790 .and_then(Value::as_str)
791 .ok_or_else(|| anyhow::anyhow!("tool result text is invalid"))?
792 .to_string();
793 if image_mode == GrokToolImageMode::Inline {
794 inline_parts.push(GrokContentPart::InputText { text });
795 } else {
796 texts.push(text);
797 }
798 }
799 if image_mode == GrokToolImageMode::Inline {
800 let mut joined: Option<String> = Some(String::new());
803 for part in &inline_parts {
804 match part {
805 GrokContentPart::InputText { text } => {
806 if let Some(acc) = joined.as_mut() {
807 if !acc.is_empty() {
808 acc.push('\n');
809 }
810 acc.push_str(text);
811 }
812 }
813 _ => joined = None,
814 }
815 }
816 match joined {
817 Some(text) => GrokToolOutput::Text(text),
818 None => GrokToolOutput::Parts(inline_parts),
819 }
820 } else {
821 GrokToolOutput::Text(texts.join("\n"))
822 }
823 }
824 _ => anyhow::bail!("tool result supports text only"),
825 };
826 out.push(GrokInputItem::FunctionCallOutput {
827 call_id: id.into(),
828 output,
829 });
830 if image_mode == GrokToolImageMode::Reattach && !tool_result_images.is_empty() {
831 out.push(GrokInputItem::Message {
832 role: "user".into(),
833 content: tool_result_images
834 .into_iter()
835 .map(|image_url| GrokContentPart::InputImage { image_url })
836 .collect(),
837 });
838 }
839 }
840 _ => anyhow::bail!("unsupported content block: {typ}"),
841 }
842 }
843 flush_message(&message.role, &mut content, out);
844 Ok(())
845}
846
847fn image_placeholder(object: &serde_json::Map<String, Value>) -> Option<String> {
848 let source = object.get("source")?.as_object()?;
849 match source.get("type").and_then(Value::as_str) {
850 Some("base64")
851 if source
852 .get("media_type")
853 .and_then(Value::as_str)
854 .is_some_and(|media_type| !media_type.is_empty())
855 && source.get("data").and_then(Value::as_str).is_some() =>
856 {
857 let media_type = source.get("media_type").and_then(Value::as_str)?;
858 Some(format!("[image omitted: {media_type}]"))
859 }
860 Some("url")
861 if source
862 .get("url")
863 .and_then(Value::as_str)
864 .is_some_and(|url| !url.is_empty()) =>
865 {
866 Some("[image omitted: url]".into())
867 }
868 _ => None,
869 }
870}
871
872const MIN_IMAGE_SIDE_PX: u32 = 8;
881const MIN_IMAGE_AREA_PX: u64 = 512;
883const MAX_IMAGE_DECODED_BYTES: u64 = 5 * 1024 * 1024;
885const MAX_REATTACHED_IMAGES: usize = 4;
887
888fn parse_image_source(object: &serde_json::Map<String, Value>) -> Option<ImageSource> {
891 let source = object.get("source")?.as_object()?;
892 let source_type = source.get("type").and_then(Value::as_str)?;
893 match source_type {
894 "base64" => {
895 let media_type = source
896 .get("media_type")
897 .and_then(Value::as_str)
898 .filter(|media_type| !media_type.is_empty())?;
899 let data = source.get("data").and_then(Value::as_str)?;
900 Some(ImageSource {
901 media_type: media_type.to_string(),
902 data: data.to_string(),
903 source_type: source_type.to_string(),
904 })
905 }
906 "url" => {
907 let url = source
908 .get("url")
909 .and_then(Value::as_str)
910 .filter(|url| !url.is_empty())?;
911 Some(ImageSource {
912 media_type: "image/*".to_string(),
913 data: url.to_string(),
914 source_type: source_type.to_string(),
915 })
916 }
917 _ => None,
918 }
919}
920
921fn gate_image(source: &ImageSource) -> Result<(), String> {
924 if source.source_type == "url" {
925 return Err("url source cannot be gated".to_string());
929 }
930 use base64::Engine;
931 let bytes = base64::engine::general_purpose::STANDARD
932 .decode(source.data.as_bytes())
933 .map_err(|_| "undecodable base64".to_string())?;
934 let raster = image_raster(&bytes, &source.media_type)
935 .ok_or_else(|| format!("unreadable dimensions for {}", source.media_type))?;
936 let width = raster.width;
937 let height = raster.height;
938 let min_side = width.min(height);
939 if min_side < MIN_IMAGE_SIDE_PX {
940 return Err(format!(
941 "{width}x{height} below minimum side {MIN_IMAGE_SIDE_PX}px"
942 ));
943 }
944 let area = width as u64 * height as u64;
945 if area < MIN_IMAGE_AREA_PX {
946 return Err(format!(
947 "{width}x{height} below minimum area {MIN_IMAGE_AREA_PX}px"
948 ));
949 }
950 let decoded = area.saturating_mul(raster.bytes_per_pixel);
951 if decoded > MAX_IMAGE_DECODED_BYTES {
952 return Err(format!(
953 "{width}x{height} too large (decoded ~{}MB > {}MB cap)",
954 decoded / (1024 * 1024),
955 MAX_IMAGE_DECODED_BYTES / (1024 * 1024)
956 ));
957 }
958 Ok(())
959}
960
961fn omit_with_reason(source: &ImageSource, reason: &str) -> String {
963 let base = if source.source_type == "url" {
964 "[image omitted: url]".to_string()
965 } else {
966 format!("[image omitted: {}]", source.media_type)
967 };
968 format!("{base} ({reason})")
969}
970
971fn cap_reason() -> String {
974 format!("only the last {MAX_REATTACHED_IMAGES} images are attached per request")
975}
976
977#[derive(Clone, Copy)]
981struct ImageRaster {
982 width: u32,
983 height: u32,
984 bytes_per_pixel: u64,
985}
986
987fn image_raster(bytes: &[u8], media_type: &str) -> Option<ImageRaster> {
988 match media_type {
989 "image/png" => png_raster(bytes),
990 "image/jpeg" => jpeg_raster(bytes),
991 "image/gif" => gif_raster(bytes),
992 _ => sniff_raster(bytes),
993 }
994}
995
996fn sniff_raster(bytes: &[u8]) -> Option<ImageRaster> {
997 png_raster(bytes)
998 .or_else(|| jpeg_raster(bytes))
999 .or_else(|| gif_raster(bytes))
1000}
1001
1002fn png_raster(bytes: &[u8]) -> Option<ImageRaster> {
1003 if bytes.len() < 26
1005 || &bytes[..8] != b"\x89PNG\r\n\x1a\n"
1006 || u32::from_be_bytes(bytes[8..12].try_into().ok()?) != 13
1007 || &bytes[12..16] != b"IHDR"
1008 {
1009 return None;
1010 }
1011 let width = u32::from_be_bytes(bytes[16..20].try_into().ok()?);
1012 let height = u32::from_be_bytes(bytes[20..24].try_into().ok()?);
1013 let bit_depth = bytes[24];
1014 let color_type = bytes[25];
1015 let bytes_per_channel = match bit_depth {
1016 1 | 2 | 4 | 8 => 1,
1017 16 => 2,
1018 _ => return None,
1019 };
1020 let channels = match color_type {
1021 0 | 4 => 2,
1023 2 | 3 | 6 => 4,
1024 _ => return None,
1025 };
1026 Some(ImageRaster {
1027 width,
1028 height,
1029 bytes_per_pixel: bytes_per_channel * channels,
1030 })
1031}
1032
1033fn gif_raster(bytes: &[u8]) -> Option<ImageRaster> {
1034 if bytes.len() < 10 || !matches!(&bytes[..6], b"GIF87a" | b"GIF89a") {
1036 return None;
1037 }
1038 let width = u16::from_le_bytes(bytes[6..8].try_into().ok()?) as u32;
1039 let height = u16::from_le_bytes(bytes[8..10].try_into().ok()?) as u32;
1040 Some(ImageRaster {
1041 width,
1042 height,
1043 bytes_per_pixel: 4,
1044 })
1045}
1046
1047fn jpeg_raster(bytes: &[u8]) -> Option<ImageRaster> {
1048 if bytes.len() < 4 || bytes[0] != 0xff || bytes[1] != 0xd8 {
1050 return None;
1051 }
1052 let mut cursor = 2usize;
1053 while cursor + 4 <= bytes.len() {
1054 if bytes[cursor] != 0xff {
1055 return None;
1056 }
1057 let marker = bytes[cursor + 1];
1058 if marker == 0xd8 || marker == 0x01 || (0xd0..=0xd7).contains(&marker) {
1060 cursor += 2;
1061 continue;
1062 }
1063 let segment_len =
1064 u16::from_be_bytes(bytes[cursor + 2..cursor + 4].try_into().ok()?) as usize;
1065 if segment_len < 2 || cursor + 2 + segment_len > bytes.len() {
1066 return None;
1067 }
1068 let is_sof = matches!(
1069 marker,
1070 0xc0..=0xc3 | 0xc5..=0xc7 | 0xc9..=0xcb | 0xcd..=0xcf
1071 );
1072 if is_sof {
1073 if segment_len < 8 {
1075 return None;
1076 }
1077 let precision = bytes[cursor + 4];
1078 let height = u16::from_be_bytes(bytes[cursor + 5..cursor + 7].try_into().ok()?) as u32;
1079 let width = u16::from_be_bytes(bytes[cursor + 7..cursor + 9].try_into().ok()?) as u32;
1080 let components = bytes[cursor + 9] as usize;
1081 if components == 0 || segment_len < 8 + 3 * components {
1082 return None;
1083 }
1084 let bytes_per_channel = u64::from(precision).div_ceil(8);
1085 return Some(ImageRaster {
1086 width,
1087 height,
1088 bytes_per_pixel: bytes_per_channel.saturating_mul(components as u64),
1089 });
1090 }
1091 cursor += 2 + segment_len;
1092 }
1093 None
1094}
1095
1096fn valid_cache_control(value: Option<&Value>) -> bool {
1097 let Some(value) = value else { return true };
1098 let Some(object) = value.as_object() else {
1099 return false;
1100 };
1101 object
1102 .keys()
1103 .all(|key| key == "type" || key == "ttl" || key == "scope")
1104 && object.get("type").and_then(Value::as_str) == Some("ephemeral")
1105 && object
1106 .get("ttl")
1107 .is_none_or(|ttl| matches!(ttl.as_str(), Some("5m") | Some("1h")))
1108 && object
1109 .get("scope")
1110 .is_none_or(|scope| matches!(scope.as_str(), Some("global")))
1111}
1112
1113fn flush_message(role: &str, content: &mut Vec<GrokContentPart>, out: &mut Vec<GrokInputItem>) {
1114 if !content.is_empty() {
1115 out.push(GrokInputItem::Message {
1116 role: role.into(),
1117 content: std::mem::take(content),
1118 });
1119 }
1120}
1121
1122#[cfg(test)]
1123mod tests {
1124 use super::*;
1125 #[test]
1126 fn grok_translation_replays_hosted_search_history() {
1127 let request: MessagesRequest = serde_json::from_value(serde_json::json!({
1128 "model":"grok-4.5",
1129 "messages":[
1130 {"role":"user","content":"search X for the project"},
1131 {"role":"assistant","content":[
1132 {"type":"server_tool_use","id":"srvtoolu_1","name":"x_search","input":{"query":"project"}},
1133 {"type":"x_search_tool_result","tool_use_id":"srvtoolu_1","content":[]},
1134 {"type":"text","text":"Found it"}
1135 ]},
1136 {"role":"user","content":"summarize it"}
1137 ]
1138 }))
1139 .unwrap();
1140 let translated = translate_request(&request, "grok-4.5".into()).unwrap();
1141 let value = serde_json::to_value(translated).unwrap();
1142 assert!(value["input"].as_array().unwrap().iter().any(|item| {
1143 item["role"] == "assistant" && item["content"][0]["text"] == "Found it"
1144 }));
1145 assert!(!value.to_string().contains("srvtoolu_1"));
1146 }
1147
1148 #[test]
1149 fn grok_translation_maps_text_and_function_round_trip() {
1150 let request: MessagesRequest = serde_json::from_value(serde_json::json!({
1151 "model":"grok-4.5", "max_tokens":12, "system":"rules",
1152 "tools":[{"name":"lookup","input_schema":{"type":"object"}}],
1153 "tool_choice":{"type":"tool","name":"lookup"},
1154 "messages":[
1155 {"role":"user","content":"hello"},
1156 {"role":"assistant","content":[{"type":"tool_use","id":"call_1","name":"lookup","input":{"q":"a"}}]},
1157 {"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":"result"}]}
1158 ]
1159 })).unwrap();
1160 let value =
1161 serde_json::to_value(translate_request(&request, "grok-4.5".into()).unwrap()).unwrap();
1162 assert!(value["instructions"].as_str().unwrap().starts_with("rules"));
1163 assert_eq!(value["input"][1]["type"], "function_call");
1164 assert_eq!(value["input"][2]["type"], "function_call_output");
1165 assert_eq!(value["tool_choice"]["type"], "function");
1166 }
1167 #[test]
1168 fn grok_translation_maps_claude_web_search_to_hosted_web_search() {
1169 let request: MessagesRequest = serde_json::from_value(serde_json::json!({
1170 "model":"grok-4.5",
1171 "messages":[{"role":"user","content":"search online for the project"}],
1172 "tools":[{
1173 "name":"WebSearch",
1174 "description":"Search the web",
1175 "input_schema":{"type":"object","properties":{"query":{"type":"string"}},"required":["query"]}
1176 }]
1177 }))
1178 .unwrap();
1179 let translated =
1180 serde_json::to_value(translate_request(&request, "grok-4.5".into()).unwrap()).unwrap();
1181 assert_eq!(
1182 translated["tools"],
1183 serde_json::json!([{"type":"web_search"}])
1184 );
1185 assert!(
1186 translated["instructions"]
1187 .as_str()
1188 .unwrap()
1189 .contains("use the hosted web_search tool")
1190 );
1191 assert_eq!(translated["tool_choice"], "required");
1192 }
1193
1194 #[test]
1195 fn grok_translation_maps_x_intent_to_required_hosted_x_search() {
1196 let request: MessagesRequest = serde_json::from_value(serde_json::json!({
1197 "model":"grok-4.5",
1198 "messages":[{"role":"user","content":"Search X for recent posts mentioning claude-code-proxy"}],
1199 "tools":[
1200 {"name":"Bash","description":"Run a command","input_schema":{"type":"object"}},
1201 {"name":"WebSearch","description":"Search the web","input_schema":{"type":"object","properties":{"query":{"type":"string"}}}}
1202 ]
1203 }))
1204 .unwrap();
1205 let translated =
1206 serde_json::to_value(translate_request(&request, "grok-4.5".into()).unwrap()).unwrap();
1207 assert_eq!(
1208 translated["tools"],
1209 serde_json::json!([{"type":"x_search"}])
1210 );
1211 assert_eq!(translated["tool_choice"], "required");
1212 assert!(!translated.to_string().contains("\"name\":\"Bash\""));
1213 }
1214
1215 #[test]
1216 fn grok_translation_maps_dedicated_xsearch_with_domain_schema() {
1217 let request: MessagesRequest = serde_json::from_value(serde_json::json!({
1218 "model":"grok-4.5",
1219 "messages":[{"role":"user","content":"find relevant posts"}],
1220 "tools":[{
1221 "name":"XSearch",
1222 "description":"Search X posts",
1223 "input_schema":{
1224 "type":"object",
1225 "properties":{
1226 "query":{"type":"string"},
1227 "allowed_x_handles":{"type":"array","items":{"type":"string"}},
1228 "excluded_x_handles":{"type":"array","items":{"type":"string"}},
1229 "from_date":{"type":"string","format":"date"},
1230 "to_date":{"type":"string","format":"date"}
1231 },
1232 "required":["query"]
1233 }
1234 }]
1235 }))
1236 .unwrap();
1237 let translated =
1238 serde_json::to_value(translate_request(&request, "grok-4.5".into()).unwrap()).unwrap();
1239 assert_eq!(
1240 translated["tools"],
1241 serde_json::json!([{"type":"x_search"}])
1242 );
1243 assert_eq!(translated["tool_choice"], "required");
1244 }
1245
1246 #[test]
1247 fn grok_translation_accepts_claude_code_context_management() {
1248 let request: MessagesRequest = serde_json::from_value(serde_json::json!({
1249 "model":"grok-composer-2.5-fast",
1250 "messages":[{"role":"user","content":"hello"}],
1251 "context_management":{"edits":[{"type":"clear_tool_uses_20250919","trigger":{"type":"input_tokens","value":100000}}]}
1252 }))
1253 .unwrap();
1254 let translated = translate_request(&request, "grok-composer-2.5-fast".into()).unwrap();
1255 assert_eq!(translated.input.len(), 1);
1256 }
1257
1258 #[test]
1259 fn grok_translation_accepts_cache_diagnostics_without_forwarding_it() {
1260 let request: MessagesRequest = serde_json::from_value(serde_json::json!({
1261 "model":"grok-4.5",
1262 "messages":[{"role":"user","content":"hello"}],
1263 "diagnostics":{"previous_message_id":"msg_previous"}
1264 }))
1265 .unwrap();
1266 let translated =
1267 serde_json::to_value(translate_request(&request, "grok-4.5".into()).unwrap()).unwrap();
1268 assert!(!translated.to_string().contains("diagnostics"));
1269 }
1270
1271 #[test]
1272 fn grok_translation_rejects_malformed_cache_diagnostics() {
1273 for diagnostics in [
1274 serde_json::json!(true),
1275 serde_json::json!({"previous_message_id": 1}),
1276 serde_json::json!({"previous_message_id": ""}),
1277 serde_json::json!({"previous_message_id": null, "unknown": true}),
1278 ] {
1279 let request: MessagesRequest = serde_json::from_value(serde_json::json!({
1280 "model":"grok-4.5",
1281 "messages":[{"role":"user","content":"hello"}],
1282 "diagnostics": diagnostics
1283 }))
1284 .unwrap();
1285 assert!(translate_request(&request, "grok-4.5".into()).is_err());
1286 }
1287 }
1288
1289 #[test]
1290 fn grok_translation_accepts_cache_control_scope_without_forwarding_it() {
1291 let request: MessagesRequest = serde_json::from_value(serde_json::json!({
1292 "model":"grok-4.5",
1293 "system":[{"type":"text","text":"rules","cache_control":{"type":"ephemeral","ttl":"1h","scope":"global"}}],
1294 "messages":[{"role":"user","content":"hello"}]
1295 }))
1296 .unwrap();
1297 let translated =
1298 serde_json::to_value(translate_request(&request, "grok-4.5".into()).unwrap()).unwrap();
1299 assert!(
1300 translated["instructions"]
1301 .as_str()
1302 .unwrap()
1303 .starts_with("rules")
1304 );
1305 assert!(!translated.to_string().contains("cache_control"));
1306 }
1307
1308 #[test]
1309 fn grok_translation_rejects_unknown_cache_control_scope() {
1310 let request: MessagesRequest = serde_json::from_value(serde_json::json!({
1311 "model":"grok-4.5",
1312 "messages":[{"role":"user","content":[{"type":"text","text":"hello","cache_control":{"type":"ephemeral","scope":"session"}}]}]
1313 }))
1314 .unwrap();
1315 assert!(translate_request(&request, "grok-4.5".into()).is_err());
1316 }
1317
1318 #[test]
1319 fn grok_translation_accepts_claude_code_eager_input_streaming_without_forwarding_it() {
1320 let request: MessagesRequest = serde_json::from_value(serde_json::json!({
1321 "model":"grok-4.5",
1322 "messages":[{"role":"user","content":"hello"}],
1323 "tools":[{
1324 "name":"lookup",
1325 "description":"d",
1326 "input_schema":{"type":"object"},
1327 "eager_input_streaming":true
1328 }]
1329 }))
1330 .unwrap();
1331 let translated =
1332 serde_json::to_value(translate_request(&request, "grok-4.5".into()).unwrap()).unwrap();
1333 assert!(
1334 translated["tools"]
1335 .as_array()
1336 .unwrap()
1337 .iter()
1338 .any(|tool| { tool["type"] == "function" && tool["name"] == "lookup" })
1339 );
1340 assert!(!translated.to_string().contains("eager_input_streaming"));
1341 }
1342
1343 #[test]
1344 fn grok_translation_rejects_malformed_eager_input_streaming() {
1345 let request: MessagesRequest = serde_json::from_value(serde_json::json!({
1346 "model":"grok-4.5",
1347 "messages":[{"role":"user","content":"hello"}],
1348 "tools":[{
1349 "name":"lookup",
1350 "input_schema":{"type":"object"},
1351 "eager_input_streaming":"true"
1352 }]
1353 }))
1354 .unwrap();
1355 assert!(translate_request(&request, "grok-4.5".into()).is_err());
1356 }
1357
1358 #[test]
1359 fn grok_translation_drops_tool_reference_children_in_tool_results() {
1360 let request = request_with_blocks(serde_json::json!([
1361 {"type":"tool_result","tool_use_id":"call_1","content":[
1362 {"type":"text","text":"ok"},
1363 {"type":"tool_reference","tool_name":"lookup"}
1364 ]}
1365 ]));
1366 let translated =
1367 serde_json::to_value(translate_request(&request, "grok-4.5".into()).unwrap()).unwrap();
1368 let rendered = translated.to_string();
1369 assert!(!rendered.contains("tool_reference"));
1370 assert!(rendered.contains("ok"));
1371 }
1372
1373 #[test]
1374 fn grok_translation_rejects_malformed_tool_reference_children() {
1375 for child in [
1376 serde_json::json!({"type":"tool_reference","name":"lookup"}),
1377 serde_json::json!({"type":"tool_reference","tool_name":""}),
1378 serde_json::json!({"type":"tool_reference","tool_name":"lookup","unknown":true}),
1379 serde_json::json!({"type":"tool_reference","tool_name":"lookup","cache_control":{"type":"persistent"}}),
1380 ] {
1381 let request = request_with_blocks(serde_json::json!([
1382 {"type":"tool_result","tool_use_id":"call_1","content":[child]}
1383 ]));
1384 assert!(translate_request(&request, "grok-4.5".into()).is_err());
1385 }
1386 }
1387
1388 #[test]
1389 fn grok_translation_rejects_unknown_fields() {
1390 let request: MessagesRequest = serde_json::from_value(
1391 serde_json::json!({"model":"grok-4.5","messages":[],"unknown_field":true}),
1392 )
1393 .unwrap();
1394 assert!(translate_request(&request, "grok-4.5".into()).is_err());
1395 }
1396
1397 #[test]
1398 fn grok_translation_accepts_verified_cache_control_without_forwarding_it() {
1399 let request: MessagesRequest = serde_json::from_value(serde_json::json!({
1400 "model":"grok-4.5",
1401 "system":[{"type":"text","text":"rules","cache_control":{"type":"ephemeral"}}],
1402 "messages":[{"role":"user","content":[{"type":"text","text":"hello","cache_control":{"type":"ephemeral","ttl":"5m"}}]}]
1403 })).unwrap();
1404 let translated =
1405 serde_json::to_value(translate_request(&request, "grok-4.5".into()).unwrap()).unwrap();
1406 assert!(
1407 translated["instructions"]
1408 .as_str()
1409 .unwrap()
1410 .starts_with("rules")
1411 );
1412 assert_eq!(translated["input"][0]["content"][0]["text"], "hello");
1413 assert!(!translated.to_string().contains("cache_control"));
1414 }
1415
1416 #[test]
1417 fn grok_translation_rejects_invalid_cache_control() {
1418 let request: MessagesRequest = serde_json::from_value(serde_json::json!({
1419 "model":"grok-4.5", "messages":[{"role":"user","content":[{"type":"text","text":"hello","cache_control":{"type":"persistent"}}]}]
1420 })).unwrap();
1421 assert!(translate_request(&request, "grok-4.5".into()).is_err());
1422 }
1423
1424 fn request_with_blocks(blocks: Value) -> MessagesRequest {
1425 serde_json::from_value(serde_json::json!({
1426 "model":"grok-4.5",
1427 "messages":[
1428 {"role":"assistant","content":[{"type":"tool_use","id":"call_1","name":"lookup","input":{}}]},
1429 {"role":"user","content":blocks}
1430 ]
1431 }))
1432 .unwrap()
1433 }
1434
1435 fn translated_with_mode(
1436 request: &MessagesRequest,
1437 image_mode: crate::config::GrokToolImageMode,
1438 ) -> Value {
1439 serde_json::to_value(
1440 translate_request_with_mode(request, "grok-4.5".into(), image_mode).unwrap(),
1441 )
1442 .unwrap()
1443 }
1444
1445 #[test]
1446 fn grok_translation_rejects_unknown_tool_block_fields() {
1447 let mut request = request_with_blocks(serde_json::json!([
1448 {"type":"tool_result","tool_use_id":"call_1","content":"ok"}
1449 ]));
1450 request.messages[0].content[0]["unknown"] = Value::Bool(true);
1451 assert!(translate_request(&request, "grok-4.5".into()).is_err());
1452
1453 let request = request_with_blocks(serde_json::json!([
1454 {"type":"tool_result","tool_use_id":"call_1","content":"ok","unknown":true}
1455 ]));
1456 assert!(translate_request(&request, "grok-4.5".into()).is_err());
1457 }
1458
1459 #[test]
1460 fn grok_translation_omits_image_only_tool_result_children() {
1461 let request = request_with_blocks(serde_json::json!([
1462 {"type":"tool_result","tool_use_id":"call_1","content":[
1463 {"type":"image","source":{"type":"base64","media_type":"image/png","data":"aGVsbG8="}}
1464 ]}
1465 ]));
1466 let translated = translated_with_mode(&request, crate::config::GrokToolImageMode::Omit);
1467 assert_eq!(translated["input"][1]["type"], "function_call_output");
1468 assert_eq!(
1469 translated["input"][1]["output"],
1470 "[image omitted: image/png]"
1471 );
1472 }
1473
1474 #[test]
1475 fn grok_translation_omits_url_image_tool_result_children() {
1476 let request = request_with_blocks(serde_json::json!([
1477 {"type":"tool_result","tool_use_id":"call_1","content":[
1478 {"type":"image","source":{"type":"url","url":"https://example.invalid/a.png"}}
1479 ]}
1480 ]));
1481 let translated = translated_with_mode(&request, crate::config::GrokToolImageMode::Omit);
1482 assert_eq!(translated["input"][1]["output"], "[image omitted: url]");
1483 }
1484
1485 #[test]
1486 fn grok_translation_joins_text_and_image_tool_result_children() {
1487 let request = request_with_blocks(serde_json::json!([
1488 {"type":"tool_result","tool_use_id":"call_1","content":[
1489 {"type":"text","text":"caption"},
1490 {"type":"image","source":{"type":"base64","media_type":"image/png","data":"aGVsbG8="}},
1491 {"type":"image","source":{"type":"url","url":"https://example.invalid/a.png"}}
1492 ]}
1493 ]));
1494 let translated = translated_with_mode(&request, crate::config::GrokToolImageMode::Omit);
1495 assert_eq!(
1496 translated["input"][1]["output"],
1497 "caption\n[image omitted: image/png]\n[image omitted: url]"
1498 );
1499 }
1500
1501 #[test]
1502 fn grok_translation_joins_multiple_text_tool_result_children_with_newlines() {
1503 let request = request_with_blocks(serde_json::json!([
1504 {"type":"tool_result","tool_use_id":"call_1","content":[
1505 {"type":"text","text":"first"},
1506 {"type":"text","text":"second"}
1507 ]}
1508 ]));
1509 let translated =
1510 serde_json::to_value(translate_request(&request, "grok-4.5".into()).unwrap()).unwrap();
1511 assert_eq!(translated["input"][1]["output"], "first\nsecond");
1512 }
1513
1514 #[test]
1515 fn grok_translation_omits_top_level_user_image_blocks() {
1516 let request: MessagesRequest = serde_json::from_value(serde_json::json!({
1517 "model":"grok-4.5",
1518 "messages":[{"role":"user","content":[
1519 {"type":"text","text":"what is this?"},
1520 {"type":"image","source":{"type":"base64","media_type":"image/png","data":"aGVsbG8="}}
1521 ]}]
1522 }))
1523 .unwrap();
1524 let translated = translated_with_mode(&request, crate::config::GrokToolImageMode::Omit);
1525 assert_eq!(
1526 translated["input"][0]["content"],
1527 serde_json::json!([
1528 {"type":"input_text","text":"what is this?"},
1529 {"type":"input_text","text":"[image omitted: image/png]"}
1530 ])
1531 );
1532 }
1533
1534 #[test]
1535 fn grok_translation_rejects_malformed_tool_result_children() {
1536 for child in [
1537 serde_json::json!("text"),
1538 serde_json::json!({"text":"ok"}),
1539 serde_json::json!({"type":"text","text":1}),
1540 serde_json::json!({"type":"text","text":"ok","unknown":true}),
1541 ] {
1542 let request = request_with_blocks(serde_json::json!([
1543 {"type":"tool_result","tool_use_id":"call_1","content":[child]}
1544 ]));
1545 assert!(translate_request(&request, "grok-4.5".into()).is_err());
1546 }
1547 }
1548
1549 #[test]
1550 fn grok_translation_rejects_duplicate_tool_results() {
1551 let request = request_with_blocks(serde_json::json!([
1552 {"type":"tool_result","tool_use_id":"call_1","content":"first"},
1553 {"type":"tool_result","tool_use_id":"call_1","content":"second"}
1554 ]));
1555 assert!(translate_request(&request, "grok-4.5".into()).is_err());
1556 }
1557
1558 #[test]
1559 fn grok_translation_accepts_tool_cache_control_without_forwarding_it() {
1560 let request: MessagesRequest = serde_json::from_value(serde_json::json!({
1561 "model":"grok-4.5",
1562 "messages":[{"role":"user","content":"hello"}],
1563 "tools":[{
1564 "name":"lookup",
1565 "description":"Look things up",
1566 "input_schema":{"type":"object"},
1567 "cache_control":{"type":"ephemeral"}
1568 }]
1569 }))
1570 .unwrap();
1571 let translated =
1572 serde_json::to_value(translate_request(&request, "grok-4.5".into()).unwrap()).unwrap();
1573 assert!(
1574 translated["tools"]
1575 .as_array()
1576 .unwrap()
1577 .iter()
1578 .any(|tool| { tool["type"] == "function" && tool["name"] == "lookup" })
1579 );
1580 assert!(!translated.to_string().contains("cache_control"));
1581 }
1582
1583 #[test]
1584 fn grok_translation_rejects_invalid_tool_cache_control() {
1585 let request: MessagesRequest = serde_json::from_value(serde_json::json!({
1586 "model":"grok-4.5",
1587 "messages":[{"role":"user","content":"hello"}],
1588 "tools":[{
1589 "name":"lookup",
1590 "input_schema":{"type":"object"},
1591 "cache_control":{"type":"persistent"}
1592 }]
1593 }))
1594 .unwrap();
1595 assert!(translate_request(&request, "grok-4.5".into()).is_err());
1596 }
1597
1598 #[test]
1599 fn grok_translation_accepts_cache_control_on_tool_use_and_tool_result() {
1600 let request: MessagesRequest = serde_json::from_value(serde_json::json!({
1601 "model":"grok-4.5",
1602 "messages":[
1603 {"role":"assistant","content":[
1604 {"type":"tool_use","id":"call_1","name":"lookup","input":{"q":"a"},"cache_control":{"type":"ephemeral","ttl":"1h"}}
1605 ]},
1606 {"role":"user","content":[
1607 {"type":"tool_result","tool_use_id":"call_1","content":[
1608 {"type":"text","text":"result","cache_control":{"type":"ephemeral"}}
1609 ]}
1610 ]}
1611 ]
1612 }))
1613 .unwrap();
1614 let translated =
1615 serde_json::to_value(translate_request(&request, "grok-4.5".into()).unwrap()).unwrap();
1616 assert_eq!(translated["input"][0]["type"], "function_call");
1617 assert_eq!(translated["input"][1]["type"], "function_call_output");
1618 assert_eq!(translated["input"][1]["output"], "result");
1619 assert!(!translated.to_string().contains("cache_control"));
1620 }
1621
1622 const PNG_32_RED: &str = "iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAIAAAD8GO2jAAAAKElEQVR4nO3NsQ0AAAzCMP5/un0CNkuZ41wybXsHAAAAAAAAAAAAxR4yw/wuPL6QkAAAAABJRU5ErkJggg==";
1628 const PNG_1_RED: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC";
1630 const PNG_8_RED: &str = "iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAIAAABLbSncAAAAEklEQVR4nGP4z8CAFWEXHbQSACj/P8Fu7N9hAAAAAElFTkSuQmCC";
1632
1633 #[test]
1634 fn grok_tool_image_mode_parses_flag_values() {
1635 use crate::config::{GrokToolImageMode, parse_grok_tool_image_mode};
1636 assert_eq!(parse_grok_tool_image_mode(None), GrokToolImageMode::Omit);
1637 assert_eq!(
1638 parse_grok_tool_image_mode(Some("omit")),
1639 GrokToolImageMode::Omit
1640 );
1641 assert_eq!(
1642 parse_grok_tool_image_mode(Some("reattach")),
1643 GrokToolImageMode::Reattach
1644 );
1645 assert_eq!(
1646 parse_grok_tool_image_mode(Some("reject")),
1647 GrokToolImageMode::Reject
1648 );
1649 assert_eq!(
1650 parse_grok_tool_image_mode(Some("inline")),
1651 GrokToolImageMode::Inline
1652 );
1653 assert_eq!(
1655 parse_grok_tool_image_mode(Some("bogus")),
1656 GrokToolImageMode::Omit
1657 );
1658 assert_eq!(
1659 parse_grok_tool_image_mode(Some("")),
1660 GrokToolImageMode::Omit
1661 );
1662 }
1663
1664 #[test]
1665 fn reattach_tool_result_image_emits_following_user_message_with_input_image() {
1666 let request = request_with_blocks(serde_json::json!([
1667 {"type":"tool_result","tool_use_id":"call_1","content":[
1668 {"type":"text","text":"screenshot"},
1669 {"type":"image","source":{"type":"base64","media_type":"image/png","data":PNG_32_RED}}
1670 ]}
1671 ]));
1672 let translated = serde_json::to_value(
1673 translate_request_with_mode(
1674 &request,
1675 "grok-4.5".into(),
1676 crate::config::GrokToolImageMode::Reattach,
1677 )
1678 .unwrap(),
1679 )
1680 .unwrap();
1681 assert_eq!(translated["input"][1]["type"], "function_call_output");
1683 assert_eq!(
1684 translated["input"][1]["output"],
1685 "screenshot\n[image omitted: image/png]"
1686 );
1687 assert_eq!(translated["input"][2]["type"], "message");
1689 assert_eq!(translated["input"][2]["role"], "user");
1690 let parts = translated["input"][2]["content"].as_array().unwrap();
1691 assert_eq!(parts.len(), 1);
1692 assert_eq!(parts[0]["type"], "input_image");
1693 let url = parts[0]["image_url"].as_str().unwrap();
1694 assert!(
1695 url.starts_with("data:image/png;base64,"),
1696 "unexpected image_url prefix: {}",
1697 &url[..url.len().min(40)]
1698 );
1699 assert!(url.contains(PNG_32_RED));
1700 }
1701
1702 #[test]
1703 fn reattach_top_level_user_image_becomes_input_image_part() {
1704 let request: MessagesRequest = serde_json::from_value(serde_json::json!({
1705 "model":"grok-4.5",
1706 "messages":[{"role":"user","content":[
1707 {"type":"text","text":"what color is this?"},
1708 {"type":"image","source":{"type":"base64","media_type":"image/png","data":PNG_32_RED}}
1709 ]}]
1710 }))
1711 .unwrap();
1712 let translated = serde_json::to_value(
1713 translate_request_with_mode(
1714 &request,
1715 "grok-4.5".into(),
1716 crate::config::GrokToolImageMode::Reattach,
1717 )
1718 .unwrap(),
1719 )
1720 .unwrap();
1721 let parts = translated["input"][0]["content"].as_array().unwrap();
1722 assert_eq!(parts[0]["type"], "input_text");
1723 assert_eq!(parts[0]["text"], "what color is this?");
1724 assert_eq!(parts[1]["type"], "input_image");
1725 assert!(
1726 parts[1]["image_url"]
1727 .as_str()
1728 .unwrap()
1729 .starts_with("data:image/png;base64,")
1730 );
1731 }
1732
1733 #[test]
1734 fn reattach_degrades_tiny_image_to_omit_with_reason_never_error() {
1735 for (data, dims) in [(PNG_1_RED, "1x1"), (PNG_8_RED, "8x8")] {
1736 let request = request_with_blocks(serde_json::json!([
1737 {"type":"tool_result","tool_use_id":"call_1","content":[
1738 {"type":"image","source":{"type":"base64","media_type":"image/png","data":data}}
1739 ]}
1740 ]));
1741 let translated = serde_json::to_value(
1742 translate_request_with_mode(
1743 &request,
1744 "grok-4.5".into(),
1745 crate::config::GrokToolImageMode::Reattach,
1746 )
1747 .unwrap(),
1748 )
1749 .unwrap();
1750 let output = translated["input"][1]["output"].as_str().unwrap();
1751 assert!(
1752 output.starts_with("[image omitted: image/png"),
1753 "unexpected output: {output}"
1754 );
1755 assert!(output.contains(dims), "reason must cite dims: {output}");
1756 assert_eq!(translated["input"].as_array().unwrap().len(), 2);
1758 }
1759 }
1760
1761 #[test]
1762 fn reattach_accepts_rgb_image_under_decoded_size_cap() {
1763 let image = solid_rgb_png_base64(1000, 700);
1766 let request = request_with_blocks(serde_json::json!([
1767 {"type":"tool_result","tool_use_id":"call_1","content":[
1768 {"type":"image","source":{"type":"base64","media_type":"image/png","data":image}}
1769 ]}
1770 ]));
1771 let translated = serde_json::to_value(
1772 translate_request_with_mode(
1773 &request,
1774 "grok-4.5".into(),
1775 crate::config::GrokToolImageMode::Reattach,
1776 )
1777 .unwrap(),
1778 )
1779 .unwrap();
1780 assert_eq!(
1781 translated["input"][1]["output"],
1782 "[image omitted: image/png]"
1783 );
1784 assert_eq!(translated["input"][2]["content"][0]["type"], "input_image");
1785 }
1786
1787 #[test]
1788 fn reattach_degrades_oversized_image_to_omit_with_reason() {
1789 let big = solid_rgb_png_base64(6000, 6000);
1792 let request = request_with_blocks(serde_json::json!([
1793 {"type":"tool_result","tool_use_id":"call_1","content":[
1794 {"type":"image","source":{"type":"base64","media_type":"image/png","data":big}}
1795 ]}
1796 ]));
1797 let translated = serde_json::to_value(
1798 translate_request_with_mode(
1799 &request,
1800 "grok-4.5".into(),
1801 crate::config::GrokToolImageMode::Reattach,
1802 )
1803 .unwrap(),
1804 )
1805 .unwrap();
1806 let output = translated["input"][1]["output"].as_str().unwrap();
1807 assert!(
1808 output.starts_with("[image omitted: image/png"),
1809 "unexpected output: {output}"
1810 );
1811 assert!(
1812 output.contains("too large"),
1813 "reason must cite size: {output}"
1814 );
1815 assert_eq!(translated["input"].as_array().unwrap().len(), 2);
1816 }
1817
1818 #[test]
1819 fn reattach_keeps_only_last_four_images_per_request() {
1820 let mut result_parts = Vec::new();
1821 for _ in 0..6 {
1822 result_parts.push(serde_json::json!({"type":"image","source":{"type":"base64","media_type":"image/png","data":PNG_32_RED}}));
1823 }
1824 let request: MessagesRequest = serde_json::from_value(serde_json::json!({
1825 "model":"grok-4.5",
1826 "messages":[
1827 {"role":"assistant","content":[{"type":"tool_use","id":"call_1","name":"lookup","input":{}}]},
1828 {"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":result_parts}]}
1829 ]
1830 }))
1831 .unwrap();
1832 let translated = serde_json::to_value(
1833 translate_request_with_mode(
1834 &request,
1835 "grok-4.5".into(),
1836 crate::config::GrokToolImageMode::Reattach,
1837 )
1838 .unwrap(),
1839 )
1840 .unwrap();
1841 let input = translated["input"].as_array().unwrap();
1842 assert_eq!(input.len(), 3);
1844 let parts = input[2]["content"].as_array().unwrap();
1845 assert_eq!(parts.len(), 4, "only the last 4 images survive");
1846 assert!(parts.iter().all(|part| part["type"] == "input_image"));
1847 let output = input[1]["output"].as_str().unwrap();
1849 let cap_drops = output.matches("only the last 4 images").count();
1850 assert_eq!(
1851 cap_drops, 2,
1852 "cap-dropped images must carry a reason: {output}"
1853 );
1854 let attached = output.matches("[image omitted: image/png]\n").count()
1855 + if output.ends_with("[image omitted: image/png]") {
1856 1
1857 } else {
1858 0
1859 };
1860 assert_eq!(attached, 4, "surviving images keep the plain L1 marker");
1861 }
1862
1863 #[test]
1864 fn reattach_cap_applies_across_tool_results_in_one_request() {
1865 let image = || serde_json::json!({"type":"image","source":{"type":"base64","media_type":"image/png","data":PNG_32_RED}});
1869 let request: MessagesRequest = serde_json::from_value(serde_json::json!({
1870 "model":"grok-4.5",
1871 "messages":[
1872 {"role":"assistant","content":[
1873 {"type":"tool_use","id":"call_1","name":"lookup","input":{}},
1874 {"type":"tool_use","id":"call_2","name":"lookup","input":{}}
1875 ]},
1876 {"role":"user","content":[
1877 {"type":"tool_result","tool_use_id":"call_1","content":[image(),image(),image()]},
1878 {"type":"tool_result","tool_use_id":"call_2","content":[image(),image(),image()]}
1879 ]}
1880 ]
1881 }))
1882 .unwrap();
1883 let translated = serde_json::to_value(
1884 translate_request_with_mode(
1885 &request,
1886 "grok-4.5".into(),
1887 crate::config::GrokToolImageMode::Reattach,
1888 )
1889 .unwrap(),
1890 )
1891 .unwrap();
1892 let input = translated["input"].as_array().unwrap();
1893 let total_attached: usize = input
1894 .iter()
1895 .map(|item| {
1896 item["content"]
1897 .as_array()
1898 .map(|parts| {
1899 parts
1900 .iter()
1901 .filter(|part| part["type"] == "input_image")
1902 .count()
1903 })
1904 .unwrap_or(0)
1905 })
1906 .sum();
1907 assert_eq!(
1908 total_attached, 4,
1909 "cap is request-wide, not per tool result"
1910 );
1911 }
1912
1913 #[test]
1914 fn reattach_degrades_url_images_with_reason_instead_of_reattaching() {
1915 let request = request_with_blocks(serde_json::json!([
1916 {"type":"tool_result","tool_use_id":"call_1","content":[
1917 {"type":"image","source":{"type":"url","url":"https://example.invalid/a.png"}}
1918 ]}
1919 ]));
1920 let translated = serde_json::to_value(
1921 translate_request_with_mode(
1922 &request,
1923 "grok-4.5".into(),
1924 crate::config::GrokToolImageMode::Reattach,
1925 )
1926 .unwrap(),
1927 )
1928 .unwrap();
1929 let output = translated["input"][1]["output"].as_str().unwrap();
1930 assert!(
1931 output.starts_with("[image omitted: url]"),
1932 "unexpected output: {output}"
1933 );
1934 assert!(
1935 output.contains("cannot be gated"),
1936 "reason must explain the skip: {output}"
1937 );
1938 assert!(!translated.to_string().contains("input_image"));
1940 }
1941
1942 #[test]
1943 fn reject_mode_restores_old_bail_on_tool_result_images() {
1944 let request = request_with_blocks(serde_json::json!([
1945 {"type":"tool_result","tool_use_id":"call_1","content":[
1946 {"type":"image","source":{"type":"base64","media_type":"image/png","data":PNG_32_RED}}
1947 ]}
1948 ]));
1949 let result = translate_request_with_mode(
1950 &request,
1951 "grok-4.5".into(),
1952 crate::config::GrokToolImageMode::Reject,
1953 );
1954 assert!(result.is_err());
1955 assert!(
1956 result
1957 .unwrap_err()
1958 .to_string()
1959 .contains("tool result supports text children only")
1960 );
1961 }
1962
1963 #[test]
1964 fn reject_mode_restores_old_bail_on_top_level_user_images() {
1965 let request: MessagesRequest = serde_json::from_value(serde_json::json!({
1966 "model":"grok-4.5",
1967 "messages":[{"role":"user","content":[
1968 {"type":"image","source":{"type":"base64","media_type":"image/png","data":PNG_32_RED}}
1969 ]}]
1970 }))
1971 .unwrap();
1972 let result = translate_request_with_mode(
1973 &request,
1974 "grok-4.5".into(),
1975 crate::config::GrokToolImageMode::Reject,
1976 );
1977 assert!(result.is_err());
1978 assert!(
1979 result
1980 .unwrap_err()
1981 .to_string()
1982 .contains("unsupported content block: image")
1983 );
1984 }
1985
1986 fn solid_rgb_png_base64(width: u32, height: u32) -> String {
1988 use base64::Engine;
1989 use std::io::Write as _;
1990 let mut png = Vec::new();
1991 png.extend_from_slice(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]);
1992 let mut ihdr = Vec::new();
1993 ihdr.extend_from_slice(&width.to_be_bytes());
1994 ihdr.extend_from_slice(&height.to_be_bytes());
1995 ihdr.extend_from_slice(&[8, 2, 0, 0, 0]); write_chunk(&mut png, b"IHDR", &ihdr);
1997 let mut raw = Vec::with_capacity((width as usize * 3 + 1) * height as usize);
1999 for _ in 0..height {
2000 raw.push(0u8);
2001 raw.extend(std::iter::repeat_n(0u8, width as usize * 3));
2002 }
2003 let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast());
2004 encoder.write_all(&raw).unwrap();
2005 let idat = encoder.finish().unwrap();
2006 write_chunk(&mut png, b"IDAT", &idat);
2007 write_chunk(&mut png, b"IEND", &[]);
2008 base64::engine::general_purpose::STANDARD.encode(png)
2009 }
2010
2011 fn write_chunk(out: &mut Vec<u8>, kind: &[u8; 4], data: &[u8]) {
2012 out.extend_from_slice(&(data.len() as u32).to_be_bytes());
2013 out.extend_from_slice(kind);
2014 out.extend_from_slice(data);
2015 let mut crc_data = Vec::with_capacity(4 + data.len());
2016 crc_data.extend_from_slice(kind);
2017 crc_data.extend_from_slice(data);
2018 out.extend_from_slice(&crc32(&crc_data).to_be_bytes());
2019 }
2020
2021 fn crc32(data: &[u8]) -> u32 {
2022 let mut crc: u32 = 0xffff_ffff;
2023 for byte in data {
2024 crc ^= *byte as u32;
2025 for _ in 0..8 {
2026 let mask = (crc & 1).wrapping_neg();
2027 crc = (crc >> 1) ^ (0xedb8_8320 & mask);
2028 }
2029 }
2030 !crc
2031 }
2032
2033 #[test]
2038 fn inline_tool_result_image_emits_array_output_with_input_image_part() {
2039 let request = request_with_blocks(serde_json::json!([
2040 {"type":"tool_result","tool_use_id":"call_1","content":[
2041 {"type":"text","text":"screenshot"},
2042 {"type":"image","source":{"type":"base64","media_type":"image/png","data":PNG_32_RED}}
2043 ]}
2044 ]));
2045 let translated = serde_json::to_value(
2046 translate_request_with_mode(
2047 &request,
2048 "grok-4.5".into(),
2049 crate::config::GrokToolImageMode::Inline,
2050 )
2051 .unwrap(),
2052 )
2053 .unwrap();
2054 assert_eq!(translated["input"][1]["type"], "function_call_output");
2055 let parts = translated["input"][1]["output"].as_array().unwrap();
2057 assert_eq!(parts.len(), 2);
2058 assert_eq!(parts[0]["type"], "input_text");
2059 assert_eq!(parts[0]["text"], "screenshot");
2060 assert_eq!(parts[1]["type"], "input_image");
2061 let url = parts[1]["image_url"].as_str().unwrap();
2062 assert!(
2063 url.starts_with("data:image/png;base64,"),
2064 "unexpected image_url prefix: {}",
2065 &url[..url.len().min(40)]
2066 );
2067 assert!(url.contains(PNG_32_RED));
2068 assert_eq!(translated["input"].as_array().unwrap().len(), 2);
2070 }
2071
2072 #[test]
2073 fn inline_image_only_tool_result_emits_array_with_single_image_part() {
2074 let request = request_with_blocks(serde_json::json!([
2075 {"type":"tool_result","tool_use_id":"call_1","content":[
2076 {"type":"image","source":{"type":"base64","media_type":"image/png","data":PNG_32_RED}}
2077 ]}
2078 ]));
2079 let translated = serde_json::to_value(
2080 translate_request_with_mode(
2081 &request,
2082 "grok-4.5".into(),
2083 crate::config::GrokToolImageMode::Inline,
2084 )
2085 .unwrap(),
2086 )
2087 .unwrap();
2088 let parts = translated["input"][1]["output"].as_array().unwrap();
2089 assert_eq!(parts.len(), 1);
2090 assert_eq!(parts[0]["type"], "input_image");
2091 }
2092
2093 #[test]
2094 fn inline_text_only_tool_result_serializes_byte_identically_to_omit() {
2095 let shapes = [
2098 serde_json::json!({"type":"tool_result","tool_use_id":"call_1","content":"plain string"}),
2099 serde_json::json!({"type":"tool_result","tool_use_id":"call_1","content":[
2100 {"type":"text","text":"first"},
2101 {"type":"text","text":"second"}
2102 ]}),
2103 ];
2104 for shape in shapes {
2105 let request = request_with_blocks(serde_json::json!([shape]));
2106 let omit = serde_json::to_string(
2107 &translate_request_with_mode(
2108 &request,
2109 "grok-4.5".into(),
2110 crate::config::GrokToolImageMode::Omit,
2111 )
2112 .unwrap(),
2113 )
2114 .unwrap();
2115 let inline = serde_json::to_string(
2116 &translate_request_with_mode(
2117 &request,
2118 "grok-4.5".into(),
2119 crate::config::GrokToolImageMode::Inline,
2120 )
2121 .unwrap(),
2122 )
2123 .unwrap();
2124 assert_eq!(omit, inline, "text-only outputs must be byte-identical");
2125 let value: Value = serde_json::from_str(&inline).unwrap();
2127 assert!(value["input"][1]["output"].is_string());
2128 }
2129 }
2130
2131 #[test]
2132 fn inline_string_output_matches_pre_inline_serialization_exactly() {
2133 let request = request_with_blocks(serde_json::json!([
2136 {"type":"tool_result","tool_use_id":"call_1","content":"result"}
2137 ]));
2138 let body = serde_json::to_string(
2139 &translate_request_with_mode(
2140 &request,
2141 "grok-4.5".into(),
2142 crate::config::GrokToolImageMode::Inline,
2143 )
2144 .unwrap(),
2145 )
2146 .unwrap();
2147 assert!(
2148 body.contains(r#""output":"result""#),
2149 "output must serialize as a bare string: {body}"
2150 );
2151 assert!(!body.contains(r#""output":["#));
2152 }
2153
2154 #[test]
2155 fn inline_all_images_gated_out_collapse_to_string_with_reasons() {
2156 for (data, dims) in [(PNG_1_RED, "1x1"), (PNG_8_RED, "8x8")] {
2157 let request = request_with_blocks(serde_json::json!([
2158 {"type":"tool_result","tool_use_id":"call_1","content":[
2159 {"type":"text","text":"shot"},
2160 {"type":"image","source":{"type":"base64","media_type":"image/png","data":data}}
2161 ]}
2162 ]));
2163 let translated = serde_json::to_value(
2165 translate_request_with_mode(
2166 &request,
2167 "grok-4.5".into(),
2168 crate::config::GrokToolImageMode::Inline,
2169 )
2170 .unwrap(),
2171 )
2172 .unwrap();
2173 let output = translated["input"][1]["output"].as_str().unwrap();
2175 assert!(
2176 output.contains("[image omitted: image/png"),
2177 "unexpected output: {output}"
2178 );
2179 assert!(output.contains(dims), "reason must cite dims: {output}");
2180 assert!(output.starts_with("shot\n"));
2181 }
2182 }
2183
2184 #[test]
2185 fn inline_mixed_passing_and_failing_images_keep_array_shape() {
2186 let request = request_with_blocks(serde_json::json!([
2187 {"type":"tool_result","tool_use_id":"call_1","content":[
2188 {"type":"image","source":{"type":"base64","media_type":"image/png","data":PNG_1_RED}},
2189 {"type":"image","source":{"type":"base64","media_type":"image/png","data":PNG_32_RED}}
2190 ]}
2191 ]));
2192 let translated = serde_json::to_value(
2193 translate_request_with_mode(
2194 &request,
2195 "grok-4.5".into(),
2196 crate::config::GrokToolImageMode::Inline,
2197 )
2198 .unwrap(),
2199 )
2200 .unwrap();
2201 let parts = translated["input"][1]["output"].as_array().unwrap();
2202 assert_eq!(parts.len(), 2);
2203 assert_eq!(parts[0]["type"], "input_text");
2205 assert!(
2206 parts[0]["text"]
2207 .as_str()
2208 .unwrap()
2209 .starts_with("[image omitted: image/png")
2210 );
2211 assert!(parts[0]["text"].as_str().unwrap().contains("1x1"));
2212 assert_eq!(parts[1]["type"], "input_image");
2213 }
2214
2215 #[test]
2216 fn inline_degrades_url_images_to_omit_fragment_inside_array() {
2217 let request = request_with_blocks(serde_json::json!([
2218 {"type":"tool_result","tool_use_id":"call_1","content":[
2219 {"type":"image","source":{"type":"url","url":"https://example.invalid/a.png"}},
2220 {"type":"image","source":{"type":"base64","media_type":"image/png","data":PNG_32_RED}}
2221 ]}
2222 ]));
2223 let translated = serde_json::to_value(
2224 translate_request_with_mode(
2225 &request,
2226 "grok-4.5".into(),
2227 crate::config::GrokToolImageMode::Inline,
2228 )
2229 .unwrap(),
2230 )
2231 .unwrap();
2232 let parts = translated["input"][1]["output"].as_array().unwrap();
2233 assert_eq!(parts.len(), 2);
2234 assert_eq!(parts[0]["type"], "input_text");
2235 assert!(
2236 parts[0]["text"]
2237 .as_str()
2238 .unwrap()
2239 .contains("cannot be gated")
2240 );
2241 assert_eq!(parts[1]["type"], "input_image");
2242 }
2243
2244 #[test]
2245 fn inline_keeps_only_last_four_images_per_request() {
2246 let mut result_parts = Vec::new();
2247 for _ in 0..6 {
2248 result_parts.push(serde_json::json!({"type":"image","source":{"type":"base64","media_type":"image/png","data":PNG_32_RED}}));
2249 }
2250 let request: MessagesRequest = serde_json::from_value(serde_json::json!({
2251 "model":"grok-4.5",
2252 "messages":[
2253 {"role":"assistant","content":[{"type":"tool_use","id":"call_1","name":"lookup","input":{}}]},
2254 {"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":result_parts}]}
2255 ]
2256 }))
2257 .unwrap();
2258 let translated = serde_json::to_value(
2259 translate_request_with_mode(
2260 &request,
2261 "grok-4.5".into(),
2262 crate::config::GrokToolImageMode::Inline,
2263 )
2264 .unwrap(),
2265 )
2266 .unwrap();
2267 let input = translated["input"].as_array().unwrap();
2268 assert_eq!(input.len(), 2);
2270 let parts = input[1]["output"].as_array().unwrap();
2271 assert_eq!(parts.len(), 6);
2272 let images = parts
2273 .iter()
2274 .filter(|part| part["type"] == "input_image")
2275 .count();
2276 assert_eq!(images, 4, "only the last 4 images survive");
2277 let cap_drops = parts
2278 .iter()
2279 .filter(|part| {
2280 part["type"] == "input_text"
2281 && part["text"]
2282 .as_str()
2283 .is_some_and(|text| text.contains("only the last 4 images"))
2284 })
2285 .count();
2286 assert_eq!(cap_drops, 2, "cap-dropped images carry a reason");
2287 }
2288
2289 #[test]
2290 fn inline_top_level_user_image_becomes_input_image_part() {
2291 let request: MessagesRequest = serde_json::from_value(serde_json::json!({
2292 "model":"grok-4.5",
2293 "messages":[{"role":"user","content":[
2294 {"type":"text","text":"what color is this?"},
2295 {"type":"image","source":{"type":"base64","media_type":"image/png","data":PNG_32_RED}}
2296 ]}]
2297 }))
2298 .unwrap();
2299 let translated = serde_json::to_value(
2300 translate_request_with_mode(
2301 &request,
2302 "grok-4.5".into(),
2303 crate::config::GrokToolImageMode::Inline,
2304 )
2305 .unwrap(),
2306 )
2307 .unwrap();
2308 let parts = translated["input"][0]["content"].as_array().unwrap();
2309 assert_eq!(parts[0]["type"], "input_text");
2310 assert_eq!(parts[1]["type"], "input_image");
2311 assert!(
2312 parts[1]["image_url"]
2313 .as_str()
2314 .unwrap()
2315 .starts_with("data:image/png;base64,")
2316 );
2317 }
2318}