agent-sdk 0.8.0

Rust Agent SDK for building LLM agents
Documentation
use crate::llm::{ChatRequest, Content, ContentBlock};
use anyhow::{Result, bail};
use base64::Engine;

const ANTHROPIC_MAX_IMAGES_PER_REQUEST: usize = 100;
const ANTHROPIC_MAX_DOCUMENTS_PER_REQUEST: usize = 5;
const ANTHROPIC_MAX_IMAGE_BYTES: usize = 5 * 1024 * 1024;
const ANTHROPIC_MAX_INLINE_ATTACHMENT_BYTES: usize = 32 * 1024 * 1024;

const GEMINI_MAX_INLINE_ATTACHMENT_BYTES: usize = 20 * 1024 * 1024;
const OPENAI_MAX_IMAGE_BYTES: usize = 20 * 1024 * 1024;
const OPENAI_MAX_DOCUMENT_BYTES: usize = 32 * 1024 * 1024;

const SUPPORTED_IMAGE_MEDIA_TYPES: &[&str] =
    &["image/jpeg", "image/png", "image/gif", "image/webp"];
const SUPPORTED_DOCUMENT_MEDIA_TYPES: &[&str] = &["application/pdf"];

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AttachmentPolicy {
    Anthropic,
    Gemini,
    OpenAI,
}

#[derive(Debug)]
pub(crate) struct AttachmentRef<'a> {
    pub media_type: &'a str,
    pub data: &'a str,
    pub kind: AttachmentKind,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum AttachmentKind {
    Image,
    Document,
}

pub(crate) fn request_has_attachments(request: &ChatRequest) -> bool {
    !collect_attachments(request).is_empty()
}

pub(crate) fn collect_attachments(request: &ChatRequest) -> Vec<AttachmentRef<'_>> {
    let mut attachments = Vec::new();

    for message in &request.messages {
        if let Content::Blocks(blocks) = &message.content {
            for block in blocks {
                match block {
                    ContentBlock::Image { source } => attachments.push(AttachmentRef {
                        media_type: &source.media_type,
                        data: &source.data,
                        kind: AttachmentKind::Image,
                    }),
                    ContentBlock::Document { source } => attachments.push(AttachmentRef {
                        media_type: &source.media_type,
                        data: &source.data,
                        kind: AttachmentKind::Document,
                    }),
                    ContentBlock::Text { .. }
                    | ContentBlock::Thinking { .. }
                    | ContentBlock::RedactedThinking { .. }
                    | ContentBlock::ToolUse { .. }
                    | ContentBlock::ToolResult { .. } => {}
                }
            }
        }
    }

    attachments
}

pub(crate) fn decode_attachment_bytes(data: &str) -> Result<Vec<u8>> {
    base64::engine::general_purpose::STANDARD
        .decode(data)
        .map_err(|error| anyhow::anyhow!("invalid base64 attachment data: {error}"))
}

pub(crate) fn validate_request_attachments(
    provider: &str,
    model: &str,
    request: &ChatRequest,
) -> Result<()> {
    let attachments = collect_attachments(request);
    if attachments.is_empty() {
        return Ok(());
    }

    match attachment_policy(provider, model) {
        Some(AttachmentPolicy::Anthropic) => validate_anthropic_inline_attachments(&attachments),
        Some(AttachmentPolicy::Gemini) => validate_gemini_inline_attachments(&attachments),
        Some(AttachmentPolicy::OpenAI) => validate_openai_inline_attachments(&attachments),
        None => bail!(
            "provider={provider} model={model} does not support image/document content blocks in this SDK yet"
        ),
    }
}

fn attachment_policy(provider: &str, model: &str) -> Option<AttachmentPolicy> {
    match provider {
        "anthropic" => Some(AttachmentPolicy::Anthropic),
        "openai" | "openai-responses" => Some(AttachmentPolicy::OpenAI),
        "vertex" if model.starts_with("claude-") => Some(AttachmentPolicy::Anthropic),
        "gemini" | "vertex" => Some(AttachmentPolicy::Gemini),
        _ => None,
    }
}

