gproxy-transform 3.0.0-alpha.0

GPROXY v3 pairwise protocol transforms
Documentation
use gproxy_protocol::{claude, openai};

use crate::TransformError;

pub(crate) fn chat_text_blocks(
    content: openai::ChatTextContent,
) -> Result<Vec<claude::ContentBlockParam>, TransformError> {
    match content {
        openai::ChatTextContent::Text(text) => Ok(text_block(text, None).into_iter().collect()),
        openai::ChatTextContent::Parts(parts) => parts
            .into_iter()
            .map(|part| match part {
                openai::ChatTextContentPart::Text(part) => {
                    Ok(text_block(part.text, part.prompt_cache_breakpoint))
                }
                openai::ChatTextContentPart::Unknown(_) => Ok(None),
                #[cfg(not(feature = "exhaustive"))]
                _ => {
                    return Err(crate::TransformError::unsupported(
                        "protocol enum",
                        "unrecognized external variant",
                    ));
                }
            })
            .filter_map(Result::transpose)
            .collect(),
        openai::ChatTextContent::Unknown(raw) => Err(TransformError::unsupported(
            "OpenAI Chat text content",
            raw.to_string(),
        )),
        #[cfg(not(feature = "exhaustive"))]
        _ => {
            return Err(crate::TransformError::unsupported(
                "protocol enum",
                "unrecognized external variant",
            ));
        }
    }
}

pub(crate) fn chat_user_blocks(
    content: openai::ChatContent,
) -> Result<Vec<claude::ContentBlockParam>, TransformError> {
    match content {
        openai::ChatContent::Text(text) => Ok(text_block(text, None).into_iter().collect()),
        openai::ChatContent::Parts(parts) => parts
            .into_iter()
            .filter_map(|part| chat_part_to_claude(part).transpose())
            .collect(),
        openai::ChatContent::Unknown(raw) => Err(TransformError::unsupported(
            "OpenAI Chat content",
            raw.to_string(),
        )),
        #[cfg(not(feature = "exhaustive"))]
        _ => {
            return Err(crate::TransformError::unsupported(
                "protocol enum",
                "unrecognized external variant",
            ));
        }
    }
}

pub(crate) fn chat_assistant_blocks(
    content: openai::ChatAssistantContent,
) -> Result<Vec<claude::ContentBlockParam>, TransformError> {
    match content {
        openai::ChatAssistantContent::Text(text) => {
            Ok(text_block(text, None).into_iter().collect())
        }
        openai::ChatAssistantContent::Parts(parts) => parts
            .into_iter()
            .map(|part| match part {
                openai::ChatAssistantContentPart::Text(part) => {
                    Ok(text_block(part.text, part.prompt_cache_breakpoint))
                }
                openai::ChatAssistantContentPart::Refusal(part) => {
                    Ok(text_block(part.refusal, part.prompt_cache_breakpoint))
                }
                openai::ChatAssistantContentPart::Unknown(_) => Ok(None),
                #[cfg(not(feature = "exhaustive"))]
                _ => {
                    return Err(crate::TransformError::unsupported(
                        "protocol enum",
                        "unrecognized external variant",
                    ));
                }
            })
            .filter_map(Result::transpose)
            .collect(),
        openai::ChatAssistantContent::Unknown(raw) => Err(TransformError::unsupported(
            "OpenAI Chat assistant content",
            raw.to_string(),
        )),
        #[cfg(not(feature = "exhaustive"))]
        _ => {
            return Err(crate::TransformError::unsupported(
                "protocol enum",
                "unrecognized external variant",
            ));
        }
    }
}

pub(crate) fn claude_system_to_chat(
    system: claude::SystemPrompt,
) -> Result<openai::ChatTextContent, TransformError> {
    match system {
        claude::StringOrArray::String(text) => Ok(openai::ChatTextContent::Text(text)),
        claude::StringOrArray::Array(blocks) => Ok(openai::ChatTextContent::Parts(
            blocks
                .into_iter()
                .map(|block| {
                    Ok(openai::ChatTextContentPart::Text(crate::wire!(
                        openai::ChatTextPart {
                            type_: openai::ChatTextPartType::Text,
                            text: block.text,
                            prompt_cache_breakpoint: cache_breakpoint(block.cache_control),
                            rest: Default::default(),
                        }
                    )))
                })
                .collect::<Result<_, TransformError>>()?,
        )),
        claude::StringOrArray::Raw(raw) => Err(TransformError::unsupported(
            "Claude system prompt",
            raw.to_string(),
        )),
        _ => Err(TransformError::unsupported(
            "Claude system prompt",
            "future system shape",
        )),
    }
}

