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::anthropic;
use crate::ContentBlock;

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

use super::media::{anthropic_source_to_file, anthropic_source_to_image};

pub(crate) fn anthropic_content_to_blocks(
    content: &anthropic::AnthropicContent,
) -> Vec<ContentBlock> {
    match content {
        anthropic::AnthropicContent::Text(text) => vec![ContentBlock::Text { text: text.clone() }],
        anthropic::AnthropicContent::Blocks(blocks) => {
            blocks.iter().map(anthropic_block_to_block).collect()
        }
        anthropic::AnthropicContent::Raw(raw) => vec![ContentBlock::Unknown { raw: raw.clone() }],
    }
}

pub(crate) fn anthropic_system_to_blocks(
    system: Option<&anthropic::AnthropicSystem>,
) -> Vec<ContentBlock> {
    match system {
        Some(anthropic::AnthropicSystem::Text(text)) => {
            vec![ContentBlock::Text { text: text.clone() }]
        }
        Some(anthropic::AnthropicSystem::Blocks(blocks)) => {
            blocks.iter().map(anthropic_block_to_block).collect()
        }
        Some(anthropic::AnthropicSystem::Raw(raw)) => {
            vec![ContentBlock::Unknown { raw: raw.clone() }]
        }
        None => Vec::new(),
    }
}

pub(crate) fn anthropic_block_to_block(block: &anthropic::AnthropicContentBlock) -> ContentBlock {
    match block.kind.as_str() {
        "text" => ContentBlock::Text {
            text: block.text.clone().unwrap_or_default(),
        },
        "image" => {
            let image = anthropic_source_to_image(block.source.as_ref());
            ContentBlock::Image {
                media_type: image.media_type,
                url: image.url,
                data: image.data,
                extensions: empty_extensions(),
            }
        }
        "document" => {
            let file = anthropic_source_to_file(block.source.as_ref());
            let mut extensions = empty_extensions();
            if let Some(file_id) = file.file_id {
                extensions.insert("file_id".to_string(), Value::String(file_id));
            }
            ContentBlock::File {
                media_type: file.media_type,
                filename: block
                    .extra
                    .get("title")
                    .or_else(|| block.extra.get("filename"))
                    .or_else(|| block.extra.get("name"))
                    .and_then(Value::as_str)
                    .map(ToString::to_string),
                url: file.url,
                data: file.data,
                extensions,
            }
        }
        "tool_use" => ContentBlock::ToolCall {
            id: block.id.clone().unwrap_or_default(),
            name: block.name.clone().unwrap_or_default(),
            arguments: block.input.clone().unwrap_or(Value::Null),
            extensions: empty_extensions(),
        },
        "tool_result" => ContentBlock::ToolResult {
            tool_call_id: block.tool_use_id.clone().unwrap_or_default(),
            content: value_to_blocks(block.content.as_ref()),
            is_error: block
                .extra
                .get("is_error")
                .and_then(Value::as_bool)
                .unwrap_or(false),
            extensions: empty_extensions(),
        },
        "thinking" => ContentBlock::Reasoning {
            text: block.thinking.clone().or_else(|| block.text.clone()),
            encrypted: block.signature.clone(),
            extensions: empty_extensions(),
        },
        "redacted_thinking" => ContentBlock::Unknown {
            raw: serde_json::to_value(block).unwrap_or(Value::Null),
        },
        _ => ContentBlock::Unknown {
            raw: serde_json::to_value(block).unwrap_or(Value::Null),
        },
    }
}

