va-ai-api-bridge 0.1.7

Protocol translation primitives for AI API request and response shapes.
Documentation
use serde_json::{Map, Value};

use crate::schema::openai;
use crate::ContentBlock;

use super::media::openai_media_url;

pub(crate) fn blocks_to_openai_content(
    blocks: &[ContentBlock],
    text_kind: &str,
    image_kind: &str,
) -> Option<openai::OpenAiContent> {
    match blocks {
        [] => None,
        [ContentBlock::Text { text }] => Some(openai::OpenAiContent::Text(text.clone())),
        blocks => Some(openai::OpenAiContent::Parts(
            blocks
                .iter()
                .map(|block| block_to_openai_part(block, text_kind, image_kind))
                .collect(),
        )),
    }
}

pub(crate) fn blocks_to_plain_text(blocks: &[ContentBlock]) -> Option<String> {
    let text = blocks
        .iter()
        .filter_map(|block| match block {
            ContentBlock::Text { text } => Some(text.as_str()),
            ContentBlock::Unknown { raw } => raw.as_str(),
            _ => None,
        })
        .filter(|text| !text.trim().is_empty())
        .collect::<Vec<_>>()
        .join("\n\n");
    (!text.is_empty()).then_some(text)
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum OpenAiResponsesContentDirection {
    Input,
    Output,
}

pub(crate) fn blocks_to_openai_responses_part_array(
    blocks: &[ContentBlock],
    direction: OpenAiResponsesContentDirection,
) -> Option<openai::OpenAiContent> {
    match direction {
        OpenAiResponsesContentDirection::Input => {
            blocks_to_openai_part_array(blocks, "input_text", "input_image")
        }
        OpenAiResponsesContentDirection::Output => {
            if blocks.is_empty() {
                return None;
            }
            Some(openai::OpenAiContent::Parts(
                blocks
                    .iter()
                    .map(block_to_openai_response_output_part)
                    .collect(),
            ))
        }
    }
}

fn blocks_to_openai_part_array(
    blocks: &[ContentBlock],
    text_kind: &str,
    image_kind: &str,
) -> Option<openai::OpenAiContent> {
    if blocks.is_empty() {
        return None;
    }
    Some(openai::OpenAiContent::Parts(
        blocks
            .iter()
            .map(|block| block_to_openai_part(block, text_kind, image_kind))
            .collect(),
    ))
}

fn block_to_openai_part(
    block: &ContentBlock,
    text_kind: &str,
    image_kind: &str,
) -> openai::OpenAiContentPart {
    let mut part = openai::OpenAiContentPart {
        kind: String::new(),
        text: None,
        image_url: None,
        input_text: None,
        output_text: None,
        input_image: None,
        file: None,
        refusal: None,
        extra: Default::default(),
    };

    match block {
        ContentBlock::Text { text } => {
            part.kind = text_kind.to_string();
            part.text = Some(text.clone());
        }
        ContentBlock::Image {
            media_type,
            url,
            data,
            extensions,
        } => {
            part.kind = image_kind.to_string();
            if image_kind == "image_url" {
                part.image_url = Some(openai::OpenAiImageUrl {
                    url: url
                        .clone()
                        .or_else(|| {
                            data.as_ref()
                                .map(|data| openai_media_url(media_type.as_deref(), data))
                        })
                        .unwrap_or_default(),
                    detail: None,
                    extra: Default::default(),
                });
            } else {
                if let Some(url) = url {
                    part.extra
                        .insert("image_url".to_string(), Value::String(url.clone()));
                } else if let Some(data) = data {
                    part.extra.insert(
                        "image_url".to_string(),
                        Value::String(openai_media_url(media_type.as_deref(), data)),
                    );
                }
                for (key, value) in extensions {
                    part.extra.insert(key.clone(), value.clone());
                }
            }
        }
        ContentBlock::File {
            filename,
            url,
            data,
            extensions,
            ..
        } => {
            if image_kind == "input_image" {
                part.kind = "input_file".to_string();
                if let Some(Value::String(file_id)) = extensions.get("file_id") {
                    part.extra
                        .insert("file_id".to_string(), Value::String(file_id.clone()));
                } else if let Some(url) = url {
                    part.extra
                        .insert("file_url".to_string(), Value::String(url.clone()));
                } else if let Some(data) = data {
                    if let Some(filename) = filename {
                        part.extra
                            .insert("filename".to_string(), Value::String(filename.clone()));
                    }
                    part.extra
                        .insert("file_data".to_string(), Value::String(data.clone()));
                }
                for (key, value) in extensions {
                    part.extra
                        .entry(key.clone())
                        .or_insert_with(|| value.clone());
                }
            } else {
                part.kind = "file".to_string();
                let mut file = Map::new();
                if let Some(filename) = filename {
                    file.insert("filename".to_string(), Value::String(filename.clone()));
                }
                if let Some(Value::String(file_id)) = extensions.get("file_id") {
                    file.insert("file_id".to_string(), Value::String(file_id.clone()));
                } else if let Some(data) = data.as_ref().or(url.as_ref()) {
                    file.insert("file_data".to_string(), Value::String(data.clone()));
                }
                for (key, value) in extensions {
                    file.entry(key.clone()).or_insert_with(|| value.clone());
                }
                part.file = Some(Value::Object(file));
            }
        }
        ContentBlock::Unknown { raw } => {
            if let Ok(raw_part) = serde_json::from_value::<openai::OpenAiContentPart>(raw.clone()) {
                return raw_part;
            }
            part.kind = "unknown".to_string();
            part.extra.insert("raw".to_string(), raw.clone());
        }
        ContentBlock::ToolCall { .. }
        | ContentBlock::ToolResult { .. }
        | ContentBlock::Reasoning { .. } => {
            part.kind = "unknown".to_string();
            part.extra.insert(
                "raw".to_string(),
                serde_json::to_value(block).unwrap_or(Value::Null),
            );
        }
    }

    part
}

fn block_to_openai_response_output_part(block: &ContentBlock) -> openai::OpenAiContentPart {
    let mut part = openai::OpenAiContentPart {
        kind: String::new(),
        text: None,
        image_url: None,
        input_text: None,
        output_text: None,
        input_image: None,
        file: None,
        refusal: None,
        extra: Default::default(),
    };

    match block {
        ContentBlock::Text { text } => {
            part.kind = "output_text".to_string();
            part.text = Some(text.clone());
        }
        ContentBlock::Unknown { raw } => {
            if let Ok(raw_part) = serde_json::from_value::<openai::OpenAiContentPart>(raw.clone()) {
                return raw_part;
            }
            part.kind = "unknown".to_string();
            part.extra.insert("raw".to_string(), raw.clone());
        }
        _ => {
            part.kind = "unknown".to_string();
            part.extra.insert(
                "raw".to_string(),
                serde_json::to_value(block).unwrap_or(Value::Null),
            );
        }
    }

    part
}

#[cfg(test)]
mod tests {
    use serde_json::{json, Value};

    use crate::translator::common::empty_extensions;
    use crate::ContentBlock;

    use super::{
        blocks_to_openai_content, blocks_to_openai_responses_part_array,
        OpenAiResponsesContentDirection,
    };

    #[test]
    fn chat_image_data_is_encoded_as_image_url_data_url() {
        let content = blocks_to_openai_content(
            &[ContentBlock::Image {
                media_type: Some("image/png".to_string()),
                url: None,
                data: Some("abc123".to_string()),
                extensions: empty_extensions(),
            }],
            "text",
            "image_url",
        );

        let value = serde_json::to_value(content).unwrap();
        assert_eq!(value[0]["type"], "image_url");
        assert_eq!(value[0]["image_url"]["url"], "data:image/png;base64,abc123");
    }

    #[test]
    fn responses_image_data_is_encoded_as_input_image() {
        let content = blocks_to_openai_responses_part_array(
            &[ContentBlock::Image {
                media_type: Some("image/png".to_string()),
                url: None,
                data: Some("abc123".to_string()),
                extensions: empty_extensions(),
            }],
            OpenAiResponsesContentDirection::Input,
        );

        let value = serde_json::to_value(content).unwrap();
        assert_eq!(value[0]["type"], "input_image");
        assert_eq!(value[0]["image_url"], "data:image/png;base64,abc123");
    }

    #[test]
    fn responses_file_url_is_encoded_as_input_file() {
        let content = blocks_to_openai_responses_part_array(
            &[ContentBlock::File {
                media_type: Some("application/pdf".to_string()),
                filename: Some("paper.pdf".to_string()),
                url: Some("https://example.test/paper.pdf".to_string()),
                data: None,
                extensions: empty_extensions(),
            }],
            OpenAiResponsesContentDirection::Input,
        );

        let value = serde_json::to_value(content).unwrap();
        assert_eq!(value[0]["type"], "input_file");
        assert_eq!(value[0]["file_url"], "https://example.test/paper.pdf");
        assert_eq!(value[0].get("file_data"), None);
    }

    #[test]
    fn chat_file_data_uses_openai_compatible_file_data_key() {
        let content = blocks_to_openai_content(
            &[ContentBlock::File {
                media_type: Some("application/pdf".to_string()),
                filename: Some("paper.pdf".to_string()),
                url: None,
                data: Some("data:application/pdf;base64,AAAA".to_string()),
                extensions: empty_extensions(),
            }],
            "text",
            "image_url",
        );

        let value = serde_json::to_value(content).unwrap();
        assert_eq!(value[0]["type"], "file");
        assert_eq!(value[0]["file"]["filename"], "paper.pdf");
        assert_eq!(
            value[0]["file"]["file_data"],
            "data:application/pdf;base64,AAAA"
        );
        assert_eq!(value[0]["file"].get("data"), None);
    }

    #[test]
    fn chat_file_id_is_preserved() {
        let mut extensions = empty_extensions();
        extensions.insert("file_id".to_string(), json!("file_123"));

        let content = blocks_to_openai_content(
            &[ContentBlock::File {
                media_type: None,
                filename: None,
                url: None,
                data: None,
                extensions,
            }],
            "text",
            "image_url",
        );

        let value = serde_json::to_value(content).unwrap();
        assert_eq!(
            value[0]["file"]["file_id"],
            Value::String("file_123".to_string())
        );
    }
}