1use std::collections::HashSet;
7
8use serde_json::{json, Map, Value};
9
10use crate::codecs::common::{
11 is_known_role_name, provider_extensions, reasoning_text_from_blocks, text_from_blocks,
12};
13use crate::codecs::openai_chat::{decode_file_source, decode_image_source};
14use crate::codecs::{
15 DecodedRequest, DecodedResponse, EncodedRequest, EncodedResponse, FormatCodec,
16};
17use crate::diagnostic::TranslationDiagnostic;
18use crate::error::{Result, TranslationError};
19use crate::format::{FormatId, WireFormat};
20use crate::llm::{
21 ContentBlock, LlmRequest, LlmResponse, MediaSource, Message, OutputParams, ProviderExtensions,
22 ReasoningParams, ResponseOutput, Role, SamplingParams, StopReason, ToolCall, ToolChoice,
23 ToolDefinition, ToolResult, Usage,
24};
25use crate::policy::{DeterministicIdPolicy, TranslationPolicy};
26use crate::util::{
27 capture_request_preservation, capture_response_preservation, embed_preservation,
28 exact_preserved_request, exact_preserved_response, json_string, push_lossy, stable_id,
29 string_value, validate_request_capabilities,
30};
31
32pub struct OpenAiResponsesCodec;
34
35impl FormatCodec for OpenAiResponsesCodec {
36 fn format(&self) -> FormatId {
37 WireFormat::OpenAiResponses.into()
38 }
39
40 fn decode_request(&self, body: &Value, policy: &TranslationPolicy) -> Result<DecodedRequest> {
41 let body = crate::util::object(body, "$")?;
42 let mut diagnostics = Vec::new();
43 let mut request = LlmRequest {
44 model: body
45 .get("model")
46 .and_then(Value::as_str)
47 .filter(|model| !model.is_empty())
48 .map(ToOwned::to_owned),
49 output: OutputParams {
50 max_output_tokens: body.get("max_output_tokens").and_then(Value::as_u64),
51 response_format: decode_responses_text_format(body.get("text")),
52 },
53 reasoning: ReasoningParams {
54 effort: body
55 .get("reasoning")
56 .and_then(Value::as_object)
57 .and_then(|object| object.get("effort"))
58 .and_then(Value::as_str)
59 .map(ToOwned::to_owned),
60 raw: body.get("reasoning").cloned(),
61 },
62 sampling: SamplingParams {
63 temperature: body.get("temperature").and_then(Value::as_f64),
64 top_p: body.get("top_p").and_then(Value::as_f64),
65 top_k: None,
66 },
67 stream: body.get("stream").and_then(Value::as_bool).unwrap_or(false),
68 preservation: capture_request_preservation(
69 WireFormat::OpenAiResponses,
70 &Value::Object(body.clone()),
71 policy,
72 ),
73 ..LlmRequest::default()
74 };
75 if let Some(instructions) = body.get("instructions").and_then(Value::as_str) {
76 if !instructions.is_empty() {
77 request.instructions.push(crate::llm::InstructionBlock {
78 role: Role::System,
79 content: vec![ContentBlock::Text {
80 text: instructions.to_string(),
81 }],
82 });
83 }
84 }
85 request.messages = decode_responses_input(
86 body.get("input").unwrap_or(&Value::String(String::new())),
87 &mut diagnostics,
88 policy,
89 )?;
90 request.tools = decode_responses_tools(body.get("tools"));
91 request.tool_choice = body
92 .get("tool_choice")
93 .and_then(decode_responses_tool_choice);
94 request.extensions.fields = provider_extensions(
95 body,
96 &[
97 "model",
98 "instructions",
99 "input",
100 "tools",
101 "tool_choice",
102 "max_output_tokens",
103 "text",
104 "reasoning",
105 "temperature",
106 "top_p",
107 "stream",
108 ],
109 );
110 Ok(DecodedRequest {
111 request,
112 diagnostics,
113 })
114 }
115
116 fn encode_request(
117 &self,
118 request: &LlmRequest,
119 _policy: &TranslationPolicy,
120 ) -> Result<EncodedRequest> {
121 if let Some(body) =
122 exact_preserved_request(&request.preservation, WireFormat::OpenAiResponses, _policy)
123 {
124 return Ok(EncodedRequest {
125 body,
126 diagnostics: Vec::new(),
127 });
128 }
129 let mut diagnostics = Vec::new();
130 validate_request_capabilities(request, &mut diagnostics, _policy)?;
131 let mut body = Map::new();
132 if let Some(model) = &request.model {
133 body.insert("model".to_string(), Value::String(model.clone()));
134 }
135 let instructions = request
136 .instructions
137 .iter()
138 .flat_map(|instruction| instruction.content.iter())
139 .filter_map(|block| match block {
140 ContentBlock::Text { text } | ContentBlock::Refusal { text } => Some(text.as_str()),
141 _ => None,
142 })
143 .collect::<Vec<_>>()
144 .join("\n\n");
145 if !instructions.is_empty() {
146 body.insert("instructions".to_string(), Value::String(instructions));
147 }
148 body.insert(
149 "input".to_string(),
150 encode_responses_input(&request.messages, &mut diagnostics, _policy)?,
151 );
152 if !request.tools.is_empty() {
153 body.insert("tools".to_string(), encode_responses_tools(&request.tools));
154 }
155 if let Some(choice) = &request.tool_choice {
156 if let Some(choice) = encode_responses_tool_choice(choice) {
157 body.insert("tool_choice".to_string(), choice);
158 }
159 }
160 if let Some(max_output_tokens) = request.output.max_output_tokens {
161 body.insert("max_output_tokens".to_string(), json!(max_output_tokens));
162 }
163 if let Some(response_format) = &request.output.response_format {
164 body.insert("text".to_string(), json!({"format": response_format}));
165 }
166 if let Some(effort) = &request.reasoning.effort {
167 body.insert("reasoning".to_string(), json!({"effort": effort}));
168 }
169 if let Some(value) = request.sampling.temperature {
170 body.insert("temperature".to_string(), json!(value));
171 }
172 if let Some(value) = request.sampling.top_p {
173 body.insert("top_p".to_string(), json!(value));
174 }
175 if request.stream {
176 body.insert("stream".to_string(), Value::Bool(true));
177 }
178 let body = embed_preservation(Value::Object(body), &request.preservation, _policy);
179 Ok(EncodedRequest { body, diagnostics })
180 }
181
182 fn decode_response(&self, body: &Value, policy: &TranslationPolicy) -> Result<DecodedResponse> {
183 let body = crate::util::object(body, "$")?;
184 let mut diagnostics = Vec::new();
185 let mut outputs = Vec::new();
186 if let Some(items) = body.get("output").and_then(Value::as_array) {
187 for item in items {
188 if let Some(output) = decode_responses_output_item(item, &mut diagnostics, policy)?
189 {
190 outputs.push(output);
191 }
192 }
193 }
194 if outputs.is_empty() {
195 outputs.push(ResponseOutput {
196 role: Role::Assistant,
197 content: vec![ContentBlock::Text {
198 text: String::new(),
199 }],
200 stop_reason: Some(StopReason::EndTurn),
201 });
202 }
203 let response = LlmResponse {
204 id: body
205 .get("id")
206 .and_then(Value::as_str)
207 .map(ToOwned::to_owned),
208 model: body
209 .get("model")
210 .and_then(Value::as_str)
211 .map(ToOwned::to_owned),
212 outputs,
213 usage: decode_responses_usage(body.get("usage")),
214 extensions: ProviderExtensions {
215 fields: provider_extensions(
216 body,
217 &["id", "model", "object", "output", "usage", "status"],
218 ),
219 },
220 preservation: capture_response_preservation(
221 WireFormat::OpenAiResponses,
222 &Value::Object(body.clone()),
223 policy,
224 ),
225 };
226 Ok(DecodedResponse {
227 response,
228 diagnostics,
229 })
230 }
231
232 fn encode_response(
233 &self,
234 response: &LlmResponse,
235 _policy: &TranslationPolicy,
236 ) -> Result<EncodedResponse> {
237 if let Some(body) =
238 exact_preserved_response(&response.preservation, WireFormat::OpenAiResponses, _policy)
239 {
240 return Ok(EncodedResponse {
241 body,
242 diagnostics: Vec::new(),
243 });
244 }
245 Ok(EncodedResponse {
246 body: embed_preservation(
247 json!({
248 "id": response.id.clone().unwrap_or_else(|| "resp_switchyard".to_string()),
249 "object": "response",
250 "created_at": 0,
251 "model": response.model.clone().unwrap_or_else(|| "unknown".to_string()),
252 "status": "completed",
253 "output": encode_responses_output(&response.outputs),
254 "usage": encode_responses_usage(&response.usage),
255 "parallel_tool_calls": true,
256 "tool_choice": "auto",
257 "tools": [],
258 }),
259 &response.preservation,
260 _policy,
261 ),
262 diagnostics: Vec::new(),
263 })
264 }
265}
266
267fn decode_responses_input(
269 value: &Value,
270 diagnostics: &mut Vec<TranslationDiagnostic>,
271 policy: &TranslationPolicy,
272) -> Result<Vec<Message>> {
273 match value {
274 Value::String(text) => Ok(vec![Message::text(Role::User, text)]),
275 Value::Array(items) => {
276 let mut messages = Vec::new();
277 let mut pending_tool_calls = Vec::new();
278 let mut pending_tool_outputs = Vec::new();
279 let mut deferred_messages = Vec::new();
280 for (index, item) in items.iter().enumerate() {
281 let Some(item) = item.as_object() else {
282 push_lossy(
283 diagnostics,
284 policy,
285 format!("Responses input item {index} is not an object"),
286 )?;
287 continue;
288 };
289 match item.get("type").and_then(Value::as_str) {
290 Some("message") => {
291 let message = Message {
292 role: request_role_from_responses(
293 item.get("role").and_then(Value::as_str),
294 &format!("$.input[{index}].role"),
295 )?,
296 content: decode_responses_content(
297 item.get("content").unwrap_or(&Value::Null),
298 ),
299 };
300 push_responses_non_tool_message(
301 &mut messages,
302 &mut pending_tool_calls,
303 &mut pending_tool_outputs,
304 &mut deferred_messages,
305 message,
306 );
307 }
308 Some("reasoning") => {
309 let message = Message {
310 role: Role::Assistant,
311 content: decode_responses_reasoning_item(item),
312 };
313 push_responses_non_tool_message(
314 &mut messages,
315 &mut pending_tool_calls,
316 &mut pending_tool_outputs,
317 &mut deferred_messages,
318 message,
319 );
320 }
321 Some("function_call") => {
322 if !pending_tool_outputs.is_empty() {
323 flush_responses_tool_block(
324 &mut messages,
325 &mut pending_tool_calls,
326 &mut pending_tool_outputs,
327 &mut deferred_messages,
328 );
329 }
330 let id = item
331 .get("call_id")
332 .and_then(Value::as_str)
333 .filter(|id| !id.is_empty())
334 .map(ToOwned::to_owned)
335 .unwrap_or_else(|| match &policy.deterministic_ids {
336 DeterministicIdPolicy::GenerateStable { prefix } => {
337 stable_id(prefix, index + 1)
338 }
339 DeterministicIdPolicy::Preserve => String::new(),
340 });
341 pending_tool_calls.push(ToolCall {
342 id,
343 name: item
344 .get("name")
345 .and_then(Value::as_str)
346 .unwrap_or_default()
347 .to_string(),
348 arguments: item.get("arguments").cloned().unwrap_or_else(|| json!({})),
349 });
350 }
351 Some("function_call_output") => {
352 let tool_call_id = item
353 .get("call_id")
354 .and_then(Value::as_str)
355 .unwrap_or_default()
356 .to_string();
357 let output_text = item.get("output").map(json_string).unwrap_or_default();
358 pending_tool_outputs.push(ToolResult {
359 tool_call_id,
360 content: vec![ContentBlock::Text { text: output_text }],
361 is_error: None,
362 });
363 }
364 _ => {
365 let message = Message {
366 role: Role::User,
367 content: vec![ContentBlock::Unknown {
368 provider: WireFormat::OpenAiResponses.into(),
369 raw: Value::Object(item.clone()),
370 }],
371 };
372 push_responses_non_tool_message(
373 &mut messages,
374 &mut pending_tool_calls,
375 &mut pending_tool_outputs,
376 &mut deferred_messages,
377 message,
378 );
379 }
380 }
381 }
382 if !pending_tool_calls.is_empty() || !pending_tool_outputs.is_empty() {
383 flush_responses_tool_block(
384 &mut messages,
385 &mut pending_tool_calls,
386 &mut pending_tool_outputs,
387 &mut deferred_messages,
388 );
389 }
390 Ok(messages)
391 }
392 other => Ok(vec![Message::text(
393 Role::User,
394 string_value(other).unwrap_or_default(),
395 )]),
396 }
397}
398
399fn push_responses_non_tool_message(
401 messages: &mut Vec<Message>,
402 pending_tool_calls: &mut Vec<ToolCall>,
403 pending_tool_outputs: &mut Vec<ToolResult>,
404 deferred_messages: &mut Vec<Message>,
405 message: Message,
406) {
407 if !pending_tool_calls.is_empty() && pending_tool_outputs.is_empty() {
408 deferred_messages.push(message);
409 return;
410 }
411 if !pending_tool_calls.is_empty() || !pending_tool_outputs.is_empty() {
412 flush_responses_tool_block(
413 messages,
414 pending_tool_calls,
415 pending_tool_outputs,
416 deferred_messages,
417 );
418 }
419 messages.push(message);
420}
421
422fn flush_responses_tool_block(
424 messages: &mut Vec<Message>,
425 pending_tool_calls: &mut Vec<ToolCall>,
426 pending_tool_outputs: &mut Vec<ToolResult>,
427 deferred_messages: &mut Vec<Message>,
428) {
429 let tool_call_ids = pending_tool_calls
430 .iter()
431 .map(|call| call.id.clone())
432 .collect::<HashSet<_>>();
433
434 if !pending_tool_calls.is_empty() {
435 messages.push(Message {
436 role: Role::Assistant,
437 content: std::mem::take(pending_tool_calls)
438 .into_iter()
439 .map(ContentBlock::ToolCall)
440 .collect(),
441 });
442 }
443
444 for output in std::mem::take(pending_tool_outputs) {
445 if tool_call_ids.contains(&output.tool_call_id) {
446 messages.push(Message {
447 role: Role::User,
448 content: vec![ContentBlock::ToolResult(output)],
449 });
450 } else {
451 let tool_call_id = output.tool_call_id;
452 let output_text = text_from_blocks(&output.content, " ");
453 messages.push(Message::text(
454 Role::User,
455 format!("Tool result {tool_call_id}: {output_text}"),
456 ));
457 }
458 }
459
460 messages.append(deferred_messages);
461}
462
463fn decode_responses_reasoning_item(item: &Map<String, Value>) -> Vec<ContentBlock> {
465 let mut parts = Vec::new();
466 collect_responses_reasoning_text(item.get("content"), &mut parts);
467 collect_responses_reasoning_text(item.get("summary"), &mut parts);
468 if let Some(text) = item.get("text").and_then(Value::as_str) {
469 parts.push(text.to_string());
470 }
471 vec![ContentBlock::Reasoning {
472 text: parts.join("\n"),
473 signature: None,
474 }]
475}
476
477fn collect_responses_reasoning_text(value: Option<&Value>, out: &mut Vec<String>) {
479 match value {
480 Some(Value::String(text)) if !text.is_empty() => out.push(text.clone()),
481 Some(Value::Array(items)) => {
482 for item in items {
483 match item {
484 Value::String(text) if !text.is_empty() => out.push(text.clone()),
485 Value::Object(object) => {
486 if matches!(
487 object.get("type").and_then(Value::as_str),
488 Some("reasoning_text" | "summary_text" | "text")
489 ) {
490 if let Some(text) = object.get("text").and_then(Value::as_str) {
491 if !text.is_empty() {
492 out.push(text.to_string());
493 }
494 }
495 }
496 }
497 _ => {}
498 }
499 }
500 }
501 _ => {}
502 }
503}
504
505fn decode_responses_content(value: &Value) -> Vec<ContentBlock> {
507 match value {
508 Value::String(text) => vec![ContentBlock::Text { text: text.clone() }],
509 Value::Array(blocks) => {
510 let mut out = Vec::new();
511 for block in blocks {
512 let Some(block) = block.as_object() else {
513 continue;
514 };
515 match block.get("type").and_then(Value::as_str) {
516 Some("input_text") | Some("output_text") | Some("text") => {
517 out.push(ContentBlock::Text {
518 text: block
519 .get("text")
520 .and_then(Value::as_str)
521 .unwrap_or_default()
522 .to_string(),
523 });
524 }
525 Some("refusal") => out.push(ContentBlock::Refusal {
526 text: block
527 .get("refusal")
528 .and_then(Value::as_str)
529 .unwrap_or_default()
530 .to_string(),
531 }),
532 Some("reasoning_text") | Some("summary_text") => {
533 out.push(ContentBlock::Reasoning {
534 text: block
535 .get("text")
536 .and_then(Value::as_str)
537 .unwrap_or_default()
538 .to_string(),
539 signature: None,
540 });
541 }
542 Some("input_image") => {
543 if let Some(source) = decode_image_source(block) {
544 out.push(ContentBlock::Image { source });
545 }
546 }
547 Some("input_file") => out.push(ContentBlock::File {
548 source: decode_file_source(block),
549 }),
550 _ => out.push(ContentBlock::Unknown {
551 provider: WireFormat::OpenAiResponses.into(),
552 raw: Value::Object(block.clone()),
553 }),
554 }
555 }
556 if out.is_empty() {
557 out.push(ContentBlock::Text {
558 text: String::new(),
559 });
560 }
561 out
562 }
563 Value::Null => vec![ContentBlock::Text {
564 text: String::new(),
565 }],
566 other => vec![ContentBlock::Text {
567 text: string_value(other).unwrap_or_default(),
568 }],
569 }
570}
571
572fn role_from_responses(role: Option<&str>) -> Role {
576 match role {
577 Some("assistant") => Role::Assistant,
578 Some("system") => Role::System,
579 Some("developer") => Role::Developer,
580 _ => Role::User,
581 }
582}
583
584fn request_role_from_responses(role: Option<&str>, path: &str) -> Result<Role> {
592 match role {
593 Some("assistant") => Ok(Role::Assistant),
594 Some("system") => Ok(Role::System),
595 Some("developer") => Ok(Role::Developer),
596 None => Ok(Role::User),
597 Some(other) if is_known_role_name(other) => Ok(Role::User),
598 Some(other) => Err(TranslationError::unsupported_role(path, other)),
599 }
600}
601
602fn decode_responses_tools(value: Option<&Value>) -> Vec<ToolDefinition> {
604 let Some(tools) = value.and_then(Value::as_array) else {
605 return Vec::new();
606 };
607 let mut out = Vec::new();
608 for tool in tools {
609 let Some(tool) = tool.as_object() else {
610 continue;
611 };
612 if tool.get("type").and_then(Value::as_str) == Some("function") {
613 if let Some(function) = tool.get("function").and_then(Value::as_object) {
614 if let Some(name) = function.get("name").and_then(Value::as_str) {
615 if !name.is_empty() {
616 out.push(ToolDefinition {
617 name: name.to_string(),
618 description: function
619 .get("description")
620 .and_then(Value::as_str)
621 .map(ToOwned::to_owned),
622 parameters: function
623 .get("parameters")
624 .cloned()
625 .unwrap_or_else(|| json!({})),
626 strict: function.get("strict").and_then(Value::as_bool),
627 });
628 }
629 }
630 } else {
631 if !push_responses_function_tool(&mut out, tool) {
632 push_responses_id_tool(&mut out, tool);
633 }
634 }
635 } else if tool.get("type").is_none() && tool.contains_key("name") {
636 push_responses_function_tool(&mut out, tool);
637 } else {
638 push_responses_id_tool(&mut out, tool);
639 }
640 }
641 out
642}
643
644fn push_responses_function_tool(
646 out: &mut Vec<ToolDefinition>,
647 tool: &serde_json::Map<String, Value>,
648) -> bool {
649 let Some(name) = tool.get("name").and_then(Value::as_str) else {
650 return false;
651 };
652 if name.is_empty() {
653 return false;
654 }
655
656 out.push(ToolDefinition {
657 name: name.to_string(),
658 description: tool
659 .get("description")
660 .and_then(Value::as_str)
661 .map(ToOwned::to_owned),
662 parameters: tool.get("parameters").cloned().unwrap_or_else(|| json!({})),
663 strict: tool.get("strict").and_then(Value::as_bool),
664 });
665 true
666}
667
668fn push_responses_id_tool(
670 out: &mut Vec<ToolDefinition>,
671 tool: &serde_json::Map<String, Value>,
672) -> bool {
673 let Some(id) = tool.get("id").and_then(Value::as_str) else {
674 return false;
675 };
676 if id.is_empty() {
677 return false;
678 }
679
680 out.push(ToolDefinition {
681 name: id.to_string(),
682 description: tool
683 .get("description")
684 .and_then(Value::as_str)
685 .map(ToOwned::to_owned),
686 parameters: tool
687 .get("inputSchema")
688 .and_then(Value::as_object)
689 .and_then(|schema| schema.get("jsonSchema"))
690 .cloned()
691 .unwrap_or_else(|| json!({})),
692 strict: None,
693 });
694 true
695}
696
697fn decode_responses_tool_choice(value: &Value) -> Option<ToolChoice> {
699 match value {
700 Value::String(text) if text == "auto" => Some(ToolChoice::Auto),
701 Value::String(text) if text == "required" => Some(ToolChoice::Required),
702 Value::String(text) if text == "none" => Some(ToolChoice::None),
703 Value::Object(object) if object.get("type").and_then(Value::as_str) == Some("function") => {
704 object
705 .get("name")
706 .and_then(Value::as_str)
707 .map(|name| ToolChoice::Tool {
708 name: name.to_string(),
709 })
710 }
711 Value::Object(_) => None,
712 _ => Some(ToolChoice::Raw(value.clone())),
713 }
714}
715
716fn decode_responses_text_format(value: Option<&Value>) -> Option<Value> {
718 let format = value
719 .and_then(Value::as_object)
720 .and_then(|object| object.get("format"))
721 .and_then(Value::as_object)?;
722
723 match format.get("type").and_then(Value::as_str) {
724 Some("json_schema") => {
725 if let Some(json_schema) = format.get("json_schema").and_then(Value::as_object) {
726 return Some(json!({
727 "type": "json_schema",
728 "json_schema": Value::Object(json_schema.clone()),
729 }));
730 }
731
732 let mut json_schema = Map::new();
733 for field in ["name", "description", "schema", "strict"] {
734 if let Some(value) = format.get(field) {
735 json_schema.insert(field.to_string(), value.clone());
736 }
737 }
738 (!json_schema.is_empty()).then_some(json!({
739 "type": "json_schema",
740 "json_schema": Value::Object(json_schema),
741 }))
742 }
743 Some("json_object") => Some(json!({"type": "json_object"})),
744 Some("text") => Some(json!({"type": "text"})),
745 _ => None,
746 }
747}
748
749fn encode_responses_input(
751 messages: &[Message],
752 diagnostics: &mut Vec<TranslationDiagnostic>,
753 policy: &TranslationPolicy,
754) -> Result<Value> {
755 if messages.len() == 1
756 && matches!(messages[0].role, Role::User)
757 && messages[0].content.len() == 1
758 && matches!(messages[0].content[0], ContentBlock::Text { .. })
759 {
760 if let ContentBlock::Text { text } = &messages[0].content[0] {
761 return Ok(Value::String(text.clone()));
762 }
763 }
764 let mut encoded = Vec::new();
765 for message in messages {
766 if message.content.iter().any(|block| {
767 matches!(
768 block,
769 ContentBlock::ToolCall(_) | ContentBlock::ToolResult(_)
770 )
771 }) {
772 encoded.extend(
773 message
774 .content
775 .iter()
776 .filter_map(encode_responses_special_input),
777 );
778 continue;
779 }
780 let mut visible_content = Vec::new();
781 let mut emitted_special = false;
782 for block in &message.content {
783 if let Some(item) = encode_responses_special_input(block) {
784 encoded.push(item);
785 emitted_special = true;
786 } else {
787 visible_content.push(block.clone());
788 }
789 }
790 if !visible_content.is_empty() || !emitted_special {
791 let content = encode_responses_content(&visible_content, diagnostics, policy)?;
792 encoded.push(json!({
793 "type": "message",
794 "role": role_to_responses(message.role),
795 "content": content,
796 }));
797 }
798 }
799 Ok(Value::Array(encoded))
800}
801
802fn encode_responses_special_input(block: &ContentBlock) -> Option<Value> {
804 match block {
805 ContentBlock::Reasoning { text, .. } => Some(json!({
806 "type": "reasoning",
807 "content": [{"type": "reasoning_text", "text": text}],
808 "summary": [],
809 })),
810 ContentBlock::ToolCall(call) => Some(json!({
811 "type": "function_call",
812 "call_id": call.id,
813 "name": call.name,
814 "arguments": call.arguments,
815 })),
816 ContentBlock::ToolResult(result) => Some(json!({
817 "type": "function_call_output",
818 "call_id": result.tool_call_id,
819 "output": text_from_blocks(&result.content, " "),
820 })),
821 _ => None,
822 }
823}
824
825fn role_to_responses(role: Role) -> &'static str {
827 match role {
828 Role::Assistant => "assistant",
829 Role::System => "system",
830 Role::Developer => "developer",
831 Role::User | Role::Tool => "user",
832 }
833}
834
835fn encode_responses_content(
837 content: &[ContentBlock],
838 diagnostics: &mut Vec<TranslationDiagnostic>,
839 policy: &TranslationPolicy,
840) -> Result<Value> {
841 let has_non_text = content.iter().any(|block| {
842 !matches!(
843 block,
844 ContentBlock::Text { .. }
845 | ContentBlock::Refusal { .. }
846 | ContentBlock::Reasoning { .. }
847 )
848 });
849 if !has_non_text {
850 return Ok(Value::String(text_from_blocks(content, "\n")));
851 }
852 let mut blocks = Vec::new();
853 for block in content {
854 match block {
855 ContentBlock::Text { text } => blocks.push(json!({"type": "input_text", "text": text})),
856 ContentBlock::Refusal { text } => {
857 blocks.push(json!({"type": "refusal", "refusal": text}));
858 }
859 ContentBlock::Image { source } => {
860 blocks.push(json!({"type": "input_image", "image_url": source}));
861 }
862 ContentBlock::Audio { source } => blocks.push(match source {
863 MediaSource::Raw(raw) => json!({"type": "input_text", "text": json_string(raw)}),
864 MediaSource::Url { url, media_type } => json!({
865 "type": "input_audio",
866 "audio_url": url,
867 "media_type": media_type,
868 }),
869 MediaSource::Base64 { media_type, data } => json!({
870 "type": "input_audio",
871 "audio": {"media_type": media_type, "data": data},
872 }),
873 }),
874 ContentBlock::Video { source } => blocks.push(match source {
875 MediaSource::Raw(raw) => json!({"type": "input_text", "text": json_string(raw)}),
876 MediaSource::Url { url, media_type } => json!({
877 "type": "input_video",
878 "video_url": url,
879 "media_type": media_type,
880 }),
881 MediaSource::Base64 { media_type, data } => json!({
882 "type": "input_video",
883 "video": {"media_type": media_type, "data": data},
884 }),
885 }),
886 ContentBlock::File { source } => {
887 blocks.push(json!({"type": "input_file", "file": source}));
888 }
889 ContentBlock::Unknown { raw, .. } => {
890 push_lossy(
891 diagnostics,
892 policy,
893 "unknown content block encoded as text for Responses",
894 )?;
895 blocks.push(json!({"type": "input_text", "text": json_string(raw)}));
896 }
897 ContentBlock::Reasoning { .. }
898 | ContentBlock::ToolCall(_)
899 | ContentBlock::ToolResult(_) => {}
900 }
901 }
902 Ok(Value::Array(blocks))
903}
904
905fn encode_responses_tools(tools: &[ToolDefinition]) -> Value {
907 Value::Array(
908 tools
909 .iter()
910 .map(|tool| {
911 let mut item = json!({
912 "type": "function",
913 "name": tool.name,
914 "description": tool.description.clone().unwrap_or_default(),
915 "parameters": tool.parameters,
916 });
917 if let Some(strict) = tool.strict {
918 item["strict"] = Value::Bool(strict);
919 }
920 item
921 })
922 .collect(),
923 )
924}
925
926fn encode_responses_tool_choice(choice: &ToolChoice) -> Option<Value> {
928 match choice {
929 ToolChoice::Auto => Some(Value::String("auto".to_string())),
930 ToolChoice::Required => Some(Value::String("required".to_string())),
931 ToolChoice::None => Some(Value::String("none".to_string())),
932 ToolChoice::Tool { name } => Some(json!({"type": "function", "name": name})),
933 ToolChoice::Raw(value) => Some(value.clone()),
934 }
935}
936
937fn decode_responses_output_item(
939 item: &Value,
940 diagnostics: &mut Vec<TranslationDiagnostic>,
941 policy: &TranslationPolicy,
942) -> Result<Option<ResponseOutput>> {
943 let Some(item) = item.as_object() else {
944 push_lossy(
945 diagnostics,
946 policy,
947 "Responses output item is not an object",
948 )?;
949 return Ok(None);
950 };
951 match item.get("type").and_then(Value::as_str) {
952 Some("message") => Ok(Some(ResponseOutput {
953 role: role_from_responses(item.get("role").and_then(Value::as_str)),
954 content: decode_responses_content(item.get("content").unwrap_or(&Value::Null)),
955 stop_reason: Some(StopReason::EndTurn),
956 })),
957 Some("function_call") => Ok(Some(ResponseOutput {
958 role: Role::Assistant,
959 content: vec![ContentBlock::ToolCall(ToolCall {
960 id: item
961 .get("call_id")
962 .and_then(Value::as_str)
963 .unwrap_or_default()
964 .to_string(),
965 name: item
966 .get("name")
967 .and_then(Value::as_str)
968 .unwrap_or_default()
969 .to_string(),
970 arguments: item.get("arguments").cloned().unwrap_or_else(|| json!({})),
971 })],
972 stop_reason: Some(StopReason::ToolUse),
973 })),
974 Some("reasoning") => Ok(Some(ResponseOutput {
975 role: Role::Assistant,
976 content: decode_responses_reasoning_item(item),
977 stop_reason: None,
978 })),
979 _ => Ok(None),
980 }
981}
982
983fn encode_responses_output(outputs: &[ResponseOutput]) -> Value {
985 Value::Array(
986 outputs
987 .iter()
988 .flat_map(|output| {
989 let has_tool_calls = output
990 .content
991 .iter()
992 .any(|block| matches!(block, ContentBlock::ToolCall(_)));
993 let text = text_from_blocks(&output.content, "");
994 let reasoning = reasoning_text_from_blocks(&output.content, "\n");
995 let mut items = Vec::new();
996
997 if !reasoning.is_empty() {
998 items.push(encode_responses_reasoning_output(&reasoning));
999 }
1000
1001 if !text.is_empty() || (!has_tool_calls && reasoning.is_empty()) {
1002 items.push(json!({
1003 "type": "message",
1004 "id": "msg_switchyard",
1005 "status": "completed",
1006 "role": role_to_responses(output.role),
1007 "content": [{
1008 "type": "output_text",
1009 "text": text,
1010 "annotations": [],
1011 }],
1012 }));
1013 }
1014
1015 items.extend(output.content.iter().filter_map(|block| match block {
1016 ContentBlock::ToolCall(call) => Some(json!({
1017 "type": "function_call",
1018 "call_id": call.id,
1019 "name": call.name,
1020 "arguments": json_string_python_style(&call.arguments),
1021 })),
1022 _ => None,
1023 }));
1024
1025 items
1026 })
1027 .collect(),
1028 )
1029}
1030
1031fn encode_responses_reasoning_output(text: &str) -> Value {
1033 json!({
1034 "type": "reasoning",
1035 "id": "rs_switchyard",
1036 "status": "completed",
1037 "content": [{
1038 "type": "reasoning_text",
1039 "text": text,
1040 }],
1041 "summary": [],
1042 })
1043}
1044
1045fn json_string_python_style(value: &Value) -> String {
1047 match value {
1048 Value::Null => "null".to_string(),
1049 Value::Bool(value) => value.to_string(),
1050 Value::Number(value) => value.to_string(),
1051 Value::String(value) => serde_json::to_string(value).unwrap_or_else(|_| value.clone()),
1052 Value::Array(items) => {
1053 let body = items
1054 .iter()
1055 .map(json_string_python_style)
1056 .collect::<Vec<_>>()
1057 .join(", ");
1058 format!("[{body}]")
1059 }
1060 Value::Object(object) => {
1061 let body = object
1062 .iter()
1063 .map(|(key, value)| {
1064 let key = serde_json::to_string(key).unwrap_or_else(|_| format!("\"{key}\""));
1065 format!("{key}: {}", json_string_python_style(value))
1066 })
1067 .collect::<Vec<_>>()
1068 .join(", ");
1069 format!("{{{body}}}")
1070 }
1071 }
1072}
1073
1074fn decode_responses_usage(value: Option<&Value>) -> Usage {
1076 let Some(value) = value.and_then(Value::as_object) else {
1077 return Usage::default();
1078 };
1079 let input_tokens = value
1080 .get("input_tokens")
1081 .or_else(|| value.get("prompt_tokens"))
1082 .and_then(Value::as_u64);
1083 let output_tokens = value
1084 .get("output_tokens")
1085 .or_else(|| value.get("completion_tokens"))
1086 .and_then(Value::as_u64);
1087 Usage {
1088 input_tokens,
1089 output_tokens,
1090 total_tokens: value
1091 .get("total_tokens")
1092 .and_then(Value::as_u64)
1093 .or_else(|| {
1094 input_tokens
1095 .zip(output_tokens)
1096 .map(|(input, output)| input + output)
1097 }),
1098 reasoning_tokens: value
1099 .get("output_tokens_details")
1100 .and_then(|details| details.get("reasoning_tokens"))
1101 .or_else(|| {
1102 value
1103 .get("completion_tokens_details")
1104 .and_then(|details| details.get("reasoning_tokens"))
1105 })
1106 .and_then(Value::as_u64),
1107 }
1108}
1109
1110fn encode_responses_usage(usage: &Usage) -> Value {
1112 let mut value = json!({
1113 "input_tokens": usage.input_tokens.unwrap_or(0),
1114 "output_tokens": usage.output_tokens.unwrap_or(0),
1115 "total_tokens": usage
1116 .total_tokens
1117 .or_else(|| Some(usage.input_tokens.unwrap_or(0) + usage.output_tokens.unwrap_or(0)))
1118 .unwrap_or(0),
1119 });
1120 if let Some(reasoning_tokens) = usage.reasoning_tokens {
1121 value["output_tokens_details"] = json!({
1122 "reasoning_tokens": reasoning_tokens,
1123 });
1124 }
1125 value
1126}