pub mod capabilities;
pub mod catalog;
pub mod schema_builder;
use crate::Provider;
use crate::model_profile::capabilities::{BetaHeader, ModelCapabilities, ThinkingSupport};
use crate::model_profile::catalog::{
CatalogEntry, ImageGenerationModelProfile, ImageGenerationModelRoute,
ImageGenerationProviderDefaults, ProviderDefaults,
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
pub struct ModelProfile {
pub provider: Provider,
pub model_family: String,
pub supports_temperature: bool,
pub supports_thinking: bool,
pub supports_reasoning: bool,
pub inline_video: bool,
pub vision: bool,
pub image_input: bool,
pub image_tool_results: bool,
pub realtime: bool,
pub supports_web_search: bool,
pub image_generation: bool,
pub params_schema: serde_json::Value,
#[serde(default)]
pub beta_headers: Vec<ModelBetaHeader>,
#[serde(skip_serializing_if = "Option::is_none")]
pub call_timeout_secs: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
pub struct ModelBetaHeader {
pub feature: String,
pub header_name: String,
pub header_value: String,
}
impl From<&BetaHeader> for ModelBetaHeader {
fn from(value: &BetaHeader) -> Self {
Self {
feature: value.feature.as_wire_str().to_string(),
header_name: value.header_name.to_string(),
header_value: value.header_value.to_string(),
}
}
}
pub fn project_to_profile(caps: &ModelCapabilities) -> ModelProfile {
ModelProfile {
provider: caps.provider,
model_family: caps.model_family.to_string(),
supports_temperature: caps.supports_temperature,
supports_thinking: caps.thinking != ThinkingSupport::None,
supports_reasoning: caps.supports_reasoning,
supports_web_search: caps.supports_web_search,
inline_video: caps.inline_video,
vision: caps.vision,
image_input: caps.vision,
image_tool_results: caps.image_tool_results,
realtime: caps.realtime,
image_generation: caps.image_generation,
params_schema: schema_builder::build_params_schema(caps),
beta_headers: caps
.beta_headers
.iter()
.map(ModelBetaHeader::from)
.collect(),
call_timeout_secs: caps.call_timeout_secs,
}
}
#[derive(Debug, Clone, Copy)]
pub struct ModelCatalog {
pub entries: &'static [CatalogEntry],
pub capabilities: &'static [ModelCapabilities],
pub provider_defaults: &'static [ProviderDefaults],
pub image_generation_models: &'static [ImageGenerationModelProfile],
pub providers: &'static [Provider],
pub default_models: &'static [(Provider, &'static str)],
pub image_generation_defaults: &'static [(Provider, &'static str)],
pub global_default_model: &'static str,
pub provider_priority: &'static [Provider],
}
impl ModelCatalog {
pub fn entry_for(self, provider: Provider, model_id: &str) -> Option<&'static CatalogEntry> {
self.entries
.iter()
.find(|e| e.provider == provider.as_str() && e.id == model_id)
}
pub fn default_model(self, provider: Provider) -> Option<&'static str> {
self.default_models
.iter()
.find(|(p, _)| *p == provider)
.map(|(_, id)| *id)
}
pub fn allowed_models(
self,
provider: Provider,
) -> impl Iterator<Item = &'static str> + 'static {
self.entries
.iter()
.filter(move |e| e.provider == provider.as_str())
.map(|e| e.id)
}
pub fn capabilities_for(
self,
provider: Provider,
model_id: &str,
) -> Option<&'static ModelCapabilities> {
self.capabilities
.iter()
.find(|c| c.provider == provider && c.id == model_id)
}
pub fn profile_for(self, provider: Provider, model: &str) -> Option<ModelProfile> {
self.capabilities_for(provider, model)
.map(project_to_profile)
}
pub fn inline_video_support_for(self, provider: Provider, model: &str) -> Option<bool> {
self.capabilities_for(provider, model)
.map(|caps| caps.inline_video)
}
pub fn infer_provider(self, model: &str) -> Option<Provider> {
self.entries
.iter()
.find(|entry| entry.id == model)
.and_then(|entry| Provider::parse_strict(entry.provider))
}
pub fn default_image_generation_model_id(self, provider: Provider) -> Option<&'static str> {
self.image_generation_defaults
.iter()
.find(|(p, _)| *p == provider)
.map(|(_, id)| *id)
}
pub fn default_image_generation_model(
self,
provider: Provider,
) -> Option<ImageGenerationModelProfile> {
let default = self.default_image_generation_model_id(provider)?;
self.image_generation_model(provider, default)
}
pub fn image_generation_model(
self,
provider: Provider,
model_id: &str,
) -> Option<ImageGenerationModelProfile> {
if let Some(profile) = self
.image_generation_models
.iter()
.copied()
.find(|profile| profile.provider == provider && profile.model_id == model_id)
{
return Some(profile);
}
let tool_model_id = self.hosted_responses_tool_model_id(provider)?;
self.capabilities_for(provider, model_id)
.map(|caps| ImageGenerationModelProfile {
provider,
model_id: caps.id,
display_name: caps.display_name,
tier: caps.tier,
route: ImageGenerationModelRoute::OpenAiHostedResponsesTool {
provider_call_model_id: None,
tool_model_id,
},
})
}
pub fn image_generation_provider_for_model(self, model_id: &str) -> Option<Provider> {
self.image_generation_models
.iter()
.find(|profile| profile.model_id == model_id)
.map(|profile| profile.provider)
.or_else(|| {
self.providers.iter().copied().find(|&provider| {
self.hosted_responses_tool_model_id(provider).is_some()
&& self.capabilities_for(provider, model_id).is_some()
})
})
}
pub fn image_generation_provider_defaults(self) -> Vec<ImageGenerationProviderDefaults> {
self.image_generation_defaults
.iter()
.filter_map(|&(provider, _)| {
let default_model = self.default_image_generation_model(provider)?;
let mut models: Vec<ImageGenerationModelProfile> = self
.image_generation_models
.iter()
.copied()
.filter(|profile| profile.provider == provider)
.collect();
if self.hosted_responses_tool_model_id(provider).is_some() {
models.extend(
self.entries
.iter()
.filter(|entry| entry.provider == provider.as_str())
.filter_map(|entry| self.image_generation_model(provider, entry.id)),
);
}
Some(ImageGenerationProviderDefaults {
provider,
default_model_id: default_model.model_id,
models,
})
})
.collect()
}
fn hosted_responses_tool_model_id(self, provider: Provider) -> Option<&'static str> {
let default_id = self.default_image_generation_model_id(provider)?;
let default_row = self
.image_generation_models
.iter()
.find(|profile| profile.provider == provider && profile.model_id == default_id)?;
match default_row.route {
ImageGenerationModelRoute::OpenAiHostedResponsesTool { tool_model_id, .. } => {
Some(tool_model_id)
}
_ => None,
}
}
}
#[cfg(test)]
pub(crate) mod test_catalog;
#[cfg(test)]
#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
mod tests {
use super::test_catalog::TEST_CATALOG;
use super::*;
const EXPECTED_MODEL_PROFILE_FIELDS: &[&str] = &[
"provider",
"model_family",
"supports_temperature",
"supports_thinking",
"supports_reasoning",
"supports_web_search",
"inline_video",
"vision",
"image_input",
"image_tool_results",
"realtime",
"image_generation",
"params_schema",
"call_timeout_secs",
"beta_headers",
];
#[test]
fn model_profile_wire_field_parity() {
let schema = schemars::schema_for!(ModelProfile);
let value = serde_json::to_value(&schema).expect("schema serializes to JSON");
let props = value
.get("properties")
.and_then(|p| p.as_object())
.expect("ModelProfile schema has a properties map")
.keys()
.cloned()
.collect::<std::collections::BTreeSet<_>>();
let expected: std::collections::BTreeSet<String> = EXPECTED_MODEL_PROFILE_FIELDS
.iter()
.map(|s| (*s).to_string())
.collect();
assert_eq!(
props, expected,
"ModelProfile field set drift — update meerkat-contracts::WireModelProfile \
in lockstep, regenerate artifact schemas (make regen-schemas), and update \
EXPECTED_MODEL_PROFILE_FIELDS if this change is intentional."
);
}
#[test]
fn profile_for_projects_capability_rows() {
for entry in TEST_CATALOG.entries {
let provider = Provider::parse_strict(entry.provider)
.unwrap_or_else(|| panic!("test catalog provider '{}' must parse", entry.provider));
let profile = TEST_CATALOG.profile_for(provider, entry.id);
assert!(
profile.is_some(),
"test catalog model '{}' must project a profile",
entry.id
);
}
}
#[test]
fn unknown_provider_or_model_fails_closed() {
assert!(
TEST_CATALOG
.profile_for(Provider::Other, "some-model")
.is_none()
);
assert!(
TEST_CATALOG
.profile_for(Provider::Anthropic, "uncatalogued-model")
.is_none()
);
assert!(
TEST_CATALOG
.inline_video_support_for(Provider::Other, "some-model")
.is_none()
);
}
#[test]
fn wrong_typed_provider_for_known_model_fails_closed() {
assert!(
TEST_CATALOG
.profile_for(Provider::Gemini, test_catalog::VIDEO_MODEL)
.is_some()
);
assert!(
TEST_CATALOG
.profile_for(Provider::OpenAI, test_catalog::VIDEO_MODEL)
.is_none()
);
}
#[test]
fn inline_video_support_reads_capability_truth() {
assert_eq!(
TEST_CATALOG.inline_video_support_for(Provider::Gemini, test_catalog::VIDEO_MODEL),
Some(true)
);
assert_eq!(
TEST_CATALOG.inline_video_support_for(Provider::OpenAI, test_catalog::OPENAI_MODEL),
Some(false)
);
}
#[test]
fn infer_provider_is_exact_catalog_match_only() {
assert_eq!(
TEST_CATALOG.infer_provider(test_catalog::ANTHROPIC_MODEL),
Some(Provider::Anthropic)
);
assert_eq!(TEST_CATALOG.infer_provider("uncatalogued-model"), None);
assert_eq!(TEST_CATALOG.infer_provider(""), None);
}
#[test]
fn default_models_resolve_through_typed_providers() {
assert_eq!(
TEST_CATALOG.default_model(Provider::Anthropic),
Some(test_catalog::ANTHROPIC_MODEL)
);
assert!(TEST_CATALOG.default_model(Provider::Other).is_none());
assert!(TEST_CATALOG.default_model(Provider::SelfHosted).is_none());
}
#[test]
fn image_generation_lookup_is_catalog_owned_and_fails_closed() {
let default = TEST_CATALOG
.default_image_generation_model(Provider::Gemini)
.expect("test catalog Gemini image default");
assert_eq!(default.model_id, test_catalog::GEMINI_IMAGE_MODEL);
assert!(
TEST_CATALOG
.default_image_generation_model(Provider::Anthropic)
.is_none()
);
assert!(
TEST_CATALOG
.image_generation_model(Provider::Gemini, "unknown-image-model")
.is_none()
);
assert_eq!(
TEST_CATALOG.image_generation_provider_for_model(test_catalog::GEMINI_IMAGE_MODEL),
Some(Provider::Gemini)
);
assert_eq!(
TEST_CATALOG.image_generation_provider_for_model("unknown-image-model"),
None
);
}
#[test]
fn hosted_tool_route_admits_text_models_of_hosted_provider_only() {
let via_text = TEST_CATALOG
.image_generation_model(Provider::OpenAI, test_catalog::OPENAI_MODEL)
.expect("hosted-tool provider text model admitted");
assert!(matches!(
via_text.route,
ImageGenerationModelRoute::OpenAiHostedResponsesTool {
provider_call_model_id: None,
..
}
));
assert!(
TEST_CATALOG
.image_generation_model(Provider::Gemini, test_catalog::VIDEO_MODEL)
.is_none()
);
}
#[test]
fn image_generation_provider_defaults_cover_declared_defaults() {
let defaults = TEST_CATALOG.image_generation_provider_defaults();
assert_eq!(defaults.len(), TEST_CATALOG.image_generation_defaults.len());
for d in &defaults {
assert!(
d.models.iter().any(|m| m.model_id == d.default_model_id),
"image-generation default must be present in provider models"
);
}
}
}