Skip to main content

open_agent/types/
image.rs

1/// Image detail level for vision API calls.
2///
3/// Controls the resolution and token cost of image processing.
4///
5/// # Token Costs Vary by Model ⚠️
6///
7/// **OpenAI Vision API** (reference values):
8/// - `Low`: ~85 tokens (512x512 max resolution)
9/// - `High`: Variable tokens based on image dimensions
10/// - `Auto`: Model decides (balanced default)
11///
12/// **Local models** (llama.cpp, Ollama, vLLM):
13/// - May have **completely different** token calculations
14/// - Some models don't charge tokens for images at all
15/// - The `ImageDetail` setting may be ignored entirely
16///
17/// **Anthropic messages protocol**: the API has no equivalent field, so the setting is
18/// dropped in translation rather than mapped onto something approximate.
19///
20/// **Recommendation:** Always benchmark your specific model to understand
21/// actual token consumption. Do not rely on OpenAI's values for capacity planning
22/// with local models.
23///
24/// # Examples
25///
26/// ```
27/// use open_agent::ImageDetail;
28///
29/// let detail = ImageDetail::High;
30/// assert_eq!(detail.to_string(), "high");
31/// ```
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "lowercase")]
34#[derive(Default)]
35pub enum ImageDetail {
36    /// Low resolution (512x512), fixed 85 tokens
37    Low,
38    /// High resolution, variable tokens based on dimensions
39    High,
40    /// Automatic selection (default)
41    #[default]
42    Auto,
43}
44
45impl std::fmt::Display for ImageDetail {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        match self {
48            ImageDetail::Low => write!(f, "low"),
49            ImageDetail::High => write!(f, "high"),
50            ImageDetail::Auto => write!(f, "auto"),
51        }
52    }
53}
54
55/// Image content block for vision-capable models.
56///
57/// Supports both URL-based images and base64-encoded images.
58///
59/// # Examples
60///
61/// ```
62/// use open_agent::{ImageBlock, ImageDetail};
63///
64/// // From URL
65/// let image = ImageBlock::from_url("https://example.com/image.jpg")?;
66///
67/// // From base64 (use properly formatted base64)
68/// let image = ImageBlock::from_base64("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", "image/png")?;
69///
70/// // With detail level
71/// let image = ImageBlock::from_url("https://example.com/image.jpg")?
72///     .with_detail(ImageDetail::High);
73/// # Ok::<(), open_agent::Error>(())
74/// ```
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct ImageBlock {
77    url: String,
78    #[serde(default)]
79    detail: ImageDetail,
80}
81
82impl ImageBlock {
83    /// Creates a new image block from a URL.
84    ///
85    /// # Arguments
86    ///
87    /// * `url` - The image URL (must be HTTP, HTTPS, or data URI)
88    ///
89    /// # Errors
90    ///
91    /// Returns `Error::InvalidInput` if:
92    /// - URL is empty
93    /// - URL contains control characters (newline, tab, null, etc.)
94    /// - URL scheme is not `http://`, `https://`, or `data:`
95    /// - Data URI is malformed (missing MIME type or base64 encoding)
96    /// - Data URI base64 portion has invalid characters, length, or padding
97    ///
98    /// # Warnings
99    ///
100    /// - Logs a warning to stderr if URL exceeds 2000 characters
101    ///
102    /// # Example
103    ///
104    /// ```
105    /// use open_agent::ImageBlock;
106    ///
107    /// let image = ImageBlock::from_url("https://example.com/cat.jpg")?;
108    /// assert_eq!(image.url(), "https://example.com/cat.jpg");
109    /// # Ok::<(), open_agent::Error>(())
110    /// ```
111    pub fn from_url(url: impl Into<String>) -> crate::Result<Self> {
112        let url = url.into();
113
114        // Validate URL is not empty
115        if url.is_empty() {
116            return Err(crate::Error::invalid_input("Image URL cannot be empty"));
117        }
118
119        // Check for control characters in URL
120        if url.contains(char::is_control) {
121            return Err(crate::Error::invalid_input(
122                "Image URL contains invalid control characters",
123            ));
124        }
125
126        // Warn about very long URLs (>2000 chars)
127        if url.len() > 2000 {
128            eprintln!(
129                "WARNING: Very long image URL ({} chars). \
130                 Some APIs may have URL length limits.",
131                url.len()
132            );
133        }
134
135        // Validate URL scheme
136        if url.starts_with("http://") || url.starts_with("https://") {
137            // Valid HTTP/HTTPS URL
138            Ok(Self {
139                url,
140                detail: ImageDetail::default(),
141            })
142        } else if let Some(mime_part) = url.strip_prefix("data:") {
143            // Validate data URI format: data:MIME;base64,DATA
144            if !url.contains(";base64,") {
145                return Err(crate::Error::invalid_input(
146                    "Data URI must be in format: data:image/TYPE;base64,DATA",
147                ));
148            }
149
150            // Extract MIME type from data:MIME;base64,DATA
151            let mime_type = if let Some(semicolon_pos) = mime_part.find(';') {
152                &mime_part[..semicolon_pos]
153            } else {
154                return Err(crate::Error::invalid_input(
155                    "Malformed data URI: missing MIME type",
156                ));
157            };
158
159            if mime_type.is_empty() || !mime_type.starts_with("image/") {
160                return Err(crate::Error::invalid_input(
161                    "Data URI MIME type must start with 'image/'",
162                ));
163            }
164
165            // Extract and validate base64 data portion
166            if let Some(base64_start_pos) = url.find(";base64,") {
167                let base64_data = &url[base64_start_pos + 8..]; // Skip ";base64,"
168
169                // Validate base64 data using same rules as from_base64()
170                // Check data is not empty
171                if base64_data.is_empty() {
172                    return Err(crate::Error::invalid_input(
173                        "Data URI base64 data cannot be empty",
174                    ));
175                }
176
177                // Check character set
178                if !base64_data
179                    .chars()
180                    .all(|c| c.is_alphanumeric() || c == '+' || c == '/' || c == '=')
181                {
182                    return Err(crate::Error::invalid_input(
183                        "Data URI base64 data contains invalid characters. Valid characters: A-Z, a-z, 0-9, +, /, =",
184                    ));
185                }
186
187                // Check length (must be multiple of 4)
188                if base64_data.len() % 4 != 0 {
189                    return Err(crate::Error::invalid_input(
190                        "Data URI base64 data has invalid length (must be multiple of 4)",
191                    ));
192                }
193
194                // Validate padding
195                let equals_count = base64_data.chars().filter(|c| *c == '=').count();
196                if equals_count > 2 {
197                    return Err(crate::Error::invalid_input(
198                        "Data URI base64 data has invalid padding (max 2 '=' characters allowed)",
199                    ));
200                }
201                // Padding must be at the end
202                if equals_count > 0 {
203                    let trimmed = base64_data.trim_end_matches('=');
204                    if trimmed.len() + equals_count != base64_data.len() {
205                        return Err(crate::Error::invalid_input(
206                            "Data URI base64 padding characters must be at the end",
207                        ));
208                    }
209                }
210            }
211
212            Ok(Self {
213                url,
214                detail: ImageDetail::default(),
215            })
216        } else {
217            Err(crate::Error::invalid_input(
218                "Image URL must start with http://, https://, or data:",
219            ))
220        }
221    }
222
223    /// Creates a new image block from base64-encoded data.
224    ///
225    /// # Arguments
226    ///
227    /// * `base64_data` - The base64-encoded image data
228    /// * `mime_type` - The MIME type (e.g., "image/jpeg", "image/png")
229    ///
230    /// # Errors
231    ///
232    /// Returns `Error::InvalidInput` if:
233    /// - Base64 data is empty
234    /// - Base64 contains invalid characters (only A-Z, a-z, 0-9, +, /, = allowed)
235    /// - Base64 length is not a multiple of 4
236    /// - Base64 has invalid padding (more than 2 '=' characters or not at end)
237    /// - MIME type is empty
238    /// - MIME type does not start with "image/"
239    /// - MIME type contains injection characters (;, \\n, \\r, ,)
240    ///
241    /// # Warnings
242    ///
243    /// - Logs a warning to stderr if base64 data exceeds 10MB (~7.5MB decoded)
244    ///
245    /// # Example
246    ///
247    /// ```
248    /// use open_agent::ImageBlock;
249    ///
250    /// let base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
251    /// let image = ImageBlock::from_base64(base64, "image/png")?;
252    /// assert!(image.url().starts_with("data:image/png;base64,"));
253    /// # Ok::<(), open_agent::Error>(())
254    /// ```
255    pub fn from_base64(
256        base64_data: impl AsRef<str>,
257        mime_type: impl AsRef<str>,
258    ) -> crate::Result<Self> {
259        let data = base64_data.as_ref();
260        let mime = mime_type.as_ref();
261
262        // Validate base64 data is not empty
263        if data.is_empty() {
264            return Err(crate::Error::invalid_input(
265                "Base64 image data cannot be empty",
266            ));
267        }
268
269        // Validate base64 character set (alphanumeric + +/=)
270        // This catches common errors like spaces, special characters, etc.
271        if !data
272            .chars()
273            .all(|c| c.is_alphanumeric() || c == '+' || c == '/' || c == '=')
274        {
275            return Err(crate::Error::invalid_input(
276                "Base64 data contains invalid characters. Valid characters: A-Z, a-z, 0-9, +, /, =",
277            ));
278        }
279
280        // Validate base64 padding (length must be multiple of 4)
281        if data.len() % 4 != 0 {
282            return Err(crate::Error::invalid_input(
283                "Base64 data has invalid length (must be multiple of 4)",
284            ));
285        }
286
287        // Validate padding characters only appear at the end (max 2)
288        let equals_count = data.chars().filter(|c| *c == '=').count();
289        if equals_count > 2 {
290            return Err(crate::Error::invalid_input(
291                "Base64 data has invalid padding (max 2 '=' characters allowed)",
292            ));
293        }
294        if equals_count > 0 {
295            // Padding must be at the end
296            let trimmed = data.trim_end_matches('=');
297            if trimmed.len() + equals_count != data.len() {
298                return Err(crate::Error::invalid_input(
299                    "Base64 padding characters must be at the end",
300                ));
301            }
302        }
303
304        // Validate MIME type is not empty
305        if mime.is_empty() {
306            return Err(crate::Error::invalid_input("MIME type cannot be empty"));
307        }
308
309        // Validate MIME type starts with "image/"
310        if !mime.starts_with("image/") {
311            return Err(crate::Error::invalid_input(
312                "MIME type must start with 'image/' (e.g., 'image/png', 'image/jpeg')",
313            ));
314        }
315
316        // Check for MIME type injection characters
317        if mime.contains([';', ',', '\n', '\r']) {
318            return Err(crate::Error::invalid_input(
319                "MIME type contains invalid characters (;, \\n, \\r not allowed)",
320            ));
321        }
322
323        // Warn about extremely large base64 data (>10MB)
324        if data.len() > 10_000_000 {
325            eprintln!(
326                "WARNING: Very large base64 image data ({} chars, ~{:.1}MB). \
327                 This may exceed API limits or cause performance issues.",
328                data.len(),
329                (data.len() as f64 * 0.75) / 1_000_000.0
330            );
331        }
332
333        let url = format!("data:{};base64,{}", mime, data);
334        Ok(Self {
335            url,
336            detail: ImageDetail::default(),
337        })
338    }
339
340    /// Creates a new image block from a local file path.
341    ///
342    /// This is a convenience method that reads the file from disk, encodes it as
343    /// base64, and creates an ImageBlock with a data URI. The MIME type is inferred
344    /// from the file extension.
345    ///
346    /// # Arguments
347    ///
348    /// * `path` - Path to the image file on the local filesystem
349    ///
350    /// # Errors
351    ///
352    /// Returns `Error::InvalidInput` if:
353    /// - File cannot be read
354    /// - File extension is missing or unsupported
355    /// - File is too large (>10MB warning)
356    ///
357    /// # Supported Formats
358    ///
359    /// - `.jpg`, `.jpeg` → `image/jpeg`
360    /// - `.png` → `image/png`
361    /// - `.gif` → `image/gif`
362    /// - `.webp` → `image/webp`
363    /// - `.bmp` → `image/bmp`
364    /// - `.svg` → `image/svg+xml`
365    ///
366    /// # Example
367    ///
368    /// ```no_run
369    /// use open_agent::ImageBlock;
370    ///
371    /// let image = ImageBlock::from_file_path("/path/to/photo.jpg")?;
372    /// # Ok::<(), open_agent::Error>(())
373    /// ```
374    ///
375    /// # Security Note
376    ///
377    /// This method reads files from the local filesystem. Ensure the path comes from
378    /// a trusted source to prevent unauthorized file access.
379    pub fn from_file_path(path: impl AsRef<std::path::Path>) -> crate::Result<Self> {
380        use base64::{Engine as _, engine::general_purpose};
381
382        let path = path.as_ref();
383
384        // Read file bytes
385        let bytes = std::fs::read(path).map_err(|e| {
386            crate::Error::invalid_input(format!(
387                "Failed to read image file '{}': {}",
388                path.display(),
389                e
390            ))
391        })?;
392
393        // Determine MIME type from file extension
394        let mime_type = match path.extension().and_then(|e| e.to_str()) {
395            Some("jpg") | Some("jpeg") => "image/jpeg",
396            Some("png") => "image/png",
397            Some("gif") => "image/gif",
398            Some("webp") => "image/webp",
399            Some("bmp") => "image/bmp",
400            Some("svg") => "image/svg+xml",
401            Some(ext) => {
402                return Err(crate::Error::invalid_input(format!(
403                    "Unsupported image file extension: .{}. Supported: jpg, jpeg, png, gif, webp, bmp, svg",
404                    ext
405                )));
406            }
407            None => {
408                return Err(crate::Error::invalid_input(
409                    "Image file path must have a file extension (e.g., .jpg, .png)",
410                ));
411            }
412        };
413
414        // Encode to base64
415        let base64_data = general_purpose::STANDARD.encode(&bytes);
416
417        // Use existing from_base64 method for validation
418        Self::from_base64(&base64_data, mime_type)
419    }
420
421    /// Sets the image detail level.
422    ///
423    /// # Example
424    ///
425    /// ```
426    /// use open_agent::{ImageBlock, ImageDetail};
427    ///
428    /// let image = ImageBlock::from_url("https://example.com/image.jpg")?
429    ///     .with_detail(ImageDetail::High);
430    /// # Ok::<(), open_agent::Error>(())
431    /// ```
432    pub fn with_detail(mut self, detail: ImageDetail) -> Self {
433        self.detail = detail;
434        self
435    }
436
437    /// Returns the image URL (or data URI for base64 images).
438    pub fn url(&self) -> &str {
439        &self.url
440    }
441
442    /// Returns the image detail level.
443    pub fn detail(&self) -> ImageDetail {
444        self.detail
445    }
446}