Skip to main content

reddb_server/runtime/ai/
batch_client.rs

1//! Batch embedding client — issue #275.
2//!
3//! Accepts a `Vec<String>`, splits into sub-batches up to `max_batch_size`,
4//! sends each via `AiTransport` with retry, and reassembles results in the
5//! original order. Empty texts are skipped; their positions get an empty
6//! `Vec<f32>` in the output.
7//!
8//! Issue #277 adds optional dedup cache and text chunking.
9
10use 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
27/// One sub-batch worth of work.
28pub 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
45/// Backend abstraction. Production uses `AiTransportSender`; tests use mocks.
46pub trait SubBatchSender: Send + Sync {
47    fn send(
48        &self,
49        request: SubBatchRequest,
50    ) -> impl Future<Output = Result<SubBatchResponse, AiTransportError>> + Send + '_;
51}
52
53/// Production backend: routes sub-batches through `AiTransport`.
54pub 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
95/// Batch embedding client.
96///
97/// Generic over the backend so tests can inject mocks without HTTP.
98/// The default type parameter is `AiTransportSender` (production).
99pub struct AiBatchClient<S = AiTransportSender> {
100    sender: S,
101    max_batch_size_override: Option<usize>,
102    /// Optional dedup cache. None = dedup disabled (default).
103    dedup_cache: Option<Arc<EmbeddingDedupCache>>,
104    /// Chunk mode applied before sending. Default = Single.
105    chunk_mode: ChunkMode,
106    /// Max tokens per chunk (approximate: 1 token ≈ 4 bytes).
107    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    /// Create with a custom backend (useful in tests).
159    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    /// Override the max sub-batch size (defaults per provider if not set).
171    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    /// Enable dedup cache.
177    pub fn with_dedup_cache(mut self, cache: Arc<EmbeddingDedupCache>) -> Self {
178        self.dedup_cache = Some(cache);
179        self
180    }
181
182    /// Set chunk mode (Single or Multi).
183    pub fn with_chunk_mode(mut self, mode: ChunkMode) -> Self {
184        self.chunk_mode = mode;
185        self
186    }
187
188    /// Set max tokens per chunk.
189    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    /// Embed `texts` in batch. Returns one `Vec<f32>` per input in order.
200    /// Empty/whitespace-only inputs yield an empty `Vec<f32>` at their position
201    /// without consuming a provider request slot.
202    ///
203    /// When dedup is enabled, previously-seen texts are served from cache and
204    /// only unseen texts are sent to the provider. Duplicate texts within a
205    /// single call are also deduplicated — the provider receives each unique
206    /// text only once.
207    ///
208    /// When chunking is enabled, texts exceeding `max_tokens` are chunked;
209    /// in Single mode the first chunk is sent to the provider.
210    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    /// Embed using an already resolved connection rather than re-resolving its endpoint.
228    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        // Step 1: apply chunking — each text → representative text to embed.
253        // In Single mode this is the first (or only) chunk.
254        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        // Step 2: check dedup cache and collect unique provider misses.
265        // result[i] = Some(embedding) when resolved, None when pending.
266        let mut result: Vec<Option<Vec<f32>>> = vec![None; texts.len()];
267
268        // unique_texts_to_embed: insertion-ordered unique texts that need a
269        // provider call. text → index in unique_texts_to_embed.
270        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        // For each input, map position → unique_texts index (for fan-out later).
275        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            // Cache lookup (covers both warm cache and intra-batch duplicates
283            // that were already cached in a prior iteration of this loop after
284            // the provider returned).
285            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            // Dedup within this batch: if text already queued, reuse its slot.
293            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        // Step 3: send unique_texts_to_embed in sub-batches.
305        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            // Determine the start index of this chunk within unique_texts_to_embed.
310            // (We need this to write results back into unique_embeddings.)
311            let chunk_start = {
312                // chunk is a subslice of unique_texts_to_embed; compute offset.
313                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                // Insert into dedup cache
372                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        // Step 4: fan-out unique_embeddings back to result positions.
384        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        // 1000 < DEFAULT_OPENAI_MAX_BATCH (2048) → exactly 1 request
623        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        // ceil(7/3) = 3 batches
637        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        // Only 2 non-empty texts → 1 request
690        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                    // encode batch number as first float for order verification
780                    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        // 5 texts → 2 batches (3 + 2)
799        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        // First 3 from batch 0
808        assert_eq!(result[0], vec![0.0]);
809        assert_eq!(result[1], vec![0.0]);
810        assert_eq!(result[2], vec![0.0]);
811        // Last 2 from batch 1
812        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    // ── Issue #277: dedup cache tests ──────────────────────────────────────
827
828    #[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        // Intra-batch dedup: provider receives 10 unique texts in 1 sub-batch.
904        assert_eq!(counter.load(Ordering::SeqCst), 1, "1 sub-batch request");
905        // First call: all 1000 texts check the cache → 1000 misses (cache empty).
906        // Intra-batch duplicates are deduplicated via HashMap, but still count
907        // as cache misses since each text is checked individually.
908        assert_eq!(cache.misses(), 1000);
909        assert_eq!(cache.hits(), 0);
910
911        // Second call with same texts: cache now has 10 entries → all 1000
912        // input texts hit cache (10 unique + 990 duplicates each hit).
913        let result2 = client
914            .embed_batch(&AiProvider::OpenAi, "model", "key", texts)
915            .await
916            .unwrap();
917        assert_eq!(result2.len(), 1000);
918        // No new provider call — all served from cache
919        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        // no dedup cache attached
931        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        // Second call with same texts — still sends all (no cache)
938        let _ = client
939            .embed_batch(&AiProvider::OpenAi, "model", "key", texts)
940            .await
941            .unwrap();
942        // 2 calls, each 1 sub-batch
943        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        // 1 token ≈ 4 bytes; max_tokens=10 → max 40 bytes
949        let (base_client, counter) = mock_client(2);
950        let client = base_client.with_max_tokens(10); // 40 byte chunks
951
952        let long_text = "a".repeat(200); // 200 bytes >> 40 byte limit
953        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        // provider received 1 item (first chunk only in Single mode)
961    }
962}