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_builtin_tool_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 });
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 { 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 {
903 role,
904 content: std::mem::take(pending_content),
905 };
906 out.push(encode_message_without_tool_results_to_openai(
907 &message,
908 diagnostics,
909 policy,
910 )?);
911 Ok(())
912}
913
914fn encode_message_without_tool_results_to_openai(
916 message: &Message,
917 diagnostics: &mut Vec<TranslationDiagnostic>,
918 policy: &TranslationPolicy,
919) -> Result<Value> {
920 let role = match message.role {
921 Role::Assistant => "assistant",
922 Role::Tool => "tool",
923 _ => "user",
924 };
925 let tool_calls = message
926 .content
927 .iter()
928 .filter_map(|block| match block {
929 ContentBlock::ToolCall(call) => Some(json!({
930 "id": call.id,
931 "type": "function",
932 "function": {
933 "name": call.name,
934 "arguments": json_string(&call.arguments),
935 },
936 })),
937 _ => None,
938 })
939 .collect::<Vec<_>>();
940 let content_blocks = message
941 .content
942 .iter()
943 .filter(|block| {
944 !matches!(
945 block,
946 ContentBlock::ToolCall(_) | ContentBlock::Reasoning { .. }
947 )
948 })
949 .cloned()
950 .collect::<Vec<_>>();
951 let mut message_json = json!({
952 "role": role,
953 "content": encode_openai_content(&content_blocks, message.role, diagnostics, policy)?,
954 });
955 encode_openai_message_reasoning(&mut message_json, &message.content);
956 if !tool_calls.is_empty() {
957 message_json["tool_calls"] = Value::Array(tool_calls);
958 if message_json["content"] == Value::String(String::new()) {
959 message_json["content"] = Value::Null;
960 }
961 }
962 Ok(message_json)
963}
964
965fn encode_openai_message_reasoning(message: &mut Value, content: &[ContentBlock]) {
967 let details = reasoning_details_from_blocks(content);
968 if details.is_empty() {
969 encode_openai_message_plaintext_reasoning(message, content);
970 } else {
971 encode_openai_message_structured_reasoning(message, content, details);
972 }
973}
974
975fn encode_openai_message_plaintext_reasoning(message: &mut Value, content: &[ContentBlock]) {
977 let reasoning = content
978 .iter()
979 .filter_map(|block| match block {
980 ContentBlock::Reasoning {
981 text,
982 signature: None,
983 ..
984 } => Some(text.as_str()),
985 _ => None,
986 })
987 .collect::<Vec<_>>()
988 .join("\n");
989 if !reasoning.is_empty() {
990 message["reasoning"] = Value::String(reasoning);
991 }
992}
993
994fn encode_openai_message_structured_reasoning(
996 message: &mut Value,
997 content: &[ContentBlock],
998 details: Vec<Value>,
999) {
1000 message["reasoning_details"] = Value::Array(details);
1001 let fallback = content
1002 .iter()
1003 .filter_map(|block| match block {
1004 ContentBlock::Reasoning { text, details, .. }
1005 if !details.is_empty()
1006 && reasoning_text_from_details(details).as_deref() != Some(text.as_str())
1007 && !text.is_empty() =>
1008 {
1009 Some(text.as_str())
1010 }
1011 _ => None,
1012 })
1013 .collect::<Vec<_>>()
1014 .join("\n");
1015 if !fallback.is_empty() {
1016 message["reasoning"] = Value::String(fallback);
1017 }
1018}
1019
1020fn message_has_tool_results(message: &Message) -> bool {
1022 message
1023 .content
1024 .iter()
1025 .any(|block| matches!(block, ContentBlock::ToolResult(_)))
1026}
1027
1028fn copy_openai_chat_request_extensions(
1030 body: &mut Map<String, Value>,
1031 extensions: &Map<String, Value>,
1032) {
1033 for field in [
1034 "metadata",
1035 "parallel_tool_calls",
1036 "prompt_cache_key",
1037 "prompt_cache_retention",
1038 "safety_identifier",
1039 "service_tier",
1040 "store",
1041 "stream_options",
1042 "logprobs",
1044 "top_logprobs",
1045 "user",
1046 ] {
1047 if let Some(value) = extensions.get(field) {
1048 body.entry(field.to_string())
1049 .or_insert_with(|| value.clone());
1050 }
1051 }
1052 if let Some(stop_sequences) = extensions.get("stop_sequences") {
1053 body.entry("stop").or_insert_with(|| stop_sequences.clone());
1054 }
1055}
1056
1057fn is_empty_text_only(content: &[ContentBlock]) -> bool {
1059 matches!(content, [ContentBlock::Text { text }] if text.is_empty())
1060}
1061
1062pub(crate) fn encode_openai_content(
1064 content: &[ContentBlock],
1065 role: Role,
1066 diagnostics: &mut Vec<TranslationDiagnostic>,
1067 policy: &TranslationPolicy,
1068) -> Result<Value> {
1069 if content
1070 .iter()
1071 .any(|block| matches!(block, ContentBlock::Mcp(_)))
1072 {
1073 return Err(TranslationError::LossyConversion(
1074 "MCP history encoding is not implemented".into(),
1075 ));
1076 }
1077 let has_non_text = content.iter().any(|block| {
1078 matches!(
1079 block,
1080 ContentBlock::Image { .. }
1081 | ContentBlock::Audio { .. }
1082 | ContentBlock::Video { .. }
1083 | ContentBlock::File { .. }
1084 | ContentBlock::Unknown { .. }
1085 )
1086 });
1087 if !has_non_text {
1088 return Ok(Value::String(text_from_blocks(content, "\n")));
1089 }
1090 if role != Role::User {
1091 push_lossy(
1092 diagnostics,
1093 policy,
1094 "OpenAI Chat only supports text content for non-user messages",
1095 )?;
1096 return Ok(Value::String(text_from_blocks(content, "\n")));
1097 }
1098 let mut blocks = Vec::new();
1099 for block in content {
1100 crate::codecs::openai_media::validate_media(block, WireFormat::OpenAiChat)?;
1101 match block {
1102 ContentBlock::Text { text } => blocks.push(json!({"type": "text", "text": text})),
1103 ContentBlock::Refusal { text } => blocks.push(json!({"type": "text", "text": text})),
1104 ContentBlock::Image { source } => match openai_image_part(source) {
1105 Some(part) => blocks.push(part),
1106 None => {
1107 push_lossy(
1108 diagnostics,
1109 policy,
1110 "OpenAI Chat codec could not map image content",
1111 )?;
1112 blocks.push(openai_text_part(&image_source_text(source)));
1113 }
1114 },
1115 ContentBlock::File { source } => match openai_file_part(source) {
1116 Some(part) => blocks.push(part),
1117 None => {
1118 push_lossy(
1119 diagnostics,
1120 policy,
1121 "OpenAI Chat codec could not map file content",
1122 )?;
1123 blocks.push(openai_text_part(&file_source_text(source)));
1124 }
1125 },
1126 ContentBlock::Audio { source } => {
1127 blocks.push(crate::codecs::openai_media::audio_part(source)?);
1128 }
1129 ContentBlock::Video { source } => {
1130 push_lossy(
1131 diagnostics,
1132 policy,
1133 "OpenAI Chat codec does not have a stable video request mapping yet",
1134 )?;
1135 blocks.push(openai_text_part(&media_source_text(source)));
1136 }
1137 ContentBlock::Mcp(_) => {
1138 return Err(TranslationError::LossyConversion(
1139 "MCP history encoding is not implemented".into(),
1140 ));
1141 }
1142 ContentBlock::Unknown { provider, raw } => {
1143 reject_responses_builtin_tool_item(provider, raw, WireFormat::OpenAiChat)?;
1144 push_lossy(
1145 diagnostics,
1146 policy,
1147 "unknown content block encoded as text for OpenAI Chat",
1148 )?;
1149 blocks.push(openai_text_part(&json_string(raw)));
1150 }
1151 ContentBlock::Reasoning { .. }
1152 | ContentBlock::ToolCall(_)
1153 | ContentBlock::ToolResult(_) => {}
1154 }
1155 }
1156 Ok(Value::Array(blocks))
1157}
1158
1159fn openai_text_part(text: &str) -> Value {
1161 json!({"type": "text", "text": text})
1162}
1163
1164fn openai_image_part(source: &ImageSource) -> Option<Value> {
1165 let ImagePayload { url, detail } = image_payload(source)?;
1166 let mut image_url = json!({});
1167 image_url["url"] = Value::String(url);
1168 if let Some(detail) = detail {
1169 image_url["detail"] = Value::String(detail);
1170 }
1171 let mut part = json!({"type": "image_url"});
1172 part["image_url"] = image_url;
1173 Some(part)
1174}
1175
1176fn openai_file_part(source: &FileSource) -> Option<Value> {
1177 let mut part = json!({"type": "file"});
1178 part["file"] = Value::Object(file_payload(source)?);
1179 Some(part)
1180}
1181
1182fn media_source_text(source: &MediaSource) -> String {
1184 match source {
1185 MediaSource::Url { url, media_type } => json_string(&json!({
1186 "url": url,
1187 "media_type": media_type,
1188 })),
1189 MediaSource::Base64 { media_type, data } => json_string(&json!({
1190 "media_type": media_type,
1191 "data": data,
1192 })),
1193 MediaSource::Raw(raw) => json_string(raw),
1194 }
1195}
1196
1197pub(crate) fn encode_openai_tools(tools: &[Tool]) -> Result<Value> {
1199 Ok(Value::Array(
1200 tools
1201 .iter()
1202 .map(|tool| {
1203 let tool = tool.function().ok_or_else(|| {
1204 TranslationError::LossyConversion(
1205 "MCP and provider tool encoding is not implemented".into(),
1206 )
1207 })?;
1208 let mut function = json!({
1209 "name": tool.name,
1210 "description": tool.description.clone().unwrap_or_default(),
1211 "parameters": tool.parameters,
1212 });
1213 if let Some(strict) = tool.strict {
1214 function["strict"] = Value::Bool(strict);
1215 }
1216 Ok(json!({"type": "function", "function": function}))
1217 })
1218 .collect::<Result<_>>()?,
1219 ))
1220}
1221
1222pub(crate) fn encode_openai_tool_choice(choice: &ToolChoice) -> Value {
1224 match choice {
1225 ToolChoice::Auto => Value::String("auto".to_string()),
1226 ToolChoice::Required => Value::String("required".to_string()),
1227 ToolChoice::None => Value::String("none".to_string()),
1228 ToolChoice::Tool { name } => json!({"type": "function", "function": {"name": name}}),
1229 ToolChoice::Raw(value) => value.clone(),
1230 }
1231}
1232
1233pub(crate) fn decode_openai_usage(value: Option<&Value>) -> Usage {
1235 let Some(value) = value.and_then(Value::as_object) else {
1236 return Usage::default();
1237 };
1238 let cached_input_tokens = value
1239 .get("prompt_tokens_details")
1240 .and_then(|details| details.get("cached_tokens"))
1241 .and_then(Value::as_u64);
1242 let cache_creation_input_tokens = value
1243 .get("prompt_tokens_details")
1244 .and_then(|details| {
1245 details
1246 .get("cache_write_tokens")
1247 .or_else(|| details.get("cache_creation_tokens"))
1248 })
1249 .and_then(Value::as_u64);
1250 Usage {
1251 provider_cost: crate::codecs::common::provider_cost(value),
1252 input_tokens: value
1253 .get("prompt_tokens")
1254 .and_then(Value::as_u64)
1255 .map(|tokens| {
1256 tokens
1257 .saturating_sub(cached_input_tokens.unwrap_or(0))
1258 .saturating_sub(cache_creation_input_tokens.unwrap_or(0))
1259 }),
1260 cache: crate::codecs::common::cache_usage_details(
1261 value,
1262 cached_input_tokens,
1263 cache_creation_input_tokens,
1264 ),
1265 output_tokens: value.get("completion_tokens").and_then(Value::as_u64),
1266 total_tokens: value.get("total_tokens").and_then(Value::as_u64),
1267 reasoning_tokens: value
1268 .get("completion_tokens_details")
1269 .and_then(|details| details.get("reasoning_tokens"))
1270 .or_else(|| {
1271 value
1272 .get("output_tokens_details")
1273 .and_then(|details| details.get("reasoning_tokens"))
1274 })
1275 .and_then(Value::as_u64),
1276 }
1277}
1278
1279pub(crate) fn encode_openai_usage(usage: &Usage) -> Value {
1281 let prompt_tokens = usage.input_tokens.unwrap_or(0)
1282 + usage.cached_input_tokens().unwrap_or(0)
1283 + usage.cache_creation_input_tokens().unwrap_or(0);
1284 let mut value = json!({
1285 "prompt_tokens": prompt_tokens,
1286 "completion_tokens": usage.output_tokens.unwrap_or(0),
1287 "total_tokens": usage
1288 .total_tokens
1289 .or_else(|| Some(prompt_tokens + usage.output_tokens.unwrap_or(0)))
1290 .unwrap_or(0),
1291 });
1292 if usage.cached_input_tokens().is_some() || usage.cache_creation_input_tokens().is_some() {
1293 value["prompt_tokens_details"] = json!({
1294 "cached_tokens": usage.cached_input_tokens().unwrap_or(0),
1295 "cache_creation_tokens": usage.cache_creation_input_tokens().unwrap_or(0),
1296 });
1297 }
1298 if let Some(reasoning_tokens) = usage.reasoning_tokens {
1299 value["completion_tokens_details"] = json!({
1300 "reasoning_tokens": reasoning_tokens,
1301 });
1302 }
1303 crate::codecs::common::encode_usage_metadata(value, usage)
1304}
1305
1306pub(crate) fn map_openai_finish_reason(reason: Option<&str>) -> StopReason {
1308 match reason {
1309 Some("length") => StopReason::MaxTokens,
1310 Some("tool_calls") | Some("function_call") => StopReason::ToolUse,
1311 Some("content_filter") => StopReason::ContentFilter,
1312 Some("stop") | None => StopReason::EndTurn,
1313 _ => StopReason::Unknown,
1314 }
1315}
1316
1317pub(crate) fn openai_finish_reason(reason: StopReason) -> &'static str {
1319 match reason {
1320 StopReason::MaxTokens => "length",
1321 StopReason::ToolUse => "tool_calls",
1322 StopReason::ContentFilter => "content_filter",
1323 StopReason::EndTurn | StopReason::Unknown | StopReason::Error => "stop",
1324 }
1325}