1use 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 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 const SUPPORTS_TOOLS: bool = false;
51
52 type StreamingUsage = crate::providers::openai::Usage;
53
54 const SUPPORTS_RESPONSE_FORMAT: bool = false;
56
57 const STREAM_INCLUDE_USAGE: bool = false;
59
60 type Response = CompletionResponse;
61
62 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 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 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
148pub 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 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
206impl 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 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 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 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 (
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 #[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}