meerkat_core/model_profile/
mod.rs1pub mod anthropic;
19pub mod capabilities;
20pub mod catalog;
21pub mod gemini;
22pub mod openai;
23pub mod schema_builder;
24
25use crate::Provider;
26use crate::model_profile::capabilities::{
27 BetaHeader, ModelCapabilities, ThinkingSupport, capabilities_for,
28};
29use serde::{Deserialize, Serialize};
30
31#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
38pub struct ModelProfile {
39 pub provider: String,
41 pub model_family: String,
43 pub supports_temperature: bool,
45 pub supports_thinking: bool,
47 pub supports_reasoning: bool,
49 pub inline_video: bool,
51 pub vision: bool,
53 pub image_input: bool,
55 pub image_tool_results: bool,
58 pub realtime: bool,
62 pub supports_web_search: bool,
64 pub image_generation: bool,
66 pub params_schema: serde_json::Value,
68 #[serde(default)]
70 pub beta_headers: Vec<ModelBetaHeader>,
71 #[serde(skip_serializing_if = "Option::is_none")]
77 pub call_timeout_secs: Option<u64>,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
82pub struct ModelBetaHeader {
83 pub feature: String,
84 pub header_name: String,
85 pub header_value: String,
86}
87
88impl From<&BetaHeader> for ModelBetaHeader {
89 fn from(value: &BetaHeader) -> Self {
90 Self {
91 feature: value.feature.to_string(),
92 header_name: value.header_name.to_string(),
93 header_value: value.header_value.to_string(),
94 }
95 }
96}
97
98pub fn profile_for(provider: Provider, model: &str) -> Option<ModelProfile> {
106 capabilities_for(provider, model).map(project_to_profile)
107}
108
109pub fn inline_video_support_for(provider: Provider, model: &str) -> Option<bool> {
113 capabilities_for(provider, model).map(|caps| caps.inline_video)
114}
115
116pub(crate) fn project_to_profile(caps: &ModelCapabilities) -> ModelProfile {
118 ModelProfile {
119 provider: caps.provider.as_str().to_string(),
120 model_family: caps.model_family.to_string(),
121 supports_temperature: caps.supports_temperature,
122 supports_thinking: caps.thinking != ThinkingSupport::None,
123 supports_reasoning: caps.supports_reasoning,
124 supports_web_search: caps.supports_web_search,
125 inline_video: caps.inline_video,
126 vision: caps.vision,
127 image_input: caps.vision,
128 image_tool_results: caps.image_tool_results,
129 realtime: caps.realtime,
130 image_generation: catalog::default_image_generation_model(caps.provider).is_some(),
131 params_schema: schema_builder::build_params_schema(caps),
132 beta_headers: caps
133 .beta_headers
134 .iter()
135 .map(ModelBetaHeader::from)
136 .collect(),
137 call_timeout_secs: caps.call_timeout_secs,
138 }
139}
140
141#[cfg(test)]
142#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
143mod tests {
144 use super::*;
145
146 fn provider_from_catalog(provider: &str) -> Provider {
147 Provider::parse_strict(provider)
148 .unwrap_or_else(|| panic!("catalog provider '{provider}' must parse"))
149 }
150
151 #[test]
152 fn profile_for_all_catalog_models() {
153 for entry in crate::model_profile::catalog::catalog() {
154 let profile = profile_for(provider_from_catalog(entry.provider), entry.id);
155 assert!(
156 profile.is_some(),
157 "catalog model '{}' (provider '{}') must have a profile",
158 entry.id,
159 entry.provider
160 );
161 }
162 }
163
164 #[test]
165 fn unknown_provider_returns_none() {
166 assert!(profile_for(Provider::Other, "some-model").is_none());
167 }
168
169 #[test]
170 fn uncatalogued_model_returns_none_for_known_provider() {
171 assert!(profile_for(Provider::OpenAI, "gpt-5.9-future").is_none());
172 assert!(profile_for(Provider::Anthropic, "claude-opus-4-7-20260501-preview").is_none());
173 assert!(profile_for(Provider::Gemini, "gemini-4-future").is_none());
174 }
175
176 #[test]
177 fn wrong_typed_provider_for_known_model_returns_none() {
178 assert!(profile_for(Provider::Anthropic, "gpt-5.4").is_none());
179 assert!(profile_for(Provider::OpenAI, "gemini-3.5-flash").is_none());
180 }
181
182 #[test]
183 fn unknown_provider_model_pairs_fail_closed_without_defaults() {
184 assert!(profile_for(Provider::Other, "gpt-5.4").is_none());
185 assert!(profile_for(Provider::Other, "uncatalogued-gpt-compatible").is_none());
186 assert!(inline_video_support_for(Provider::Other, "gemini-3.5-flash").is_none());
187 }
188
189 #[test]
190 fn display_provider_strings_cannot_select_capability_without_typed_provider() {
191 let display_provider = Provider::parse_strict("Gemini").unwrap_or(Provider::Other);
192 assert_eq!(display_provider, Provider::Other);
193 assert_eq!(
194 inline_video_support_for(display_provider, "gemini-3.5-flash"),
195 None
196 );
197 }
198
199 #[test]
200 fn claude_profile_vision_and_image_tool_results_true() {
201 let profile = profile_for(Provider::Anthropic, "claude-opus-4-6")
202 .expect("claude-opus-4-6 must have a profile");
203 assert!(profile.vision, "Anthropic models must support vision");
204 assert!(
205 profile.image_tool_results,
206 "Anthropic models must support image tool results"
207 );
208 assert!(
209 !profile.inline_video,
210 "Anthropic models must NOT support inline video"
211 );
212
213 let profile = profile_for(Provider::Anthropic, "claude-sonnet-4-5")
214 .expect("claude-sonnet-4-5 must have a profile");
215 assert!(profile.vision);
216 assert!(profile.image_tool_results);
217 }
218
219 #[test]
220 fn gpt_profile_vision_true_image_tool_results_false() {
221 let profile =
222 profile_for(Provider::OpenAI, "gpt-5.4").expect("gpt-5.4 must have a profile");
223 assert!(profile.vision, "OpenAI models must support vision");
224 assert!(
225 !profile.image_tool_results,
226 "OpenAI models must NOT support image tool results"
227 );
228 assert!(
229 !profile.inline_video,
230 "OpenAI models must NOT support inline video"
231 );
232 }
233
234 #[test]
235 fn gemini_profile_vision_and_image_tool_results_true() {
236 let profile = profile_for(Provider::Gemini, "gemini-3.5-flash")
237 .expect("gemini-3.5-flash must have a profile");
238 assert!(profile.vision, "Gemini models must support vision");
239 assert!(
240 profile.image_tool_results,
241 "Gemini models must support image tool results"
242 );
243 assert!(
244 profile.inline_video,
245 "Gemini models must support inline video"
246 );
247 }
248
249 #[test]
250 fn all_gemini_profiles_preserve_inline_video_support() {
251 for entry in catalog::catalog()
252 .iter()
253 .filter(|entry| entry.provider == "gemini")
254 {
255 assert!(
256 profile_for(provider_from_catalog(entry.provider), entry.id)
257 .as_ref()
258 .is_some_and(|profile| profile.inline_video),
259 "Gemini model '{}' must support inline video",
260 entry.id
261 );
262 }
263 }
264
265 #[test]
266 fn inline_video_support_for_reads_capability_truth() {
267 assert_eq!(
268 inline_video_support_for(Provider::Gemini, "gemini-3.5-flash"),
269 Some(true)
270 );
271 assert_eq!(
272 inline_video_support_for(Provider::OpenAI, "gpt-5.4"),
273 Some(false)
274 );
275 assert_eq!(
276 inline_video_support_for(Provider::Gemini, "gemini-4-future"),
277 None
278 );
279 }
280
281 #[test]
282 fn params_schema_non_empty_for_all_profiles() {
283 for entry in crate::model_profile::catalog::catalog() {
284 let profile = profile_for(provider_from_catalog(entry.provider), entry.id);
285 if let Some(p) = profile {
286 assert!(
287 p.params_schema.is_object(),
288 "params_schema for '{}' must be a JSON object, got {:?}",
289 entry.id,
290 p.params_schema
291 );
292 }
293 }
294 }
295
296 #[test]
297 fn call_timeout_secs_populated_for_known_models() {
298 for entry in crate::model_profile::catalog::catalog() {
299 let profile = profile_for(provider_from_catalog(entry.provider), entry.id);
300 if let Some(p) = profile {
301 assert!(
302 p.call_timeout_secs.is_some(),
303 "catalog model '{}' (provider '{}', family '{}') must have call_timeout_secs",
304 entry.id,
305 entry.provider,
306 p.model_family
307 );
308 }
309 }
310 }
311
312 #[test]
313 fn anthropic_opus_has_longer_timeout_than_haiku() {
314 let opus = profile_for(Provider::Anthropic, "claude-opus-4-6").unwrap();
315 let haiku = profile_for(Provider::Anthropic, "claude-haiku-4-5-20251001").unwrap();
316 assert!(
317 opus.call_timeout_secs.unwrap() > haiku.call_timeout_secs.unwrap(),
318 "Opus should have a longer default timeout than Haiku"
319 );
320 }
321
322 #[test]
323 fn openai_pro_has_longer_timeout_than_standard_gpt5() {
324 let pro = profile_for(Provider::OpenAI, "gpt-5.5-pro").unwrap();
325 let standard = profile_for(Provider::OpenAI, "gpt-5.5").unwrap();
326 assert!(
327 pro.call_timeout_secs.unwrap() > standard.call_timeout_secs.unwrap(),
328 "gpt-5.5-pro ({}) should have a much longer timeout than gpt-5.5 ({})",
329 pro.call_timeout_secs.unwrap(),
330 standard.call_timeout_secs.unwrap(),
331 );
332 }
333
334 #[test]
335 fn gemini_flash_has_shorter_timeout_than_pro() {
336 let flash = profile_for(Provider::Gemini, "gemini-3.1-flash-lite-preview").unwrap();
337 let pro = profile_for(Provider::Gemini, "gemini-3.1-pro-preview").unwrap();
338 assert!(
339 flash.call_timeout_secs.unwrap() < pro.call_timeout_secs.unwrap(),
340 "gemini flash ({}) should have shorter timeout than gemini pro ({})",
341 flash.call_timeout_secs.unwrap(),
342 pro.call_timeout_secs.unwrap(),
343 );
344 }
345
346 #[test]
347 fn unknown_provider_call_timeout_is_none() {
348 assert!(profile_for(Provider::Other, "model").is_none());
349 }
350
351 #[test]
352 fn web_search_flag_populated_for_all_catalog_models() {
353 for entry in crate::model_profile::catalog::catalog() {
354 let profile = profile_for(provider_from_catalog(entry.provider), entry.id);
355 assert!(
356 profile.is_some(),
357 "catalog model '{}' (provider '{}') must have a profile",
358 entry.id,
359 entry.provider
360 );
361 }
362 }
363
364 #[test]
365 fn anthropic_supports_web_search() {
366 let profile = profile_for(Provider::Anthropic, "claude-opus-4-6").unwrap();
367 assert!(profile.supports_web_search);
368 }
369
370 #[test]
371 fn openai_supports_web_search() {
372 let profile = profile_for(Provider::OpenAI, "gpt-5.4").unwrap();
373 assert!(profile.supports_web_search);
374 }
375
376 #[test]
377 fn gemini_supports_web_search() {
378 let profile = profile_for(Provider::Gemini, "gemini-3.5-flash").unwrap();
379 assert!(profile.supports_web_search);
380 }
381}