Skip to main content

rig_core/providers/
mira.rs

1//! Mira API client and Rig integration
2//!
3//! # Example
4//! ```
5//! use rig_core::providers::mira;
6//!
7//! let client = mira::Client::new("YOUR_API_KEY");
8//!
9//! ```
10use crate::client::{
11    self, BearerAuth, Capabilities, Capable, DebugExt, Nothing, Provider, ProviderBuilder,
12    ProviderClient,
13};
14use crate::http_client::{self, HttpClientExt};
15use crate::{
16    OneOrMany,
17    completion::{self, CompletionError},
18    message::{self, AssistantContent, Message, UserContent},
19};
20use serde::{Deserialize, Serialize};
21use std::string::FromUtf8Error;
22use thiserror::Error;
23use tracing::{self};
24
25#[derive(Debug, Default, Clone, Copy)]
26pub struct MiraExt;
27#[derive(Debug, Default, Clone, Copy)]
28pub struct MiraBuilder;
29
30type MiraApiKey = BearerAuth;
31
32impl Provider for MiraExt {
33    type Builder = MiraBuilder;
34
35    const VERIFY_PATH: &'static str = "/user-credits";
36}
37
38impl<H> Capabilities<H> for MiraExt {
39    type Completion = Capable<CompletionModel<H>>;
40    type Embeddings = Nothing;
41    type Transcription = Nothing;
42    type ModelListing = Nothing;
43
44    #[cfg(feature = "image")]
45    type ImageGeneration = Nothing;
46
47    #[cfg(feature = "audio")]
48    type AudioGeneration = Nothing;
49    type Rerank = Nothing;
50}
51
52impl DebugExt for MiraExt {}
53
54impl crate::providers::openai::completion::OpenAICompatibleProvider for MiraExt {
55    const PROVIDER_NAME: &'static str = "mira";
56
57    // Mira's gateway rejects tool parameters.
58    const SUPPORTS_TOOLS: bool = false;
59
60    type StreamingUsage = crate::providers::openai::Usage;
61
62    // Mira's gateway does not accept OpenAI structured-output parameters.
63    const SUPPORTS_RESPONSE_FORMAT: bool = false;
64
65    // The gateway also rejects unknown parameters like `stream_options`.
66    const STREAM_INCLUDE_USAGE: bool = false;
67
68    type Response = CompletionResponse;
69
70    // The client base URL is the bare host; `list_models` builds its own v1 path.
71    fn completion_path(&self, _model: &str) -> String {
72        "/v1/chat/completions".to_string()
73    }
74
75    fn prepare_request(
76        &self,
77        request: &mut crate::providers::openai::completion::CompletionRequest,
78    ) -> Result<(), CompletionError> {
79        // Mira's gateway rejects pass-through parameters (tools are dropped
80        // via `SUPPORTS_TOOLS = false` during conversion).
81        if request.additional_params.take().is_some() {
82            tracing::warn!("Additional parameters are not supported by Mira and will be ignored");
83        }
84
85        Ok(())
86    }
87
88    fn finalize_request_body(&self, body: &mut serde_json::Value) -> Result<(), CompletionError> {
89        let Some(map) = body.as_object_mut() else {
90            return Ok(());
91        };
92
93        // Mira only understands plain `{role, content}` string messages;
94        // strip tool-exchange remnants and message names, and flatten
95        // content-part arrays.
96        if let Some(messages) = map
97            .get_mut("messages")
98            .and_then(serde_json::Value::as_array_mut)
99        {
100            crate::providers::openai::completion::sanitize_plain_text_history(
101                messages,
102                Some(("\n", false)),
103                true,
104                false,
105            );
106        }
107
108        Ok(())
109    }
110}
111
112impl ProviderBuilder for MiraBuilder {
113    type Extension<H>
114        = MiraExt
115    where
116        H: HttpClientExt;
117    type ApiKey = MiraApiKey;
118
119    const BASE_URL: &'static str = MIRA_API_BASE_URL;
120
121    fn build<H>(
122        _builder: &crate::client::ClientBuilder<Self, Self::ApiKey, H>,
123    ) -> http_client::Result<Self::Extension<H>>
124    where
125        H: HttpClientExt,
126    {
127        Ok(MiraExt)
128    }
129}
130
131pub type Client<H = reqwest::Client> = client::Client<MiraExt, H>;
132pub type ClientBuilder<H = crate::markers::Missing> =
133    client::ClientBuilder<MiraBuilder, MiraApiKey, H>;
134
135#[derive(Debug, Error)]
136pub enum MiraError {
137    #[error("Invalid API key")]
138    InvalidApiKey,
139    #[error("API error: {0}")]
140    ApiError(u16),
141    #[error("Request error: {0}")]
142    RequestError(#[from] http_client::Error),
143    #[error("UTF-8 error: {0}")]
144    Utf8Error(#[from] FromUtf8Error),
145    #[error("JSON error: {0}")]
146    JsonError(#[from] serde_json::Error),
147}
148
149#[derive(Debug, Deserialize, Clone, Serialize)]
150pub struct RawMessage {
151    pub role: String,
152    pub content: String,
153}
154
155const MIRA_API_BASE_URL: &str = "https://api.mira.network";
156
157impl TryFrom<RawMessage> for message::Message {
158    type Error = CompletionError;
159
160    fn try_from(raw: RawMessage) -> Result<Self, Self::Error> {
161        match raw.role.as_str() {
162            "system" => Ok(message::Message::System {
163                content: raw.content,
164            }),
165            "user" => Ok(message::Message::User {
166                content: OneOrMany::one(UserContent::Text(message::Text::new(raw.content))),
167            }),
168            "assistant" => Ok(message::Message::Assistant {
169                id: None,
170                content: OneOrMany::one(AssistantContent::Text(message::Text::new(raw.content))),
171            }),
172            _ => Err(CompletionError::ResponseError(format!(
173                "Unsupported message role: {}",
174                raw.role
175            ))),
176        }
177    }
178}
179
180#[derive(Debug, Deserialize, Serialize)]
181#[serde(untagged)]
182pub enum CompletionResponse {
183    Structured {
184        id: String,
185        object: String,
186        created: u64,
187        model: String,
188        choices: Vec<ChatChoice>,
189        #[serde(skip_serializing_if = "Option::is_none")]
190        usage: Option<Usage>,
191    },
192    Simple(String),
193}
194
195#[derive(Debug, Deserialize, Serialize)]
196pub struct ChatChoice {
197    pub message: RawMessage,
198    #[serde(default)]
199    pub finish_reason: Option<String>,
200    #[serde(default)]
201    pub index: Option<usize>,
202}
203
204#[derive(Debug, Deserialize, Serialize)]
205struct ModelsResponse {
206    data: Vec<ModelInfo>,
207}
208
209#[derive(Debug, Deserialize, Serialize)]
210struct ModelInfo {
211    id: String,
212}
213
214impl<T> Client<T>
215where
216    T: HttpClientExt + 'static,
217{
218    /// List available models
219    pub async fn list_models(&self) -> Result<Vec<String>, MiraError> {
220        let req = self.get("/v1/models").and_then(|req| {
221            req.body(http_client::NoBody)
222                .map_err(http_client::Error::Protocol)
223        })?;
224
225        let response = self.send(req).await?;
226
227        let status = response.status();
228
229        if !status.is_success() {
230            // Log the error text but don't store it in an unused variable
231            let error_text = http_client::text(response).await.unwrap_or_default();
232            tracing::error!("Error response: {}", error_text);
233            return Err(MiraError::ApiError(status.as_u16()));
234        }
235
236        let response_text = http_client::text(response).await?;
237
238        let models: ModelsResponse = serde_json::from_str(&response_text).map_err(|e| {
239            tracing::error!("Failed to parse response: {}", e);
240            MiraError::JsonError(e)
241        })?;
242
243        Ok(models.data.into_iter().map(|model| model.id).collect())
244    }
245}
246
247impl ProviderClient for Client {
248    type Input = String;
249    type Error = crate::client::ProviderClientError;
250
251    /// Create a new Mira client from the `MIRA_API_KEY` environment variable.
252    fn from_env() -> Result<Self, Self::Error> {
253        let api_key = crate::client::required_env_var("MIRA_API_KEY")?;
254        Self::new(&api_key).map_err(Into::into)
255    }
256
257    fn from_val(input: Self::Input) -> Result<Self, Self::Error> {
258        Self::new(&input).map_err(Into::into)
259    }
260}
261
262/// Mira completion model, driven by the shared OpenAI Chat Completions path.
263pub type CompletionModel<H = reqwest::Client> =
264    crate::providers::openai::completion::GenericCompletionModel<MiraExt, H>;
265
266impl crate::telemetry::ProviderResponseExt for CompletionResponse {
267    type OutputMessage = ChatChoice;
268    type Usage = Usage;
269
270    fn get_response_id(&self) -> Option<String> {
271        match self {
272            Self::Structured { id, .. } => Some(id.clone()),
273            Self::Simple(_) => None,
274        }
275    }
276
277    fn get_response_model_name(&self) -> Option<String> {
278        match self {
279            Self::Structured { model, .. } => Some(model.clone()),
280            Self::Simple(_) => None,
281        }
282    }
283
284    fn get_output_messages(&self) -> Vec<Self::OutputMessage> {
285        match self {
286            Self::Structured { choices, .. } => choices
287                .iter()
288                .map(|choice| ChatChoice {
289                    message: choice.message.clone(),
290                    finish_reason: choice.finish_reason.clone(),
291                    index: choice.index,
292                })
293                .collect(),
294            Self::Simple(_) => Vec::new(),
295        }
296    }
297
298    fn get_text_response(&self) -> Option<String> {
299        match self {
300            Self::Structured { choices, .. } => choices
301                .iter()
302                .find(|choice| choice.message.role == "assistant")
303                .map(|choice| choice.message.content.clone()),
304            Self::Simple(text) => Some(text.clone()),
305        }
306    }
307
308    fn get_usage(&self) -> Option<Self::Usage> {
309        match self {
310            Self::Structured { usage, .. } => usage.clone(),
311            Self::Simple(_) => None,
312        }
313    }
314}
315
316impl crate::completion::GetTokenUsage for Usage {
317    fn token_usage(&self) -> crate::completion::Usage {
318        let mut usage = crate::completion::Usage::new();
319        usage.input_tokens = self.prompt_tokens as u64;
320        usage.output_tokens = self.total_tokens.saturating_sub(self.prompt_tokens) as u64;
321        usage.total_tokens = self.total_tokens as u64;
322        usage
323    }
324}
325
326impl TryFrom<CompletionResponse> for completion::CompletionResponse<CompletionResponse> {
327    type Error = CompletionError;
328
329    fn try_from(response: CompletionResponse) -> Result<Self, Self::Error> {
330        let (content, usage) = match &response {
331            CompletionResponse::Structured { choices, usage, .. } => {
332                let choice = choices.first().ok_or_else(|| {
333                    CompletionError::ResponseError("Response contained no choices".to_owned())
334                })?;
335
336                let usage = usage
337                    .as_ref()
338                    .map(|usage| completion::Usage {
339                        input_tokens: usage.prompt_tokens as u64,
340                        output_tokens: usage.total_tokens.saturating_sub(usage.prompt_tokens)
341                            as u64,
342                        total_tokens: usage.total_tokens as u64,
343                        cached_input_tokens: 0,
344                        cache_creation_input_tokens: 0,
345                        tool_use_prompt_tokens: 0,
346                        reasoning_tokens: 0,
347                    })
348                    .unwrap_or_default();
349
350                // Convert RawMessage to message::Message
351                let message = message::Message::try_from(choice.message.clone())?;
352
353                let content = match message {
354                    Message::Assistant { content, .. } => {
355                        if content.is_empty() {
356                            return Err(CompletionError::ResponseError(
357                                "Response contained empty content".to_owned(),
358                            ));
359                        }
360
361                        // Log warning for unsupported content types
362                        for c in content.iter() {
363                            if !matches!(c, AssistantContent::Text(_)) {
364                                tracing::warn!(target: "rig",
365                                    "Unsupported content type encountered: {:?}. The Mira provider currently only supports text content", c
366                                );
367                            }
368                        }
369
370                        content.iter().map(|c| {
371                            match c {
372                                AssistantContent::Text(text) => Ok(completion::AssistantContent::text(&text.text)),
373                                other => Err(CompletionError::ResponseError(
374                                    format!("Unsupported content type: {other:?}. The Mira provider currently only supports text content")
375                                ))
376                            }
377                        }).collect::<Result<Vec<_>, _>>()?
378                    }
379                    Message::User { .. } => {
380                        tracing::warn!(target: "rig", "Received user message in response where assistant message was expected");
381                        return Err(CompletionError::ResponseError(
382                            "Received user message in response where assistant message was expected".to_owned()
383                        ));
384                    }
385                    Message::System { .. } => {
386                        tracing::warn!(target: "rig", "Received system message in response where assistant message was expected");
387                        return Err(CompletionError::ResponseError(
388                            "Received system message in response where assistant message was expected".to_owned(),
389                        ));
390                    }
391                };
392
393                (content, usage)
394            }
395            CompletionResponse::Simple(text) => (
396                vec![completion::AssistantContent::text(text)],
397                completion::Usage::new(),
398            ),
399        };
400
401        let choice = OneOrMany::many(content).map_err(|_| {
402            CompletionError::ResponseError(
403                "Response contained no message or tool call (empty)".to_owned(),
404            )
405        })?;
406
407        Ok(completion::CompletionResponse {
408            choice,
409            usage,
410            raw_response: response,
411            message_id: None,
412        })
413    }
414}
415
416#[derive(Clone, Debug, Deserialize, Serialize)]
417pub struct Usage {
418    pub prompt_tokens: usize,
419    pub total_tokens: usize,
420}
421
422impl std::fmt::Display for Usage {
423    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
424        write!(
425            f,
426            "Prompt tokens: {} Total tokens: {}",
427            self.prompt_tokens, self.total_tokens
428        )
429    }
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435
436    #[test]
437    fn test_completion_response_conversion() {
438        let mira_response = CompletionResponse::Structured {
439            id: "resp_123".to_string(),
440            object: "chat.completion".to_string(),
441            created: 1234567890,
442            model: "deepseek-r1".to_string(),
443            choices: vec![ChatChoice {
444                message: RawMessage {
445                    role: "assistant".to_string(),
446                    content: "Test response".to_string(),
447                },
448                finish_reason: Some("stop".to_string()),
449                index: Some(0),
450            }],
451            usage: Some(Usage {
452                prompt_tokens: 10,
453                total_tokens: 20,
454            }),
455        };
456
457        let completion_response: completion::CompletionResponse<CompletionResponse> =
458            mira_response.try_into().unwrap();
459
460        assert_eq!(
461            completion_response.choice.first(),
462            completion::AssistantContent::text("Test response")
463        );
464    }
465    #[test]
466    fn test_client_initialization() {
467        let _client =
468            crate::providers::mira::Client::new("dummy-key").expect("Client::new() failed");
469        let _client_from_builder = crate::providers::mira::Client::builder()
470            .api_key("dummy-key")
471            .build()
472            .expect("Client::builder() failed");
473    }
474
475    // Proves a non-success HTTP response from `/v1/chat/completions` preserves
476    // the provider's status + body through the `provider_response_*` helpers
477    // (issue #1931).
478    #[tokio::test]
479    async fn completion_non_success_preserves_status_and_body() {
480        use crate::client::CompletionClient;
481        use crate::completion::CompletionModel;
482        use crate::test_utils::RecordingHttpClient;
483
484        let body = r#"{"error":{"message":"boom"}}"#;
485        let http_client =
486            RecordingHttpClient::with_error_response(http::StatusCode::SERVICE_UNAVAILABLE, body);
487        let client = Client::builder()
488            .api_key("test-key")
489            .http_client(http_client)
490            .build()
491            .expect("build client");
492        let model = client.completion_model("deepseek-r1");
493        let request = model.completion_request("hello").build();
494
495        let error = model
496            .completion(request)
497            .await
498            .expect_err("should fail with non-success status");
499
500        assert!(matches!(error, CompletionError::HttpError(_)));
501        assert_eq!(
502            error.provider_response_status(),
503            Some(http::StatusCode::SERVICE_UNAVAILABLE)
504        );
505        assert_eq!(error.provider_response_body(), Some(body));
506    }
507}