fn value_to_blocks(value: Option<&Value>) -> Vec<ContentBlock> {
    match value {
        Some(Value::String(text)) => vec![ContentBlock::Text { text: text.clone() }],
        Some(Value::Array(items)) => items
            .iter()
            .map(|item| {
                serde_json::from_value::<anthropic::AnthropicContentBlock>(item.clone())
                    .map(|block| anthropic_block_to_block(&block))
                    .unwrap_or_else(|_| ContentBlock::Unknown { raw: item.clone() })
            })
            .collect(),
        Some(value) => vec![ContentBlock::Unknown { raw: value.clone() }],
        None => Vec::new(),
    }
}

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

    use crate::schema::anthropic::AnthropicContentBlock;
    use crate::translator::anthropic::block_to_anthropic_block;
    use crate::ContentBlock;

    use super::anthropic_block_to_block;

    #[test]
    fn preserves_redacted_thinking_as_raw_anthropic_block() {
        let mut extra: crate::schema::anthropic::ExtraFields = Default::default();
        extra.insert("data".to_string(), json!("opaque"));
        let block = AnthropicContentBlock {
            kind: "redacted_thinking".to_string(),
            text: None,
            source: None,
            id: None,
            name: None,
            input: None,
            tool_use_id: None,
            content: None,
            thinking: None,
            signature: None,
            extra,
        };

        let decoded = anthropic_block_to_block(&block);
        let ContentBlock::Unknown { raw } = &decoded else {
            panic!("redacted thinking should stay raw");
        };
        assert_eq!(raw["type"], "redacted_thinking");
        assert_eq!(raw["data"], "opaque");

        let encoded = block_to_anthropic_block(&decoded);
        assert_eq!(encoded.kind, "redacted_thinking");
        assert_eq!(
            encoded.extra.get("data"),
            Some(&Value::String("opaque".to_string()))
        );
    }

    #[test]
    fn decodes_document_file_id_as_file_block() {
        let mut extra: crate::schema::anthropic::ExtraFields = Default::default();
        extra.insert("title".to_string(), json!("report.pdf"));
        let block = AnthropicContentBlock {
            kind: "document".to_string(),
            text: None,
            source: Some(json!({
                "type": "file",
                "file_id": "file_123"
            })),
            id: None,
            name: None,
            input: None,
            tool_use_id: None,
            content: None,
            thinking: None,
            signature: None,
            extra,
        };

        let decoded = anthropic_block_to_block(&block);
        let ContentBlock::File {
            filename,
            extensions,
            ..
        } = decoded
        else {
            panic!("document should decode as file");
        };
        assert_eq!(filename.as_deref(), Some("report.pdf"));
        assert_eq!(extensions.get("file_id"), Some(&json!("file_123")));
    }

    #[test]
    fn encodes_file_data_as_document_base64_source() {
        let block = ContentBlock::File {
            media_type: None,
            filename: Some("report.pdf".to_string()),
            url: None,
            data: Some("data:application/pdf;base64,AAAA".to_string()),
            extensions: crate::translator::common::empty_extensions(),
        };

        let encoded = block_to_anthropic_block(&block);
        assert_eq!(encoded.kind, "document");
        assert_eq!(encoded.extra.get("title"), Some(&json!("report.pdf")));
        assert_eq!(
            encoded.source,
            Some(json!({
                "type": "base64",
                "media_type": "application/pdf",
                "data": "AAAA"
            }))
        );
    }

    #[test]
    fn encodes_file_url_as_document_url_source() {
        let block = ContentBlock::File {
            media_type: Some("application/pdf".to_string()),
            filename: None,
            url: Some("https://example.test/report.pdf".to_string()),
            data: None,
            extensions: crate::translator::common::empty_extensions(),
        };

        let encoded = block_to_anthropic_block(&block);
        assert_eq!(encoded.kind, "document");
        assert_eq!(
            encoded.source,
            Some(json!({
                "type": "url",
                "url": "https://example.test/report.pdf"
            }))
        );
    }

    #[test]
    fn encodes_image_url_as_url_source() {
        let block = ContentBlock::Image {
            media_type: Some("image/png".to_string()),
            url: Some("https://example.test/image.png".to_string()),
            data: None,
            extensions: crate::translator::common::empty_extensions(),
        };

        let encoded = block_to_anthropic_block(&block);

        assert_eq!(encoded.kind, "image");
        assert_eq!(
            encoded.source,
            Some(json!({
                "type": "url",
                "url": "https://example.test/image.png"
            }))
        );
    }
}