pub(crate) fn claude_user_parts(
    blocks: Vec<claude::ContentBlockParam>,
) -> Result<Vec<openai::ChatContentPart>, TransformError> {
    blocks
        .into_iter()
        .filter_map(|block| match block {
            claude::ContentBlockParam::Text(block) => Some(Ok(openai::ChatContentPart::Text(
                crate::wire!(openai::ChatTextPart {
                    type_: openai::ChatTextPartType::Text,
                    text: block.text,
                    prompt_cache_breakpoint: cache_breakpoint(block.cache_control),
                    rest: Default::default(),
                }),
            ))),
            claude::ContentBlockParam::Image(block) => Some(image_to_chat(block)),
            claude::ContentBlockParam::Document(block) => Some(document_to_chat(block)),
            claude::ContentBlockParam::Raw(_) => None,
            other => Some(Err(TransformError::unsupported(
                "Claude user block",
                variant_name(&other),
            ))),
        })
        .collect()
}

fn chat_part_to_claude(
    part: openai::ChatContentPart,
) -> Result<Option<claude::ContentBlockParam>, TransformError> {
    match part {
        openai::ChatContentPart::Text(part) => {
            Ok(text_block(part.text, part.prompt_cache_breakpoint))
        }
        openai::ChatContentPart::ImageUrl(part) => Ok(Some(claude::ContentBlockParam::Image(
            crate::wire!(claude::ImageBlock {
                source: image_source(part.image_url.url)?,
                type_: claude::ImageBlockType::Image,
                cache_control: cache_control(part.prompt_cache_breakpoint),
                rest: Default::default(),
            }),
        ))),
        openai::ChatContentPart::File(part) => Ok(Some(claude::ContentBlockParam::Document(
            crate::wire!(claude::DocumentBlock {
                source: document_source(&part.file)?,
                type_: claude::DocumentBlockType::Document,
                cache_control: cache_control(part.prompt_cache_breakpoint),
                citations: None,
                context: None,
                title: part.file.filename,
                rest: Default::default(),
            }),
        ))),
        openai::ChatContentPart::InputAudio(_) => Err(TransformError::unsupported(
            "OpenAI Chat content",
            "input_audio",
        )),
        openai::ChatContentPart::Unknown(_) => Ok(None),
        #[cfg(not(feature = "exhaustive"))]
        _ => {
            return Err(crate::TransformError::unsupported(
                "protocol enum",
                "unrecognized external variant",
            ));
        }
    }
}

fn text_block(
    text: String,
    breakpoint: Option<openai::PromptCacheBreakpoint>,
) -> Option<claude::ContentBlockParam> {
    (!text.is_empty()).then(|| {
        claude::ContentBlockParam::Text(crate::wire!(claude::TextBlock {
            text,
            type_: claude::TextBlockType::Text,
            cache_control: cache_control(breakpoint),
            citations: None,
            rest: Default::default(),
        }))
    })
}

fn cache_control(
    breakpoint: Option<openai::PromptCacheBreakpoint>,
) -> Option<claude::CacheControl> {
    breakpoint.map(|_| {
        crate::wire!(claude::CacheControl {
            type_: claude::CacheControlType::Ephemeral,
            ttl: None,
            rest: Default::default(),
        })
    })
}

fn cache_breakpoint(
    control: Option<claude::CacheControl>,
) -> Option<openai::PromptCacheBreakpoint> {
    control.map(|_| {
        crate::wire!(openai::PromptCacheBreakpoint {
            mode: openai::PromptCacheBreakpointMode::Explicit,
            rest: Default::default(),
        })
    })
}

fn image_source(url: String) -> Result<claude::ImageSource, TransformError> {
    let Some(data) = url.strip_prefix("data:") else {
        return Ok(claude::ImageSource::Url(crate::wire!(
            claude::UrlImageSource {
                type_: claude::UrlSourceType::Url,
                url,
                rest: Default::default(),
            }
        )));
    };
    let (media_type, data) = data
        .split_once(";base64,")
        .ok_or_else(|| TransformError::shape("image URL", "invalid data URL"))?;
    let media_type = match media_type {
        "image/jpeg" => claude::ImageMediaType::Jpeg,
        "image/png" => claude::ImageMediaType::Png,
        "image/gif" => claude::ImageMediaType::Gif,
        "image/webp" => claude::ImageMediaType::Webp,
        other => return Err(TransformError::unsupported("image media type", other)),
    };
    Ok(claude::ImageSource::Base64(crate::wire!(
        claude::Base64ImageSource {
            data: data.into(),
            media_type,
            type_: claude::Base64SourceType::Base64,
            rest: Default::default(),
        }
    )))
}