fn validate_media_type(kind: AttachmentKind, media_type: &str, provider_label: &str) -> Result<()> {
    match kind {
        AttachmentKind::Image if SUPPORTED_IMAGE_MEDIA_TYPES.contains(&media_type) => Ok(()),
        AttachmentKind::Document if SUPPORTED_DOCUMENT_MEDIA_TYPES.contains(&media_type) => Ok(()),
        AttachmentKind::Image => {
            bail!("unsupported image media type '{media_type}' for {provider_label} attachments")
        }
        AttachmentKind::Document => {
            bail!("unsupported document media type '{media_type}' for {provider_label} attachments")
        }
    }
}

fn validate_anthropic_inline_attachments(attachments: &[AttachmentRef<'_>]) -> Result<()> {
    let mut image_count = 0;
    let mut document_count = 0;
    let mut total_inline_bytes = 0;

    for attachment in attachments {
        let decoded_bytes = decode_attachment_bytes(attachment.data)?;
        total_inline_bytes += attachment.data.len();
        validate_media_type(
            attachment.kind,
            attachment.media_type,
            "Anthropic/Vertex Claude",
        )?;

        match attachment.kind {
            AttachmentKind::Image => {
                image_count += 1;
                if decoded_bytes.len() > ANTHROPIC_MAX_IMAGE_BYTES {
                    bail!(
                        "image attachment exceeds Anthropic limit: {} bytes > {} bytes",
                        decoded_bytes.len(),
                        ANTHROPIC_MAX_IMAGE_BYTES
                    );
                }
            }
            AttachmentKind::Document => {
                document_count += 1;
            }
        }
    }

    if image_count > ANTHROPIC_MAX_IMAGES_PER_REQUEST {
        bail!(
            "too many image attachments for Anthropic/Vertex Claude: {image_count} > {ANTHROPIC_MAX_IMAGES_PER_REQUEST}"
        );
    }

    if document_count > ANTHROPIC_MAX_DOCUMENTS_PER_REQUEST {
        bail!(
            "too many document attachments for Anthropic/Vertex Claude: {document_count} > {ANTHROPIC_MAX_DOCUMENTS_PER_REQUEST}"
        );
    }

    if total_inline_bytes > ANTHROPIC_MAX_INLINE_ATTACHMENT_BYTES {
        bail!(
            "total inline attachment payload exceeds Anthropic/Vertex Claude limit: {total_inline_bytes} bytes > {ANTHROPIC_MAX_INLINE_ATTACHMENT_BYTES} bytes"
        );
    }

    Ok(())
}

fn validate_gemini_inline_attachments(attachments: &[AttachmentRef<'_>]) -> Result<()> {
    let mut total_inline_bytes = 0;

    for attachment in attachments {
        let decoded_bytes = decode_attachment_bytes(attachment.data)?;
        validate_media_type(
            attachment.kind,
            attachment.media_type,
            "Gemini/Vertex Gemini",
        )?;
        total_inline_bytes += decoded_bytes.len();
    }

    if total_inline_bytes > GEMINI_MAX_INLINE_ATTACHMENT_BYTES {
        bail!(
            "total inline attachment payload exceeds Gemini/Vertex Gemini limit: {total_inline_bytes} bytes > {GEMINI_MAX_INLINE_ATTACHMENT_BYTES} bytes"
        );
    }

    Ok(())
}

fn validate_openai_inline_attachments(attachments: &[AttachmentRef<'_>]) -> Result<()> {
    for attachment in attachments {
        let decoded_bytes = decode_attachment_bytes(attachment.data)?;
        validate_media_type(attachment.kind, attachment.media_type, "OpenAI")?;

        match attachment.kind {
            AttachmentKind::Image if decoded_bytes.len() > OPENAI_MAX_IMAGE_BYTES => {
                bail!(
                    "image attachment exceeds OpenAI inline limit: {} bytes > {} bytes",
                    decoded_bytes.len(),
                    OPENAI_MAX_IMAGE_BYTES
                );
            }
            AttachmentKind::Document if decoded_bytes.len() > OPENAI_MAX_DOCUMENT_BYTES => {
                bail!(
                    "document attachment exceeds OpenAI inline limit: {} bytes > {} bytes",
                    decoded_bytes.len(),
                    OPENAI_MAX_DOCUMENT_BYTES
                );
            }
            AttachmentKind::Image | AttachmentKind::Document => {}
        }
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::llm::{ContentSource, Message, Role};

    fn request_with_blocks(blocks: Vec<ContentBlock>) -> ChatRequest {
        ChatRequest {
            system: String::new(),
            messages: vec![Message {
                role: Role::User,
                content: Content::Blocks(blocks),
            }],
            tools: None,
            max_tokens: 1024,
            max_tokens_explicit: true,
            session_id: None,
            cached_content: None,
            thinking: None,
        }
    }

    #[test]
    fn test_request_has_attachments() {
        let request = request_with_blocks(vec![ContentBlock::Image {
            source: ContentSource::new(
                "image/png",
                base64::engine::general_purpose::STANDARD.encode(b"png"),
            ),
        }]);
        assert!(request_has_attachments(&request));
    }

    #[test]
    fn test_validate_anthropic_accepts_supported_image() -> anyhow::Result<()> {
        let request = request_with_blocks(vec![ContentBlock::Image {
            source: ContentSource::new(
                "image/png",
                base64::engine::general_purpose::STANDARD.encode(b"png"),
            ),
        }]);

        validate_request_attachments("anthropic", "claude-sonnet-4-6", &request)?;
        Ok(())
    }

    #[test]
    fn test_validate_anthropic_rejects_unsupported_document_type() {
        let request = request_with_blocks(vec![ContentBlock::Document {
            source: ContentSource::new(
                "application/msword",
                base64::engine::general_purpose::STANDARD.encode(b"doc"),
            ),
        }]);

        let error = validate_request_attachments("anthropic", "claude-sonnet-4-6", &request)
            .expect_err("expected unsupported media type error");
        assert!(
            error
                .to_string()
                .contains("unsupported document media type")
        );
    }

    #[test]
    fn test_validate_anthropic_rejects_large_image() {
        let oversized = vec![0_u8; ANTHROPIC_MAX_IMAGE_BYTES + 1];
        let request = request_with_blocks(vec![ContentBlock::Image {
            source: ContentSource::new(
                "image/png",
                base64::engine::general_purpose::STANDARD.encode(oversized),
            ),
        }]);

        let error = validate_request_attachments("anthropic", "claude-sonnet-4-6", &request)
            .expect_err("expected large image error");
        assert!(
            error
                .to_string()
                .contains("image attachment exceeds Anthropic limit")
        );
    }

    #[test]
    fn test_validate_openai_accepts_attachments() -> anyhow::Result<()> {
        let request = request_with_blocks(vec![ContentBlock::Document {
            source: ContentSource::new(
                "application/pdf",
                base64::engine::general_purpose::STANDARD.encode(b"%PDF-1.7"),
            ),
        }]);

        validate_request_attachments("openai", "gpt-5", &request)?;
        Ok(())
    }

    #[test]
    fn test_validate_vertex_claude_accepts_attachments() -> anyhow::Result<()> {
        let request = request_with_blocks(vec![ContentBlock::Document {
            source: ContentSource::new(
                "application/pdf",
                base64::engine::general_purpose::STANDARD.encode(b"%PDF-1.7"),
            ),
        }]);

        validate_request_attachments("vertex", "claude-sonnet-4-6", &request)?;
        Ok(())
    }

    #[test]
    fn test_validate_vertex_gemini_accepts_attachments() -> anyhow::Result<()> {
        let request = request_with_blocks(vec![ContentBlock::Image {
            source: ContentSource::new(
                "image/png",
                base64::engine::general_purpose::STANDARD.encode(b"png"),
            ),
        }]);

        validate_request_attachments("vertex", "gemini-2.5-pro", &request)?;
        Ok(())
    }
}