1use std::sync::Arc;
10
11use agent_framework_core::client::EmbeddingClient;
12use agent_framework_core::error::{Error, Result};
13use agent_framework_core::types::{
14 Embedding, EmbeddingGenerationOptions, GeneratedEmbeddings, UsageDetails,
15};
16use serde_json::{json, Map, Value};
17
18use crate::{classify_service_error, parse_retry_after, DEFAULT_BASE_URL};
19
20#[derive(Clone)]
35pub struct OpenAIEmbeddingClient {
36 inner: Arc<Inner>,
37}
38
39struct Inner {
40 http: reqwest::Client,
41 api_key: String,
42 base_url: String,
43 model: String,
44 organization: Option<String>,
45}
46
47impl OpenAIEmbeddingClient {
48 pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Self {
50 Self {
51 inner: Arc::new(Inner {
52 http: reqwest::Client::new(),
53 api_key: api_key.into(),
54 base_url: DEFAULT_BASE_URL.to_string(),
55 model: model.into(),
56 organization: None,
57 }),
58 }
59 }
60
61 pub fn from_env(model: impl Into<String>) -> Result<Self> {
66 let key = std::env::var("OPENAI_API_KEY")
67 .map_err(|_| Error::Configuration("OPENAI_API_KEY is not set".into()))?;
68 let mut model = model.into();
69 if model.is_empty() {
70 model = std::env::var("OPENAI_EMBEDDING_MODEL").map_err(|_| {
71 Error::Configuration(
72 "no embedding model: pass one or set OPENAI_EMBEDDING_MODEL".into(),
73 )
74 })?;
75 }
76 let mut client = Self::new(key, model);
77 if let Ok(base) = std::env::var("OPENAI_BASE_URL") {
78 client = client.with_base_url(base);
79 }
80 Ok(client)
81 }
82
83 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
85 arc_inner(&mut self.inner).base_url = base_url.into();
86 self
87 }
88
89 pub fn with_organization(mut self, org: impl Into<String>) -> Self {
91 arc_inner(&mut self.inner).organization = Some(org.into());
92 self
93 }
94
95 fn build_body(&self, values: &[String], options: Option<&EmbeddingGenerationOptions>) -> Value {
96 let mut body = Map::new();
97 let model = options
98 .and_then(|o| o.model.clone())
99 .unwrap_or_else(|| self.inner.model.clone());
100 body.insert("model".into(), json!(model));
101 body.insert("input".into(), json!(values));
102 if let Some(options) = options {
103 if let Some(dimensions) = options.dimensions {
104 body.insert("dimensions".into(), json!(dimensions));
105 }
106 for key in ["encoding_format", "user"] {
109 if let Some(v) = options.additional_properties.get(key) {
110 body.insert(key.into(), v.clone());
111 }
112 }
113 }
114 Value::Object(body)
115 }
116}
117
118fn arc_inner(inner: &mut Arc<Inner>) -> &mut Inner {
122 if Arc::strong_count(inner) != 1 {
123 *inner = Arc::new(Inner {
124 http: inner.http.clone(),
125 api_key: inner.api_key.clone(),
126 base_url: inner.base_url.clone(),
127 model: inner.model.clone(),
128 organization: inner.organization.clone(),
129 });
130 }
131 Arc::get_mut(inner).expect("just ensured unique")
132}
133
134pub fn parse_embeddings_response(value: &Value) -> Result<GeneratedEmbeddings> {
138 let model = value.get("model").and_then(Value::as_str);
139 let data = value
140 .get("data")
141 .and_then(Value::as_array)
142 .ok_or_else(|| Error::service("embeddings response missing 'data' array"))?;
143
144 let mut indexed: Vec<(usize, Embedding)> = Vec::with_capacity(data.len());
145 for (position, item) in data.iter().enumerate() {
146 let vector: Vec<f32> = item
147 .get("embedding")
148 .and_then(Value::as_array)
149 .ok_or_else(|| Error::service("embeddings item missing 'embedding' vector"))?
150 .iter()
151 .map(|v| v.as_f64().unwrap_or_default() as f32)
152 .collect();
153 let index = item
154 .get("index")
155 .and_then(Value::as_u64)
156 .map(|i| i as usize)
157 .unwrap_or(position);
158 indexed.push((
159 index,
160 Embedding {
161 vector,
162 model: model.map(String::from),
163 },
164 ));
165 }
166 indexed.sort_by_key(|(i, _)| *i);
167
168 let mut batch = GeneratedEmbeddings::new(indexed.into_iter().map(|(_, e)| e).collect());
169 if let Some(usage) = value.get("usage") {
170 let input = usage.get("prompt_tokens").and_then(Value::as_u64);
171 let total = usage.get("total_tokens").and_then(Value::as_u64);
172 if input.is_some() || total.is_some() {
173 batch.usage = Some(UsageDetails {
174 input_token_count: input,
175 total_token_count: total,
176 ..Default::default()
177 });
178 }
179 }
180 Ok(batch)
181}
182
183#[async_trait::async_trait]
184impl EmbeddingClient for OpenAIEmbeddingClient {
185 async fn get_embeddings(
186 &self,
187 values: Vec<String>,
188 options: Option<EmbeddingGenerationOptions>,
189 ) -> Result<GeneratedEmbeddings> {
190 let body = self.build_body(&values, options.as_ref());
191 let url = format!("{}/embeddings", self.inner.base_url.trim_end_matches('/'));
192 let mut req = self
193 .inner
194 .http
195 .post(&url)
196 .bearer_auth(&self.inner.api_key)
197 .json(&body);
198 if let Some(org) = &self.inner.organization {
199 req = req.header("OpenAI-Organization", org);
200 }
201 let resp = req
202 .send()
203 .await
204 .map_err(|e| Error::service(format!("request failed: {e}")))?;
205 if !resp.status().is_success() {
206 let status = resp.status();
207 let retry_after = parse_retry_after(resp.headers());
208 let text = resp.text().await.unwrap_or_default();
209 return Err(classify_service_error(
210 status.as_u16(),
211 &text,
212 format!("OpenAI API error {status}: {text}"),
213 retry_after,
214 ));
215 }
216 let value: Value = resp
217 .json()
218 .await
219 .map_err(|e| Error::service(format!("invalid response json: {e}")))?;
220 parse_embeddings_response(&value)
221 }
222
223 fn model(&self) -> Option<&str> {
224 Some(&self.inner.model)
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231
232 #[test]
233 fn build_body_includes_model_input_and_dimensions() {
234 let client = OpenAIEmbeddingClient::new("sk-test", "text-embedding-3-small");
235 let options = EmbeddingGenerationOptions::new().with_dimensions(256);
236 let body = client.build_body(&["a".into(), "b".into()], Some(&options));
237 assert_eq!(body["model"], "text-embedding-3-small");
238 assert_eq!(body["input"], json!(["a", "b"]));
239 assert_eq!(body["dimensions"], 256);
240 }
241
242 #[test]
243 fn build_body_option_model_overrides_default() {
244 let client = OpenAIEmbeddingClient::new("sk-test", "text-embedding-3-small");
245 let options = EmbeddingGenerationOptions::new().with_model("text-embedding-3-large");
246 let body = client.build_body(&["a".into()], Some(&options));
247 assert_eq!(body["model"], "text-embedding-3-large");
248 }
249
250 #[test]
251 fn build_body_forwards_known_additional_properties_only() {
252 let client = OpenAIEmbeddingClient::new("sk-test", "m");
253 let mut options = EmbeddingGenerationOptions::new();
254 options
255 .additional_properties
256 .insert("encoding_format".into(), json!("float"));
257 options
258 .additional_properties
259 .insert("unrelated".into(), json!(true));
260 let body = client.build_body(&["a".into()], Some(&options));
261 assert_eq!(body["encoding_format"], "float");
262 assert!(body.get("unrelated").is_none());
263 }
264
265 #[test]
266 fn parse_response_restores_index_order_and_usage() {
267 let value = json!({
268 "model": "text-embedding-3-small",
269 "data": [
270 { "index": 1, "embedding": [0.3, 0.4] },
271 { "index": 0, "embedding": [0.1, 0.2] },
272 ],
273 "usage": { "prompt_tokens": 5, "total_tokens": 5 }
274 });
275 let batch = parse_embeddings_response(&value).unwrap();
276 assert_eq!(batch.len(), 2);
277 assert_eq!(batch[0].vector, vec![0.1, 0.2]);
278 assert_eq!(batch[1].vector, vec![0.3, 0.4]);
279 assert_eq!(batch[0].model.as_deref(), Some("text-embedding-3-small"));
280 let usage = batch.usage.as_ref().unwrap();
281 assert_eq!(usage.input_token_count, Some(5));
282 assert_eq!(usage.total_token_count, Some(5));
283 }
284
285 #[test]
286 fn parse_response_missing_data_errors() {
287 assert!(parse_embeddings_response(&json!({})).is_err());
288 }
289}