Skip to main content

rig_core/providers/cohere/
embeddings.rs

1use super::{client::ApiResponse, client::Client};
2use crate::{
3    embeddings::{self, EmbeddingError},
4    http_client::HttpClientExt,
5    wasm_compat::*,
6};
7use serde::Deserialize;
8use serde_json::json;
9
10#[derive(Deserialize)]
11pub struct EmbeddingResponse {
12    #[serde(default)]
13    pub response_type: Option<String>,
14    pub id: String,
15    pub embeddings: Vec<Vec<serde_json::Number>>,
16    pub texts: Vec<String>,
17    #[serde(default)]
18    pub meta: Option<Meta>,
19}
20
21#[derive(Deserialize)]
22pub struct Meta {
23    pub api_version: ApiVersion,
24    pub billed_units: BilledUnits,
25    #[serde(default)]
26    pub warnings: Vec<String>,
27}
28
29#[derive(Deserialize)]
30pub struct ApiVersion {
31    pub version: String,
32    #[serde(default)]
33    pub is_deprecated: Option<bool>,
34    #[serde(default)]
35    pub is_experimental: Option<bool>,
36}
37
38#[derive(Deserialize, Debug)]
39pub struct BilledUnits {
40    #[serde(default)]
41    pub input_tokens: u32,
42    #[serde(default)]
43    pub output_tokens: u32,
44    #[serde(default)]
45    pub search_units: u32,
46    #[serde(default)]
47    pub classifications: u32,
48}
49
50impl std::fmt::Display for BilledUnits {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        write!(
53            f,
54            "Input tokens: {}\nOutput tokens: {}\nSearch units: {}\nClassifications: {}",
55            self.input_tokens, self.output_tokens, self.search_units, self.classifications
56        )
57    }
58}
59
60#[derive(Clone)]
61pub struct EmbeddingModel<T = reqwest::Client> {
62    client: Client<T>,
63    pub model: String,
64    pub input_type: String,
65    ndims: usize,
66}
67
68impl<T> embeddings::EmbeddingModel for EmbeddingModel<T>
69where
70    T: HttpClientExt + Clone + WasmCompatSend + WasmCompatSync + 'static,
71{
72    const MAX_DOCUMENTS: usize = 96;
73    type Client = Client<T>;
74
75    fn make(client: &Self::Client, model: impl Into<String>, dims: Option<usize>) -> Self {
76        let model = model.into();
77        let dims = dims
78            .or(super::model_dimensions_from_identifier(&model))
79            .unwrap_or_default();
80
81        Self::new(client.clone(), model, "search_document", dims)
82    }
83
84    fn ndims(&self) -> usize {
85        self.ndims
86    }
87
88    async fn embed_texts(
89        &self,
90        documents: impl IntoIterator<Item = String>,
91    ) -> Result<Vec<embeddings::Embedding>, EmbeddingError> {
92        let documents = documents.into_iter().collect::<Vec<_>>();
93
94        let body = json!({
95            "model": self.model.to_string(),
96            "texts": documents,
97            "input_type": self.input_type
98        });
99
100        let body = serde_json::to_vec(&body)?;
101
102        let req = self
103            .client
104            .post("/v1/embed")?
105            .body(body)
106            .map_err(|e| EmbeddingError::HttpError(e.into()))?;
107
108        let response = self
109            .client
110            .send::<_, Vec<u8>>(req)
111            .await
112            .map_err(EmbeddingError::HttpError)?;
113
114        let status = response.status();
115        let raw_body = response.into_body().await?;
116
117        if status.is_success() {
118            let body: ApiResponse<EmbeddingResponse> = serde_json::from_slice(raw_body.as_slice())?;
119
120            match body {
121                ApiResponse::Ok(response) => {
122                    match response.meta {
123                        Some(meta) => tracing::info!(target: "rig",
124                            "Cohere embeddings billed units: {}",
125                            meta.billed_units,
126                        ),
127                        None => tracing::info!(target: "rig",
128                            "Cohere embeddings billed units: n/a",
129                        ),
130                    };
131
132                    if response.embeddings.len() != documents.len() {
133                        return Err(EmbeddingError::DocumentError(
134                            format!(
135                                "Expected {} embeddings, got {}",
136                                documents.len(),
137                                response.embeddings.len()
138                            )
139                            .into(),
140                        ));
141                    }
142
143                    Ok(response
144                        .embeddings
145                        .into_iter()
146                        .zip(documents.into_iter())
147                        .map(|(embedding, document)| embeddings::Embedding {
148                            document,
149                            vec: embedding.into_iter().filter_map(|n| n.as_f64()).collect(),
150                        })
151                        .collect())
152                }
153                ApiResponse::Err(error) => {
154                    tracing::warn!(
155                        message = %error.message,
156                        "Cohere returned an error response"
157                    );
158                    Err(EmbeddingError::from_http_response(
159                        status,
160                        String::from_utf8_lossy(&raw_body),
161                    ))
162                }
163            }
164        } else {
165            Err(EmbeddingError::from_http_response(
166                status,
167                String::from_utf8_lossy(&raw_body),
168            ))
169        }
170    }
171}
172
173impl<T> EmbeddingModel<T> {
174    pub fn new(
175        client: Client<T>,
176        model: impl Into<String>,
177        input_type: &str,
178        ndims: usize,
179    ) -> Self {
180        Self {
181            client,
182            model: model.into(),
183            input_type: input_type.to_string(),
184            ndims,
185        }
186    }
187
188    pub fn with_model(client: Client<T>, model: &str, input_type: &str, ndims: usize) -> Self {
189        Self {
190            client,
191            model: model.into(),
192            input_type: input_type.into(),
193            ndims,
194        }
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[tokio::test]
203    async fn embeddings_non_success_preserves_status_and_body() {
204        use crate::embeddings::EmbeddingModel as _;
205        use crate::test_utils::RecordingHttpClient;
206
207        let body = r#"{"error":{"message":"boom"}}"#;
208        let http_client =
209            RecordingHttpClient::with_error_response(http::StatusCode::SERVICE_UNAVAILABLE, body);
210        let client = crate::providers::cohere::Client::builder()
211            .api_key("test-key")
212            .http_client(http_client)
213            .build()
214            .expect("build client");
215        let model = client.embedding_model(
216            crate::providers::cohere::EMBED_ENGLISH_V3,
217            "search_document",
218        );
219
220        let error = model
221            .embed_texts(["hello".to_string()])
222            .await
223            .expect_err("should fail with non-success status");
224
225        assert!(matches!(error, EmbeddingError::HttpError(_)));
226        assert_eq!(
227            error.provider_response_status(),
228            Some(http::StatusCode::SERVICE_UNAVAILABLE)
229        );
230        assert_eq!(error.provider_response_body(), Some(body));
231    }
232
233    #[tokio::test]
234    async fn embeddings_2xx_error_envelope_preserves_status_and_body() {
235        use crate::embeddings::EmbeddingModel as _;
236        use crate::test_utils::RecordingHttpClient;
237
238        // Deserializes to `ApiResponse::Err(ApiErrorResponse { message })` on a 200 OK.
239        let body = r#"{"message":"boom"}"#;
240        let http_client = RecordingHttpClient::new(body);
241        let client = crate::providers::cohere::Client::builder()
242            .api_key("test-key")
243            .http_client(http_client)
244            .build()
245            .expect("build client");
246        let model = client.embedding_model(
247            crate::providers::cohere::EMBED_ENGLISH_V3,
248            "search_document",
249        );
250
251        let error = model
252            .embed_texts(["hello".to_string()])
253            .await
254            .expect_err("should fail with provider error envelope");
255
256        match &error {
257            EmbeddingError::ProviderResponse(stored) => {
258                assert_eq!(stored.body, body);
259                assert_eq!(stored.status, Some(http::StatusCode::OK));
260            }
261            other => panic!("expected ProviderResponse, got {other:?}"),
262        }
263    }
264}