1use crate::ports::catalog::{CatalogModelEntry, ModelCatalog};
11use oxicode_ai::{Cost, InputModality, Model};
12
13pub fn catalog_entry_to_model(provider: &str, entry: &CatalogModelEntry) -> Model {
25 Model {
26 id: entry.model_id.clone(),
27 name: entry.name.clone(),
28 api: entry.protocol.as_oxicode_api(),
29 provider: provider.to_string(),
30 base_url: entry.base_url.clone().unwrap_or_default(),
31 reasoning: entry.reasoning,
32 input: modalities_from_catalog(&entry.input_modalities, entry.supports_vision),
33 cost: Cost {
34 input: entry.cost_input,
35 output: entry.cost_output,
36 cache_read: entry.cost_cache_read,
37 cache_write: entry.cost_cache_write,
38 },
39 context_window: entry.context_window as usize,
40 max_tokens: entry.max_tokens as usize,
41 headers: HashMap::new(),
42 compat: None,
43 }
44}
45
46pub fn provider_base_url(catalog: &dyn ModelCatalog, provider: &str) -> Option<String> {
51 catalog.get_provider_sync(provider).and_then(|p| p.base_url)
52}
53
54fn modalities_from_catalog(modalities: &[String], supports_vision: bool) -> Vec<InputModality> {
59 let mut out: Vec<InputModality> = if modalities.is_empty() {
60 vec![InputModality::Text]
61 } else {
62 modalities
63 .iter()
64 .filter_map(|m| match m.to_lowercase().as_str() {
65 "text" => Some(InputModality::Text),
66 "image" | "images" | "video" | "audio" | "pdf" | "file" | "files" => {
67 Some(InputModality::Image)
70 }
71 _ => None,
72 })
73 .collect()
74 };
75
76 if !out.iter().any(|m| matches!(m, InputModality::Text)) {
78 out.insert(0, InputModality::Text);
79 }
80
81 if supports_vision && !out.iter().any(|m| matches!(m, InputModality::Image)) {
83 out.push(InputModality::Image);
84 }
85
86 out
87}
88
89use std::collections::HashMap;
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94 use crate::ports::catalog::{CatalogProtocol, CatalogSource};
95 use oxicode_ai::Api;
96
97 fn sample_entry() -> CatalogModelEntry {
98 CatalogModelEntry {
99 provider: "openai".to_string(),
100 model_id: "gpt-4o".to_string(),
101 name: "GPT-4o".to_string(),
102 protocol: CatalogProtocol::OpenAiCompletions,
103 source: CatalogSource::Embedded,
104 base_url: None,
105 reasoning: false,
106 supports_vision: true,
107 cost_input: 2.5,
108 cost_output: 10.0,
109 cost_cache_read: 1.25,
110 cost_cache_write: 0.0,
111 context_window: 128_000,
112 max_tokens: 16_384,
113 input_modalities: vec!["text".to_string(), "image".to_string()],
114 release_date: None,
115 status: None,
116 }
117 }
118
119 #[test]
120 fn converts_basic_fields() {
121 let entry = sample_entry();
122 let model = catalog_entry_to_model("openai", &entry);
123 assert_eq!(model.id, "gpt-4o");
124 assert_eq!(model.name, "GPT-4o");
125 assert_eq!(model.api, Api::OpenAiCompletions);
126 assert_eq!(model.provider, "openai");
127 assert_eq!(model.context_window, 128_000);
128 assert_eq!(model.max_tokens, 16_384);
129 assert!(!model.reasoning);
130 }
131
132 #[test]
133 fn converts_pricing() {
134 let entry = sample_entry();
135 let model = catalog_entry_to_model("openai", &entry);
136 assert!((model.cost.input - 2.5).abs() < f64::EPSILON);
137 assert!((model.cost.output - 10.0).abs() < f64::EPSILON);
138 assert!((model.cost.cache_read - 1.25).abs() < f64::EPSILON);
139 }
140
141 #[test]
142 fn converts_modalities_with_vision() {
143 let entry = sample_entry();
144 let model = catalog_entry_to_model("openai", &entry);
145 assert!(model.input.contains(&InputModality::Text));
146 assert!(model.input.contains(&InputModality::Image));
147 }
148
149 #[test]
150 fn adds_vision_from_flag() {
151 let mut entry = sample_entry();
152 entry.input_modalities = vec!["text".to_string()];
153 entry.supports_vision = true;
154 let model = catalog_entry_to_model("openai", &entry);
155 assert!(model.input.contains(&InputModality::Image));
156 }
157
158 #[test]
159 fn empty_modalities_defaults_to_text() {
160 let mut entry = sample_entry();
161 entry.input_modalities = vec![];
162 entry.supports_vision = false;
163 let model = catalog_entry_to_model("openai", &entry);
164 assert_eq!(model.input, vec![InputModality::Text]);
165 }
166
167 #[test]
168 fn base_url_override_used() {
169 let mut entry = sample_entry();
170 entry.base_url = Some("https://custom.api/v1".to_string());
171 let model = catalog_entry_to_model("openai", &entry);
172 assert_eq!(model.base_url, "https://custom.api/v1");
173 }
174
175 #[test]
176 fn protocol_maps_correctly() {
177 let cases = [
178 (CatalogProtocol::OpenAiCompletions, Api::OpenAiCompletions),
179 (CatalogProtocol::OpenAiResponses, Api::OpenAiResponses),
180 (CatalogProtocol::AnthropicMessages, Api::AnthropicMessages),
181 (CatalogProtocol::GoogleGenerativeAi, Api::GoogleGenerativeAi),
182 ];
183 for (proto, expected_api) in cases {
184 let mut entry = sample_entry();
185 entry.protocol = proto;
186 let model = catalog_entry_to_model("test", &entry);
187 assert_eq!(model.api, expected_api);
188 }
189 }
190}