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