Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:46:28.377Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

mistral.rs

986 lines · 37.5 KB · rust
1use anyhow::{Result, anyhow};
2use collections::{BTreeMap, HashMap};
3use credentials_provider::CredentialsProvider;
4
5use futures::{FutureExt, Stream, StreamExt, future::BoxFuture, stream::BoxStream};
6use gpui::{App, AppContext, AsyncApp, Context, Entity, Global, SharedString, Task};
7use http_client::{CustomHeaders, HttpClient};
8use language_model::{
9    ApiKeyConfiguration, ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel,
10    LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName,
11    LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
12    LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice,
13    LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, ProviderSettingsView,
14    RateLimiter, Role, StopReason, TokenUsage, env_var,
15};
16pub use mistral::{MISTRAL_API_URL, StreamResponse};
17pub use settings::MistralAvailableModel as AvailableModel;
18use settings::{Settings, SettingsStore};
19use std::pin::Pin;
20use std::sync::{Arc, LazyLock};
21use strum::IntoEnumIterator;
22use ui::IconName;
23
24use language_model::util::{fix_streamed_json, parse_tool_arguments};
25
26const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("mistral");
27const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("Mistral");
28
29const API_KEY_ENV_VAR_NAME: &str = "MISTRAL_API_KEY";
30static API_KEY_ENV_VAR: LazyLock<EnvVar> = env_var!(API_KEY_ENV_VAR_NAME);
31pub(crate) const RESERVED_HEADER_NAMES: &[&str] = &["x-affinity"];
32
33#[derive(Default, Clone, Debug, PartialEq)]
34pub struct MistralSettings {
35    pub api_url: String,
36    pub available_models: Vec<AvailableModel>,
37    pub custom_headers: CustomHeaders,
38}
39
40pub struct MistralLanguageModelProvider {
41    http_client: Arc<dyn HttpClient>,
42    pub state: Entity<State>,
43}
44
45pub struct State {
46    api_key_state: ApiKeyState,
47    credentials_provider: Arc<dyn CredentialsProvider>,
48}
49
50impl State {
51    fn is_authenticated(&self) -> bool {
52        self.api_key_state.has_key()
53    }
54
55    fn set_api_key(&mut self, api_key: Option<String>, cx: &mut Context<Self>) -> Task<Result<()>> {
56        let credentials_provider = self.credentials_provider.clone();
57        let api_url = MistralLanguageModelProvider::api_url(cx);
58        self.api_key_state.store(
59            api_url,
60            api_key,
61            |this| &mut this.api_key_state,
62            credentials_provider,
63            cx,
64        )
65    }
66
67    fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
68        let credentials_provider = self.credentials_provider.clone();
69        let api_url = MistralLanguageModelProvider::api_url(cx);
70        self.api_key_state.load_if_needed(
71            api_url,
72            |this| &mut this.api_key_state,
73            credentials_provider,
74            cx,
75        )
76    }
77}
78
79struct GlobalMistralLanguageModelProvider(Arc<MistralLanguageModelProvider>);
80
81impl Global for GlobalMistralLanguageModelProvider {}
82
83impl MistralLanguageModelProvider {
84    pub fn try_global(cx: &App) -> Option<&Arc<MistralLanguageModelProvider>> {
85        cx.try_global::<GlobalMistralLanguageModelProvider>()
86            .map(|this| &this.0)
87    }
88
89    pub fn global(
90        http_client: Arc<dyn HttpClient>,
91        credentials_provider: Arc<dyn CredentialsProvider>,
92        cx: &mut App,
93    ) -> Arc<Self> {
94        if let Some(this) = cx.try_global::<GlobalMistralLanguageModelProvider>() {
95            return this.0.clone();
96        }
97        let state = cx.new(|cx| {
98            cx.observe_global::<SettingsStore>(|this: &mut State, cx| {
99                let credentials_provider = this.credentials_provider.clone();
100                let api_url = Self::api_url(cx);
101                this.api_key_state.handle_url_change(
102                    api_url,
103                    |this| &mut this.api_key_state,
104                    credentials_provider,
105                    cx,
106                );
107                cx.notify();
108            })
109            .detach();
110            State {
111                api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
112                credentials_provider,
113            }
114        });
115
116        let this = Arc::new(Self { http_client, state });
117        cx.set_global(GlobalMistralLanguageModelProvider(this));
118        cx.global::<GlobalMistralLanguageModelProvider>().0.clone()
119    }
120
121    fn create_language_model(&self, model: mistral::Model) -> Arc<dyn LanguageModel> {
122        Arc::new(MistralLanguageModel {
123            id: LanguageModelId::from(model.id().to_string()),
124            model,
125            state: self.state.clone(),
126            http_client: self.http_client.clone(),
127            request_limiter: RateLimiter::new(4),
128        })
129    }
130
131    fn settings(cx: &App) -> &MistralSettings {
132        &crate::AllLanguageModelSettings::get_global(cx).mistral
133    }
134
135    pub fn api_url(cx: &App) -> SharedString {
136        let api_url = &Self::settings(cx).api_url;
137        if api_url.is_empty() {
138            mistral::MISTRAL_API_URL.into()
139        } else {
140            SharedString::new(api_url.as_str())
141        }
142    }
143}
144
145impl LanguageModelProviderState for MistralLanguageModelProvider {
146    type ObservableEntity = State;
147
148    fn observable_entity(&self) -> Option<Entity<Self::ObservableEntity>> {
149        Some(self.state.clone())
150    }
151}
152
153impl LanguageModelProvider for MistralLanguageModelProvider {
154    fn id(&self) -> LanguageModelProviderId {
155        PROVIDER_ID
156    }
157
158    fn name(&self) -> LanguageModelProviderName {
159        PROVIDER_NAME
160    }
161
162    fn icon(&self) -> IconOrSvg {
163        IconOrSvg::Icon(IconName::AiMistral)
164    }
165
166    fn default_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
167        Some(self.create_language_model(mistral::Model::default()))
168    }
169
170    fn default_fast_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
171        Some(self.create_language_model(mistral::Model::default_fast()))
172    }
173
174    fn provided_models(&self, cx: &App) -> Vec<Arc<dyn LanguageModel>> {
175        let mut models = BTreeMap::default();
176
177        // Add base models from mistral::Model::iter()
178        for model in mistral::Model::iter() {
179            if !matches!(model, mistral::Model::Custom { .. }) {
180                models.insert(model.id().to_string(), model);
181            }
182        }
183
184        // Override with available models from settings
185        for model in &Self::settings(cx).available_models {
186            models.insert(
187                model.name.clone(),
188                mistral::Model::Custom {
189                    name: model.name.clone(),
190                    display_name: model.display_name.clone(),
191                    max_tokens: model.max_tokens,
192                    max_output_tokens: model.max_output_tokens,
193                    max_completion_tokens: model.max_completion_tokens,
194                    supports_tools: model.supports_tools,
195                    supports_images: model.supports_images,
196                    supports_thinking: model.supports_thinking,
197                },
198            );
199        }
200
201        models
202            .into_values()
203            .map(|model| {
204                Arc::new(MistralLanguageModel {
205                    id: LanguageModelId::from(model.id().to_string()),
206                    model,
207                    state: self.state.clone(),
208                    http_client: self.http_client.clone(),
209                    request_limiter: RateLimiter::new(4),
210                }) as Arc<dyn LanguageModel>
211            })
212            .collect()
213    }
214
215    fn is_authenticated(&self, cx: &App) -> bool {
216        self.state.read(cx).is_authenticated()
217    }
218
219    fn authenticate(&self, cx: &mut App) -> Task<Result<(), AuthenticateError>> {
220        self.state.update(cx, |state, cx| state.authenticate(cx))
221    }
222
223    fn settings_view(&self, cx: &mut App) -> Option<ProviderSettingsView> {
224        let state = self.state.read(cx);
225        Some(ProviderSettingsView::ApiKey(ApiKeyConfiguration::new(
226            state.api_key_state.has_key(),
227            state.api_key_state.is_from_env_var(),
228            state.api_key_state.env_var_name().clone(),
229            "https://console.mistral.ai/api-keys".into(),
230        )))
231    }
232
233    fn set_api_key(&self, api_key: Option<String>, cx: &mut App) -> Task<Result<()>> {
234        self.state
235            .update(cx, |state, cx| state.set_api_key(api_key, cx))
236    }
237}
238
239pub struct MistralLanguageModel {
240    id: LanguageModelId,
241    model: mistral::Model,
242    state: Entity<State>,
243    http_client: Arc<dyn HttpClient>,
244    request_limiter: RateLimiter,
245}
246
247impl MistralLanguageModel {
248    fn stream_completion(
249        &self,
250        request: mistral::Request,
251        affinity: Option<String>,
252        cx: &AsyncApp,
253    ) -> BoxFuture<
254        'static,
255        Result<futures::stream::BoxStream<'static, Result<mistral::StreamResponse>>>,
256    > {
257        let http_client = self.http_client.clone();
258
259        let (api_key, api_url, extra_headers) = self.state.read_with(cx, |state, cx| {
260            let api_url = MistralLanguageModelProvider::api_url(cx);
261            let extra_headers = MistralLanguageModelProvider::settings(cx)
262                .custom_headers
263                .clone();
264            (state.api_key_state.key(&api_url), api_url, extra_headers)
265        });
266
267        let future = self.request_limiter.stream(async move {
268            let Some(api_key) = api_key else {
269                return Err(LanguageModelCompletionError::NoApiKey {
270                    provider: PROVIDER_NAME,
271                });
272            };
273            let request = mistral::stream_completion(
274                http_client.as_ref(),
275                &api_url,
276                &api_key,
277                request,
278                affinity,
279                &extra_headers,
280            );
281            let response = request.await?;
282            Ok(response)
283        });
284
285        async move { Ok(future.await?.boxed()) }.boxed()
286    }
287}
288
289impl LanguageModel for MistralLanguageModel {
290    fn id(&self) -> LanguageModelId {
291        self.id.clone()
292    }
293
294    fn name(&self) -> LanguageModelName {
295        LanguageModelName::from(self.model.display_name().to_string())
296    }
297
298    fn provider_id(&self) -> LanguageModelProviderId {
299        PROVIDER_ID
300    }
301
302    fn provider_name(&self) -> LanguageModelProviderName {
303        PROVIDER_NAME
304    }
305
306    fn supports_tools(&self) -> bool {
307        self.model.supports_tools()
308    }
309
310    fn supports_streaming_tools(&self) -> bool {
311        true
312    }
313
314    fn supports_tool_choice(&self, _choice: LanguageModelToolChoice) -> bool {
315        self.model.supports_tools()
316    }
317
318    fn supports_images(&self) -> bool {
319        self.model.supports_images()
320    }
321
322    fn telemetry_id(&self) -> String {
323        format!("mistral/{}", self.model.id())
324    }
325
326    fn max_token_count(&self) -> u64 {
327        self.model.max_token_count()
328    }
329
330    fn max_output_tokens(&self) -> Option<u64> {
331        self.model.max_output_tokens()
332    }
333
334    fn stream_completion(
335        &self,
336        request: LanguageModelRequest,
337        cx: &AsyncApp,
338    ) -> BoxFuture<
339        'static,
340        Result<
341            BoxStream<'static, Result<LanguageModelCompletionEvent, LanguageModelCompletionError>>,
342            LanguageModelCompletionError,
343        >,
344    > {
345        let (request, affinity) =
346            match into_mistral(request, self.model.clone(), self.max_output_tokens()) {
347                Ok(request) => request,
348                Err(error) => return async move { Err(error.into()) }.boxed(),
349            };
350        let stream = self.stream_completion(request, affinity, cx);
351
352        async move {
353            let stream = stream.await?;
354            let mapper = MistralEventMapper::new();
355            Ok(mapper.map_stream(stream).boxed())
356        }
357        .boxed()
358    }
359}
360
361pub fn into_mistral(
362    request: LanguageModelRequest,
363    model: mistral::Model,
364    max_output_tokens: Option<u64>,
365) -> Result<(mistral::Request, Option<String>)> {
366    if request.contains_custom_tool_input() {
367        anyhow::bail!("Mistral does not support custom tools");
368    }
369
370    let stream = true;
371
372    let mut messages = Vec::new();
373    for message in &request.messages {
374        match message.role {
375            Role::User => {
376                let mut message_content = mistral::MessageContent::empty();
377                for content in &message.content {
378                    match content {
379                        MessageContent::Text(text) => {
380                            message_content
381                                .push_part(mistral::MessagePart::Text { text: text.clone() });
382                        }
383                        MessageContent::Image(image_content) => {
384                            if model.supports_images() {
385                                message_content.push_part(mistral::MessagePart::ImageUrl {
386                                    image_url: image_content.to_base64_url(),
387                                });
388                            }
389                        }
390                        MessageContent::Thinking { text, .. } => {
391                            if model.supports_thinking() {
392                                message_content.push_part(mistral::MessagePart::Thinking {
393                                    thinking: vec![mistral::ThinkingPart::Text {
394                                        text: text.clone(),
395                                    }],
396                                });
397                            }
398                        }
399                        MessageContent::RedactedThinking(_) => {}
400                        MessageContent::Compaction(_) => {}
401                        MessageContent::ToolUse(_) => {
402                            // Tool use is not supported in User messages for Mistral
403                        }
404                        MessageContent::ToolResult(tool_result) => {
405                            let mut text_parts: Vec<String> = Vec::new();
406                            for part in &tool_result.content {
407                                match part {
408                                    LanguageModelToolResultContent::Text(text) => {
409                                        text_parts.push(text.to_string());
410                                    }
411                                    LanguageModelToolResultContent::Image(_) => {
412                                        text_parts.push("[Tool responded with an image, but Omega doesn't support these in Mistral models yet]".to_string());
413                                    }
414                                }
415                            }
416                            messages.push(mistral::RequestMessage::Tool {
417                                content: text_parts.join("\n"),
418                                tool_call_id: tool_result.tool_use_id.to_string(),
419                            });
420                        }
421                    }
422                }
423                if !matches!(message_content, mistral::MessageContent::Plain { ref content } if content.is_empty())
424                {
425                    messages.push(mistral::RequestMessage::User {
426                        content: message_content,
427                    });
428                }
429            }
430            Role::Assistant => {
431                for content in &message.content {
432                    match content {
433                        MessageContent::Text(text) if text.is_empty() => {
434                            // Mistral API returns a 400 if there's neither content nor tool_calls
435                        }
436                        MessageContent::Text(text) => {
437                            messages.push(mistral::RequestMessage::Assistant {
438                                content: Some(mistral::MessageContent::Plain {
439                                    content: text.clone(),
440                                }),
441                                tool_calls: Vec::new(),
442                            });
443                        }
444                        MessageContent::Thinking { text, .. } => {
445                            if model.supports_thinking() {
446                                messages.push(mistral::RequestMessage::Assistant {
447                                    content: Some(mistral::MessageContent::Multipart {
448                                        content: vec![mistral::MessagePart::Thinking {
449                                            thinking: vec![mistral::ThinkingPart::Text {
450                                                text: text.clone(),
451                                            }],
452                                        }],
453                                    }),
454                                    tool_calls: Vec::new(),
455                                });
456                            }
457                        }
458                        MessageContent::RedactedThinking(_) => {}
459                        MessageContent::Image(_) => {}
460                        MessageContent::Compaction(_) => {}
461                        MessageContent::ToolUse(tool_use) => {
462                            let input = tool_use.input.as_json().ok_or_else(|| {
463                                anyhow!("Mistral does not support custom tool calls")
464                            })?;
465                            let tool_call = mistral::ToolCall {
466                                id: tool_use.id.to_string(),
467                                content: mistral::ToolCallContent::Function {
468                                    function: mistral::FunctionContent {
469                                        name: tool_use.name.to_string(),
470                                        arguments: serde_json::to_string(input).unwrap_or_default(),
471                                    },
472                                },
473                            };
474
475                            if let Some(mistral::RequestMessage::Assistant { tool_calls, .. }) =
476                                messages.last_mut()
477                            {
478                                tool_calls.push(tool_call);
479                            } else {
480                                messages.push(mistral::RequestMessage::Assistant {
481                                    content: None,
482                                    tool_calls: vec![tool_call],
483                                });
484                            }
485                        }
486                        MessageContent::ToolResult(_) => {
487                            // Tool results are not supported in Assistant messages
488                        }
489                    }
490                }
491            }
492            Role::System => {
493                for content in &message.content {
494                    match content {
495                        MessageContent::Text(text) => {
496                            messages.push(mistral::RequestMessage::System {
497                                content: mistral::MessageContent::Plain {
498                                    content: text.clone(),
499                                },
500                            });
501                        }
502                        MessageContent::Thinking { text, .. } => {
503                            if model.supports_thinking() {
504                                messages.push(mistral::RequestMessage::System {
505                                    content: mistral::MessageContent::Multipart {
506                                        content: vec![mistral::MessagePart::Thinking {
507                                            thinking: vec![mistral::ThinkingPart::Text {
508                                                text: text.clone(),
509                                            }],
510                                        }],
511                                    },
512                                });
513                            }
514                        }
515                        MessageContent::RedactedThinking(_) => {}
516                        MessageContent::Compaction(_) => {}
517                        MessageContent::Image(_)
518                        | MessageContent::ToolUse(_)
519                        | MessageContent::ToolResult(_) => {
520                            // Images and tools are not supported in System messages
521                        }
522                    }
523                }
524            }
525        }
526    }
527
528    Ok((
529        mistral::Request {
530            model: model.id().to_string(),
531            messages,
532            stream,
533            stream_options: if stream {
534                Some(mistral::StreamOptions {
535                    stream_tool_calls: Some(true),
536                })
537            } else {
538                None
539            },
540            max_tokens: max_output_tokens,
541            temperature: request.temperature,
542            response_format: None,
543            tool_choice: match request.tool_choice {
544                Some(LanguageModelToolChoice::Auto) if !request.tools.is_empty() => {
545                    Some(mistral::ToolChoice::Auto)
546                }
547                Some(LanguageModelToolChoice::Any) if !request.tools.is_empty() => {
548                    Some(mistral::ToolChoice::Any)
549                }
550                Some(LanguageModelToolChoice::None) => Some(mistral::ToolChoice::None),
551                _ if !request.tools.is_empty() => Some(mistral::ToolChoice::Auto),
552                _ => None,
553            },
554            parallel_tool_calls: if !request.tools.is_empty() {
555                Some(false)
556            } else {
557                None
558            },
559            tools: request
560                .tools
561                .into_iter()
562                .map(|tool| {
563                    let input_schema = match tool.input {
564                        language_model::LanguageModelRequestToolInput::Function {
565                            input_schema,
566                            ..
567                        } => input_schema,
568                        language_model::LanguageModelRequestToolInput::Custom { .. } => {
569                            return Err(anyhow::anyhow!("Mistral does not support custom tools"));
570                        }
571                    };
572                    Ok(mistral::ToolDefinition::Function {
573                        function: mistral::FunctionDefinition {
574                            name: tool.name,
575                            description: Some(tool.description),
576                            parameters: Some(input_schema),
577                        },
578                    })
579                })
580                .collect::<Result<_>>()?,
581            reasoning_effort: if model.supports_thinking() && request.thinking_allowed {
582                Some(mistral::ReasoningEffort::High)
583            } else {
584                None
585            },
586        },
587        request.thread_id,
588    ))
589}
590
591pub struct MistralEventMapper {
592    tool_calls_by_index: HashMap<usize, RawToolCall>,
593}
594
595impl MistralEventMapper {
596    pub fn new() -> Self {
597        Self {
598            tool_calls_by_index: HashMap::default(),
599        }
600    }
601
602    pub fn map_stream(
603        mut self,
604        events: Pin<Box<dyn Send + Stream<Item = Result<StreamResponse>>>>,
605    ) -> impl Stream<Item = Result<LanguageModelCompletionEvent, LanguageModelCompletionError>>
606    {
607        events.flat_map(move |event| {
608            futures::stream::iter(match event {
609                Ok(event) => self.map_event(event),
610                Err(error) => vec![Err(LanguageModelCompletionError::from(error))],
611            })
612        })
613    }
614
615    pub fn map_event(
616        &mut self,
617        event: mistral::StreamResponse,
618    ) -> Vec<Result<LanguageModelCompletionEvent, LanguageModelCompletionError>> {
619        let Some(choice) = event.choices.first() else {
620            return vec![Err(LanguageModelCompletionError::from(anyhow!(
621                "Response contained no choices"
622            )))];
623        };
624
625        let mut events = Vec::new();
626        if let Some(content) = choice.delta.content.as_ref() {
627            match content {
628                mistral::MessageContentDelta::Text(text) => {
629                    events.push(Ok(LanguageModelCompletionEvent::Text(text.clone())));
630                }
631                mistral::MessageContentDelta::Parts(parts) => {
632                    for part in parts {
633                        match part {
634                            mistral::MessagePart::Text { text } => {
635                                events.push(Ok(LanguageModelCompletionEvent::Text(text.clone())));
636                            }
637                            mistral::MessagePart::Thinking { thinking } => {
638                                for tp in thinking.iter().cloned() {
639                                    match tp {
640                                        mistral::ThinkingPart::Text { text } => {
641                                            events.push(Ok(
642                                                LanguageModelCompletionEvent::Thinking {
643                                                    text,
644                                                    signature: None,
645                                                },
646                                            ));
647                                        }
648                                    }
649                                }
650                            }
651                            mistral::MessagePart::ImageUrl { .. } => {
652                                // We currently don't emit a separate event for images in responses.
653                            }
654                        }
655                    }
656                }
657            }
658        }
659
660        if let Some(tool_calls) = choice.delta.tool_calls.as_ref() {
661            for tool_call in tool_calls {
662                let entry = self.tool_calls_by_index.entry(tool_call.index).or_default();
663
664                if let Some(tool_id) = tool_call.id.clone()
665                    && !tool_id.is_empty()
666                    && tool_id != "null"
667                {
668                    entry.id = tool_id;
669                }
670
671                if let Some(function) = tool_call.function.as_ref() {
672                    if let Some(name) = function.name.clone()
673                        && !name.is_empty()
674                    {
675                        entry.name = name;
676                    }
677
678                    if let Some(arguments) = function.arguments.clone() {
679                        entry.arguments.push_str(&arguments);
680                    }
681                }
682
683                if !entry.id.is_empty() && !entry.name.is_empty() {
684                    if let Ok(input) = serde_json::from_str::<serde_json::Value>(
685                        &fix_streamed_json(&entry.arguments),
686                    ) {
687                        events.push(Ok(LanguageModelCompletionEvent::ToolUse(
688                            LanguageModelToolUse {
689                                id: entry.id.clone().into(),
690                                name: entry.name.as_str().into(),
691                                is_input_complete: false,
692                                input: language_model::LanguageModelToolUseInput::Json(input),
693                                raw_input: entry.arguments.clone(),
694                                thought_signature: None,
695                            },
696                        )));
697                    }
698                }
699            }
700        }
701
702        if let Some(usage) = event.usage {
703            events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
704                input_tokens: usage.prompt_tokens,
705                output_tokens: usage.completion_tokens,
706                cache_creation_input_tokens: 0,
707                cache_read_input_tokens: 0,
708            })));
709        }
710
711        if let Some(finish_reason) = choice.finish_reason.as_deref() {
712            match finish_reason {
713                "stop" => {
714                    events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)));
715                }
716                "tool_calls" => {
717                    events.extend(self.process_tool_calls());
718                    events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::ToolUse)));
719                }
720                unexpected => {
721                    log::error!("Unexpected Mistral stop_reason: {unexpected:?}");
722                    events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)));
723                }
724            }
725        }
726
727        events
728    }
729
730    fn process_tool_calls(
731        &mut self,
732    ) -> Vec<Result<LanguageModelCompletionEvent, LanguageModelCompletionError>> {
733        let mut results = Vec::new();
734
735        for (_, tool_call) in self.tool_calls_by_index.drain() {
736            if tool_call.id.is_empty() || tool_call.name.is_empty() {
737                results.push(Err(LanguageModelCompletionError::from(anyhow!(
738                    "Received incomplete tool call: missing id or name"
739                ))));
740                continue;
741            }
742
743            match parse_tool_arguments(&tool_call.arguments) {
744                Ok(input) => results.push(Ok(LanguageModelCompletionEvent::ToolUse(
745                    LanguageModelToolUse {
746                        id: tool_call.id.into(),
747                        name: tool_call.name.into(),
748                        is_input_complete: true,
749                        input: language_model::LanguageModelToolUseInput::Json(input),
750                        raw_input: tool_call.arguments,
751                        thought_signature: None,
752                    },
753                ))),
754                Err(error) => {
755                    results.push(Ok(LanguageModelCompletionEvent::ToolUseJsonParseError {
756                        id: tool_call.id.into(),
757                        tool_name: tool_call.name.into(),
758                        raw_input: tool_call.arguments.into(),
759                        json_parse_error: error.to_string(),
760                    }))
761                }
762            }
763        }
764
765        results
766    }
767}
768
769#[derive(Default)]
770struct RawToolCall {
771    id: String,
772    name: String,
773    arguments: String,
774}
775
776#[cfg(test)]
777mod tests {
778    use super::*;
779    use language_model::{LanguageModelImage, LanguageModelRequestMessage, MessageContent};
780
781    fn tool_call_chunk(
782        id: Option<&str>,
783        name: Option<&str>,
784        arguments: Option<&str>,
785        finish_reason: Option<&str>,
786    ) -> mistral::StreamResponse {
787        mistral::StreamResponse {
788            id: "resp".into(),
789            object: "chat.completion.chunk".into(),
790            created: 0,
791            model: "test".into(),
792            choices: vec![mistral::StreamChoice {
793                index: 0,
794                delta: mistral::StreamDelta {
795                    role: None,
796                    content: None,
797                    tool_calls: if finish_reason.is_some() {
798                        None
799                    } else {
800                        Some(vec![mistral::ToolCallChunk {
801                            index: 0,
802                            id: id.map(Into::into),
803                            function: Some(mistral::FunctionChunk {
804                                name: name.map(Into::into),
805                                arguments: arguments.map(Into::into),
806                            }),
807                        }])
808                    },
809                },
810                finish_reason: finish_reason.map(Into::into),
811            }],
812            usage: None,
813        }
814    }
815
816    #[test]
817    fn test_streaming_tool_call_ignores_null_id() {
818        // Mistral's streaming API sometimes sends `"id": "null"` in continuation chunks.
819        let mut mapper = MistralEventMapper::new();
820
821        mapper.map_event(tool_call_chunk(
822            Some("real_id_123"),
823            Some("read_file"),
824            Some("{\"path\":"),
825            None,
826        ));
827        mapper.map_event(tool_call_chunk(
828            Some("null"),
829            None,
830            Some("\"a.txt\"}"),
831            None,
832        ));
833        let events = mapper.map_event(tool_call_chunk(None, None, None, Some("tool_calls")));
834
835        let Ok(LanguageModelCompletionEvent::ToolUse(tool_use)) = &events[0] else {
836            panic!("Expected first event to be ToolUse, got: {:?}", events[0]);
837        };
838
839        assert_eq!(tool_use.id.to_string(), "real_id_123");
840        assert_eq!(tool_use.name.as_ref(), "read_file");
841        assert_eq!(
842            tool_use.input,
843            language_model::LanguageModelToolUseInput::Json(serde_json::json!({"path": "a.txt"}))
844        );
845    }
846
847    #[test]
848    fn test_into_mistral_basic_conversion() {
849        let request = LanguageModelRequest {
850            messages: vec![
851                LanguageModelRequestMessage {
852                    role: Role::System,
853                    content: vec![MessageContent::Text("System prompt".into())],
854                    cache: false,
855                    reasoning_details: None,
856                },
857                LanguageModelRequestMessage {
858                    role: Role::User,
859                    content: vec![MessageContent::Text("Hello".into())],
860                    cache: false,
861                    reasoning_details: None,
862                },
863                // should skip empty assistant messages
864                LanguageModelRequestMessage {
865                    role: Role::Assistant,
866                    content: vec![MessageContent::Text("".into())],
867                    cache: false,
868                    reasoning_details: None,
869                },
870            ],
871            temperature: Some(0.5),
872            tools: vec![],
873            tool_choice: None,
874            thread_id: Some("abcdef".into()),
875            prompt_id: None,
876            intent: None,
877            stop: vec![],
878            thinking_allowed: true,
879            thinking_effort: None,
880            speed: Default::default(),
881            compact_at_tokens: None,
882        };
883
884        let (mistral_request, affinity) =
885            into_mistral(request, mistral::Model::MistralSmallLatest, None).unwrap();
886
887        assert_eq!(mistral_request.model, "mistral-small-latest");
888        assert_eq!(mistral_request.temperature, Some(0.5));
889        assert_eq!(mistral_request.messages.len(), 2);
890        assert!(mistral_request.stream);
891        assert_eq!(affinity, Some("abcdef".into()));
892    }
893
894    #[test]
895    fn test_into_mistral_reasoning_effort() {
896        let request = |thinking_allowed| LanguageModelRequest {
897            messages: vec![LanguageModelRequestMessage {
898                role: Role::User,
899                content: vec![MessageContent::Text("Hello".into())],
900                cache: false,
901                reasoning_details: None,
902            }],
903            temperature: None,
904            tools: vec![],
905            tool_choice: None,
906            thread_id: None,
907            prompt_id: None,
908            intent: None,
909            stop: vec![],
910            thinking_allowed,
911            thinking_effort: None,
912            speed: Default::default(),
913            compact_at_tokens: None,
914        };
915
916        let (mistral_request, _) =
917            into_mistral(request(true), mistral::Model::MistralMediumLatest, None).unwrap();
918        assert_eq!(
919            mistral_request.reasoning_effort,
920            Some(mistral::ReasoningEffort::High)
921        );
922
923        let (mistral_request, _) =
924            into_mistral(request(false), mistral::Model::MistralMediumLatest, None).unwrap();
925        assert_eq!(mistral_request.reasoning_effort, None);
926
927        let (mistral_request, _) =
928            into_mistral(request(true), mistral::Model::CodestralLatest, None).unwrap();
929        assert_eq!(mistral_request.reasoning_effort, None);
930    }
931
932    #[test]
933    fn test_into_mistral_with_image() {
934        let request = LanguageModelRequest {
935            messages: vec![LanguageModelRequestMessage {
936                role: Role::User,
937                content: vec![
938                    MessageContent::Text("What's in this image?".into()),
939                    MessageContent::Image(LanguageModelImage {
940                        source: "base64data".into(),
941                    }),
942                ],
943                cache: false,
944                reasoning_details: None,
945            }],
946            tools: vec![],
947            tool_choice: None,
948            temperature: None,
949            thread_id: None,
950            prompt_id: None,
951            intent: None,
952            stop: vec![],
953            thinking_allowed: true,
954            thinking_effort: None,
955            speed: None,
956            compact_at_tokens: None,
957        };
958
959        let (mistral_request, _) =
960            into_mistral(request, mistral::Model::MistralSmallLatest, None).unwrap();
961
962        assert_eq!(mistral_request.messages.len(), 1);
963        assert!(matches!(
964            &mistral_request.messages[0],
965            mistral::RequestMessage::User {
966                content: mistral::MessageContent::Multipart { .. }
967            }
968        ));
969
970        if let mistral::RequestMessage::User {
971            content: mistral::MessageContent::Multipart { content },
972        } = &mistral_request.messages[0]
973        {
974            assert_eq!(content.len(), 2);
975            assert!(matches!(
976                &content[0],
977                mistral::MessagePart::Text { text } if text == "What's in this image?"
978            ));
979            assert!(matches!(
980                &content[1],
981                mistral::MessagePart::ImageUrl { image_url } if image_url.starts_with("data:image/png;base64,")
982            ));
983        }
984    }
985}
986
Served at tenant.openagents/omega Member data and write actions are omitted.