use std::ops::RangeInclusive;
use serde::{Deserialize, Serialize};
use crate::completion::{
CacheRetention, Cost, Effort, GenerationOptions, Reasoning, UnsupportedOption, Usage,
};
use crate::providers::registry::ProviderId;
#[non_exhaustive]
#[derive(Clone, Debug, PartialEq)]
pub struct ModelSpec {
pub id: String,
pub provider: ProviderId,
pub display_name: String,
pub context_window: Option<u32>,
pub max_output_tokens: Option<u32>,
pub input: Modalities,
pub reasoning: ReasoningSupport,
pub caching: CacheSupport,
pub tools: bool,
pub structured_output: bool,
pub pricing: Option<Pricing>,
pub deprecated: bool,
pub sampling: Option<Sampling>,
#[doc(hidden)]
pub compat: Compat,
}
#[non_exhaustive]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub struct Modalities {
pub text: bool,
pub image: bool,
pub audio: bool,
pub video: bool,
pub pdf: bool,
}
#[non_exhaustive]
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ReasoningSupport {
pub supported: bool,
pub levels: Vec<Effort>,
pub budget: Option<RangeInclusive<u32>>,
pub can_disable: bool,
pub default: Option<Effort>,
}
#[non_exhaustive]
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct CacheSupport {
pub retention: Vec<CacheRetention>,
}
#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Pricing {
pub input: f64,
pub output: f64,
pub cache_read: Option<f64>,
pub cache_write: Option<f64>,
}
impl Pricing {
pub fn cost(&self, usage: &Usage) -> Option<Cost> {
let input = usage.input_tokens?;
let output = usage.output_tokens?;
let read = usage.cached_input_tokens.unwrap_or(0);
let written = usage.cache_creation_input_tokens.unwrap_or(0);
let uncached = input.saturating_sub(read).saturating_sub(written);
let price = |tokens: u64, per_million: f64| tokens as f64 * per_million / 1_000_000.0;
Some(Cost::from_parts(
price(uncached, self.input),
price(output, self.output),
price(read, self.cache_read.unwrap_or(self.input)),
price(written, self.cache_write.unwrap_or(self.input)),
))
}
}
#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Sampling {
Any,
ReasoningOff,
Never,
}
#[doc(hidden)]
#[non_exhaustive]
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Compat {
pub reasoning_field: Option<String>,
pub adaptive_thinking: bool,
pub thinking_off: Option<String>,
pub mid_conversation_system: bool,
pub rejects_forced_tool_choice: bool,
pub binds_context: bool,
pub prompt_cache_options: bool,
pub chat_tools_need_reasoning_off: bool,
}
impl ModelSpec {
pub fn validate(&self, options: &GenerationOptions) -> Result<(), UnsupportedOption> {
let refuse = |option: &'static str, reason: String| {
UnsupportedOption::new(option, self.provider.vendor(), &self.id, reason)
};
if let Some(reason) = options
.reasoning
.as_ref()
.and_then(|reasoning| self.reasoning.refusal(reasoning))
{
return Err(refuse("reasoning", reason));
}
if let Some(reason) = options
.cache
.as_ref()
.and_then(|cache| self.caching.refusal(cache))
{
return Err(refuse("cache", reason));
}
Ok(())
}
}
impl ReasoningSupport {
pub fn refusal(&self, reasoning: &Reasoning) -> Option<String> {
match reasoning {
Reasoning::Off if !self.supported || self.can_disable => None,
Reasoning::Off => Some("reasoning cannot be turned off on this model".to_owned()),
_ if !self.supported => Some("the model does not reason".to_owned()),
Reasoning::Effort(effort) if self.levels.contains(effort) => None,
Reasoning::Effort(_) if self.levels.is_empty() && self.budget.is_some() => {
Some("the model takes a reasoning budget, not an effort level".to_owned())
}
Reasoning::Effort(_) if self.levels.is_empty() => {
Some("the model takes no effort level".to_owned())
}
Reasoning::Effort(effort) => Some(format!(
"the model has no `{}` effort level",
effort.as_str()
)),
Reasoning::Budget { tokens } => match &self.budget {
Some(range) if range.contains(tokens) => None,
Some(range) => Some(format!(
"the model takes a reasoning budget from {} to {} tokens",
range.start(),
range.end()
)),
None if self.levels.is_empty() => {
Some("the model takes no reasoning budget".to_owned())
}
None => Some("the model takes an effort level, not a reasoning budget".to_owned()),
},
}
}
}
impl CacheSupport {
pub fn refusal(&self, cache: &CacheRetention) -> Option<String> {
(!self.retention.is_empty() && !self.retention.contains(cache)).then(|| {
format!(
"the model does not honour `{}` cache retention",
retention_word(cache)
)
})
}
}
pub(super) fn retention_word(cache: &CacheRetention) -> &'static str {
match cache {
CacheRetention::None => "none",
CacheRetention::Short => "short",
CacheRetention::Long => "long",
}
}