1use super::{client::ApiResponse, completion::Usage};
2use crate::embeddings::EmbeddingError;
3use crate::http_client::HttpClientExt;
4use crate::wasm_compat::{WasmCompatSend, WasmCompatSync};
5use crate::{embeddings, http_client};
6use serde::{Deserialize, Serialize};
7
8pub const TEXT_EMBEDDING_3_LARGE: &str = "text-embedding-3-large";
13pub const TEXT_EMBEDDING_3_SMALL: &str = "text-embedding-3-small";
15pub const TEXT_EMBEDDING_ADA_002: &str = "text-embedding-ada-002";
17
18#[derive(Debug, Deserialize)]
19pub struct EmbeddingResponse {
20 pub object: String,
21 pub data: Vec<EmbeddingData>,
22 pub model: String,
23 pub usage: Usage,
24}
25
26#[derive(Debug, Deserialize)]
27struct CompatibleEmbeddingResponse {
28 #[serde(rename = "object")]
29 _object: String,
30 pub data: Vec<EmbeddingData>,
31 #[serde(rename = "model")]
32 _model: String,
33 #[serde(default)]
34 pub usage: Option<Usage>,
35}
36
37#[doc(hidden)]
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum EmbeddingDimensions {
41 Dimensions(usize),
43 OutputDimension(usize),
45}
46
47#[doc(hidden)]
50pub trait OpenAIEmbeddingsCompatible: crate::client::Provider {
51 const PROVIDER_NAME: &'static str;
53
54 const REQUIRES_USAGE: bool = true;
56
57 const SUPPORTS_ENCODING_FORMAT: bool = true;
59
60 const SUPPORTS_USER: bool = true;
62
63 fn embeddings_path(&self) -> String {
65 "/embeddings".to_string()
66 }
67
68 fn embedding_dimensions(
70 &self,
71 _model: &str,
72 dimensions: Option<usize>,
73 ) -> Result<Option<EmbeddingDimensions>, EmbeddingError> {
74 Ok(dimensions.map(EmbeddingDimensions::Dimensions))
75 }
76}
77
78impl OpenAIEmbeddingsCompatible for super::OpenAIResponsesExt {
79 const PROVIDER_NAME: &'static str = "openai";
80}
81
82impl OpenAIEmbeddingsCompatible for super::OpenAICompletionsExt {
83 const PROVIDER_NAME: &'static str = "openai";
84}
85
86#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq, Serialize)]
87#[serde(rename_all = "snake_case")]
88pub enum EncodingFormat {
89 Float,
90 Base64,
91}
92
93#[derive(Debug, Serialize)]
94struct CompatibleEmbeddingRequest<'a> {
95 model: &'a str,
96 input: &'a [String],
97 #[serde(skip_serializing_if = "Option::is_none")]
98 dimensions: Option<usize>,
99 #[serde(skip_serializing_if = "Option::is_none")]
100 output_dimension: Option<usize>,
101 #[serde(skip_serializing_if = "Option::is_none")]
102 encoding_format: Option<EncodingFormat>,
103 #[serde(skip_serializing_if = "Option::is_none")]
104 user: Option<&'a str>,
105}
106
107#[derive(Debug, Deserialize)]
108pub struct EmbeddingData {
109 pub object: String,
110 pub embedding: Vec<serde_json::Number>,
111 pub index: usize,
112}
113
114#[doc(hidden)]
115#[derive(Clone)]
116pub struct GenericEmbeddingModel<Ext = super::OpenAIResponsesExt, H = reqwest::Client> {
117 client: crate::client::Client<Ext, H>,
118 pub model: String,
119 pub encoding_format: Option<EncodingFormat>,
120 pub user: Option<String>,
121 ndims: usize,
122}
123
124pub type EmbeddingModel<H = reqwest::Client> = GenericEmbeddingModel<super::OpenAIResponsesExt, H>;
129
130fn model_dimensions_from_identifier(identifier: &str) -> Option<usize> {
131 match identifier {
132 TEXT_EMBEDDING_3_LARGE => Some(3_072),
133 TEXT_EMBEDDING_3_SMALL | TEXT_EMBEDDING_ADA_002 => Some(1_536),
134 _ => None,
135 }
136}
137
138impl<Ext, H> embeddings::EmbeddingModel for GenericEmbeddingModel<Ext, H>
139where
140 crate::client::Client<Ext, H>:
141 HttpClientExt + Clone + WasmCompatSend + WasmCompatSync + 'static,
142 Ext: OpenAIEmbeddingsCompatible + Clone + 'static,
143{
144 const MAX_DOCUMENTS: usize = 1024;
145
146 type Client = crate::client::Client<Ext, H>;
147
148 fn make(client: &Self::Client, model: impl Into<String>, ndims: Option<usize>) -> Self {
149 let model = model.into();
150 let dims = ndims
151 .or(model_dimensions_from_identifier(&model))
152 .unwrap_or_default();
153
154 Self::new(client.clone(), model, dims)
155 }
156
157 fn ndims(&self) -> usize {
158 self.ndims
159 }
160
161 async fn embed_texts(
162 &self,
163 documents: impl IntoIterator<Item = String>,
164 ) -> Result<Vec<embeddings::Embedding>, EmbeddingError> {
165 let documents: Vec<String> = documents.into_iter().collect();
166 let response = self.embed_texts_with_usage(documents).await?;
167 Ok(response.embeddings)
168 }
169
170 async fn embed_texts_with_usage(
171 &self,
172 documents: impl IntoIterator<Item = String>,
173 ) -> Result<embeddings::EmbeddingResponse, EmbeddingError> {
174 let documents: Vec<String> = documents.into_iter().collect();
175
176 if self.encoding_format == Some(EncodingFormat::Base64) {
177 return Err(EmbeddingError::UnsupportedResponseEncoding {
178 provider: Ext::PROVIDER_NAME,
179 encoding_format: "base64",
180 });
181 }
182
183 if self.encoding_format.is_some() && !Ext::SUPPORTS_ENCODING_FORMAT {
184 return Err(EmbeddingError::UnsupportedParameter {
185 provider: Ext::PROVIDER_NAME,
186 parameter: "encoding_format",
187 });
188 }
189
190 if self.user.is_some() && !Ext::SUPPORTS_USER {
191 return Err(EmbeddingError::UnsupportedParameter {
192 provider: Ext::PROVIDER_NAME,
193 parameter: "user",
194 });
195 }
196
197 let requested_dimensions =
198 (self.ndims > 0 && self.model != TEXT_EMBEDDING_ADA_002).then_some(self.ndims);
199 let dimensions = self
200 .client
201 .ext()
202 .embedding_dimensions(&self.model, requested_dimensions)?;
203 let (dimensions, output_dimension) = match dimensions {
204 Some(EmbeddingDimensions::Dimensions(value)) => (Some(value), None),
205 Some(EmbeddingDimensions::OutputDimension(value)) => (None, Some(value)),
206 None => (None, None),
207 };
208
209 let body = serde_json::to_vec(&CompatibleEmbeddingRequest {
210 model: &self.model,
211 input: &documents,
212 dimensions,
213 output_dimension,
214 encoding_format: self.encoding_format,
215 user: self.user.as_deref(),
216 })?;
217
218 let req = self
219 .client
220 .post(self.client.ext().embeddings_path())?
221 .body(body)
222 .map_err(|e| EmbeddingError::HttpError(e.into()))?;
223
224 let response = self.client.send(req).await?;
225
226 let status = response.status();
227 if status.is_success() {
228 let response_body: Vec<u8> = response.into_body().await?;
229 let parsed: ApiResponse<CompatibleEmbeddingResponse> =
230 serde_json::from_slice(&response_body)?;
231
232 match parsed {
233 ApiResponse::Ok(response) => {
234 tracing::info!(target: "rig",
235 "embedding token usage: {:?}",
236 response.usage
237 );
238
239 if response.data.len() != documents.len() {
240 return Err(EmbeddingError::ResponseError(
241 "Response data length does not match input length".into(),
242 ));
243 }
244
245 let usage = match response.usage {
246 Some(usage) => crate::completion::Usage {
247 input_tokens: usage.prompt_tokens as u64,
248 output_tokens: 0,
249 total_tokens: usage.total_tokens as u64,
250 cached_input_tokens: usage
251 .prompt_tokens_details
252 .as_ref()
253 .map_or(0, |details| details.cached_tokens as u64),
254 cache_creation_input_tokens: 0,
255 tool_use_prompt_tokens: 0,
256 reasoning_tokens: 0,
257 },
258 None if Ext::REQUIRES_USAGE => {
259 return Err(EmbeddingError::MissingUsage {
260 provider: Ext::PROVIDER_NAME,
261 });
262 }
263 None => crate::completion::Usage::new(),
264 };
265
266 let embeddings = response
267 .data
268 .into_iter()
269 .zip(documents.into_iter())
270 .map(|(embedding, document)| embeddings::Embedding {
271 document,
272 vec: embedding
273 .embedding
274 .into_iter()
275 .filter_map(|n| n.as_f64())
276 .collect(),
277 })
278 .collect();
279
280 Ok(embeddings::EmbeddingResponse { embeddings, usage })
281 }
282 ApiResponse::Err(err) => {
283 tracing::warn!(message = %err.message, "provider returned an error response");
284 Err(EmbeddingError::from_http_response(
285 status,
286 String::from_utf8_lossy(&response_body).into_owned(),
287 ))
288 }
289 }
290 } else {
291 let text = http_client::text(response).await?;
292 Err(EmbeddingError::from_http_response(status, text))
293 }
294 }
295}
296
297impl<Ext, H> GenericEmbeddingModel<Ext, H>
298where
299 Ext: crate::client::Provider,
300{
301 pub fn new(
302 client: crate::client::Client<Ext, H>,
303 model: impl Into<String>,
304 ndims: usize,
305 ) -> Self {
306 Self {
307 client,
308 model: model.into(),
309 encoding_format: None,
310 ndims,
311 user: None,
312 }
313 }
314
315 pub fn with_model(client: crate::client::Client<Ext, H>, model: &str, ndims: usize) -> Self {
316 Self {
317 client,
318 model: model.into(),
319 encoding_format: None,
320 ndims,
321 user: None,
322 }
323 }
324
325 pub fn with_encoding_format(
326 client: crate::client::Client<Ext, H>,
327 model: &str,
328 ndims: usize,
329 encoding_format: EncodingFormat,
330 ) -> Self {
331 Self {
332 client,
333 model: model.into(),
334 encoding_format: Some(encoding_format),
335 ndims,
336 user: None,
337 }
338 }
339
340 pub fn encoding_format(mut self, encoding_format: EncodingFormat) -> Self {
341 self.encoding_format = Some(encoding_format);
342 self
343 }
344
345 pub fn user(mut self, user: impl Into<String>) -> Self {
346 self.user = Some(user.into());
347 self
348 }
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354 use crate::client::EmbeddingsClient;
355 use crate::embeddings::EmbeddingModel as _;
356 use crate::http_client::{LazyBody, MultipartForm, Request, Response, StreamingResponse};
357 use crate::providers::openai::CompletionsClient;
358 use crate::test_utils::RecordingHttpClient;
359 use bytes::Bytes;
360 use std::future::{self, Future};
361
362 #[derive(Clone)]
363 struct CustomHttpClient;
364
365 impl HttpClientExt for CustomHttpClient {
366 fn send<T, U>(
367 &self,
368 _req: Request<T>,
369 ) -> impl Future<Output = http_client::Result<Response<LazyBody<U>>>> + WasmCompatSend + 'static
370 where
371 T: Into<Bytes> + WasmCompatSend,
372 U: From<Bytes> + WasmCompatSend + 'static,
373 {
374 future::ready(Err(http_client::Error::StreamEnded))
375 }
376
377 fn send_multipart<U>(
378 &self,
379 _req: Request<MultipartForm>,
380 ) -> impl Future<Output = http_client::Result<Response<LazyBody<U>>>> + WasmCompatSend + 'static
381 where
382 U: From<Bytes> + WasmCompatSend + 'static,
383 {
384 future::ready(Err(http_client::Error::StreamEnded))
385 }
386
387 fn send_streaming<T>(
388 &self,
389 _req: Request<T>,
390 ) -> impl Future<Output = http_client::Result<StreamingResponse>> + WasmCompatSend
391 where
392 T: Into<Bytes> + WasmCompatSend,
393 {
394 future::ready(Err(http_client::Error::StreamEnded))
395 }
396 }
397
398 const RESPONSE_BODY: &str = r#"{
399 "object": "list",
400 "model": "text-embedding-3-small",
401 "usage": { "prompt_tokens": 4, "total_tokens": 4 },
402 "data": [{ "object": "embedding", "index": 0, "embedding": [0.1, 0.2] }]
403 }"#;
404
405 #[test]
406 fn embedding_model_accepts_backend_without_default_or_debug() {
407 let client = CompletionsClient::builder()
408 .api_key("test-key")
409 .http_client(CustomHttpClient)
410 .build()
411 .expect("build client");
412
413 let model = client.embedding_model(TEXT_EMBEDDING_3_SMALL);
414
415 assert_eq!(model.ndims(), 1_536);
416 }
417
418 #[tokio::test]
419 async fn openai_embeddings_preserve_path_parameters_and_usage() {
420 let http_client = RecordingHttpClient::new(RESPONSE_BODY);
421 let client = CompletionsClient::builder()
422 .api_key("test-key")
423 .http_client(http_client.clone())
424 .build()
425 .expect("build client");
426 let model = client
427 .embedding_model(TEXT_EMBEDDING_3_SMALL)
428 .encoding_format(EncodingFormat::Float)
429 .user("user-123");
430
431 let response = model
432 .embed_texts_with_usage(["hello".to_string()])
433 .await
434 .expect("embedding should succeed");
435
436 assert_eq!(response.usage.input_tokens, 4);
437 assert_eq!(response.usage.total_tokens, 4);
438 let requests = http_client.requests();
439 assert_eq!(requests[0].uri, "https://api.openai.com/v1/embeddings");
440 let body: serde_json::Value =
441 serde_json::from_slice(&requests[0].body).expect("request body should be JSON");
442 assert_eq!(body["dimensions"], serde_json::json!(1_536));
443 assert_eq!(body["encoding_format"], serde_json::json!("float"));
444 assert_eq!(body["user"], serde_json::json!("user-123"));
445 }
446
447 #[tokio::test]
448 async fn openai_rejects_base64_before_sending() {
449 let http_client = RecordingHttpClient::new(RESPONSE_BODY);
450 let client = CompletionsClient::builder()
451 .api_key("test-key")
452 .http_client(http_client.clone())
453 .build()
454 .expect("build client");
455 let model = client
456 .embedding_model(TEXT_EMBEDDING_3_SMALL)
457 .encoding_format(EncodingFormat::Base64);
458
459 let error = model
460 .embed_texts(["hello".to_string()])
461 .await
462 .expect_err("numeric response parser should reject base64");
463
464 assert!(matches!(
465 error,
466 EmbeddingError::UnsupportedResponseEncoding {
467 provider: "openai",
468 encoding_format: "base64"
469 }
470 ));
471 assert!(http_client.requests().is_empty());
472 }
473
474 #[test]
475 fn public_openai_embedding_response_requires_usage() {
476 let body = r#"{
477 "object": "list",
478 "model": "text-embedding-3-small",
479 "data": [{ "object": "embedding", "index": 0, "embedding": [0.1] }]
480 }"#;
481
482 assert!(serde_json::from_str::<EmbeddingResponse>(body).is_err());
483 }
484
485 #[tokio::test]
486 async fn embedding_preserves_raw_provider_error_json_on_api_error_envelope() {
487 let body = r#"{"message":"embedding quota exceeded","type":"insufficient_quota"}"#;
488 let http_client =
489 RecordingHttpClient::with_error_response(http::StatusCode::ACCEPTED, body);
490 let client = CompletionsClient::builder()
491 .api_key("test-key")
492 .http_client(http_client)
493 .build()
494 .expect("build client");
495 let model = client.embedding_model("text-embedding-3-small");
496
497 let error = model
498 .embed_texts(["hello".to_string()])
499 .await
500 .expect_err("embedding should fail with provider error envelope");
501
502 match &error {
503 EmbeddingError::ProviderResponse(stored) => {
504 assert_eq!(stored.body, body);
505 assert_eq!(stored.status, Some(http::StatusCode::ACCEPTED));
506 assert_eq!(error.provider_response_body(), Some(body));
507 let json = error
508 .provider_response_json()
509 .expect("raw body should be valid JSON")
510 .expect("parsed JSON should be present");
511 assert_eq!(json["type"], "insufficient_quota");
512 }
513 other => panic!("expected ProviderResponse, got {other:?}"),
514 }
515 }
516
517 #[tokio::test]
518 async fn embedding_http_non_success_preserves_status_and_body() {
519 let body = r#"{"error":{"message":"invalid api key","type":"invalid_request_error"}}"#;
520 let http_client =
521 RecordingHttpClient::with_error_response(http::StatusCode::UNAUTHORIZED, body);
522 let client = CompletionsClient::builder()
523 .api_key("test-key")
524 .http_client(http_client)
525 .build()
526 .expect("build client");
527 let model = client.embedding_model("text-embedding-3-small");
528
529 let error = model
530 .embed_texts(["hello".to_string()])
531 .await
532 .expect_err("embedding should fail with non-success status");
533
534 assert!(matches!(error, EmbeddingError::HttpError(_)));
535 assert_eq!(
536 error.provider_response_status(),
537 Some(http::StatusCode::UNAUTHORIZED)
538 );
539 assert_eq!(error.provider_response_body(), Some(body));
540 }
541}