agent_framework_mistral/
lib.rs1pub mod convert;
42pub mod embeddings;
43pub use embeddings::{MistralEmbeddingClient, DEFAULT_EMBEDDING_MODEL};
44
45use std::sync::Arc;
46
47use agent_framework_core::client::{ChatClient, ChatStream};
48use agent_framework_core::error::{Error, Result};
49use agent_framework_core::types::{ChatOptions, ChatResponse, Message};
50use futures::StreamExt;
51use serde_json::Value;
52
53pub(crate) const DEFAULT_BASE_URL: &str = "https://api.mistral.ai/v1";
54
55fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option<f64> {
60 headers
61 .get(reqwest::header::RETRY_AFTER)
62 .and_then(|v| v.to_str().ok())
63 .and_then(|s| s.trim().parse::<f64>().ok())
64 .filter(|s| s.is_finite() && *s >= 0.0)
65}
66
67#[derive(Clone)]
69pub struct MistralChatClient {
70 inner: Arc<Inner>,
71}
72
73#[derive(Clone)]
74struct Inner {
75 http: reqwest::Client,
76 api_key: String,
77 base_url: String,
78 model: String,
79}
80
81impl std::fmt::Debug for MistralChatClient {
82 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 f.debug_struct("MistralChatClient")
84 .field("base_url", &self.inner.base_url)
85 .field("model", &self.inner.model)
86 .finish_non_exhaustive()
87 }
88}
89
90impl MistralChatClient {
91 pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Self {
93 Self {
94 inner: Arc::new(Inner {
95 http: reqwest::Client::new(),
96 api_key: api_key.into(),
97 base_url: DEFAULT_BASE_URL.to_string(),
98 model: model.into(),
99 }),
100 }
101 }
102
103 pub fn from_env(model: impl Into<String>) -> Result<Self> {
110 let key = std::env::var("MISTRAL_API_KEY")
111 .map_err(|_| Error::Configuration("MISTRAL_API_KEY is not set".into()))?;
112 let mut client = Self::new(key, model);
113 if let Ok(base) = std::env::var("MISTRAL_BASE_URL") {
114 client = client.with_base_url(base);
115 }
116 Ok(client)
117 }
118
119 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
121 Arc::make_mut(&mut self.inner).base_url = base_url.into();
122 self
123 }
124
125 pub fn model(&self) -> &str {
127 &self.inner.model
128 }
129
130 fn url(&self) -> String {
131 format!(
132 "{}/chat/completions",
133 self.inner.base_url.trim_end_matches('/')
134 )
135 }
136
137 fn build_body(&self, messages: &[Message], options: &ChatOptions, stream: bool) -> Value {
138 let model = options
139 .model
140 .clone()
141 .unwrap_or_else(|| self.inner.model.clone());
142 convert::build_request(messages, options, &model, stream)
143 }
144
145 async fn post(&self, body: &Value) -> Result<reqwest::Response> {
146 let resp = self
147 .inner
148 .http
149 .post(self.url())
150 .bearer_auth(&self.inner.api_key)
151 .json(body)
152 .send()
153 .await
154 .map_err(|e| Error::service(format!("request failed: {e}")))?;
155 if !resp.status().is_success() {
156 let status = resp.status();
157 let retry_after = parse_retry_after(resp.headers());
158 let text = resp.text().await.unwrap_or_default();
159 return Err(convert::classify_mistral_error(
160 status.as_u16(),
161 format!("Mistral API error {status}: {text}"),
162 retry_after,
163 ));
164 }
165 Ok(resp)
166 }
167}
168
169#[async_trait::async_trait]
170impl ChatClient for MistralChatClient {
171 async fn get_response(
172 &self,
173 messages: Vec<Message>,
174 options: ChatOptions,
175 ) -> Result<ChatResponse> {
176 let body = self.build_body(&messages, &options, false);
177 let resp = self.post(&body).await?;
178 let value: Value = resp
179 .json()
180 .await
181 .map_err(|e| Error::service(format!("invalid response json: {e}")))?;
182 Ok(convert::parse_response(&value))
183 }
184
185 async fn get_streaming_response(
186 &self,
187 messages: Vec<Message>,
188 options: ChatOptions,
189 ) -> Result<ChatStream> {
190 let body = self.build_body(&messages, &options, true);
191 let resp = self.post(&body).await?;
192 Ok(agent_framework_openai::parse_sse_stream(resp).boxed())
198 }
199
200 fn model(&self) -> Option<&str> {
201 Some(&self.inner.model)
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208
209 fn client() -> MistralChatClient {
210 MistralChatClient::new("test-key", "mistral-large-latest")
211 }
212
213 #[test]
216 fn url_uses_default_base_url() {
217 let c = client();
218 assert_eq!(c.url(), "https://api.mistral.ai/v1/chat/completions");
219 }
220
221 #[test]
222 fn with_base_url_overrides_default_and_trims_trailing_slash() {
223 let c = client().with_base_url("https://proxy.example.com/v1/");
224 assert_eq!(c.url(), "https://proxy.example.com/v1/chat/completions");
225 }
226
227 #[test]
232 fn build_body_defaults_to_client_model() {
233 let c = client();
234 let body = c.build_body(&[Message::user("hi")], &ChatOptions::new(), false);
235 assert_eq!(body["model"], serde_json::json!("mistral-large-latest"));
236 }
237
238 #[test]
239 fn build_body_per_request_model_overrides_client_default() {
240 let c = client();
241 let options = ChatOptions::new().with_model("mistral-small-latest");
242 let body = c.build_body(&[Message::user("hi")], &options, false);
243 assert_eq!(body["model"], serde_json::json!("mistral-small-latest"));
244 }
245
246 #[test]
247 fn build_body_stream_flag() {
248 let c = client();
249 let body = c.build_body(&[Message::user("hi")], &ChatOptions::new(), true);
250 assert_eq!(body["stream"], serde_json::json!(true));
251 }
252
253 static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
274
275 #[test]
276 fn from_env_reads_api_key_and_base_url() {
277 let _guard = ENV_MUTEX.lock().unwrap();
278 unsafe {
281 std::env::set_var("MISTRAL_API_KEY", "test-key-123");
282 std::env::set_var("MISTRAL_BASE_URL", "https://example.test/v1");
283 }
284 let client = MistralChatClient::from_env("mistral-large-latest").unwrap();
285 assert_eq!(client.inner.api_key, "test-key-123");
286 assert_eq!(client.inner.base_url, "https://example.test/v1");
287 unsafe {
288 std::env::remove_var("MISTRAL_API_KEY");
289 std::env::remove_var("MISTRAL_BASE_URL");
290 }
291 }
292
293 #[test]
294 fn from_env_errors_when_api_key_missing() {
295 let _guard = ENV_MUTEX.lock().unwrap();
296 unsafe {
298 std::env::remove_var("MISTRAL_API_KEY");
299 std::env::remove_var("MISTRAL_BASE_URL");
300 }
301 let result = MistralChatClient::from_env("mistral-large-latest");
302 assert!(result.is_err());
303 }
304
305 #[test]
306 fn from_env_defaults_base_url_when_unset() {
307 let _guard = ENV_MUTEX.lock().unwrap();
308 unsafe {
310 std::env::set_var("MISTRAL_API_KEY", "test-key-123");
311 std::env::remove_var("MISTRAL_BASE_URL");
312 }
313 let client = MistralChatClient::from_env("mistral-large-latest").unwrap();
314 assert_eq!(client.inner.base_url, DEFAULT_BASE_URL);
315 unsafe {
316 std::env::remove_var("MISTRAL_API_KEY");
317 }
318 }
319
320 #[test]
323 fn model_returns_default_model() {
324 let c = client();
325 assert_eq!(c.model(), "mistral-large-latest");
326 assert_eq!(ChatClient::model(&c), Some("mistral-large-latest"));
327 }
328}