Skip to main content

gproxy_protocol/transform/generate/claude_gemini/
media.rs

1use crate::{
2    transform::{Report, TransformError},
3    wire::{claude::content as c, gemini as g},
4};
5use base64::{Engine, engine::general_purpose::STANDARD};
6use std::collections::BTreeMap;
7
8/// MIME and public resource URI resolved by an invocation. The key is the exact
9/// source URL/file ID; opaque native IDs cannot simply become target URLs.
10#[derive(Debug, Default)]
11pub struct MediaFacts {
12    pub resources: BTreeMap<String, g::FileData>,
13}
14
15pub(super) fn image(
16    block: c::ImageBlock,
17    facts: &MediaFacts,
18    report: &mut Report,
19) -> Result<g::Part, TransformError> {
20    if block.cache_control.is_some() {
21        report.omitted("image.cache_control", "Gemini has no per-part cache field");
22    }
23    match block.source {
24        c::ImageSource::Base64(source) => blob(source.data, mime(source.media_type).into()),
25        c::ImageSource::Url(source) => resource(&source.url, facts, true),
26        c::ImageSource::File(source) => resource(&source.file_id, facts, false),
27    }
28}
29
30fn resource(key: &str, facts: &MediaFacts, is_url: bool) -> Result<g::Part, TransformError> {
31    if let Some(value) = facts.resources.get(key) {
32        if value.mime_type.as_deref().is_none_or(str::is_empty)
33            || !(value.file_uri.starts_with("https://")
34                || value.file_uri.starts_with("http://")
35                || value.file_uri.starts_with("gs://"))
36        {
37            return Err(TransformError::missing_metadata(
38                "media resolved URI and MIME",
39            ));
40        }
41        return Ok(g::Part::builder()
42            .file_data(g::FileData {
43                file_uri: value.file_uri.clone(),
44                mime_type: value.mime_type.clone(),
45                display_name: value.display_name.clone(),
46                rest: Default::default(),
47            })
48            .build());
49    }
50    if is_url && key.starts_with("data:") {
51        let (header, data) = key
52            .split_once(',')
53            .ok_or_else(|| TransformError::shape("data_url", "missing separator"))?;
54        let mime = header
55            .strip_prefix("data:")
56            .and_then(|v| v.strip_suffix(";base64"))
57            .ok_or_else(|| TransformError::unsupported("data_url", "base64 data URL required"))?;
58        return blob(data.into(), mime.into());
59    }
60    Err(TransformError::missing_metadata(format!(
61        "media[{key}] resolved URI and MIME"
62    )))
63}
64
65fn blob(data: String, mime: String) -> Result<g::Part, TransformError> {
66    STANDARD
67        .decode(&data)
68        .map_err(|e| TransformError::shape("media.base64", e.to_string()))?;
69    if mime.is_empty() {
70        return Err(TransformError::missing_metadata("media.mime"));
71    }
72    Ok(g::Part::builder()
73        .inline_data(g::Blob::builder(mime, data).build())
74        .build())
75}
76
77fn mime(value: c::ImageMediaType) -> &'static str {
78    match value {
79        c::ImageMediaType::Jpeg => "image/jpeg",
80        c::ImageMediaType::Png => "image/png",
81        c::ImageMediaType::Gif => "image/gif",
82        c::ImageMediaType::Webp => "image/webp",
83    }
84}
85
86fn image_mime(value: &str) -> Option<c::ImageMediaType> {
87    match value {
88        "image/jpeg" => Some(c::ImageMediaType::Jpeg),
89        "image/png" => Some(c::ImageMediaType::Png),
90        "image/gif" => Some(c::ImageMediaType::Gif),
91        "image/webp" => Some(c::ImageMediaType::Webp),
92        _ => None,
93    }
94}
95
96pub(super) fn document(
97    block: c::DocumentBlock,
98    facts: &MediaFacts,
99    report: &mut Report,
100) -> Result<Vec<g::Part>, TransformError> {
101    if block.cache_control.is_some() {
102        report.omitted(
103            "document.cache_control",
104            "Gemini has no per-part cache field",
105        );
106    }
107    Ok(match block.source {
108        c::DocumentSource::Base64(source) => vec![blob(source.data, "application/pdf".into())?],
109        c::DocumentSource::Text(source) => vec![blob(
110            STANDARD.encode(source.data.as_bytes()),
111            "text/plain".into(),
112        )?],
113        c::DocumentSource::Url(source) => vec![resource(&source.url, facts, true)?],
114        c::DocumentSource::File(source) => vec![resource(&source.file_id, facts, false)?],
115        c::DocumentSource::Content(source) => match source.content {
116            c::DocumentContent::Text(text) => vec![g::Part::builder().text(text).build()],
117            c::DocumentContent::Blocks(blocks) => {
118                let mut out = Vec::new();
119                for block in blocks {
120                    out.push(match block {
121                        c::DocumentContentBlock::Text(text) => {
122                            g::Part::builder().text(text.text).build()
123                        }
124                        c::DocumentContentBlock::Image(block) => image(block, facts, report)?,
125                    });
126                }
127                out
128            }
129        },
130    })
131}
132
133pub(super) fn inline(source: g::Blob) -> Result<c::ContentBlock, TransformError> {
134    let bytes = STANDARD
135        .decode(&source.data)
136        .map_err(|e| TransformError::shape("inline_data", e.to_string()))?;
137    if let Some(media_type) = image_mime(&source.mime_type) {
138        return Ok(c::ContentBlock::Image(
139            c::ImageBlock::builder(
140                c::ImageBlockType::Tag,
141                c::ImageSource::Base64(c::Base64Source::builder(source.data, media_type).build()),
142            )
143            .build(),
144        ));
145    }
146    let source = match source.mime_type.as_str() {
147        "application/pdf" => c::DocumentSource::Base64(
148            c::PdfBase64Source::builder(source.data, c::PdfMediaType::Pdf).build(),
149        ),
150        "text/plain" => c::DocumentSource::Text(
151            c::PlainTextSource::builder(
152                String::from_utf8(bytes)
153                    .map_err(|e| TransformError::shape("inline_data.text", e.to_string()))?,
154                c::TextMediaType::Plain,
155            )
156            .build(),
157        ),
158        _ => {
159            return Err(TransformError::unsupported(
160                "inline_data.mime_type",
161                "Claude supports only image, PDF or text document media in this contract",
162            ));
163        }
164    };
165    Ok(c::ContentBlock::Document(
166        c::DocumentBlock::builder(c::DocumentBlockType::Tag, source).build(),
167    ))
168}
169
170pub(super) fn file(source: g::FileData) -> Result<c::ContentBlock, TransformError> {
171    if !source.file_uri.starts_with("https://") && !source.file_uri.starts_with("http://") {
172        return Err(TransformError::missing_metadata(
173            "file_data public URL or transferred Claude file ID",
174        ));
175    }
176    let mime = source
177        .mime_type
178        .ok_or_else(|| TransformError::missing_metadata("file_data.mime_type"))?;
179    if image_mime(&mime).is_some() {
180        return Ok(c::ContentBlock::Image(
181            c::ImageBlock::builder(
182                c::ImageBlockType::Tag,
183                c::ImageSource::Url(c::UrlSource::builder(source.file_uri).build()),
184            )
185            .build(),
186        ));
187    }
188    if mime == "application/pdf" {
189        return Ok(c::ContentBlock::Document(
190            c::DocumentBlock::builder(
191                c::DocumentBlockType::Tag,
192                c::DocumentSource::Url(c::UrlSource::builder(source.file_uri).build()),
193            )
194            .build(),
195        ));
196    }
197    Err(TransformError::unsupported(
198        "file_data.mime_type",
199        "requested file MIME needs a resource conversion",
200    ))
201}