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#[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
217fn 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
309pub 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 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
445fn 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 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
833fn 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 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 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}