oxios_kernel/embedding/
api.rs1use std::sync::Arc;
36use std::time::Duration;
37
38use anyhow::{Context, Result};
39use async_trait::async_trait;
40use parking_lot::Mutex;
41use serde::{Deserialize, Serialize};
42
43use crate::embedding::{EmbeddingProvider, EmbeddingVector};
44
45pub fn default_dimensions(model: &str) -> Option<usize> {
49 match model {
50 "text-embedding-3-small" => Some(1536),
51 "text-embedding-3-large" => Some(3072),
52 "text-embedding-ada-002" => Some(1536),
53 _ => None,
54 }
55}
56
57#[derive(Debug)]
59struct ApiClient {
60 inner: reqwest::Client,
61}
62
63impl ApiClient {
64 fn new() -> Result<Self> {
65 let inner = reqwest::Client::builder()
66 .timeout(Duration::from_secs(15))
67 .connect_timeout(Duration::from_secs(5))
68 .build()
69 .context("Failed to construct reqwest client for embedding API")?;
70 Ok(Self { inner })
71 }
72}
73
74#[derive(Debug, Serialize)]
76struct EmbeddingRequest<'a> {
77 input: &'a str,
78 model: &'a str,
79 #[serde(skip_serializing_if = "Option::is_none")]
80 dimensions: Option<usize>,
81}
82
83#[derive(Debug, Deserialize)]
85struct EmbeddingResponse {
86 data: Vec<EmbeddingData>,
88}
89
90#[derive(Debug, Deserialize)]
91struct EmbeddingData {
92 embedding: Vec<f32>,
93}
94
95pub struct ApiEmbeddingProvider {
100 endpoint: String,
102 api_key: String,
104 model: String,
106 dimensions: usize,
109 client: Mutex<Option<Arc<ApiClient>>>,
112}
113
114impl std::fmt::Debug for ApiEmbeddingProvider {
115 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116 f.debug_struct("ApiEmbeddingProvider")
117 .field("endpoint", &self.endpoint)
118 .field("model", &self.model)
119 .field("dimensions", &self.dimensions)
120 .finish_non_exhaustive()
121 }
122}
123
124impl ApiEmbeddingProvider {
125 pub fn new(
129 endpoint: String,
130 api_key: String,
131 model: String,
132 dimensions: Option<usize>,
133 ) -> Result<Self> {
134 let dimensions = match dimensions {
135 Some(d) if d > 0 => d,
136 _ => default_dimensions(&model)
137 .ok_or_else(|| anyhow::anyhow!(
138 "Embedding model '{model}' has no known dimensionality; specify `dimensions` in [embedding] config"
139 ))?,
140 };
141 Ok(Self {
142 endpoint,
143 api_key,
144 model,
145 dimensions,
146 client: Mutex::new(None),
147 })
148 }
149
150 fn http(&self) -> Result<Arc<ApiClient>> {
152 let mut guard = self.client.lock();
153 if let Some(c) = guard.as_ref() {
154 return Ok(Arc::clone(c));
155 }
156 let c = Arc::new(ApiClient::new()?);
157 *guard = Some(Arc::clone(&c));
158 Ok(c)
159 }
160}
161
162#[async_trait]
163impl EmbeddingProvider for ApiEmbeddingProvider {
164 async fn embed(&self, text: &str) -> Result<EmbeddingVector> {
165 let client = self.http()?;
166 let req = EmbeddingRequest {
167 input: text,
168 model: &self.model,
169 dimensions: None, };
171 let resp = client
172 .inner
173 .post(&self.endpoint)
174 .bearer_auth(&self.api_key)
175 .json(&req)
176 .send()
177 .await
178 .context("Embedding API request failed")?;
179
180 let status = resp.status();
181 let body = resp
182 .text()
183 .await
184 .context("Failed to read embedding API response body")?;
185
186 if !status.is_success() {
187 anyhow::bail!(
188 "Embedding API returned HTTP {status}: {}",
189 truncate(&body, 200)
190 );
191 }
192
193 let parsed: EmbeddingResponse = serde_json::from_str(&body)
194 .context("Failed to parse embedding API response as JSON")?;
195
196 let vec = parsed
197 .data
198 .into_iter()
199 .next()
200 .ok_or_else(|| anyhow::anyhow!("Embedding API returned no vectors in response"))?
201 .embedding;
202
203 if vec.len() != self.dimensions {
204 anyhow::bail!(
205 "Embedding dimension mismatch: configured {} got {}",
206 self.dimensions,
207 vec.len()
208 );
209 }
210
211 Ok(EmbeddingVector::DenseF32(vec))
212 }
213
214 fn name(&self) -> &str {
215 "api-embedding"
216 }
217}
218
219fn truncate(s: &str, max_bytes: usize) -> String {
221 if s.len() <= max_bytes {
222 return s.to_string();
223 }
224 let mut end = max_bytes;
225 while end > 0 && !s.is_char_boundary(end) {
226 end -= 1;
227 }
228 format!("{}...", &s[..end])
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234
235 #[test]
236 fn test_default_dimensions() {
237 assert_eq!(default_dimensions("text-embedding-3-small"), Some(1536));
238 assert_eq!(default_dimensions("text-embedding-3-large"), Some(3072));
239 assert_eq!(default_dimensions("unknown-model"), None);
240 }
241
242 #[test]
243 fn test_truncate_ascii() {
244 assert_eq!(truncate("hello world", 5), "hello...");
245 }
246
247 #[test]
248 fn test_truncate_korean_safe_boundary() {
249 let s = "가나다라마";
251 let _ = truncate(s, 7);
252 }
253
254 #[test]
255 fn test_constructor_requires_known_dim() {
256 let p = ApiEmbeddingProvider::new(
257 "https://example.com".into(),
258 "k".into(),
259 "text-embedding-3-small".into(),
260 None,
261 )
262 .unwrap();
263 assert_eq!(p.dimensions, 1536);
264 }
265
266 #[test]
267 fn test_constructor_explicit_dim() {
268 let p = ApiEmbeddingProvider::new(
269 "https://example.com".into(),
270 "k".into(),
271 "custom-model".into(),
272 Some(768),
273 )
274 .unwrap();
275 assert_eq!(p.dimensions, 768);
276 }
277
278 #[test]
279 fn test_constructor_unknown_model_no_dim_fails() {
280 let r = ApiEmbeddingProvider::new(
281 "https://example.com".into(),
282 "k".into(),
283 "custom-model".into(),
284 None,
285 );
286 assert!(r.is_err());
287 }
288}