Skip to main content

dakera_client/
client.rs

1//! Dakera client implementation
2
3use reqwest::{
4    header::{HeaderMap, HeaderValue, AUTHORIZATION},
5    Client, StatusCode,
6};
7use std::sync::{Arc, Mutex};
8use std::time::Duration;
9use tracing::{debug, instrument};
10
11use serde::Deserialize;
12
13use crate::error::{ClientError, Result, ServerErrorCode};
14use crate::types::*;
15
16/// Default timeout for requests
17const DEFAULT_TIMEOUT_SECS: u64 = 30;
18
19/// Dakera client for interacting with the vector database
20#[derive(Debug, Clone)]
21pub struct DakeraClient {
22    /// HTTP client
23    pub(crate) client: Client,
24    /// Base URL of the Dakera server
25    pub(crate) base_url: String,
26    /// ODE-2: Base URL of the dakera-ode sidecar (optional)
27    pub(crate) ode_url: Option<String>,
28    /// Retry configuration (wired into API call sites in a follow-up; suppressed until then)
29    #[allow(dead_code)]
30    pub(crate) retry_config: RetryConfig,
31    /// OPS-1: last seen rate-limit headers (shared across clones)
32    pub(crate) last_rate_limit: Arc<Mutex<Option<RateLimitHeaders>>>,
33}
34
35impl DakeraClient {
36    /// Create a new client with the given base URL
37    ///
38    /// # Example
39    ///
40    /// ```rust,no_run
41    /// use dakera_client::DakeraClient;
42    ///
43    /// let client = DakeraClient::new("http://localhost:3000").unwrap();
44    /// ```
45    pub fn new(base_url: impl Into<String>) -> Result<Self> {
46        DakeraClientBuilder::new(base_url).build()
47    }
48
49    /// Create a new client builder for more configuration options
50    pub fn builder(base_url: impl Into<String>) -> DakeraClientBuilder {
51        DakeraClientBuilder::new(base_url)
52    }
53
54    // ========================================================================
55    // Health & Status
56    // ========================================================================
57
58    /// Check server health
59    #[instrument(skip(self))]
60    pub async fn health(&self) -> Result<HealthResponse> {
61        let url = format!("{}/health", self.base_url);
62        let response = self.client.get(&url).send().await?;
63
64        if response.status().is_success() {
65            let json: serde_json::Value = response.json().await?;
66            // Server returns {"service":"dakera","status":"healthy","version":"..."}.
67            // Accept both `healthy: bool` (legacy) and `status: "healthy"` (current).
68            let healthy = json
69                .get("healthy")
70                .and_then(|v| v.as_bool())
71                .unwrap_or_else(|| json.get("status").and_then(|v| v.as_str()) == Some("healthy"));
72            let version = json
73                .get("version")
74                .and_then(|v| v.as_str())
75                .map(String::from);
76            let uptime_seconds = json.get("uptime_seconds").and_then(|v| v.as_u64());
77            Ok(HealthResponse {
78                healthy,
79                version,
80                uptime_seconds,
81                build_sha: json
82                    .get("build_sha")
83                    .and_then(|v| v.as_str())
84                    .map(String::from),
85            })
86        } else {
87            // Health endpoint might return simple OK
88            Ok(HealthResponse {
89                healthy: true,
90                version: None,
91                uptime_seconds: None,
92                build_sha: None,
93            })
94        }
95    }
96
97    /// Check if server is ready
98    #[instrument(skip(self))]
99    pub async fn ready(&self) -> Result<ReadinessResponse> {
100        let url = format!("{}/health/ready", self.base_url);
101        let response = self.client.get(&url).send().await?;
102
103        if response.status().is_success() {
104            Ok(response.json().await?)
105        } else {
106            Ok(ReadinessResponse {
107                ready: false,
108                components: None,
109            })
110        }
111    }
112
113    /// Check if server is live
114    #[instrument(skip(self))]
115    pub async fn live(&self) -> Result<bool> {
116        let url = format!("{}/health/live", self.base_url);
117        let response = self.client.get(&url).send().await?;
118        Ok(response.status().is_success())
119    }
120
121    // ========================================================================
122    // Namespace Operations
123    // ========================================================================
124
125    /// List all namespaces
126    #[instrument(skip(self))]
127    pub async fn list_namespaces(&self) -> Result<Vec<String>> {
128        let url = format!("{}/v1/namespaces", self.base_url);
129        let response = self.client.get(&url).send().await?;
130        self.handle_response::<ListNamespacesResponse>(response)
131            .await
132            .map(|r| r.namespaces)
133    }
134
135    /// Get namespace information
136    #[instrument(skip(self))]
137    pub async fn get_namespace(&self, namespace: &str) -> Result<NamespaceInfo> {
138        let url = format!("{}/v1/namespaces/{}", self.base_url, namespace);
139        let response = self.client.get(&url).send().await?;
140        self.handle_response(response).await
141    }
142
143    /// Create a new namespace
144    #[instrument(skip(self, request))]
145    pub async fn create_namespace(
146        &self,
147        namespace: &str,
148        request: CreateNamespaceRequest,
149    ) -> Result<NamespaceInfo> {
150        let url = format!("{}/v1/namespaces/{}", self.base_url, namespace);
151        let response = self.client.put(&url).json(&request).send().await?;
152        self.handle_response(response).await
153    }
154
155    /// Create or update a namespace configuration (upsert semantics — v0.6.0).
156    ///
157    /// Creates the namespace if it does not exist, or updates its distance-metric
158    /// configuration if it already exists.  Dimension changes are rejected to
159    /// prevent silent data corruption.  Requires `Scope::Write`.
160    #[instrument(skip(self, request), fields(namespace = %namespace))]
161    pub async fn configure_namespace(
162        &self,
163        namespace: &str,
164        request: ConfigureNamespaceRequest,
165    ) -> Result<ConfigureNamespaceResponse> {
166        let url = format!("{}/v1/namespaces/{}", self.base_url, namespace);
167        let response = self.client.put(&url).json(&request).send().await?;
168        self.handle_response(response).await
169    }
170
171    /// Delete a namespace and all its data.
172    #[instrument(skip(self))]
173    pub async fn delete_namespace(&self, namespace: &str) -> Result<()> {
174        let url = format!("{}/v1/namespaces/{}", self.base_url, namespace);
175        let response = self.client.delete(&url).send().await?;
176        if response.status().is_success() {
177            Ok(())
178        } else {
179            let status = response.status().as_u16();
180            let text = response.text().await.unwrap_or_default();
181            Err(ClientError::Server {
182                status,
183                message: text,
184                code: None,
185            })
186        }
187    }
188
189    /// Flush pending writes for a namespace.
190    #[instrument(skip(self))]
191    pub async fn flush(&self, namespace: &str) -> Result<serde_json::Value> {
192        let url = format!("{}/v1/namespaces/{}/flush", self.base_url, namespace);
193        let response = self.client.post(&url).send().await?;
194        self.handle_response(response).await
195    }
196
197    /// Get index statistics for a specific namespace.
198    #[instrument(skip(self))]
199    pub async fn get_namespace_stats(&self, namespace: &str) -> Result<serde_json::Value> {
200        let url = format!("{}/v1/namespaces/{}/stats", self.base_url, namespace);
201        let response = self.client.get(&url).send().await?;
202        self.handle_response(response).await
203    }
204
205    /// Alias for [`get_namespace_stats`](Self::get_namespace_stats) matching Python/JS naming.
206    #[instrument(skip(self))]
207    pub async fn get_index_stats(&self, namespace: &str) -> Result<serde_json::Value> {
208        self.get_namespace_stats(namespace).await
209    }
210
211    // ========================================================================
212    // Vector Operations
213    // ========================================================================
214
215    /// Upsert vectors into a namespace
216    #[instrument(skip(self, request), fields(vector_count = request.vectors.len()))]
217    pub async fn upsert(&self, namespace: &str, request: UpsertRequest) -> Result<UpsertResponse> {
218        let url = format!("{}/v1/namespaces/{}/vectors", self.base_url, namespace);
219        debug!(
220            "Upserting {} vectors to {}",
221            request.vectors.len(),
222            namespace
223        );
224
225        let response = self.client.post(&url).json(&request).send().await?;
226        self.handle_response(response).await
227    }
228
229    /// Upsert a single vector (convenience method)
230    #[instrument(skip(self, vector))]
231    pub async fn upsert_one(&self, namespace: &str, vector: Vector) -> Result<UpsertResponse> {
232        self.upsert(namespace, UpsertRequest::single(vector)).await
233    }
234
235    /// Upsert vectors in column format (Turbopuffer-inspired)
236    ///
237    /// This format is more efficient for bulk upserts as it avoids repeating
238    /// field names for each vector. All arrays must have equal length.
239    ///
240    /// # Example
241    ///
242    /// ```rust,no_run
243    /// use dakera_client::{DakeraClient, ColumnUpsertRequest};
244    ///
245    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
246    /// let client = DakeraClient::new("http://localhost:3000")?;
247    ///
248    /// let request = ColumnUpsertRequest::new(
249    ///     vec!["id1".to_string(), "id2".to_string(), "id3".to_string()],
250    ///     vec![
251    ///         vec![0.1, 0.2, 0.3],
252    ///         vec![0.4, 0.5, 0.6],
253    ///         vec![0.7, 0.8, 0.9],
254    ///     ],
255    /// )
256    /// .with_attribute("category", vec![
257    ///     serde_json::json!("A"),
258    ///     serde_json::json!("B"),
259    ///     serde_json::json!("A"),
260    /// ]);
261    ///
262    /// let response = client.upsert_columns("my-namespace", request).await?;
263    /// println!("Upserted {} vectors", response.upserted_count);
264    /// # Ok(())
265    /// # }
266    /// ```
267    #[instrument(skip(self, request), fields(namespace = %namespace, count = request.ids.len()))]
268    pub async fn upsert_columns(
269        &self,
270        namespace: &str,
271        request: ColumnUpsertRequest,
272    ) -> Result<UpsertResponse> {
273        let url = format!(
274            "{}/v1/namespaces/{}/upsert-columns",
275            self.base_url, namespace
276        );
277        debug!(
278            "Upserting {} vectors in column format to {}",
279            request.ids.len(),
280            namespace
281        );
282
283        let response = self.client.post(&url).json(&request).send().await?;
284        self.handle_response(response).await
285    }
286
287    /// Query for similar vectors
288    #[instrument(skip(self, request), fields(top_k = request.top_k))]
289    pub async fn query(&self, namespace: &str, request: QueryRequest) -> Result<QueryResponse> {
290        let url = format!("{}/v1/namespaces/{}/query", self.base_url, namespace);
291        debug!(
292            "Querying namespace {} for top {} results",
293            namespace, request.top_k
294        );
295
296        let response = self.client.post(&url).json(&request).send().await?;
297        self.handle_response(response).await
298    }
299
300    /// Simple query with just a vector and top_k (convenience method)
301    #[instrument(skip(self, vector))]
302    pub async fn query_simple(
303        &self,
304        namespace: &str,
305        vector: Vec<f32>,
306        top_k: u32,
307    ) -> Result<QueryResponse> {
308        self.query(namespace, QueryRequest::new(vector, top_k))
309            .await
310    }
311
312    /// Execute multiple queries in a single request
313    ///
314    /// This allows executing multiple vector similarity queries in parallel,
315    /// which is more efficient than making separate requests.
316    ///
317    /// # Example
318    ///
319    /// ```rust,no_run
320    /// use dakera_client::{DakeraClient, BatchQueryRequest, BatchQueryItem};
321    ///
322    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
323    /// let client = DakeraClient::new("http://localhost:3000")?;
324    ///
325    /// let request = BatchQueryRequest::new(vec![
326    ///     BatchQueryItem::new(vec![0.1, 0.2, 0.3], 5).with_id("query1"),
327    ///     BatchQueryItem::new(vec![0.4, 0.5, 0.6], 10).with_id("query2"),
328    /// ]);
329    ///
330    /// let response = client.batch_query("my-namespace", request).await?;
331    /// println!("Executed {} queries in {}ms", response.query_count, response.total_latency_ms);
332    /// # Ok(())
333    /// # }
334    /// ```
335    #[instrument(skip(self, request), fields(namespace = %namespace, query_count = request.queries.len()))]
336    pub async fn batch_query(
337        &self,
338        namespace: &str,
339        request: BatchQueryRequest,
340    ) -> Result<BatchQueryResponse> {
341        let url = format!("{}/v1/namespaces/{}/batch-query", self.base_url, namespace);
342        debug!(
343            "Batch querying namespace {} with {} queries",
344            namespace,
345            request.queries.len()
346        );
347
348        let response = self.client.post(&url).json(&request).send().await?;
349        self.handle_response(response).await
350    }
351
352    /// Delete vectors by ID
353    #[instrument(skip(self, request), fields(id_count = request.ids.len()))]
354    pub async fn delete(&self, namespace: &str, request: DeleteRequest) -> Result<DeleteResponse> {
355        let url = format!(
356            "{}/v1/namespaces/{}/vectors/delete",
357            self.base_url, namespace
358        );
359        debug!("Deleting {} vectors from {}", request.ids.len(), namespace);
360
361        let response = self.client.post(&url).json(&request).send().await?;
362        self.handle_response(response).await
363    }
364
365    /// Delete a single vector by ID (convenience method)
366    #[instrument(skip(self))]
367    pub async fn delete_one(&self, namespace: &str, id: &str) -> Result<DeleteResponse> {
368        self.delete(namespace, DeleteRequest::single(id)).await
369    }
370
371    /// Bulk update vector metadata matching a filter.
372    #[instrument(skip(self, request))]
373    pub async fn bulk_update_vectors(
374        &self,
375        namespace: &str,
376        request: BulkUpdateRequest,
377    ) -> Result<BulkUpdateResponse> {
378        let url = format!(
379            "{}/v1/namespaces/{}/vectors/bulk-update",
380            self.base_url, namespace
381        );
382        let response = self.client.post(&url).json(&request).send().await?;
383        self.handle_response(response).await
384    }
385
386    /// Bulk delete vectors matching a filter.
387    #[instrument(skip(self, request))]
388    pub async fn bulk_delete_vectors(
389        &self,
390        namespace: &str,
391        request: BulkDeleteRequest,
392    ) -> Result<BulkDeleteResponse> {
393        let url = format!(
394            "{}/v1/namespaces/{}/vectors/bulk-delete",
395            self.base_url, namespace
396        );
397        let response = self.client.post(&url).json(&request).send().await?;
398        self.handle_response(response).await
399    }
400
401    /// Count vectors in a namespace, optionally filtered.
402    #[instrument(skip(self, request))]
403    pub async fn count_vectors(
404        &self,
405        namespace: &str,
406        request: CountVectorsRequest,
407    ) -> Result<CountVectorsResponse> {
408        let url = format!(
409            "{}/v1/namespaces/{}/vectors/count",
410            self.base_url, namespace
411        );
412        let response = self.client.post(&url).json(&request).send().await?;
413        self.handle_response(response).await
414    }
415
416    // ========================================================================
417    // Full-Text Search Operations
418    // ========================================================================
419
420    /// Index documents for full-text search
421    #[instrument(skip(self, request), fields(doc_count = request.documents.len()))]
422    pub async fn index_documents(
423        &self,
424        namespace: &str,
425        request: IndexDocumentsRequest,
426    ) -> Result<IndexDocumentsResponse> {
427        let url = format!(
428            "{}/v1/namespaces/{}/fulltext/index",
429            self.base_url, namespace
430        );
431        debug!(
432            "Indexing {} documents in {}",
433            request.documents.len(),
434            namespace
435        );
436
437        let response = self.client.post(&url).json(&request).send().await?;
438        self.handle_response(response).await
439    }
440
441    /// Index a single document (convenience method)
442    #[instrument(skip(self, document))]
443    pub async fn index_document(
444        &self,
445        namespace: &str,
446        document: Document,
447    ) -> Result<IndexDocumentsResponse> {
448        self.index_documents(
449            namespace,
450            IndexDocumentsRequest {
451                documents: vec![document],
452            },
453        )
454        .await
455    }
456
457    /// Perform full-text search
458    #[instrument(skip(self, request))]
459    pub async fn fulltext_search(
460        &self,
461        namespace: &str,
462        request: FullTextSearchRequest,
463    ) -> Result<FullTextSearchResponse> {
464        let url = format!(
465            "{}/v1/namespaces/{}/fulltext/search",
466            self.base_url, namespace
467        );
468        debug!("Full-text search in {} for: {}", namespace, request.query);
469
470        let response = self.client.post(&url).json(&request).send().await?;
471        self.handle_response(response).await
472    }
473
474    /// Simple full-text search (convenience method)
475    #[instrument(skip(self))]
476    pub async fn search_text(
477        &self,
478        namespace: &str,
479        query: &str,
480        top_k: u32,
481    ) -> Result<FullTextSearchResponse> {
482        self.fulltext_search(namespace, FullTextSearchRequest::new(query, top_k))
483            .await
484    }
485
486    /// Get full-text index statistics
487    #[instrument(skip(self))]
488    pub async fn fulltext_stats(&self, namespace: &str) -> Result<FullTextStats> {
489        let url = format!(
490            "{}/v1/namespaces/{}/fulltext/stats",
491            self.base_url, namespace
492        );
493        let response = self.client.get(&url).send().await?;
494        self.handle_response(response).await
495    }
496
497    /// Delete documents from full-text index
498    #[instrument(skip(self, request))]
499    pub async fn fulltext_delete(
500        &self,
501        namespace: &str,
502        request: DeleteRequest,
503    ) -> Result<DeleteResponse> {
504        let url = format!(
505            "{}/v1/namespaces/{}/fulltext/delete",
506            self.base_url, namespace
507        );
508        let response = self.client.post(&url).json(&request).send().await?;
509        self.handle_response(response).await
510    }
511
512    // ========================================================================
513    // Hybrid Search Operations
514    // ========================================================================
515
516    /// Perform hybrid search (vector + full-text)
517    #[instrument(skip(self, request), fields(top_k = request.top_k))]
518    pub async fn hybrid_search(
519        &self,
520        namespace: &str,
521        request: HybridSearchRequest,
522    ) -> Result<HybridSearchResponse> {
523        let url = format!("{}/v1/namespaces/{}/hybrid", self.base_url, namespace);
524        debug!(
525            "Hybrid search in {} with vector_weight={}",
526            namespace, request.vector_weight
527        );
528
529        let response = self.client.post(&url).json(&request).send().await?;
530        self.handle_response(response).await
531    }
532
533    // ========================================================================
534    // Multi-Vector Search Operations
535    // ========================================================================
536
537    /// Multi-vector search with positive/negative vectors and MMR
538    ///
539    /// This performs semantic search using multiple positive vectors (to search towards)
540    /// and optional negative vectors (to search away from). Supports MMR (Maximal Marginal
541    /// Relevance) for result diversity.
542    ///
543    /// # Example
544    ///
545    /// ```rust,no_run
546    /// use dakera_client::{DakeraClient, MultiVectorSearchRequest};
547    ///
548    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
549    /// let client = DakeraClient::new("http://localhost:3000")?;
550    ///
551    /// // Search towards multiple concepts, away from others
552    /// let request = MultiVectorSearchRequest::new(vec![
553    ///     vec![0.1, 0.2, 0.3],  // positive vector 1
554    ///     vec![0.4, 0.5, 0.6],  // positive vector 2
555    /// ])
556    /// .with_negative_vectors(vec![
557    ///     vec![0.7, 0.8, 0.9],  // negative vector
558    /// ])
559    /// .with_top_k(10)
560    /// .with_mmr(0.7);  // Enable MMR with lambda=0.7
561    ///
562    /// let response = client.multi_vector_search("my-namespace", request).await?;
563    /// for result in response.results {
564    ///     println!("ID: {}, Score: {}", result.id, result.score);
565    /// }
566    /// # Ok(())
567    /// # }
568    /// ```
569    #[instrument(skip(self, request), fields(namespace = %namespace))]
570    pub async fn multi_vector_search(
571        &self,
572        namespace: &str,
573        request: MultiVectorSearchRequest,
574    ) -> Result<MultiVectorSearchResponse> {
575        let url = format!("{}/v1/namespaces/{}/multi-vector", self.base_url, namespace);
576        debug!(
577            "Multi-vector search in {} with {} positive vectors",
578            namespace,
579            request.positive_vectors.len()
580        );
581
582        let response = self.client.post(&url).json(&request).send().await?;
583        self.handle_response(response).await
584    }
585
586    // ========================================================================
587    // Aggregation Operations
588    // ========================================================================
589
590    /// Aggregate vectors with grouping (Turbopuffer-inspired)
591    ///
592    /// This performs aggregation queries on vector metadata, supporting
593    /// count, sum, avg, min, and max operations with optional grouping.
594    ///
595    /// # Example
596    ///
597    /// ```rust,no_run
598    /// use dakera_client::{DakeraClient, AggregationRequest};
599    ///
600    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
601    /// let client = DakeraClient::new("http://localhost:3000")?;
602    ///
603    /// // Count all vectors and sum scores, grouped by category
604    /// let request = AggregationRequest::new()
605    ///     .with_count("total_count")
606    ///     .with_sum("total_score", "score")
607    ///     .with_avg("avg_score", "score")
608    ///     .with_group_by("category");
609    ///
610    /// let response = client.aggregate("my-namespace", request).await?;
611    /// if let Some(groups) = response.aggregation_groups {
612    ///     for group in groups {
613    ///         println!("Group: {:?}", group.group_key);
614    ///     }
615    /// }
616    /// # Ok(())
617    /// # }
618    /// ```
619    #[instrument(skip(self, request), fields(namespace = %namespace))]
620    pub async fn aggregate(
621        &self,
622        namespace: &str,
623        request: AggregationRequest,
624    ) -> Result<AggregationResponse> {
625        let url = format!("{}/v1/namespaces/{}/aggregate", self.base_url, namespace);
626        debug!(
627            "Aggregating in namespace {} with {} aggregations",
628            namespace,
629            request.aggregate_by.len()
630        );
631
632        let response = self.client.post(&url).json(&request).send().await?;
633        self.handle_response(response).await
634    }
635
636    // ========================================================================
637    // Unified Query Operations
638    // ========================================================================
639
640    /// Unified query with flexible ranking options (Turbopuffer-inspired)
641    ///
642    /// This provides a unified API for vector search (ANN/kNN), full-text search (BM25),
643    /// and attribute ordering. Supports combining multiple ranking functions with
644    /// Sum, Max, and Product operators.
645    ///
646    /// # Example
647    ///
648    /// ```rust,no_run
649    /// use dakera_client::{DakeraClient, UnifiedQueryRequest, SortDirection};
650    ///
651    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
652    /// let client = DakeraClient::new("http://localhost:3000")?;
653    ///
654    /// // Vector ANN search
655    /// let request = UnifiedQueryRequest::vector_search(vec![0.1, 0.2, 0.3], 10);
656    /// let response = client.unified_query("my-namespace", request).await?;
657    ///
658    /// // Full-text BM25 search
659    /// let request = UnifiedQueryRequest::fulltext_search("content", "hello world", 10);
660    /// let response = client.unified_query("my-namespace", request).await?;
661    ///
662    /// // Attribute ordering with filter
663    /// let request = UnifiedQueryRequest::attribute_order("timestamp", SortDirection::Desc, 10)
664    ///     .with_filter(serde_json::json!({"category": {"$eq": "science"}}));
665    /// let response = client.unified_query("my-namespace", request).await?;
666    ///
667    /// for result in response.results {
668    ///     println!("ID: {}, Score: {:?}", result.id, result.dist);
669    /// }
670    /// # Ok(())
671    /// # }
672    /// ```
673    #[instrument(skip(self, request), fields(namespace = %namespace))]
674    pub async fn unified_query(
675        &self,
676        namespace: &str,
677        request: UnifiedQueryRequest,
678    ) -> Result<UnifiedQueryResponse> {
679        let url = format!(
680            "{}/v1/namespaces/{}/unified-query",
681            self.base_url, namespace
682        );
683        debug!(
684            "Unified query in namespace {} with top_k={}",
685            namespace, request.top_k
686        );
687
688        let response = self.client.post(&url).json(&request).send().await?;
689        self.handle_response(response).await
690    }
691
692    /// Simple vector search using the unified query API (convenience method)
693    ///
694    /// This is a shortcut for `unified_query` with a vector ANN search.
695    #[instrument(skip(self, vector))]
696    pub async fn unified_vector_search(
697        &self,
698        namespace: &str,
699        vector: Vec<f32>,
700        top_k: usize,
701    ) -> Result<UnifiedQueryResponse> {
702        self.unified_query(namespace, UnifiedQueryRequest::vector_search(vector, top_k))
703            .await
704    }
705
706    /// Simple full-text search using the unified query API (convenience method)
707    ///
708    /// This is a shortcut for `unified_query` with a BM25 full-text search.
709    #[instrument(skip(self))]
710    pub async fn unified_text_search(
711        &self,
712        namespace: &str,
713        field: &str,
714        query: &str,
715        top_k: usize,
716    ) -> Result<UnifiedQueryResponse> {
717        self.unified_query(
718            namespace,
719            UnifiedQueryRequest::fulltext_search(field, query, top_k),
720        )
721        .await
722    }
723
724    // ========================================================================
725    // Query Explain Operations
726    // ========================================================================
727
728    /// Explain query execution plan (similar to SQL EXPLAIN)
729    ///
730    /// This provides detailed information about how a query will be executed,
731    /// including index selection, execution stages, cost estimates, and
732    /// performance recommendations.
733    ///
734    /// # Example
735    ///
736    /// ```rust,no_run
737    /// use dakera_client::{DakeraClient, QueryExplainRequest};
738    ///
739    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
740    /// let client = DakeraClient::new("http://localhost:3000")?;
741    ///
742    /// // Explain a vector search query
743    /// let request = QueryExplainRequest::vector_search(vec![0.1, 0.2, 0.3], 10)
744    ///     .with_verbose();
745    /// let plan = client.explain_query("my-namespace", request).await?;
746    ///
747    /// println!("Query plan: {}", plan.summary);
748    /// println!("Estimated time: {}ms", plan.cost_estimate.estimated_time_ms);
749    ///
750    /// for stage in &plan.stages {
751    ///     println!("Stage {}: {} - {}", stage.order, stage.name, stage.description);
752    /// }
753    ///
754    /// for rec in &plan.recommendations {
755    ///     println!("Recommendation ({}): {}", rec.priority, rec.description);
756    /// }
757    /// # Ok(())
758    /// # }
759    /// ```
760    #[instrument(skip(self, request), fields(namespace = %namespace))]
761    pub async fn explain_query(
762        &self,
763        namespace: &str,
764        request: QueryExplainRequest,
765    ) -> Result<QueryExplainResponse> {
766        let url = format!("{}/v1/namespaces/{}/explain", self.base_url, namespace);
767        debug!(
768            "Explaining query in namespace {} (query_type={:?}, top_k={})",
769            namespace, request.query_type, request.top_k
770        );
771
772        let response = self.client.post(&url).json(&request).send().await?;
773        self.handle_response(response).await
774    }
775
776    // ========================================================================
777    // Cache Warming Operations
778    // ========================================================================
779
780    /// Warm cache for vectors in a namespace
781    ///
782    /// This pre-loads vectors into cache tiers for faster subsequent access.
783    /// Supports priority levels and can run in the background.
784    ///
785    /// # Example
786    ///
787    /// ```rust,no_run
788    /// use dakera_client::{DakeraClient, WarmCacheRequest, WarmingPriority};
789    ///
790    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
791    /// let client = DakeraClient::new("http://localhost:3000")?;
792    ///
793    /// // Warm entire namespace with high priority
794    /// let response = client.warm_cache(
795    ///     WarmCacheRequest::new("my-namespace")
796    ///         .with_priority(WarmingPriority::High)
797    /// ).await?;
798    ///
799    /// println!("Warmed {} entries", response.entries_warmed);
800    /// # Ok(())
801    /// # }
802    /// ```
803    #[instrument(skip(self, request), fields(namespace = %request.namespace, priority = ?request.priority))]
804    pub async fn warm_cache(&self, request: WarmCacheRequest) -> Result<WarmCacheResponse> {
805        let url = format!(
806            "{}/v1/namespaces/{}/cache/warm",
807            self.base_url, request.namespace
808        );
809        debug!(
810            "Warming cache for namespace {} with priority {:?}",
811            request.namespace, request.priority
812        );
813
814        let response = self.client.post(&url).json(&request).send().await?;
815        self.handle_response(response).await
816    }
817
818    /// Warm specific vectors by ID (convenience method)
819    #[instrument(skip(self, vector_ids))]
820    pub async fn warm_vectors(
821        &self,
822        namespace: &str,
823        vector_ids: Vec<String>,
824    ) -> Result<WarmCacheResponse> {
825        self.warm_cache(WarmCacheRequest::new(namespace).with_vector_ids(vector_ids))
826            .await
827    }
828
829    // ========================================================================
830    // Export Operations
831    // ========================================================================
832
833    /// Export vectors from a namespace with pagination
834    ///
835    /// This exports all vectors from a namespace, supporting pagination for
836    /// large datasets. Use the `next_cursor` from the response to fetch
837    /// subsequent pages.
838    ///
839    /// # Example
840    ///
841    /// ```rust,no_run
842    /// use dakera_client::{DakeraClient, ExportRequest};
843    ///
844    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
845    /// let client = DakeraClient::new("http://localhost:3000")?;
846    ///
847    /// // Export first page of vectors
848    /// let mut request = ExportRequest::new().with_top_k(1000);
849    /// let response = client.export("my-namespace", request).await?;
850    ///
851    /// println!("Exported {} vectors", response.returned_count);
852    ///
853    /// // Fetch next page if available
854    /// if let Some(cursor) = response.next_cursor {
855    ///     let next_request = ExportRequest::new().with_cursor(cursor);
856    ///     let next_response = client.export("my-namespace", next_request).await?;
857    /// }
858    /// # Ok(())
859    /// # }
860    /// ```
861    #[instrument(skip(self, request), fields(namespace = %namespace))]
862    pub async fn export(&self, namespace: &str, request: ExportRequest) -> Result<ExportResponse> {
863        let url = format!("{}/v1/namespaces/{}/export", self.base_url, namespace);
864        debug!(
865            "Exporting vectors from namespace {} (top_k={}, cursor={:?})",
866            namespace, request.top_k, request.cursor
867        );
868
869        let response = self.client.post(&url).json(&request).send().await?;
870        self.handle_response(response).await
871    }
872
873    /// Export all vectors from a namespace (convenience method)
874    ///
875    /// This is a simple wrapper that exports with default settings.
876    #[instrument(skip(self))]
877    pub async fn export_all(&self, namespace: &str) -> Result<ExportResponse> {
878        self.export(namespace, ExportRequest::new()).await
879    }
880
881    /// Alias for [`export`](Self::export) matching Python/JS/Go SDK naming.
882    #[instrument(skip(self, request), fields(namespace = %namespace))]
883    pub async fn export_vectors(
884        &self,
885        namespace: &str,
886        request: ExportRequest,
887    ) -> Result<ExportResponse> {
888        self.export(namespace, request).await
889    }
890
891    // ========================================================================
892    // Operations
893    // ========================================================================
894
895    /// Get system diagnostics
896    #[instrument(skip(self))]
897    pub async fn diagnostics(&self) -> Result<SystemDiagnostics> {
898        let url = format!("{}/ops/diagnostics", self.base_url);
899        let response = self.client.get(&url).send().await?;
900        self.handle_response(response).await
901    }
902
903    /// List background jobs
904    #[instrument(skip(self))]
905    pub async fn list_jobs(&self) -> Result<Vec<JobInfo>> {
906        let url = format!("{}/ops/jobs", self.base_url);
907        let response = self.client.get(&url).send().await?;
908        self.handle_response(response).await
909    }
910
911    /// Get a specific job status
912    #[instrument(skip(self))]
913    pub async fn get_job(&self, job_id: &str) -> Result<Option<JobInfo>> {
914        let url = format!("{}/ops/jobs/{}", self.base_url, job_id);
915        let response = self.client.get(&url).send().await?;
916
917        if response.status() == StatusCode::NOT_FOUND {
918            return Ok(None);
919        }
920
921        self.handle_response(response).await.map(Some)
922    }
923
924    /// Trigger index compaction
925    #[instrument(skip(self, request))]
926    pub async fn compact(&self, request: CompactionRequest) -> Result<CompactionResponse> {
927        let url = format!("{}/ops/compact", self.base_url);
928        let response = self.client.post(&url).json(&request).send().await?;
929        self.handle_response(response).await
930    }
931
932    /// Request graceful shutdown
933    #[instrument(skip(self))]
934    pub async fn shutdown(&self) -> Result<()> {
935        let url = format!("{}/ops/shutdown", self.base_url);
936        let response = self.client.post(&url).send().await?;
937
938        if response.status().is_success() {
939            Ok(())
940        } else {
941            let status = response.status().as_u16();
942            let text = response.text().await.unwrap_or_default();
943            Err(ClientError::Server {
944                status,
945                message: text,
946                code: None,
947            })
948        }
949    }
950
951    // ========================================================================
952    // Fetch by ID
953    // ========================================================================
954
955    /// Fetch vectors by their IDs
956    #[instrument(skip(self, request), fields(id_count = request.ids.len()))]
957    pub async fn fetch(&self, namespace: &str, request: FetchRequest) -> Result<FetchResponse> {
958        let url = format!("{}/v1/namespaces/{}/fetch", self.base_url, namespace);
959        debug!("Fetching {} vectors from {}", request.ids.len(), namespace);
960        let response = self.client.post(&url).json(&request).send().await?;
961        self.handle_response(response).await
962    }
963
964    /// Fetch vectors by IDs (convenience method)
965    #[instrument(skip(self))]
966    pub async fn fetch_by_ids(&self, namespace: &str, ids: &[&str]) -> Result<Vec<Vector>> {
967        let request = FetchRequest::new(ids.iter().map(|s| s.to_string()).collect());
968        self.fetch(namespace, request).await.map(|r| r.vectors)
969    }
970
971    // ========================================================================
972    // Text Auto-Embedding Operations
973    // ========================================================================
974
975    /// Upsert text documents with automatic server-side embedding generation
976    #[instrument(skip(self, request), fields(doc_count = request.documents.len()))]
977    pub async fn upsert_text(
978        &self,
979        namespace: &str,
980        request: UpsertTextRequest,
981    ) -> Result<TextUpsertResponse> {
982        let url = format!("{}/v1/namespaces/{}/upsert-text", self.base_url, namespace);
983        debug!(
984            "Upserting {} text documents to {}",
985            request.documents.len(),
986            namespace
987        );
988        let response = self.client.post(&url).json(&request).send().await?;
989        self.handle_response(response).await
990    }
991
992    /// Query using natural language text with automatic server-side embedding
993    #[instrument(skip(self, request), fields(top_k = request.top_k))]
994    pub async fn query_text(
995        &self,
996        namespace: &str,
997        request: QueryTextRequest,
998    ) -> Result<TextQueryResponse> {
999        let url = format!("{}/v1/namespaces/{}/query-text", self.base_url, namespace);
1000        debug!("Text query in {} for: {}", namespace, request.text);
1001        let response = self.client.post(&url).json(&request).send().await?;
1002        self.handle_response(response).await
1003    }
1004
1005    /// Query text (convenience method)
1006    #[instrument(skip(self))]
1007    pub async fn query_text_simple(
1008        &self,
1009        namespace: &str,
1010        text: &str,
1011        top_k: u32,
1012    ) -> Result<TextQueryResponse> {
1013        self.query_text(namespace, QueryTextRequest::new(text, top_k))
1014            .await
1015    }
1016
1017    /// Execute multiple text queries with automatic embedding in a single request
1018    #[instrument(skip(self, request), fields(query_count = request.queries.len()))]
1019    pub async fn batch_query_text(
1020        &self,
1021        namespace: &str,
1022        request: BatchQueryTextRequest,
1023    ) -> Result<BatchQueryTextResponse> {
1024        let url = format!(
1025            "{}/v1/namespaces/{}/batch-query-text",
1026            self.base_url, namespace
1027        );
1028        debug!(
1029            "Batch text query in {} with {} queries",
1030            namespace,
1031            request.queries.len()
1032        );
1033        let response = self.client.post(&url).json(&request).send().await?;
1034        self.handle_response(response).await
1035    }
1036
1037    // ========================================================================
1038    // CE-4: GLiNER Entity Extraction
1039    // ========================================================================
1040
1041    /// Get entity extraction configuration for a namespace.
1042    #[instrument(skip(self))]
1043    pub async fn get_namespace_entity_config(
1044        &self,
1045        namespace: &str,
1046    ) -> Result<NamespaceEntityConfig> {
1047        let url = format!("{}/v1/namespaces/{}/config", self.base_url, namespace);
1048        let response = self.client.get(&url).send().await?;
1049        self.handle_response(response).await
1050    }
1051
1052    /// Get the extractor provider configuration for a namespace.
1053    #[instrument(skip(self))]
1054    pub async fn get_namespace_extractor(
1055        &self,
1056        namespace: &str,
1057    ) -> Result<NamespaceExtractorConfig> {
1058        let url = format!("{}/v1/namespaces/{}/extractor", self.base_url, namespace);
1059        let response = self.client.get(&url).send().await?;
1060        self.handle_response(response).await
1061    }
1062
1063    /// Configure namespace-level entity extraction settings (CE-4).
1064    ///
1065    /// Sends `PATCH /v1/namespaces/{namespace}/config` with the provided
1066    /// [`NamespaceNerConfig`].
1067    #[instrument(skip(self, config))]
1068    pub async fn configure_namespace_ner(
1069        &self,
1070        namespace: &str,
1071        config: NamespaceNerConfig,
1072    ) -> Result<serde_json::Value> {
1073        let url = format!("{}/v1/namespaces/{}/config", self.base_url, namespace);
1074        let response = self.client.patch(&url).json(&config).send().await?;
1075        self.handle_response(response).await
1076    }
1077
1078    /// Extract entities from arbitrary text using the GLiNER pipeline (CE-4).
1079    ///
1080    /// Sends `POST /v1/memories/extract` with the supplied text and optional
1081    /// entity type list.
1082    #[instrument(skip(self, text, entity_types))]
1083    pub async fn extract_entities(
1084        &self,
1085        text: &str,
1086        entity_types: Option<Vec<String>>,
1087    ) -> Result<EntityExtractionResponse> {
1088        let url = format!("{}/v1/memories/extract", self.base_url);
1089        let body = serde_json::json!({
1090            "content": text,
1091            "entity_types": entity_types,
1092        });
1093        let response = self.client.post(&url).json(&body).send().await?;
1094        self.handle_response(response).await
1095    }
1096
1097    /// Retrieve entity tags associated with a stored memory (CE-4).
1098    ///
1099    /// Sends `GET /v1/memory/entities/{memory_id}`.
1100    #[instrument(skip(self))]
1101    pub async fn memory_entities(&self, memory_id: &str) -> Result<MemoryEntitiesResponse> {
1102        let url = format!("{}/v1/memory/entities/{}", self.base_url, memory_id);
1103        let response = self.client.get(&url).send().await?;
1104        self.handle_response(response).await
1105    }
1106
1107    // ========================================================================
1108    // Private Helpers
1109    // ========================================================================
1110
1111    /// Rate-limit headers from the most recent API response (OPS-1).
1112    ///
1113    /// Returns `None` until the first successful request has been made.
1114    pub fn last_rate_limit_headers(&self) -> Option<RateLimitHeaders> {
1115        self.last_rate_limit.lock().ok()?.clone()
1116    }
1117
1118    /// Handle response and deserialize JSON
1119    pub(crate) async fn handle_response<T: serde::de::DeserializeOwned>(
1120        &self,
1121        response: reqwest::Response,
1122    ) -> Result<T> {
1123        let status = response.status();
1124
1125        // OPS-1: capture rate-limit headers before consuming the response body
1126        if let Ok(mut guard) = self.last_rate_limit.lock() {
1127            *guard = Some(RateLimitHeaders::from_response(&response));
1128        }
1129
1130        if status.is_success() {
1131            Ok(response.json().await?)
1132        } else {
1133            let status_code = status.as_u16();
1134            // Extract Retry-After before consuming response
1135            let retry_after = response
1136                .headers()
1137                .get("Retry-After")
1138                .and_then(|v| v.to_str().ok())
1139                .and_then(|s| s.parse::<u64>().ok());
1140            let text = response.text().await.unwrap_or_default();
1141
1142            if status_code == 429 {
1143                return Err(ClientError::RateLimitExceeded { retry_after });
1144            }
1145
1146            #[derive(Deserialize)]
1147            struct ErrorBody {
1148                error: Option<String>,
1149                code: Option<ServerErrorCode>,
1150            }
1151
1152            let (message, code) = if let Ok(body) = serde_json::from_str::<ErrorBody>(&text) {
1153                (body.error.unwrap_or_else(|| text.clone()), body.code)
1154            } else {
1155                (text, None)
1156            };
1157
1158            match status_code {
1159                401 => Err(ClientError::Server {
1160                    status: 401,
1161                    message,
1162                    code,
1163                }),
1164                403 => Err(ClientError::Authorization {
1165                    status: 403,
1166                    message,
1167                    code,
1168                }),
1169                404 => match &code {
1170                    Some(ServerErrorCode::NamespaceNotFound) => {
1171                        Err(ClientError::NamespaceNotFound(message))
1172                    }
1173                    Some(ServerErrorCode::VectorNotFound) => {
1174                        Err(ClientError::VectorNotFound(message))
1175                    }
1176                    _ => Err(ClientError::Server {
1177                        status: 404,
1178                        message,
1179                        code,
1180                    }),
1181                },
1182                _ => Err(ClientError::Server {
1183                    status: status_code,
1184                    message,
1185                    code,
1186                }),
1187            }
1188        }
1189    }
1190
1191    /// Handle response and return raw text body (for non-JSON endpoints like /v1/ops/metrics).
1192    pub(crate) async fn handle_text_response(&self, response: reqwest::Response) -> Result<String> {
1193        let status = response.status();
1194
1195        // OPS-1: capture rate-limit headers before consuming the response body
1196        if let Ok(mut guard) = self.last_rate_limit.lock() {
1197            *guard = Some(RateLimitHeaders::from_response(&response));
1198        }
1199
1200        let retry_after = response
1201            .headers()
1202            .get("Retry-After")
1203            .and_then(|v| v.to_str().ok())
1204            .and_then(|s| s.parse::<u64>().ok());
1205        let text = response.text().await.unwrap_or_default();
1206
1207        if status.is_success() {
1208            return Ok(text);
1209        }
1210
1211        let status_code = status.as_u16();
1212
1213        if status_code == 429 {
1214            return Err(ClientError::RateLimitExceeded { retry_after });
1215        }
1216
1217        #[derive(Deserialize)]
1218        struct ErrorBody {
1219            error: Option<String>,
1220            code: Option<ServerErrorCode>,
1221        }
1222
1223        let (message, code) = if let Ok(body) = serde_json::from_str::<ErrorBody>(&text) {
1224            (body.error.unwrap_or_else(|| text.clone()), body.code)
1225        } else {
1226            (text, None)
1227        };
1228
1229        match status_code {
1230            401 => Err(ClientError::Server {
1231                status: 401,
1232                message,
1233                code,
1234            }),
1235            403 => Err(ClientError::Authorization {
1236                status: 403,
1237                message,
1238                code,
1239            }),
1240            _ => Err(ClientError::Server {
1241                status: status_code,
1242                message,
1243                code,
1244            }),
1245        }
1246    }
1247
1248    /// Execute a fallible async operation with retry logic and exponential backoff.
1249    ///
1250    /// Retries on transient errors (5xx, rate-limit, connection/timeout).
1251    /// Respects the `Retry-After` header when the server returns HTTP 429.
1252    /// Does NOT retry on 4xx client errors (except 429).
1253    ///
1254    /// NOTE: API call-site wiring is deferred to a follow-up (infrastructure PR).
1255    #[allow(dead_code)]
1256    pub(crate) async fn execute_with_retry<F, Fut, T>(&self, f: F) -> Result<T>
1257    where
1258        F: Fn() -> Fut,
1259        Fut: std::future::Future<Output = Result<T>>,
1260    {
1261        let rc = &self.retry_config;
1262
1263        for attempt in 0..rc.max_retries {
1264            match f().await {
1265                Ok(v) => return Ok(v),
1266                Err(e) => {
1267                    let is_last = attempt == rc.max_retries - 1;
1268                    if is_last || !e.is_retryable() {
1269                        return Err(e);
1270                    }
1271
1272                    let wait = match &e {
1273                        ClientError::RateLimitExceeded {
1274                            retry_after: Some(secs),
1275                        } => Duration::from_secs(*secs),
1276                        _ => {
1277                            let base_ms = rc.base_delay.as_millis() as f64;
1278                            let backoff_ms = base_ms * 2f64.powi(attempt as i32);
1279                            let capped_ms = backoff_ms.min(rc.max_delay.as_millis() as f64);
1280                            let final_ms = if rc.jitter {
1281                                // Simple deterministic jitter: vary between 50% and 150%
1282                                let seed = (attempt as u64).wrapping_mul(6364136223846793005);
1283                                let factor = 0.5 + (seed % 1000) as f64 / 1000.0;
1284                                capped_ms * factor
1285                            } else {
1286                                capped_ms
1287                            };
1288                            Duration::from_millis(final_ms as u64)
1289                        }
1290                    };
1291
1292                    tokio::time::sleep(wait).await;
1293                }
1294            }
1295        }
1296
1297        // Unreachable: the loop always returns on the last attempt
1298        Err(ClientError::Config("retry loop exhausted".to_string()))
1299    }
1300}
1301
1302// ============================================================================
1303// ODE-2: GLiNER Entity Extraction (dakera-ode sidecar)
1304// ============================================================================
1305
1306impl DakeraClient {
1307    /// Extract named entities from text using the GLiNER sidecar (ODE-2).
1308    ///
1309    /// Calls `POST /ode/extract` on the dakera-ode sidecar. Requires
1310    /// [`ode_url`][DakeraClientBuilder::ode_url] to be set on the builder.
1311    ///
1312    /// Unlike the CE-4 server-side NER, this method calls the dedicated GLiNER
1313    /// sidecar and returns character offsets, model name, and processing time.
1314    ///
1315    /// # Errors
1316    ///
1317    /// Returns [`ClientError::Config`] if `ode_url` is not configured.
1318    pub async fn ode_extract_entities(
1319        &self,
1320        req: ExtractEntitiesRequest,
1321    ) -> Result<ExtractEntitiesResponse> {
1322        let ode_url = self.ode_url.as_deref().ok_or_else(|| {
1323            ClientError::Config(
1324                "ode_url must be configured to use extract_entities(). \
1325                 Call .ode_url(\"http://localhost:8080\") on the builder."
1326                    .to_string(),
1327            )
1328        })?;
1329        let url = format!("{}/ode/extract", ode_url);
1330        let response = self.client.post(&url).json(&req).send().await?;
1331        if response.status().is_success() {
1332            Ok(response.json::<ExtractEntitiesResponse>().await?)
1333        } else {
1334            let status = response.status().as_u16();
1335            let body = response.text().await.unwrap_or_default();
1336            Err(ClientError::Server {
1337                status,
1338                message: format!("ODE sidecar error: {}", body),
1339                code: None,
1340            })
1341        }
1342    }
1343
1344    // ========================================================================
1345    // COG-1: Per-namespace Memory Lifecycle Policy
1346    // ========================================================================
1347
1348    /// Return the memory lifecycle policy for a namespace (COG-1).
1349    ///
1350    /// Sends `GET /v1/namespaces/{namespace}/memory_policy`.
1351    ///
1352    /// When no explicit policy has been configured the server returns the COG-1
1353    /// defaults: working=4 h, episodic=30 d, semantic=365 d, procedural=730 d;
1354    /// exponential/power_law/logarithmic/flat decay curves; SR factor 1.0.
1355    #[instrument(skip(self))]
1356    pub async fn get_memory_policy(&self, namespace: &str) -> Result<MemoryPolicy> {
1357        let url = format!(
1358            "{}/v1/namespaces/{}/memory_policy",
1359            self.base_url,
1360            urlencoding::encode(namespace)
1361        );
1362        let response = self.client.get(&url).send().await?;
1363        self.handle_response(response).await
1364    }
1365
1366    /// Set the memory lifecycle policy for a namespace (COG-1).
1367    ///
1368    /// Sends `PUT /v1/namespaces/{namespace}/memory_policy`.
1369    ///
1370    /// The policy is persisted and applied immediately to the decay engine.
1371    /// Only populate the fields you want to override — all have safe defaults.
1372    #[instrument(skip(self, policy))]
1373    pub async fn set_memory_policy(
1374        &self,
1375        namespace: &str,
1376        policy: MemoryPolicy,
1377    ) -> Result<MemoryPolicy> {
1378        let url = format!(
1379            "{}/v1/namespaces/{}/memory_policy",
1380            self.base_url,
1381            urlencoding::encode(namespace)
1382        );
1383        let response = self.client.put(&url).json(&policy).send().await?;
1384        self.handle_response(response).await
1385    }
1386}
1387
1388/// Builder for DakeraClient
1389#[derive(Debug)]
1390pub struct DakeraClientBuilder {
1391    base_url: String,
1392    api_key: Option<String>,
1393    ode_url: Option<String>,
1394    timeout: Duration,
1395    connect_timeout: Option<Duration>,
1396    retry_config: RetryConfig,
1397    user_agent: Option<String>,
1398}
1399
1400impl DakeraClientBuilder {
1401    /// Create a new builder
1402    pub fn new(base_url: impl Into<String>) -> Self {
1403        Self {
1404            base_url: base_url.into(),
1405            api_key: None,
1406            ode_url: None,
1407            timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
1408            connect_timeout: None,
1409            retry_config: RetryConfig::default(),
1410            user_agent: None,
1411        }
1412    }
1413
1414    /// Set the API key for Bearer authentication.
1415    ///
1416    /// If not set explicitly, the builder will try to read `DAKERA_API_KEY`
1417    /// from the environment at build time.
1418    pub fn api_key(mut self, key: impl Into<String>) -> Self {
1419        self.api_key = Some(key.into());
1420        self
1421    }
1422
1423    /// Set the base URL of the dakera-ode sidecar (ODE-2).
1424    ///
1425    /// Required to call [`DakeraClient::extract_entities`].
1426    pub fn ode_url(mut self, ode_url: impl Into<String>) -> Self {
1427        self.ode_url = Some(ode_url.into().trim_end_matches('/').to_string());
1428        self
1429    }
1430
1431    /// Set the request timeout
1432    pub fn timeout(mut self, timeout: Duration) -> Self {
1433        self.timeout = timeout;
1434        self
1435    }
1436
1437    /// Set the request timeout in seconds
1438    pub fn timeout_secs(mut self, secs: u64) -> Self {
1439        self.timeout = Duration::from_secs(secs);
1440        self
1441    }
1442
1443    /// Set the connection establishment timeout (defaults to `timeout`).
1444    pub fn connect_timeout(mut self, timeout: Duration) -> Self {
1445        self.connect_timeout = Some(timeout);
1446        self
1447    }
1448
1449    /// Set fine-grained retry configuration.
1450    pub fn retry_config(mut self, config: RetryConfig) -> Self {
1451        self.retry_config = config;
1452        self
1453    }
1454
1455    /// Set the maximum number of retry attempts.
1456    pub fn max_retries(mut self, max_retries: u32) -> Self {
1457        self.retry_config.max_retries = max_retries;
1458        self
1459    }
1460
1461    /// Set a custom user agent
1462    pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
1463        self.user_agent = Some(user_agent.into());
1464        self
1465    }
1466
1467    /// Build the client
1468    pub fn build(self) -> Result<DakeraClient> {
1469        // Normalize base URL (remove trailing slash)
1470        let base_url = self.base_url.trim_end_matches('/').to_string();
1471
1472        // Validate URL
1473        if !base_url.starts_with("http://") && !base_url.starts_with("https://") {
1474            return Err(ClientError::InvalidUrl(
1475                "URL must start with http:// or https://".to_string(),
1476            ));
1477        }
1478
1479        let user_agent = self
1480            .user_agent
1481            .unwrap_or_else(|| format!("dakera-client/{}", env!("CARGO_PKG_VERSION")));
1482
1483        let connect_timeout = self.connect_timeout.unwrap_or(self.timeout);
1484
1485        // Resolve API key: explicit > DAKERA_API_KEY env var
1486        let api_key = self
1487            .api_key
1488            .or_else(|| std::env::var("DAKERA_API_KEY").ok());
1489
1490        let mut default_headers = HeaderMap::new();
1491        if let Some(key) = &api_key {
1492            let bearer = format!("Bearer {key}");
1493            let mut value = HeaderValue::from_str(&bearer)
1494                .map_err(|_| ClientError::Config("invalid API key".into()))?;
1495            value.set_sensitive(true);
1496            default_headers.insert(AUTHORIZATION, value);
1497        }
1498
1499        let client = Client::builder()
1500            .timeout(self.timeout)
1501            .connect_timeout(connect_timeout)
1502            .user_agent(user_agent)
1503            .default_headers(default_headers)
1504            .build()
1505            .map_err(|e| ClientError::Config(e.to_string()))?;
1506
1507        Ok(DakeraClient {
1508            client,
1509            base_url,
1510            ode_url: self.ode_url,
1511            retry_config: self.retry_config,
1512            last_rate_limit: Arc::new(Mutex::new(None)),
1513        })
1514    }
1515}
1516
1517// ============================================================================
1518// SSE Streaming (CE-1)
1519// ============================================================================
1520
1521impl DakeraClient {
1522    /// Subscribe to namespace-scoped SSE events.
1523    ///
1524    /// Opens a long-lived connection to `GET /v1/namespaces/{namespace}/events`
1525    /// and returns a [`tokio::sync::mpsc::Receiver`] that yields
1526    /// [`DakeraEvent`] results as they arrive.  The background task exits when
1527    /// the server closes the stream or the receiver is dropped.
1528    ///
1529    /// Requires a Read-scoped API key.
1530    ///
1531    /// # Example
1532    ///
1533    /// ```rust,no_run
1534    /// use dakera_client::DakeraClient;
1535    ///
1536    /// #[tokio::main]
1537    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
1538    ///     let client = DakeraClient::new("http://localhost:3000")?;
1539    ///     let mut rx = client.stream_namespace_events("my-ns").await?;
1540    ///     while let Some(result) = rx.recv().await {
1541    ///         println!("{:?}", result?);
1542    ///     }
1543    ///     Ok(())
1544    /// }
1545    /// ```
1546    pub async fn stream_namespace_events(
1547        &self,
1548        namespace: &str,
1549    ) -> Result<tokio::sync::mpsc::Receiver<Result<crate::events::DakeraEvent>>> {
1550        let url = format!(
1551            "{}/v1/namespaces/{}/events",
1552            self.base_url,
1553            urlencoding::encode(namespace)
1554        );
1555        self.stream_sse(url).await
1556    }
1557
1558    /// Subscribe to the global SSE event stream (all namespaces).
1559    ///
1560    /// Opens a long-lived connection to `GET /ops/events` and returns a
1561    /// [`tokio::sync::mpsc::Receiver`] that yields [`DakeraEvent`] results.
1562    ///
1563    /// Requires an Admin-scoped API key.
1564    pub async fn stream_global_events(
1565        &self,
1566    ) -> Result<tokio::sync::mpsc::Receiver<Result<crate::events::DakeraEvent>>> {
1567        let url = format!("{}/ops/events", self.base_url);
1568        self.stream_sse(url).await
1569    }
1570
1571    /// Subscribe to the memory lifecycle SSE event stream (DASH-B).
1572    ///
1573    /// Opens a long-lived connection to `GET /v1/events/stream` and returns a
1574    /// [`tokio::sync::mpsc::Receiver`] that yields [`MemoryEvent`] results as
1575    /// they arrive.  The background task exits when the server closes the stream
1576    /// or the receiver is dropped.
1577    ///
1578    /// Requires a Read-scoped API key.
1579    pub async fn stream_memory_events(
1580        &self,
1581    ) -> Result<tokio::sync::mpsc::Receiver<Result<crate::events::MemoryEvent>>> {
1582        let url = format!("{}/v1/events/stream", self.base_url);
1583        self.stream_sse(url).await
1584    }
1585
1586    /// Low-level generic SSE streaming helper.
1587    pub(crate) async fn stream_sse<T>(
1588        &self,
1589        url: String,
1590    ) -> Result<tokio::sync::mpsc::Receiver<Result<T>>>
1591    where
1592        T: serde::de::DeserializeOwned + Send + 'static,
1593    {
1594        use futures_util::StreamExt;
1595
1596        let response = self
1597            .client
1598            .get(&url)
1599            .header("Accept", "text/event-stream")
1600            .header("Cache-Control", "no-cache")
1601            .send()
1602            .await?;
1603
1604        if !response.status().is_success() {
1605            let status = response.status().as_u16();
1606            let body = response.text().await.unwrap_or_default();
1607            return Err(ClientError::Server {
1608                status,
1609                message: body,
1610                code: None,
1611            });
1612        }
1613
1614        let (tx, rx) = tokio::sync::mpsc::channel(64);
1615
1616        tokio::spawn(async move {
1617            let mut byte_stream = response.bytes_stream();
1618            let mut remaining = String::new();
1619            let mut data_lines: Vec<String> = Vec::new();
1620
1621            while let Some(chunk) = byte_stream.next().await {
1622                match chunk {
1623                    Ok(bytes) => {
1624                        remaining.push_str(&String::from_utf8_lossy(&bytes));
1625                        while let Some(pos) = remaining.find('\n') {
1626                            let raw = &remaining[..pos];
1627                            let line = raw.trim_end_matches('\r').to_string();
1628                            remaining = remaining[pos + 1..].to_string();
1629
1630                            if line.starts_with(':') {
1631                                // SSE comment / heartbeat — skip
1632                            } else if let Some(data) = line.strip_prefix("data:") {
1633                                data_lines.push(data.trim_start().to_string());
1634                            } else if line.is_empty() {
1635                                if !data_lines.is_empty() {
1636                                    let payload = data_lines.join("\n");
1637                                    data_lines.clear();
1638                                    let result = serde_json::from_str::<T>(&payload)
1639                                        .map_err(ClientError::Json);
1640                                    if tx.send(result).await.is_err() {
1641                                        return; // receiver dropped
1642                                    }
1643                                }
1644                            } else {
1645                                // Unrecognised field (e.g. "event:") — ignore
1646                            }
1647                        }
1648                    }
1649                    Err(e) => {
1650                        let _ = tx.send(Err(ClientError::Http(e))).await;
1651                        return;
1652                    }
1653                }
1654            }
1655        });
1656
1657        Ok(rx)
1658    }
1659
1660    // ========================================================================
1661    // Route Query
1662    // ========================================================================
1663
1664    /// Route a query to the best-matching namespaces via `POST /v1/route`.
1665    #[instrument(skip(self, request))]
1666    pub async fn route_query(&self, request: RouteRequest) -> Result<RouteResponse> {
1667        let url = format!("{}/v1/route", self.base_url);
1668        let response = self.client.post(&url).json(&request).send().await?;
1669        self.handle_response(response).await
1670    }
1671
1672    // ========================================================================
1673    // Import Job Status
1674    // ========================================================================
1675
1676    /// Get import job status via `GET /v1/import/{job_id}/status`.
1677    #[instrument(skip(self))]
1678    pub async fn import_job_status(&self, job_id: &str) -> Result<ImportJobStatus> {
1679        let url = format!("{}/v1/import/{}/status", self.base_url, job_id);
1680        let response = self.client.get(&url).send().await?;
1681        self.handle_response(response).await
1682    }
1683}
1684
1685#[cfg(test)]
1686mod tests {
1687    use super::*;
1688
1689    #[test]
1690    fn test_client_builder() {
1691        let client = DakeraClient::new("http://localhost:3000");
1692        assert!(client.is_ok());
1693    }
1694
1695    #[test]
1696    fn test_client_builder_with_options() {
1697        let client = DakeraClient::builder("http://localhost:3000")
1698            .timeout_secs(60)
1699            .user_agent("test-client/1.0")
1700            .build();
1701        assert!(client.is_ok());
1702    }
1703
1704    #[test]
1705    fn test_client_builder_invalid_url() {
1706        let client = DakeraClient::new("invalid-url");
1707        assert!(client.is_err());
1708    }
1709
1710    #[test]
1711    fn test_client_builder_trailing_slash() {
1712        let client = DakeraClient::new("http://localhost:3000/").unwrap();
1713        assert!(!client.base_url.ends_with('/'));
1714    }
1715
1716    #[test]
1717    fn test_vector_creation() {
1718        let v = Vector::new("test", vec![0.1, 0.2, 0.3]);
1719        assert_eq!(v.id, "test");
1720        assert_eq!(v.values.len(), 3);
1721        assert!(v.metadata.is_none());
1722    }
1723
1724    #[test]
1725    fn test_query_request_builder() {
1726        let req = QueryRequest::new(vec![0.1, 0.2], 10)
1727            .with_filter(serde_json::json!({"category": "test"}))
1728            .include_metadata(false);
1729
1730        assert_eq!(req.top_k, 10);
1731        assert!(req.filter.is_some());
1732        assert!(!req.include_metadata);
1733    }
1734
1735    #[test]
1736    fn test_hybrid_search_request() {
1737        let req = HybridSearchRequest::new(vec![0.1], "test query", 5).with_vector_weight(0.7);
1738
1739        assert_eq!(req.vector_weight, 0.7);
1740        assert_eq!(req.text, "test query");
1741        assert!(req.vector.is_some());
1742    }
1743
1744    #[test]
1745    fn test_hybrid_search_weight_clamping() {
1746        let req = HybridSearchRequest::new(vec![0.1], "test", 5).with_vector_weight(1.5); // Should be clamped to 1.0
1747
1748        assert_eq!(req.vector_weight, 1.0);
1749    }
1750
1751    #[test]
1752    fn test_hybrid_search_text_only() {
1753        let req = HybridSearchRequest::text_only("bm25 query", 10);
1754
1755        assert!(req.vector.is_none());
1756        assert_eq!(req.text, "bm25 query");
1757        assert_eq!(req.top_k, 10);
1758        // Verify vector is not serialised
1759        let json = serde_json::to_value(&req).unwrap();
1760        assert!(json.get("vector").is_none());
1761    }
1762
1763    #[test]
1764    fn test_text_document_builder() {
1765        let doc = TextDocument::new("doc1", "Hello world").with_ttl(3600);
1766
1767        assert_eq!(doc.id, "doc1");
1768        assert_eq!(doc.text, "Hello world");
1769        assert_eq!(doc.ttl_seconds, Some(3600));
1770        assert!(doc.metadata.is_none());
1771    }
1772
1773    #[test]
1774    fn test_upsert_text_request_builder() {
1775        let docs = vec![
1776            TextDocument::new("doc1", "Hello"),
1777            TextDocument::new("doc2", "World"),
1778        ];
1779        let req = UpsertTextRequest::new(docs).with_model(EmbeddingModel::BgeSmall);
1780
1781        assert_eq!(req.documents.len(), 2);
1782        assert_eq!(req.model, Some(EmbeddingModel::BgeSmall));
1783    }
1784
1785    #[test]
1786    fn test_query_text_request_builder() {
1787        let req = QueryTextRequest::new("semantic search query", 5)
1788            .with_filter(serde_json::json!({"category": "docs"}))
1789            .include_vectors(true)
1790            .with_model(EmbeddingModel::E5Small);
1791
1792        assert_eq!(req.text, "semantic search query");
1793        assert_eq!(req.top_k, 5);
1794        assert!(req.filter.is_some());
1795        assert!(req.include_vectors);
1796        assert_eq!(req.model, Some(EmbeddingModel::E5Small));
1797    }
1798
1799    #[test]
1800    fn test_fetch_request_builder() {
1801        let req = FetchRequest::new(vec!["id1".to_string(), "id2".to_string()]);
1802
1803        assert_eq!(req.ids.len(), 2);
1804        assert!(req.include_values);
1805        assert!(req.include_metadata);
1806    }
1807
1808    #[test]
1809    fn test_create_namespace_request_builder() {
1810        let req = CreateNamespaceRequest::new()
1811            .with_dimensions(384)
1812            .with_index_type("hnsw");
1813
1814        assert_eq!(req.dimensions, Some(384));
1815        assert_eq!(req.index_type.as_deref(), Some("hnsw"));
1816    }
1817
1818    #[test]
1819    fn test_batch_query_text_request() {
1820        let req =
1821            BatchQueryTextRequest::new(vec!["query one".to_string(), "query two".to_string()], 10);
1822
1823        assert_eq!(req.queries.len(), 2);
1824        assert_eq!(req.top_k, 10);
1825        assert!(!req.include_vectors);
1826        assert!(req.model.is_none());
1827    }
1828
1829    // =========================================================================
1830    // RetryConfig tests
1831    // =========================================================================
1832
1833    #[test]
1834    fn test_retry_config_defaults() {
1835        let rc = RetryConfig::default();
1836        assert_eq!(rc.max_retries, 3);
1837        assert_eq!(rc.base_delay, Duration::from_millis(100));
1838        assert_eq!(rc.max_delay, Duration::from_secs(60));
1839        assert!(rc.jitter);
1840    }
1841
1842    #[test]
1843    fn test_builder_connect_timeout() {
1844        let client = DakeraClient::builder("http://localhost:3000")
1845            .connect_timeout(Duration::from_secs(5))
1846            .timeout_secs(30)
1847            .build()
1848            .unwrap();
1849        // Client was built successfully with separate connect timeout
1850        assert!(client.base_url.starts_with("http"));
1851    }
1852
1853    #[test]
1854    fn test_builder_max_retries() {
1855        let client = DakeraClient::builder("http://localhost:3000")
1856            .max_retries(5)
1857            .build()
1858            .unwrap();
1859        assert_eq!(client.retry_config.max_retries, 5);
1860    }
1861
1862    #[test]
1863    fn test_builder_retry_config() {
1864        let rc = RetryConfig {
1865            max_retries: 7,
1866            base_delay: Duration::from_millis(200),
1867            max_delay: Duration::from_secs(30),
1868            jitter: false,
1869        };
1870        let client = DakeraClient::builder("http://localhost:3000")
1871            .retry_config(rc)
1872            .build()
1873            .unwrap();
1874        assert_eq!(client.retry_config.max_retries, 7);
1875        assert!(!client.retry_config.jitter);
1876    }
1877
1878    #[test]
1879    fn test_rate_limit_error_retryable() {
1880        let e = ClientError::RateLimitExceeded { retry_after: None };
1881        assert!(e.is_retryable());
1882    }
1883
1884    #[test]
1885    fn test_server_408_retryable() {
1886        let e = ClientError::Server {
1887            status: 408,
1888            message: String::new(),
1889            code: None,
1890        };
1891        assert!(e.is_retryable());
1892    }
1893
1894    #[test]
1895    fn test_server_400_not_retryable() {
1896        let e = ClientError::Server {
1897            status: 400,
1898            message: String::new(),
1899            code: None,
1900        };
1901        assert!(!e.is_retryable());
1902    }
1903
1904    #[test]
1905    fn test_rate_limit_error_with_retry_after_zero() {
1906        // retry_after: Some(0) should still be Some, not treated as missing
1907        let e = ClientError::RateLimitExceeded {
1908            retry_after: Some(0),
1909        };
1910        assert!(e.is_retryable());
1911        if let ClientError::RateLimitExceeded {
1912            retry_after: Some(secs),
1913        } = &e
1914        {
1915            assert_eq!(*secs, 0u64);
1916        } else {
1917            panic!("unexpected variant");
1918        }
1919    }
1920
1921    #[tokio::test]
1922    async fn test_execute_with_retry_succeeds_immediately() {
1923        let client = DakeraClient::builder("http://localhost:3000")
1924            .max_retries(3)
1925            .build()
1926            .unwrap();
1927
1928        let call_count = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
1929        let cc = call_count.clone();
1930        let result = client
1931            .execute_with_retry(|| {
1932                let cc = cc.clone();
1933                async move {
1934                    cc.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1935                    Ok::<u32, ClientError>(42)
1936                }
1937            })
1938            .await;
1939        assert_eq!(result.unwrap(), 42);
1940        assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 1);
1941    }
1942
1943    #[tokio::test]
1944    async fn test_execute_with_retry_no_retry_on_4xx() {
1945        let client = DakeraClient::builder("http://localhost:3000")
1946            .max_retries(3)
1947            .build()
1948            .unwrap();
1949
1950        let call_count = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
1951        let cc = call_count.clone();
1952        let result = client
1953            .execute_with_retry(|| {
1954                let cc = cc.clone();
1955                async move {
1956                    cc.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1957                    Err::<u32, ClientError>(ClientError::Server {
1958                        status: 400,
1959                        message: "bad request".to_string(),
1960                        code: None,
1961                    })
1962                }
1963            })
1964            .await;
1965        assert!(result.is_err());
1966        // Should not retry on 4xx
1967        assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 1);
1968    }
1969
1970    #[tokio::test]
1971    async fn test_execute_with_retry_retries_on_5xx() {
1972        let client = DakeraClient::builder("http://localhost:3000")
1973            .retry_config(RetryConfig {
1974                max_retries: 3,
1975                base_delay: Duration::from_millis(0),
1976                max_delay: Duration::from_millis(0),
1977                jitter: false,
1978            })
1979            .build()
1980            .unwrap();
1981
1982        let call_count = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
1983        let cc = call_count.clone();
1984        let result = client
1985            .execute_with_retry(|| {
1986                let cc = cc.clone();
1987                async move {
1988                    let n = cc.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1989                    if n < 2 {
1990                        Err::<u32, ClientError>(ClientError::Server {
1991                            status: 503,
1992                            message: "unavailable".to_string(),
1993                            code: None,
1994                        })
1995                    } else {
1996                        Ok(99)
1997                    }
1998                }
1999            })
2000            .await;
2001        assert_eq!(result.unwrap(), 99);
2002        assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 3);
2003    }
2004
2005    // =========================================================================
2006    // CE-2: Batch Recall / Forget (v0.7.0)
2007    // =========================================================================
2008
2009    #[test]
2010    fn test_batch_recall_request_new() {
2011        use crate::memory::BatchRecallRequest;
2012        let req = BatchRecallRequest::new("agent-1");
2013        assert_eq!(req.agent_id, "agent-1");
2014        assert_eq!(req.limit, 100);
2015    }
2016
2017    #[test]
2018    fn test_batch_recall_request_builder() {
2019        use crate::memory::{BatchMemoryFilter, BatchRecallRequest};
2020        let filter = BatchMemoryFilter::default()
2021            .with_tags(vec!["qa".to_string()])
2022            .with_min_importance(0.7);
2023        let req = BatchRecallRequest::new("agent-1")
2024            .with_filter(filter)
2025            .with_limit(50);
2026        assert_eq!(req.agent_id, "agent-1");
2027        assert_eq!(req.limit, 50);
2028        assert_eq!(
2029            req.filter.tags.as_deref(),
2030            Some(["qa".to_string()].as_slice())
2031        );
2032        assert_eq!(req.filter.min_importance, Some(0.7));
2033    }
2034
2035    #[test]
2036    fn test_batch_recall_request_serialization() {
2037        use crate::memory::{BatchMemoryFilter, BatchRecallRequest};
2038        let filter = BatchMemoryFilter::default().with_min_importance(0.5);
2039        let req = BatchRecallRequest::new("agent-1")
2040            .with_filter(filter)
2041            .with_limit(25);
2042        let json = serde_json::to_value(&req).unwrap();
2043        assert_eq!(json["agent_id"], "agent-1");
2044        assert_eq!(json["limit"], 25);
2045        assert_eq!(json["filter"]["min_importance"], 0.5);
2046    }
2047
2048    #[test]
2049    fn test_batch_forget_request_new() {
2050        use crate::memory::{BatchForgetRequest, BatchMemoryFilter};
2051        let filter = BatchMemoryFilter::default().with_min_importance(0.1);
2052        let req = BatchForgetRequest::new("agent-1", filter);
2053        assert_eq!(req.agent_id, "agent-1");
2054        assert_eq!(req.filter.min_importance, Some(0.1));
2055    }
2056
2057    #[test]
2058    fn test_batch_forget_request_serialization() {
2059        use crate::memory::{BatchForgetRequest, BatchMemoryFilter};
2060        let filter = BatchMemoryFilter {
2061            created_before: Some(1_700_000_000),
2062            ..Default::default()
2063        };
2064        let req = BatchForgetRequest::new("agent-1", filter);
2065        let json = serde_json::to_value(&req).unwrap();
2066        assert_eq!(json["agent_id"], "agent-1");
2067        assert_eq!(json["filter"]["created_before"], 1_700_000_000u64);
2068    }
2069
2070    #[test]
2071    fn test_batch_recall_response_deserialization() {
2072        use crate::memory::BatchRecallResponse;
2073        let json = serde_json::json!({
2074            "memories": [],
2075            "total": 42,
2076            "filtered": 7
2077        });
2078        let resp: BatchRecallResponse = serde_json::from_value(json).unwrap();
2079        assert_eq!(resp.total, 42);
2080        assert_eq!(resp.filtered, 7);
2081        assert!(resp.memories.is_empty());
2082    }
2083
2084    #[test]
2085    fn test_batch_forget_response_deserialization() {
2086        use crate::memory::BatchForgetResponse;
2087        let json = serde_json::json!({ "deleted_count": 13 });
2088        let resp: BatchForgetResponse = serde_json::from_value(json).unwrap();
2089        assert_eq!(resp.deleted_count, 13);
2090    }
2091
2092    // =========================================================================
2093    // OPS-1: RateLimitHeaders (v0.7.0)
2094    // =========================================================================
2095
2096    #[test]
2097    fn test_rate_limit_headers_default_all_none() {
2098        use crate::types::RateLimitHeaders;
2099        let rl = RateLimitHeaders {
2100            limit: None,
2101            remaining: None,
2102            reset: None,
2103            quota_used: None,
2104            quota_limit: None,
2105        };
2106        assert!(rl.limit.is_none());
2107        assert!(rl.remaining.is_none());
2108        assert!(rl.reset.is_none());
2109        assert!(rl.quota_used.is_none());
2110        assert!(rl.quota_limit.is_none());
2111    }
2112
2113    #[test]
2114    fn test_rate_limit_headers_populated() {
2115        use crate::types::RateLimitHeaders;
2116        let rl = RateLimitHeaders {
2117            limit: Some(1000),
2118            remaining: Some(750),
2119            reset: Some(1_700_000_060),
2120            quota_used: Some(500),
2121            quota_limit: Some(10_000),
2122        };
2123        assert_eq!(rl.limit, Some(1000));
2124        assert_eq!(rl.remaining, Some(750));
2125        assert_eq!(rl.reset, Some(1_700_000_060));
2126        assert_eq!(rl.quota_used, Some(500));
2127        assert_eq!(rl.quota_limit, Some(10_000));
2128    }
2129
2130    #[test]
2131    fn test_last_rate_limit_headers_initially_none() {
2132        let client = DakeraClient::new("http://localhost:3000").unwrap();
2133        assert!(client.last_rate_limit_headers().is_none());
2134    }
2135
2136    // =========================================================================
2137    // CE-4: GLiNER Entity Extraction
2138    // =========================================================================
2139
2140    #[test]
2141    fn test_namespace_ner_config_default() {
2142        use crate::types::NamespaceNerConfig;
2143        let cfg = NamespaceNerConfig::default();
2144        assert!(!cfg.extract_entities);
2145        assert!(cfg.entity_types.is_none());
2146    }
2147
2148    #[test]
2149    fn test_namespace_ner_config_serialization_skip_none() {
2150        use crate::types::NamespaceNerConfig;
2151        let cfg = NamespaceNerConfig {
2152            extract_entities: true,
2153            entity_types: None,
2154        };
2155        let json = serde_json::to_value(&cfg).unwrap();
2156        assert_eq!(json["extract_entities"], true);
2157        // entity_types should be omitted when None
2158        assert!(json.get("entity_types").is_none());
2159    }
2160
2161    #[test]
2162    fn test_namespace_ner_config_serialization_with_types() {
2163        use crate::types::NamespaceNerConfig;
2164        let cfg = NamespaceNerConfig {
2165            extract_entities: true,
2166            entity_types: Some(vec!["PERSON".to_string(), "ORG".to_string()]),
2167        };
2168        let json = serde_json::to_value(&cfg).unwrap();
2169        assert_eq!(json["extract_entities"], true);
2170        assert_eq!(json["entity_types"][0], "PERSON");
2171        assert_eq!(json["entity_types"][1], "ORG");
2172    }
2173
2174    #[test]
2175    fn test_extracted_entity_deserialization() {
2176        use crate::types::ExtractedEntity;
2177        let json = serde_json::json!({
2178            "entity_type": "PERSON",
2179            "value": "Alice",
2180            "score": 0.95
2181        });
2182        let entity: ExtractedEntity = serde_json::from_value(json).unwrap();
2183        assert_eq!(entity.entity_type, "PERSON");
2184        assert_eq!(entity.value, "Alice");
2185        assert!((entity.score - 0.95).abs() < f64::EPSILON);
2186    }
2187
2188    #[test]
2189    fn test_entity_extraction_response_deserialization() {
2190        use crate::types::EntityExtractionResponse;
2191        let json = serde_json::json!({
2192            "entities": [
2193                { "entity_type": "PERSON", "value": "Bob", "score": 0.9 },
2194                { "entity_type": "ORG",    "value": "Acme", "score": 0.87 }
2195            ]
2196        });
2197        let resp: EntityExtractionResponse = serde_json::from_value(json).unwrap();
2198        assert_eq!(resp.entities.len(), 2);
2199        assert_eq!(resp.entities[0].entity_type, "PERSON");
2200        assert_eq!(resp.entities[1].value, "Acme");
2201    }
2202
2203    #[test]
2204    fn test_memory_entities_response_deserialization() {
2205        use crate::types::MemoryEntitiesResponse;
2206        let json = serde_json::json!({
2207            "memory_id": "mem-abc-123",
2208            "entities": [
2209                { "entity_type": "LOC", "value": "London", "score": 0.88 }
2210            ]
2211        });
2212        let resp: MemoryEntitiesResponse = serde_json::from_value(json).unwrap();
2213        assert_eq!(resp.memory_id, "mem-abc-123");
2214        assert_eq!(resp.entities.len(), 1);
2215        assert_eq!(resp.entities[0].entity_type, "LOC");
2216        assert_eq!(resp.entities[0].value, "London");
2217    }
2218
2219    #[test]
2220    fn test_configure_namespace_ner_url_pattern() {
2221        // Verify the client is constructable and base_url is correct
2222        let client = DakeraClient::new("http://localhost:3000").unwrap();
2223        let expected = "http://localhost:3000/v1/namespaces/my-ns/config";
2224        let actual = format!("{}/v1/namespaces/{}/config", client.base_url, "my-ns");
2225        assert_eq!(actual, expected);
2226    }
2227
2228    #[test]
2229    fn test_extract_entities_url_pattern() {
2230        let client = DakeraClient::new("http://localhost:3000").unwrap();
2231        let expected = "http://localhost:3000/v1/memories/extract";
2232        let actual = format!("{}/v1/memories/extract", client.base_url);
2233        assert_eq!(actual, expected);
2234    }
2235
2236    #[test]
2237    fn test_memory_entities_url_pattern() {
2238        let client = DakeraClient::new("http://localhost:3000").unwrap();
2239        let memory_id = "mem-xyz-789";
2240        let expected = "http://localhost:3000/v1/memory/entities/mem-xyz-789";
2241        let actual = format!("{}/v1/memory/entities/{}", client.base_url, memory_id);
2242        assert_eq!(actual, expected);
2243    }
2244
2245    // ========================================================================
2246    // INT-1 Memory Feedback Loop tests
2247    // ========================================================================
2248
2249    #[test]
2250    fn test_feedback_signal_serialization() {
2251        use crate::types::FeedbackSignal;
2252        let upvote = serde_json::to_value(FeedbackSignal::Upvote).unwrap();
2253        assert_eq!(upvote, serde_json::json!("upvote"));
2254        let downvote = serde_json::to_value(FeedbackSignal::Downvote).unwrap();
2255        assert_eq!(downvote, serde_json::json!("downvote"));
2256        let flag = serde_json::to_value(FeedbackSignal::Flag).unwrap();
2257        assert_eq!(flag, serde_json::json!("flag"));
2258    }
2259
2260    #[test]
2261    fn test_feedback_signal_deserialization() {
2262        use crate::types::FeedbackSignal;
2263        let signal: FeedbackSignal = serde_json::from_str("\"upvote\"").unwrap();
2264        assert_eq!(signal, FeedbackSignal::Upvote);
2265        let signal: FeedbackSignal = serde_json::from_str("\"positive\"").unwrap();
2266        assert_eq!(signal, FeedbackSignal::Positive);
2267    }
2268
2269    #[test]
2270    fn test_feedback_response_deserialization() {
2271        use crate::types::{FeedbackResponse, FeedbackSignal};
2272        let json = serde_json::json!({
2273            "memory_id": "mem-abc",
2274            "new_importance": 0.92,
2275            "signal": "upvote"
2276        });
2277        let resp: FeedbackResponse = serde_json::from_value(json).unwrap();
2278        assert_eq!(resp.memory_id, "mem-abc");
2279        assert!((resp.new_importance - 0.92).abs() < f32::EPSILON);
2280        assert_eq!(resp.signal, FeedbackSignal::Upvote);
2281    }
2282
2283    #[test]
2284    fn test_feedback_history_response_deserialization() {
2285        use crate::types::{FeedbackHistoryResponse, FeedbackSignal};
2286        let json = serde_json::json!({
2287            "memory_id": "mem-abc",
2288            "entries": [
2289                {"signal": "upvote", "timestamp": 1774000000_u64, "old_importance": 0.5, "new_importance": 0.575},
2290                {"signal": "downvote", "timestamp": 1774001000_u64, "old_importance": 0.575, "new_importance": 0.489}
2291            ]
2292        });
2293        let resp: FeedbackHistoryResponse = serde_json::from_value(json).unwrap();
2294        assert_eq!(resp.memory_id, "mem-abc");
2295        assert_eq!(resp.entries.len(), 2);
2296        assert_eq!(resp.entries[0].signal, FeedbackSignal::Upvote);
2297        assert_eq!(resp.entries[1].signal, FeedbackSignal::Downvote);
2298    }
2299
2300    #[test]
2301    fn test_agent_feedback_summary_deserialization() {
2302        use crate::types::AgentFeedbackSummary;
2303        let json = serde_json::json!({
2304            "agent_id": "agent-1",
2305            "upvotes": 42_u64,
2306            "downvotes": 7_u64,
2307            "flags": 2_u64,
2308            "total_feedback": 51_u64,
2309            "health_score": 0.78
2310        });
2311        let summary: AgentFeedbackSummary = serde_json::from_value(json).unwrap();
2312        assert_eq!(summary.agent_id, "agent-1");
2313        assert_eq!(summary.upvotes, 42);
2314        assert_eq!(summary.total_feedback, 51);
2315        assert!((summary.health_score - 0.78).abs() < f32::EPSILON);
2316    }
2317
2318    #[test]
2319    fn test_feedback_health_response_deserialization() {
2320        use crate::types::FeedbackHealthResponse;
2321        let json = serde_json::json!({
2322            "agent_id": "agent-1",
2323            "health_score": 0.78,
2324            "memory_count": 120_usize,
2325            "avg_importance": 0.72
2326        });
2327        let health: FeedbackHealthResponse = serde_json::from_value(json).unwrap();
2328        assert_eq!(health.agent_id, "agent-1");
2329        assert!((health.health_score - 0.78).abs() < f32::EPSILON);
2330        assert_eq!(health.memory_count, 120);
2331    }
2332
2333    #[test]
2334    fn test_memory_feedback_body_serialization() {
2335        use crate::types::{FeedbackSignal, MemoryFeedbackBody};
2336        let body = MemoryFeedbackBody {
2337            agent_id: "agent-1".to_string(),
2338            signal: FeedbackSignal::Flag,
2339        };
2340        let json = serde_json::to_value(body).unwrap();
2341        assert_eq!(json["agent_id"], "agent-1");
2342        assert_eq!(json["signal"], "flag");
2343    }
2344
2345    #[test]
2346    fn test_feedback_memory_url_pattern() {
2347        let client = DakeraClient::new("http://localhost:3000").unwrap();
2348        let memory_id = "mem-abc";
2349        let expected_post = "http://localhost:3000/v1/memories/mem-abc/feedback";
2350        let actual_post = format!("{}/v1/memories/{}/feedback", client.base_url, memory_id);
2351        assert_eq!(actual_post, expected_post);
2352
2353        let expected_patch = "http://localhost:3000/v1/memories/mem-abc/importance";
2354        let actual_patch = format!("{}/v1/memories/{}/importance", client.base_url, memory_id);
2355        assert_eq!(actual_patch, expected_patch);
2356    }
2357
2358    #[test]
2359    fn test_feedback_health_url_pattern() {
2360        let client = DakeraClient::new("http://localhost:3000").unwrap();
2361        let agent_id = "agent-1";
2362        let expected = "http://localhost:3000/v1/feedback/health?agent_id=agent-1";
2363        let actual = format!(
2364            "{}/v1/feedback/health?agent_id={}",
2365            client.base_url, agent_id
2366        );
2367        assert_eq!(actual, expected);
2368    }
2369
2370    // ODE-2 tests
2371    #[test]
2372    fn test_ode_extract_entities_requires_ode_url() {
2373        // Client without ode_url should return Config error.
2374        let client = DakeraClient::new("http://localhost:3000").unwrap();
2375        let rt = tokio::runtime::Runtime::new().unwrap();
2376        let result = rt.block_on(client.ode_extract_entities(ExtractEntitiesRequest {
2377            content: "Alice lives in Paris.".to_string(),
2378            agent_id: "agent-1".to_string(),
2379            memory_id: None,
2380            entity_types: None,
2381        }));
2382        assert!(result.is_err());
2383        let err = result.unwrap_err();
2384        assert!(matches!(err, ClientError::Config(_)));
2385    }
2386
2387    #[test]
2388    fn test_ode_extract_entities_url_built_from_ode_url() {
2389        // Verify the ODE URL is used, not base_url.
2390        let client = DakeraClient::builder("http://localhost:3000")
2391            .ode_url("http://localhost:8080")
2392            .build()
2393            .unwrap();
2394        assert_eq!(client.ode_url.as_deref(), Some("http://localhost:8080"));
2395        let expected = "http://localhost:8080/ode/extract";
2396        let actual = format!("{}/ode/extract", client.ode_url.as_deref().unwrap());
2397        assert_eq!(actual, expected);
2398    }
2399
2400    #[test]
2401    fn test_extract_entities_request_serialization() {
2402        let req = ExtractEntitiesRequest {
2403            content: "Alice in Wonderland".to_string(),
2404            agent_id: "agent-42".to_string(),
2405            memory_id: Some("mem-001".to_string()),
2406            entity_types: Some(vec!["person".to_string(), "location".to_string()]),
2407        };
2408        let json = serde_json::to_string(&req).unwrap();
2409        assert!(json.contains("\"content\":\"Alice in Wonderland\""));
2410        assert!(json.contains("\"agent_id\":\"agent-42\""));
2411        assert!(json.contains("\"memory_id\":\"mem-001\""));
2412        assert!(json.contains("\"person\""));
2413    }
2414
2415    #[test]
2416    fn test_extract_entities_request_omits_none_fields() {
2417        let req = ExtractEntitiesRequest {
2418            content: "hello".to_string(),
2419            agent_id: "a".to_string(),
2420            memory_id: None,
2421            entity_types: None,
2422        };
2423        let json = serde_json::to_string(&req).unwrap();
2424        assert!(!json.contains("memory_id"));
2425        assert!(!json.contains("entity_types"));
2426    }
2427
2428    #[test]
2429    fn test_ode_entity_deserialization() {
2430        let json = r#"{"text":"Alice","label":"person","start":0,"end":5,"score":0.97}"#;
2431        let entity: OdeEntity = serde_json::from_str(json).unwrap();
2432        assert_eq!(entity.text, "Alice");
2433        assert_eq!(entity.label, "person");
2434        assert_eq!(entity.start, 0);
2435        assert_eq!(entity.end, 5);
2436        assert!((entity.score - 0.97).abs() < 1e-4);
2437    }
2438
2439    #[test]
2440    fn test_extract_entities_response_deserialization() {
2441        let json = r#"{
2442            "entities": [
2443                {"text":"Alice","label":"person","start":0,"end":5,"score":0.97},
2444                {"text":"Paris","label":"location","start":16,"end":21,"score":0.92}
2445            ],
2446            "model": "gliner-multi-v2.1",
2447            "processing_time_ms": 34
2448        }"#;
2449        let resp: ExtractEntitiesResponse = serde_json::from_str(json).unwrap();
2450        assert_eq!(resp.entities.len(), 2);
2451        assert_eq!(resp.entities[0].text, "Alice");
2452        assert_eq!(resp.model, "gliner-multi-v2.1");
2453        assert_eq!(resp.processing_time_ms, 34);
2454    }
2455}