Skip to main content

rig_core/providers/openai/
embedding.rs

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
8// ================================================================
9// OpenAI Embedding API
10// ================================================================
11/// `text-embedding-3-large` embedding model
12pub const TEXT_EMBEDDING_3_LARGE: &str = "text-embedding-3-large";
13/// `text-embedding-3-small` embedding model
14pub const TEXT_EMBEDDING_3_SMALL: &str = "text-embedding-3-small";
15/// `text-embedding-ada-002` embedding model
16pub 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/// Provider-specific spelling for an embedding dimension request field.
38#[doc(hidden)]
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum EmbeddingDimensions {
41    /// Serialize the value as the OpenAI-compatible `dimensions` field.
42    Dimensions(usize),
43    /// Serialize the value as Mistral's `output_dimension` field.
44    OutputDimension(usize),
45}
46
47/// Contract for provider extensions that speak an OpenAI-compatible embeddings
48/// wire format through [`GenericEmbeddingModel`].
49#[doc(hidden)]
50pub trait OpenAIEmbeddingsCompatible: crate::client::Provider {
51    /// Provider name used in embedding request and response errors.
52    const PROVIDER_NAME: &'static str;
53
54    /// Whether successful responses from this provider must include usage.
55    const REQUIRES_USAGE: bool = true;
56
57    /// Whether the provider accepts the OpenAI-compatible `encoding_format` field.
58    const SUPPORTS_ENCODING_FORMAT: bool = true;
59
60    /// Whether the provider accepts the OpenAI-compatible `user` field.
61    const SUPPORTS_USER: bool = true;
62
63    /// The request path for embeddings, resolved against the client base URL.
64    fn embeddings_path(&self) -> String {
65        "/embeddings".to_string()
66    }
67
68    /// Validate and select the provider's dimension field.
69    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
124/// The embedding model struct for OpenAI's Embeddings API.
125///
126/// This preserves the historical public generic shape where the first generic
127/// parameter is the HTTP client type.
128pub 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}