1#[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 Premium = 3,
15 Standard = 2,
17 Economy = 1,
19 Local = 0,
21}
22
23impl ModelTier {
24 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 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 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 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#[derive(Debug, Clone)]
63pub struct ModelCascade {
64 current: ModelTier,
66 pressure_threshold: f64,
68 total_budget: f64,
70}
71
72impl ModelCascade {
73 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 pub fn current(&self) -> ModelTier {
90 self.current
91 }
92
93 pub(crate) fn set_total_budget(&mut self, total: f64) {
95 if total > 0.0 {
96 self.total_budget = total;
97 }
98 }
99
100 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 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 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 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); assert_eq!(result, None); }
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}