everruns_core/image_services.rs
1//! Neutral contracts for image artifacts and model image resolution.
2
3use crate::error::Result;
4use crate::typed_id::ImageId;
5use async_trait::async_trait;
6use chrono::{DateTime, Utc};
7use uuid::Uuid;
8
9/// Metadata for a stored image artifact.
10#[derive(Debug, Clone)]
11pub struct StoredImageInfo {
12 pub id: ImageId,
13 pub filename: String,
14 pub content_type: String,
15 pub size_bytes: i64,
16 pub metadata: serde_json::Value,
17 pub created_at: DateTime<Utc>,
18}
19
20/// Stored image artifact with binary data.
21#[derive(Debug, Clone)]
22pub struct StoredImage {
23 pub info: StoredImageInfo,
24 pub data: Vec<u8>,
25}
26
27/// Input for creating a stored image artifact.
28#[derive(Debug, Clone)]
29pub struct CreateStoredImage {
30 pub filename: String,
31 pub content_type: String,
32 pub data: Vec<u8>,
33 pub metadata: serde_json::Value,
34}
35
36#[async_trait]
37pub trait ImageArtifactStore: Send + Sync {
38 /// Persist an image artifact and return its durable metadata.
39 async fn create_image(&self, input: CreateStoredImage) -> Result<StoredImageInfo>;
40
41 /// Load a stored image artifact including bytes.
42 async fn get_image(&self, image_id: ImageId) -> Result<Option<StoredImage>>;
43
44 /// Load stored image metadata without binary data.
45 async fn get_image_info(&self, image_id: ImageId) -> Result<Option<StoredImageInfo>>;
46}
47
48/// Resolved image data for LLM consumption
49///
50/// This struct contains the actual image data in a format suitable for
51/// sending to LLM providers. Both OpenAI and Anthropic accept base64-encoded
52/// images with media type information.
53#[derive(Debug, Clone)]
54pub struct ResolvedImage {
55 /// Base64-encoded image data (without data URL prefix)
56 pub base64: String,
57 /// MIME type (e.g., "image/png", "image/jpeg")
58 pub media_type: String,
59}
60
61impl ResolvedImage {
62 /// Create a new resolved image
63 pub fn new(base64: impl Into<String>, media_type: impl Into<String>) -> Self {
64 Self {
65 base64: base64.into(),
66 media_type: media_type.into(),
67 }
68 }
69
70 /// Convert to a data URL suitable for OpenAI Vision API
71 ///
72 /// Format: `data:{media_type};base64,{base64_data}`
73 pub fn to_data_url(&self) -> String {
74 format!("data:{};base64,{}", self.media_type, self.base64)
75 }
76}
77
78/// Trait for resolving image_file content parts to actual image data
79///
80/// When building LLM messages, `image_file` content parts contain only
81/// a reference (UUID) to an uploaded image. This trait allows resolving
82/// those references to actual image data.
83///
84/// # Provider-specific formatting
85///
86/// The resolved image data is then converted to provider-specific formats:
87///
88/// **OpenAI Vision:**
89/// ```json
90/// {
91/// "type": "image_url",
92/// "image_url": { "url": "data:image/png;base64,..." }
93/// }
94/// ```
95///
96/// **Anthropic Vision:**
97/// ```json
98/// {
99/// "type": "image",
100/// "source": { "type": "base64", "media_type": "image/png", "data": "..." }
101/// }
102/// ```
103///
104/// # Implementation notes
105///
106/// Implementations should:
107/// - Fetch image data from storage (database, S3, etc.)
108/// - Return base64-encoded data with media type
109/// - Handle missing images gracefully (return None)
110#[async_trait]
111pub trait ImageResolver: Send + Sync {
112 /// Resolve an image_file reference to actual image data
113 ///
114 /// Returns `None` if the image is not found.
115 async fn resolve_image(&self, image_id: Uuid) -> Result<Option<ResolvedImage>>;
116}
117
118// ============================================================================
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123
124 #[test]
125 fn resolved_images_preserve_mime_and_base64_in_complete_data_urls() {
126 for (data, mime, expected) in [
127 ("SGVsbG8=", "image/png", "data:image/png;base64,SGVsbG8="),
128 ("+/8=", "image/jpeg", "data:image/jpeg;base64,+/8="),
129 (
130 "PHN2Zy8+",
131 "image/svg+xml",
132 "data:image/svg+xml;base64,PHN2Zy8+",
133 ),
134 ] {
135 assert_eq!(ResolvedImage::new(data, mime).to_data_url(), expected);
136 }
137 }
138}