Skip to main content

llm_budget/
model.rs

1//! Model tier definitions and automatic downgrade cascade.
2//!
3//! When budget pressure is detected, the cascade selects the next cheaper tier
4//! automatically. The cascade is: Premium, then Standard, then Economy, then Local.
5//!
6//! Tier prices are rough averages for when you do not know the exact model.
7//! For real per-model prices use [`crate::pricing::PriceTable`].
8
9/// Cost tier of an LLM model.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize)]
11#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
12pub enum ModelTier {
13    /// Highest capability, highest cost (e.g. GPT-4o, Claude Opus).
14    Premium = 3,
15    /// Mid-tier capability and cost (e.g. GPT-4o-mini, Claude Sonnet).
16    Standard = 2,
17    /// Low-cost, reduced capability (e.g. GPT-3.5, Claude Haiku).
18    Economy = 1,
19    /// Zero marginal cost local model (e.g. Ollama, llama.cpp).
20    Local = 0,
21}
22
23impl ModelTier {
24    /// Return the next cheaper tier, or None if already at the lowest.
25    pub fn downgrade(&self) -> Option<ModelTier> {
26        match self {
27            ModelTier::Premium => Some(ModelTier::Standard),
28            ModelTier::Standard => Some(ModelTier::Economy),
29            ModelTier::Economy => Some(ModelTier::Local),
30            ModelTier::Local => None,
31        }
32    }
33
34    /// Approximate cost per 1K output tokens in USD.
35    pub fn cost_per_1k_output(&self) -> f64 {
36        match self {
37            ModelTier::Premium => 0.075,
38            ModelTier::Standard => 0.015,
39            ModelTier::Economy => 0.00125,
40            ModelTier::Local => 0.0,
41        }
42    }
43
44    /// Approximate cost per 1K input tokens in USD.
45    pub fn cost_per_1k_input(&self) -> f64 {
46        match self {
47            ModelTier::Premium => 0.015,
48            ModelTier::Standard => 0.003,
49            ModelTier::Economy => 0.00025,
50            ModelTier::Local => 0.0,
51        }
52    }
53
54    /// Estimate cost for a request.
55    pub fn estimate_cost(&self, input_tokens: u32, output_tokens: u32) -> f64 {
56        (input_tokens as f64 / 1000.0) * self.cost_per_1k_input()
57            + (output_tokens as f64 / 1000.0) * self.cost_per_1k_output()
58    }
59}
60
61/// Automatic model downgrade cascade based on budget pressure thresholds.
62#[derive(Debug, Clone)]
63pub struct ModelCascade {
64    /// Current tier.
65    current: ModelTier,
66    /// Downgrade when remaining budget falls below this fraction (0.0 to 1.0).
67    pressure_threshold: f64,
68    /// Total budget used for percentage calculation.
69    total_budget: f64,
70}
71
72impl ModelCascade {
73    /// Create a cascade starting at `tier` with the given pressure threshold.
74    pub fn new(tier: ModelTier, pressure_threshold: f64, total_budget: f64) -> Result<Self, crate::error::BudgetError> {
75        if !(0.0..=1.0).contains(&pressure_threshold) {
76            return Err(crate::error::BudgetError::InvalidConfig(
77                format!("pressure_threshold must be in [0.0, 1.0], got {pressure_threshold}"),
78            ));
79        }
80        if total_budget <= 0.0 {
81            return Err(crate::error::BudgetError::InvalidConfig(
82                "total_budget must be positive".into(),
83            ));
84        }
85        Ok(Self { current: tier, pressure_threshold, total_budget })
86    }
87
88    /// Current model tier.
89    pub fn current(&self) -> ModelTier {
90        self.current
91    }
92
93    /// Update the budget the threshold is measured against (after a reallocation).
94    pub(crate) fn set_total_budget(&mut self, total: f64) {
95        if total > 0.0 {
96            self.total_budget = total;
97        }
98    }
99
100    /// Evaluate remaining budget and downgrade if under pressure.
101    /// Returns Some(new_tier) if a downgrade occurred, None otherwise.
102    pub fn evaluate(&mut self, remaining: f64) -> Option<ModelTier> {
103        let fraction_remaining = remaining / self.total_budget;
104        if fraction_remaining <= self.pressure_threshold {
105            if let Some(next) = self.current.downgrade() {
106                self.current = next;
107                return Some(self.current);
108            }
109        }
110        None
111    }
112
113    /// Force downgrade one tier. Returns the new tier, or None if already Local.
114    pub fn force_downgrade(&mut self) -> Option<ModelTier> {
115        if let Some(next) = self.current.downgrade() {
116            self.current = next;
117            Some(self.current)
118        } else {
119            None
120        }
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn test_model_tier_ordering() {
130        assert!(ModelTier::Premium > ModelTier::Standard);
131        assert!(ModelTier::Standard > ModelTier::Economy);
132        assert!(ModelTier::Economy > ModelTier::Local);
133    }
134
135    #[test]
136    fn test_downgrade_premium_to_standard() {
137        assert_eq!(ModelTier::Premium.downgrade(), Some(ModelTier::Standard));
138    }
139
140    #[test]
141    fn test_downgrade_standard_to_economy() {
142        assert_eq!(ModelTier::Standard.downgrade(), Some(ModelTier::Economy));
143    }
144
145    #[test]
146    fn test_downgrade_economy_to_local() {
147        assert_eq!(ModelTier::Economy.downgrade(), Some(ModelTier::Local));
148    }
149
150    #[test]
151    fn test_downgrade_local_returns_none() {
152        assert_eq!(ModelTier::Local.downgrade(), None);
153    }
154
155    #[test]
156    fn test_cost_per_1k_decreases_by_tier() {
157        assert!(ModelTier::Premium.cost_per_1k_output() > ModelTier::Standard.cost_per_1k_output());
158        assert!(ModelTier::Standard.cost_per_1k_output() > ModelTier::Economy.cost_per_1k_output());
159        assert_eq!(ModelTier::Local.cost_per_1k_output(), 0.0);
160    }
161
162    #[test]
163    fn test_estimate_cost_local_is_zero() {
164        assert_eq!(ModelTier::Local.estimate_cost(10000, 5000), 0.0);
165    }
166
167    #[test]
168    fn test_estimate_cost_premium_nonzero() {
169        let cost = ModelTier::Premium.estimate_cost(1000, 1000);
170        assert!(cost > 0.0);
171    }
172
173    #[test]
174    fn test_cascade_new_invalid_threshold() {
175        let result = ModelCascade::new(ModelTier::Premium, 1.5, 100.0);
176        assert!(result.is_err());
177    }
178
179    #[test]
180    fn test_cascade_new_invalid_budget() {
181        let result = ModelCascade::new(ModelTier::Premium, 0.2, 0.0);
182        assert!(result.is_err());
183    }
184
185    #[test]
186    fn test_cascade_evaluate_triggers_downgrade_when_below_threshold() {
187        let mut cascade = ModelCascade::new(ModelTier::Premium, 0.2, 100.0).expect("new");
188        // Remaining = 15.0 / 100.0 = 0.15 < 0.20 threshold
189        let result = cascade.evaluate(15.0);
190        assert_eq!(result, Some(ModelTier::Standard));
191        assert_eq!(cascade.current(), ModelTier::Standard);
192    }
193
194    #[test]
195    fn test_cascade_evaluate_no_downgrade_when_above_threshold() {
196        let mut cascade = ModelCascade::new(ModelTier::Premium, 0.2, 100.0).expect("new");
197        // Remaining = 50.0 / 100.0 = 0.50 > 0.20 threshold
198        let result = cascade.evaluate(50.0);
199        assert_eq!(result, None);
200        assert_eq!(cascade.current(), ModelTier::Premium);
201    }
202
203    #[test]
204    fn test_cascade_local_cannot_downgrade() {
205        let mut cascade = ModelCascade::new(ModelTier::Local, 0.5, 100.0).expect("new");
206        let result = cascade.evaluate(10.0); // 10% remaining < 50% threshold
207        assert_eq!(result, None); // no tier below Local
208    }
209
210    #[test]
211    fn test_cascade_force_downgrade_chain() {
212        let mut cascade = ModelCascade::new(ModelTier::Premium, 0.1, 100.0).expect("new");
213        assert_eq!(cascade.force_downgrade(), Some(ModelTier::Standard));
214        assert_eq!(cascade.force_downgrade(), Some(ModelTier::Economy));
215        assert_eq!(cascade.force_downgrade(), Some(ModelTier::Local));
216        assert_eq!(cascade.force_downgrade(), None);
217    }
218
219    #[test]
220    fn test_model_tier_serde_roundtrip() {
221        let tier = ModelTier::Standard;
222        let json = serde_json::to_string(&tier).expect("serialize");
223        let restored: ModelTier = serde_json::from_str(&json).expect("deserialize");
224        assert_eq!(restored, tier);
225    }
226}