Skip to main content

claude_codex/providers/codex/translate/
request.rs

1use std::collections::HashSet;
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6use crate::anthropic::schema::MessagesRequest;
7use crate::config;
8use crate::providers::translate_shared::{
9    ContentBlock, flatten_system_text, image_source_to_url, normalize_content, read_effort,
10    wrap_reasoning,
11};
12
13use super::read_rewrite::{ReadOffsetRewrite, read_offset_rewrite};
14
15// ---------------------------------------------------------------------------
16// Types
17// ---------------------------------------------------------------------------
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum Effort {
22    None,
23    Low,
24    Medium,
25    High,
26    Xhigh,
27    Max,
28}
29
30impl std::fmt::Display for Effort {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        match self {
33            Effort::None => write!(f, "none"),
34            Effort::Low => write!(f, "low"),
35            Effort::Medium => write!(f, "medium"),
36            Effort::High => write!(f, "high"),
37            Effort::Xhigh => write!(f, "xhigh"),
38            Effort::Max => write!(f, "max"),
39        }
40    }
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
44#[serde(rename_all = "snake_case")]
45pub enum ServiceTier {
46    Priority,
47    Flex,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51#[serde(untagged)]
52pub enum ResponsesToolChoice {
53    Auto,
54    None,
55    Required,
56    Function {
57        r#type: String,
58        name: String,
59    },
60    WebSearch {
61        r#type: String,
62    },
63    AllowedTools {
64        r#type: String,
65        mode: String,
66        tools: Vec<Value>,
67    },
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct ResponsesRequest {
72    pub model: String,
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub instructions: Option<String>,
75    pub input: Vec<ResponsesInputItem>,
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub tools: Option<Vec<ResponsesTool>>,
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub tool_choice: Option<ResponsesToolChoice>,
80    pub store: bool,
81    pub stream: bool,
82    pub parallel_tool_calls: bool,
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub include: Option<Vec<String>>,
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub client_metadata: Option<std::collections::HashMap<String, String>>,
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub service_tier: Option<ServiceTier>,
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub prompt_cache_key: Option<String>,
91    pub text: ResponsesText,
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub reasoning: Option<ResponsesReasoning>,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct ResponsesReasoning {
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub effort: Option<Effort>,
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub summary: Option<String>,
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub context: Option<String>,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct ResponsesText {
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub verbosity: Option<String>,
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub format: Option<ResponsesTextFormat>,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
115#[serde(tag = "type")]
116#[serde(rename_all = "snake_case")]
117pub enum ResponsesTextFormat {
118    Text,
119    JsonObject,
120    JsonSchema {
121        name: String,
122        schema: Value,
123        #[serde(default)]
124        strict: Option<bool>,
125    },
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
129#[serde(tag = "type")]
130pub enum ResponsesInputItem {
131    #[serde(rename = "additional_tools")]
132    AdditionalTools {
133        #[serde(default, skip_serializing_if = "Option::is_none")]
134        id: Option<String>,
135        role: String,
136        tools: Vec<Value>,
137    },
138    #[serde(rename = "message")]
139    Message {
140        role: String,
141        content: Vec<ResponsesContentPart>,
142    },
143    #[serde(rename = "function_call")]
144    FunctionCall {
145        #[serde(default)]
146        call_id: String,
147        name: String,
148        arguments: String,
149    },
150    #[serde(rename = "function_call_output")]
151    FunctionCallOutput {
152        #[serde(default)]
153        call_id: String,
154        output: String,
155    },
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
159#[serde(tag = "type")]
160pub enum ResponsesContentPart {
161    #[serde(rename = "input_text")]
162    InputText { text: String },
163    #[serde(rename = "output_text")]
164    OutputText { text: String },
165    #[serde(rename = "input_image")]
166    InputImage {
167        image_url: String,
168        #[serde(default, skip_serializing_if = "Option::is_none")]
169        detail: Option<String>,
170    },
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
174#[serde(untagged)]
175pub enum ResponsesTool {
176    Function(ResponsesFunctionTool),
177    WebSearch(ResponsesWebSearchTool),
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct ResponsesFunctionTool {
182    #[serde(rename = "type")]
183    pub kind: String,
184    pub name: String,
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub description: Option<String>,
187    pub parameters: Value,
188    #[serde(default)]
189    pub strict: bool,
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct ResponsesWebSearchTool {
194    #[serde(rename = "type")]
195    pub kind: String,
196    pub external_web_access: bool,
197    pub search_content_types: Vec<String>,
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub filters: Option<ResponsesWebSearchFilters>,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct ResponsesWebSearchFilters {
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub allowed_domains: Option<Vec<String>>,
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub blocked_domains: Option<Vec<String>>,
208}
209
210pub struct TranslateOptions {
211    pub session_id: Option<String>,
212    pub service_tier: Option<ServiceTier>,
213    pub model: String,
214    pub use_responses_lite: bool,
215}
216
217// ---------------------------------------------------------------------------
218// Translation entry point
219// ---------------------------------------------------------------------------
220
221fn to_codex_effort(effort: Option<&str>) -> Option<Effort> {
222    match effort {
223        Some("max") => Some(Effort::Max),
224        Some("xhigh") => Some(Effort::Xhigh),
225        Some("low") => Some(Effort::Low),
226        Some("medium") => Some(Effort::Medium),
227        Some("high") => Some(Effort::High),
228        _ => None,
229    }
230}
231
232fn resolve_effort(effort: Option<Effort>) -> Result<Option<Effort>, anyhow::Error> {
233    resolve_effort_override(effort, config::codex_effort().as_deref())
234}
235
236fn resolve_effort_override(
237    effort: Option<Effort>,
238    override_effort: Option<&str>,
239) -> Result<Option<Effort>, anyhow::Error> {
240    if let Some(val) = override_effort {
241        let valid = ["none", "low", "medium", "high", "xhigh", "max"];
242        if !valid.contains(&val) {
243            anyhow::bail!(
244                "Invalid effort override: \"{val}\". Must be one of: none, low, medium, high, xhigh, max"
245            );
246        }
247        return Ok(Some(match val {
248            "max" => Effort::Max,
249            "xhigh" => Effort::Xhigh,
250            "high" => Effort::High,
251            "medium" => Effort::Medium,
252            "low" => Effort::Low,
253            _ => Effort::None,
254        }));
255    }
256    Ok(effort)
257}
258
259fn reasoning_summary_requested(summary: Option<&str>) -> bool {
260    !matches!(summary, Some("off" | "none"))
261}
262
263const VALID_SERVICE_TIERS: &[&str] = &["fast", "priority", "flex"];
264
265fn normalize_service_tier(tier: &str) -> Result<ServiceTier, anyhow::Error> {
266    if !VALID_SERVICE_TIERS.contains(&tier) {
267        anyhow::bail!(
268            "Invalid service tier override: \"{tier}\". Must be one of: {}",
269            VALID_SERVICE_TIERS.join(", ")
270        );
271    }
272    match tier {
273        "flex" => Ok(ServiceTier::Flex),
274        _ => Ok(ServiceTier::Priority),
275    }
276}
277
278fn resolve_service_tier(
279    model_tier: Option<ServiceTier>,
280) -> Result<Option<ServiceTier>, anyhow::Error> {
281    let tier = config::codex_service_tier();
282    match tier {
283        Some(ref val) => Ok(Some(normalize_service_tier(val)?)),
284        None => Ok(model_tier),
285    }
286}
287
288pub fn normalize_strict_json_schema(schema: &Value) -> Value {
289    match schema {
290        Value::Array(arr) => Value::Array(arr.iter().map(normalize_strict_json_schema).collect()),
291        Value::Object(map) => {
292            let mut out = map.clone();
293            if let Some(properties) = out.get("properties").and_then(|v| v.as_object()) {
294                let keys: Vec<String> = properties.keys().cloned().collect();
295                out.insert(
296                    "required".into(),
297                    Value::Array(keys.into_iter().map(Value::String).collect()),
298                );
299            }
300            for (key, val) in out.clone().iter() {
301                out.insert(key.clone(), normalize_strict_json_schema(val));
302            }
303            Value::Object(out)
304        }
305        _ => schema.clone(),
306    }
307}
308
309/// Hosted tools (web_search) are rejected by the Responses Lite lane, which
310/// only supports function and custom tools. Requests carrying them must use
311/// the full Responses API.
312pub fn has_hosted_web_search(req: &MessagesRequest) -> bool {
313    req.extra
314        .get("tools")
315        .and_then(|v| v.as_array())
316        .is_some_and(|tools| {
317            tools.iter().any(|tool| {
318                tool.get("type").and_then(|v| v.as_str()) == Some("web_search_20250305")
319            })
320        })
321}
322
323pub fn translate_request(
324    req: &MessagesRequest,
325    opts: TranslateOptions,
326) -> Result<ResponsesRequest, anyhow::Error> {
327    let instructions = flatten_system_text(req.extra.get("system"));
328    let input = build_input(req);
329    let tools = read_tools(req)?;
330    let tool_choice = map_tool_choice(req)?;
331
332    let mut text = ResponsesText {
333        verbosity: Some("low".to_string()),
334        format: None,
335    };
336
337    if let Some(fmt) = read_output_format(req) {
338        text.format = Some(fmt);
339    }
340
341    let mut out = ResponsesRequest {
342        model: opts.model,
343        instructions,
344        input,
345        store: false,
346        stream: true,
347        parallel_tool_calls: true,
348        tool_choice,
349        text,
350        tools: None,
351        include: None,
352        client_metadata: None,
353        service_tier: None,
354        prompt_cache_key: None,
355        reasoning: None,
356    };
357
358    if opts.use_responses_lite {
359        out.client_metadata = Some(std::collections::HashMap::from([(
360            "ws_request_header_x_openai_internal_codex_responses_lite".to_string(),
361            "true".to_string(),
362        )]));
363        out.parallel_tool_calls = false;
364
365        let mut prefix = Vec::new();
366        if let Some(ref tools) = tools
367            && !tools.is_empty()
368        {
369            let tools = tools
370                .iter()
371                .map(serde_json::to_value)
372                .collect::<Result<Vec<_>, _>>()?;
373            prefix.push(ResponsesInputItem::AdditionalTools {
374                id: None,
375                role: "developer".to_string(),
376                tools,
377            });
378        }
379        if let Some(instructions) = out.instructions.take()
380            && !instructions.is_empty()
381        {
382            prefix.push(ResponsesInputItem::Message {
383                role: "developer".to_string(),
384                content: vec![ResponsesContentPart::InputText { text: instructions }],
385            });
386        }
387        if !prefix.is_empty() {
388            prefix.extend(out.input);
389            out.input = prefix;
390        }
391    } else if let Some(tools) = tools
392        && !tools.is_empty()
393    {
394        out.tools = Some(tools);
395    }
396
397    // Never force a web_search tool_choice the request didn't register —
398    // upstream 502s instead of ignoring it.
399    if matches!(
400        out.tool_choice,
401        Some(ResponsesToolChoice::WebSearch { .. } | ResponsesToolChoice::AllowedTools { .. })
402    ) {
403        let has_web_search = out.tools.as_ref().is_some_and(|t| {
404            t.iter()
405                .any(|tool| matches!(tool, ResponsesTool::WebSearch(_)))
406        });
407        if !has_web_search {
408            out.tool_choice = Some(ResponsesToolChoice::Auto);
409        }
410    }
411
412    if let Some(sid) = opts.session_id {
413        out.prompt_cache_key = Some(sid);
414    }
415
416    let service_tier = resolve_service_tier(opts.service_tier)?;
417    if let Some(ref tier) = service_tier {
418        out.service_tier = Some(tier.clone());
419    }
420
421    let effort = read_effort(req)?;
422    let codex_effort = to_codex_effort(effort);
423    let resolved_effort = resolve_effort(codex_effort)?;
424    if resolved_effort.is_some() || opts.use_responses_lite {
425        let summary = if resolved_effort.is_some()
426            && reasoning_summary_requested(config::codex_reasoning_summary().as_deref())
427        {
428            Some("auto".to_string())
429        } else {
430            None
431        };
432        out.reasoning = Some(ResponsesReasoning {
433            effort: resolved_effort.clone(),
434            summary,
435            context: opts.use_responses_lite.then_some("all_turns".to_string()),
436        });
437    }
438    if resolved_effort.is_some() {
439        out.include = Some(vec!["reasoning.encrypted_content".to_string()]);
440    }
441
442    Ok(out)
443}
444
445// ---------------------------------------------------------------------------
446// Helpers
447// ---------------------------------------------------------------------------
448
449fn read_output_format(req: &MessagesRequest) -> Option<ResponsesTextFormat> {
450    let output_config = req.extra.get("output_config")?.as_object()?;
451    let format = output_config.get("format")?.as_object()?;
452    let kind = format.get("type")?.as_str()?;
453    match kind {
454        "json_schema" => {
455            let name = format
456                .get("name")
457                .and_then(|v| v.as_str())
458                .unwrap_or("response")
459                .to_string();
460            let schema = format.get("schema")?;
461            let normalized = normalize_strict_json_schema(schema);
462            Some(ResponsesTextFormat::JsonSchema {
463                name,
464                schema: normalized,
465                strict: Some(true),
466            })
467        }
468        "json_object" => Some(ResponsesTextFormat::JsonObject),
469        _ => Some(ResponsesTextFormat::Text),
470    }
471}
472
473fn read_tools(req: &MessagesRequest) -> Result<Option<Vec<ResponsesTool>>, anyhow::Error> {
474    let Some(tools) = req.extra.get("tools") else {
475        return Ok(None);
476    };
477    let tools_arr = match tools {
478        Value::Array(a) => a,
479        _ => return Ok(None),
480    };
481    let mut out = Vec::new();
482    for tool in tools_arr {
483        let tool_type = tool
484            .get("type")
485            .and_then(|v| v.as_str())
486            .unwrap_or("function");
487        if tool_type == "web_search_20250305" {
488            let mut filters = ResponsesWebSearchFilters {
489                allowed_domains: None,
490                blocked_domains: None,
491            };
492            let allowed = tool.get("allowed_domains").and_then(|v| v.as_array());
493            if allowed.is_some_and(|a| !a.is_empty()) {
494                filters.allowed_domains = allowed.map(|a| {
495                    a.iter()
496                        .filter_map(|v| v.as_str().map(String::from))
497                        .collect()
498                });
499            }
500            let blocked = tool.get("blocked_domains").and_then(|v| v.as_array());
501            if blocked.is_some_and(|a| !a.is_empty()) {
502                filters.blocked_domains = blocked.map(|a| {
503                    a.iter()
504                        .filter_map(|v| v.as_str().map(String::from))
505                        .collect()
506                });
507            }
508            let has_filters =
509                filters.allowed_domains.is_some() || filters.blocked_domains.is_some();
510            out.push(ResponsesTool::WebSearch(ResponsesWebSearchTool {
511                kind: "web_search".to_string(),
512                external_web_access: true,
513                search_content_types: vec!["text".to_string(), "image".to_string()],
514                filters: if has_filters { Some(filters) } else { None },
515            }));
516        } else {
517            let name = tool
518                .get("name")
519                .and_then(|v| v.as_str())
520                .unwrap_or("")
521                .to_string();
522            let description = tool
523                .get("description")
524                .and_then(|v| v.as_str())
525                .map(|s| s.to_string());
526            let parameters = tool
527                .get("input_schema")
528                .cloned()
529                .unwrap_or(serde_json::json!({}));
530            let description = codex_tool_description(&name, description);
531            let parameters = codex_tool_parameters(&name, parameters);
532            out.push(ResponsesTool::Function(ResponsesFunctionTool {
533                kind: "function".to_string(),
534                name,
535                description,
536                parameters,
537                strict: false,
538            }));
539        }
540    }
541    if out.is_empty() {
542        Ok(None)
543    } else {
544        Ok(Some(out))
545    }
546}
547
548fn codex_tool_description(name: &str, description: Option<String>) -> Option<String> {
549    if name != "Read" {
550        return description;
551    }
552
553    let base = description.unwrap_or_else(|| "Reads a file from the local filesystem.".to_string());
554    Some(format!("{base}\n\n{}", read_offset_guidance()))
555}
556
557fn codex_tool_parameters(name: &str, mut parameters: Value) -> Value {
558    if name != "Read" {
559        return parameters;
560    }
561
562    let Some(props) = parameters
563        .get_mut("properties")
564        .and_then(Value::as_object_mut)
565    else {
566        return parameters;
567    };
568
569    if let Some(offset) = props.get_mut("offset").and_then(Value::as_object_mut) {
570        offset.insert(
571            "description".to_string(),
572            Value::String(
573                "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(),
574            ),
575        );
576    }
577
578    if let Some(limit) = props.get_mut("limit").and_then(Value::as_object_mut) {
579        limit.insert(
580            "description".to_string(),
581            Value::String(
582                "Optional number of lines to read. Omit when opening a file. Use with offset only when continuing a large file."
583                    .to_string(),
584            ),
585        );
586    }
587
588    parameters
589}
590
591fn map_tool_choice(req: &MessagesRequest) -> Result<Option<ResponsesToolChoice>, anyhow::Error> {
592    let choice = match req.extra.get("tool_choice") {
593        Some(Value::Object(m)) => m,
594        Some(Value::String(s)) => {
595            return Ok(Some(match s.as_str() {
596                "auto" => ResponsesToolChoice::Auto,
597                "none" => ResponsesToolChoice::None,
598                "any" | "required" => ResponsesToolChoice::Required,
599                _ => ResponsesToolChoice::Auto,
600            }));
601        }
602        _ => return Ok(None),
603    };
604
605    let choice_type = choice
606        .get("type")
607        .and_then(|v| v.as_str())
608        .unwrap_or("auto");
609    match choice_type {
610        "auto" => Ok(Some(ResponsesToolChoice::Auto)),
611        "none" => Ok(Some(ResponsesToolChoice::None)),
612        "any" | "required" => Ok(Some(ResponsesToolChoice::Required)),
613        "tool" => {
614            let name = choice.get("name").and_then(|v| v.as_str()).unwrap_or("");
615            let tools = req.extra.get("tools").and_then(|v| v.as_array());
616            let is_web_search = tools.is_some_and(|t| {
617                t.iter().any(|tool| {
618                    (tool.get("type").and_then(|v| v.as_str()) == Some("web_search_20250305"))
619                        && tool.get("name").and_then(|v| v.as_str()) == Some(name)
620                })
621            });
622            if is_web_search {
623                Ok(Some(ResponsesToolChoice::AllowedTools {
624                    r#type: "allowed_tools".to_string(),
625                    mode: "required".to_string(),
626                    tools: vec![serde_json::json!({"type": "web_search"})],
627                }))
628            } else {
629                Ok(Some(ResponsesToolChoice::Function {
630                    r#type: "function".to_string(),
631                    name: name.to_string(),
632                }))
633            }
634        }
635        _ => Ok(None),
636    }
637}
638
639fn build_input(req: &MessagesRequest) -> Vec<ResponsesInputItem> {
640    let mut out: Vec<ResponsesInputItem> = Vec::new();
641    let mut read_tool_uses_with_offset = HashSet::new();
642
643    for msg in &req.messages {
644        let blocks = normalize_content(&msg.content, Value::Null);
645        match msg.role.as_str() {
646            "user" => {
647                let mut parts: Vec<ResponsesContentPart> = Vec::new();
648                for block in &blocks {
649                    match block {
650                        ContentBlock::Text { text } => {
651                            parts.push(ResponsesContentPart::InputText { text: text.clone() });
652                        }
653                        ContentBlock::Image { source } => {
654                            parts.push(ResponsesContentPart::InputImage {
655                                image_url: image_source_to_url(source),
656                                detail: None,
657                            });
658                        }
659                        ContentBlock::ToolResult {
660                            tool_use_id,
661                            content,
662                            is_error,
663                        } => {
664                            if !parts.is_empty() {
665                                out.push(ResponsesInputItem::Message {
666                                    role: "user".to_string(),
667                                    content: std::mem::take(&mut parts),
668                                });
669                            }
670                            let body = tool_result_to_string(content);
671                            let output = if is_error.unwrap_or(false) {
672                                format!("[tool execution error]\n{body}")
673                            } else {
674                                body
675                            };
676                            let output =
677                                maybe_append_rewritten_read_offset_note(output, tool_use_id);
678                            let output = maybe_append_read_offset_guidance(
679                                output,
680                                read_tool_uses_with_offset.contains(tool_use_id),
681                                is_error.unwrap_or(false),
682                            );
683                            out.push(ResponsesInputItem::FunctionCallOutput {
684                                call_id: tool_use_id.clone(),
685                                output,
686                            });
687                        }
688                        _ => {}
689                    }
690                }
691                if !parts.is_empty() {
692                    out.push(ResponsesInputItem::Message {
693                        role: "user".to_string(),
694                        content: parts,
695                    });
696                }
697            }
698            "system" => {
699                let parts: Vec<ResponsesContentPart> = blocks
700                    .iter()
701                    .filter_map(|b| match b {
702                        ContentBlock::Text { text } => {
703                            Some(ResponsesContentPart::InputText { text: text.clone() })
704                        }
705                        _ => None,
706                    })
707                    .collect();
708                if !parts.is_empty() {
709                    out.push(ResponsesInputItem::Message {
710                        role: "developer".to_string(),
711                        content: parts,
712                    });
713                }
714            }
715            _ => {
716                let mut text_parts: Vec<ResponsesContentPart> = Vec::new();
717                let flush_text =
718                    |out: &mut Vec<ResponsesInputItem>,
719                     text_parts: &mut Vec<ResponsesContentPart>| {
720                        if !text_parts.is_empty() {
721                            out.push(ResponsesInputItem::Message {
722                                role: "assistant".to_string(),
723                                content: std::mem::take(text_parts),
724                            });
725                        }
726                    };
727                for block in &blocks {
728                    match block {
729                        ContentBlock::Text { text } => {
730                            text_parts
731                                .push(ResponsesContentPart::OutputText { text: text.clone() });
732                        }
733                        // Preserve reasoning across an opus->codex switch. The Responses API
734                        // has no `thinking` container, so a replayed thinking block would be
735                        // dropped; carry it forward as tagged text instead (same marker the
736                        // anthropic passthrough uses in the other direction).
737                        ContentBlock::Thinking { thinking } if !thinking.is_empty() => {
738                            text_parts.push(ResponsesContentPart::OutputText {
739                                text: wrap_reasoning(thinking),
740                            });
741                        }
742                        ContentBlock::ToolUse { id, name, input } => {
743                            flush_text(&mut out, &mut text_parts);
744                            if is_read_tool_use_with_offset(name, input) {
745                                read_tool_uses_with_offset.insert(id.clone());
746                            }
747                            let args =
748                                serde_json::to_string(input).unwrap_or_else(|_| "{}".to_string());
749                            out.push(ResponsesInputItem::FunctionCall {
750                                call_id: id.clone(),
751                                name: name.clone(),
752                                arguments: args,
753                            });
754                        }
755                        _ => {}
756                    }
757                }
758                flush_text(&mut out, &mut text_parts);
759            }
760        }
761    }
762
763    out
764}
765
766fn is_read_tool_use_with_offset(name: &str, input: &Value) -> bool {
767    name == "Read" && input.get("offset").is_some()
768}
769
770fn maybe_append_rewritten_read_offset_note(output: String, tool_use_id: &str) -> String {
771    if output.contains("Proxy Read offset note:") {
772        return output;
773    }
774    let Some(rewrite) = read_offset_rewrite(tool_use_id) else {
775        return output;
776    };
777    format!("{output}\n\n{}", read_offset_rewrite_note(&rewrite))
778}
779
780fn read_offset_rewrite_note(rewrite: &ReadOffsetRewrite) -> String {
781    let file = rewrite
782        .file_path
783        .as_deref()
784        .map(|path| format!(" for {path}"))
785        .unwrap_or_default();
786    format!(
787        "Proxy Read offset note:\n\
788         - Requested Read offset {}{} exceeds the proxy rewrite threshold of 1000000.\n\
789         - This Read starts at the beginning of the file.\n\
790         - For continuation reads, use offset after a prior Read of the same file returned content and more lines are needed.\n\
791         - Compute offset as prior offset plus the number of lines returned by that prior Read.",
792        rewrite.offset, file
793    )
794}
795
796fn maybe_append_read_offset_guidance(
797    output: String,
798    read_call_had_offset: bool,
799    is_error: bool,
800) -> String {
801    if !read_call_had_offset
802        || output.contains("Codex Read guidance:")
803        || !looks_like_read_offset_result(&output)
804        || (!is_error && !looks_like_read_offset_warning(&output))
805    {
806        return output;
807    }
808    format!("{output}\n\n{}", read_offset_guidance())
809}
810
811fn looks_like_read_offset_result(output: &str) -> bool {
812    let lower = output.to_ascii_lowercase();
813    lower.contains("offset")
814        && (lower.contains("file has")
815            || lower.contains("out of range")
816            || (lower.contains("line") && lower.contains("requested")))
817}
818
819fn looks_like_read_offset_warning(output: &str) -> bool {
820    let lower = output.to_ascii_lowercase();
821    lower.contains("warning") || lower.contains("system-reminder")
822}
823
824fn read_offset_guidance() -> &'static str {
825    "Codex Read guidance:\n\
826     - offset is an optional zero based continuation index, not a line number lookup.\n\
827     - Use offset only after a prior Read of the same file returned content and more lines are needed.\n\
828     - Compute offset as prior offset plus the number of lines returned by that prior Read.\n\
829     - Displayed line numbers, grep line numbers, byte counts, token counts, file sizes, and guessed positions are invalid offsets.\n\
830     - Omit offset and limit when opening a file or when unsure."
831}
832
833// ---------------------------------------------------------------------------
834// Tool result rendering
835// ---------------------------------------------------------------------------
836
837fn tool_result_to_string(content: &Value) -> String {
838    match content {
839        Value::String(s) => s.clone(),
840        Value::Array(arr) => {
841            let mut parts = Vec::new();
842            for b in arr {
843                match b.get("type").and_then(|v| v.as_str()) {
844                    Some("text") => match b.get("text").and_then(|v| v.as_str()) {
845                        Some(text) => parts.push(text.to_string()),
846                        None => parts.push(unsupported_tool_result_block_to_string(b)),
847                    },
848                    Some("image") => {
849                        if let Some(source) = b.get("source").and_then(|v| v.as_object()) {
850                            match source.get("type").and_then(|v| v.as_str()) {
851                                Some("url")
852                                    if source.get("url").and_then(|v| v.as_str()).is_some() =>
853                                {
854                                    parts.push("[image omitted: url]".to_string());
855                                }
856                                Some("base64")
857                                    if source
858                                        .get("media_type")
859                                        .and_then(|v| v.as_str())
860                                        .is_some()
861                                        && source
862                                            .get("data")
863                                            .and_then(|v| v.as_str())
864                                            .is_some() =>
865                                {
866                                    let media_type = source
867                                        .get("media_type")
868                                        .and_then(|v| v.as_str())
869                                        .unwrap_or("image");
870                                    parts.push(format!("[image omitted: {media_type}]"));
871                                }
872                                _ => parts.push(unsupported_tool_result_block_to_string(b)),
873                            }
874                        } else {
875                            parts.push(unsupported_tool_result_block_to_string(b));
876                        }
877                    }
878                    Some(other) => {
879                        parts.push(format!("[unsupported content block omitted: {other}]"));
880                    }
881                    None => parts.push(unsupported_tool_result_block_to_string(b)),
882                }
883            }
884            parts.join("\n")
885        }
886        _ => String::new(),
887    }
888}
889
890fn unsupported_tool_result_block_to_string(block: &Value) -> String {
891    let kind = block
892        .get("type")
893        .and_then(|v| v.as_str())
894        .unwrap_or("unknown");
895    format!("[unsupported content block omitted: {kind}]")
896}
897
898#[cfg(test)]
899mod tests {
900    use super::*;
901    use serde_json::json;
902
903    fn opts() -> TranslateOptions {
904        TranslateOptions {
905            session_id: None,
906            service_tier: None,
907            model: "gpt-5.5".to_string(),
908            use_responses_lite: false,
909        }
910    }
911
912    #[test]
913    fn translate_web_search_tool_to_codex_tool() {
914        let req: MessagesRequest = serde_json::from_value(json!({
915            "model": "gpt-5.5",
916            "messages": [{"role":"user", "content":"find it"}],
917            "tools": [{
918                "type":"web_search_20250305",
919                "name":"web_search",
920                "allowed_domains":["example.com"]
921            }],
922            "tool_choice": {"type":"tool", "name":"web_search"}
923        }))
924        .unwrap();
925        let out = translate_request(
926            &req,
927            TranslateOptions {
928                session_id: Some("s".into()),
929                service_tier: None,
930                model: "gpt-5.5".to_string(),
931                use_responses_lite: false,
932            },
933        )
934        .unwrap();
935        assert_eq!(out.prompt_cache_key.as_deref(), Some("s"));
936        assert!(matches!(
937            out.tool_choice,
938            Some(ResponsesToolChoice::AllowedTools { .. })
939        ));
940        let tool_choice = serde_json::to_value(out.tool_choice.as_ref().unwrap()).unwrap();
941        assert_eq!(tool_choice["type"], "allowed_tools");
942        assert_eq!(tool_choice["mode"], "required");
943        assert_eq!(tool_choice["tools"], json!([{"type":"web_search"}]));
944        let ResponsesTool::WebSearch(tool) = &out.tools.as_ref().unwrap()[0] else {
945            panic!("expected web_search tool");
946        };
947        assert!(tool.external_web_access);
948        assert_eq!(
949            tool.filters.as_ref().unwrap().allowed_domains.as_deref(),
950            Some(&["example.com".to_string()][..])
951        );
952        assert!(out.instructions.is_none());
953    }
954
955    #[test]
956    fn automatic_filtered_web_search_keeps_native_filters() {
957        let req: MessagesRequest = serde_json::from_value(json!({
958            "model": "gpt-5.5",
959            "messages": [{"role":"user", "content":"find it"}],
960            "tools": [{
961                "type":"web_search_20250305",
962                "name":"web_search",
963                "allowed_domains":["example.com"],
964                "blocked_domains":["spam.example"]
965            }],
966            "tool_choice": {"type":"auto"}
967        }))
968        .unwrap();
969        let out = translate_request(&req, opts()).unwrap();
970        let ResponsesTool::WebSearch(tool) = &out.tools.as_ref().unwrap()[0] else {
971            panic!("expected web_search tool");
972        };
973        assert!(tool.external_web_access);
974        let filters = tool.filters.as_ref().unwrap();
975        assert_eq!(
976            filters.allowed_domains.as_deref(),
977            Some(&["example.com".to_string()][..])
978        );
979        assert_eq!(
980            filters.blocked_domains.as_deref(),
981            Some(&["spam.example".to_string()][..])
982        );
983        assert!(out.instructions.is_none());
984    }
985
986    #[test]
987    fn forced_filtered_web_search_keeps_native_filters() {
988        let req: MessagesRequest = serde_json::from_value(json!({
989            "model": "gpt-5.5",
990            "messages": [{"role":"user", "content":"find it"}],
991            "system": "Be brief.",
992            "tools": [{
993                "type":"web_search_20250305",
994                "name":"web_search",
995                "allowed_domains":["a.example", "b.example"],
996                "blocked_domains":["spam.example"]
997            }],
998            "tool_choice": {"type":"tool", "name":"web_search"}
999        }))
1000        .unwrap();
1001        let out = translate_request(&req, opts()).unwrap();
1002        let ResponsesTool::WebSearch(tool) = &out.tools.as_ref().unwrap()[0] else {
1003            panic!("expected web_search tool");
1004        };
1005        let filters = tool.filters.as_ref().unwrap();
1006        assert_eq!(
1007            filters.allowed_domains.as_deref(),
1008            Some(&["a.example".to_string(), "b.example".to_string()][..])
1009        );
1010        assert_eq!(
1011            filters.blocked_domains.as_deref(),
1012            Some(&["spam.example".to_string()][..])
1013        );
1014        assert_eq!(out.instructions.as_deref(), Some("Be brief."));
1015        assert!(matches!(
1016            out.tool_choice,
1017            Some(ResponsesToolChoice::AllowedTools { .. })
1018        ));
1019    }
1020
1021    #[test]
1022    fn unfiltered_web_search_adds_no_domain_instructions() {
1023        for tool_choice in [None, Some(json!({"type":"tool", "name":"web_search"}))] {
1024            let mut body = json!({
1025                "model": "gpt-5.5",
1026                "messages": [{"role":"user", "content":"find it"}],
1027                "tools": [{"type":"web_search_20250305", "name":"web_search"}]
1028            });
1029            if let Some(tool_choice) = tool_choice {
1030                body["tool_choice"] = tool_choice;
1031            }
1032            let req: MessagesRequest = serde_json::from_value(body).unwrap();
1033            let out = translate_request(&req, opts()).unwrap();
1034            let ResponsesTool::WebSearch(tool) = &out.tools.as_ref().unwrap()[0] else {
1035                panic!("expected web_search tool");
1036            };
1037            assert!(tool.external_web_access);
1038            assert!(tool.filters.is_none());
1039            assert!(out.instructions.is_none());
1040        }
1041    }
1042
1043    #[test]
1044    fn has_hosted_web_search_detects_web_search_tool() {
1045        let with: MessagesRequest = serde_json::from_value(json!({
1046            "model": "gpt-5.6-sol",
1047            "messages": [{"role":"user", "content":"find it"}],
1048            "tools": [
1049                {"name":"Bash", "input_schema":{}},
1050                {"type":"web_search_20250305", "name":"web_search"}
1051            ]
1052        }))
1053        .unwrap();
1054        assert!(has_hosted_web_search(&with));
1055
1056        let without: MessagesRequest = serde_json::from_value(json!({
1057            "model": "gpt-5.6-sol",
1058            "messages": [{"role":"user", "content":"run it"}],
1059            "tools": [{"name":"Bash", "input_schema":{}}]
1060        }))
1061        .unwrap();
1062        assert!(!has_hosted_web_search(&without));
1063    }
1064
1065    #[test]
1066    fn responses_lite_downgrades_unregistered_web_search_tool_choice() {
1067        // On the lite lane tools travel in the AdditionalTools developer
1068        // prefix, so a top-level web_search tool_choice would reference a
1069        // tool upstream doesn't know about and 502.
1070        let req: MessagesRequest = serde_json::from_value(json!({
1071            "model": "gpt-5.6-sol",
1072            "messages": [{"role":"user", "content":"find it"}],
1073            "tools": [{
1074                "type":"web_search_20250305",
1075                "name":"web_search"
1076            }],
1077            "tool_choice": {"type":"tool", "name":"web_search"}
1078        }))
1079        .unwrap();
1080        let out = translate_request(
1081            &req,
1082            TranslateOptions {
1083                session_id: None,
1084                service_tier: None,
1085                model: "gpt-5.6-sol".to_string(),
1086                use_responses_lite: true,
1087            },
1088        )
1089        .unwrap();
1090        assert!(out.tools.is_none());
1091        assert!(matches!(out.tool_choice, Some(ResponsesToolChoice::Auto)));
1092    }
1093
1094    #[test]
1095    fn full_lane_keeps_web_search_tool_choice_registered() {
1096        let req: MessagesRequest = serde_json::from_value(json!({
1097            "model": "gpt-5.6-sol",
1098            "messages": [{"role":"user", "content":"find it"}],
1099            "tools": [{
1100                "type":"web_search_20250305",
1101                "name":"web_search"
1102            }],
1103            "tool_choice": {"type":"tool", "name":"web_search"}
1104        }))
1105        .unwrap();
1106        let out = translate_request(
1107            &req,
1108            TranslateOptions {
1109                session_id: None,
1110                service_tier: None,
1111                model: "gpt-5.6-sol".to_string(),
1112                use_responses_lite: false,
1113            },
1114        )
1115        .unwrap();
1116        assert!(out.tools.as_ref().is_some_and(|t| {
1117            t.iter()
1118                .any(|tool| matches!(tool, ResponsesTool::WebSearch(_)))
1119        }));
1120        assert!(matches!(
1121            out.tool_choice,
1122            Some(ResponsesToolChoice::AllowedTools { .. })
1123        ));
1124    }
1125
1126    #[test]
1127    fn translate_read_tool_adds_codex_offset_guidance() {
1128        let req: MessagesRequest = serde_json::from_value(json!({
1129            "model": "gpt-5.5",
1130            "messages": [{"role":"user", "content":"read it"}],
1131            "tools": [{
1132                "name": "Read",
1133                "description": "Reads a file from the local filesystem.",
1134                "input_schema": {
1135                    "type": "object",
1136                    "properties": {
1137                        "file_path": {"type": "string"},
1138                        "offset": {"type": "integer", "description": "old offset"},
1139                        "limit": {"type": "integer", "description": "old limit"}
1140                    },
1141                    "required": ["file_path"]
1142                }
1143            }]
1144        }))
1145        .unwrap();
1146        let out = translate_request(&req, opts()).unwrap();
1147        let tools = out.tools.as_ref().unwrap();
1148        let ResponsesTool::Function(tool) = &tools[0] else {
1149            panic!("expected function tool");
1150        };
1151        let description = tool.description.as_deref().unwrap();
1152        assert!(description.contains("Codex Read guidance"));
1153        assert!(description.contains("zero based continuation index"));
1154        assert!(description.contains("guessed positions are invalid offsets"));
1155
1156        let props = tool
1157            .parameters
1158            .get("properties")
1159            .and_then(Value::as_object)
1160            .unwrap();
1161        assert_eq!(
1162            props
1163                .get("offset")
1164                .and_then(|v| v.get("description"))
1165                .and_then(Value::as_str),
1166            Some(
1167                "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."
1168            )
1169        );
1170        assert_eq!(
1171            props
1172                .get("limit")
1173                .and_then(|v| v.get("description"))
1174                .and_then(Value::as_str),
1175            Some(
1176                "Optional number of lines to read. Omit when opening a file. Use with offset only when continuing a large file."
1177            )
1178        );
1179    }
1180
1181    #[test]
1182    fn translate_non_read_tool_preserves_tool_metadata() {
1183        let req: MessagesRequest = serde_json::from_value(json!({
1184            "model": "gpt-5.5",
1185            "messages": [{"role":"user", "content":"search"}],
1186            "tools": [{
1187                "name": "Search",
1188                "description": "Find matching records.",
1189                "input_schema": {
1190                    "type": "object",
1191                    "properties": {
1192                        "offset": {"type": "integer", "description": "record offset"}
1193                    }
1194                }
1195            }]
1196        }))
1197        .unwrap();
1198        let out = translate_request(&req, opts()).unwrap();
1199        let tools = out.tools.as_ref().unwrap();
1200        let ResponsesTool::Function(tool) = &tools[0] else {
1201            panic!("expected function tool");
1202        };
1203        assert_eq!(tool.description.as_deref(), Some("Find matching records."));
1204        assert!(!tool.strict);
1205        assert_eq!(
1206            serde_json::to_value(tool).unwrap()["strict"],
1207            Value::Bool(false)
1208        );
1209        assert_eq!(
1210            tool.parameters
1211                .get("properties")
1212                .and_then(|v| v.get("offset"))
1213                .and_then(|v| v.get("description"))
1214                .and_then(Value::as_str),
1215            Some("record offset")
1216        );
1217    }
1218
1219    #[test]
1220    fn translate_omits_reasoning_when_not_enabled() {
1221        let req: MessagesRequest = serde_json::from_value(json!({
1222            "model": "gpt-5.5",
1223            "messages": [{"role":"user", "content":"hello"}]
1224        }))
1225        .unwrap();
1226        let out = translate_request(&req, opts()).unwrap();
1227        assert!(out.reasoning.is_none());
1228        assert!(out.include.is_none());
1229    }
1230
1231    #[test]
1232    fn translate_includes_reasoning_when_enabled() {
1233        let req: MessagesRequest = serde_json::from_value(json!({
1234            "model": "gpt-5.5",
1235            "messages": [{"role":"user", "content":"hello"}],
1236            "output_config": {"effort": "medium"}
1237        }))
1238        .unwrap();
1239        let out = translate_request(&req, opts()).unwrap();
1240        let reasoning = out.reasoning.unwrap();
1241        assert!(matches!(reasoning.effort, Some(Effort::Medium)));
1242        assert_eq!(reasoning.summary.as_deref(), Some("auto"));
1243        assert_eq!(
1244            out.include,
1245            Some(vec!["reasoning.encrypted_content".to_string()])
1246        );
1247    }
1248
1249    #[test]
1250    fn translate_effort_max_maps_to_max() {
1251        let req: MessagesRequest = serde_json::from_value(json!({
1252            "model": "gpt-5.5",
1253            "messages": [{"role":"user", "content":"hello"}],
1254            "output_config": {"effort": "max"}
1255        }))
1256        .unwrap();
1257        let out = translate_request(&req, opts()).unwrap();
1258        assert!(matches!(out.reasoning.unwrap().effort, Some(Effort::Max)));
1259    }
1260
1261    #[test]
1262    fn translate_effort_override_max_maps_to_max() {
1263        let effort = resolve_effort_override(Some(Effort::Low), Some("max")).unwrap();
1264        assert!(matches!(effort, Some(Effort::Max)));
1265    }
1266
1267    #[test]
1268    fn max_tokens_is_not_serialized_for_codex() {
1269        let req: MessagesRequest = serde_json::from_value(json!({
1270            "model": "gpt-5.5",
1271            "max_tokens": 4096,
1272            "messages": [{"role":"user", "content":"hello"}]
1273        }))
1274        .unwrap();
1275        let out = translate_request(&req, opts()).unwrap();
1276        let value = serde_json::to_value(out).unwrap();
1277        assert!(value.get("max_output_tokens").is_none());
1278    }
1279
1280    #[test]
1281    fn translate_effort_xhigh_maps_to_xhigh() {
1282        let req: MessagesRequest = serde_json::from_value(json!({
1283            "model": "gpt-5.5",
1284            "messages": [{"role":"user", "content":"hello"}],
1285            "output_config": {"effort": "xhigh"}
1286        }))
1287        .unwrap();
1288        let out = translate_request(&req, opts()).unwrap();
1289        assert!(matches!(out.reasoning.unwrap().effort, Some(Effort::Xhigh)));
1290        assert_eq!(
1291            out.include,
1292            Some(vec!["reasoning.encrypted_content".to_string()])
1293        );
1294    }
1295
1296    #[test]
1297    fn reasoning_summary_override_values() {
1298        assert!(reasoning_summary_requested(None));
1299        assert!(reasoning_summary_requested(Some("auto")));
1300        assert!(reasoning_summary_requested(Some("detailed")));
1301        assert!(!reasoning_summary_requested(Some("off")));
1302        assert!(!reasoning_summary_requested(Some("none")));
1303    }
1304
1305    #[test]
1306    fn translate_user_text_and_image() {
1307        let req: MessagesRequest = serde_json::from_value(json!({
1308            "model": "gpt-5.5",
1309            "messages": [{"role":"user", "content": [
1310                {"type":"text", "text":"describe"},
1311                {"type":"image", "source": {"type":"base64", "media_type":"image/jpeg", "data":"xyz"}}
1312            ]}]
1313        }))
1314        .unwrap();
1315        let out = translate_request(&req, opts()).unwrap();
1316        assert_eq!(out.input.len(), 1);
1317        if let ResponsesInputItem::Message { role, content } = &out.input[0] {
1318            assert_eq!(role, "user");
1319            assert_eq!(content.len(), 2);
1320        } else {
1321            panic!("expected Message");
1322        }
1323    }
1324
1325    #[test]
1326    fn translate_assistant_with_text_and_tool_use() {
1327        let req: MessagesRequest = serde_json::from_value(json!({
1328            "model": "gpt-5.5",
1329            "messages": [{"role":"assistant", "content": [
1330                {"type":"text", "text":"answer"},
1331                {"type":"tool_use", "id":"tu_1", "name":"search", "input": {"q":"rust"}}
1332            ]}]
1333        }))
1334        .unwrap();
1335        let out = translate_request(&req, opts()).unwrap();
1336        assert_eq!(out.input.len(), 2);
1337    }
1338
1339    #[test]
1340    fn translate_assistant_thinking_becomes_tagged_reasoning() {
1341        // Symmetric with the anthropic passthrough: on an opus->codex switch a replayed
1342        // thinking block has no Responses container, so it is carried as tagged text
1343        // rather than dropped.
1344        use crate::providers::translate_shared::{REASONING_CLOSE, REASONING_OPEN};
1345        let req: MessagesRequest = serde_json::from_value(json!({
1346            "model": "gpt-5.5",
1347            "messages": [{"role":"assistant", "content": [
1348                {"type":"thinking", "thinking":"opus reasoning", "signature":"sig"},
1349                {"type":"text", "text":"the answer"}
1350            ]}]
1351        }))
1352        .unwrap();
1353        let out = translate_request(&req, opts()).unwrap();
1354        assert_eq!(out.input.len(), 1);
1355        let ResponsesInputItem::Message { role, content } = &out.input[0] else {
1356            panic!("expected Message");
1357        };
1358        assert_eq!(role, "assistant");
1359        assert_eq!(content.len(), 2);
1360        let ResponsesContentPart::OutputText { text: reasoning } = &content[0] else {
1361            panic!("expected reasoning OutputText");
1362        };
1363        assert!(reasoning.starts_with(REASONING_OPEN), "{reasoning}");
1364        assert!(reasoning.contains("opus reasoning"), "{reasoning}");
1365        assert!(reasoning.ends_with(REASONING_CLOSE), "{reasoning}");
1366        let ResponsesContentPart::OutputText { text: answer } = &content[1] else {
1367            panic!("expected answer OutputText");
1368        };
1369        assert_eq!(answer, "the answer");
1370    }
1371
1372    #[test]
1373    fn translate_strict_json_schema_normalization() {
1374        let req: MessagesRequest = serde_json::from_value(json!({
1375            "model": "gpt-5.5",
1376            "messages": [{"role":"user", "content":"hi"}],
1377            "output_config": {"format": {
1378                "type": "json_schema",
1379                "schema": {
1380                    "type": "object",
1381                    "properties": {"ok": {"type": "boolean"}, "reason": {"type": "string"}},
1382                    "required": ["ok"]
1383                }
1384            }}
1385        }))
1386        .unwrap();
1387        let out = translate_request(&req, opts()).unwrap();
1388        if let Some(ResponsesTextFormat::JsonSchema { schema, .. }) = &out.text.format {
1389            let required = schema.get("required").and_then(|v| v.as_array()).unwrap();
1390            assert!(required.iter().any(|v| v == "ok"));
1391            assert!(required.iter().any(|v| v == "reason"));
1392        } else {
1393            panic!("expected JsonSchema format");
1394        }
1395    }
1396
1397    #[test]
1398    fn translate_tool_result_content() {
1399        let req: MessagesRequest = serde_json::from_value(json!({
1400            "model": "gpt-5.5",
1401            "messages": [{"role":"user", "content": [{
1402                "type": "tool_result",
1403                "tool_use_id": "tu_1",
1404                "content": [{"type":"text", "text":"result"}]
1405            }]}]
1406        }))
1407        .unwrap();
1408        let out = translate_request(&req, opts()).unwrap();
1409        assert_eq!(out.input.len(), 1);
1410        if let ResponsesInputItem::FunctionCallOutput { call_id, .. } = &out.input[0] {
1411            assert_eq!(call_id, "tu_1");
1412        } else {
1413            panic!("expected FunctionCallOutput");
1414        }
1415    }
1416
1417    #[test]
1418    fn translate_read_offset_error_adds_guidance() {
1419        let req: MessagesRequest = serde_json::from_value(json!({
1420            "model": "gpt-5.5",
1421            "messages": [
1422                {"role":"assistant", "content": [{
1423                    "type": "tool_use",
1424                    "id": "tu_1",
1425                    "name": "Read",
1426                    "input": {"file_path": "/tmp/a", "offset": 2952, "limit": 200}
1427                }]},
1428                {"role":"user", "content": [{
1429                    "type": "tool_result",
1430                    "tool_use_id": "tu_1",
1431                    "is_error": true,
1432                    "content": [{"type":"text", "text":"File has 331 lines, but offset 2952 was requested."}]
1433                }]}
1434            ]
1435        }))
1436        .unwrap();
1437        let out = translate_request(&req, opts()).unwrap();
1438        assert_eq!(out.input.len(), 2);
1439        if let ResponsesInputItem::FunctionCallOutput { output, .. } = &out.input[1] {
1440            assert!(output.contains("[tool execution error]"));
1441            assert!(output.contains("File has 331 lines"));
1442            assert!(output.contains("Codex Read guidance:"));
1443            assert!(output.contains("zero based continuation index"));
1444        } else {
1445            panic!("expected FunctionCallOutput");
1446        }
1447    }
1448
1449    #[test]
1450    fn translate_read_unrelated_error_keeps_original_output() {
1451        let req: MessagesRequest = serde_json::from_value(json!({
1452            "model": "gpt-5.5",
1453            "messages": [
1454                {"role":"assistant", "content": [{
1455                    "type": "tool_use",
1456                    "id": "tu_1",
1457                    "name": "Read",
1458                    "input": {"file_path": "/tmp/a", "offset": 10, "limit": 20}
1459                }]},
1460                {"role":"user", "content": [{
1461                    "type": "tool_result",
1462                    "tool_use_id": "tu_1",
1463                    "is_error": true,
1464                    "content": [{"type":"text", "text":"File does not exist."}]
1465                }]}
1466            ]
1467        }))
1468        .unwrap();
1469        let out = translate_request(&req, opts()).unwrap();
1470        assert_eq!(out.input.len(), 2);
1471        if let ResponsesInputItem::FunctionCallOutput { output, .. } = &out.input[1] {
1472            assert_eq!(output, "[tool execution error]\nFile does not exist.");
1473        } else {
1474            panic!("expected FunctionCallOutput");
1475        }
1476    }
1477
1478    #[test]
1479    fn translate_rewritten_read_result_adds_proxy_note() {
1480        crate::providers::codex::translate::read_rewrite::sanitize_read_args(
1481            "Read",
1482            r#"{"file_path":"/tmp/a","offset":1300000,"limit":20}"#,
1483            Some("tu_rewritten_read"),
1484        );
1485        let req: MessagesRequest = serde_json::from_value(json!({
1486            "model": "gpt-5.5",
1487            "messages": [
1488                {"role":"assistant", "content": [{
1489                    "type": "tool_use",
1490                    "id": "tu_rewritten_read",
1491                    "name": "Read",
1492                    "input": {"file_path": "/tmp/a", "limit": 20}
1493                }]},
1494                {"role":"user", "content": [{
1495                    "type": "tool_result",
1496                    "tool_use_id": "tu_rewritten_read",
1497                    "content": [{"type":"text", "text":"1\tcontent"}]
1498                }]}
1499            ]
1500        }))
1501        .unwrap();
1502        let out = translate_request(&req, opts()).unwrap();
1503        assert_eq!(out.input.len(), 2);
1504        if let ResponsesInputItem::FunctionCallOutput { output, .. } = &out.input[1] {
1505            assert!(output.contains("1\tcontent"));
1506            assert!(output.contains("Proxy Read offset note:"));
1507            assert!(output.contains("1300000"));
1508            assert!(output.contains("/tmp/a"));
1509        } else {
1510            panic!("expected FunctionCallOutput");
1511        }
1512    }
1513
1514    #[test]
1515    fn translate_read_success_with_offset_words_keeps_original_output() {
1516        let req: MessagesRequest = serde_json::from_value(json!({
1517            "model": "gpt-5.5",
1518            "messages": [
1519                {"role":"assistant", "content": [{
1520                    "type": "tool_use",
1521                    "id": "tu_1",
1522                    "name": "Read",
1523                    "input": {"file_path": "/tmp/a", "offset": 10, "limit": 20}
1524                }]},
1525                {"role":"user", "content": [{
1526                    "type": "tool_result",
1527                    "tool_use_id": "tu_1",
1528                    "content": [{"type":"text", "text":"File has 331 lines, and the requested offset is shown in this fixture."}]
1529                }]}
1530            ]
1531        }))
1532        .unwrap();
1533        let out = translate_request(&req, opts()).unwrap();
1534        assert_eq!(out.input.len(), 2);
1535        if let ResponsesInputItem::FunctionCallOutput { output, .. } = &out.input[1] {
1536            assert_eq!(
1537                output,
1538                "File has 331 lines, and the requested offset is shown in this fixture."
1539            );
1540        } else {
1541            panic!("expected FunctionCallOutput");
1542        }
1543    }
1544
1545    #[test]
1546    fn tool_result_stringifies_images_and_malformed_blocks() {
1547        let rendered = tool_result_to_string(&json!([
1548            {"type": "text", "text": "caption"},
1549            {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "abc"}},
1550            {"type": "image", "source": {"type": "url", "url": "https://example.invalid/a.png"}},
1551            {"type": "text"},
1552            {"type": "image"},
1553            {}
1554        ]));
1555        assert_eq!(
1556            rendered,
1557            "caption\n[image omitted: image/png]\n[image omitted: url]\n[unsupported content block omitted: text]\n[unsupported content block omitted: image]\n[unsupported content block omitted: unknown]"
1558        );
1559    }
1560
1561    #[test]
1562    fn luna_preserves_high_effort() {
1563        let req: MessagesRequest = serde_json::from_value(json!({
1564            "model": "gpt-5.6-luna",
1565            "messages": [{"role":"user", "content":"hello"}],
1566            "output_config": {"effort": "high"}
1567        }))
1568        .unwrap();
1569        let out = translate_request(
1570            &req,
1571            TranslateOptions {
1572                model: "gpt-5.6-luna".to_string(),
1573                use_responses_lite: true,
1574                ..opts()
1575            },
1576        )
1577        .unwrap();
1578        assert!(matches!(out.reasoning.unwrap().effort, Some(Effort::High)));
1579    }
1580
1581    #[test]
1582    fn sol_preserves_high_effort() {
1583        let req: MessagesRequest = serde_json::from_value(json!({
1584            "model": "gpt-5.6-sol",
1585            "messages": [{"role":"user", "content":"hello"}],
1586            "output_config": {"effort": "high"}
1587        }))
1588        .unwrap();
1589        let out = translate_request(
1590            &req,
1591            TranslateOptions {
1592                model: "gpt-5.6-sol".to_string(),
1593                use_responses_lite: true,
1594                ..opts()
1595            },
1596        )
1597        .unwrap();
1598        assert!(matches!(out.reasoning.unwrap().effort, Some(Effort::High)));
1599    }
1600
1601    #[test]
1602    fn responses_lite_moves_instructions_and_tools_into_input() {
1603        let req: MessagesRequest = serde_json::from_value(json!({
1604            "model": "gpt-5.6-luna",
1605            "messages": [{"role":"user", "content":"hello"}],
1606            "system": "be helpful",
1607            "tools": [{"name":"test","input_schema":{"type":"object"}}]
1608        }))
1609        .unwrap();
1610        let out = translate_request(
1611            &req,
1612            TranslateOptions {
1613                model: "gpt-5.6-luna".to_string(),
1614                use_responses_lite: true,
1615                ..opts()
1616            },
1617        )
1618        .unwrap();
1619        assert!(out.instructions.is_none());
1620        assert!(out.tools.is_none());
1621        assert!(!out.parallel_tool_calls);
1622        assert!(out.client_metadata.is_some());
1623        assert_eq!(out.input.len(), 3);
1624        assert!(matches!(
1625            out.input[0],
1626            ResponsesInputItem::AdditionalTools { .. }
1627        ));
1628        if let ResponsesInputItem::Message { role, content } = &out.input[1] {
1629            assert_eq!(role, "developer");
1630            assert!(matches!(content[0], ResponsesContentPart::InputText { .. }));
1631        } else {
1632            panic!("expected developer message");
1633        }
1634    }
1635
1636    #[test]
1637    fn responses_lite_without_effort_uses_all_turns_context() {
1638        let req: MessagesRequest = serde_json::from_value(json!({
1639            "model": "claude-haiku-4-5",
1640            "messages": [{"role":"user", "content":"hello"}]
1641        }))
1642        .unwrap();
1643        let out = translate_request(
1644            &req,
1645            TranslateOptions {
1646                model: "gpt-5.6-luna".to_string(),
1647                use_responses_lite: true,
1648                ..opts()
1649            },
1650        )
1651        .unwrap();
1652        let reasoning = out.reasoning.unwrap();
1653        assert!(reasoning.effort.is_none());
1654        assert!(reasoning.summary.is_none());
1655        assert_eq!(reasoning.context.as_deref(), Some("all_turns"));
1656        assert!(out.include.is_none());
1657    }
1658
1659    #[test]
1660    fn responses_lite_reasoning_uses_all_turns_context() {
1661        let req: MessagesRequest = serde_json::from_value(json!({
1662            "model": "gpt-5.6-luna",
1663            "messages": [{"role":"user", "content":"hello"}],
1664            "output_config": {"effort": "medium"}
1665        }))
1666        .unwrap();
1667        let out = translate_request(
1668            &req,
1669            TranslateOptions {
1670                model: "gpt-5.6-luna".to_string(),
1671                use_responses_lite: true,
1672                ..opts()
1673            },
1674        )
1675        .unwrap();
1676        assert_eq!(out.reasoning.unwrap().context.as_deref(), Some("all_turns"));
1677    }
1678
1679    #[test]
1680    fn translate_returns_only_expected_top_level_fields() {
1681        let req: MessagesRequest = serde_json::from_value(json!({
1682            "model": "claude-sonnet-4-6",
1683            "messages": [{"role":"user", "content":"hello"}],
1684            "system": "be helpful",
1685            "tools": [{"name":"test","input_schema":{"type":"object"}}],
1686            "tool_choice": {"type":"tool", "name":"test"}
1687        }))
1688        .unwrap();
1689        let out = translate_request(
1690            &req,
1691            TranslateOptions {
1692                model: "gpt-5.4".to_string(),
1693                ..opts()
1694            },
1695        )
1696        .unwrap();
1697        assert_eq!(out.model, "gpt-5.4");
1698        let out_value = serde_json::to_value(&out).unwrap();
1699        let keys: std::collections::BTreeSet<String> =
1700            out_value.as_object().unwrap().keys().cloned().collect();
1701        for key in &[
1702            "model",
1703            "input",
1704            "store",
1705            "stream",
1706            "parallel_tool_calls",
1707            "text",
1708        ] {
1709            assert!(keys.contains(*key), "missing key: {key}");
1710        }
1711    }
1712}