use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::model_config::{ModelCapabilities, ModelType};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscoveredModel {
pub id: String,
pub name: String,
pub provider: String,
pub context_length: usize,
pub max_output_tokens: usize,
pub capabilities: ModelCapabilities,
pub source: DiscoverySource,
pub discovered_at: DateTime<Utc>,
pub available: bool,
pub cost_per_m_input: Option<f64>,
pub cost_per_m_output: Option<f64>,
pub model_type: ModelType,
pub tags: Vec<String>,
pub deprecated: bool,
}
impl Default for DiscoveredModel {
fn default() -> Self {
Self {
id: String::new(),
name: String::new(),
provider: String::new(),
context_length: 0,
max_output_tokens: 0,
capabilities: ModelCapabilities::default(),
source: DiscoverySource::Unknown,
discovered_at: Utc::now(),
available: true,
cost_per_m_input: None,
cost_per_m_output: None,
model_type: ModelType::Llm,
tags: Vec::new(),
deprecated: false,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub enum DiscoverySource {
DynamicApi,
StaticRegistry,
Hybrid,
UserConfig,
#[default]
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DiscoveryStrategy {
Dynamic,
Static,
Hybrid,
}
#[derive(Debug, Clone, Default)]
pub struct CapabilityFilter {
pub min_context_length: Option<usize>,
pub min_output_tokens: Option<usize>,
pub requires_vision: Option<bool>,
pub requires_tools: Option<bool>,
pub requires_thinking: Option<bool>,
pub requires_streaming: Option<bool>,
pub requires_json_mode: Option<bool>,
pub model_type: Option<ModelType>,
pub provider: Option<String>,
pub tags: Option<Vec<String>>,
pub max_cost_per_m_input: Option<f64>,
pub exclude_deprecated: Option<bool>,
}
impl CapabilityFilter {
pub fn matches(&self, model: &DiscoveredModel) -> bool {
if let Some(min) = self.min_context_length {
if model.context_length < min {
return false;
}
}
if let Some(min) = self.min_output_tokens {
if model.max_output_tokens < min {
return false;
}
}
if let Some(true) = self.requires_vision {
if !model.capabilities.supports_vision {
return false;
}
}
if let Some(true) = self.requires_tools {
if !model.capabilities.supports_function_calling {
return false;
}
}
if let Some(true) = self.requires_thinking {
if !model.capabilities.supports_thinking {
return false;
}
}
if let Some(true) = self.requires_streaming {
if !model.capabilities.supports_streaming {
return false;
}
}
if let Some(true) = self.requires_json_mode {
if !model.capabilities.supports_json_mode {
return false;
}
}
if let Some(ref mt) = self.model_type {
if &model.model_type != mt {
return false;
}
}
if let Some(ref p) = self.provider {
if &model.provider != p {
return false;
}
}
if let Some(ref tags) = self.tags {
if !tags.iter().any(|t| model.tags.contains(t)) {
return false;
}
}
if let Some(max) = self.max_cost_per_m_input {
match model.cost_per_m_input {
Some(cost) if cost > max => return false,
None => return false,
_ => {}
}
}
if let Some(true) = self.exclude_deprecated {
if model.deprecated {
return false;
}
}
true
}
}
#[derive(Debug, thiserror::Error)]
pub enum DiscoveryError {
#[error("Provider '{provider}' unreachable: {source}")]
ProviderUnreachable {
provider: String,
source: Box<dyn std::error::Error + Send + Sync>,
},
#[error("Failed to parse discovery response from '{provider}': {details}")]
ParseError { provider: String, details: String },
#[error("Authentication required for '{provider}' model discovery")]
AuthRequired { provider: String },
#[error("Discovery timed out for '{provider}' after {timeout_ms}ms")]
Timeout { provider: String, timeout_ms: u64 },
#[error("Provider '{provider}' not registered")]
ProviderNotRegistered { provider: String },
#[error("{0}")]
Other(String),
}
impl From<DiscoveryError> for crate::error::LlmError {
fn from(e: DiscoveryError) -> Self {
crate::error::LlmError::Unknown(e.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_model(
id: &str,
provider: &str,
ctx: usize,
vision: bool,
tools: bool,
thinking: bool,
) -> DiscoveredModel {
DiscoveredModel {
id: id.into(),
name: id.into(),
provider: provider.into(),
context_length: ctx,
max_output_tokens: 4096,
capabilities: ModelCapabilities {
supports_vision: vision,
supports_function_calling: tools,
supports_thinking: thinking,
supports_streaming: true,
supports_json_mode: true,
..Default::default()
},
cost_per_m_input: Some(2.0),
..Default::default()
}
}
#[test]
fn test_filter_matches_all() {
let model = make_model("gpt-4.1", "openai", 1_000_000, true, true, false);
let filter = CapabilityFilter::default();
assert!(filter.matches(&model));
}
#[test]
fn test_filter_min_context() {
let model = make_model("small", "openai", 8_000, false, false, false);
let filter = CapabilityFilter {
min_context_length: Some(100_000),
..Default::default()
};
assert!(!filter.matches(&model));
}
#[test]
fn test_filter_requires_vision() {
let model = make_model("no-vision", "openai", 128_000, false, true, false);
let filter = CapabilityFilter {
requires_vision: Some(true),
..Default::default()
};
assert!(!filter.matches(&model));
}
#[test]
fn test_filter_requires_tools() {
let model = make_model("no-tools", "openai", 128_000, true, false, false);
let filter = CapabilityFilter {
requires_tools: Some(true),
..Default::default()
};
assert!(!filter.matches(&model));
}
#[test]
fn test_filter_requires_thinking() {
let model = make_model("thinker", "anthropic", 200_000, true, true, true);
let filter = CapabilityFilter {
requires_thinking: Some(true),
..Default::default()
};
assert!(filter.matches(&model));
}
#[test]
fn test_filter_provider() {
let model = make_model("claude", "anthropic", 200_000, true, true, true);
let filter = CapabilityFilter {
provider: Some("openai".into()),
..Default::default()
};
assert!(!filter.matches(&model));
}
#[test]
fn test_filter_max_cost() {
let model = make_model("expensive", "openai", 128_000, true, true, false);
let filter = CapabilityFilter {
max_cost_per_m_input: Some(1.0),
..Default::default()
};
assert!(!filter.matches(&model));
}
#[test]
fn test_filter_exclude_deprecated() {
let mut model = make_model("old", "openai", 128_000, true, true, false);
model.deprecated = true;
let filter = CapabilityFilter {
exclude_deprecated: Some(true),
..Default::default()
};
assert!(!filter.matches(&model));
}
#[test]
fn test_filter_combined() {
let model = make_model("claude-opus-4-8", "anthropic", 1_000_000, true, true, true);
let filter = CapabilityFilter {
min_context_length: Some(500_000),
requires_vision: Some(true),
requires_tools: Some(true),
requires_thinking: Some(true),
provider: Some("anthropic".into()),
..Default::default()
};
assert!(filter.matches(&model));
}
#[test]
fn test_discovery_source_default_is_unknown() {
assert_eq!(DiscoverySource::default(), DiscoverySource::Unknown);
}
#[test]
fn test_unknown_model_has_zero_capabilities() {
let model = DiscoveredModel::default();
assert_eq!(model.context_length, 0);
assert_eq!(model.max_output_tokens, 0);
assert!(!model.capabilities.supports_vision);
assert!(!model.capabilities.supports_function_calling);
assert!(!model.capabilities.supports_thinking);
assert_eq!(model.source, DiscoverySource::Unknown);
}
}