Skip to main content

meerkat_core/model_profile/
mod.rs

1//! Model profile — projects a [`crate::capabilities::ModelCapabilities`] row
2//! into the narrower [`ModelProfile`] surface consumed by the rest of the
3//! platform.
4//!
5//! Before the per-model capability refactor, this module owned the full
6//! capability definitions via hand-written struct-per-bucket types. That
7//! lived in `anthropic.rs`, `openai.rs`, `gemini.rs` — each providing a
8//! `profile(model)` function, a fixed set of JSON Schema buckets, and
9//! heuristic helpers (`supports_adaptive_thinking`, `is_gpt5_family`, …).
10//!
11//! After the refactor, capability data lives in
12//! [`crate::capabilities`] as a per-model table, and the JSON Schema is
13//! derived from it by [`schema_builder::build_params_schema`]. The
14//! per-provider modules now hold only request-shaping helpers that read the
15//! same catalog. Uncatalogued model IDs do not receive synthesized semantic
16//! capabilities.
17
18pub 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/// Runtime profile for a model, describing its capabilities and operational defaults.
32///
33/// This is a **capability-plus-operational-defaults catalog**: it owns both model
34/// capability flags (vision, thinking, temperature) and authoritative model-specific
35/// operational defaults (call timeout) that the factory composes into effective
36/// runtime policy. This ownership expansion is deliberate — see dogma rule §11.
37#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
38pub struct ModelProfile {
39    /// Canonical provider string.
40    pub provider: String,
41    /// Model family identifier (e.g., `"claude-opus-4"`, `"gpt-5"`, `"gemini-3"`).
42    pub model_family: String,
43    /// Whether the model accepts a `temperature` parameter.
44    pub supports_temperature: bool,
45    /// Whether the model supports extended thinking / reasoning budgets.
46    pub supports_thinking: bool,
47    /// Whether the model supports explicit reasoning effort control.
48    pub supports_reasoning: bool,
49    /// Whether the model accepts inline video content in user messages.
50    pub inline_video: bool,
51    /// Whether the model accepts image content in user messages.
52    pub vision: bool,
53    /// Whether user messages may include image input blocks.
54    pub image_input: bool,
55    /// Whether the model can process image blocks in tool results.
56    /// When false, `view_image` is hidden from the tool list.
57    pub image_tool_results: bool,
58    /// Whether the model supports a realtime bidirectional streaming transport
59    /// (e.g. OpenAI `*-realtime*` endpoints, Gemini `*-live*` endpoints). Drives
60    /// capability-based realtime transport attach/detach in the runtime.
61    pub realtime: bool,
62    /// Whether the model supports provider-native web search tools.
63    pub supports_web_search: bool,
64    /// Whether the provider/model can use Meerkat image generation.
65    pub image_generation: bool,
66    /// JSON Schema describing accepted provider-specific parameters.
67    pub params_schema: serde_json::Value,
68    /// Beta headers authorized by the model capability catalog.
69    #[serde(default)]
70    pub beta_headers: Vec<ModelBetaHeader>,
71    /// Authoritative default call timeout in seconds for this model family.
72    ///
73    /// `None` means the model family has no profiled default timeout.
74    /// This is the canonical source for model-specific call timeout defaults,
75    /// consumed by the factory/agent-loop resolver trait at call time.
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub call_timeout_secs: Option<u64>,
78}
79
80/// Catalog-owned beta header metadata for a model.
81#[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
98/// Look up the profile for a model by typed provider and model ID.
99///
100/// Catalog models project directly from their capability row. Uncatalogued
101/// model IDs return `None`; semantic capability facts must come from the
102/// capability catalog, not model-name prefixes.
103///
104/// Returns `None` if the provider/model pair has no catalog capability row.
105pub fn profile_for(provider: Provider, model: &str) -> Option<ModelProfile> {
106    capabilities_for(provider, model).map(project_to_profile)
107}
108
109/// Look up whether a model accepts inline video by typed provider and model ID.
110///
111/// Returns `None` when the provider/model pair has no capability row.
112pub fn inline_video_support_for(provider: Provider, model: &str) -> Option<bool> {
113    capabilities_for(provider, model).map(|caps| caps.inline_video)
114}
115
116/// Project a capability record into the [`ModelProfile`] surface.
117pub(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}