Skip to main content

rig_core/providers/mistral/
client.rs

1use crate::{
2    client::{self, BearerAuth, DebugExt, Provider},
3    providers::mistral::MistralModelLister,
4};
5use serde::{Deserialize, Serialize};
6use std::fmt::Debug;
7
8const MISTRAL_API_BASE_URL: &str = "https://api.mistral.ai";
9
10#[derive(Debug, Default, Clone, Copy)]
11pub struct MistralExt;
12#[derive(Debug, Default, Clone, Copy)]
13pub struct MistralBuilder;
14
15type MistralApiKey = BearerAuth;
16
17pub type Client<H = reqwest::Client> = client::Client<MistralExt, H>;
18pub type ClientBuilder<H = crate::markers::Missing> =
19    client::ClientBuilder<MistralBuilder, MistralApiKey, H>;
20
21impl Provider for MistralExt {
22    type Builder = MistralBuilder;
23    // The client base URL is the bare host, so every Mistral path carries its
24    // own `/v1` — as `completion_path` and `MistralModelLister` already do.
25    // `/models` is a gateway 404 ("no Route matched with those values"), which
26    // made `verify()` fail for every key, valid or not.
27    const VERIFY_PATH: &'static str = "/v1/models";
28}
29
30impl crate::providers::openai::completion::OpenAICompatibleProvider for MistralExt {
31    const PROVIDER_NAME: &'static str = "mistral";
32
33    /// Mistral labels its transport request id `mistral-correlation-id`, and
34    /// sends it on every response — success and error alike. It also mirrors
35    /// the same value under the gateway's `x-kong-request-id`; the
36    /// provider-branded spelling is the one rig reads.
37    const REQUEST_ID_HEADER: Option<&'static str> = Some("mistral-correlation-id");
38
39    type StreamingUsage = Usage;
40
41    const EMITS_COMPLETE_SINGLE_CHUNK_TOOL_CALLS: bool = true;
42
43    // Mistral is strict about unknown parameters and reports usage on the
44    // final stream chunk without `stream_options`.
45    const STREAM_INCLUDE_USAGE: bool = false;
46
47    type Response = super::CompletionResponse;
48
49    // The client base URL is the bare host; other Mistral capabilities
50    // (embeddings, transcription, model listing) build their own v1 paths.
51    fn completion_path(&self, _model: &str) -> String {
52        "/v1/chat/completions".to_string()
53    }
54
55    fn finalize_request_body(
56        &self,
57        body: &mut serde_json::Value,
58    ) -> Result<(), crate::completion::CompletionError> {
59        let Some(map) = body.as_object_mut() else {
60            return Ok(());
61        };
62
63        // Mistral spells the "must call some tool" mode `any`, not `required`.
64        if let Some(tool_choice) = map.get_mut("tool_choice")
65            && tool_choice.as_str() == Some("required")
66        {
67            *tool_choice = serde_json::Value::String("any".to_string());
68        }
69
70        // Mistral accepts a *structured* response format beside tools only
71        // under `tool_choice: auto` (or `none`): anything that forces a call
72        // is a 400, "`json_schema` response type with tools is only compatible
73        // with `tool_choice: auto`". Rig reaches that combination on its own —
74        // a structured-output agent defers `response_format` until a tool
75        // result exists, then emits it beside the caller's standing
76        // `tool_choice`, so the turn after the first tool call dies. Relaxing
77        // the choice keeps both features working; dropping the response format
78        // instead would silently discard the schema the caller asked for.
79        //
80        // Keyed on the format's *type* rather than its presence: the
81        // constraint is specific to `json_schema` and `json_object`, and
82        // `{"type": "text"}` — the API default, which a caller can still pass
83        // explicitly — rides beside a forced choice happily.
84        let forces_a_tool_call = map
85            .get("tool_choice")
86            .is_some_and(|choice| !matches!(choice.as_str(), Some("auto" | "none")));
87        let has_tools = map
88            .get("tools")
89            .and_then(serde_json::Value::as_array)
90            .is_some_and(|tools| !tools.is_empty());
91        let has_structured_format = map
92            .get("response_format")
93            .and_then(|format| format.get("type"))
94            .and_then(serde_json::Value::as_str)
95            .is_some_and(|kind| matches!(kind, "json_schema" | "json_object"));
96        if forces_a_tool_call && has_tools && has_structured_format {
97            tracing::debug!(
98                "relaxing tool_choice to `auto`: Mistral rejects a forced tool choice \
99                 alongside a response format"
100            );
101            map.insert(
102                "tool_choice".to_string(),
103                serde_json::Value::String("auto".to_string()),
104            );
105        }
106
107        if let Some(messages) = map
108            .get_mut("messages")
109            .and_then(serde_json::Value::as_array_mut)
110        {
111            for message in messages {
112                let Some(message) = message.as_object_mut() else {
113                    continue;
114                };
115                let is_assistant =
116                    message.get("role").and_then(serde_json::Value::as_str) == Some("assistant");
117
118                // Mistral takes text-only message `content` as a plain string
119                // and carries images, audio and documents as its own chunk
120                // array. Content it has no chunk for fails here rather than
121                // reaching the API with the part removed.
122                if let Some(content) = message.get_mut("content") {
123                    super::completion::normalize_request_content(content)?;
124                }
125
126                if is_assistant {
127                    if !message.contains_key("content") {
128                        message.insert(
129                            "content".to_string(),
130                            serde_json::Value::String(String::new()),
131                        );
132                    }
133                    // `prefix` is part of Mistral's assistant message schema.
134                    message
135                        .entry("prefix")
136                        .or_insert(serde_json::Value::Bool(false));
137                    // Mistral rejects unknown assistant fields; hidden
138                    // reasoning cannot be echoed back.
139                    message.remove("reasoning_content");
140                }
141            }
142        }
143
144        Ok(())
145    }
146}
147
148client::impl_capabilities!(
149    MistralExt,
150    completion = super::CompletionModel<H>,
151    embeddings = super::EmbeddingModel<H>,
152    transcription = super::TranscriptionModel<H>,
153    model_listing = MistralModelLister<H>,
154);
155
156impl DebugExt for MistralExt {}
157
158client::impl_default_provider_builder!(
159    MistralBuilder => MistralExt,
160    api_key = MistralApiKey,
161    base_url = MISTRAL_API_BASE_URL,
162);
163
164client::impl_provider_client!(Client, input = String, api_key_env = "MISTRAL_API_KEY");
165
166/// In-depth details on prompt tokens.
167///
168/// Mirrors Mistral's `PromptTokensDetails` schema. The Mistral API also exposes
169/// the same shape under the singular field name `prompt_token_details`; the
170/// `Usage` field accepts either form via `serde(alias = ...)`.
171#[derive(Clone, Debug, Default, Deserialize, Serialize)]
172pub struct PromptTokensDetails {
173    /// Number of tokens served from the prompt cache.
174    #[serde(default)]
175    pub cached_tokens: u64,
176    /// Tokens the audio-input models charge for the prompt's audio. Reported
177    /// *alongside* `prompt_tokens` rather than inside it — the two plus
178    /// `completion_tokens` are what add up to `total_tokens`.
179    #[serde(default)]
180    pub audio_tokens: u64,
181}
182
183/// Token usage returned by Mistral's chat completions and embeddings endpoints.
184///
185/// See <https://docs.mistral.ai/api/> (`UsageInfo` schema). The three counts are
186/// always present; the remaining fields are populated by Mistral on a best-effort
187/// basis (e.g. cached-token information appears once a prompt is large enough to
188/// be cached).
189#[derive(Clone, Debug, Default, Deserialize, Serialize)]
190pub struct Usage {
191    pub completion_tokens: usize,
192    pub prompt_tokens: usize,
193    pub total_tokens: usize,
194    /// Capacity tier that served the request, when Mistral reports it.
195    ///
196    /// Although the generated `UsageInfo` reference currently omits this
197    /// field, the live chat-completions wire includes values such as
198    /// `"standard"` in both blocking responses and terminal stream chunks.
199    /// Keeping it here prevents the provider-native `raw_completion` and
200    /// `raw_stream` surfaces from silently discarding that wire metadata.
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub service_tier: Option<String>,
203    /// Duration in seconds of audio tokens in the prompt (audio-input models only).
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub prompt_audio_seconds: Option<u64>,
206    /// Total cached prompt tokens reported at the top level. Some Mistral
207    /// responses populate this in addition to (or instead of)
208    /// `prompt_tokens_details.cached_tokens`.
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub num_cached_tokens: Option<u64>,
211    /// In-depth breakdown of prompt token usage (currently only cached tokens).
212    #[serde(
213        default,
214        alias = "prompt_token_details",
215        skip_serializing_if = "Option::is_none"
216    )]
217    pub prompt_tokens_details: Option<PromptTokensDetails>,
218}
219
220impl Usage {
221    /// Returns the number of cached prompt tokens, preferring the structured
222    /// `prompt_tokens_details.cached_tokens` field and falling back to the
223    /// top-level `num_cached_tokens`. Returns 0 when neither is present.
224    pub fn cached_tokens(&self) -> u64 {
225        self.prompt_tokens_details
226            .as_ref()
227            .map(|d| d.cached_tokens)
228            .or(self.num_cached_tokens)
229            .unwrap_or(0)
230    }
231
232    /// Tokens charged for audio in the prompt. 0 for every non-audio turn.
233    pub fn audio_tokens(&self) -> u64 {
234        self.prompt_tokens_details
235            .as_ref()
236            .map_or(0, |details| details.audio_tokens)
237    }
238
239    /// Every token charged against the prompt.
240    ///
241    /// Mistral reports audio outside `prompt_tokens`: a Voxtral turn answering
242    /// a 375-audio-token clip reports `prompt_tokens: 6`, `audio_tokens: 375`,
243    /// `completion_tokens: 2` and `total_tokens: 383`. Counting only
244    /// `prompt_tokens` as input leaves `input + output` short of `total` by the
245    /// whole audio payload.
246    pub fn input_tokens(&self) -> u64 {
247        self.prompt_tokens as u64 + self.audio_tokens()
248    }
249}
250
251impl From<&Usage> for crate::completion::Usage {
252    fn from(usage: &Usage) -> Self {
253        crate::providers::internal::completion_usage(
254            usage.input_tokens(),
255            usage.completion_tokens as u64,
256            usage.total_tokens as u64,
257            usage.cached_tokens(),
258        )
259    }
260}
261
262impl From<Usage> for crate::completion::Usage {
263    fn from(usage: Usage) -> Self {
264        Self::from(&usage)
265    }
266}
267
268impl std::fmt::Display for Usage {
269    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270        write!(
271            f,
272            "Prompt tokens: {} Total tokens: {}",
273            self.prompt_tokens, self.total_tokens
274        )
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::Usage;
281
282    #[test]
283    fn test_client_initialization() {
284        let _client =
285            crate::providers::mistral::Client::new("dummy-key").expect("Client::new() failed");
286        let builder: crate::providers::mistral::ClientBuilder =
287            crate::providers::mistral::Client::builder().api_key("dummy-key");
288        let _client_from_builder = builder.build().expect("Client::builder() failed");
289    }
290
291    #[test]
292    fn usage_retains_live_service_tier() {
293        let usage: Usage = serde_json::from_value(serde_json::json!({
294            "completion_tokens": 4,
295            "prompt_tokens": 20,
296            "total_tokens": 24,
297            "prompt_tokens_details": { "cached_tokens": 0 },
298            "service_tier": "standard"
299        }))
300        .expect("live Mistral usage should deserialize");
301
302        assert_eq!(usage.service_tier.as_deref(), Some("standard"));
303        assert_eq!(
304            serde_json::to_value(usage).expect("Mistral usage should serialize")["service_tier"],
305            "standard"
306        );
307    }
308}