fn document_source(file: &openai::ChatFileRef) -> Result<claude::DocumentSource, TransformError> {
    if let Some(file_id) = &file.file_id {
        return Ok(claude::DocumentSource::File(crate::wire!(
            claude::FileDocumentSource {
                file_id: file_id.clone(),
                type_: claude::FileSourceType::File,
                rest: Default::default(),
            }
        )));
    }
    let data = file
        .file_data
        .clone()
        .ok_or_else(|| TransformError::shape("OpenAI Chat file", "file data is missing"))?;
    Ok(claude::DocumentSource::Text(crate::wire!(
        claude::PlainTextSource {
            data,
            media_type: claude::PlainTextMediaType::TextPlain,
            type_: claude::TextSourceType::Text,
            rest: Default::default(),
        }
    )))
}

fn image_to_chat(block: claude::ImageBlock) -> Result<openai::ChatContentPart, TransformError> {
    let url = match block.source {
        claude::ImageSource::Url(source) => source.url,
        claude::ImageSource::Base64(source) => {
            let media_type = serde_json::to_value(&source.media_type)?
                .as_str()
                .map(str::to_owned)
                .ok_or_else(|| TransformError::shape("Claude image", "media type is not text"))?;
            format!("data:{media_type};base64,{}", source.data)
        }
        claude::ImageSource::File(source) => {
            return Ok(openai::ChatContentPart::File(openai::ChatFilePart {
                type_: openai::ChatFilePartType::File,
                file: crate::wire!(openai::ChatFileRef {
                    file_data: None,
                    file_id: Some(source.file_id),
                    filename: None,
                    rest: Default::default(),
                }),
                prompt_cache_breakpoint: cache_breakpoint(block.cache_control),
                rest: Default::default(),
            }));
        }
        claude::ImageSource::Raw(raw) => {
            return Err(TransformError::unsupported(
                "Claude image source",
                raw.to_string(),
            ));
        }
        _ => {
            return Err(TransformError::unsupported(
                "Claude image source",
                "future image source",
            ));
        }
    };
    Ok(openai::ChatContentPart::ImageUrl(
        openai::ChatImageUrlPart {
            type_: openai::ChatImageUrlPartType::ImageUrl,
            image_url: crate::wire!(openai::ImageUrl {
                url,
                detail: None,
                rest: Default::default(),
            }),
            prompt_cache_breakpoint: cache_breakpoint(block.cache_control),
            rest: Default::default(),
        },
    ))
}

fn document_to_chat(
    block: claude::DocumentBlock,
) -> Result<openai::ChatContentPart, TransformError> {
    let file = match block.source {
        claude::DocumentSource::File(source) => crate::wire!(openai::ChatFileRef {
            file_data: None,
            file_id: Some(source.file_id),
            filename: block.title,
            rest: Default::default(),
        }),
        claude::DocumentSource::Text(source) => crate::wire!(openai::ChatFileRef {
            file_data: Some(source.data),
            file_id: None,
            filename: block.title,
            rest: Default::default(),
        }),
        claude::DocumentSource::Raw(raw) => {
            return Err(TransformError::unsupported(
                "Claude document source",
                raw.to_string(),
            ));
        }
        _ => {
            return Err(TransformError::unsupported(
                "Claude document source",
                "non-file document",
            ));
        }
    };
    Ok(openai::ChatContentPart::File(openai::ChatFilePart {
        type_: openai::ChatFilePartType::File,
        file,
        prompt_cache_breakpoint: cache_breakpoint(block.cache_control),
        rest: Default::default(),
    }))
}

fn variant_name(block: &claude::ContentBlockParam) -> &'static str {
    match block {
        claude::ContentBlockParam::Text(_) => "text",
        claude::ContentBlockParam::Image(_) => "image",
        claude::ContentBlockParam::Document(_) => "document",
        claude::ContentBlockParam::ToolUse(_) => "tool_use",
        claude::ContentBlockParam::ToolResult(_) => "tool_result",
        claude::ContentBlockParam::Thinking(_) => "thinking",
        claude::ContentBlockParam::RedactedThinking(_) => "redacted_thinking",
        claude::ContentBlockParam::Raw(_) => "raw",
        _ => "provider-specific block",
    }
}