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    /// Whether the model is sent as a `model` field in the request body.
64    /// Azure routes the deployment through the URL and sends no model field.
65    const SENDS_MODEL_FIELD: bool = true;
66
67    /// Most inputs the provider accepts in one embeddings request.
68    ///
69    /// [`EmbeddingsBuilder`](crate::embeddings::EmbeddingsBuilder) chunks by
70    /// this, so a value above the provider's real cap turns a large job into a
71    /// rejected request rather than more round trips. OpenAI's 1024 is the
72    /// default; providers with a smaller cap override it.
73    const MAX_DOCUMENTS: usize = 1024;
74
75    /// Output dimensions for a model the provider knows by name, used when the
76    /// caller did not state them. The default consults OpenAI's own table;
77    /// providers with their own models override it, because a model missing
78    /// from every table reports `ndims() == 0`.
79    fn default_ndims(model: &str) -> Option<usize> {
80        model_dimensions_from_identifier(model)
81    }
82
83    /// The request path for embeddings, resolved against the client base URL.
84    fn embeddings_path(&self) -> String {
85        "/embeddings".to_string()
86    }
87
88    /// The request path for embeddings for a given model. Providers that
89    /// route the model through the URL (Azure deployments) override this;
90    /// everyone else inherits [`OpenAIEmbeddingsCompatible::embeddings_path`].
91    fn embeddings_path_for_model(&self, _model: &str) -> String {
92        self.embeddings_path()
93    }
94
95    /// Validate and select the provider's dimension field.
96    fn embedding_dimensions(
97        &self,
98        model: &str,
99        dimensions: Option<usize>,
100    ) -> Result<Option<EmbeddingDimensions>, EmbeddingError> {
101        // OpenAI's legacy Ada model does not accept `dimensions`. Keep that
102        // OpenAI-specific exception in the provider hook so another
103        // OpenAI-compatible provider can validate an identically named model.
104        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
158/// The embedding model struct for OpenAI's Embeddings API.
159///
160/// This preserves the historical public generic shape where the first generic
161/// parameter is the HTTP client type.
162pub type EmbeddingModel<H = reqwest::Client> = GenericEmbeddingModel<super::OpenAIResponsesExt, H>;
163
164/// Default dimensions for OpenAI's known embedding models (also used by
165/// Azure OpenAI, which deploys the same models).
166pub(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}