1use std::time::Duration;
8
9use super::TokenUsage;
10
11#[derive(Debug, Clone)]
19pub struct RetryPolicy {
20 pub max_retries: usize,
21 pub initial_backoff: Duration,
22 pub max_backoff: Duration,
23}
24
25impl Default for RetryPolicy {
26 fn default() -> Self {
27 Self {
28 max_retries: 2,
29 initial_backoff: Duration::from_secs(1),
30 max_backoff: Duration::from_secs(8),
31 }
32 }
33}
34
35impl RetryPolicy {
36 pub fn backoff_for(
39 &self,
40 attempt: usize,
41 status: Option<u16>,
42 is_network_error: bool,
43 ) -> Option<Duration> {
44 if attempt >= self.max_retries {
45 return None;
46 }
47 let is_transient =
48 is_network_error || status.is_some_and(|s| s == 429 || (500..600).contains(&s));
49 if !is_transient {
50 return None;
51 }
52 let backoff = self.initial_backoff * 2u32.saturating_pow(attempt as u32);
53 Some(backoff.min(self.max_backoff))
54 }
55}
56
57#[derive(Debug, Clone, Copy, PartialEq)]
61pub struct ModelPricing {
62 pub input_per_million: f64,
63 pub output_per_million: f64,
64 pub cache_hit_input_per_million: f64,
67}
68
69impl ModelPricing {
70 pub fn cost_usd(&self, usage: TokenUsage) -> f64 {
72 let in_cost = if usage.cache_hit_tokens > 0 {
73 let cache_hit =
75 usage.cache_hit_tokens as f64 * self.cache_hit_input_per_million / 1_000_000.0;
76 let cache_miss = usage.cache_miss_tokens as f64 * self.input_per_million / 1_000_000.0;
80 cache_hit + cache_miss
81 } else {
82 (usage.prompt_tokens as f64) * self.input_per_million / 1_000_000.0
83 };
84 let total_output_tokens = usage
85 .completion_tokens
86 .saturating_add(usage.reasoning_tokens);
87 let out_cost = total_output_tokens as f64 * self.output_per_million / 1_000_000.0;
88 in_cost + out_cost
89 }
90}
91
92pub fn context_window_tokens_for_model(model: &str) -> usize {
101 use crate::providers::all_presets;
102 for preset in all_presets() {
103 for spec in &preset.models {
104 if spec.name == model {
105 return spec.context_window;
106 }
107 }
108 }
109 128_000
111}
112
113pub fn context_window_tokens_for_model_effective(model: &str) -> usize {
121 for preset in crate::providers::all_presets_effective() {
122 for spec in &preset.models {
123 if spec.name == model {
124 if spec.context_window > 0 {
130 return spec.context_window;
131 }
132 }
133 }
134 }
135 context_window_tokens_for_model(model)
138}
139
140pub fn default_compact_threshold_chars(model: &str) -> usize {
154 let context_tokens = context_window_tokens_for_model(model);
155 let reserved_for_summary = 20_000_usize.min(context_tokens / 4);
156 let effective_tokens = context_tokens.saturating_sub(reserved_for_summary);
157 (effective_tokens as f64 * 0.8 * 4.0) as usize
159}
160
161pub fn default_compact_threshold_tokens(model: &str) -> u32 {
172 let context_tokens = context_window_tokens_for_model(model);
173 let reserved_for_summary = 20_000_usize.min(context_tokens / 4);
174 let effective_tokens = context_tokens.saturating_sub(reserved_for_summary);
175 (effective_tokens as f64 * 0.8) as u32
177}
178
179pub fn pricing_for(model: &str) -> Option<ModelPricing> {
185 let spec = crate::providers::find_model_pricing_effective(model)?;
186 Some(ModelPricing {
187 input_per_million: spec.input_per_million,
188 output_per_million: spec.output_per_million,
189 cache_hit_input_per_million: spec
190 .cache_hit_input_per_million
191 .unwrap_or(spec.input_per_million),
192 })
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198 use crate::llm::TokenUsage;
199
200 fn default_usage() -> TokenUsage {
201 TokenUsage {
202 prompt_tokens: 0,
203 completion_tokens: 0,
204 total_tokens: 0,
205 cache_hit_tokens: 0,
206 cache_miss_tokens: 0,
207 reasoning_tokens: 0,
208 }
209 }
210
211 #[test]
214 fn retry_policy_returns_none_when_attempt_exceeds_max() {
215 let policy = RetryPolicy::default(); assert!(
217 policy.backoff_for(2, None, true).is_none(),
218 "attempt == max_retries must return None"
219 );
220 assert!(
221 policy.backoff_for(5, None, true).is_none(),
222 "attempt > max_retries must return None"
223 );
224 }
225
226 #[test]
227 fn retry_policy_network_error_returns_some_backoff() {
228 let policy = RetryPolicy::default();
229 let result = policy.backoff_for(0, None, true);
230 assert!(result.is_some(), "network error must trigger retry");
231 assert_eq!(result.unwrap(), Duration::from_secs(1));
232 }
233
234 #[test]
235 fn retry_policy_status_429_returns_some_backoff() {
236 let policy = RetryPolicy::default();
237 let result = policy.backoff_for(0, Some(429), false);
238 assert!(result.is_some(), "429 must trigger retry");
239 }
240
241 #[test]
242 fn retry_policy_status_503_returns_some_backoff() {
243 let policy = RetryPolicy::default();
244 let result = policy.backoff_for(0, Some(503), false);
245 assert!(result.is_some(), "503 (5xx) must trigger retry");
246 }
247
248 #[test]
249 fn retry_policy_status_200_returns_none() {
250 let policy = RetryPolicy::default();
251 let result = policy.backoff_for(0, Some(200), false);
252 assert!(result.is_none(), "200 OK must not trigger retry");
253 }
254
255 #[test]
256 fn retry_policy_status_400_returns_none() {
257 let policy = RetryPolicy::default();
258 let result = policy.backoff_for(0, Some(400), false);
259 assert!(result.is_none(), "400 client error must not trigger retry");
260 }
261
262 #[test]
263 fn retry_policy_backoff_doubles_per_attempt() {
264 let policy = RetryPolicy {
265 max_retries: 5,
266 initial_backoff: Duration::from_secs(1),
267 max_backoff: Duration::from_secs(100),
268 };
269 assert_eq!(
270 policy.backoff_for(0, None, true).unwrap(),
271 Duration::from_secs(1)
272 );
273 assert_eq!(
274 policy.backoff_for(1, None, true).unwrap(),
275 Duration::from_secs(2)
276 );
277 assert_eq!(
278 policy.backoff_for(2, None, true).unwrap(),
279 Duration::from_secs(4)
280 );
281 assert_eq!(
282 policy.backoff_for(3, None, true).unwrap(),
283 Duration::from_secs(8)
284 );
285 }
286
287 #[test]
288 fn retry_policy_backoff_capped_at_max() {
289 let policy = RetryPolicy {
290 max_retries: 5,
291 initial_backoff: Duration::from_secs(1),
292 max_backoff: Duration::from_secs(5),
293 };
294 let b3 = policy.backoff_for(3, None, true).unwrap(); assert_eq!(
296 b3,
297 Duration::from_secs(5),
298 "backoff must be capped at max_backoff"
299 );
300 }
301
302 #[test]
305 fn model_pricing_cost_no_tokens_is_zero() {
306 let pricing = ModelPricing {
307 input_per_million: 1.0,
308 output_per_million: 2.0,
309 cache_hit_input_per_million: 0.5,
310 };
311 let cost = pricing.cost_usd(default_usage());
312 assert_eq!(cost, 0.0);
313 }
314
315 #[test]
316 fn model_pricing_cost_prompt_only() {
317 let pricing = ModelPricing {
318 input_per_million: 1.0,
319 output_per_million: 2.0,
320 cache_hit_input_per_million: 0.5,
321 };
322 let usage = TokenUsage {
323 prompt_tokens: 1_000_000,
324 ..default_usage()
325 };
326 let cost = pricing.cost_usd(usage);
327 assert!(
328 (cost - 1.0).abs() < 1e-9,
329 "1M input tokens at $1/M must cost $1, got {cost}"
330 );
331 }
332
333 #[test]
334 fn model_pricing_cost_completion_only() {
335 let pricing = ModelPricing {
336 input_per_million: 1.0,
337 output_per_million: 3.0,
338 cache_hit_input_per_million: 0.5,
339 };
340 let usage = TokenUsage {
341 completion_tokens: 1_000_000,
342 ..default_usage()
343 };
344 let cost = pricing.cost_usd(usage);
345 assert!(
346 (cost - 3.0).abs() < 1e-9,
347 "1M completion tokens at $3/M must cost $3, got {cost}"
348 );
349 }
350
351 #[test]
352 fn model_pricing_cost_reasoning_tokens_add_to_output() {
353 let pricing = ModelPricing {
354 input_per_million: 0.0,
355 output_per_million: 1.0,
356 cache_hit_input_per_million: 0.0,
357 };
358 let usage = TokenUsage {
359 completion_tokens: 500_000,
360 reasoning_tokens: 500_000,
361 ..default_usage()
362 };
363 let cost = pricing.cost_usd(usage);
364 assert!(
365 (cost - 1.0).abs() < 1e-9,
366 "completion + reasoning = 1M output tokens at $1/M must cost $1, got {cost}"
367 );
368 }
369
370 #[test]
371 fn model_pricing_cost_cache_hit_applies_discounted_rate() {
372 let pricing = ModelPricing {
373 input_per_million: 1.0,
374 output_per_million: 0.0,
375 cache_hit_input_per_million: 0.1,
376 };
377 let usage = TokenUsage {
379 cache_hit_tokens: 500_000,
380 cache_miss_tokens: 500_000,
381 ..default_usage()
382 };
383 let cost = pricing.cost_usd(usage);
384 let expected = 0.5 * 0.1 + 0.5 * 1.0; assert!(
386 (cost - expected).abs() < 1e-9,
387 "cache-hit tokens use discounted rate, got {cost} vs {expected}"
388 );
389 }
390
391 #[test]
394 fn context_window_unknown_model_returns_fallback() {
395 let w = context_window_tokens_for_model("totally-unknown-model-xyz");
396 assert_eq!(w, 128_000, "unknown models must fall back to 128K");
397 }
398
399 #[test]
402 fn context_window_effective_unknown_model_returns_fallback() {
403 let w = context_window_tokens_for_model_effective("totally-unknown-model-xyz");
408 assert_eq!(w, 128_000, "unknown models must fall back to 128K");
409 }
410
411 #[test]
412 fn context_window_effective_known_model_is_nonzero() {
413 let w = context_window_tokens_for_model_effective("gpt-4o");
418 assert!(w > 0, "effective context window for gpt-4o must be > 0");
419 }
420
421 #[test]
424 fn compact_threshold_unknown_model_uses_fallback_128k() {
425 let threshold = default_compact_threshold_chars("unknown-xyz");
426 assert_eq!(threshold, 345_600);
428 }
429
430 #[test]
431 fn compact_threshold_is_positive() {
432 let t = default_compact_threshold_chars("gpt-4o");
433 assert!(t > 0, "compact threshold must be positive for any model");
434 }
435
436 #[test]
439 fn compact_threshold_tokens_unknown_model_uses_fallback_128k() {
440 let threshold = default_compact_threshold_tokens("unknown-xyz");
441 assert_eq!(threshold, 86_400);
443 }
444
445 #[test]
446 fn compact_threshold_tokens_glm5_2_uses_200k_window() {
447 let threshold = default_compact_threshold_tokens("z-ai/glm-5.2");
448 assert_eq!(threshold, 144_000);
450 }
451
452 #[test]
453 fn compact_threshold_tokens_glm5_2_1m_uses_1m_window() {
454 let threshold = default_compact_threshold_tokens("z-ai/glm-5.2[1m]");
455 assert_eq!(threshold, 784_000);
457 }
458
459 #[test]
460 fn compact_threshold_tokens_is_less_than_context_window() {
461 for model in &["gpt-4o", "z-ai/glm-5.2", "deepseek-chat", "unknown-xyz"] {
462 let window = context_window_tokens_for_model(model);
463 let threshold = default_compact_threshold_tokens(model) as usize;
464 assert!(
465 threshold < window,
466 "token threshold {threshold} must be below context window {window} for {model}"
467 );
468 }
469 }
470}