1use std::future::Future;
11use std::sync::Arc;
12use std::time::Instant;
13
14use crate::ai::AiProvider;
15use crate::json::{Map, Value as JsonValue};
16use crate::runtime::ai::dedup_cache::{
17 EmbeddingDedupCache, DEFAULT_DEDUP_LRU_SIZE, DEFAULT_DEDUP_TTL_MS,
18};
19use crate::runtime::ai::text_chunker::{ChunkMode, DEFAULT_MAX_TOKENS};
20use crate::runtime::ai::transport::{AiHttpRequest, AiTransport, AiTransportError};
21use crate::runtime::audit_log::AuditLogger;
22
23pub const CONFIG_MAX_BATCH_SIZE: &str = "runtime.ai.embedding_max_batch_size";
24pub const DEFAULT_OPENAI_MAX_BATCH: usize = 2048;
25pub const DEFAULT_OTHER_MAX_BATCH: usize = 256;
26
27pub struct SubBatchRequest {
29 pub provider: String,
30 pub api_key: String,
31 pub api_base: String,
32 pub model: String,
33 pub inputs: Vec<String>,
34}
35
36pub struct SubBatchResponse {
37 pub embeddings: Vec<Vec<f32>>,
38 pub model: String,
39 pub prompt_tokens: Option<u64>,
40 pub total_tokens: Option<u64>,
41 pub attempt_count: u32,
42 pub total_wait_ms: u64,
43}
44
45pub trait SubBatchSender: Send + Sync {
47 fn send(
48 &self,
49 request: SubBatchRequest,
50 ) -> impl Future<Output = Result<SubBatchResponse, AiTransportError>> + Send + '_;
51}
52
53pub struct AiTransportSender {
55 pub transport: AiTransport,
56}
57
58impl SubBatchSender for AiTransportSender {
59 #[allow(clippy::manual_async_fn)]
60 fn send(
61 &self,
62 request: SubBatchRequest,
63 ) -> impl Future<Output = Result<SubBatchResponse, AiTransportError>> + Send + '_ {
64 async move {
65 let payload = crate::ai::build_embedding_payload(&request.model, &request.inputs);
66 let url = format!("{}/embeddings", request.api_base.trim_end_matches('/'));
67 let http_req = AiHttpRequest::post_json(request.provider.as_str(), url, payload)
68 .model(request.model.clone())
69 .header("authorization", format!("Bearer {}", request.api_key));
70
71 let response = self.transport.request(http_req).await?;
72
73 let parsed = crate::ai::parse_embedding_response(&response.body).map_err(|msg| {
74 AiTransportError {
75 provider: request.provider.clone(),
76 status_code: None,
77 attempt_count: 1,
78 total_wait_ms: 0,
79 message: msg,
80 }
81 })?;
82
83 Ok(SubBatchResponse {
84 embeddings: parsed.embeddings,
85 model: parsed.model,
86 prompt_tokens: parsed.prompt_tokens,
87 total_tokens: parsed.total_tokens,
88 attempt_count: response.attempt_count,
89 total_wait_ms: response.total_wait_ms,
90 })
91 }
92 }
93}
94
95pub struct AiBatchClient<S = AiTransportSender> {
100 sender: S,
101 max_batch_size_override: Option<usize>,
102 dedup_cache: Option<Arc<EmbeddingDedupCache>>,
104 chunk_mode: ChunkMode,
106 max_tokens: usize,
108 audit_log: Option<Arc<AuditLogger>>,
109}
110
111impl AiBatchClient<AiTransportSender> {
112 pub fn new(transport: AiTransport) -> Self {
113 Self {
114 sender: AiTransportSender { transport },
115 max_batch_size_override: None,
116 dedup_cache: None,
117 chunk_mode: ChunkMode::Single,
118 max_tokens: DEFAULT_MAX_TOKENS,
119 audit_log: None,
120 }
121 }
122
123 pub fn from_runtime(runtime: &crate::runtime::RedDBRuntime) -> Self {
124 use crate::runtime::ai::dedup_cache::{
125 CONFIG_DEDUP_ENABLED, CONFIG_DEDUP_LRU_SIZE, CONFIG_DEDUP_TTL_MS,
126 };
127 use crate::runtime::ai::text_chunker::{CONFIG_CHUNK_MODE, CONFIG_MAX_TOKENS};
128 use std::time::Duration;
129
130 let transport = AiTransport::from_runtime(runtime);
131 let dedup_enabled = runtime.config_bool(CONFIG_DEDUP_ENABLED, false);
132 let dedup_cache = if dedup_enabled {
133 let lru_size =
134 runtime.config_u64(CONFIG_DEDUP_LRU_SIZE, DEFAULT_DEDUP_LRU_SIZE as u64) as usize;
135 let ttl_ms = runtime.config_u64(CONFIG_DEDUP_TTL_MS, DEFAULT_DEDUP_TTL_MS);
136 Some(Arc::new(EmbeddingDedupCache::new(
137 lru_size,
138 Duration::from_millis(ttl_ms),
139 )))
140 } else {
141 None
142 };
143 let chunk_mode = ChunkMode::from_str(&runtime.config_string(CONFIG_CHUNK_MODE, "single"));
144 let max_tokens = runtime.config_u64(CONFIG_MAX_TOKENS, DEFAULT_MAX_TOKENS as u64) as usize;
145
146 Self {
147 sender: AiTransportSender { transport },
148 max_batch_size_override: None,
149 dedup_cache,
150 chunk_mode,
151 max_tokens,
152 audit_log: Some(runtime.audit_log_arc()),
153 }
154 }
155}
156
157impl<S: SubBatchSender> AiBatchClient<S> {
158 pub fn with_sender(sender: S) -> Self {
160 Self {
161 sender,
162 max_batch_size_override: None,
163 dedup_cache: None,
164 chunk_mode: ChunkMode::Single,
165 max_tokens: DEFAULT_MAX_TOKENS,
166 audit_log: None,
167 }
168 }
169
170 pub fn with_max_batch_size(mut self, size: usize) -> Self {
172 self.max_batch_size_override = Some(size.max(1));
173 self
174 }
175
176 pub fn with_dedup_cache(mut self, cache: Arc<EmbeddingDedupCache>) -> Self {
178 self.dedup_cache = Some(cache);
179 self
180 }
181
182 pub fn with_chunk_mode(mut self, mode: ChunkMode) -> Self {
184 self.chunk_mode = mode;
185 self
186 }
187
188 pub fn with_max_tokens(mut self, max: usize) -> Self {
190 self.max_tokens = max.max(1);
191 self
192 }
193
194 pub fn with_audit_log(mut self, audit_log: Arc<AuditLogger>) -> Self {
195 self.audit_log = Some(audit_log);
196 self
197 }
198
199 pub async fn embed_batch(
211 &self,
212 provider: &AiProvider,
213 model: &str,
214 api_key: &str,
215 texts: Vec<String>,
216 ) -> Result<Vec<Vec<f32>>, AiTransportError> {
217 self.embed_batch_at(
218 provider,
219 model,
220 api_key,
221 &provider.resolve_api_base(),
222 texts,
223 )
224 .await
225 }
226
227 pub async fn embed_batch_at(
229 &self,
230 provider: &AiProvider,
231 model: &str,
232 api_key: &str,
233 api_base: &str,
234 texts: Vec<String>,
235 ) -> Result<Vec<Vec<f32>>, AiTransportError> {
236 if texts.is_empty() {
237 return Ok(vec![]);
238 }
239
240 let max_batch = self
241 .max_batch_size_override
242 .unwrap_or_else(|| default_max_batch_size(provider));
243 let cache_scope = [provider.token(), api_base, model, api_key];
244 let started = Instant::now();
245 let mut local_dedup_hits = 0u64;
246 let mut any_chunked = false;
247 let mut retries_total = 0u64;
248 let mut total_wait_ms = 0u64;
249 let mut prompt_tokens_total = 0u64;
250 let mut total_tokens_total = 0u64;
251
252 let mut chunked_texts: Vec<String> = Vec::with_capacity(texts.len());
255 for t in &texts {
256 let chunks = crate::runtime::ai::text_chunker::chunk(t, self.max_tokens);
257 if chunks.len() > 1 {
258 any_chunked = true;
259 }
260 let chosen = crate::runtime::ai::text_chunker::apply_mode(chunks, self.chunk_mode);
261 chunked_texts.push(chosen.into_iter().next().unwrap_or_default());
262 }
263
264 let mut result: Vec<Option<Vec<f32>>> = vec![None; texts.len()];
267
268 let mut unique_text_index: std::collections::HashMap<String, usize> =
271 std::collections::HashMap::new();
272 let mut unique_texts_to_embed: Vec<String> = Vec::new();
273
274 let mut pos_to_unique: Vec<Option<usize>> = vec![None; texts.len()];
276
277 for (i, text) in chunked_texts.iter().enumerate() {
278 if text.trim().is_empty() {
279 result[i] = Some(vec![]);
280 continue;
281 }
282 if let Some(cache) = &self.dedup_cache {
286 if let Some(cached) = cache.get_scoped(&cache_scope, text) {
287 local_dedup_hits = local_dedup_hits.saturating_add(1);
288 result[i] = Some(cached);
289 continue;
290 }
291 }
292 let unique_idx = if let Some(&existing) = unique_text_index.get(text.as_str()) {
294 existing
295 } else {
296 let idx = unique_texts_to_embed.len();
297 unique_text_index.insert(text.clone(), idx);
298 unique_texts_to_embed.push(text.clone());
299 idx
300 };
301 pos_to_unique[i] = Some(unique_idx);
302 }
303
304 let mut unique_embeddings: Vec<Vec<f32>> = vec![vec![]; unique_texts_to_embed.len()];
306
307 for chunk in unique_texts_to_embed.chunks(max_batch) {
308 crate::runtime::ai::metrics::record_batch_size(provider.token(), chunk.len());
309 let chunk_start = {
312 let base = unique_texts_to_embed.as_ptr();
314 let ptr = chunk.as_ptr();
315 (ptr as usize - base as usize) / std::mem::size_of::<String>()
316 };
317
318 let request = SubBatchRequest {
319 provider: provider.token().to_string(),
320 api_key: api_key.to_string(),
321 api_base: api_base.to_string(),
322 model: model.to_string(),
323 inputs: chunk.to_vec(),
324 };
325
326 let response = match self.sender.send(request).await {
327 Ok(response) => response,
328 Err(err) => {
329 self.record_error_audit(provider.token(), &err);
330 return Err(err);
331 }
332 };
333 retries_total =
334 retries_total.saturating_add(u64::from(response.attempt_count.saturating_sub(1)));
335 total_wait_ms = total_wait_ms.saturating_add(response.total_wait_ms);
336 if let Some(tokens) = response.prompt_tokens {
337 prompt_tokens_total = prompt_tokens_total.saturating_add(tokens);
338 }
339 if let Some(tokens) = response.total_tokens {
340 total_tokens_total = total_tokens_total.saturating_add(tokens);
341 }
342 let token_metric = response
343 .prompt_tokens
344 .unwrap_or(0)
345 .saturating_add(response.total_tokens.unwrap_or(0));
346 crate::runtime::ai::metrics::record_tokens(
347 provider.token(),
348 &response.model,
349 token_metric,
350 );
351 let embeddings = response.embeddings;
352
353 if embeddings.len() != chunk.len() {
354 let err = AiTransportError {
355 provider: provider.token().to_string(),
356 status_code: None,
357 attempt_count: 0,
358 total_wait_ms: 0,
359 message: format!(
360 "provider returned {} embeddings for {} inputs",
361 embeddings.len(),
362 chunk.len()
363 ),
364 };
365 self.record_error_audit(provider.token(), &err);
366 return Err(err);
367 }
368
369 for (j, embedding) in embeddings.into_iter().enumerate() {
370 let unique_idx = chunk_start + j;
371 if let Some(cache) = &self.dedup_cache {
373 cache.insert_scoped(
374 &cache_scope,
375 &unique_texts_to_embed[unique_idx],
376 embedding.clone(),
377 );
378 }
379 unique_embeddings[unique_idx] = embedding;
380 }
381 }
382
383 for (i, unique_idx_opt) in pos_to_unique.into_iter().enumerate() {
385 if let Some(unique_idx) = unique_idx_opt {
386 result[i] = Some(unique_embeddings[unique_idx].clone());
387 }
388 }
389
390 self.record_batch_audit(BatchAudit {
391 provider: provider.token(),
392 model,
393 batch_size: texts.len(),
394 total_tokens: total_tokens_total,
395 duration_ms: millis_u64(started.elapsed()),
396 retries: retries_total,
397 dedup_hits: local_dedup_hits,
398 chunked: any_chunked,
399 total_wait_ms,
400 prompt_tokens: prompt_tokens_total,
401 });
402
403 Ok(result.into_iter().map(|v| v.unwrap_or_default()).collect())
404 }
405
406 fn record_batch_audit(&self, audit: BatchAudit<'_>) {
407 tracing::info!(
408 target: "reddb::developer",
409 provider = audit.provider,
410 model = audit.model,
411 batch_size = audit.batch_size,
412 total_tokens = audit.total_tokens,
413 duration_ms = audit.duration_ms,
414 retries = audit.retries,
415 dedup_hits = audit.dedup_hits,
416 chunked = audit.chunked,
417 "ai embedding batch completed"
418 );
419
420 let Some(audit_log) = &self.audit_log else {
421 return;
422 };
423 let mut details = Map::new();
424 details.insert(
425 "provider".to_string(),
426 JsonValue::String(audit.provider.to_string()),
427 );
428 details.insert(
429 "model".to_string(),
430 JsonValue::String(audit.model.to_string()),
431 );
432 details.insert(
433 "batch_size".to_string(),
434 JsonValue::Number(audit.batch_size as f64),
435 );
436 details.insert(
437 "total_tokens".to_string(),
438 JsonValue::Number(audit.total_tokens as f64),
439 );
440 details.insert(
441 "duration_ms".to_string(),
442 JsonValue::Number(audit.duration_ms as f64),
443 );
444 details.insert(
445 "retries".to_string(),
446 JsonValue::Number(audit.retries as f64),
447 );
448 details.insert(
449 "dedup_hits".to_string(),
450 JsonValue::Number(audit.dedup_hits as f64),
451 );
452 details.insert("chunked".to_string(), JsonValue::Bool(audit.chunked));
453 details.insert(
454 "total_wait_ms".to_string(),
455 JsonValue::Number(audit.total_wait_ms as f64),
456 );
457 details.insert(
458 "prompt_tokens".to_string(),
459 JsonValue::Number(audit.prompt_tokens as f64),
460 );
461 audit_log.record(
462 "ai/embedding_batch",
463 "system",
464 audit.provider,
465 "ok",
466 JsonValue::Object(details),
467 );
468 }
469
470 fn record_error_audit(&self, provider: &str, err: &AiTransportError) {
471 tracing::warn!(
472 target: "reddb::developer",
473 provider = provider,
474 status_code = err.status_code.unwrap_or(0),
475 attempt_count = err.attempt_count,
476 total_wait_ms = err.total_wait_ms,
477 "ai embedding provider error"
478 );
479
480 let Some(audit_log) = &self.audit_log else {
481 return;
482 };
483 let mut details = Map::new();
484 details.insert(
485 "provider".to_string(),
486 JsonValue::String(provider.to_string()),
487 );
488 details.insert(
489 "status_code".to_string(),
490 err.status_code
491 .map(|status| JsonValue::Number(status as f64))
492 .unwrap_or(JsonValue::Null),
493 );
494 details.insert(
495 "attempt_count".to_string(),
496 JsonValue::Number(err.attempt_count as f64),
497 );
498 details.insert(
499 "total_wait_ms".to_string(),
500 JsonValue::Number(err.total_wait_ms as f64),
501 );
502 audit_log.record(
503 "ai/embedding_error",
504 "system",
505 provider,
506 "error",
507 JsonValue::Object(details),
508 );
509 }
510}
511
512struct BatchAudit<'a> {
513 provider: &'a str,
514 model: &'a str,
515 batch_size: usize,
516 total_tokens: u64,
517 duration_ms: u64,
518 retries: u64,
519 dedup_hits: u64,
520 chunked: bool,
521 total_wait_ms: u64,
522 prompt_tokens: u64,
523}
524
525fn millis_u64(duration: std::time::Duration) -> u64 {
526 duration.as_millis().min(u128::from(u64::MAX)) as u64
527}
528
529fn default_max_batch_size(provider: &AiProvider) -> usize {
530 match provider {
531 AiProvider::OpenAi
532 | AiProvider::OpenRouter
533 | AiProvider::OpenAiCompat
534 | AiProvider::RedRouter
535 | AiProvider::Together
536 | AiProvider::Venice
537 | AiProvider::Groq
538 | AiProvider::DeepSeek
539 | AiProvider::Custom(_) => DEFAULT_OPENAI_MAX_BATCH,
540 _ => DEFAULT_OTHER_MAX_BATCH,
541 }
542}
543
544#[cfg(test)]
545mod tests {
546 use super::*;
547 use std::sync::atomic::{AtomicUsize, Ordering};
548 use std::sync::Arc;
549 use std::time::Duration;
550
551 struct MockSender {
552 call_count: Arc<AtomicUsize>,
553 dims: usize,
554 }
555
556 impl SubBatchSender for MockSender {
557 fn send(
558 &self,
559 request: SubBatchRequest,
560 ) -> impl Future<Output = Result<SubBatchResponse, AiTransportError>> + Send + '_ {
561 let n = request.inputs.len();
562 let dims = self.dims;
563 self.call_count.fetch_add(1, Ordering::SeqCst);
564 async move {
565 Ok(SubBatchResponse {
566 embeddings: (0..n).map(|_| vec![0.1f32; dims]).collect(),
567 model: request.model,
568 prompt_tokens: Some(n as u64),
569 total_tokens: Some(n as u64),
570 attempt_count: 1,
571 total_wait_ms: 0,
572 })
573 }
574 }
575 }
576
577 fn mock_client(dims: usize) -> (AiBatchClient<MockSender>, Arc<AtomicUsize>) {
578 let counter = Arc::new(AtomicUsize::new(0));
579 let client = AiBatchClient::with_sender(MockSender {
580 call_count: Arc::clone(&counter),
581 dims,
582 });
583 (client, counter)
584 }
585
586 #[tokio::test]
587 async fn embed_three_texts_returns_three_vectors() {
588 let (client, _) = mock_client(3);
589 let result = client
590 .embed_batch(
591 &AiProvider::OpenAi,
592 "model",
593 "key",
594 vec!["a".into(), "b".into(), "c".into()],
595 )
596 .await
597 .unwrap();
598 assert_eq!(result.len(), 3);
599 assert!(result.iter().all(|v| v.len() == 3));
600 }
601
602 #[tokio::test]
603 async fn embed_empty_input_zero_requests() {
604 let (client, counter) = mock_client(3);
605 let result = client
606 .embed_batch(&AiProvider::OpenAi, "model", "key", vec![])
607 .await
608 .unwrap();
609 assert!(result.is_empty());
610 assert_eq!(counter.load(Ordering::SeqCst), 0);
611 }
612
613 #[tokio::test]
614 async fn embed_1000_inputs_single_request_openai() {
615 let (client, counter) = mock_client(4);
616 let texts: Vec<String> = (0..1000).map(|i| format!("text {i}")).collect();
617 let result = client
618 .embed_batch(&AiProvider::OpenAi, "model", "key", texts)
619 .await
620 .unwrap();
621 assert_eq!(result.len(), 1000);
622 assert_eq!(counter.load(Ordering::SeqCst), 1);
624 }
625
626 #[tokio::test]
627 async fn embed_splits_when_over_max_batch() {
628 let (client, counter) = mock_client(2);
629 let client = client.with_max_batch_size(3);
630 let texts: Vec<String> = (0..7).map(|i| format!("t{i}")).collect();
631 let result = client
632 .embed_batch(&AiProvider::OpenAi, "model", "key", texts)
633 .await
634 .unwrap();
635 assert_eq!(result.len(), 7);
636 assert_eq!(counter.load(Ordering::SeqCst), 3);
638 }
639
640 #[tokio::test]
641 async fn embed_records_batch_size_and_token_metrics() {
642 let (client, _) = mock_client(2);
643 let provider = AiProvider::Custom("test_batch_metrics_provider".to_string());
644 let _ = client
645 .with_max_batch_size(2)
646 .embed_batch(
647 &provider,
648 "test-batch-metrics-model",
649 "key",
650 vec!["a".into(), "b".into(), "c".into()],
651 )
652 .await
653 .unwrap();
654
655 let mut body = String::new();
656 crate::runtime::ai::metrics::render_ai_metrics(&mut body);
657 assert!(
658 body.contains(
659 "reddb_ai_embedding_batch_size_count{provider=\"test_batch_metrics_provider\"} 2"
660 ),
661 "{body}"
662 );
663 assert!(
664 body.contains(
665 "reddb_ai_text_tokens_total{provider=\"test_batch_metrics_provider\",model=\"test-batch-metrics-model\"} 6"
666 ),
667 "{body}"
668 );
669 }
670
671 #[tokio::test]
672 async fn embed_empty_strings_skipped_positions_preserved() {
673 let (client, counter) = mock_client(2);
674 let texts = vec![
675 "".to_string(),
676 "hello".to_string(),
677 " ".to_string(),
678 "world".to_string(),
679 ];
680 let result = client
681 .embed_batch(&AiProvider::OpenAi, "model", "key", texts)
682 .await
683 .unwrap();
684 assert_eq!(result.len(), 4);
685 assert!(result[0].is_empty(), "empty string → empty vec");
686 assert_eq!(result[1].len(), 2, "hello → embedding");
687 assert!(result[2].is_empty(), "whitespace-only → empty vec");
688 assert_eq!(result[3].len(), 2, "world → embedding");
689 assert_eq!(counter.load(Ordering::SeqCst), 1);
691 }
692
693 #[tokio::test]
694 async fn embed_error_propagated() {
695 struct ErrorSender;
696
697 impl SubBatchSender for ErrorSender {
698 fn send(
699 &self,
700 request: SubBatchRequest,
701 ) -> impl Future<Output = Result<SubBatchResponse, AiTransportError>> + Send + '_
702 {
703 async move {
704 Err(AiTransportError {
705 provider: request.provider,
706 status_code: Some(500),
707 attempt_count: 3,
708 total_wait_ms: 2000,
709 message: "server error".to_string(),
710 })
711 }
712 }
713 }
714
715 let client = AiBatchClient::with_sender(ErrorSender);
716 let err = client
717 .embed_batch(
718 &AiProvider::OpenAi,
719 "model",
720 "key",
721 vec!["text".to_string()],
722 )
723 .await
724 .unwrap_err();
725 assert_eq!(err.status_code, Some(500));
726 assert_eq!(err.attempt_count, 3);
727 }
728
729 #[tokio::test]
730 async fn embed_writes_structured_audit_line_when_logger_attached() {
731 let (client, _) = mock_client(2);
732 let dir = tempfile::tempdir().unwrap();
733 let audit_path = dir.path().join(".audit.log");
734 let audit_log = Arc::new(AuditLogger::with_max_bytes(audit_path, 1024 * 1024));
735 let provider = AiProvider::Custom("test_audit_provider".to_string());
736
737 let _ = client
738 .with_audit_log(Arc::clone(&audit_log))
739 .embed_batch(
740 &provider,
741 "test-audit-model",
742 "key",
743 vec!["alpha".into(), "beta".into()],
744 )
745 .await
746 .unwrap();
747
748 assert!(audit_log.wait_idle(Duration::from_secs(2)));
749 let body = std::fs::read_to_string(audit_log.path()).unwrap();
750 assert!(body.contains("\"action\":\"ai/embedding_batch\""), "{body}");
751 assert!(
752 body.contains("\"provider\":\"test_audit_provider\""),
753 "{body}"
754 );
755 assert!(body.contains("\"model\":\"test-audit-model\""), "{body}");
756 assert!(body.contains("\"batch_size\":2"), "{body}");
757 assert!(body.contains("\"total_tokens\":2"), "{body}");
758 assert!(body.contains("\"duration_ms\""), "{body}");
759 assert!(body.contains("\"retries\":0"), "{body}");
760 assert!(body.contains("\"dedup_hits\":0"), "{body}");
761 assert!(body.contains("\"chunked\":false"), "{body}");
762 }
763
764 #[tokio::test]
765 async fn embed_order_preserved_across_batches() {
766 struct BatchNumberSender {
767 call_count: Arc<AtomicUsize>,
768 }
769
770 impl SubBatchSender for BatchNumberSender {
771 fn send(
772 &self,
773 request: SubBatchRequest,
774 ) -> impl Future<Output = Result<SubBatchResponse, AiTransportError>> + Send + '_
775 {
776 let call = self.call_count.fetch_add(1, Ordering::SeqCst);
777 let n = request.inputs.len();
778 async move {
779 Ok(SubBatchResponse {
781 embeddings: (0..n).map(|_| vec![call as f32]).collect(),
782 model: request.model,
783 prompt_tokens: Some(n as u64),
784 total_tokens: Some(n as u64),
785 attempt_count: 1,
786 total_wait_ms: 0,
787 })
788 }
789 }
790 }
791
792 let counter = Arc::new(AtomicUsize::new(0));
793 let client = AiBatchClient::with_sender(BatchNumberSender {
794 call_count: Arc::clone(&counter),
795 })
796 .with_max_batch_size(3);
797
798 let texts: Vec<String> = (0..5).map(|i| format!("t{i}")).collect();
800 let result = client
801 .embed_batch(&AiProvider::OpenAi, "model", "key", texts)
802 .await
803 .unwrap();
804
805 assert_eq!(result.len(), 5);
806 assert_eq!(counter.load(Ordering::SeqCst), 2);
807 assert_eq!(result[0], vec![0.0]);
809 assert_eq!(result[1], vec![0.0]);
810 assert_eq!(result[2], vec![0.0]);
811 assert_eq!(result[3], vec![1.0]);
813 assert_eq!(result[4], vec![1.0]);
814 }
815
816 #[tokio::test]
817 async fn default_max_batch_size_openai_is_2048() {
818 assert_eq!(default_max_batch_size(&AiProvider::OpenAi), 2048);
819 }
820
821 #[tokio::test]
822 async fn default_max_batch_size_ollama_is_256() {
823 assert_eq!(default_max_batch_size(&AiProvider::Ollama), 256);
824 }
825
826 #[tokio::test]
829 async fn dedup_cache_never_reuses_vectors_across_connections_or_models() {
830 let (client, counter) = mock_client(2);
831 let client = client.with_dedup_cache(Arc::new(EmbeddingDedupCache::new(
832 32,
833 Duration::from_secs(60),
834 )));
835 let connections = [
836 (
837 AiProvider::OpenAi,
838 "model-a",
839 "key-a",
840 "https://a.example/v1",
841 ),
842 (
843 AiProvider::OpenAi,
844 "model-b",
845 "key-a",
846 "https://a.example/v1",
847 ),
848 (
849 AiProvider::OpenAi,
850 "model-a",
851 "key-b",
852 "https://a.example/v1",
853 ),
854 (
855 AiProvider::OpenAi,
856 "model-a",
857 "key-a",
858 "https://b.example/v1",
859 ),
860 (
861 AiProvider::RedRouter,
862 "model-a",
863 "key-a",
864 "https://a.example/v1",
865 ),
866 ];
867 for (index, (provider, model, key, endpoint)) in connections.iter().enumerate() {
868 for _ in 0..2 {
869 client
870 .embed_batch_at(
871 provider,
872 model,
873 key,
874 endpoint,
875 vec!["same text".to_string()],
876 )
877 .await
878 .expect("embedding");
879 assert_eq!(
880 counter.load(Ordering::SeqCst),
881 index + 1,
882 "new connection misses, same connection hits"
883 );
884 }
885 }
886 }
887
888 #[tokio::test]
889 async fn dedup_on_1000_inputs_10_unique_sends_10_to_provider() {
890 let (base_client, counter) = mock_client(4);
891 let cache = Arc::new(EmbeddingDedupCache::new(1024, Duration::from_secs(60)));
892 let client = base_client.with_dedup_cache(Arc::clone(&cache));
893
894 let unique: Vec<String> = (0..10).map(|i| format!("unique text {i}")).collect();
895 let texts: Vec<String> = (0..1000).map(|i| unique[i % 10].clone()).collect();
896
897 let result = client
898 .embed_batch(&AiProvider::OpenAi, "model", "key", texts.clone())
899 .await
900 .unwrap();
901
902 assert_eq!(result.len(), 1000);
903 assert_eq!(counter.load(Ordering::SeqCst), 1, "1 sub-batch request");
905 assert_eq!(cache.misses(), 1000);
909 assert_eq!(cache.hits(), 0);
910
911 let result2 = client
914 .embed_batch(&AiProvider::OpenAi, "model", "key", texts)
915 .await
916 .unwrap();
917 assert_eq!(result2.len(), 1000);
918 assert_eq!(
920 counter.load(Ordering::SeqCst),
921 1,
922 "still 1 provider request total"
923 );
924 assert_eq!(cache.hits(), 1000, "all 1000 hit cache on second call");
925 }
926
927 #[tokio::test]
928 async fn dedup_off_by_default_all_texts_sent() {
929 let (client, counter) = mock_client(4);
930 let texts: Vec<String> = (0..10).map(|i| format!("text {i}")).collect();
932 let result = client
933 .embed_batch(&AiProvider::OpenAi, "model", "key", texts.clone())
934 .await
935 .unwrap();
936 assert_eq!(result.len(), 10);
937 let _ = client
939 .embed_batch(&AiProvider::OpenAi, "model", "key", texts)
940 .await
941 .unwrap();
942 assert_eq!(counter.load(Ordering::SeqCst), 2);
944 }
945
946 #[tokio::test]
947 async fn chunker_long_text_truncated_to_first_chunk_single_mode() {
948 let (base_client, counter) = mock_client(2);
950 let client = base_client.with_max_tokens(10); let long_text = "a".repeat(200); let result = client
954 .embed_batch(&AiProvider::OpenAi, "model", "key", vec![long_text])
955 .await
956 .unwrap();
957
958 assert_eq!(result.len(), 1);
959 assert_eq!(counter.load(Ordering::SeqCst), 1);
960 }
962}