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