Skip to main content

sqlite_graphrag/embedding_api/
client.rs

1//! Client construction and the two embedding entry points.
2//!
3//! Owns the three constructors and the `embed_single` / `embed_batch` calls;
4//! the retry loop underneath lives in [`super::transport`].
5
6use super::error::EmbedError;
7use super::mrl::{model_default_input_type, mrl_wire_dimensions};
8use super::wire::{EmbeddingInput, EmbeddingRequest};
9use super::{
10    OpenRouterClient, DEFAULT_CONNECT_TIMEOUT_SECS, DEFAULT_EMBED_HTTP_BATCH_SIZE,
11    DEFAULT_TIMEOUT_SECS,
12};
13use crate::constants::DEFAULT_OPENROUTER_EMBEDDINGS_URL;
14use crate::errors::AppError;
15use secrecy::SecretBox;
16use std::time::Duration;
17
18impl OpenRouterClient {
19    /// Builds an embedding client bound to `model`, applying `timeout_secs` as
20    /// the total per-request budget.
21    ///
22    /// A value of `0` falls back to `DEFAULT_TIMEOUT_SECS`, mirroring
23    /// [`crate::chat_api::OpenRouterChatClient::new`], so a missing or zero
24    /// setting never degrades into reqwest's immediate-timeout behaviour.
25    pub fn new(
26        api_key: SecretBox<String>,
27        model: String,
28        dim: usize,
29        timeout_secs: u64,
30    ) -> Result<Self, AppError> {
31        let base_url =
32            crate::runtime_config::openrouter_embeddings_url(DEFAULT_OPENROUTER_EMBEDDINGS_URL);
33        Self::new_with_base_url(api_key, model, dim, timeout_secs, base_url)
34    }
35
36    /// Build a client posting to an explicit `base_url` (XDG override, tests, gateways).
37    ///
38    /// `timeout_secs` follows the same zero-guard as [`Self::new`].
39    pub fn new_with_base_url(
40        api_key: SecretBox<String>,
41        model: String,
42        dim: usize,
43        timeout_secs: u64,
44        base_url: String,
45    ) -> Result<Self, AppError> {
46        let timeout_secs = if timeout_secs == 0 {
47            DEFAULT_TIMEOUT_SECS
48        } else {
49            timeout_secs
50        };
51        let client = reqwest::Client::builder()
52            .timeout(Duration::from_secs(timeout_secs))
53            .connect_timeout(Duration::from_secs(DEFAULT_CONNECT_TIMEOUT_SECS))
54            .user_agent(concat!("sqlite-graphrag/", env!("CARGO_PKG_VERSION")))
55            .build()
56            .map_err(|e| {
57                AppError::Embedding(crate::i18n::validation::embedding_http_client_build_failed(
58                    e,
59                ))
60            })?;
61
62        let default_input_type = model_default_input_type(&model);
63
64        Ok(Self {
65            client,
66            api_key,
67            model,
68            dim,
69            default_input_type,
70            base_url,
71        })
72    }
73
74    /// Test-only constructor that POSTs to an arbitrary `base_url` (such as a
75    /// `wiremock::MockServer`) instead of the public OpenRouter endpoint.
76    /// Behaviour is otherwise identical to [`Self::new`].
77    #[cfg(test)]
78    pub(super) fn new_with_url(
79        api_key: SecretBox<String>,
80        model: String,
81        dim: usize,
82        timeout_secs: u64,
83        base_url: String,
84    ) -> Result<Self, AppError> {
85        Self::new_with_base_url(api_key, model, dim, timeout_secs, base_url)
86    }
87
88    /// Default input type.
89    pub fn default_input_type(&self) -> Option<&'static str> {
90        self.default_input_type
91    }
92
93    /// Embed single.
94    pub async fn embed_single(
95        &self,
96        text: &str,
97        input_type: Option<&str>,
98    ) -> Result<Vec<f32>, EmbedError> {
99        // GAP-SG-02: reject an input that would overflow the model's token
100        // window BEFORE the HTTP request, surfacing a clear Validation error
101        // instead of a provider context-length rejection paid for round-trip.
102        crate::memory_guard::check_embedding_input_size(text)?;
103
104        let request = EmbeddingRequest {
105            model: &self.model,
106            input: EmbeddingInput::Single(text),
107            dimensions: mrl_wire_dimensions(&self.model, self.dim),
108            encoding_format: "float",
109            input_type,
110        };
111
112        let response = self.execute_with_retry(&request).await?;
113
114        let embedding = response
115            .data
116            .into_iter()
117            .next()
118            .ok_or_else(|| {
119                AppError::Embedding(
120                    crate::i18n::validation::embedding_empty_response_from_openrouter(),
121                )
122            })?
123            .embedding;
124
125        Ok(self.truncate_embedding(embedding)?)
126    }
127
128    /// Embed batch.
129    pub async fn embed_batch(
130        &self,
131        texts: &[&str],
132        input_type: Option<&str>,
133    ) -> Result<Vec<Vec<f32>>, EmbedError> {
134        if texts.is_empty() {
135            return Ok(Vec::new());
136        }
137
138        // GAP-SG-02: validate every input before any HTTP request so an
139        // oversized member of the batch fails fast as Validation rather than a
140        // provider context-length rejection mid-batch.
141        for text in texts {
142            crate::memory_guard::check_embedding_input_size(text)?;
143        }
144
145        let mut all = Vec::with_capacity(texts.len());
146
147        let batch_size = crate::runtime_config::embedding_batch_size(DEFAULT_EMBED_HTTP_BATCH_SIZE);
148        for chunk in texts.chunks(batch_size) {
149            let request = EmbeddingRequest {
150                model: &self.model,
151                input: EmbeddingInput::Batch(chunk.to_vec()),
152                dimensions: mrl_wire_dimensions(&self.model, self.dim),
153                encoding_format: "float",
154                input_type,
155            };
156
157            let response = self.execute_with_retry(&request).await?;
158
159            if response.data.len() != chunk.len() {
160                return Err(AppError::Embedding(
161                    crate::i18n::validation::embedding_expected_count(
162                        chunk.len(),
163                        response.data.len(),
164                    ),
165                )
166                .into());
167            }
168
169            let mut sorted = response.data;
170            sorted.sort_by_key(|d| d.index);
171
172            for d in sorted {
173                all.push(self.truncate_embedding(d.embedding)?);
174            }
175        }
176
177        Ok(all)
178    }
179}