1use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::hash::Hash;
6
7pub use oxi_catalog::Api;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
17#[serde(rename_all = "lowercase")]
18pub enum CacheRetention {
19 #[default]
21 None,
22 Short,
24 Long,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
30#[serde(rename_all = "lowercase")]
31#[non_exhaustive]
32pub enum ThinkingLevel {
33 #[default]
35 Off,
36 Minimal,
38 Low,
40 Medium,
42 High,
44 XHigh,
46}
47
48impl ThinkingLevel {
49 pub fn as_str(&self) -> Option<&str> {
51 match self {
52 ThinkingLevel::Off => None,
53 ThinkingLevel::Minimal => Some("minimal"),
54 ThinkingLevel::Low => Some("low"),
55 ThinkingLevel::Medium => Some("medium"),
56 ThinkingLevel::High => Some("high"),
57 ThinkingLevel::XHigh => Some("xhigh"),
58 }
59 }
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "lowercase")]
65#[non_exhaustive]
66pub enum InputModality {
67 Text,
69 Image,
71}
72
73#[derive(Debug, Clone, Default, Serialize, Deserialize)]
75#[serde(default)]
76pub struct Cost {
77 #[serde(default)]
79 pub input: f64,
80 #[serde(default)]
82 pub output: f64,
83 #[serde(default)]
85 pub cache_read: f64,
86 #[serde(default)]
88 pub cache_write: f64,
89}
90
91impl Cost {
92 pub fn total(&self) -> f64 {
94 self.input + self.output + self.cache_read + self.cache_write
95 }
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(rename_all = "camelCase")]
101#[non_exhaustive]
102pub enum StopReason {
103 Stop,
105 Length,
107 ToolUse,
109 Error,
111 Aborted,
113}
114
115#[derive(Debug, Clone, Default, Serialize, Deserialize)]
117pub struct Usage {
118 #[serde(default)]
120 pub input: usize,
121 #[serde(default)]
123 pub output: usize,
124 #[serde(default)]
126 pub cache_read: usize,
127 #[serde(default)]
129 pub cache_write: usize,
130 #[serde(default)]
132 pub total_tokens: usize,
133 #[serde(default)]
135 pub cost: Cost,
136}
137
138impl Usage {
139 pub fn calculate_cost(
143 &mut self,
144 input_cost_per_million: Option<f64>,
145 output_cost_per_million: Option<f64>,
146 ) {
147 self.total_tokens = self.input + self.output + self.cache_read + self.cache_write;
148 self.cost.input = input_cost_per_million.unwrap_or(1.0) * self.input as f64 / 1_000_000.0;
149 self.cost.output =
150 output_cost_per_million.unwrap_or(1.0) * self.output as f64 / 1_000_000.0;
151 self.cost.cache_read = (self.cache_read as f64) / 1_000_000.0;
152 self.cost.cache_write = (self.cache_write as f64) / 1_000_000.0;
153 }
154}
155
156#[derive(Debug, Clone, Default, Serialize, Deserialize)]
161#[serde(default)]
162pub struct CompatSettings {
163 #[serde(default = "default_true")]
165 pub supports_store: bool,
166 #[serde(default = "default_true")]
168 pub supports_developer_role: bool,
169 #[serde(default = "default_true")]
171 pub supports_reasoning_effort: bool,
172 #[serde(default = "default_true")]
174 pub supports_usage_in_streaming: bool,
175 #[serde(default)]
177 pub max_tokens_field: Option<MaxTokensField>,
178 #[serde(default = "default_false")]
180 pub requires_tool_result_name: bool,
181 #[serde(default = "default_false")]
183 pub requires_assistant_after_tool_result: bool,
184 #[serde(default = "default_false")]
186 pub requires_thinking_as_text: bool,
187 #[serde(default)]
189 pub thinking_format: Option<ThinkingFormat>,
190}
191
192fn default_true() -> bool {
193 true
194}
195fn default_false() -> bool {
196 false
197}
198
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
201#[serde(rename_all = "kebab-case")]
202pub enum MaxTokensField {
203 MaxCompletionTokens,
205 MaxTokens,
207}
208
209#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
211#[serde(rename_all = "lowercase")]
212pub enum ThinkingFormat {
213 OpenAI,
215 OpenRouter,
217 DeepSeek,
219 Zai,
221 Qwen,
223 QwenChatTemplate,
225}
226
227#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Hash)]
229pub enum Complexity {
230 Trivial,
232 Simple,
234 Moderate,
236 #[default]
238 Complex,
239 Research,
241}
242
243impl Complexity {
244 pub fn cost_tier(&self) -> u8 {
246 match self {
247 Self::Trivial => 0,
248 Self::Simple => 1,
249 Self::Moderate => 2,
250 Self::Complex => 3,
251 Self::Research => 4,
252 }
253 }
254}
255
256#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct ToolResult {
259 pub tool_call_id: String,
261 pub content: String,
263 pub status: String,
265}
266
267impl ToolResult {
268 pub fn success(tool_call_id: impl Into<String>, content: impl Into<String>) -> Self {
270 Self {
271 tool_call_id: tool_call_id.into(),
272 content: content.into(),
273 status: "success".to_string(),
274 }
275 }
276
277 pub fn error(tool_call_id: impl Into<String>, content: impl Into<String>) -> Self {
279 Self {
280 tool_call_id: tool_call_id.into(),
281 content: content.into(),
282 status: "error".to_string(),
283 }
284 }
285
286 pub fn is_error(&self) -> bool {
288 self.status == "error"
289 }
290}
291
292#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
294#[non_exhaustive]
295pub enum ImagesApi {
296 OpenRouter,
298}
299
300impl std::fmt::Display for ImagesApi {
301 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
302 match self {
303 ImagesApi::OpenRouter => write!(f, "openrouter"),
304 }
305 }
306}
307
308#[derive(Debug, Clone, Serialize, Deserialize)]
310#[serde(default)]
311pub struct ImageGenerationRequest {
312 pub prompt: String,
314 pub model: Option<String>,
316 pub size: Option<String>,
318 pub n: Option<u32>,
320 pub response_format: Option<String>,
322}
323
324impl Default for ImageGenerationRequest {
325 fn default() -> Self {
326 Self {
327 prompt: String::new(),
328 model: None,
329 size: None,
330 n: Some(1),
331 response_format: Some("b64_json".to_string()),
332 }
333 }
334}
335
336#[derive(Debug, Clone, Serialize, Deserialize, Default)]
338#[serde(default)]
339pub struct ImageGenerationResponse {
340 pub images: Vec<Vec<u8>>,
342 pub revised_prompt: Option<String>,
344}
345
346#[derive(Debug, Clone, Serialize, Deserialize)]
350pub struct Model {
351 pub id: String,
353 pub name: String,
355 pub api: Api,
357 pub provider: String,
359 pub base_url: String,
361 #[serde(default)]
363 pub reasoning: bool,
364 #[serde(default)]
366 pub input: Vec<InputModality>,
367 #[serde(default)]
369 pub cost: Cost,
370 pub context_window: usize,
372 pub max_tokens: usize,
374 #[serde(default)]
376 pub headers: HashMap<String, String>,
377 #[serde(default)]
379 pub compat: Option<CompatSettings>,
380}
381
382impl Model {
383 pub fn new(
385 id: impl Into<String>,
386 name: impl Into<String>,
387 api: Api,
388 provider: impl Into<String>,
389 base_url: impl Into<String>,
390 ) -> Self {
391 Self {
392 id: id.into(),
393 name: name.into(),
394 api,
395 provider: provider.into(),
396 base_url: base_url.into(),
397 reasoning: false,
398 input: vec![InputModality::Text],
399 cost: Cost::default(),
400 context_window: 128_000,
401 max_tokens: 32_000,
402 headers: HashMap::new(),
403 compat: None,
404 }
405 }
406
407 pub fn supports_vision(&self) -> bool {
409 self.input.contains(&InputModality::Image)
410 }
411
412 pub fn supports_reasoning(&self) -> bool {
414 self.reasoning
415 }
416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421
422 #[test]
423 fn model_roundtrip() {
424 let mut model = Model::new(
425 "gpt-4o",
426 "GPT-4o",
427 Api::OpenAiCompletions,
428 "openai",
429 "https://api.openai.com/v1",
430 );
431 model.reasoning = true;
432 model.input.push(InputModality::Image);
433 model.cost = Cost {
434 input: 5.0,
435 output: 15.0,
436 cache_read: 2.5,
437 cache_write: 0.0,
438 };
439 model.compat = Some(CompatSettings::default());
440
441 let json = serde_json::to_string(&model).unwrap();
442 let deserialized: Model = serde_json::from_str(&json).unwrap();
443
444 assert_eq!(deserialized.id, "gpt-4o");
445 assert_eq!(deserialized.name, "GPT-4o");
446 assert_eq!(deserialized.api, Api::OpenAiCompletions);
447 assert_eq!(deserialized.provider, "openai");
448 assert!(deserialized.reasoning);
449 assert!(deserialized.supports_vision());
450 assert!(deserialized.supports_reasoning());
451 assert_eq!(deserialized.cost.input, 5.0);
452 assert_eq!(deserialized.cost.output, 15.0);
453 }
454
455 #[test]
456 fn usage_calculate_cost() {
457 let mut usage = Usage {
458 input: 1_000_000,
459 output: 500_000,
460 cache_read: 200_000,
461 cache_write: 100_000,
462 ..Default::default()
463 };
464 usage.calculate_cost(None, None);
465
466 assert_eq!(usage.total_tokens, 1_800_000);
467 assert_eq!(usage.cost.input, 1.0);
468 assert_eq!(usage.cost.output, 0.5);
469 assert_eq!(usage.cost.cache_read, 0.2);
470 assert_eq!(usage.cost.cache_write, 0.1);
471 }
472
473 #[test]
474 fn cost_total() {
475 let cost = Cost {
476 input: 3.0,
477 output: 6.0,
478 cache_read: 1.0,
479 cache_write: 0.5,
480 };
481 assert!((cost.total() - 10.5).abs() < f64::EPSILON);
482
483 let default_cost = Cost::default();
484 assert_eq!(default_cost.total(), 0.0);
485 }
486
487 #[test]
488 fn api_display() {
489 assert_eq!(Api::OpenAiCompletions.to_string(), "openai-completions");
490 assert_eq!(Api::OpenAiResponses.to_string(), "openai-responses");
491 assert_eq!(Api::AnthropicMessages.to_string(), "anthropic-messages");
492 assert_eq!(Api::GoogleGenerativeAi.to_string(), "google-generative-ai");
493 assert_eq!(Api::GoogleVertex.to_string(), "google-vertex");
494 assert_eq!(
495 Api::AzureOpenAiResponses.to_string(),
496 "azure-openai-responses"
497 );
498 assert_eq!(
499 Api::BedrockConverseStream.to_string(),
500 "bedrock-converse-stream"
501 );
502 }
503
504 #[test]
505 fn api_serde_roundtrip() {
506 for api in [
507 Api::OpenAiCompletions,
508 Api::OpenAiResponses,
509 Api::AnthropicMessages,
510 Api::GoogleGenerativeAi,
511 Api::GoogleVertex,
512 Api::AzureOpenAiResponses,
513 Api::BedrockConverseStream,
514 ] {
515 let json = serde_json::to_string(&api).unwrap();
516 let back: Api = serde_json::from_str(&json).unwrap();
517 assert_eq!(api, back);
518 }
519 }
520
521 #[test]
522 fn thinking_level_serde() {
523 for level in [
524 ThinkingLevel::Off,
525 ThinkingLevel::Minimal,
526 ThinkingLevel::Low,
527 ThinkingLevel::Medium,
528 ThinkingLevel::High,
529 ThinkingLevel::XHigh,
530 ] {
531 let json = serde_json::to_string(&level).unwrap();
532 let back: ThinkingLevel = serde_json::from_str(&json).unwrap();
533 assert_eq!(level, back);
534 }
535 assert_eq!(ThinkingLevel::default(), ThinkingLevel::Off);
537 assert_eq!(
539 serde_json::to_string(&ThinkingLevel::High).unwrap(),
540 "\"high\""
541 );
542 assert_eq!(
543 serde_json::to_string(&ThinkingLevel::Off).unwrap(),
544 "\"off\""
545 );
546 assert!(ThinkingLevel::Off.as_str().is_none());
548 assert_eq!(ThinkingLevel::High.as_str(), Some("high"));
549 assert_eq!(ThinkingLevel::XHigh.as_str(), Some("xhigh"));
550 }
551
552 #[test]
553 fn stop_reason_serde() {
554 assert_eq!(
555 serde_json::to_string(&StopReason::ToolUse).unwrap(),
556 "\"toolUse\""
557 );
558 let back: StopReason = serde_json::from_str("\"toolUse\"").unwrap();
559 assert_eq!(back, StopReason::ToolUse);
560 }
561
562 #[test]
563 fn tool_result_helpers() {
564 let success = ToolResult::success("call_1", "result text");
565 assert_eq!(success.tool_call_id, "call_1");
566 assert_eq!(success.content, "result text");
567 assert_eq!(success.status, "success");
568 assert!(!success.is_error());
569
570 let error = ToolResult::error("call_2", "something failed");
571 assert!(error.is_error());
572 assert_eq!(error.status, "error");
573 }
574
575 #[test]
576 fn cache_retention_default() {
577 assert_eq!(CacheRetention::default(), CacheRetention::None);
578 }
579}