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