use std::sync::OnceLock;
use chrono::NaiveDate;
use serde::Deserialize;
use crate::ModelSpec;
use crate::pricing::CostCalculator;
use crate::types::{
AssistantMessage, Cost, ModelCapabilities, ThinkingLevel, ThinkingLevelSet, Usage,
};
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProviderKind {
Remote,
Local,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuthMode {
Bearer,
ApiKeyHeader,
AwsSigv4,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ApiVersion {
V1,
V1beta,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PresetCapability {
Text,
Tools,
Thinking,
ImagesIn,
Streaming,
StructuredOutput,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PresetStatus {
Ga,
Preview,
Deprecated {
#[serde(default)]
replacement_model_id: Option<String>,
},
}
impl PresetStatus {
#[must_use]
pub const fn is_deprecated(&self) -> bool {
matches!(self, Self::Deprecated { .. })
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct PresetCatalog {
pub id: String,
pub display_name: String,
pub group: Option<String>,
pub model_id: String,
pub api_version: Option<ApiVersion>,
#[serde(default)]
pub capabilities: Vec<PresetCapability>,
pub status: Option<PresetStatus>,
pub context_window_tokens: Option<u64>,
pub max_output_tokens: Option<u64>,
#[serde(default)]
pub include_by_default: bool,
pub repo_id: Option<String>,
pub filename: Option<String>,
#[serde(default)]
pub cost_per_million_input: Option<f64>,
#[serde(default)]
pub cost_per_million_output: Option<f64>,
#[serde(default)]
pub cost_per_million_cache_read: Option<f64>,
#[serde(default)]
pub cost_per_million_cache_write: Option<f64>,
pub reasoning_levels: Option<ThinkingLevelSet>,
}
impl PresetCatalog {
#[must_use]
pub fn new(
id: impl Into<String>,
display_name: impl Into<String>,
model_id: impl Into<String>,
) -> Self {
Self {
id: id.into(),
display_name: display_name.into(),
group: None,
model_id: model_id.into(),
api_version: None,
capabilities: Vec::new(),
status: None,
context_window_tokens: None,
max_output_tokens: None,
include_by_default: false,
repo_id: None,
filename: None,
cost_per_million_input: None,
cost_per_million_output: None,
cost_per_million_cache_read: None,
cost_per_million_cache_write: None,
reasoning_levels: None,
}
}
#[must_use]
pub fn with_group(mut self, group: impl Into<String>) -> Self {
self.group = Some(group.into());
self
}
#[must_use]
pub fn with_api_version(mut self, api_version: ApiVersion) -> Self {
self.api_version = Some(api_version);
self
}
#[must_use]
pub fn with_capabilities(mut self, capabilities: Vec<PresetCapability>) -> Self {
self.capabilities = capabilities;
self
}
#[must_use]
pub const fn with_reasoning_levels(mut self, levels: ThinkingLevelSet) -> Self {
self.reasoning_levels = Some(levels);
self
}
#[must_use]
pub fn with_status(mut self, status: PresetStatus) -> Self {
self.status = Some(status);
self
}
#[must_use]
pub const fn with_context_window_tokens(mut self, tokens: u64) -> Self {
self.context_window_tokens = Some(tokens);
self
}
#[must_use]
pub const fn with_max_output_tokens(mut self, tokens: u64) -> Self {
self.max_output_tokens = Some(tokens);
self
}
#[must_use]
pub const fn with_include_by_default(mut self, include: bool) -> Self {
self.include_by_default = include;
self
}
#[must_use]
pub fn with_repo_id(mut self, repo_id: impl Into<String>) -> Self {
self.repo_id = Some(repo_id.into());
self
}
#[must_use]
pub fn with_filename(mut self, filename: impl Into<String>) -> Self {
self.filename = Some(filename.into());
self
}
#[must_use]
pub const fn with_cost_per_million_input(mut self, cost: f64) -> Self {
self.cost_per_million_input = Some(cost);
self
}
#[must_use]
pub const fn with_cost_per_million_output(mut self, cost: f64) -> Self {
self.cost_per_million_output = Some(cost);
self
}
#[must_use]
pub const fn with_cost_per_million_cache_read(mut self, cost: f64) -> Self {
self.cost_per_million_cache_read = Some(cost);
self
}
#[must_use]
pub const fn with_cost_per_million_cache_write(mut self, cost: f64) -> Self {
self.cost_per_million_cache_write = Some(cost);
self
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct ProviderCatalog {
pub key: String,
pub display_name: String,
pub kind: ProviderKind,
pub auth_mode: Option<AuthMode>,
pub credential_env_var: Option<String>,
pub base_url_env_var: Option<String>,
pub default_base_url: Option<String>,
#[serde(default)]
pub requires_base_url: bool,
pub region_env_var: Option<String>,
#[serde(default)]
pub presets: Vec<PresetCatalog>,
}
impl ProviderCatalog {
#[must_use]
pub fn new(
key: impl Into<String>,
display_name: impl Into<String>,
kind: ProviderKind,
) -> Self {
Self {
key: key.into(),
display_name: display_name.into(),
kind,
auth_mode: None,
credential_env_var: None,
base_url_env_var: None,
default_base_url: None,
requires_base_url: false,
region_env_var: None,
presets: Vec::new(),
}
}
#[must_use]
pub fn with_auth_mode(mut self, auth_mode: AuthMode) -> Self {
self.auth_mode = Some(auth_mode);
self
}
#[must_use]
pub fn with_credential_env_var(mut self, var: impl Into<String>) -> Self {
self.credential_env_var = Some(var.into());
self
}
#[must_use]
pub fn with_base_url_env_var(mut self, var: impl Into<String>) -> Self {
self.base_url_env_var = Some(var.into());
self
}
#[must_use]
pub fn with_default_base_url(mut self, url: impl Into<String>) -> Self {
self.default_base_url = Some(url.into());
self
}
#[must_use]
pub const fn with_requires_base_url(mut self, requires: bool) -> Self {
self.requires_base_url = requires;
self
}
#[must_use]
pub fn with_region_env_var(mut self, var: impl Into<String>) -> Self {
self.region_env_var = Some(var.into());
self
}
#[must_use]
pub fn with_presets(mut self, presets: Vec<PresetCatalog>) -> Self {
self.presets = presets;
self
}
#[must_use]
pub fn preset(&self, preset_id: &str) -> Option<&PresetCatalog> {
self.presets.iter().find(|preset| preset.id == preset_id)
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct ModelCatalog {
#[serde(default)]
pub pricing_as_of: Option<String>,
#[serde(default)]
pub providers: Vec<ProviderCatalog>,
}
impl ModelCatalog {
#[must_use]
pub fn new() -> Self {
Self {
pricing_as_of: None,
providers: Vec::new(),
}
}
#[must_use]
pub fn with_pricing_as_of(mut self, pricing_as_of: impl Into<String>) -> Self {
self.pricing_as_of = Some(pricing_as_of.into());
self
}
#[must_use]
pub fn with_providers(mut self, providers: Vec<ProviderCatalog>) -> Self {
self.providers = providers;
self
}
#[must_use]
pub fn provider(&self, provider_key: &str) -> Option<&ProviderCatalog> {
self.providers
.iter()
.find(|provider| provider.key == provider_key)
}
#[must_use]
pub fn find_preset_by_model_id(&self, model_id: &str) -> Option<CatalogPreset> {
for provider in &self.providers {
for preset in &provider.presets {
if preset.model_id == model_id {
return self.preset(&provider.key, &preset.id);
}
}
}
None
}
#[must_use]
pub fn find_preset(&self, provider_key: &str, model_id: &str) -> Option<CatalogPreset> {
let provider = self.provider(provider_key)?;
let preset = provider.presets.iter().find(|p| p.model_id == model_id)?;
self.preset(&provider.key, &preset.id)
}
#[must_use]
pub fn preset(&self, provider_key: &str, preset_id: &str) -> Option<CatalogPreset> {
let provider = self.provider(provider_key)?;
let preset = provider.preset(preset_id)?;
Some(CatalogPreset {
provider_key: provider.key.clone(),
provider_display_name: provider.display_name.clone(),
provider_kind: provider.kind.clone(),
preset_id: preset.id.clone(),
display_name: preset.display_name.clone(),
group: preset.group.clone(),
model_id: preset.model_id.clone(),
api_version: preset.api_version.clone(),
capabilities: preset.capabilities.clone(),
status: preset.status.clone(),
context_window_tokens: preset.context_window_tokens,
max_output_tokens: preset.max_output_tokens,
auth_mode: provider.auth_mode.clone(),
credential_env_var: provider.credential_env_var.clone(),
base_url_env_var: provider.base_url_env_var.clone(),
default_base_url: provider.default_base_url.clone(),
requires_base_url: provider.requires_base_url,
region_env_var: provider.region_env_var.clone(),
include_by_default: preset.include_by_default,
repo_id: preset.repo_id.clone(),
filename: preset.filename.clone(),
cost_per_million_input: preset.cost_per_million_input,
cost_per_million_output: preset.cost_per_million_output,
cost_per_million_cache_read: preset.cost_per_million_cache_read,
cost_per_million_cache_write: preset.cost_per_million_cache_write,
reasoning_levels: preset.reasoning_levels,
})
}
}
impl Default for ModelCatalog {
fn default() -> Self {
Self::new()
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq)]
pub struct CatalogPreset {
pub provider_key: String,
pub provider_display_name: String,
pub provider_kind: ProviderKind,
pub preset_id: String,
pub display_name: String,
pub group: Option<String>,
pub model_id: String,
pub api_version: Option<ApiVersion>,
pub capabilities: Vec<PresetCapability>,
pub status: Option<PresetStatus>,
pub context_window_tokens: Option<u64>,
pub max_output_tokens: Option<u64>,
pub auth_mode: Option<AuthMode>,
pub credential_env_var: Option<String>,
pub base_url_env_var: Option<String>,
pub default_base_url: Option<String>,
pub requires_base_url: bool,
pub region_env_var: Option<String>,
pub include_by_default: bool,
pub repo_id: Option<String>,
pub filename: Option<String>,
pub cost_per_million_input: Option<f64>,
pub cost_per_million_output: Option<f64>,
pub cost_per_million_cache_read: Option<f64>,
pub cost_per_million_cache_write: Option<f64>,
pub reasoning_levels: Option<ThinkingLevelSet>,
}
impl CatalogPreset {
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn new(
provider_key: impl Into<String>,
provider_display_name: impl Into<String>,
provider_kind: ProviderKind,
preset_id: impl Into<String>,
display_name: impl Into<String>,
model_id: impl Into<String>,
) -> Self {
Self {
provider_key: provider_key.into(),
provider_display_name: provider_display_name.into(),
provider_kind,
preset_id: preset_id.into(),
display_name: display_name.into(),
group: None,
model_id: model_id.into(),
api_version: None,
capabilities: Vec::new(),
status: None,
context_window_tokens: None,
max_output_tokens: None,
auth_mode: None,
credential_env_var: None,
base_url_env_var: None,
default_base_url: None,
requires_base_url: false,
region_env_var: None,
include_by_default: false,
repo_id: None,
filename: None,
cost_per_million_input: None,
cost_per_million_output: None,
cost_per_million_cache_read: None,
cost_per_million_cache_write: None,
reasoning_levels: None,
}
}
#[must_use]
pub fn with_group(mut self, group: impl Into<String>) -> Self {
self.group = Some(group.into());
self
}
#[must_use]
pub fn with_api_version(mut self, api_version: ApiVersion) -> Self {
self.api_version = Some(api_version);
self
}
#[must_use]
pub fn with_capabilities(mut self, capabilities: Vec<PresetCapability>) -> Self {
self.capabilities = capabilities;
self
}
#[must_use]
pub const fn with_reasoning_levels(mut self, levels: ThinkingLevelSet) -> Self {
self.reasoning_levels = Some(levels);
self
}
#[must_use]
pub fn with_status(mut self, status: PresetStatus) -> Self {
self.status = Some(status);
self
}
#[must_use]
pub const fn with_context_window_tokens(mut self, tokens: u64) -> Self {
self.context_window_tokens = Some(tokens);
self
}
#[must_use]
pub const fn with_max_output_tokens(mut self, tokens: u64) -> Self {
self.max_output_tokens = Some(tokens);
self
}
#[must_use]
pub fn with_auth_mode(mut self, auth_mode: AuthMode) -> Self {
self.auth_mode = Some(auth_mode);
self
}
#[must_use]
pub fn with_credential_env_var(mut self, var: impl Into<String>) -> Self {
self.credential_env_var = Some(var.into());
self
}
#[must_use]
pub fn with_base_url_env_var(mut self, var: impl Into<String>) -> Self {
self.base_url_env_var = Some(var.into());
self
}
#[must_use]
pub fn with_default_base_url(mut self, url: impl Into<String>) -> Self {
self.default_base_url = Some(url.into());
self
}
#[must_use]
pub const fn with_requires_base_url(mut self, requires: bool) -> Self {
self.requires_base_url = requires;
self
}
#[must_use]
pub fn with_region_env_var(mut self, var: impl Into<String>) -> Self {
self.region_env_var = Some(var.into());
self
}
#[must_use]
pub const fn with_include_by_default(mut self, include: bool) -> Self {
self.include_by_default = include;
self
}
#[must_use]
pub fn with_repo_id(mut self, repo_id: impl Into<String>) -> Self {
self.repo_id = Some(repo_id.into());
self
}
#[must_use]
pub fn with_filename(mut self, filename: impl Into<String>) -> Self {
self.filename = Some(filename.into());
self
}
#[must_use]
pub const fn with_cost_per_million_input(mut self, cost: f64) -> Self {
self.cost_per_million_input = Some(cost);
self
}
#[must_use]
pub const fn with_cost_per_million_output(mut self, cost: f64) -> Self {
self.cost_per_million_output = Some(cost);
self
}
#[must_use]
pub const fn with_cost_per_million_cache_read(mut self, cost: f64) -> Self {
self.cost_per_million_cache_read = Some(cost);
self
}
#[must_use]
pub const fn with_cost_per_million_cache_write(mut self, cost: f64) -> Self {
self.cost_per_million_cache_write = Some(cost);
self
}
#[must_use]
pub fn model_capabilities(&self) -> ModelCapabilities {
let has = |cap: &PresetCapability| self.capabilities.contains(cap);
ModelCapabilities {
supports_thinking: has(&PresetCapability::Thinking),
supports_vision: has(&PresetCapability::ImagesIn),
supports_tool_use: has(&PresetCapability::Tools),
supports_streaming: has(&PresetCapability::Streaming),
supports_structured_output: has(&PresetCapability::StructuredOutput),
max_context_window: self.context_window_tokens,
max_output_tokens: self.max_output_tokens,
reasoning_levels: self.reasoning_levels,
}
}
#[must_use]
pub fn model_spec(&self) -> ModelSpec {
let capabilities = self.model_capabilities();
let mut spec = ModelSpec::new(&self.provider_key, &self.model_id);
if self.provider_kind == ProviderKind::Local && capabilities.supports_thinking {
spec = spec.with_thinking_level(ThinkingLevel::Medium);
}
spec.with_capabilities(capabilities)
}
#[must_use]
pub fn is_deprecated(&self) -> bool {
self.status
.as_ref()
.is_some_and(PresetStatus::is_deprecated)
}
#[must_use]
pub fn replacement_model_id(&self) -> Option<&str> {
match self.status.as_ref()? {
PresetStatus::Deprecated {
replacement_model_id,
} => replacement_model_id.as_deref(),
_ => None,
}
}
}
impl ModelCatalog {
#[must_use]
pub fn pricing_as_of_date(&self) -> Option<NaiveDate> {
NaiveDate::parse_from_str(self.pricing_as_of.as_deref()?, "%Y-%m-%d").ok()
}
#[must_use]
pub fn pricing_staleness_at(
&self,
today: NaiveDate,
threshold_days: u32,
) -> Option<PricingStaleness> {
let as_of = self.pricing_as_of_date()?;
let age_days = (today - as_of).num_days();
(age_days > i64::from(threshold_days)).then_some(PricingStaleness {
as_of,
age_days,
threshold_days,
})
}
}
pub const DEFAULT_PRICING_STALENESS_DAYS: u32 = 180;
pub const PRICING_STALENESS_ENV_VAR: &str = "SWINK_PRICING_STALENESS_DAYS";
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PricingStaleness {
pub as_of: NaiveDate,
pub age_days: i64,
pub threshold_days: u32,
}
impl PricingStaleness {
#[must_use]
pub const fn new(as_of: NaiveDate, age_days: i64, threshold_days: u32) -> Self {
Self {
as_of,
age_days,
threshold_days,
}
}
}
#[must_use]
pub fn pricing_staleness(threshold_days: u32) -> Option<PricingStaleness> {
model_catalog().pricing_staleness_at(chrono::Utc::now().date_naive(), threshold_days)
}
pub(crate) fn warn_if_pricing_stale() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
let threshold_days = std::env::var(PRICING_STALENESS_ENV_VAR)
.ok()
.and_then(|value| value.trim().parse::<u32>().ok())
.unwrap_or(DEFAULT_PRICING_STALENESS_DAYS);
if let Some(staleness) = pricing_staleness(threshold_days) {
tracing::warn!(
pricing_as_of = %staleness.as_of,
age_days = staleness.age_days,
threshold_days = staleness.threshold_days,
"compiled-in model pricing table may be stale; costs from \
calculate_cost() may not match current provider prices"
);
}
});
}
#[must_use]
pub fn model_catalog() -> &'static ModelCatalog {
static MODEL_CATALOG: OnceLock<ModelCatalog> = OnceLock::new();
MODEL_CATALOG.get_or_init(|| {
toml::from_str(include_str!("model_catalog.toml"))
.expect("src/model_catalog.toml must be valid TOML")
})
}
#[must_use]
pub fn calculate_cost(model_id: &str, usage: &Usage) -> Cost {
let Some(preset) = model_catalog().find_preset_by_model_id(model_id) else {
tracing::debug!(
model_id,
"model not found in catalog; cost reported as zero"
);
return Cost::default();
};
cost_from_preset(&preset, usage)
}
#[must_use]
pub fn calculate_cost_for_provider(provider_key: &str, model_id: &str, usage: &Usage) -> Cost {
match model_catalog().find_preset(provider_key, model_id) {
Some(preset) => cost_from_preset(&preset, usage),
None => calculate_cost(model_id, usage),
}
}
fn cost_from_preset(preset: &CatalogPreset, usage: &Usage) -> Cost {
#[allow(clippy::cast_precision_loss)] let per_m = |tokens: u64, rate: Option<f64>| -> f64 {
rate.map_or(0.0, |r| tokens as f64 * r / 1_000_000.0)
};
let input = per_m(usage.input, preset.cost_per_million_input);
let output = per_m(usage.output, preset.cost_per_million_output);
let cache_read = per_m(usage.cache_read, preset.cost_per_million_cache_read);
let cache_write = per_m(usage.cache_write, preset.cost_per_million_cache_write);
Cost {
input,
output,
cache_read,
cache_write,
total: input + output + cache_read + cache_write,
..Cost::default()
}
}
pub fn price_assistant_message(message: &mut AssistantMessage) -> bool {
price_assistant_message_with(message, None)
}
pub fn price_assistant_message_with(
message: &mut AssistantMessage,
calculator: Option<&dyn CostCalculator>,
) -> bool {
if !message.cost.is_zero() {
return false;
}
let priced = calculator
.and_then(|calculator| calculator.calculate(&message.model_id, &message.usage))
.filter(|cost| !cost.is_zero())
.unwrap_or_else(|| {
calculate_cost_for_provider(&message.provider, &message.model_id, &message.usage)
});
if priced.is_zero() {
return false;
}
message.cost = priced;
true
}
#[cfg(test)]
#[path = "model_catalog_tests.rs"]
mod tests;