reddb_server/runtime/ai/
vision.rs1use std::sync::{Arc, OnceLock, RwLock};
24use std::time::Duration;
25
26use crate::crypto::sha256::Sha256;
27use crate::{RedDBError, RedDBResult};
28
29#[derive(Debug, Clone, PartialEq)]
33pub struct VisionDetection {
34 pub label: String,
35 pub confidence: f32,
36 pub bbox: [f32; 4],
37}
38
39#[derive(Debug, Clone, Default, PartialEq)]
41pub struct VisionResult {
42 pub detections: Vec<VisionDetection>,
45 pub embedding: Option<Vec<f32>>,
48}
49
50#[derive(Debug, Clone)]
52pub struct VisionRequest {
53 pub model: String,
55 pub image_bytes: Vec<u8>,
57 pub want_detections: bool,
59 pub want_embedding: bool,
61}
62
63pub trait LocalVisionBackend: Send + Sync {
67 fn analyze(&self, request: &VisionRequest) -> RedDBResult<VisionResult>;
68}
69
70const LOCAL_VISION_DISABLED_MESSAGE: &str =
71 "local vision requires the `local-models` feature flag at engine build time, \
72 or a backend installed via \
73 runtime::ai::vision::install_local_vision_backend. Alternatively, declare a \
74 vision-capable remote provider in the collection's VISION policy.";
75
76const FAKE_EMBEDDING_DIM: usize = 16;
78
79#[derive(Debug, Default, Clone, Copy)]
84pub struct DeterministicFakeVisionBackend;
85
86impl LocalVisionBackend for DeterministicFakeVisionBackend {
87 fn analyze(&self, request: &VisionRequest) -> RedDBResult<VisionResult> {
88 let digest = {
89 let mut hasher = Sha256::new();
90 hasher.update(request.model.as_bytes());
91 hasher.update(&[0u8]);
92 hasher.update(&request.image_bytes);
93 hasher.finalize()
94 };
95
96 let detections = if request.want_detections {
97 const VOCAB: [&str; 4] = ["person", "car", "dog", "bicycle"];
102 let pick = |byte: u8| VOCAB[(byte as usize) % VOCAB.len()].to_string();
103 let conf = |byte: u8| (byte as f32) / 255.0;
104 let coord = |byte: u8| (byte as f32) / 255.0;
105 vec![
106 VisionDetection {
107 label: pick(digest[0]),
108 confidence: conf(digest[1]),
109 bbox: [
110 coord(digest[2]),
111 coord(digest[3]),
112 coord(digest[4]),
113 coord(digest[5]),
114 ],
115 },
116 VisionDetection {
117 label: pick(digest[6]),
118 confidence: conf(digest[7]),
119 bbox: [
120 coord(digest[8]),
121 coord(digest[9]),
122 coord(digest[10]),
123 coord(digest[11]),
124 ],
125 },
126 ]
127 } else {
128 Vec::new()
129 };
130
131 let embedding = if request.want_embedding {
132 let mut out = Vec::with_capacity(FAKE_EMBEDDING_DIM);
133 let mut counter: u32 = 0;
134 while out.len() < FAKE_EMBEDDING_DIM {
135 let mut hasher = Sha256::new();
136 hasher.update(&digest);
137 hasher.update(&counter.to_le_bytes());
138 let chunk_digest = hasher.finalize();
139 for chunk in chunk_digest.chunks(4) {
140 if out.len() >= FAKE_EMBEDDING_DIM {
141 break;
142 }
143 let mut bytes = [0u8; 4];
144 bytes.copy_from_slice(chunk);
145 let raw = u32::from_le_bytes(bytes) as f32 / u32::MAX as f32;
146 out.push(raw * 2.0 - 1.0);
147 }
148 counter = counter.wrapping_add(1);
149 }
150 Some(out)
151 } else {
152 None
153 };
154
155 Ok(VisionResult {
156 detections,
157 embedding,
158 })
159 }
160}
161
162type BackendSlot = Arc<dyn LocalVisionBackend>;
163
164fn backend_slot() -> &'static RwLock<Option<BackendSlot>> {
165 static SLOT: OnceLock<RwLock<Option<BackendSlot>>> = OnceLock::new();
166 SLOT.get_or_init(|| RwLock::new(None))
167}
168
169pub fn install_local_vision_backend(backend: Arc<dyn LocalVisionBackend>) {
175 let mut guard = backend_slot()
176 .write()
177 .expect("vision backend slot poisoned");
178 *guard = Some(backend);
179}
180
181#[doc(hidden)]
184pub fn clear_local_vision_backend_for_tests() {
185 let mut guard = backend_slot()
186 .write()
187 .expect("vision backend slot poisoned");
188 *guard = None;
189}
190
191fn current_backend() -> Option<BackendSlot> {
192 backend_slot()
193 .read()
194 .expect("vision backend slot poisoned")
195 .as_ref()
196 .map(Arc::clone)
197}
198
199pub fn analyze_local(
203 model: &str,
204 image_bytes: Vec<u8>,
205 want_detections: bool,
206 want_embedding: bool,
207) -> RedDBResult<VisionResult> {
208 let backend = match current_backend() {
209 Some(b) => b,
210 None => {
211 if cfg!(feature = "local-models") {
212 let fake: Arc<dyn LocalVisionBackend> = Arc::new(DeterministicFakeVisionBackend);
213 install_local_vision_backend(Arc::clone(&fake));
214 fake
215 } else {
216 return Err(RedDBError::FeatureNotEnabled(
217 LOCAL_VISION_DISABLED_MESSAGE.to_string(),
218 ));
219 }
220 }
221 };
222
223 backend.analyze(&VisionRequest {
224 model: model.to_string(),
225 image_bytes,
226 want_detections,
227 want_embedding,
228 })
229}
230
231pub fn fetch_image_bytes(reference: &str) -> RedDBResult<Vec<u8>> {
242 let reference = reference.trim();
243 if reference.is_empty() {
244 return Err(RedDBError::Query(
245 "vision image reference is empty".to_string(),
246 ));
247 }
248
249 if reference.starts_with("http://") || reference.starts_with("https://") {
255 crate::ai::validate_custom_provider_url(reference)?;
256 return fetch_http_image(reference);
257 }
258
259 if !local_files_allowed() {
260 return Err(RedDBError::Query(format!(
261 "vision image reference '{reference}' names a local file; local image \
262 paths are disabled unless {LOCAL_FILES_ENV}=1"
263 )));
264 }
265
266 if let Some(rest) = reference.strip_prefix("file://") {
267 let path = rest.strip_prefix("localhost").unwrap_or(rest);
269 return read_local_image(path);
270 }
271
272 read_local_image(reference)
274}
275
276const LOCAL_FILES_ENV: &str = "REDDB_AI_VISION_ALLOW_LOCAL_FILES";
278
279const DEFAULT_MAX_IMAGE_BYTES: u64 = 32 * 1024 * 1024;
282
283fn local_files_allowed() -> bool {
284 std::env::var(LOCAL_FILES_ENV)
285 .map(|value| matches!(value.trim(), "1" | "true" | "yes"))
286 .unwrap_or(false)
287}
288
289fn max_image_bytes() -> u64 {
290 std::env::var("REDDB_AI_VISION_MAX_IMAGE_BYTES")
291 .ok()
292 .and_then(|raw| raw.trim().parse::<u64>().ok())
293 .filter(|value| *value > 0)
294 .unwrap_or(DEFAULT_MAX_IMAGE_BYTES)
295}
296
297fn read_local_image(path: &str) -> RedDBResult<Vec<u8>> {
298 use std::io::Read;
299 let limit = max_image_bytes();
300 let file = std::fs::File::open(path)
301 .map_err(|err| RedDBError::Internal(format!("read image '{path}': {err}")))?;
302 let mut bytes = Vec::new();
303 file.take(limit + 1)
304 .read_to_end(&mut bytes)
305 .map_err(|err| RedDBError::Internal(format!("read image '{path}': {err}")))?;
306 if bytes.len() as u64 > limit {
307 return Err(RedDBError::Query(format!(
308 "image '{path}' exceeds the {limit}-byte vision limit"
309 )));
310 }
311 Ok(bytes)
312}
313
314fn fetch_http_image(url: &str) -> RedDBResult<Vec<u8>> {
315 let agent: ureq::Agent = ureq::Agent::config_builder()
316 .timeout_connect(Some(Duration::from_secs(15)))
317 .timeout_send_request(Some(Duration::from_secs(30)))
318 .timeout_recv_response(Some(Duration::from_secs(30)))
319 .timeout_recv_body(Some(Duration::from_secs(120)))
320 .build()
321 .into();
322
323 let mut resp = agent
324 .get(url)
325 .call()
326 .map_err(|err| RedDBError::Internal(format!("HTTP GET image '{url}': {err}")))?;
327
328 let status = resp.status().as_u16();
329 if status != 200 {
330 return Err(RedDBError::Internal(format!(
331 "HTTP GET image '{url}': status {status}"
332 )));
333 }
334
335 let limit = max_image_bytes();
336 let bytes = resp
337 .body_mut()
338 .with_config()
339 .limit(limit + 1)
340 .read_to_vec()
341 .map_err(|err| RedDBError::Internal(format!("read image body from '{url}': {err}")))?;
342 if bytes.len() as u64 > limit {
343 return Err(RedDBError::Query(format!(
344 "image at '{url}' exceeds the {limit}-byte vision limit"
345 )));
346 }
347 Ok(bytes)
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353
354 #[test]
355 fn image_references_from_row_data_cannot_name_local_files_or_private_hosts() {
356 if std::env::var_os(LOCAL_FILES_ENV).is_some()
357 || std::env::var_os("REDDB_AI_ALLOW_PRIVATE_PROVIDERS").is_some()
358 {
359 return; }
361 for reference in [
362 "file:///etc/hostname",
363 "/etc/hostname",
364 "relative/image.png",
365 ] {
366 let err = fetch_image_bytes(reference).expect_err(reference);
367 assert!(
368 err.to_string().contains(LOCAL_FILES_ENV),
369 "{reference}: {err}"
370 );
371 }
372 for reference in [
373 "http://169.254.169.254/latest/meta-data/",
374 "http://10.0.0.8/internal.png",
375 "http://example.com/plaintext.png",
376 ] {
377 assert!(
378 fetch_image_bytes(reference).is_err(),
379 "{reference} must be refused"
380 );
381 }
382 }
383
384 #[test]
385 fn deterministic_fake_is_pure() {
386 let req = VisionRequest {
387 model: "fake-vision".to_string(),
388 image_bytes: b"some image bytes".to_vec(),
389 want_detections: true,
390 want_embedding: true,
391 };
392 let a = DeterministicFakeVisionBackend.analyze(&req).expect("a");
393 let b = DeterministicFakeVisionBackend.analyze(&req).expect("b");
394 assert_eq!(a, b, "fake vision backend must be pure");
395 assert_eq!(a.detections.len(), 2);
396 assert_eq!(a.embedding.as_ref().map(Vec::len), Some(FAKE_EMBEDDING_DIM));
397 }
398
399 #[test]
400 fn detections_and_embedding_are_gated_by_request() {
401 let base = VisionRequest {
402 model: "m".to_string(),
403 image_bytes: b"img".to_vec(),
404 want_detections: false,
405 want_embedding: false,
406 };
407 let none = DeterministicFakeVisionBackend.analyze(&base).expect("none");
408 assert!(none.detections.is_empty());
409 assert!(none.embedding.is_none());
410
411 let detect_only = DeterministicFakeVisionBackend
412 .analyze(&VisionRequest {
413 want_detections: true,
414 ..base.clone()
415 })
416 .expect("detect");
417 assert!(!detect_only.detections.is_empty());
418 assert!(detect_only.embedding.is_none());
419 }
420
421 #[test]
422 fn local_reads_honour_the_size_limit() {
423 let dir = std::env::temp_dir();
426 let path = dir.join("reddb_vision_fetch_fixture.bin");
427 std::fs::write(&path, b"\x89PNG fixture").expect("write fixture");
428
429 let bytes = read_local_image(path.to_str().expect("utf8 path")).expect("read");
430 assert_eq!(bytes, b"\x89PNG fixture");
431 assert!(read_local_image("/definitely/missing/image.png").is_err());
432
433 let _ = std::fs::remove_file(&path);
434 }
435
436 #[test]
437 fn fetch_rejects_empty_reference() {
438 assert!(fetch_image_bytes(" ").is_err());
439 }
440}