Skip to main content

claude_codex/providers/grok/translate/
request.rs

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/// Tool output payload: a plain string, or (in `inline` image mode) an array
50/// of `input_text` + `input_image` parts. Untagged so string outputs serialize
51/// byte-identically to the pre-inline shape.
52#[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
195/// Pre-scans the request to decide which gate-passing images survive the
196/// request-wide "keep only the last few" cap, so the main walk can mark
197/// cap-dropped images with a reason instead of silently dropping pixels.
198struct ReattachBudget {
199    /// Per-image-URL count of gate-passing occurrences that must be dropped
200    /// (the oldest ones) before survivors begin, so the *last* few images win.
201    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            // Drop the oldest occurrences beyond the cap, keeping the last few.
222            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    /// Record a gate-passing image; `true` when it survives the request cap.
233    /// Oldest occurrences are dropped first, in conversation order.
234    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
246/// Every image block in one message: top-level user images and tool_result
247/// image children, in conversation order.
248fn 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                            // Text-only results keep the plain-string shape so
801                            // inline mode is invisible unless an image is present.
802                            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
872// ---------------------------------------------------------------------------
873// L2a reattach gates (limits verified against cli-chat-proxy.grok.com on
874// 2026-07-20: 1x1 and 8x8 rejected, 32x32 accepted; production-scale
875// screenshots well above the minimums; both `reattach` and `inline` wire
876// shapes returned 200 with correct visual answers)
877// ---------------------------------------------------------------------------
878
879/// Upstream rejects images whose smallest side is under this many pixels.
880const MIN_IMAGE_SIDE_PX: u32 = 8;
881/// Upstream rejects images whose area is under this many square pixels.
882const MIN_IMAGE_AREA_PX: u64 = 512;
883/// Decoded RGB(A) payload cap; larger images are degraded to the omit marker.
884const MAX_IMAGE_DECODED_BYTES: u64 = 5 * 1024 * 1024;
885/// Only the last few images across the whole request are attached.
886const MAX_REATTACHED_IMAGES: usize = 4;
887
888/// Parse an Anthropic image block into a shared `ImageSource`. Returns `None`
889/// for structurally invalid blocks (missing fields, unknown source type).
890fn 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
921/// Gate a candidate image against the upstream-verified limits. Returns the
922/// omit reason on failure so the caller can degrade just this one image.
923fn gate_image(source: &ImageSource) -> Result<(), String> {
924    if source.source_type == "url" {
925        // The proxy cannot gate a remote image without fetching it (dimensions
926        // and decoded size are unknown), and an unverifiable image can 400 the
927        // whole turn upstream — so URL sources never reattach.
928        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
961/// Render the L1 omit marker with a gate-failure reason appended.
962fn 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
971/// Reason attached to images that passed every per-image gate but lost the
972/// request-wide "keep only the last few" cap.
973fn cap_reason() -> String {
974    format!("only the last {MAX_REATTACHED_IMAGES} images are attached per request")
975}
976
977/// Extract raster dimensions and a conservative decoded byte width from the
978/// encoded image header. PNG accounting reserves alpha when the format may
979/// carry transparency. GIF accounting reserves an RGBA output pixel.
980#[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    // Signature (8) + IHDR length (4) + "IHDR" (4) + IHDR fields.
1004    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        // Grayscale and RGB may carry transparency in a tRNS chunk.
1022        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    // "GIF87a"/"GIF89a" (6) + width (2 LE) + height (2 LE).
1035    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    // Walk SOI-delimited segments to the first SOF0..SOF15 frame header.
1049    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        // Standalone markers without a length field.
1059        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            // Segment: length (2), precision (1), dimensions (4), components (1).
1074            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    // ---------------------------------------------------------------------
1623    // L2a: CCP_GROK_TOOL_IMAGE flag + reattach vision tests
1624    // ---------------------------------------------------------------------
1625
1626    // 32x32 solid red PNG (valid dimensions, passes all gates).
1627    const PNG_32_RED: &str = "iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAIAAAD8GO2jAAAAKElEQVR4nO3NsQ0AAAzCMP5/un0CNkuZ41wybXsHAAAAAAAAAAAAxR4yw/wuPL6QkAAAAABJRU5ErkJggg==";
1628    // 1x1 red PNG (min-side + area gate failure).
1629    const PNG_1_RED: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC";
1630    // 8x8 red PNG (area gate failure: 64 < 512).
1631    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        // Unknown values are the safe default.
1654        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        // function_call_output keeps the L1 omit marker (text stays verbatim).
1682        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        // A user message follows carrying the image as an input_image data URL.
1688        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            // No reattached message: gated-out images never produce input_image.
1757            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        // The 8-bit RGB raster is 2.1MB. A format-independent 8-byte estimate
1764        // would incorrectly reject this common screenshot size.
1765        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        // 6000x6000 solid PNG: decoded raw size is at least 108MB. Deflate of a
1790        // solid scanline is tiny, so the base64 fixture stays small.
1791        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        // function_call + function_call_output + one reattached user message.
1843        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        // The two cap-dropped images degrade with a reason in the tool output.
1848        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        // Two tool results with 3 images each: 6 gate-passing images, so only
1866        // the last 4 across the request reattach (the whole second result's 3
1867        // plus the first result's last 1).
1868        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        // No input_image part anywhere.
1939        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    /// Build a solid 8-bit RGB PNG of the given size, returned as base64.
1987    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]); // 8-bit RGB
1996        write_chunk(&mut png, b"IHDR", &ihdr);
1997        // One scanline: filter byte + width * 3 zero bytes, repeated.
1998        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    // ---------------------------------------------------------------------
2034    // L2b: CCP_GROK_TOOL_IMAGE=inline — tool output as a content-part array
2035    // ---------------------------------------------------------------------
2036
2037    #[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        // output is an untagged array of content parts, not a string.
2056        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        // No reattached user message: the image rides inside the tool output.
2069        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        // String-only outputs (and text-only array results) must keep the
2096        // plain-string shape in every mode — inline included.
2097        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            // And the output field really is a bare JSON string, not an array.
2126            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        // Regression: the whole upstream-bound request body for a string tool
2134        // result must serialize exactly as before the GrokToolOutput widening.
2135        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            // Gate failures degrade per-image, never a 400 for the whole turn.
2164            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            // All parts ended up as text → collapses back to a plain string.
2174            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        // The gated-out image becomes an in-band omit fragment inside the array.
2204        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        // function_call + function_call_output only — no reattached message.
2269        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}