1use std::sync::Arc;
10
11use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
12use base64::Engine as _;
13
14use agent_framework_core::client::EmbeddingClient;
15use agent_framework_core::error::{Error, Result};
16use agent_framework_core::types::{
17 Embedding, EmbeddingGenerationOptions, GeneratedEmbeddings, UsageDetails,
18};
19use serde_json::{json, Map, Value};
20
21use crate::{classify_service_error, parse_retry_after, DEFAULT_BASE_URL};
22
23#[derive(Clone)]
38pub struct OpenAIEmbeddingClient {
39 inner: Arc<Inner>,
40}
41
42struct Inner {
43 http: reqwest::Client,
44 api_key: String,
45 base_url: String,
46 model: String,
47 organization: Option<String>,
48}
49
50impl OpenAIEmbeddingClient {
51 pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Self {
53 Self {
54 inner: Arc::new(Inner {
55 http: reqwest::Client::new(),
56 api_key: api_key.into(),
57 base_url: DEFAULT_BASE_URL.to_string(),
58 model: model.into(),
59 organization: None,
60 }),
61 }
62 }
63
64 pub fn from_env(model: impl Into<String>) -> Result<Self> {
69 let key = std::env::var("OPENAI_API_KEY")
70 .map_err(|_| Error::Configuration("OPENAI_API_KEY is not set".into()))?;
71 let mut model = model.into();
72 if model.is_empty() {
73 model = std::env::var("OPENAI_EMBEDDING_MODEL").map_err(|_| {
74 Error::Configuration(
75 "no embedding model: pass one or set OPENAI_EMBEDDING_MODEL".into(),
76 )
77 })?;
78 }
79 let mut client = Self::new(key, model);
80 if let Ok(base) = std::env::var("OPENAI_BASE_URL") {
81 client = client.with_base_url(base);
82 }
83 Ok(client)
84 }
85
86 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
88 arc_inner(&mut self.inner).base_url = base_url.into();
89 self
90 }
91
92 pub fn with_organization(mut self, org: impl Into<String>) -> Self {
94 arc_inner(&mut self.inner).organization = Some(org.into());
95 self
96 }
97
98 fn build_body(&self, values: &[String], options: Option<&EmbeddingGenerationOptions>) -> Value {
99 let mut body = Map::new();
100 let model = options
101 .and_then(|o| o.model.clone())
102 .unwrap_or_else(|| self.inner.model.clone());
103 body.insert("model".into(), json!(model));
104 body.insert("input".into(), json!(values));
105 if let Some(options) = options {
106 if let Some(dimensions) = options.dimensions {
107 body.insert("dimensions".into(), json!(dimensions));
108 }
109 for key in ["encoding_format", "user"] {
112 if let Some(v) = options.additional_properties.get(key) {
113 body.insert(key.into(), v.clone());
114 }
115 }
116 }
117 Value::Object(body)
118 }
119}
120
121fn arc_inner(inner: &mut Arc<Inner>) -> &mut Inner {
125 if Arc::strong_count(inner) != 1 {
126 *inner = Arc::new(Inner {
127 http: inner.http.clone(),
128 api_key: inner.api_key.clone(),
129 base_url: inner.base_url.clone(),
130 model: inner.model.clone(),
131 organization: inner.organization.clone(),
132 });
133 }
134 Arc::get_mut(inner).expect("just ensured unique")
135}
136
137fn parse_embedding_vector(field: Option<&Value>) -> Result<Vec<f32>> {
146 match field {
147 Some(Value::Array(values)) => Ok(values
148 .iter()
149 .map(|v| v.as_f64().unwrap_or_default() as f32)
150 .collect()),
151 Some(Value::String(encoded)) => {
152 let bytes = BASE64_STANDARD.decode(encoded.as_bytes()).map_err(|e| {
153 Error::service(format!("embeddings item has undecodable base64: {e}"))
154 })?;
155 if bytes.len() % 4 != 0 {
156 return Err(Error::service(format!(
157 "base64 embedding decodes to {} bytes, not a whole number of f32s",
158 bytes.len()
159 )));
160 }
161 let mut vector = Vec::with_capacity(bytes.len() / 4);
168 let mut rest = bytes.as_slice();
169 while let Some((chunk, tail)) = rest.split_first_chunk::<4>() {
170 vector.push(f32::from_le_bytes(*chunk));
171 rest = tail;
172 }
173 Ok(vector)
174 }
175 _ => Err(Error::service("embeddings item missing 'embedding' vector")),
176 }
177}
178
179pub fn parse_embeddings_response(value: &Value) -> Result<GeneratedEmbeddings> {
187 let model = value.get("model").and_then(Value::as_str);
188 let data = value
189 .get("data")
190 .and_then(Value::as_array)
191 .ok_or_else(|| Error::service("embeddings response missing 'data' array"))?;
192
193 let mut indexed: Vec<(usize, Embedding)> = Vec::with_capacity(data.len());
194 for (position, item) in data.iter().enumerate() {
195 let vector = parse_embedding_vector(item.get("embedding"))?;
196 let index = item
197 .get("index")
198 .and_then(Value::as_u64)
199 .map(|i| i as usize)
200 .unwrap_or(position);
201 indexed.push((
202 index,
203 Embedding {
204 vector,
205 model: model.map(String::from),
206 },
207 ));
208 }
209 indexed.sort_by_key(|(i, _)| *i);
210
211 let mut batch = GeneratedEmbeddings::new(indexed.into_iter().map(|(_, e)| e).collect());
212 if let Some(usage) = value.get("usage") {
213 let input = usage.get("prompt_tokens").and_then(Value::as_u64);
214 let total = usage.get("total_tokens").and_then(Value::as_u64);
215 if input.is_some() || total.is_some() {
216 batch.usage = Some(UsageDetails {
217 input_token_count: input,
218 total_token_count: total,
219 ..Default::default()
220 });
221 }
222 }
223 Ok(batch)
224}
225
226#[async_trait::async_trait]
227impl EmbeddingClient for OpenAIEmbeddingClient {
228 async fn get_embeddings(
229 &self,
230 values: Vec<String>,
231 options: Option<EmbeddingGenerationOptions>,
232 ) -> Result<GeneratedEmbeddings> {
233 let body = self.build_body(&values, options.as_ref());
234 let url = format!("{}/embeddings", self.inner.base_url.trim_end_matches('/'));
235 let mut req = self
236 .inner
237 .http
238 .post(&url)
239 .bearer_auth(&self.inner.api_key)
240 .json(&body);
241 if let Some(org) = &self.inner.organization {
242 req = req.header("OpenAI-Organization", org);
243 }
244 let resp = req
245 .send()
246 .await
247 .map_err(|e| Error::service(format!("request failed: {e}")))?;
248 if !resp.status().is_success() {
249 let status = resp.status();
250 let retry_after = parse_retry_after(resp.headers());
251 let text = resp.text().await.unwrap_or_default();
252 return Err(classify_service_error(
253 status.as_u16(),
254 &text,
255 format!("OpenAI API error {status}: {text}"),
256 retry_after,
257 ));
258 }
259 let value: Value = resp
260 .json()
261 .await
262 .map_err(|e| Error::service(format!("invalid response json: {e}")))?;
263 parse_embeddings_response(&value)
264 }
265
266 fn model(&self) -> Option<&str> {
267 Some(&self.inner.model)
268 }
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274
275 #[test]
276 fn build_body_includes_model_input_and_dimensions() {
277 let client = OpenAIEmbeddingClient::new("sk-test", "text-embedding-3-small");
278 let options = EmbeddingGenerationOptions::new().with_dimensions(256);
279 let body = client.build_body(&["a".into(), "b".into()], Some(&options));
280 assert_eq!(body["model"], "text-embedding-3-small");
281 assert_eq!(body["input"], json!(["a", "b"]));
282 assert_eq!(body["dimensions"], 256);
283 }
284
285 #[test]
286 fn build_body_option_model_overrides_default() {
287 let client = OpenAIEmbeddingClient::new("sk-test", "text-embedding-3-small");
288 let options = EmbeddingGenerationOptions::new().with_model("text-embedding-3-large");
289 let body = client.build_body(&["a".into()], Some(&options));
290 assert_eq!(body["model"], "text-embedding-3-large");
291 }
292
293 #[test]
294 fn build_body_forwards_known_additional_properties_only() {
295 let client = OpenAIEmbeddingClient::new("sk-test", "m");
296 let mut options = EmbeddingGenerationOptions::new();
297 options
298 .additional_properties
299 .insert("encoding_format".into(), json!("float"));
300 options
301 .additional_properties
302 .insert("unrelated".into(), json!(true));
303 let body = client.build_body(&["a".into()], Some(&options));
304 assert_eq!(body["encoding_format"], "float");
305 assert!(body.get("unrelated").is_none());
306 }
307
308 #[test]
309 fn parse_response_restores_index_order_and_usage() {
310 let value = json!({
311 "model": "text-embedding-3-small",
312 "data": [
313 { "index": 1, "embedding": [0.3, 0.4] },
314 { "index": 0, "embedding": [0.1, 0.2] },
315 ],
316 "usage": { "prompt_tokens": 5, "total_tokens": 5 }
317 });
318 let batch = parse_embeddings_response(&value).unwrap();
319 assert_eq!(batch.len(), 2);
320 assert_eq!(batch[0].vector, vec![0.1, 0.2]);
321 assert_eq!(batch[1].vector, vec![0.3, 0.4]);
322 assert_eq!(batch[0].model.as_deref(), Some("text-embedding-3-small"));
323 let usage = batch.usage.as_ref().unwrap();
324 assert_eq!(usage.input_token_count, Some(5));
325 assert_eq!(usage.total_token_count, Some(5));
326 }
327
328 #[test]
329 fn parse_response_missing_data_errors() {
330 assert!(parse_embeddings_response(&json!({})).is_err());
331 }
332
333 #[test]
338 fn parse_response_decodes_base64_embeddings() {
339 let vector: [f32; 3] = [1.0, -2.5, 0.125];
340 let mut bytes = Vec::new();
341 for f in vector {
342 bytes.extend_from_slice(&f.to_le_bytes());
343 }
344 let encoded = BASE64_STANDARD.encode(&bytes);
345
346 let value = json!({
347 "data": [{ "index": 0, "embedding": encoded }],
348 "model": "text-embedding-3-small",
349 });
350 let batch = parse_embeddings_response(&value).unwrap();
351 assert_eq!(batch.embeddings[0].vector, vector.to_vec());
352 }
353
354 #[test]
355 fn parse_response_mixes_base64_and_array_items_by_index() {
356 let encoded = BASE64_STANDARD.encode(2.0f32.to_le_bytes());
357 let value = json!({
358 "data": [
359 { "index": 1, "embedding": encoded },
360 { "index": 0, "embedding": [1.0] },
361 ],
362 });
363 let batch = parse_embeddings_response(&value).unwrap();
364 assert_eq!(batch.embeddings[0].vector, vec![1.0]);
365 assert_eq!(batch.embeddings[1].vector, vec![2.0]);
366 }
367
368 #[test]
369 fn parse_response_rejects_undecodable_or_misaligned_base64() {
370 let bad = json!({ "data": [{ "index": 0, "embedding": "!!!not base64!!!" }] });
372 assert!(parse_embeddings_response(&bad).is_err());
373
374 let misaligned = BASE64_STANDARD.encode([1u8, 2, 3, 4, 5]);
377 let value = json!({ "data": [{ "index": 0, "embedding": misaligned }] });
378 let err = parse_embeddings_response(&value).unwrap_err();
379 assert!(err.to_string().contains("whole number of f32s"), "{err}");
380 }
381}