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::{self, BearerAuth, DebugExt, Provider};
11use crate::completion::{self, CompletionError};
12use serde::{Deserialize, Serialize};
13use tracing::{self};
14
15#[derive(Debug, Default, Clone, Copy)]
16pub struct MiraExt;
17#[derive(Debug, Default, Clone, Copy)]
18pub struct MiraBuilder;
19
20type MiraApiKey = BearerAuth;
21
22impl Provider for MiraExt {
23    type Builder = MiraBuilder;
24
25    const VERIFY_PATH: &'static str = "/user-credits";
26}
27
28client::impl_capabilities!(
29    MiraExt,
30    completion = CompletionModel<H>,
31    model_listing = MiraModelLister<H>,
32);
33
34crate::providers::internal::model_listing::impl_model_lister!(
35    /// [`ModelLister`](crate::client::ModelLister) implementation for the
36    /// Mira API (`GET /v1/models`).
37    MiraModelLister,
38    Client<H>,
39    crate::providers::internal::model_listing::ListModelEntry,
40    "Mira",
41    "/v1/models"
42);
43
44impl DebugExt for MiraExt {}
45
46impl crate::providers::openai::completion::OpenAICompatibleProvider for MiraExt {
47    const PROVIDER_NAME: &'static str = "mira";
48
49    // Mira's gateway rejects tool parameters.
50    const SUPPORTS_TOOLS: bool = false;
51
52    type StreamingUsage = crate::providers::openai::Usage;
53
54    // Mira's gateway does not accept OpenAI structured-output parameters.
55    const SUPPORTS_RESPONSE_FORMAT: bool = false;
56
57    // The gateway also rejects unknown parameters like `stream_options`.
58    const STREAM_INCLUDE_USAGE: bool = false;
59
60    type Response = CompletionResponse;
61
62    // The client base URL is the bare host; `list_models` builds its own v1 path.
63    fn completion_path(&self, _model: &str) -> String {
64        "/v1/chat/completions".to_string()
65    }
66
67    fn prepare_request(
68        &self,
69        request: &mut crate::providers::openai::completion::CompletionRequest,
70    ) -> Result<(), CompletionError> {
71        // Mira's gateway rejects pass-through parameters (tools are dropped
72        // via `SUPPORTS_TOOLS = false` during conversion).
73        if request.additional_params.take().is_some() {
74            tracing::warn!("Additional parameters are not supported by Mira and will be ignored");
75        }
76
77        Ok(())
78    }
79
80    fn finalize_request_body(&self, body: &mut serde_json::Value) -> Result<(), CompletionError> {
81        let Some(map) = body.as_object_mut() else {
82            return Ok(());
83        };
84
85        // Mira only understands plain `{role, content}` string messages;
86        // strip tool-exchange remnants and message names, and flatten
87        // content-part arrays.
88        if let Some(messages) = map
89            .get_mut("messages")
90            .and_then(serde_json::Value::as_array_mut)
91        {
92            crate::providers::openai::completion::sanitize_plain_text_history(
93                messages,
94                Some(("\n", false)),
95                true,
96                false,
97            );
98        }
99
100        Ok(())
101    }
102}
103
104client::impl_default_provider_builder!(
105    MiraBuilder => MiraExt,
106    api_key = MiraApiKey,
107    base_url = MIRA_API_BASE_URL,
108);
109
110pub type Client<H = reqwest::Client> = client::Client<MiraExt, H>;
111pub type ClientBuilder<H = crate::markers::Missing> =
112    client::ClientBuilder<MiraBuilder, MiraApiKey, H>;
113
114#[derive(Debug, Deserialize, Clone, Serialize)]
115pub struct RawMessage {
116    pub role: String,
117    pub content: String,
118}
119
120const MIRA_API_BASE_URL: &str = "https://api.mira.network";
121
122#[derive(Debug, Deserialize, Serialize)]
123#[serde(untagged)]
124pub enum CompletionResponse {
125    Structured {
126        id: String,
127        object: String,
128        created: u64,
129        model: String,
130        choices: Vec<ChatChoice>,
131        #[serde(skip_serializing_if = "Option::is_none")]
132        usage: Option<Usage>,
133    },
134    Simple(String),
135}
136
137#[derive(Debug, Deserialize, Serialize)]
138pub struct ChatChoice {
139    pub message: RawMessage,
140    #[serde(default)]
141    pub finish_reason: Option<String>,
142    #[serde(default)]
143    pub index: Option<usize>,
144}
145
146client::impl_provider_client!(Client, input = String, api_key_env = "MIRA_API_KEY");
147
148/// Mira completion model, driven by the shared OpenAI Chat Completions path.
149pub type CompletionModel<H = reqwest::Client> =
150    crate::providers::openai::completion::GenericCompletionModel<MiraExt, H>;
151
152impl crate::telemetry::ProviderResponseExt for CompletionResponse {
153    type Usage = Usage;
154
155    fn get_response_id(&self) -> Option<String> {
156        match self {
157            Self::Structured { id, .. } => Some(id.clone()),
158            Self::Simple(_) => None,
159        }
160    }
161
162    fn get_response_model_name(&self) -> Option<String> {
163        match self {
164            Self::Structured { model, .. } => Some(model.clone()),
165            Self::Simple(_) => None,
166        }
167    }
168
169    fn get_text_response(&self) -> Option<String> {
170        match self {
171            Self::Structured { choices, .. } => choices
172                .iter()
173                .find(|choice| choice.message.role == "assistant")
174                .map(|choice| choice.message.content.clone()),
175            Self::Simple(text) => Some(text.clone()),
176        }
177    }
178
179    fn get_usage(&self) -> Option<Self::Usage> {
180        match self {
181            Self::Structured { usage, .. } => usage.clone(),
182            Self::Simple(_) => None,
183        }
184    }
185}
186
187impl From<&Usage> for completion::Usage {
188    fn from(usage: &Usage) -> Self {
189        crate::providers::internal::completion_usage(
190            usage.prompt_tokens as u64,
191            // Mira reports only prompt and total counts; the completion count
192            // is the remainder.
193            usage.total_tokens.saturating_sub(usage.prompt_tokens) as u64,
194            usage.total_tokens as u64,
195            0,
196        )
197    }
198}
199
200impl From<Usage> for completion::Usage {
201    fn from(usage: Usage) -> Self {
202        Self::from(&usage)
203    }
204}
205
206/// Normalize a Mira chat completion response.
207///
208/// The provider descriptor name is an *input* rather than a constant so the
209/// shared OpenAI-compatible completion path labels the response with the
210/// descriptor that actually produced it.
211impl crate::completion::NormalizeCompletionResponse for CompletionResponse {
212    fn normalize(self, provider: &str) -> Result<completion::CompletionResponse, CompletionError> {
213        use crate::providers::internal::openai_chat_completions_compatible as compat;
214
215        let (id, model, choices, usage) = match self {
216            CompletionResponse::Structured {
217                id,
218                model,
219                choices,
220                usage,
221                ..
222            } => (id, model, choices, usage),
223            // The bare-string variant carries no metadata at all — not even a
224            // terminal reason, so the normalized reason stays `None`.
225            CompletionResponse::Simple(text) => {
226                let choice = crate::message::require_non_empty_response(vec![
227                    completion::AssistantContent::text(&text),
228                ])?;
229                return Ok(completion::CompletionResponse::new(
230                    choice,
231                    completion::Usage::new(),
232                    provider,
233                ));
234            }
235        };
236
237        // Preserve Mira's role-specific error messages: the shared helper
238        // folds every non-assistant message into one generic error. Mira's
239        // wire messages are plain `{role, content}` strings, so an assistant
240        // message can never carry unsupported content types.
241        if let Some(choice) = choices.first() {
242            match choice.message.role.as_str() {
243                "assistant" => {}
244                "user" => {
245                    tracing::warn!(target: "rig", "Received user message in response where assistant message was expected");
246                    return Err(CompletionError::ResponseError(
247                        "Received user message in response where assistant message was expected"
248                            .to_owned(),
249                    ));
250                }
251                "system" => {
252                    tracing::warn!(target: "rig", "Received system message in response where assistant message was expected");
253                    return Err(CompletionError::ResponseError(
254                        "Received system message in response where assistant message was expected"
255                            .to_owned(),
256                    ));
257                }
258                other => {
259                    return Err(CompletionError::ResponseError(format!(
260                        "Unsupported message role: {other}"
261                    )));
262                }
263            }
264        }
265
266        let usage = usage
267            .as_ref()
268            .map(completion::Usage::from)
269            .unwrap_or_default();
270
271        compat::normalize_openai_response(
272            provider,
273            &choices,
274            Some(id.as_str()).filter(|id| !id.is_empty()),
275            Some(model.as_str()).filter(|model| !model.is_empty()),
276            usage,
277            |choice| choice.finish_reason.as_deref().unwrap_or(""),
278            |choice| {
279                Some(vec![completion::AssistantContent::text(
280                    &choice.message.content,
281                )])
282            },
283        )
284    }
285}
286
287#[derive(Clone, Debug, Deserialize, Serialize)]
288pub struct Usage {
289    pub prompt_tokens: usize,
290    pub total_tokens: usize,
291}
292
293impl std::fmt::Display for Usage {
294    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
295        write!(
296            f,
297            "Prompt tokens: {} Total tokens: {}",
298            self.prompt_tokens, self.total_tokens
299        )
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306    use crate::completion::FinishReason;
307    use crate::completion::NormalizeCompletionResponse;
308    use crate::providers::openai::completion::OpenAICompatibleProvider;
309
310    /// Normalize a Mira wire response the way the shared completion path does,
311    /// threading Mira's own descriptor name through the conversion.
312    fn normalized(response: CompletionResponse) -> completion::CompletionResponse {
313        response
314            .normalize(MiraExt::PROVIDER_NAME)
315            .expect("Mira response should convert")
316    }
317
318    #[test]
319    fn test_completion_response_conversion() {
320        let mira_response = CompletionResponse::Structured {
321            id: "resp_123".to_string(),
322            object: "chat.completion".to_string(),
323            created: 1234567890,
324            model: "deepseek-r1".to_string(),
325            choices: vec![ChatChoice {
326                message: RawMessage {
327                    role: "assistant".to_string(),
328                    content: "Test response".to_string(),
329                },
330                finish_reason: Some("stop".to_string()),
331                index: Some(0),
332            }],
333            usage: Some(Usage {
334                prompt_tokens: 10,
335                total_tokens: 20,
336            }),
337        };
338
339        let completion_response = normalized(mira_response);
340
341        assert_eq!(
342            completion_response.choice.first(),
343            Some(&completion::AssistantContent::text("Test response"))
344        );
345        assert_eq!(completion_response.provider, "mira");
346        assert_eq!(completion_response.response_id.as_deref(), Some("resp_123"));
347        assert_eq!(completion_response.message_id, None);
348        assert_eq!(completion_response.model.as_deref(), Some("deepseek-r1"));
349        assert_eq!(
350            completion_response.finish_reason(),
351            Some(FinishReason::Stop)
352        );
353        assert_eq!(completion_response.usage.input_tokens, 10);
354        assert_eq!(completion_response.usage.output_tokens, 10);
355        assert_eq!(completion_response.usage.total_tokens, 20);
356    }
357
358    fn structured_response_with_finish_reason(finish_reason: &str) -> CompletionResponse {
359        CompletionResponse::Structured {
360            id: "resp_123".to_string(),
361            object: "chat.completion".to_string(),
362            created: 1234567890,
363            model: "deepseek-r1".to_string(),
364            choices: vec![ChatChoice {
365                message: RawMessage {
366                    role: "assistant".to_string(),
367                    content: "Test response".to_string(),
368                },
369                finish_reason: Some(finish_reason.to_string()),
370                index: Some(0),
371            }],
372            usage: None,
373        }
374    }
375
376    #[test]
377    fn mira_finish_reasons_normalize_and_preserve_unknowns() {
378        for (wire, expected) in [
379            ("stop", FinishReason::Stop),
380            ("length", FinishReason::Length),
381            ("max_tokens", FinishReason::Length),
382            ("tool_calls", FinishReason::ToolCalls),
383            ("function_call", FinishReason::ToolCalls),
384            ("content_filter", FinishReason::ContentFilter),
385            // A gateway-specific reason survives verbatim rather than reading
386            // as a natural stop.
387            (
388                "ERROR_UPSTREAM",
389                FinishReason::Other("ERROR_UPSTREAM".to_owned()),
390            ),
391        ] {
392            let converted = normalized(structured_response_with_finish_reason(wire));
393
394            assert_eq!(converted.finish_reason(), Some(expected), "wire: {wire}");
395        }
396    }
397
398    #[test]
399    fn mira_simple_response_reports_no_metadata() {
400        let converted = normalized(CompletionResponse::Simple("Test response".to_string()));
401
402        assert_eq!(converted.provider, "mira");
403        assert_eq!(converted.message_id, None);
404        assert_eq!(converted.model, None);
405        assert_eq!(converted.finish_reason(), None);
406    }
407
408    #[test]
409    fn test_client_initialization() {
410        let _client =
411            crate::providers::mira::Client::new("dummy-key").expect("Client::new() failed");
412        let _client_from_builder = crate::providers::mira::Client::builder()
413            .api_key("dummy-key")
414            .build()
415            .expect("Client::builder() failed");
416    }
417
418    // Proves a non-success HTTP response from `/v1/chat/completions` preserves
419    // the provider's status + body through the `provider_response_*` helpers
420    // (issue #1931).
421    #[tokio::test]
422    async fn completion_non_success_preserves_status_and_body() {
423        use crate::client::CompletionClient;
424        use crate::completion::CompletionModel;
425        use crate::test_utils::RecordingHttpClient;
426
427        let body = r#"{"error":{"message":"boom"}}"#;
428        let http_client =
429            RecordingHttpClient::with_error_response(http::StatusCode::SERVICE_UNAVAILABLE, body);
430        let client = Client::builder()
431            .api_key("test-key")
432            .http_client(http_client)
433            .build()
434            .expect("build client");
435        let model = client.completion_model("deepseek-r1");
436        let request = model.completion_request("hello").build();
437
438        let error = model
439            .completion(request)
440            .await
441            .expect_err("should fail with non-success status");
442
443        assert!(matches!(error, CompletionError::HttpError(_)));
444        assert_eq!(
445            error.provider_response_status(),
446            Some(http::StatusCode::SERVICE_UNAVAILABLE)
447        );
448        assert_eq!(error.provider_response_body(), Some(body));
449    }
450}