va-ai-api-bridge 0.1.7

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

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

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

pub(crate) fn openai_content_to_blocks(
    content: Option<&openai::OpenAiContent>,
) -> Vec<ContentBlock> {
    let Some(content) = content else {
        return Vec::new();
    };
    match content {
        openai::OpenAiContent::Text(text) => vec![ContentBlock::Text { text: text.clone() }],
        openai::OpenAiContent::Parts(parts) => parts.iter().map(openai_part_to_block).collect(),
        openai::OpenAiContent::Null => Vec::new(),
        openai::OpenAiContent::Raw(raw) => vec![ContentBlock::Unknown { raw: raw.clone() }],
    }
}

fn openai_part_to_block(part: &openai::OpenAiContentPart) -> ContentBlock {
    match part.kind.as_str() {
        "text" => ContentBlock::Text {
            text: part.text.clone().unwrap_or_default(),
        },
        "input_text" => ContentBlock::Text {
            text: part
                .input_text
                .clone()
                .or_else(|| part.text.clone())
                .unwrap_or_default(),
        },
        "output_text" => ContentBlock::Text {
            text: part
                .output_text
                .clone()
                .or_else(|| part.text.clone())
                .unwrap_or_default(),
        },
        "image_url" => match &part.image_url {
            Some(image_url) => ContentBlock::Image {
                media_type: None,
                url: Some(image_url.url.clone()),
                data: None,
                extensions: empty_extensions(),
            },
            None => ContentBlock::Unknown {
                raw: serde_json::to_value(part).unwrap_or(Value::Null),
            },
        },
        "input_image" => input_image_part_to_block(part),
        "file" | "input_file" => value_to_file_or_unknown(
            part.file
                .as_ref()
                .unwrap_or(&Value::Object(part.extra.clone().into_iter().collect())),
        ),
        _ => ContentBlock::Unknown {
            raw: serde_json::to_value(part).unwrap_or(Value::Null),
        },
    }
}

fn value_to_image_or_unknown(value: &Value) -> ContentBlock {
    if let Some(url) = value.as_str() {
        return ContentBlock::Image {
            media_type: None,
            url: Some(url.to_string()),
            data: None,
            extensions: empty_extensions(),
        };
    }
    let Some(object) = value.as_object() else {
        return ContentBlock::Unknown { raw: value.clone() };
    };
    let mut extensions = empty_extensions();
    for key in ["file_id", "detail"] {
        if let Some(value) = object.get(key) {
            extensions.insert(key.to_string(), value.clone());
        }
    }
    ContentBlock::Image {
        media_type: object
            .get("media_type")
            .and_then(Value::as_str)
            .map(ToString::to_string),
        url: image_url_from_object(object).or_else(|| {
            object
                .get("url")
                .and_then(Value::as_str)
                .map(ToString::to_string)
        }),
        data: object
            .get("data")
            .and_then(Value::as_str)
            .map(ToString::to_string),
        extensions,
    }
}

fn input_image_part_to_block(part: &openai::OpenAiContentPart) -> ContentBlock {
    if let Some(image_url) = &part.image_url {
        let mut extensions = empty_extensions();
        if let Some(detail) = &image_url.detail {
            extensions.insert("detail".to_string(), Value::String(detail.clone()));
        }
        for key in ["file_id", "detail"] {
            if let Some(value) = part.extra.get(key) {
                extensions.insert(key.to_string(), value.clone());
            }
        }
        return ContentBlock::Image {
            media_type: None,
            url: Some(image_url.url.clone()),
            data: None,
            extensions,
        };
    }

    value_to_image_or_unknown(
        part.input_image
            .as_ref()
            .unwrap_or(&Value::Object(part.extra.clone().into_iter().collect())),
    )
}

fn image_url_from_object(object: &serde_json::Map<String, Value>) -> Option<String> {
    match object.get("image_url") {
        Some(Value::String(url)) => Some(url.clone()),
        Some(Value::Object(image_url)) => image_url
            .get("url")
            .and_then(Value::as_str)
            .map(ToString::to_string),
        _ => None,
    }
}

fn value_to_file_or_unknown(value: &Value) -> ContentBlock {
    let Some(object) = value.as_object() else {
        return ContentBlock::Unknown { raw: value.clone() };
    };
    let mut extensions = empty_extensions();
    if let Some(file_id) = object.get("file_id") {
        extensions.insert("file_id".to_string(), file_id.clone());
    }
    let explicit_url = object
        .get("url")
        .or_else(|| object.get("file_url"))
        .or_else(|| object.get("fileUrl"))
        .and_then(Value::as_str)
        .map(ToString::to_string);
    let file_data = object
        .get("data")
        .or_else(|| object.get("file_data"))
        .or_else(|| object.get("fileData"))
        .and_then(Value::as_str)
        .map(ToString::to_string);
    let (url, data) = match (explicit_url, file_data) {
        (Some(url), data) => (Some(url), data),
        (None, Some(value)) if is_http_url(&value) => (Some(value), None),
        (None, data) => (None, data),
    };

    ContentBlock::File {
        media_type: object
            .get("media_type")
            .and_then(Value::as_str)
            .map(ToString::to_string),
        filename: object
            .get("filename")
            .or_else(|| object.get("name"))
            .and_then(Value::as_str)
            .map(ToString::to_string),
        url,
        data,
        extensions,
    }
}

fn is_http_url(value: &str) -> bool {
    value.starts_with("http://") || value.starts_with("https://")
}

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

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

    use super::openai_content_to_blocks;

    #[test]
    fn decodes_openrouter_file_data_url_as_file_url() {
        let blocks =
            openai_content_to_blocks(Some(&OpenAiContent::Parts(vec![serde_json::from_value(
                json!({
                    "type": "file",
                    "file": {
                        "filename": "paper.pdf",
                        "fileData": "https://example.test/paper.pdf"
                    }
                }),
            )
            .unwrap()])));

        let ContentBlock::File {
            filename,
            url,
            data,
            ..
        } = &blocks[0]
        else {
            panic!("file part should decode as file");
        };
        assert_eq!(filename.as_deref(), Some("paper.pdf"));
        assert_eq!(url.as_deref(), Some("https://example.test/paper.pdf"));
        assert_eq!(data, &None);
    }

    #[test]
    fn decodes_responses_input_image_string_as_image_url() {
        let blocks =
            openai_content_to_blocks(Some(&OpenAiContent::Parts(vec![serde_json::from_value(
                json!({
                    "type": "input_image",
                    "image_url": "data:image/png;base64,abc123"
                }),
            )
            .unwrap()])));

        let ContentBlock::Image { url, data, .. } = &blocks[0] else {
            panic!("input_image part should decode as image");
        };
        assert_eq!(url.as_deref(), Some("data:image/png;base64,abc123"));
        assert_eq!(data, &None);
    }
}