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