1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4use super::{EmbedError, Embedder, Result};
5
6#[derive(Debug, Clone)]
7pub struct ApiEmbedder {
8 endpoint: String,
9 model: Option<String>,
10 dimensions: usize,
11}
12
13impl ApiEmbedder {
14 pub fn new(endpoint: String, model: Option<String>, dimensions: usize) -> Self {
15 Self {
16 endpoint,
17 model,
18 dimensions,
19 }
20 }
21
22 pub fn endpoint(&self) -> &str {
23 &self.endpoint
24 }
25
26 pub fn model(&self) -> Option<&str> {
27 self.model.as_deref()
28 }
29}
30
31#[async_trait::async_trait]
32impl Embedder for ApiEmbedder {
33 async fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
34 if texts.is_empty() {
35 return Ok(Vec::new());
36 }
37
38 if is_ollama_embeddings_endpoint(self.endpoint()) {
39 return self.embed_ollama_compatible(texts).await;
40 }
41
42 self.embed_openai_compatible(texts).await
43 }
44
45 fn dimensions(&self) -> usize {
46 self.dimensions
47 }
48
49 fn name(&self) -> &str {
50 "api"
51 }
52}
53
54impl ApiEmbedder {
55 async fn embed_openai_compatible(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
56 let endpoint = self.endpoint().to_string();
57 let response = reqwest::Client::new()
58 .post(self.endpoint())
59 .json(&OpenAiEmbeddingsRequest {
60 model: self.model().unwrap_or("text-embedding-3-small"),
61 input: texts,
62 })
63 .send()
64 .await
65 .map_err(|source| EmbedError::HttpRequest {
66 endpoint: endpoint.clone(),
67 source,
68 })?
69 .error_for_status()
70 .map_err(|source| EmbedError::HttpStatus {
71 endpoint: endpoint.clone(),
72 source,
73 })?
74 .json::<OpenAiEmbeddingsResponse>()
75 .await
76 .map_err(|source| EmbedError::DecodeResponse { endpoint, source })?;
77
78 let vectors = response
79 .data
80 .into_iter()
81 .map(|item| item.embedding)
82 .collect::<Vec<_>>();
83 validate_vectors(&vectors, self.dimensions())?;
84 Ok(vectors)
85 }
86
87 async fn embed_ollama_compatible(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
88 let client = reqwest::Client::new();
89 let mut vectors = Vec::with_capacity(texts.len());
90 let endpoint = self.endpoint().to_string();
91
92 for text in texts {
93 let response = client
94 .post(self.endpoint())
95 .json(&OllamaEmbeddingsRequest {
96 model: self.model().unwrap_or("nomic-embed-text"),
97 prompt: text,
98 })
99 .send()
100 .await
101 .map_err(|source| EmbedError::HttpRequest {
102 endpoint: endpoint.clone(),
103 source,
104 })?
105 .error_for_status()
106 .map_err(|source| EmbedError::HttpStatus {
107 endpoint: endpoint.clone(),
108 source,
109 })?
110 .json::<Value>()
111 .await
112 .map_err(|source| EmbedError::DecodeResponse {
113 endpoint: endpoint.clone(),
114 source,
115 })?;
116
117 let embedding = response
118 .get("embedding")
119 .and_then(Value::as_array)
120 .ok_or_else(|| {
121 EmbedError::InvalidResponse(
122 "embedding response missing embedding array".to_string(),
123 )
124 })?
125 .iter()
126 .map(json_number_to_f32)
127 .collect::<Result<Vec<_>>>()?;
128 vectors.push(embedding);
129 }
130
131 validate_vectors(&vectors, self.dimensions())?;
132 Ok(vectors)
133 }
134}
135
136#[derive(Debug, Serialize)]
137struct OpenAiEmbeddingsRequest<'a> {
138 model: &'a str,
139 input: &'a [&'a str],
140}
141
142#[derive(Debug, Deserialize)]
143struct OpenAiEmbeddingsResponse {
144 data: Vec<OpenAiEmbeddingItem>,
145}
146
147#[derive(Debug, Deserialize)]
148struct OpenAiEmbeddingItem {
149 embedding: Vec<f32>,
150}
151
152#[derive(Debug, Serialize)]
153struct OllamaEmbeddingsRequest<'a> {
154 model: &'a str,
155 prompt: &'a str,
156}
157
158fn is_ollama_embeddings_endpoint(endpoint: &str) -> bool {
159 endpoint.trim_end_matches('/').ends_with("/api/embeddings")
160}
161
162fn validate_vectors(vectors: &[Vec<f32>], expected_dimensions: usize) -> Result<()> {
163 if vectors.is_empty() {
164 return Err(EmbedError::EmptyVectors);
165 }
166
167 if let Some(actual) = vectors
168 .iter()
169 .map(Vec::len)
170 .find(|length| *length != expected_dimensions)
171 {
172 return Err(EmbedError::InvalidDimensions {
173 expected: expected_dimensions,
174 actual,
175 });
176 }
177
178 Ok(())
179}
180
181fn json_number_to_f32(value: &Value) -> Result<f32> {
182 value
183 .as_f64()
184 .map(|number| number as f32)
185 .ok_or_else(|| EmbedError::InvalidResponse("embedding element was not numeric".to_string()))
186}