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