1use serde_json::{Map, Value, json};
7
8use crate::codecs::common::{
9 first_nonempty_string, is_known_role_name, provider_extensions, reasoning_text_from_blocks,
10 reasoning_text_from_details, text_from_blocks,
11};
12use crate::codecs::openai_media::{
13 ImagePayload, file_payload, file_source_text, image_payload, image_source_text,
14};
15use crate::codecs::structured_output::{decode_openai_schema_enforcement, encode_openai_format};
16use crate::codecs::{
17 DecodedRequest, DecodedResponse, EncodedRequest, EncodedResponse, FormatCodec,
18};
19use crate::diagnostic::TranslationDiagnostic;
20use crate::error::{Result, TranslationError};
21use crate::format::{FormatId, WireFormat};
22use crate::llm::{
23 AggLlmResponse, ContentBlock, FileSource, ImageSource, InstructionBlock, LlmRequest,
24 MediaSource, Message, OutputParams, ProviderExtensions, ReasoningParams, ResponseOutput, Role,
25 SamplingParams, StopReason, Tool, ToolCall, ToolChoice, ToolDefinition, ToolResult, Usage,
26};
27use crate::policy::{DeterministicIdPolicy, TranslationPolicy};
28use crate::util::{
29 capture_request_preservation, capture_response_preservation, embed_preservation,
30 exact_preserved_request, exact_preserved_response, json_string, object, push_lossy,
31 reject_responses_native_input_item, stable_id, string_value, validate_request_capabilities,
32};
33
34pub struct OpenAiChatCodec;
36
37impl FormatCodec for OpenAiChatCodec {
38 fn format(&self) -> FormatId {
39 WireFormat::OpenAiChat.into()
40 }
41
42 fn decode_request(&self, body: &Value, policy: &TranslationPolicy) -> Result<DecodedRequest> {
43 let body = object(body, "$")?;
44 let mut diagnostics = Vec::new();
45 let mut request = LlmRequest {
46 model: body
47 .get("model")
48 .and_then(Value::as_str)
49 .filter(|model| !model.is_empty())
50 .map(ToOwned::to_owned),
51 stream: body.get("stream").and_then(Value::as_bool).unwrap_or(false),
52 sampling: SamplingParams {
53 temperature: body.get("temperature").and_then(Value::as_f64),
54 top_p: body.get("top_p").and_then(Value::as_f64),
55 top_k: None,
56 },
57 output: OutputParams {
58 max_output_tokens: body
59 .get("max_completion_tokens")
60 .or_else(|| body.get("max_tokens"))
61 .and_then(Value::as_u64),
62 response_format: body.get("response_format").cloned(),
63 is_schema_enforced: decode_openai_schema_enforcement(body.get("response_format")),
64 },
65 reasoning: ReasoningParams {
66 effort: body
67 .get("reasoning_effort")
68 .and_then(Value::as_str)
69 .map(ToOwned::to_owned),
70 raw: None,
71 },
72 preservation: capture_request_preservation(
73 WireFormat::OpenAiChat,
74 &Value::Object(body.clone()),
75 policy,
76 ),
77 ..LlmRequest::default()
78 };
79
80 if let Some(messages) = body.get("messages") {
81 let messages = messages
82 .as_array()
83 .ok_or_else(|| TranslationError::InvalidType {
84 path: "$.messages".to_string(),
85 expected: "array",
86 })?;
87 let mut generated_id = 0;
88 for (index, message) in messages.iter().enumerate() {
89 let Some(message) = message.as_object() else {
90 push_lossy(
91 &mut diagnostics,
92 policy,
93 format!("OpenAI message at index {index} is not an object"),
94 )?;
95 continue;
96 };
97 let role = role_from_openai(
98 message.get("role").and_then(Value::as_str),
99 &format!("$.messages[{index}].role"),
100 )?;
101 let mut content = decode_openai_content(
102 message.get("content").unwrap_or(&Value::Null),
103 WireFormat::OpenAiChat,
104 &mut diagnostics,
105 policy,
106 format!("$.messages[{index}].content"),
107 )?;
108 prepend_openai_reasoning_blocks(&mut content, message);
109 if role == Role::Assistant
110 && let Some(tool_calls) = message.get("tool_calls").and_then(Value::as_array)
111 {
112 if is_empty_text_only(&content) {
113 content.clear();
114 }
115 for tool_call in tool_calls {
116 generated_id += 1;
117 if let Some(call) =
118 decode_openai_tool_call(tool_call, generated_id, policy)?
119 {
120 content.push(ContentBlock::ToolCall(call));
121 }
122 }
123 }
124 if role == Role::Tool {
125 let text = content
126 .iter()
127 .filter_map(|block| match block {
128 ContentBlock::Text { text } => Some(text.as_str()),
129 _ => None,
130 })
131 .collect::<Vec<_>>()
132 .join("\n");
133 let tool_call_id = message
134 .get("tool_call_id")
135 .and_then(Value::as_str)
136 .unwrap_or_default()
137 .to_string();
138 request.messages.push(Message::new(
139 Role::User,
140 vec![ContentBlock::ToolResult(ToolResult {
141 tool_call_id,
142 content: vec![ContentBlock::Text { text }],
143 is_error: message.get("is_error").and_then(Value::as_bool),
144 })],
145 ));
146 continue;
147 }
148 match role {
149 Role::System | Role::Developer => {
150 request
151 .instructions
152 .push(InstructionBlock { role, content });
153 }
154 Role::User | Role::Assistant | Role::Tool => {
155 request.messages.push(Message::new(role, content));
156 }
157 }
158 }
159 }
160
161 request.tools = decode_openai_tools(body.get("tools"), &mut diagnostics, policy)?
162 .into_iter()
163 .map(Tool::from)
164 .collect();
165 request.tool_choice = body.get("tool_choice").map(decode_openai_tool_choice);
166 request.extensions.fields = provider_extensions(
167 body,
168 &[
169 "model",
170 "messages",
171 "stream",
172 "temperature",
173 "top_p",
174 "max_completion_tokens",
175 "max_tokens",
176 "response_format",
177 "reasoning_effort",
178 "tools",
179 "tool_choice",
180 ],
181 );
182
183 Ok(DecodedRequest {
184 request,
185 diagnostics,
186 })
187 }
188
189 fn encode_request(
190 &self,
191 request: &LlmRequest,
192 policy: &TranslationPolicy,
193 ) -> Result<EncodedRequest> {
194 if let Some(body) =
195 exact_preserved_request(&request.preservation, WireFormat::OpenAiChat, policy)
196 {
197 return Ok(EncodedRequest {
198 body,
199 diagnostics: Vec::new(),
200 });
201 }
202 let mut diagnostics = Vec::new();
203 validate_request_capabilities(request, &mut diagnostics, policy)?;
204 let allowed = match &request.tool_choice {
205 Some(ToolChoice::Raw(choice))
206 if choice.get("type").and_then(Value::as_str) == Some("allowed_tools")
207 && choice.get("allowed_tools").is_some() =>
208 {
209 None
210 }
211 _ => crate::codecs::common::allowed_function_tools(request)?,
212 };
213 let (tools, tool_choice) = allowed.as_ref().map_or(
214 (request.tools.as_slice(), request.tool_choice.as_ref()),
215 |(tools, choice)| (tools.as_slice(), Some(choice)),
216 );
217 let mut body = Map::new();
218 if let Some(model) = &request.model {
219 body.insert("model".to_string(), Value::String(model.clone()));
220 }
221
222 let mut messages = Vec::new();
223 for instruction in &request.instructions {
224 let role = match instruction.role {
225 Role::Developer => "developer",
226 _ => "system",
227 };
228 messages.push(json!({
229 "role": role,
230 "content": text_from_blocks(&instruction.content, "\n\n"),
231 }));
232 }
233 for message in &request.messages {
234 messages.extend(encode_message_to_openai(message, &mut diagnostics, policy)?);
235 }
236 body.insert("messages".to_string(), Value::Array(messages));
237
238 if !tools.is_empty() {
239 body.insert("tools".to_string(), encode_openai_tools(tools)?);
240 if let Some(choice) = tool_choice {
241 body.insert("tool_choice".to_string(), encode_openai_tool_choice(choice));
242 }
243 }
244 if let Some(value) = request.output.max_output_tokens {
245 body.insert("max_completion_tokens".to_string(), json!(value));
246 }
247 if let Some(value) = request.sampling.temperature {
248 body.insert("temperature".to_string(), json!(value));
249 }
250 if let Some(value) = request.sampling.top_p {
251 body.insert("top_p".to_string(), json!(value));
252 }
253 if request.stream {
254 body.insert("stream".to_string(), Value::Bool(true));
255 }
256 if let Some(effort) = &request.reasoning.effort {
257 body.insert(
258 "reasoning_effort".to_string(),
259 Value::String(effort.clone()),
260 );
261 }
262 if let Some(format) = encode_openai_format(&request.output, &mut diagnostics, policy)? {
263 body.insert("response_format".to_string(), format);
264 }
265 copy_openai_chat_request_extensions(&mut body, &request.extensions.fields);
266
267 let body = embed_preservation(Value::Object(body), &request.preservation, policy);
268 Ok(EncodedRequest { body, diagnostics })
269 }
270
271 fn decode_response(
272 &self,
273 body: &Value,
274 _policy: &TranslationPolicy,
275 ) -> Result<DecodedResponse> {
276 let object = object(body, "$")?;
277 if let Some(reason @ ("abort" | "error" | "repetition")) = object
279 .get("choices")
280 .and_then(Value::as_array)
281 .and_then(|choices| choices.first())
282 .and_then(|choice| choice.get("finish_reason"))
283 .and_then(Value::as_str)
284 {
285 return Err(TranslationError::UpstreamFailure {
286 error: json!({ "message": format!("provider finished with finish_reason \"{reason}\"") }),
287 });
288 }
289 let mut response = AggLlmResponse {
290 id: object
291 .get("id")
292 .and_then(Value::as_str)
293 .map(ToOwned::to_owned),
294 model: object
295 .get("model")
296 .and_then(Value::as_str)
297 .map(ToOwned::to_owned),
298 outputs: Vec::new(),
299 usage: decode_openai_usage(object.get("usage")),
300 extensions: ProviderExtensions {
301 fields: provider_extensions(object, &["id", "model", "choices", "usage"]),
302 },
303 preservation: capture_response_preservation(
304 WireFormat::OpenAiChat,
305 &Value::Object(object.clone()),
306 _policy,
307 ),
308 };
309 if let Some(choice) = object
310 .get("choices")
311 .and_then(Value::as_array)
312 .and_then(|choices| choices.first())
313 .and_then(Value::as_object)
314 {
315 let message = choice
316 .get("message")
317 .and_then(Value::as_object)
318 .cloned()
319 .unwrap_or_default();
320 let refusal = message
321 .get("refusal")
322 .and_then(Value::as_str)
323 .filter(|text| !text.is_empty());
324 let mut content = match message.get("content") {
325 None | Some(Value::Null) => Vec::new(),
327 Some(content) => decode_openai_content(
328 content,
329 WireFormat::OpenAiChat,
330 &mut Vec::new(),
331 &TranslationPolicy::default(),
332 "$.choices[0].message.content",
333 )?,
334 };
335 prepend_openai_reasoning_blocks(&mut content, &message);
336 if let Some(text) = refusal {
337 content.push(ContentBlock::Refusal {
338 text: text.to_string(),
339 });
340 }
341 if let Some(tool_calls) = message.get("tool_calls").and_then(Value::as_array) {
342 for (index, tool_call) in tool_calls.iter().enumerate() {
343 if let Some(call) = decode_openai_tool_call(
344 tool_call,
345 index + 1,
346 &TranslationPolicy::default(),
347 )? {
348 content.push(ContentBlock::ToolCall(call));
349 }
350 }
351 }
352 let finish_reason = choice.get("finish_reason").and_then(Value::as_str);
353 let stop_reason = if refusal.is_some() && matches!(finish_reason, Some("stop") | None) {
354 StopReason::ContentFilter
355 } else {
356 map_openai_finish_reason(finish_reason)
357 };
358 response.outputs.push(ResponseOutput {
359 url_citations: message
360 .get("annotations")
361 .and_then(Value::as_array)
362 .into_iter()
363 .flatten()
364 .filter(|annotation| annotation["type"] == "url_citation")
365 .filter_map(|annotation| {
366 serde_json::from_value(annotation["url_citation"].clone()).ok()
367 })
368 .collect(),
369 role: Role::Assistant,
370 content,
371 stop_reason: Some(stop_reason),
372 });
373 }
374
375 Ok(DecodedResponse {
376 response,
377 diagnostics: Vec::new(),
378 })
379 }
380
381 fn encode_response(
382 &self,
383 response: &AggLlmResponse,
384 _policy: &TranslationPolicy,
385 ) -> Result<EncodedResponse> {
386 super::super::responses::validate_response_output(response, WireFormat::OpenAiChat)?;
387 if let Some(body) =
388 exact_preserved_response(&response.preservation, WireFormat::OpenAiChat, _policy)
389 {
390 return Ok(EncodedResponse {
391 body,
392 diagnostics: Vec::new(),
393 });
394 }
395 let output = response.first_output();
396 let content = output
397 .map(|output| text_from_blocks(&output.content, ""))
398 .unwrap_or_default();
399 let tool_calls = output
400 .map(|output| {
401 output
402 .content
403 .iter()
404 .filter_map(|block| match block {
405 ContentBlock::ToolCall(call) => Some(json!({
406 "id": call.id,
407 "type": "function",
408 "function": {
409 "name": call.name,
410 "arguments": json_string(&call.arguments),
411 },
412 })),
413 _ => None,
414 })
415 .collect::<Vec<_>>()
416 })
417 .unwrap_or_default();
418 let mut message = json!({
419 "role": "assistant",
420 "content": if content.is_empty() && !tool_calls.is_empty() {
421 Value::Null
422 } else {
423 Value::String(content)
424 },
425 });
426 if let Some(output) = output.filter(|output| !output.url_citations.is_empty()) {
427 message["annotations"] = Value::Array(
428 output
429 .url_citations
430 .iter()
431 .map(|citation| json!({"type": "url_citation", "url_citation": citation}))
432 .collect(),
433 );
434 }
435 if let Some(reasoning) = output
436 .map(|output| reasoning_text_from_blocks(&output.content, "\n"))
437 .filter(|reasoning| !reasoning.is_empty())
438 {
439 message["reasoning_content"] = Value::String(reasoning);
440 }
441 if let Some(details) = output
442 .map(|output| reasoning_details_from_blocks(&output.content))
443 .filter(|details| !details.is_empty())
444 {
445 message["reasoning_details"] = Value::Array(details);
446 }
447 if !tool_calls.is_empty() {
448 message["tool_calls"] = Value::Array(tool_calls);
449 }
450
451 let body = json!({
452 "id": response.id.clone().unwrap_or_else(|| "chatcmpl_switchyard".to_string()),
453 "object": "chat.completion",
454 "created": 0,
455 "model": response.model.clone().unwrap_or_else(|| "unknown".to_string()),
456 "choices": [{
457 "index": 0,
458 "message": message,
459 "finish_reason": output
460 .and_then(|output| output.stop_reason)
461 .map(openai_finish_reason)
462 .unwrap_or("stop"),
463 }],
464 "usage": encode_openai_usage(&response.usage),
465 });
466 Ok(EncodedResponse {
467 body: embed_preservation(body, &response.preservation, _policy),
468 diagnostics: Vec::new(),
469 })
470 }
471}
472
473fn prepend_openai_reasoning_blocks(content: &mut Vec<ContentBlock>, object: &Map<String, Value>) {
475 if let Some(details) = object
476 .get("reasoning_details")
477 .and_then(Value::as_array)
478 .filter(|details| !details.is_empty())
479 {
480 let text = reasoning_text_from_details(details)
481 .or_else(|| {
482 first_nonempty_string(object, &["reasoning_content", "reasoning"])
483 .map(ToOwned::to_owned)
484 })
485 .unwrap_or_default();
486 let signature = details.iter().find_map(|detail| {
487 detail
488 .get("signature")
489 .and_then(Value::as_str)
490 .filter(|signature| !signature.is_empty())
491 .map(ToOwned::to_owned)
492 });
493 content.insert(
494 0,
495 ContentBlock::Reasoning {
496 text,
497 signature,
498 details: details.clone(),
499 },
500 );
501 return;
502 }
503
504 if let Some(text) = first_nonempty_string(object, &["reasoning_content", "reasoning"]) {
505 content.insert(
506 0,
507 ContentBlock::Reasoning {
508 text: text.to_string(),
509 signature: None,
510 details: Vec::new(),
511 },
512 );
513 }
514}
515
516fn reasoning_details_from_blocks(content: &[ContentBlock]) -> Vec<Value> {
518 content
519 .iter()
520 .filter_map(|block| match block {
521 ContentBlock::Reasoning { details, .. } => Some(details.as_slice()),
522 _ => None,
523 })
524 .flatten()
525 .cloned()
526 .collect()
527}
528
529pub(crate) fn role_from_openai(role: Option<&str>, path: &str) -> Result<Role> {
537 match role {
538 Some("system") => Ok(Role::System),
539 Some("developer") => Ok(Role::Developer),
540 Some("assistant") => Ok(Role::Assistant),
541 Some("tool") => Ok(Role::Tool),
542 None => Ok(Role::User),
543 Some(other) if is_known_role_name(other) => Ok(Role::User),
544 Some(other) => Err(TranslationError::unsupported_role(path, other)),
545 }
546}
547
548pub(crate) fn decode_openai_content(
550 content: &Value,
551 provider: WireFormat,
552 diagnostics: &mut Vec<TranslationDiagnostic>,
553 policy: &TranslationPolicy,
554 path: impl Into<String>,
555) -> Result<Vec<ContentBlock>> {
556 let path = path.into();
557 match content {
558 Value::Null => Ok(vec![ContentBlock::Text {
559 text: String::new(),
560 }]),
561 Value::String(text) => Ok(vec![ContentBlock::Text { text: text.clone() }]),
562 Value::Array(blocks) => {
563 let mut content = Vec::new();
564 for (index, block) in blocks.iter().enumerate() {
565 let Some(block) = block.as_object() else {
566 push_lossy(
567 diagnostics,
568 policy,
569 format!("content block at {path}[{index}] is not an object"),
570 )?;
571 continue;
572 };
573 match block.get("type").and_then(Value::as_str) {
574 Some("text") | Some("input_text") | Some("output_text") => {
575 content.push(ContentBlock::Text {
576 text: block
577 .get("text")
578 .and_then(Value::as_str)
579 .unwrap_or_default()
580 .to_string(),
581 });
582 }
583 Some("refusal") => {
584 content.push(ContentBlock::Refusal {
585 text: block
586 .get("refusal")
587 .and_then(Value::as_str)
588 .unwrap_or_default()
589 .to_string(),
590 });
591 }
592 Some("image_url") | Some("input_image") => {
593 if let Some(source) = decode_image_source(block) {
594 content.push(ContentBlock::Image { source });
595 }
596 }
597 Some("input_audio") => content.push(ContentBlock::Audio {
598 source: MediaSource::Raw(Value::Object(block.clone())),
599 }),
600 Some("file") | Some("input_file") => {
601 content.push(ContentBlock::File {
602 source: decode_file_source(block),
603 });
604 }
605 _ => content.push(ContentBlock::Unknown {
606 provider: provider.into(),
607 raw: Value::Object(block.clone()),
608 }),
609 }
610 }
611 if content.is_empty() {
612 Ok(vec![ContentBlock::Text {
613 text: String::new(),
614 }])
615 } else {
616 Ok(content)
617 }
618 }
619 other => Ok(vec![ContentBlock::Text {
620 text: string_value(other).unwrap_or_default(),
621 }]),
622 }
623}
624
625pub(crate) fn decode_image_source(block: &Map<String, Value>) -> Option<ImageSource> {
627 if let Some(image_url) = block.get("image_url") {
628 if let Some(url) = image_url.as_str() {
629 return Some(ImageSource::Url {
630 url: url.to_string(),
631 detail: block
632 .get("detail")
633 .and_then(Value::as_str)
634 .map(ToOwned::to_owned),
635 });
636 }
637 if let Some(payload) = image_url.as_object() {
638 return payload
639 .get("url")
640 .and_then(Value::as_str)
641 .map(|url| ImageSource::Url {
642 url: url.to_string(),
643 detail: payload
644 .get("detail")
645 .or_else(|| block.get("detail"))
646 .and_then(Value::as_str)
647 .map(ToOwned::to_owned),
648 });
649 }
650 }
651 if let Some(image_url) = block.get("image_url").and_then(Value::as_str) {
652 return Some(ImageSource::Url {
653 url: image_url.to_string(),
654 detail: block
655 .get("detail")
656 .and_then(Value::as_str)
657 .map(ToOwned::to_owned),
658 });
659 }
660 Some(ImageSource::Raw(Value::Object(block.clone())))
661}
662
663pub(crate) fn decode_file_source(block: &Map<String, Value>) -> FileSource {
670 if let Some(file) = block.get("file").and_then(Value::as_object) {
671 if let Some(file_id) = file.get("file_id").and_then(Value::as_str) {
672 return FileSource::FileId(file_id.to_string());
673 }
674 if let Some(file_data) = file.get("file_data").and_then(Value::as_str) {
675 return FileSource::FileData {
676 data: file_data.to_string(),
677 filename: file
678 .get("filename")
679 .and_then(Value::as_str)
680 .map(ToOwned::to_owned),
681 };
682 }
683 return FileSource::Raw(Value::Object(file.clone()));
684 }
685 if let Some(file_id) = block.get("file_id").and_then(Value::as_str) {
688 return FileSource::FileId(file_id.to_string());
689 }
690 if let Some(file_data) = block.get("file_data").and_then(Value::as_str) {
691 return FileSource::FileData {
692 data: file_data.to_string(),
693 filename: block
694 .get("filename")
695 .and_then(Value::as_str)
696 .map(ToOwned::to_owned),
697 };
698 }
699 FileSource::Raw(Value::Object(block.clone()))
700}
701
702pub(crate) fn decode_openai_tool_call(
704 tool_call: &Value,
705 generated_counter: usize,
706 policy: &TranslationPolicy,
707) -> Result<Option<ToolCall>> {
708 let Some(tool_call) = tool_call.as_object() else {
709 return Ok(None);
710 };
711 let function = tool_call
712 .get("function")
713 .and_then(Value::as_object)
714 .cloned()
715 .unwrap_or_default();
716 let id = tool_call
717 .get("id")
718 .and_then(Value::as_str)
719 .filter(|id| !id.is_empty())
720 .map(ToOwned::to_owned)
721 .unwrap_or_else(|| match &policy.deterministic_ids {
722 DeterministicIdPolicy::GenerateStable { prefix } => {
723 stable_id(prefix, generated_counter)
724 }
725 DeterministicIdPolicy::Preserve => String::new(),
726 });
727 let custom = tool_call.get("custom").and_then(Value::as_object);
730 let arguments = match custom {
731 Some(custom) => json!({
732 crate::codex_custom_tools::INPUT_ARGUMENT:
733 custom.get("input").and_then(Value::as_str).unwrap_or_default()
734 }),
735 None => function
736 .get("arguments")
737 .map(parse_arguments)
738 .unwrap_or_else(|| json!({})),
739 };
740 let name = custom
741 .unwrap_or(&function)
742 .get("name")
743 .and_then(Value::as_str)
744 .unwrap_or_default()
745 .to_string();
746 Ok(Some(ToolCall {
747 id,
748 name,
749 arguments,
750 }))
751}
752
753pub(crate) fn parse_arguments(value: &Value) -> Value {
755 match value {
756 Value::String(text) => serde_json::from_str(text).unwrap_or_else(|_| json!({"raw": text})),
757 other => other.clone(),
758 }
759}
760
761pub(crate) fn decode_openai_tools(
763 tools: Option<&Value>,
764 _diagnostics: &mut Vec<TranslationDiagnostic>,
765 _policy: &TranslationPolicy,
766) -> Result<Vec<ToolDefinition>> {
767 let Some(tools) = tools.and_then(Value::as_array) else {
768 return Ok(Vec::new());
769 };
770 let mut definitions = Vec::new();
771 for tool in tools {
772 let Some(tool) = tool.as_object() else {
773 continue;
774 };
775 let function = tool
776 .get("function")
777 .and_then(Value::as_object)
778 .cloned()
779 .unwrap_or_else(|| tool.clone());
780 let name = function
781 .get("name")
782 .and_then(Value::as_str)
783 .unwrap_or_default()
784 .to_string();
785 if name.is_empty() {
786 continue;
787 }
788 definitions.push(ToolDefinition {
789 name,
790 description: function
791 .get("description")
792 .and_then(Value::as_str)
793 .map(ToOwned::to_owned),
794 parameters: function
795 .get("parameters")
796 .cloned()
797 .unwrap_or_else(|| json!({})),
798 strict: function.get("strict").and_then(Value::as_bool),
799 });
800 }
801 Ok(definitions)
802}
803
804pub(crate) fn decode_openai_tool_choice(value: &Value) -> ToolChoice {
806 match value {
807 Value::String(text) if text == "auto" => ToolChoice::Auto,
808 Value::String(text) if text == "required" => ToolChoice::Required,
809 Value::String(text) if text == "none" => ToolChoice::None,
810 Value::Object(object) => object
811 .get("function")
812 .and_then(Value::as_object)
813 .and_then(|function| function.get("name"))
814 .and_then(Value::as_str)
815 .map(|name| ToolChoice::Tool {
816 name: name.to_string(),
817 })
818 .unwrap_or_else(|| ToolChoice::Raw(value.clone())),
819 _ => ToolChoice::Raw(value.clone()),
820 }
821}
822
823pub(crate) fn encode_message_to_openai(
825 message: &Message,
826 diagnostics: &mut Vec<TranslationDiagnostic>,
827 policy: &TranslationPolicy,
828) -> Result<Vec<Value>> {
829 if message_has_tool_results(message) {
830 return encode_message_with_tool_results_to_openai(message, diagnostics, policy);
831 }
832
833 Ok(vec![encode_message_without_tool_results_to_openai(
834 message,
835 diagnostics,
836 policy,
837 )?])
838}
839
840fn encode_message_with_tool_results_to_openai(
842 message: &Message,
843 diagnostics: &mut Vec<TranslationDiagnostic>,
844 policy: &TranslationPolicy,
845) -> Result<Vec<Value>> {
846 let mut out = Vec::new();
847 let mut pending_content = Vec::new();
848
849 for block in &message.content {
850 if let ContentBlock::ToolResult(result) = block {
851 out.push(json!({
852 "role": "tool",
853 "tool_call_id": result.tool_call_id,
854 "content": text_from_blocks(&result.content, " "),
855 }));
856 let non_text = result
857 .content
858 .iter()
859 .filter(|block| {
860 !matches!(
861 block,
862 ContentBlock::Text { .. } | ContentBlock::Refusal { .. }
863 )
864 })
865 .cloned()
866 .collect::<Vec<_>>();
867 if !non_text.is_empty() {
868 push_lossy(
869 diagnostics,
870 policy,
871 "OpenAI Chat tool messages only support text; non-text tool-result content was moved to a user message",
872 )?;
873 pending_content.extend(non_text);
874 }
875 } else {
876 pending_content.push(block.clone());
877 }
878 }
879
880 push_pending_openai_message(
881 &mut out,
882 message.role,
883 &mut pending_content,
884 diagnostics,
885 policy,
886 )?;
887 Ok(out)
888}
889
890fn push_pending_openai_message(
892 out: &mut Vec<Value>,
893 role: Role,
894 pending_content: &mut Vec<ContentBlock>,
895 diagnostics: &mut Vec<TranslationDiagnostic>,
896 policy: &TranslationPolicy,
897) -> Result<()> {
898 if pending_content.is_empty() {
899 return Ok(());
900 }
901
902 let message = Message::new(role, std::mem::take(pending_content));
903 out.push(encode_message_without_tool_results_to_openai(
904 &message,
905 diagnostics,
906 policy,
907 )?);
908 Ok(())
909}
910
911fn encode_message_without_tool_results_to_openai(
913 message: &Message,
914 diagnostics: &mut Vec<TranslationDiagnostic>,
915 policy: &TranslationPolicy,
916) -> Result<Value> {
917 let role = match message.role {
918 Role::System => "system",
919 Role::Developer => "developer",
920 Role::Assistant => "assistant",
921 Role::Tool => "tool",
922 Role::User => "user",
923 };
924 let tool_calls = message
925 .content
926 .iter()
927 .filter_map(|block| match block {
928 ContentBlock::ToolCall(call) => Some(json!({
929 "id": call.id,
930 "type": "function",
931 "function": {
932 "name": call.name,
933 "arguments": json_string(&call.arguments),
934 },
935 })),
936 _ => None,
937 })
938 .collect::<Vec<_>>();
939 let content_blocks = message
940 .content
941 .iter()
942 .filter(|block| {
943 !matches!(
944 block,
945 ContentBlock::ToolCall(_) | ContentBlock::Reasoning { .. }
946 )
947 })
948 .cloned()
949 .collect::<Vec<_>>();
950 let mut message_json = json!({
951 "role": role,
952 "content": encode_openai_content(&content_blocks, message.role, diagnostics, policy)?,
953 });
954 encode_openai_message_reasoning(&mut message_json, &message.content);
955 if !tool_calls.is_empty() {
956 message_json["tool_calls"] = Value::Array(tool_calls);
957 if message_json["content"] == Value::String(String::new()) {
958 message_json["content"] = Value::Null;
959 }
960 }
961 Ok(message_json)
962}
963
964fn encode_openai_message_reasoning(message: &mut Value, content: &[ContentBlock]) {
966 let details = reasoning_details_from_blocks(content);
967 if details.is_empty() {
968 encode_openai_message_plaintext_reasoning(message, content);
969 } else {
970 encode_openai_message_structured_reasoning(message, content, details);
971 }
972}
973
974fn encode_openai_message_plaintext_reasoning(message: &mut Value, content: &[ContentBlock]) {
976 let reasoning = content
977 .iter()
978 .filter_map(|block| match block {
979 ContentBlock::Reasoning {
980 text,
981 signature: None,
982 ..
983 } => Some(text.as_str()),
984 _ => None,
985 })
986 .collect::<Vec<_>>()
987 .join("\n");
988 if !reasoning.is_empty() {
989 message["reasoning"] = Value::String(reasoning);
990 }
991}
992
993fn encode_openai_message_structured_reasoning(
995 message: &mut Value,
996 content: &[ContentBlock],
997 details: Vec<Value>,
998) {
999 message["reasoning_details"] = Value::Array(details);
1000 let fallback = content
1001 .iter()
1002 .filter_map(|block| match block {
1003 ContentBlock::Reasoning { text, details, .. }
1004 if !details.is_empty()
1005 && reasoning_text_from_details(details).as_deref() != Some(text.as_str())
1006 && !text.is_empty() =>
1007 {
1008 Some(text.as_str())
1009 }
1010 _ => None,
1011 })
1012 .collect::<Vec<_>>()
1013 .join("\n");
1014 if !fallback.is_empty() {
1015 message["reasoning"] = Value::String(fallback);
1016 }
1017}
1018
1019fn message_has_tool_results(message: &Message) -> bool {
1021 message
1022 .content
1023 .iter()
1024 .any(|block| matches!(block, ContentBlock::ToolResult(_)))
1025}
1026
1027fn copy_openai_chat_request_extensions(
1029 body: &mut Map<String, Value>,
1030 extensions: &Map<String, Value>,
1031) {
1032 for field in [
1033 "chat_template_kwargs",
1035 "metadata",
1036 "parallel_tool_calls",
1037 "prompt_cache_key",
1038 "prompt_cache_retention",
1039 "safety_identifier",
1040 "service_tier",
1041 "store",
1042 "stream_options",
1043 "logprobs",
1045 "top_logprobs",
1046 "user",
1047 ] {
1048 if let Some(value) = extensions.get(field) {
1049 body.entry(field.to_string())
1050 .or_insert_with(|| value.clone());
1051 }
1052 }
1053 if let Some(stop_sequences) = extensions.get("stop_sequences") {
1054 body.entry("stop").or_insert_with(|| stop_sequences.clone());
1055 }
1056}
1057
1058fn is_empty_text_only(content: &[ContentBlock]) -> bool {
1060 matches!(content, [ContentBlock::Text { text }] if text.is_empty())
1061}
1062
1063pub(crate) fn encode_openai_content(
1065 content: &[ContentBlock],
1066 role: Role,
1067 diagnostics: &mut Vec<TranslationDiagnostic>,
1068 policy: &TranslationPolicy,
1069) -> Result<Value> {
1070 if content
1071 .iter()
1072 .any(|block| matches!(block, ContentBlock::Mcp(_)))
1073 {
1074 return Err(TranslationError::LossyConversion(
1075 "MCP history encoding is not implemented".into(),
1076 ));
1077 }
1078 let has_non_text = content.iter().any(|block| {
1079 matches!(
1080 block,
1081 ContentBlock::Image { .. }
1082 | ContentBlock::Audio { .. }
1083 | ContentBlock::Video { .. }
1084 | ContentBlock::File { .. }
1085 | ContentBlock::Unknown { .. }
1086 )
1087 });
1088 if !has_non_text {
1089 return Ok(Value::String(text_from_blocks(content, "\n")));
1090 }
1091 if role != Role::User {
1092 for block in content {
1093 if let ContentBlock::Unknown { provider, raw } = block {
1094 reject_responses_native_input_item(provider, raw, WireFormat::OpenAiChat)?;
1095 }
1096 }
1097 push_lossy(
1098 diagnostics,
1099 policy,
1100 "OpenAI Chat only supports text content for non-user messages",
1101 )?;
1102 return Ok(Value::String(text_from_blocks(content, "\n")));
1103 }
1104 let mut blocks = Vec::new();
1105 for block in content {
1106 crate::codecs::openai_media::validate_media(block, WireFormat::OpenAiChat)?;
1107 match block {
1108 ContentBlock::Text { text } => blocks.push(json!({"type": "text", "text": text})),
1109 ContentBlock::Refusal { text } => blocks.push(json!({"type": "text", "text": text})),
1110 ContentBlock::Image { source } => match openai_image_part(source) {
1111 Some(part) => blocks.push(part),
1112 None => {
1113 push_lossy(
1114 diagnostics,
1115 policy,
1116 "OpenAI Chat codec could not map image content",
1117 )?;
1118 blocks.push(openai_text_part(&image_source_text(source)));
1119 }
1120 },
1121 ContentBlock::File { source } => match openai_file_part(source) {
1122 Some(part) => blocks.push(part),
1123 None => {
1124 push_lossy(
1125 diagnostics,
1126 policy,
1127 "OpenAI Chat codec could not map file content",
1128 )?;
1129 blocks.push(openai_text_part(&file_source_text(source)));
1130 }
1131 },
1132 ContentBlock::Audio { source } => {
1133 blocks.push(crate::codecs::openai_media::audio_part(source)?);
1134 }
1135 ContentBlock::Video { source } => {
1136 push_lossy(
1137 diagnostics,
1138 policy,
1139 "OpenAI Chat codec does not have a stable video request mapping yet",
1140 )?;
1141 blocks.push(openai_text_part(&media_source_text(source)));
1142 }
1143 ContentBlock::Mcp(_) => {
1144 return Err(TranslationError::LossyConversion(
1145 "MCP history encoding is not implemented".into(),
1146 ));
1147 }
1148 ContentBlock::Unknown { provider, raw } => {
1149 reject_responses_native_input_item(provider, raw, WireFormat::OpenAiChat)?;
1150 push_lossy(
1151 diagnostics,
1152 policy,
1153 "unknown content block encoded as text for OpenAI Chat",
1154 )?;
1155 blocks.push(openai_text_part(&json_string(raw)));
1156 }
1157 ContentBlock::Reasoning { .. }
1158 | ContentBlock::ToolCall(_)
1159 | ContentBlock::ToolResult(_) => {}
1160 }
1161 }
1162 Ok(Value::Array(blocks))
1163}
1164
1165fn openai_text_part(text: &str) -> Value {
1167 json!({"type": "text", "text": text})
1168}
1169
1170fn openai_image_part(source: &ImageSource) -> Option<Value> {
1171 let ImagePayload { url, detail } = image_payload(source)?;
1172 let mut image_url = json!({});
1173 image_url["url"] = Value::String(url);
1174 if let Some(detail) = detail {
1175 image_url["detail"] = Value::String(detail);
1176 }
1177 let mut part = json!({"type": "image_url"});
1178 part["image_url"] = image_url;
1179 Some(part)
1180}
1181
1182fn openai_file_part(source: &FileSource) -> Option<Value> {
1183 let mut part = json!({"type": "file"});
1184 part["file"] = Value::Object(file_payload(source)?);
1185 Some(part)
1186}
1187
1188fn media_source_text(source: &MediaSource) -> String {
1190 match source {
1191 MediaSource::Url { url, media_type } => json_string(&json!({
1192 "url": url,
1193 "media_type": media_type,
1194 })),
1195 MediaSource::Base64 { media_type, data } => json_string(&json!({
1196 "media_type": media_type,
1197 "data": data,
1198 })),
1199 MediaSource::Raw(raw) => json_string(raw),
1200 }
1201}
1202
1203pub(crate) fn encode_openai_tools(tools: &[Tool]) -> Result<Value> {
1205 Ok(Value::Array(
1206 tools
1207 .iter()
1208 .map(|tool| {
1209 let tool = tool.function().ok_or_else(|| {
1210 TranslationError::LossyConversion(
1211 "MCP and provider tool encoding is not implemented".into(),
1212 )
1213 })?;
1214 let mut function = json!({
1215 "name": tool.name,
1216 "description": tool.description.clone().unwrap_or_default(),
1217 "parameters": tool.parameters,
1218 });
1219 if let Some(strict) = tool.strict {
1220 function["strict"] = Value::Bool(strict);
1221 }
1222 Ok(json!({"type": "function", "function": function}))
1223 })
1224 .collect::<Result<_>>()?,
1225 ))
1226}
1227
1228pub(crate) fn encode_openai_tool_choice(choice: &ToolChoice) -> Value {
1230 match choice {
1231 ToolChoice::Auto => Value::String("auto".to_string()),
1232 ToolChoice::Required => Value::String("required".to_string()),
1233 ToolChoice::None => Value::String("none".to_string()),
1234 ToolChoice::Tool { name } => json!({"type": "function", "function": {"name": name}}),
1235 ToolChoice::Raw(value) => value.clone(),
1236 }
1237}
1238
1239pub(crate) fn decode_openai_usage(value: Option<&Value>) -> Usage {
1241 let Some(value) = value.and_then(Value::as_object) else {
1242 return Usage::default();
1243 };
1244 let cached_input_tokens = value
1245 .get("prompt_tokens_details")
1246 .and_then(|details| details.get("cached_tokens"))
1247 .and_then(Value::as_u64);
1248 let cache_creation_input_tokens = value
1249 .get("prompt_tokens_details")
1250 .and_then(|details| {
1251 details
1252 .get("cache_write_tokens")
1253 .or_else(|| details.get("cache_creation_tokens"))
1254 })
1255 .and_then(Value::as_u64);
1256 Usage {
1257 provider_cost: crate::codecs::common::provider_cost(value),
1258 input_tokens: value
1259 .get("prompt_tokens")
1260 .and_then(Value::as_u64)
1261 .map(|tokens| {
1262 tokens
1263 .saturating_sub(cached_input_tokens.unwrap_or(0))
1264 .saturating_sub(cache_creation_input_tokens.unwrap_or(0))
1265 }),
1266 cache: crate::codecs::common::cache_usage_details(
1267 value,
1268 cached_input_tokens,
1269 cache_creation_input_tokens,
1270 ),
1271 output_tokens: value.get("completion_tokens").and_then(Value::as_u64),
1272 total_tokens: value.get("total_tokens").and_then(Value::as_u64),
1273 reasoning_tokens: value
1274 .get("completion_tokens_details")
1275 .and_then(|details| details.get("reasoning_tokens"))
1276 .or_else(|| {
1277 value
1278 .get("output_tokens_details")
1279 .and_then(|details| details.get("reasoning_tokens"))
1280 })
1281 .and_then(Value::as_u64),
1282 }
1283}
1284
1285pub(crate) fn encode_openai_usage(usage: &Usage) -> Value {
1287 let prompt_tokens = usage.input_tokens.unwrap_or(0)
1288 + usage.cached_input_tokens().unwrap_or(0)
1289 + usage.cache_creation_input_tokens().unwrap_or(0);
1290 let mut value = json!({
1291 "prompt_tokens": prompt_tokens,
1292 "completion_tokens": usage.output_tokens.unwrap_or(0),
1293 "total_tokens": usage
1294 .total_tokens
1295 .or_else(|| Some(prompt_tokens + usage.output_tokens.unwrap_or(0)))
1296 .unwrap_or(0),
1297 });
1298 if usage.cached_input_tokens().is_some() || usage.cache_creation_input_tokens().is_some() {
1299 value["prompt_tokens_details"] = json!({
1300 "cached_tokens": usage.cached_input_tokens().unwrap_or(0),
1301 "cache_write_tokens": usage.cache_creation_input_tokens().unwrap_or(0),
1302 });
1303 }
1304 if let Some(reasoning_tokens) = usage.reasoning_tokens {
1305 value["completion_tokens_details"] = json!({
1306 "reasoning_tokens": reasoning_tokens,
1307 });
1308 }
1309 crate::codecs::common::encode_usage_metadata(value, usage)
1310}
1311
1312pub(crate) fn map_openai_finish_reason(reason: Option<&str>) -> StopReason {
1314 match reason {
1315 Some("length") => StopReason::MaxTokens,
1316 Some("tool_calls") | Some("function_call") => StopReason::ToolUse,
1317 Some("content_filter") => StopReason::ContentFilter,
1318 Some("stop") | None => StopReason::EndTurn,
1319 _ => StopReason::Unknown,
1320 }
1321}
1322
1323pub(crate) fn openai_finish_reason(reason: StopReason) -> &'static str {
1325 match reason {
1326 StopReason::MaxTokens => "length",
1327 StopReason::ToolUse => "tool_calls",
1328 StopReason::ContentFilter => "content_filter",
1329 StopReason::EndTurn | StopReason::Unknown | StopReason::Error => "stop",
1330 }
1331}