Skip to main content

claude_codex/providers/codex/translate/
request.rs

1use std::collections::HashSet;
2
3use base64::Engine;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7use crate::anthropic::schema::MessagesRequest;
8use crate::config;
9use crate::providers::translate_shared::{
10    ContentBlock, flatten_system_text, image_source_to_url, normalize_content, parallel_tool_calls,
11    read_effort, wrap_reasoning,
12};
13
14use super::read_rewrite::{ReadOffsetRewrite, read_offset_rewrite};
15use super::reasoning_signature::decode_reasoning_signature;
16
17// ---------------------------------------------------------------------------
18// Types
19// ---------------------------------------------------------------------------
20
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
22#[serde(rename_all = "snake_case")]
23pub enum Effort {
24    None,
25    Low,
26    Medium,
27    High,
28    Xhigh,
29    Max,
30}
31
32impl std::fmt::Display for Effort {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        match self {
35            Effort::None => write!(f, "none"),
36            Effort::Low => write!(f, "low"),
37            Effort::Medium => write!(f, "medium"),
38            Effort::High => write!(f, "high"),
39            Effort::Xhigh => write!(f, "xhigh"),
40            Effort::Max => write!(f, "max"),
41        }
42    }
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
46#[serde(rename_all = "snake_case")]
47pub enum ServiceTier {
48    Priority,
49    Flex,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum ResponsesToolChoiceMode {
55    Auto,
56    None,
57    Required,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61#[serde(untagged)]
62pub enum ResponsesToolChoice {
63    Mode(ResponsesToolChoiceMode),
64    Function {
65        r#type: String,
66        name: String,
67    },
68    WebSearch {
69        r#type: String,
70    },
71    AllowedTools {
72        r#type: String,
73        mode: String,
74        tools: Vec<Value>,
75    },
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct ResponsesRequest {
80    pub model: String,
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub instructions: Option<String>,
83    pub input: Vec<ResponsesInputItem>,
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub tools: Option<Vec<ResponsesTool>>,
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub tool_choice: Option<ResponsesToolChoice>,
88    pub store: bool,
89    pub stream: bool,
90    pub parallel_tool_calls: bool,
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub include: Option<Vec<String>>,
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub client_metadata: Option<std::collections::HashMap<String, String>>,
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub service_tier: Option<ServiceTier>,
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub prompt_cache_key: Option<String>,
99    pub text: ResponsesText,
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub reasoning: Option<ResponsesReasoning>,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct ResponsesReasoning {
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub effort: Option<Effort>,
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub summary: Option<String>,
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub context: Option<String>,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct ResponsesText {
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub verbosity: Option<String>,
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub format: Option<ResponsesTextFormat>,
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
123#[serde(tag = "type")]
124#[serde(rename_all = "snake_case")]
125pub enum ResponsesTextFormat {
126    Text,
127    JsonObject,
128    JsonSchema {
129        name: String,
130        schema: Value,
131        #[serde(default)]
132        strict: Option<bool>,
133    },
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
137#[serde(tag = "type")]
138pub enum ResponsesInputItem {
139    #[serde(rename = "additional_tools")]
140    AdditionalTools {
141        #[serde(default, skip_serializing_if = "Option::is_none")]
142        id: Option<String>,
143        role: String,
144        tools: Vec<Value>,
145    },
146    #[serde(rename = "message")]
147    Message {
148        role: String,
149        content: Vec<ResponsesContentPart>,
150    },
151    #[serde(rename = "function_call")]
152    FunctionCall {
153        #[serde(default)]
154        call_id: String,
155        name: String,
156        arguments: String,
157    },
158    #[serde(rename = "function_call_output")]
159    FunctionCallOutput {
160        #[serde(default)]
161        call_id: String,
162        output: ResponsesFunctionCallOutput,
163    },
164    #[serde(rename = "reasoning")]
165    Reasoning {
166        id: String,
167        summary: Vec<Value>,
168        encrypted_content: String,
169    },
170    #[serde(rename = "compaction")]
171    Compaction { encrypted_content: String },
172    #[serde(rename = "compaction_trigger")]
173    CompactionTrigger,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
177#[serde(untagged)]
178pub enum ResponsesFunctionCallOutput {
179    Text(String),
180    ContentItems(Vec<ResponsesFunctionCallOutputContentPart>),
181}
182
183impl ResponsesFunctionCallOutput {
184    #[cfg(test)]
185    fn as_text(&self) -> Option<&str> {
186        match self {
187            Self::Text(text) => Some(text),
188            Self::ContentItems(_) => None,
189        }
190    }
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize)]
194#[serde(tag = "type", rename_all = "snake_case")]
195pub enum ResponsesFunctionCallOutputContentPart {
196    InputText {
197        text: String,
198    },
199    InputImage {
200        image_url: String,
201        #[serde(default, skip_serializing_if = "Option::is_none")]
202        detail: Option<String>,
203    },
204}
205
206#[derive(Debug, Clone, Serialize, Deserialize)]
207#[serde(tag = "type")]
208pub enum ResponsesContentPart {
209    #[serde(rename = "input_text")]
210    InputText { text: String },
211    #[serde(rename = "output_text")]
212    OutputText { text: String },
213    #[serde(rename = "input_image")]
214    InputImage {
215        image_url: String,
216        #[serde(default, skip_serializing_if = "Option::is_none")]
217        detail: Option<String>,
218    },
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
222#[serde(untagged)]
223pub enum ResponsesTool {
224    Function(ResponsesFunctionTool),
225    WebSearch(ResponsesWebSearchTool),
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize)]
229pub struct ResponsesFunctionTool {
230    #[serde(rename = "type")]
231    pub kind: String,
232    pub name: String,
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub description: Option<String>,
235    pub parameters: Value,
236    #[serde(default)]
237    pub strict: bool,
238}
239
240#[derive(Debug, Clone, Serialize, Deserialize)]
241pub struct ResponsesWebSearchTool {
242    #[serde(rename = "type")]
243    pub kind: String,
244    pub external_web_access: bool,
245    pub search_content_types: Vec<String>,
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub filters: Option<ResponsesWebSearchFilters>,
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize)]
251pub struct ResponsesWebSearchFilters {
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub allowed_domains: Option<Vec<String>>,
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub blocked_domains: Option<Vec<String>>,
256}
257
258pub struct TranslateOptions {
259    pub session_id: Option<String>,
260    pub service_tier: Option<ServiceTier>,
261    pub model: String,
262    pub use_responses_lite: bool,
263}
264
265// ---------------------------------------------------------------------------
266// Translation entry point
267// ---------------------------------------------------------------------------
268
269pub(crate) fn to_codex_effort(effort: Option<&str>) -> Option<Effort> {
270    match effort {
271        Some("max") => Some(Effort::Max),
272        Some("xhigh") => Some(Effort::Xhigh),
273        Some("low") => Some(Effort::Low),
274        Some("medium") => Some(Effort::Medium),
275        Some("high") => Some(Effort::High),
276        _ => None,
277    }
278}
279
280fn resolve_effort(effort: Option<Effort>) -> Result<Option<Effort>, anyhow::Error> {
281    resolve_effort_override(effort, config::codex_effort().as_deref())
282}
283
284pub(crate) fn resolve_effort_override(
285    effort: Option<Effort>,
286    override_effort: Option<&str>,
287) -> Result<Option<Effort>, anyhow::Error> {
288    if let Some(val) = override_effort {
289        let valid = ["none", "low", "medium", "high", "xhigh", "max"];
290        if !valid.contains(&val) {
291            anyhow::bail!(
292                "Invalid effort override: \"{val}\". Must be one of: none, low, medium, high, xhigh, max"
293            );
294        }
295        return Ok(Some(match val {
296            "max" => Effort::Max,
297            "xhigh" => Effort::Xhigh,
298            "high" => Effort::High,
299            "medium" => Effort::Medium,
300            "low" => Effort::Low,
301            _ => Effort::None,
302        }));
303    }
304    Ok(effort)
305}
306
307fn reasoning_summary_requested(summary: Option<&str>) -> bool {
308    !matches!(summary, Some("off" | "none"))
309}
310
311// ---------------------------------------------------------------------------
312// Compaction fast path
313// ---------------------------------------------------------------------------
314
315const COMPACT_SYSTEM_MARKER: &str =
316    "You are a helpful AI assistant tasked with summarizing conversations";
317const COMPACT_MESSAGE_PREFIX: &str = "CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.";
318const COMPACT_MESSAGE_TASK: &str =
319    "Your task is to create a detailed summary of the conversation so far";
320
321pub(crate) fn is_compact_request(instructions: Option<&str>) -> bool {
322    instructions.is_some_and(|text| text.contains(COMPACT_SYSTEM_MARKER))
323}
324
325pub(crate) fn is_compact_message_text(text: &str) -> bool {
326    text.contains(COMPACT_MESSAGE_PREFIX) && text.contains(COMPACT_MESSAGE_TASK)
327}
328
329fn is_compact_message_content(content: &Value) -> bool {
330    match content {
331        Value::String(text) => is_compact_message_text(text),
332        Value::Array(blocks) => blocks.iter().any(|block| {
333            block.get("type").and_then(Value::as_str) == Some("text")
334                && block
335                    .get("text")
336                    .and_then(Value::as_str)
337                    .is_some_and(is_compact_message_text)
338        }),
339        _ => false,
340    }
341}
342
343pub(crate) fn is_compact_messages_request(request: &MessagesRequest) -> bool {
344    is_compact_request(flatten_system_text(request.extra.get("system")).as_deref())
345        || request.messages.last().is_some_and(|message| {
346            message.role == "user" && is_compact_message_content(&message.content)
347        })
348}
349
350/// Reasoning-effort cap applied to compaction requests, or None when the
351/// fast path is disabled. Summarization is extraction, not problem solving:
352/// native Claude Code compacts without extended thinking, so burning
353/// medium/high reasoning on a 200k-token summary only adds latency. The cap
354/// never raises effort — a request already below it is left alone.
355fn compact_effort_cap() -> Option<Effort> {
356    compact_effort_cap_from(std::env::var("CCP_COMPACT_EFFORT").ok().as_deref())
357}
358
359fn compact_effort_cap_from(raw: Option<&str>) -> Option<Effort> {
360    match raw {
361        None | Some("") => Some(Effort::Low),
362        Some("off") => None,
363        Some("none") => Some(Effort::None),
364        Some(other) => to_codex_effort(Some(other)).or(Some(Effort::Low)),
365    }
366}
367
368const VALID_SERVICE_TIERS: &[&str] = &["fast", "priority", "flex"];
369
370fn normalize_service_tier(tier: &str) -> Result<ServiceTier, anyhow::Error> {
371    if !VALID_SERVICE_TIERS.contains(&tier) {
372        anyhow::bail!(
373            "Invalid service tier override: \"{tier}\". Must be one of: {}",
374            VALID_SERVICE_TIERS.join(", ")
375        );
376    }
377    match tier {
378        "flex" => Ok(ServiceTier::Flex),
379        _ => Ok(ServiceTier::Priority),
380    }
381}
382
383fn resolve_service_tier(
384    model_tier: Option<ServiceTier>,
385) -> Result<Option<ServiceTier>, anyhow::Error> {
386    let tier = config::codex_service_tier();
387    match tier {
388        Some(ref val) => Ok(Some(normalize_service_tier(val)?)),
389        None => Ok(model_tier),
390    }
391}
392
393pub fn normalize_strict_json_schema(schema: &Value) -> Value {
394    match schema {
395        Value::Array(arr) => Value::Array(arr.iter().map(normalize_strict_json_schema).collect()),
396        Value::Object(map) => {
397            let mut out = map.clone();
398            if let Some(properties) = out.get("properties").and_then(|v| v.as_object()) {
399                let keys: Vec<String> = properties.keys().cloned().collect();
400                out.insert(
401                    "required".into(),
402                    Value::Array(keys.into_iter().map(Value::String).collect()),
403                );
404            }
405            for (key, val) in out.clone().iter() {
406                out.insert(key.clone(), normalize_strict_json_schema(val));
407            }
408            Value::Object(out)
409        }
410        _ => schema.clone(),
411    }
412}
413
414/// Hosted tools (web_search) are rejected by the Responses Lite lane, which
415/// only supports function and custom tools. Requests carrying them must use
416/// the full Responses API.
417pub fn has_hosted_web_search(req: &MessagesRequest) -> bool {
418    req.extra
419        .get("tools")
420        .and_then(|v| v.as_array())
421        .is_some_and(|tools| {
422            tools.iter().any(|tool| {
423                tool.get("type").and_then(|v| v.as_str()) == Some("web_search_20250305")
424            })
425        })
426}
427
428pub fn translate_request(
429    req: &MessagesRequest,
430    opts: TranslateOptions,
431) -> Result<ResponsesRequest, anyhow::Error> {
432    translate_request_inner(req, opts, true)
433}
434
435pub fn translate_openai_compatible_request(
436    req: &MessagesRequest,
437    model: String,
438    session_id: Option<String>,
439) -> Result<ResponsesRequest, anyhow::Error> {
440    translate_request_inner(
441        req,
442        TranslateOptions {
443            session_id,
444            service_tier: None,
445            model,
446            use_responses_lite: false,
447        },
448        false,
449    )
450}
451
452fn translate_request_inner(
453    req: &MessagesRequest,
454    opts: TranslateOptions,
455    apply_codex_config: bool,
456) -> Result<ResponsesRequest, anyhow::Error> {
457    let instructions = flatten_system_text(req.extra.get("system"));
458    let is_compact = is_compact_messages_request(req);
459    let input = build_input(req);
460    let tools = read_tools(req)?;
461    let tool_choice = map_tool_choice(req)?;
462    let parallel_tool_calls = parallel_tool_calls(req).unwrap_or(true);
463
464    let mut text = ResponsesText {
465        verbosity: Some("low".to_string()),
466        format: None,
467    };
468
469    if let Some(fmt) = read_output_format(req) {
470        text.format = Some(fmt);
471    }
472
473    let mut out = ResponsesRequest {
474        model: opts.model,
475        instructions,
476        input,
477        store: false,
478        stream: true,
479        parallel_tool_calls,
480        tool_choice,
481        text,
482        tools: None,
483        include: None,
484        client_metadata: None,
485        service_tier: None,
486        prompt_cache_key: None,
487        reasoning: None,
488    };
489
490    if opts.use_responses_lite {
491        out.client_metadata = Some(std::collections::HashMap::from([(
492            "ws_request_header_x_openai_internal_codex_responses_lite".to_string(),
493            "true".to_string(),
494        )]));
495        out.parallel_tool_calls = false;
496
497        let mut prefix = Vec::new();
498        if let Some(ref tools) = tools
499            && !tools.is_empty()
500        {
501            let tools = tools
502                .iter()
503                .map(serde_json::to_value)
504                .collect::<Result<Vec<_>, _>>()?;
505            prefix.push(ResponsesInputItem::AdditionalTools {
506                id: None,
507                role: "developer".to_string(),
508                tools,
509            });
510        }
511        if let Some(instructions) = out.instructions.take()
512            && !instructions.is_empty()
513        {
514            prefix.push(ResponsesInputItem::Message {
515                role: "developer".to_string(),
516                content: vec![ResponsesContentPart::InputText { text: instructions }],
517            });
518        }
519        if !prefix.is_empty() {
520            prefix.extend(out.input);
521            out.input = prefix;
522        }
523    } else if let Some(tools) = tools
524        && !tools.is_empty()
525    {
526        out.tools = Some(tools);
527    }
528
529    // Never force a web_search tool_choice the request didn't register —
530    // upstream 502s instead of ignoring it.
531    if matches!(
532        out.tool_choice,
533        Some(ResponsesToolChoice::WebSearch { .. } | ResponsesToolChoice::AllowedTools { .. })
534    ) {
535        let has_web_search = out.tools.as_ref().is_some_and(|t| {
536            t.iter()
537                .any(|tool| matches!(tool, ResponsesTool::WebSearch(_)))
538        });
539        if !has_web_search {
540            out.tool_choice = Some(ResponsesToolChoice::Mode(ResponsesToolChoiceMode::Auto));
541        }
542    }
543
544    if let Some(sid) = opts.session_id {
545        out.prompt_cache_key = Some(sid);
546    }
547
548    if apply_codex_config {
549        let service_tier = resolve_service_tier(opts.service_tier)?;
550        if let Some(ref tier) = service_tier {
551            out.service_tier = Some(tier.clone());
552        }
553    }
554
555    let effort = read_effort(req)?;
556    let codex_effort = to_codex_effort(effort);
557    let mut resolved_effort = if apply_codex_config {
558        resolve_effort(codex_effort)?
559    } else {
560        codex_effort
561    };
562    if apply_codex_config
563        && is_compact
564        && let Some(cap) = compact_effort_cap()
565        && resolved_effort.as_ref().is_some_and(|e| *e > cap)
566    {
567        resolved_effort = Some(cap);
568    }
569    if resolved_effort.is_some() || opts.use_responses_lite {
570        let summary = if resolved_effort.is_some()
571            && (!apply_codex_config
572                || reasoning_summary_requested(config::codex_reasoning_summary().as_deref()))
573        {
574            Some("auto".to_string())
575        } else {
576            None
577        };
578        out.reasoning = Some(ResponsesReasoning {
579            effort: resolved_effort.clone(),
580            summary,
581            context: opts.use_responses_lite.then_some("all_turns".to_string()),
582        });
583    }
584    if resolved_effort.is_some() {
585        out.include = Some(vec!["reasoning.encrypted_content".to_string()]);
586    }
587
588    Ok(out)
589}
590
591// ---------------------------------------------------------------------------
592// Helpers
593// ---------------------------------------------------------------------------
594
595fn read_output_format(req: &MessagesRequest) -> Option<ResponsesTextFormat> {
596    let output_config = req.extra.get("output_config")?.as_object()?;
597    let format = output_config.get("format")?.as_object()?;
598    let kind = format.get("type")?.as_str()?;
599    match kind {
600        "json_schema" => {
601            let name = format
602                .get("name")
603                .and_then(|v| v.as_str())
604                .unwrap_or("response")
605                .to_string();
606            let schema = format.get("schema")?;
607            let normalized = normalize_strict_json_schema(schema);
608            Some(ResponsesTextFormat::JsonSchema {
609                name,
610                schema: normalized,
611                strict: Some(true),
612            })
613        }
614        "json_object" => Some(ResponsesTextFormat::JsonObject),
615        _ => Some(ResponsesTextFormat::Text),
616    }
617}
618
619fn read_tools(req: &MessagesRequest) -> Result<Option<Vec<ResponsesTool>>, anyhow::Error> {
620    let Some(tools) = req.extra.get("tools") else {
621        return Ok(None);
622    };
623    let tools_arr = match tools {
624        Value::Array(a) => a,
625        _ => return Ok(None),
626    };
627    let mut out = Vec::new();
628    for tool in tools_arr {
629        let tool_type = tool
630            .get("type")
631            .and_then(|v| v.as_str())
632            .unwrap_or("function");
633        if tool_type == "web_search_20250305" {
634            let mut filters = ResponsesWebSearchFilters {
635                allowed_domains: None,
636                blocked_domains: None,
637            };
638            let allowed = tool.get("allowed_domains").and_then(|v| v.as_array());
639            if allowed.is_some_and(|a| !a.is_empty()) {
640                filters.allowed_domains = allowed.map(|a| {
641                    a.iter()
642                        .filter_map(|v| v.as_str().map(String::from))
643                        .collect()
644                });
645            }
646            let blocked = tool.get("blocked_domains").and_then(|v| v.as_array());
647            if blocked.is_some_and(|a| !a.is_empty()) {
648                filters.blocked_domains = blocked.map(|a| {
649                    a.iter()
650                        .filter_map(|v| v.as_str().map(String::from))
651                        .collect()
652                });
653            }
654            let has_filters =
655                filters.allowed_domains.is_some() || filters.blocked_domains.is_some();
656            out.push(ResponsesTool::WebSearch(ResponsesWebSearchTool {
657                kind: "web_search".to_string(),
658                external_web_access: true,
659                search_content_types: vec!["text".to_string(), "image".to_string()],
660                filters: if has_filters { Some(filters) } else { None },
661            }));
662        } else {
663            let name = tool
664                .get("name")
665                .and_then(|v| v.as_str())
666                .unwrap_or("")
667                .to_string();
668            let description = tool
669                .get("description")
670                .and_then(|v| v.as_str())
671                .map(|s| s.to_string());
672            let parameters = tool
673                .get("input_schema")
674                .cloned()
675                .unwrap_or(serde_json::json!({}));
676            let description = codex_tool_description(&name, description);
677            let parameters = codex_tool_parameters(&name, parameters);
678            out.push(ResponsesTool::Function(ResponsesFunctionTool {
679                kind: "function".to_string(),
680                name,
681                description,
682                parameters,
683                strict: false,
684            }));
685        }
686    }
687    if out.is_empty() {
688        Ok(None)
689    } else {
690        Ok(Some(out))
691    }
692}
693
694fn codex_tool_description(name: &str, description: Option<String>) -> Option<String> {
695    if name != "Read" {
696        return description;
697    }
698
699    let base = description.unwrap_or_else(|| "Reads a file from the local filesystem.".to_string());
700    Some(format!("{base}\n\n{}", read_offset_guidance()))
701}
702
703fn codex_tool_parameters(name: &str, mut parameters: Value) -> Value {
704    if name != "Read" {
705        return parameters;
706    }
707
708    let Some(props) = parameters
709        .get_mut("properties")
710        .and_then(Value::as_object_mut)
711    else {
712        return parameters;
713    };
714
715    if let Some(offset) = props.get_mut("offset").and_then(Value::as_object_mut) {
716        offset.insert(
717            "description".to_string(),
718            Value::String(
719                "Optional continuation index. Use only after a prior Read of the same file returned content and more lines are needed. Compute as prior offset plus returned line count. Displayed line numbers, grep line numbers, byte counts, token counts, file sizes, and guessed positions are invalid offsets. Omit when unsure.".to_string(),
720            ),
721        );
722    }
723
724    if let Some(limit) = props.get_mut("limit").and_then(Value::as_object_mut) {
725        limit.insert(
726            "description".to_string(),
727            Value::String(
728                "Optional number of lines to read. Omit when opening a file. Use with offset only when continuing a large file."
729                    .to_string(),
730            ),
731        );
732    }
733
734    parameters
735}
736
737fn map_tool_choice(req: &MessagesRequest) -> Result<Option<ResponsesToolChoice>, anyhow::Error> {
738    let choice = match req.extra.get("tool_choice") {
739        Some(Value::Object(m)) => m,
740        Some(Value::String(s)) => {
741            return Ok(Some(match s.as_str() {
742                "auto" => ResponsesToolChoice::Mode(ResponsesToolChoiceMode::Auto),
743                "none" => ResponsesToolChoice::Mode(ResponsesToolChoiceMode::None),
744                "any" | "required" => ResponsesToolChoice::Mode(ResponsesToolChoiceMode::Required),
745                _ => ResponsesToolChoice::Mode(ResponsesToolChoiceMode::Auto),
746            }));
747        }
748        _ => return Ok(None),
749    };
750
751    let choice_type = choice
752        .get("type")
753        .and_then(|v| v.as_str())
754        .unwrap_or("auto");
755    match choice_type {
756        "auto" => Ok(Some(ResponsesToolChoice::Mode(
757            ResponsesToolChoiceMode::Auto,
758        ))),
759        "none" => Ok(Some(ResponsesToolChoice::Mode(
760            ResponsesToolChoiceMode::None,
761        ))),
762        "any" | "required" => Ok(Some(ResponsesToolChoice::Mode(
763            ResponsesToolChoiceMode::Required,
764        ))),
765        "tool" => {
766            let name = choice.get("name").and_then(|v| v.as_str()).unwrap_or("");
767            let tools = req.extra.get("tools").and_then(|v| v.as_array());
768            let is_web_search = tools.is_some_and(|t| {
769                t.iter().any(|tool| {
770                    (tool.get("type").and_then(|v| v.as_str()) == Some("web_search_20250305"))
771                        && tool.get("name").and_then(|v| v.as_str()) == Some(name)
772                })
773            });
774            if is_web_search {
775                Ok(Some(ResponsesToolChoice::AllowedTools {
776                    r#type: "allowed_tools".to_string(),
777                    mode: "required".to_string(),
778                    tools: vec![serde_json::json!({"type": "web_search"})],
779                }))
780            } else {
781                Ok(Some(ResponsesToolChoice::Function {
782                    r#type: "function".to_string(),
783                    name: name.to_string(),
784                }))
785            }
786        }
787        _ => Ok(None),
788    }
789}
790
791fn build_input(req: &MessagesRequest) -> Vec<ResponsesInputItem> {
792    let mut out: Vec<ResponsesInputItem> = Vec::new();
793    let mut read_tool_uses_with_offset = HashSet::new();
794
795    for msg in &req.messages {
796        let blocks = normalize_content(&msg.content, Value::Null);
797        match msg.role.as_str() {
798            "user" => {
799                let mut parts: Vec<ResponsesContentPart> = Vec::new();
800                for block in &blocks {
801                    match block {
802                        ContentBlock::Text { text } => {
803                            parts.push(ResponsesContentPart::InputText { text: text.clone() });
804                        }
805                        ContentBlock::Image { source } => {
806                            parts.push(ResponsesContentPart::InputImage {
807                                image_url: image_source_to_url(source),
808                                detail: None,
809                            });
810                        }
811                        ContentBlock::ToolResult {
812                            tool_use_id,
813                            content,
814                            is_error,
815                        } => {
816                            if !parts.is_empty() {
817                                out.push(ResponsesInputItem::Message {
818                                    role: "user".to_string(),
819                                    content: std::mem::take(&mut parts),
820                                });
821                            }
822                            let mut rendered = render_tool_result(content);
823                            if is_error.unwrap_or(false) {
824                                rendered.prepend_text("[tool execution error]".to_string());
825                            }
826                            if let Some(note) =
827                                rewritten_read_offset_note(&rendered.joined_text(), tool_use_id)
828                            {
829                                rendered.push_text(format!("\n{note}"));
830                            }
831                            if should_append_read_offset_guidance(
832                                &rendered.joined_text(),
833                                read_tool_uses_with_offset.contains(tool_use_id),
834                                is_error.unwrap_or(false),
835                            ) {
836                                rendered.push_text(format!("\n{}", read_offset_guidance()));
837                            }
838                            out.push(ResponsesInputItem::FunctionCallOutput {
839                                call_id: tool_use_id.clone(),
840                                output: function_call_output(rendered),
841                            });
842                        }
843                        _ => {}
844                    }
845                }
846                if !parts.is_empty() {
847                    out.push(ResponsesInputItem::Message {
848                        role: "user".to_string(),
849                        content: parts,
850                    });
851                }
852            }
853            "system" => {
854                let parts: Vec<ResponsesContentPart> = blocks
855                    .iter()
856                    .filter_map(|b| match b {
857                        ContentBlock::Text { text } => {
858                            Some(ResponsesContentPart::InputText { text: text.clone() })
859                        }
860                        _ => None,
861                    })
862                    .collect();
863                if !parts.is_empty() {
864                    out.push(ResponsesInputItem::Message {
865                        role: "developer".to_string(),
866                        content: parts,
867                    });
868                }
869            }
870            _ => {
871                let mut text_parts: Vec<ResponsesContentPart> = Vec::new();
872                let flush_text =
873                    |out: &mut Vec<ResponsesInputItem>,
874                     text_parts: &mut Vec<ResponsesContentPart>| {
875                        if !text_parts.is_empty() {
876                            out.push(ResponsesInputItem::Message {
877                                role: "assistant".to_string(),
878                                content: std::mem::take(text_parts),
879                            });
880                        }
881                    };
882                for block in &blocks {
883                    match block {
884                        ContentBlock::Text { text } => {
885                            text_parts
886                                .push(ResponsesContentPart::OutputText { text: text.clone() });
887                        }
888                        ContentBlock::ToolUse { id, name, input } => {
889                            flush_text(&mut out, &mut text_parts);
890                            if is_read_tool_use_with_offset(name, input) {
891                                read_tool_uses_with_offset.insert(id.clone());
892                            }
893                            let args =
894                                serde_json::to_string(input).unwrap_or_else(|_| "{}".to_string());
895                            out.push(ResponsesInputItem::FunctionCall {
896                                call_id: id.clone(),
897                                name: name.clone(),
898                                arguments: args,
899                            });
900                        }
901                        ContentBlock::Thinking {
902                            thinking,
903                            signature,
904                        } => {
905                            if let Some(replay) =
906                                signature.as_deref().and_then(decode_reasoning_signature)
907                            {
908                                flush_text(&mut out, &mut text_parts);
909                                out.push(ResponsesInputItem::Reasoning {
910                                    id: replay.id,
911                                    summary: Vec::new(),
912                                    encrypted_content: replay.encrypted_content,
913                                });
914                            } else if !thinking.is_empty() {
915                                text_parts.push(ResponsesContentPart::OutputText {
916                                    text: wrap_reasoning(thinking),
917                                });
918                            }
919                        }
920                        _ => {}
921                    }
922                }
923                flush_text(&mut out, &mut text_parts);
924            }
925        }
926    }
927
928    out
929}
930
931fn is_read_tool_use_with_offset(name: &str, input: &Value) -> bool {
932    name == "Read" && input.get("offset").is_some()
933}
934
935fn rewritten_read_offset_note(output: &str, tool_use_id: &str) -> Option<String> {
936    if output.contains("Proxy Read offset note:") {
937        return None;
938    }
939    read_offset_rewrite(tool_use_id)
940        .as_ref()
941        .map(read_offset_rewrite_note)
942}
943
944fn read_offset_rewrite_note(rewrite: &ReadOffsetRewrite) -> String {
945    let file = rewrite
946        .file_path
947        .as_deref()
948        .map(|path| format!(" for {path}"))
949        .unwrap_or_default();
950    format!(
951        "Proxy Read offset note:\n\
952         - Requested Read offset {}{} exceeds the proxy rewrite threshold of 1000000.\n\
953         - This Read starts at the beginning of the file.\n\
954         - For continuation reads, use offset after a prior Read of the same file returned content and more lines are needed.\n\
955         - Compute offset as prior offset plus the number of lines returned by that prior Read.",
956        rewrite.offset, file
957    )
958}
959
960fn should_append_read_offset_guidance(
961    output: &str,
962    read_call_had_offset: bool,
963    is_error: bool,
964) -> bool {
965    read_call_had_offset
966        && !output.contains("Codex Read guidance:")
967        && looks_like_read_offset_result(output)
968        && (is_error || looks_like_read_offset_warning(output))
969}
970
971fn looks_like_read_offset_result(output: &str) -> bool {
972    let lower = output.to_ascii_lowercase();
973    lower.contains("offset")
974        && (lower.contains("file has")
975            || lower.contains("out of range")
976            || (lower.contains("line") && lower.contains("requested")))
977}
978
979fn looks_like_read_offset_warning(output: &str) -> bool {
980    let lower = output.to_ascii_lowercase();
981    lower.contains("warning") || lower.contains("system-reminder")
982}
983
984fn read_offset_guidance() -> &'static str {
985    "Codex Read guidance:\n\
986     - offset is an optional zero based continuation index, not a line number lookup.\n\
987     - Use offset only after a prior Read of the same file returned content and more lines are needed.\n\
988     - Compute offset as prior offset plus the number of lines returned by that prior Read.\n\
989     - Displayed line numbers, grep line numbers, byte counts, token counts, file sizes, and guessed positions are invalid offsets.\n\
990     - Omit offset and limit when opening a file or when unsure."
991}
992
993// ---------------------------------------------------------------------------
994// Tool result rendering
995// ---------------------------------------------------------------------------
996
997enum RenderedToolResultPart {
998    Text(String),
999    Image(String),
1000}
1001
1002struct RenderedToolResult {
1003    parts: Vec<RenderedToolResultPart>,
1004}
1005
1006impl RenderedToolResult {
1007    fn has_images(&self) -> bool {
1008        self.parts
1009            .iter()
1010            .any(|part| matches!(part, RenderedToolResultPart::Image(_)))
1011    }
1012
1013    fn joined_text(&self) -> String {
1014        self.parts
1015            .iter()
1016            .filter_map(|part| match part {
1017                RenderedToolResultPart::Text(text) => Some(text.as_str()),
1018                RenderedToolResultPart::Image(_) => None,
1019            })
1020            .collect::<Vec<_>>()
1021            .join("\n")
1022    }
1023
1024    fn prepend_text(&mut self, text: String) {
1025        self.parts.insert(0, RenderedToolResultPart::Text(text));
1026    }
1027
1028    fn push_text(&mut self, text: String) {
1029        self.parts.push(RenderedToolResultPart::Text(text));
1030    }
1031}
1032
1033fn render_tool_result(content: &Value) -> RenderedToolResult {
1034    let parts = match content {
1035        Value::String(text) => vec![RenderedToolResultPart::Text(text.clone())],
1036        Value::Array(blocks) => blocks.iter().map(render_tool_result_block).collect(),
1037        _ => Vec::new(),
1038    };
1039    RenderedToolResult { parts }
1040}
1041
1042fn render_tool_result_block(block: &Value) -> RenderedToolResultPart {
1043    match block.get("type").and_then(Value::as_str) {
1044        Some("text") => block
1045            .get("text")
1046            .and_then(Value::as_str)
1047            .map(|text| RenderedToolResultPart::Text(text.to_string()))
1048            .unwrap_or_else(|| {
1049                RenderedToolResultPart::Text(unsupported_tool_result_block_to_string(block))
1050            }),
1051        Some("image") => render_tool_result_image(block),
1052        Some(other) => {
1053            RenderedToolResultPart::Text(format!("[unsupported content block omitted: {other}]"))
1054        }
1055        None => RenderedToolResultPart::Text(unsupported_tool_result_block_to_string(block)),
1056    }
1057}
1058
1059fn render_tool_result_image(block: &Value) -> RenderedToolResultPart {
1060    let Some(source) = block.get("source").and_then(Value::as_object) else {
1061        return RenderedToolResultPart::Text(unsupported_tool_result_block_to_string(block));
1062    };
1063    match source.get("type").and_then(Value::as_str) {
1064        Some("url") if source.get("url").and_then(Value::as_str).is_some() => {
1065            RenderedToolResultPart::Text("[image omitted: url]".to_string())
1066        }
1067        Some("base64") => {
1068            let media_type = source.get("media_type").and_then(Value::as_str);
1069            let data = source.get("data").and_then(Value::as_str);
1070            match media_type
1071                .zip(data)
1072                .and_then(|(media_type, data)| validated_image_data_url(media_type, data))
1073            {
1074                Some(image_url) => RenderedToolResultPart::Image(image_url),
1075                None => {
1076                    RenderedToolResultPart::Text(unsupported_tool_result_block_to_string(block))
1077                }
1078            }
1079        }
1080        _ => RenderedToolResultPart::Text(unsupported_tool_result_block_to_string(block)),
1081    }
1082}
1083
1084fn validated_image_data_url(media_type: &str, data: &str) -> Option<String> {
1085    if !matches!(
1086        media_type,
1087        "image/jpeg" | "image/png" | "image/gif" | "image/webp"
1088    ) {
1089        return None;
1090    }
1091
1092    let compact: String = data
1093        .chars()
1094        .filter(|character| !character.is_ascii_whitespace())
1095        .collect();
1096    if compact.is_empty() {
1097        return None;
1098    }
1099    let decoded = base64::engine::general_purpose::STANDARD
1100        .decode(&compact)
1101        .or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(&compact))
1102        .ok()?;
1103    let canonical = base64::engine::general_purpose::STANDARD.encode(decoded);
1104    Some(format!("data:{media_type};base64,{canonical}"))
1105}
1106
1107fn function_call_output(rendered: RenderedToolResult) -> ResponsesFunctionCallOutput {
1108    if !rendered.has_images() {
1109        return ResponsesFunctionCallOutput::Text(rendered.joined_text());
1110    }
1111
1112    ResponsesFunctionCallOutput::ContentItems(
1113        rendered
1114            .parts
1115            .into_iter()
1116            .map(|part| match part {
1117                RenderedToolResultPart::Text(text) => {
1118                    ResponsesFunctionCallOutputContentPart::InputText { text }
1119                }
1120                RenderedToolResultPart::Image(image_url) => {
1121                    ResponsesFunctionCallOutputContentPart::InputImage {
1122                        image_url,
1123                        detail: None,
1124                    }
1125                }
1126            })
1127            .collect(),
1128    )
1129}
1130
1131fn unsupported_tool_result_block_to_string(block: &Value) -> String {
1132    let kind = block
1133        .get("type")
1134        .and_then(|v| v.as_str())
1135        .unwrap_or("unknown");
1136    format!("[unsupported content block omitted: {kind}]")
1137}
1138
1139#[cfg(test)]
1140mod tests {
1141    use super::*;
1142    use serde_json::json;
1143
1144    const PNG_BASE64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
1145
1146    fn opts() -> TranslateOptions {
1147        TranslateOptions {
1148            session_id: None,
1149            service_tier: None,
1150            model: "gpt-5.5".to_string(),
1151            use_responses_lite: false,
1152        }
1153    }
1154
1155    #[test]
1156    fn responses_tool_choice_modes_serialize_as_openai_strings() {
1157        for (mode, expected) in [
1158            (ResponsesToolChoiceMode::Auto, json!("auto")),
1159            (ResponsesToolChoiceMode::None, json!("none")),
1160            (ResponsesToolChoiceMode::Required, json!("required")),
1161        ] {
1162            assert_eq!(
1163                serde_json::to_value(ResponsesToolChoice::Mode(mode)).unwrap(),
1164                expected
1165            );
1166        }
1167    }
1168
1169    #[test]
1170    fn translate_tool_choice_preserves_wire_and_parallel_semantics() {
1171        for (tool_choice, expected_choice, expected_parallel) in [
1172            (json!({"type":"auto"}), json!("auto"), true),
1173            (
1174                json!({"type":"auto","disable_parallel_tool_use":false}),
1175                json!("auto"),
1176                true,
1177            ),
1178            (json!({"type":"none"}), json!("none"), true),
1179            (json!({"type":"any"}), json!("required"), true),
1180            (
1181                json!({"type":"any","disable_parallel_tool_use":true}),
1182                json!("required"),
1183                false,
1184            ),
1185            (
1186                json!({
1187                    "type":"tool",
1188                    "name":"test",
1189                    "disable_parallel_tool_use":true
1190                }),
1191                json!({"type":"function","name":"test"}),
1192                false,
1193            ),
1194        ] {
1195            let req: MessagesRequest = serde_json::from_value(json!({
1196                "model": "gpt-5.5",
1197                "messages": [{"role":"user", "content":"use the tool"}],
1198                "tools": [{
1199                    "name":"test",
1200                    "input_schema":{"type":"object","properties":{}}
1201                }],
1202                "tool_choice": tool_choice
1203            }))
1204            .unwrap();
1205            let wire = serde_json::to_value(translate_request(&req, opts()).unwrap()).unwrap();
1206
1207            assert_eq!(wire["tool_choice"], expected_choice);
1208            assert_eq!(wire["parallel_tool_calls"], expected_parallel);
1209        }
1210    }
1211
1212    #[test]
1213    fn responses_lite_keeps_parallel_tool_calls_disabled() {
1214        let req: MessagesRequest = serde_json::from_value(json!({
1215            "model": "gpt-5.6-luna",
1216            "messages": [{"role":"user", "content":"use the tool"}],
1217            "tools": [{
1218                "name":"test",
1219                "input_schema":{"type":"object","properties":{}}
1220            }],
1221            "tool_choice": {
1222                "type":"any",
1223                "disable_parallel_tool_use":false
1224            }
1225        }))
1226        .unwrap();
1227        let wire = serde_json::to_value(
1228            translate_request(
1229                &req,
1230                TranslateOptions {
1231                    model: "gpt-5.6-luna".to_string(),
1232                    use_responses_lite: true,
1233                    ..opts()
1234                },
1235            )
1236            .unwrap(),
1237        )
1238        .unwrap();
1239
1240        assert_eq!(wire["tool_choice"], json!("required"));
1241        assert_eq!(wire["parallel_tool_calls"], false);
1242    }
1243
1244    #[test]
1245    fn translate_web_search_tool_to_codex_tool() {
1246        let req: MessagesRequest = serde_json::from_value(json!({
1247            "model": "gpt-5.5",
1248            "messages": [{"role":"user", "content":"find it"}],
1249            "tools": [{
1250                "type":"web_search_20250305",
1251                "name":"web_search",
1252                "allowed_domains":["example.com"]
1253            }],
1254            "tool_choice": {"type":"tool", "name":"web_search"}
1255        }))
1256        .unwrap();
1257        let out = translate_request(
1258            &req,
1259            TranslateOptions {
1260                session_id: Some("s".into()),
1261                service_tier: None,
1262                model: "gpt-5.5".to_string(),
1263                use_responses_lite: false,
1264            },
1265        )
1266        .unwrap();
1267        assert_eq!(out.prompt_cache_key.as_deref(), Some("s"));
1268        assert!(matches!(
1269            out.tool_choice,
1270            Some(ResponsesToolChoice::AllowedTools { .. })
1271        ));
1272        let tool_choice = serde_json::to_value(out.tool_choice.as_ref().unwrap()).unwrap();
1273        assert_eq!(tool_choice["type"], "allowed_tools");
1274        assert_eq!(tool_choice["mode"], "required");
1275        assert_eq!(tool_choice["tools"], json!([{"type":"web_search"}]));
1276        let ResponsesTool::WebSearch(tool) = &out.tools.as_ref().unwrap()[0] else {
1277            panic!("expected web_search tool");
1278        };
1279        assert!(tool.external_web_access);
1280        assert_eq!(
1281            tool.filters.as_ref().unwrap().allowed_domains.as_deref(),
1282            Some(&["example.com".to_string()][..])
1283        );
1284        assert!(out.instructions.is_none());
1285    }
1286
1287    #[test]
1288    fn automatic_filtered_web_search_keeps_native_filters() {
1289        let req: MessagesRequest = serde_json::from_value(json!({
1290            "model": "gpt-5.5",
1291            "messages": [{"role":"user", "content":"find it"}],
1292            "tools": [{
1293                "type":"web_search_20250305",
1294                "name":"web_search",
1295                "allowed_domains":["example.com"],
1296                "blocked_domains":["spam.example"]
1297            }],
1298            "tool_choice": {"type":"auto"}
1299        }))
1300        .unwrap();
1301        let out = translate_request(&req, opts()).unwrap();
1302        let ResponsesTool::WebSearch(tool) = &out.tools.as_ref().unwrap()[0] else {
1303            panic!("expected web_search tool");
1304        };
1305        assert!(tool.external_web_access);
1306        let filters = tool.filters.as_ref().unwrap();
1307        assert_eq!(
1308            filters.allowed_domains.as_deref(),
1309            Some(&["example.com".to_string()][..])
1310        );
1311        assert_eq!(
1312            filters.blocked_domains.as_deref(),
1313            Some(&["spam.example".to_string()][..])
1314        );
1315        assert!(out.instructions.is_none());
1316    }
1317
1318    #[test]
1319    fn forced_filtered_web_search_keeps_native_filters() {
1320        let req: MessagesRequest = serde_json::from_value(json!({
1321            "model": "gpt-5.5",
1322            "messages": [{"role":"user", "content":"find it"}],
1323            "system": "Be brief.",
1324            "tools": [{
1325                "type":"web_search_20250305",
1326                "name":"web_search",
1327                "allowed_domains":["a.example", "b.example"],
1328                "blocked_domains":["spam.example"]
1329            }],
1330            "tool_choice": {"type":"tool", "name":"web_search"}
1331        }))
1332        .unwrap();
1333        let out = translate_request(&req, opts()).unwrap();
1334        let ResponsesTool::WebSearch(tool) = &out.tools.as_ref().unwrap()[0] else {
1335            panic!("expected web_search tool");
1336        };
1337        let filters = tool.filters.as_ref().unwrap();
1338        assert_eq!(
1339            filters.allowed_domains.as_deref(),
1340            Some(&["a.example".to_string(), "b.example".to_string()][..])
1341        );
1342        assert_eq!(
1343            filters.blocked_domains.as_deref(),
1344            Some(&["spam.example".to_string()][..])
1345        );
1346        assert_eq!(out.instructions.as_deref(), Some("Be brief."));
1347        assert!(matches!(
1348            out.tool_choice,
1349            Some(ResponsesToolChoice::AllowedTools { .. })
1350        ));
1351    }
1352
1353    #[test]
1354    fn unfiltered_web_search_adds_no_domain_instructions() {
1355        for tool_choice in [None, Some(json!({"type":"tool", "name":"web_search"}))] {
1356            let mut body = json!({
1357                "model": "gpt-5.5",
1358                "messages": [{"role":"user", "content":"find it"}],
1359                "tools": [{"type":"web_search_20250305", "name":"web_search"}]
1360            });
1361            if let Some(tool_choice) = tool_choice {
1362                body["tool_choice"] = tool_choice;
1363            }
1364            let req: MessagesRequest = serde_json::from_value(body).unwrap();
1365            let out = translate_request(&req, opts()).unwrap();
1366            let ResponsesTool::WebSearch(tool) = &out.tools.as_ref().unwrap()[0] else {
1367                panic!("expected web_search tool");
1368            };
1369            assert!(tool.external_web_access);
1370            assert!(tool.filters.is_none());
1371            assert!(out.instructions.is_none());
1372        }
1373    }
1374
1375    #[test]
1376    fn has_hosted_web_search_detects_web_search_tool() {
1377        let with: MessagesRequest = serde_json::from_value(json!({
1378            "model": "gpt-5.6-sol",
1379            "messages": [{"role":"user", "content":"find it"}],
1380            "tools": [
1381                {"name":"Bash", "input_schema":{}},
1382                {"type":"web_search_20250305", "name":"web_search"}
1383            ]
1384        }))
1385        .unwrap();
1386        assert!(has_hosted_web_search(&with));
1387
1388        let without: MessagesRequest = serde_json::from_value(json!({
1389            "model": "gpt-5.6-sol",
1390            "messages": [{"role":"user", "content":"run it"}],
1391            "tools": [{"name":"Bash", "input_schema":{}}]
1392        }))
1393        .unwrap();
1394        assert!(!has_hosted_web_search(&without));
1395    }
1396
1397    #[test]
1398    fn responses_lite_downgrades_unregistered_web_search_tool_choice() {
1399        // On the lite lane tools travel in the AdditionalTools developer
1400        // prefix, so a top-level web_search tool_choice would reference a
1401        // tool upstream doesn't know about and 502.
1402        let req: MessagesRequest = serde_json::from_value(json!({
1403            "model": "gpt-5.6-sol",
1404            "messages": [{"role":"user", "content":"find it"}],
1405            "tools": [{
1406                "type":"web_search_20250305",
1407                "name":"web_search"
1408            }],
1409            "tool_choice": {"type":"tool", "name":"web_search"}
1410        }))
1411        .unwrap();
1412        let out = translate_request(
1413            &req,
1414            TranslateOptions {
1415                session_id: None,
1416                service_tier: None,
1417                model: "gpt-5.6-sol".to_string(),
1418                use_responses_lite: true,
1419            },
1420        )
1421        .unwrap();
1422        assert!(out.tools.is_none());
1423        assert!(matches!(
1424            out.tool_choice,
1425            Some(ResponsesToolChoice::Mode(ResponsesToolChoiceMode::Auto))
1426        ));
1427        assert_eq!(
1428            serde_json::to_value(&out).unwrap()["tool_choice"],
1429            json!("auto")
1430        );
1431    }
1432
1433    #[test]
1434    fn full_lane_keeps_web_search_tool_choice_registered() {
1435        let req: MessagesRequest = serde_json::from_value(json!({
1436            "model": "gpt-5.6-sol",
1437            "messages": [{"role":"user", "content":"find it"}],
1438            "tools": [{
1439                "type":"web_search_20250305",
1440                "name":"web_search"
1441            }],
1442            "tool_choice": {"type":"tool", "name":"web_search"}
1443        }))
1444        .unwrap();
1445        let out = translate_request(
1446            &req,
1447            TranslateOptions {
1448                session_id: None,
1449                service_tier: None,
1450                model: "gpt-5.6-sol".to_string(),
1451                use_responses_lite: false,
1452            },
1453        )
1454        .unwrap();
1455        assert!(out.tools.as_ref().is_some_and(|t| {
1456            t.iter()
1457                .any(|tool| matches!(tool, ResponsesTool::WebSearch(_)))
1458        }));
1459        assert!(matches!(
1460            out.tool_choice,
1461            Some(ResponsesToolChoice::AllowedTools { .. })
1462        ));
1463    }
1464
1465    #[test]
1466    fn translate_read_tool_adds_codex_offset_guidance() {
1467        let req: MessagesRequest = serde_json::from_value(json!({
1468            "model": "gpt-5.5",
1469            "messages": [{"role":"user", "content":"read it"}],
1470            "tools": [{
1471                "name": "Read",
1472                "description": "Reads a file from the local filesystem.",
1473                "input_schema": {
1474                    "type": "object",
1475                    "properties": {
1476                        "file_path": {"type": "string"},
1477                        "offset": {"type": "integer", "description": "old offset"},
1478                        "limit": {"type": "integer", "description": "old limit"}
1479                    },
1480                    "required": ["file_path"]
1481                }
1482            }]
1483        }))
1484        .unwrap();
1485        let out = translate_request(&req, opts()).unwrap();
1486        let tools = out.tools.as_ref().unwrap();
1487        let ResponsesTool::Function(tool) = &tools[0] else {
1488            panic!("expected function tool");
1489        };
1490        let description = tool.description.as_deref().unwrap();
1491        assert!(description.contains("Codex Read guidance"));
1492        assert!(description.contains("zero based continuation index"));
1493        assert!(description.contains("guessed positions are invalid offsets"));
1494
1495        let props = tool
1496            .parameters
1497            .get("properties")
1498            .and_then(Value::as_object)
1499            .unwrap();
1500        assert_eq!(
1501            props
1502                .get("offset")
1503                .and_then(|v| v.get("description"))
1504                .and_then(Value::as_str),
1505            Some(
1506                "Optional continuation index. Use only after a prior Read of the same file returned content and more lines are needed. Compute as prior offset plus returned line count. Displayed line numbers, grep line numbers, byte counts, token counts, file sizes, and guessed positions are invalid offsets. Omit when unsure."
1507            )
1508        );
1509        assert_eq!(
1510            props
1511                .get("limit")
1512                .and_then(|v| v.get("description"))
1513                .and_then(Value::as_str),
1514            Some(
1515                "Optional number of lines to read. Omit when opening a file. Use with offset only when continuing a large file."
1516            )
1517        );
1518    }
1519
1520    #[test]
1521    fn translate_non_read_tool_preserves_tool_metadata() {
1522        let req: MessagesRequest = serde_json::from_value(json!({
1523            "model": "gpt-5.5",
1524            "messages": [{"role":"user", "content":"search"}],
1525            "tools": [{
1526                "name": "Search",
1527                "description": "Find matching records.",
1528                "input_schema": {
1529                    "type": "object",
1530                    "properties": {
1531                        "offset": {"type": "integer", "description": "record offset"}
1532                    }
1533                }
1534            }]
1535        }))
1536        .unwrap();
1537        let out = translate_request(&req, opts()).unwrap();
1538        let tools = out.tools.as_ref().unwrap();
1539        let ResponsesTool::Function(tool) = &tools[0] else {
1540            panic!("expected function tool");
1541        };
1542        assert_eq!(tool.description.as_deref(), Some("Find matching records."));
1543        assert!(!tool.strict);
1544        assert_eq!(
1545            serde_json::to_value(tool).unwrap()["strict"],
1546            Value::Bool(false)
1547        );
1548        assert_eq!(
1549            tool.parameters
1550                .get("properties")
1551                .and_then(|v| v.get("offset"))
1552                .and_then(|v| v.get("description"))
1553                .and_then(Value::as_str),
1554            Some("record offset")
1555        );
1556    }
1557
1558    #[test]
1559    fn translate_omits_reasoning_when_not_enabled() {
1560        let req: MessagesRequest = serde_json::from_value(json!({
1561            "model": "gpt-5.5",
1562            "messages": [{"role":"user", "content":"hello"}]
1563        }))
1564        .unwrap();
1565        let out = translate_request(&req, opts()).unwrap();
1566        assert!(out.reasoning.is_none());
1567        assert!(out.include.is_none());
1568    }
1569
1570    #[test]
1571    fn translate_includes_reasoning_when_enabled() {
1572        let req: MessagesRequest = serde_json::from_value(json!({
1573            "model": "gpt-5.5",
1574            "messages": [{"role":"user", "content":"hello"}],
1575            "output_config": {"effort": "medium"}
1576        }))
1577        .unwrap();
1578        let out = translate_request(&req, opts()).unwrap();
1579        let reasoning = out.reasoning.unwrap();
1580        assert!(matches!(reasoning.effort, Some(Effort::Medium)));
1581        assert_eq!(reasoning.summary.as_deref(), Some("auto"));
1582        assert_eq!(
1583            out.include,
1584            Some(vec!["reasoning.encrypted_content".to_string()])
1585        );
1586    }
1587
1588    #[test]
1589    fn translate_effort_max_maps_to_max() {
1590        let req: MessagesRequest = serde_json::from_value(json!({
1591            "model": "gpt-5.5",
1592            "messages": [{"role":"user", "content":"hello"}],
1593            "output_config": {"effort": "max"}
1594        }))
1595        .unwrap();
1596        let out = translate_request(&req, opts()).unwrap();
1597        assert!(matches!(out.reasoning.unwrap().effort, Some(Effort::Max)));
1598    }
1599
1600    #[test]
1601    fn translate_effort_override_max_maps_to_max() {
1602        let effort = resolve_effort_override(Some(Effort::Low), Some("max")).unwrap();
1603        assert!(matches!(effort, Some(Effort::Max)));
1604    }
1605
1606    #[test]
1607    fn compact_request_detected_from_system_marker() {
1608        assert!(is_compact_request(Some(
1609            "You are a helpful AI assistant tasked with summarizing conversations."
1610        )));
1611        assert!(!is_compact_request(Some("You are Claude Code.")));
1612        assert!(!is_compact_request(None));
1613    }
1614
1615    #[test]
1616    fn compact_request_detected_from_final_user_message() {
1617        let req: MessagesRequest = serde_json::from_value(json!({
1618            "model": "gpt-5.6-sol",
1619            "messages": [
1620                {"role": "user", "content": "prior turn"},
1621                {
1622                    "role": "user",
1623                    "content": [
1624                        {
1625                            "type": "tool_result",
1626                            "tool_use_id": "tool-1",
1627                            "content": "result"
1628                        },
1629                        {
1630                            "type": "text",
1631                            "text": concat!(
1632                                "CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.\n\n",
1633                                "Your task is to create a detailed summary of the conversation so far, ",
1634                                "paying close attention to the user's explicit requests."
1635                            )
1636                        }
1637                    ]
1638                }
1639            ],
1640            "system": "You are Claude Code."
1641        }))
1642        .unwrap();
1643
1644        assert!(is_compact_messages_request(&req));
1645    }
1646
1647    #[test]
1648    fn compact_message_markers_must_be_in_final_user_message() {
1649        let req: MessagesRequest = serde_json::from_value(json!({
1650            "model": "gpt-5.6-sol",
1651            "messages": [
1652                {
1653                    "role": "user",
1654                    "content": concat!(
1655                        "CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.\n",
1656                        "Your task is to create a detailed summary of the conversation so far."
1657                    )
1658                },
1659                {"role": "user", "content": "continue normally"}
1660            ],
1661            "system": "You are Claude Code."
1662        }))
1663        .unwrap();
1664
1665        assert!(!is_compact_messages_request(&req));
1666    }
1667
1668    #[test]
1669    fn compact_message_requires_both_markers() {
1670        let req: MessagesRequest = serde_json::from_value(json!({
1671            "model": "gpt-5.6-sol",
1672            "messages": [{
1673                "role": "user",
1674                "content": "Your task is to create a detailed summary of the conversation so far."
1675            }],
1676            "system": "You are Claude Code."
1677        }))
1678        .unwrap();
1679
1680        assert!(!is_compact_messages_request(&req));
1681    }
1682
1683    #[test]
1684    fn compact_effort_cap_parses_env_values() {
1685        assert!(matches!(compact_effort_cap_from(None), Some(Effort::Low)));
1686        assert!(matches!(
1687            compact_effort_cap_from(Some("")),
1688            Some(Effort::Low)
1689        ));
1690        assert!(compact_effort_cap_from(Some("off")).is_none());
1691        assert!(matches!(
1692            compact_effort_cap_from(Some("none")),
1693            Some(Effort::None)
1694        ));
1695        assert!(matches!(
1696            compact_effort_cap_from(Some("medium")),
1697            Some(Effort::Medium)
1698        ));
1699        // Unrecognized values fall back to the safe default.
1700        assert!(matches!(
1701            compact_effort_cap_from(Some("bogus")),
1702            Some(Effort::Low)
1703        ));
1704    }
1705
1706    #[test]
1707    fn compact_request_downgrades_effort_to_cap() {
1708        let req: MessagesRequest = serde_json::from_value(json!({
1709            "model": "gpt-5.5",
1710            "messages": [{"role":"user", "content":"summarize"}],
1711            "system": "You are a helpful AI assistant tasked with summarizing conversations.",
1712            "output_config": {"effort": "medium"}
1713        }))
1714        .unwrap();
1715        let out = translate_request(&req, opts()).unwrap();
1716        assert!(matches!(out.reasoning.unwrap().effort, Some(Effort::Low)));
1717    }
1718
1719    #[test]
1720    fn compact_cap_never_raises_effort() {
1721        // A compact request already at or below the cap is left alone.
1722        let req: MessagesRequest = serde_json::from_value(json!({
1723            "model": "gpt-5.5",
1724            "messages": [{"role":"user", "content":"summarize"}],
1725            "system": "You are a helpful AI assistant tasked with summarizing conversations.",
1726            "output_config": {"effort": "low"}
1727        }))
1728        .unwrap();
1729        let out = translate_request(&req, opts()).unwrap();
1730        assert!(matches!(out.reasoning.unwrap().effort, Some(Effort::Low)));
1731    }
1732
1733    #[test]
1734    fn non_compact_request_keeps_requested_effort() {
1735        let req: MessagesRequest = serde_json::from_value(json!({
1736            "model": "gpt-5.5",
1737            "messages": [{"role":"user", "content":"hello"}],
1738            "system": "You are Claude Code.",
1739            "output_config": {"effort": "high"}
1740        }))
1741        .unwrap();
1742        let out = translate_request(&req, opts()).unwrap();
1743        assert!(matches!(out.reasoning.unwrap().effort, Some(Effort::High)));
1744    }
1745
1746    #[test]
1747    fn effort_ordering_matches_variant_order() {
1748        assert!(Effort::None < Effort::Low);
1749        assert!(Effort::Low < Effort::Medium);
1750        assert!(Effort::Medium < Effort::High);
1751        assert!(Effort::High < Effort::Xhigh);
1752        assert!(Effort::Xhigh < Effort::Max);
1753    }
1754
1755    #[test]
1756    fn max_tokens_is_not_serialized_for_codex() {
1757        let req: MessagesRequest = serde_json::from_value(json!({
1758            "model": "gpt-5.5",
1759            "max_tokens": 4096,
1760            "messages": [{"role":"user", "content":"hello"}]
1761        }))
1762        .unwrap();
1763        let out = translate_request(&req, opts()).unwrap();
1764        let value = serde_json::to_value(out).unwrap();
1765        assert!(value.get("max_output_tokens").is_none());
1766    }
1767
1768    #[test]
1769    fn translate_effort_xhigh_maps_to_xhigh() {
1770        let req: MessagesRequest = serde_json::from_value(json!({
1771            "model": "gpt-5.5",
1772            "messages": [{"role":"user", "content":"hello"}],
1773            "output_config": {"effort": "xhigh"}
1774        }))
1775        .unwrap();
1776        let out = translate_request(&req, opts()).unwrap();
1777        assert!(matches!(out.reasoning.unwrap().effort, Some(Effort::Xhigh)));
1778        assert_eq!(
1779            out.include,
1780            Some(vec!["reasoning.encrypted_content".to_string()])
1781        );
1782    }
1783
1784    #[test]
1785    fn reasoning_summary_override_values() {
1786        assert!(reasoning_summary_requested(None));
1787        assert!(reasoning_summary_requested(Some("auto")));
1788        assert!(reasoning_summary_requested(Some("detailed")));
1789        assert!(!reasoning_summary_requested(Some("off")));
1790        assert!(!reasoning_summary_requested(Some("none")));
1791    }
1792
1793    #[test]
1794    fn translate_user_text_and_image() {
1795        let req: MessagesRequest = serde_json::from_value(json!({
1796            "model": "gpt-5.5",
1797            "messages": [{"role":"user", "content": [
1798                {"type":"text", "text":"describe"},
1799                {"type":"image", "source": {"type":"base64", "media_type":"image/jpeg", "data":"xyz"}}
1800            ]}]
1801        }))
1802        .unwrap();
1803        let out = translate_request(&req, opts()).unwrap();
1804        assert_eq!(out.input.len(), 1);
1805        if let ResponsesInputItem::Message { role, content } = &out.input[0] {
1806            assert_eq!(role, "user");
1807            assert_eq!(content.len(), 2);
1808        } else {
1809            panic!("expected Message");
1810        }
1811    }
1812
1813    #[test]
1814    fn translate_assistant_with_text_and_tool_use() {
1815        let req: MessagesRequest = serde_json::from_value(json!({
1816            "model": "gpt-5.5",
1817            "messages": [{"role":"assistant", "content": [
1818                {"type":"text", "text":"answer"},
1819                {"type":"tool_use", "id":"tu_1", "name":"search", "input": {"q":"rust"}}
1820            ]}]
1821        }))
1822        .unwrap();
1823        let out = translate_request(&req, opts()).unwrap();
1824        assert_eq!(out.input.len(), 2);
1825    }
1826
1827    #[test]
1828    fn translate_assistant_thinking_becomes_tagged_reasoning() {
1829        // Symmetric with the anthropic passthrough: on an opus->codex switch a replayed
1830        // thinking block has no Responses container, so it is carried as tagged text
1831        // rather than dropped.
1832        use crate::providers::translate_shared::{REASONING_CLOSE, REASONING_OPEN};
1833        let req: MessagesRequest = serde_json::from_value(json!({
1834            "model": "gpt-5.5",
1835            "messages": [{"role":"assistant", "content": [
1836                {"type":"thinking", "thinking":"opus reasoning", "signature":"sig"},
1837                {"type":"text", "text":"the answer"}
1838            ]}]
1839        }))
1840        .unwrap();
1841        let out = translate_request(&req, opts()).unwrap();
1842        assert_eq!(out.input.len(), 1);
1843        let ResponsesInputItem::Message { role, content } = &out.input[0] else {
1844            panic!("expected Message");
1845        };
1846        assert_eq!(role, "assistant");
1847        assert_eq!(content.len(), 2);
1848        let ResponsesContentPart::OutputText { text: reasoning } = &content[0] else {
1849            panic!("expected reasoning OutputText");
1850        };
1851        assert!(reasoning.starts_with(REASONING_OPEN), "{reasoning}");
1852        assert!(reasoning.contains("opus reasoning"), "{reasoning}");
1853        assert!(reasoning.ends_with(REASONING_CLOSE), "{reasoning}");
1854        let ResponsesContentPart::OutputText { text: answer } = &content[1] else {
1855            panic!("expected answer OutputText");
1856        };
1857        assert_eq!(answer, "the answer");
1858    }
1859
1860    #[test]
1861    fn translate_strict_json_schema_normalization() {
1862        let req: MessagesRequest = serde_json::from_value(json!({
1863            "model": "gpt-5.5",
1864            "messages": [{"role":"user", "content":"hi"}],
1865            "output_config": {"format": {
1866                "type": "json_schema",
1867                "schema": {
1868                    "type": "object",
1869                    "properties": {"ok": {"type": "boolean"}, "reason": {"type": "string"}},
1870                    "required": ["ok"]
1871                }
1872            }}
1873        }))
1874        .unwrap();
1875        let out = translate_request(&req, opts()).unwrap();
1876        if let Some(ResponsesTextFormat::JsonSchema { schema, .. }) = &out.text.format {
1877            let required = schema.get("required").and_then(|v| v.as_array()).unwrap();
1878            assert!(required.iter().any(|v| v == "ok"));
1879            assert!(required.iter().any(|v| v == "reason"));
1880        } else {
1881            panic!("expected JsonSchema format");
1882        }
1883    }
1884
1885    #[test]
1886    fn translate_tool_result_content() {
1887        let req: MessagesRequest = serde_json::from_value(json!({
1888            "model": "gpt-5.5",
1889            "messages": [{"role":"user", "content": [{
1890                "type": "tool_result",
1891                "tool_use_id": "tu_1",
1892                "content": [{"type":"text", "text":"result"}]
1893            }]}]
1894        }))
1895        .unwrap();
1896        let out = translate_request(&req, opts()).unwrap();
1897        assert_eq!(out.input.len(), 1);
1898        if let ResponsesInputItem::FunctionCallOutput { call_id, output } = &out.input[0] {
1899            assert_eq!(call_id, "tu_1");
1900            assert_eq!(output.as_text(), Some("result"));
1901        } else {
1902            panic!("expected FunctionCallOutput");
1903        }
1904    }
1905
1906    #[test]
1907    fn translate_read_offset_error_adds_guidance() {
1908        let req: MessagesRequest = serde_json::from_value(json!({
1909            "model": "gpt-5.5",
1910            "messages": [
1911                {"role":"assistant", "content": [{
1912                    "type": "tool_use",
1913                    "id": "tu_1",
1914                    "name": "Read",
1915                    "input": {"file_path": "/tmp/a", "offset": 2952, "limit": 200}
1916                }]},
1917                {"role":"user", "content": [{
1918                    "type": "tool_result",
1919                    "tool_use_id": "tu_1",
1920                    "is_error": true,
1921                    "content": [{"type":"text", "text":"File has 331 lines, but offset 2952 was requested."}]
1922                }]}
1923            ]
1924        }))
1925        .unwrap();
1926        let out = translate_request(&req, opts()).unwrap();
1927        assert_eq!(out.input.len(), 2);
1928        if let ResponsesInputItem::FunctionCallOutput { output, .. } = &out.input[1] {
1929            let output = output.as_text().expect("text tool output");
1930            assert!(output.contains("[tool execution error]"));
1931            assert!(output.contains("File has 331 lines"));
1932            assert!(output.contains("Codex Read guidance:"));
1933            assert!(output.contains("zero based continuation index"));
1934        } else {
1935            panic!("expected FunctionCallOutput");
1936        }
1937    }
1938
1939    #[test]
1940    fn translate_read_unrelated_error_keeps_original_output() {
1941        let req: MessagesRequest = serde_json::from_value(json!({
1942            "model": "gpt-5.5",
1943            "messages": [
1944                {"role":"assistant", "content": [{
1945                    "type": "tool_use",
1946                    "id": "tu_1",
1947                    "name": "Read",
1948                    "input": {"file_path": "/tmp/a", "offset": 10, "limit": 20}
1949                }]},
1950                {"role":"user", "content": [{
1951                    "type": "tool_result",
1952                    "tool_use_id": "tu_1",
1953                    "is_error": true,
1954                    "content": [{"type":"text", "text":"File does not exist."}]
1955                }]}
1956            ]
1957        }))
1958        .unwrap();
1959        let out = translate_request(&req, opts()).unwrap();
1960        assert_eq!(out.input.len(), 2);
1961        if let ResponsesInputItem::FunctionCallOutput { output, .. } = &out.input[1] {
1962            assert_eq!(
1963                output.as_text(),
1964                Some("[tool execution error]\nFile does not exist.")
1965            );
1966        } else {
1967            panic!("expected FunctionCallOutput");
1968        }
1969    }
1970
1971    #[test]
1972    fn translate_rewritten_read_result_adds_proxy_note() {
1973        crate::providers::codex::translate::read_rewrite::sanitize_read_args(
1974            "Read",
1975            r#"{"file_path":"/tmp/a","offset":1300000,"limit":20}"#,
1976            Some("tu_rewritten_read"),
1977        );
1978        let req: MessagesRequest = serde_json::from_value(json!({
1979            "model": "gpt-5.5",
1980            "messages": [
1981                {"role":"assistant", "content": [{
1982                    "type": "tool_use",
1983                    "id": "tu_rewritten_read",
1984                    "name": "Read",
1985                    "input": {"file_path": "/tmp/a", "limit": 20}
1986                }]},
1987                {"role":"user", "content": [{
1988                    "type": "tool_result",
1989                    "tool_use_id": "tu_rewritten_read",
1990                    "content": [{"type":"text", "text":"1\tcontent"}]
1991                }]}
1992            ]
1993        }))
1994        .unwrap();
1995        let out = translate_request(&req, opts()).unwrap();
1996        assert_eq!(out.input.len(), 2);
1997        if let ResponsesInputItem::FunctionCallOutput { output, .. } = &out.input[1] {
1998            let output = output.as_text().expect("text tool output");
1999            assert!(output.contains("1\tcontent"));
2000            assert!(output.contains("Proxy Read offset note:"));
2001            assert!(output.contains("1300000"));
2002            assert!(output.contains("/tmp/a"));
2003        } else {
2004            panic!("expected FunctionCallOutput");
2005        }
2006    }
2007
2008    #[test]
2009    fn translate_read_success_with_offset_words_keeps_original_output() {
2010        let req: MessagesRequest = serde_json::from_value(json!({
2011            "model": "gpt-5.5",
2012            "messages": [
2013                {"role":"assistant", "content": [{
2014                    "type": "tool_use",
2015                    "id": "tu_1",
2016                    "name": "Read",
2017                    "input": {"file_path": "/tmp/a", "offset": 10, "limit": 20}
2018                }]},
2019                {"role":"user", "content": [{
2020                    "type": "tool_result",
2021                    "tool_use_id": "tu_1",
2022                    "content": [{"type":"text", "text":"File has 331 lines, and the requested offset is shown in this fixture."}]
2023                }]}
2024            ]
2025        }))
2026        .unwrap();
2027        let out = translate_request(&req, opts()).unwrap();
2028        assert_eq!(out.input.len(), 2);
2029        if let ResponsesInputItem::FunctionCallOutput { output, .. } = &out.input[1] {
2030            assert_eq!(
2031                output.as_text(),
2032                Some("File has 331 lines, and the requested offset is shown in this fixture.")
2033            );
2034        } else {
2035            panic!("expected FunctionCallOutput");
2036        }
2037    }
2038
2039    #[test]
2040    fn translate_tool_result_preserves_mixed_content_order() {
2041        let req: MessagesRequest = serde_json::from_value(json!({
2042            "model": "gpt-5.5",
2043            "messages": [{"role":"user", "content": [{
2044                "type": "tool_result",
2045                "tool_use_id": "tu_image",
2046                "content": [
2047                    {"type": "text", "text": "before"},
2048                    {"type": "image", "source": {
2049                        "type": "base64",
2050                        "media_type": "image/png",
2051                        "data": PNG_BASE64
2052                    }},
2053                    {"type": "text", "text": "after"}
2054                ]
2055            }]}]
2056        }))
2057        .unwrap();
2058
2059        let out = translate_request(&req, opts()).unwrap();
2060        assert_eq!(
2061            serde_json::to_value(&out.input[0]).unwrap(),
2062            json!({
2063                "type": "function_call_output",
2064                "call_id": "tu_image",
2065                "output": [
2066                    {"type": "input_text", "text": "before"},
2067                    {"type": "input_image", "image_url": format!("data:image/png;base64,{PNG_BASE64}")},
2068                    {"type": "input_text", "text": "after"}
2069                ]
2070            })
2071        );
2072    }
2073
2074    #[test]
2075    fn translate_tool_result_preserves_image_then_text_order() {
2076        let rendered = render_tool_result(&json!([
2077            {"type": "image", "source": {
2078                "type": "base64",
2079                "media_type": "image/png",
2080                "data": PNG_BASE64
2081            }},
2082            {"type": "text", "text": "caption"}
2083        ]));
2084
2085        assert_eq!(
2086            serde_json::to_value(function_call_output(rendered)).unwrap(),
2087            json!([
2088                {"type": "input_image", "image_url": format!("data:image/png;base64,{PNG_BASE64}")},
2089                {"type": "input_text", "text": "caption"}
2090            ])
2091        );
2092    }
2093
2094    #[test]
2095    fn unsupported_tool_result_images_become_in_place_text_placeholders() {
2096        let rendered = render_tool_result(&json!([
2097            {"type": "text", "text": "before"},
2098            {"type": "image", "source": {
2099                "type": "url",
2100                "url": "https://example.invalid/a.png"
2101            }},
2102            {"type": "image", "source": {
2103                "type": "base64",
2104                "media_type": "text/plain",
2105                "data": "aGVsbG8="
2106            }},
2107            {"type": "image", "source": {
2108                "type": "base64",
2109                "media_type": "image/png",
2110                "data": "not base64"
2111            }},
2112            {"type": "text", "text": "after"}
2113        ]));
2114
2115        assert_eq!(
2116            serde_json::to_value(function_call_output(rendered)).unwrap(),
2117            json!(
2118                "before\n[image omitted: url]\n[unsupported content block omitted: image]\n[unsupported content block omitted: image]\nafter"
2119            )
2120        );
2121    }
2122
2123    #[test]
2124    fn supported_tool_result_image_media_types_pass_validation() {
2125        for media_type in ["image/jpeg", "image/png", "image/gif", "image/webp"] {
2126            assert_eq!(
2127                validated_image_data_url(media_type, "YQ"),
2128                Some(format!("data:{media_type};base64,YQ=="))
2129            );
2130        }
2131        assert!(validated_image_data_url("image/svg+xml", "YQ==").is_none());
2132        assert!(validated_image_data_url("image/png", "").is_none());
2133    }
2134
2135    #[test]
2136    fn text_only_tool_result_keeps_string_wire_format() {
2137        let rendered = render_tool_result(&json!([
2138            {"type": "text", "text": "first"},
2139            {"type": "text", "text": "second"}
2140        ]));
2141
2142        assert_eq!(
2143            serde_json::to_value(function_call_output(rendered)).unwrap(),
2144            json!("first\nsecond")
2145        );
2146    }
2147
2148    #[test]
2149    fn tool_result_error_prefix_precedes_image_content() {
2150        let req: MessagesRequest = serde_json::from_value(json!({
2151            "model": "gpt-5.5",
2152            "messages": [{"role":"user", "content": [{
2153                "type": "tool_result",
2154                "tool_use_id": "tu_error_image",
2155                "is_error": true,
2156                "content": [{"type": "image", "source": {
2157                    "type": "base64",
2158                    "media_type": "image/png",
2159                    "data": PNG_BASE64
2160                }}]
2161            }]}]
2162        }))
2163        .unwrap();
2164
2165        let out = translate_request(&req, opts()).unwrap();
2166        assert_eq!(
2167            serde_json::to_value(&out.input[0]).unwrap()["output"],
2168            json!([
2169                {"type": "input_text", "text": "[tool execution error]"},
2170                {"type": "input_image", "image_url": format!("data:image/png;base64,{PNG_BASE64}")}
2171            ])
2172        );
2173    }
2174
2175    #[test]
2176    fn malformed_tool_result_blocks_still_become_text_placeholders() {
2177        let rendered = render_tool_result(&json!([
2178            {"type": "text"},
2179            {"type": "image"},
2180            {}
2181        ]));
2182
2183        assert_eq!(
2184            rendered.joined_text(),
2185            "[unsupported content block omitted: text]\n[unsupported content block omitted: image]\n[unsupported content block omitted: unknown]"
2186        );
2187        assert!(!rendered.has_images());
2188    }
2189
2190    #[test]
2191    fn luna_preserves_high_effort() {
2192        let req: MessagesRequest = serde_json::from_value(json!({
2193            "model": "gpt-5.6-luna",
2194            "messages": [{"role":"user", "content":"hello"}],
2195            "output_config": {"effort": "high"}
2196        }))
2197        .unwrap();
2198        let out = translate_request(
2199            &req,
2200            TranslateOptions {
2201                model: "gpt-5.6-luna".to_string(),
2202                use_responses_lite: true,
2203                ..opts()
2204            },
2205        )
2206        .unwrap();
2207        assert!(matches!(out.reasoning.unwrap().effort, Some(Effort::High)));
2208    }
2209
2210    #[test]
2211    fn sol_preserves_high_effort() {
2212        let req: MessagesRequest = serde_json::from_value(json!({
2213            "model": "gpt-5.6-sol",
2214            "messages": [{"role":"user", "content":"hello"}],
2215            "output_config": {"effort": "high"}
2216        }))
2217        .unwrap();
2218        let out = translate_request(
2219            &req,
2220            TranslateOptions {
2221                model: "gpt-5.6-sol".to_string(),
2222                use_responses_lite: true,
2223                ..opts()
2224            },
2225        )
2226        .unwrap();
2227        assert!(matches!(out.reasoning.unwrap().effort, Some(Effort::High)));
2228    }
2229
2230    #[test]
2231    fn responses_lite_moves_instructions_and_tools_into_input() {
2232        let req: MessagesRequest = serde_json::from_value(json!({
2233            "model": "gpt-5.6-luna",
2234            "messages": [{"role":"user", "content":"hello"}],
2235            "system": "be helpful",
2236            "tools": [{"name":"test","input_schema":{"type":"object"}}]
2237        }))
2238        .unwrap();
2239        let out = translate_request(
2240            &req,
2241            TranslateOptions {
2242                model: "gpt-5.6-luna".to_string(),
2243                use_responses_lite: true,
2244                ..opts()
2245            },
2246        )
2247        .unwrap();
2248        assert!(out.instructions.is_none());
2249        assert!(out.tools.is_none());
2250        assert!(!out.parallel_tool_calls);
2251        assert!(out.client_metadata.is_some());
2252        assert_eq!(out.input.len(), 3);
2253        assert!(matches!(
2254            out.input[0],
2255            ResponsesInputItem::AdditionalTools { .. }
2256        ));
2257        if let ResponsesInputItem::Message { role, content } = &out.input[1] {
2258            assert_eq!(role, "developer");
2259            assert!(matches!(content[0], ResponsesContentPart::InputText { .. }));
2260        } else {
2261            panic!("expected developer message");
2262        }
2263    }
2264
2265    #[test]
2266    fn responses_lite_without_effort_uses_all_turns_context() {
2267        let req: MessagesRequest = serde_json::from_value(json!({
2268            "model": "claude-haiku-4-5",
2269            "messages": [{"role":"user", "content":"hello"}]
2270        }))
2271        .unwrap();
2272        let out = translate_request(
2273            &req,
2274            TranslateOptions {
2275                model: "gpt-5.6-luna".to_string(),
2276                use_responses_lite: true,
2277                ..opts()
2278            },
2279        )
2280        .unwrap();
2281        let reasoning = out.reasoning.unwrap();
2282        assert!(reasoning.effort.is_none());
2283        assert!(reasoning.summary.is_none());
2284        assert_eq!(reasoning.context.as_deref(), Some("all_turns"));
2285        assert!(out.include.is_none());
2286    }
2287
2288    #[test]
2289    fn responses_lite_reasoning_uses_all_turns_context() {
2290        let req: MessagesRequest = serde_json::from_value(json!({
2291            "model": "gpt-5.6-luna",
2292            "messages": [{"role":"user", "content":"hello"}],
2293            "output_config": {"effort": "medium"}
2294        }))
2295        .unwrap();
2296        let out = translate_request(
2297            &req,
2298            TranslateOptions {
2299                model: "gpt-5.6-luna".to_string(),
2300                use_responses_lite: true,
2301                ..opts()
2302            },
2303        )
2304        .unwrap();
2305        assert_eq!(out.reasoning.unwrap().context.as_deref(), Some("all_turns"));
2306    }
2307
2308    #[test]
2309    fn translate_returns_only_expected_top_level_fields() {
2310        let req: MessagesRequest = serde_json::from_value(json!({
2311            "model": "claude-sonnet-4-6",
2312            "messages": [{"role":"user", "content":"hello"}],
2313            "system": "be helpful",
2314            "tools": [{"name":"test","input_schema":{"type":"object"}}],
2315            "tool_choice": {"type":"tool", "name":"test"}
2316        }))
2317        .unwrap();
2318        let out = translate_request(
2319            &req,
2320            TranslateOptions {
2321                model: "gpt-5.4".to_string(),
2322                ..opts()
2323            },
2324        )
2325        .unwrap();
2326        assert_eq!(out.model, "gpt-5.4");
2327        let out_value = serde_json::to_value(&out).unwrap();
2328        let keys: std::collections::BTreeSet<String> =
2329            out_value.as_object().unwrap().keys().cloned().collect();
2330        for key in &[
2331            "model",
2332            "input",
2333            "store",
2334            "stream",
2335            "parallel_tool_calls",
2336            "text",
2337        ] {
2338            assert!(keys.contains(*key), "missing key: {key}");
2339        }
2340    }
2341
2342    #[test]
2343    fn assistant_thinking_signature_replays_codex_reasoning_item() {
2344        let replay = super::super::reasoning_signature::ReasoningReplay {
2345            id: "rs_1".to_string(),
2346            encrypted_content: "opaque".to_string(),
2347        };
2348        let signature =
2349            super::super::reasoning_signature::encode_reasoning_signature(&replay).unwrap();
2350        let req: MessagesRequest = serde_json::from_value(json!({
2351            "model": "gpt-5.5",
2352            "messages": [
2353                {"role":"user","content":"start"},
2354                {"role":"assistant","content":[
2355                    {"type":"thinking","thinking":"visible summary","signature":signature},
2356                    {"type":"text","text":"done"}
2357                ]},
2358                {"role":"user","content":"continue"}
2359            ]
2360        }))
2361        .unwrap();
2362        let out = translate_request(&req, opts()).unwrap();
2363        let reasoning_index = out
2364            .input
2365            .iter()
2366            .position(|item| matches!(item, ResponsesInputItem::Reasoning { .. }))
2367            .unwrap();
2368        let ResponsesInputItem::Reasoning {
2369            id,
2370            summary,
2371            encrypted_content,
2372        } = &out.input[reasoning_index]
2373        else {
2374            unreachable!();
2375        };
2376        assert_eq!(id, "rs_1");
2377        assert!(summary.is_empty());
2378        assert_eq!(encrypted_content, "opaque");
2379        assert!(matches!(
2380            out.input.get(reasoning_index + 1),
2381            Some(ResponsesInputItem::Message { role, .. }) if role == "assistant"
2382        ));
2383    }
2384}