1use 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 const SUPPORTS_TOOLS: bool = false;
59
60 type StreamingUsage = crate::providers::openai::Usage;
61
62 const SUPPORTS_RESPONSE_FORMAT: bool = false;
64
65 const STREAM_INCLUDE_USAGE: bool = false;
67
68 type Response = CompletionResponse;
69
70 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 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 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 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 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 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
262pub 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 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 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 #[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}