Skip to main content

recursive/llm/
pricing.rs

1//! LLM pricing and retry policy.
2//!
3//! Contains the shared [`RetryPolicy`] struct used by all provider
4//! implementations, the per-model [`ModelPricing`] struct, and lookup
5//! helpers that consult the bundled `providers.toml` preset catalog.
6
7use std::time::Duration;
8
9use super::TokenUsage;
10
11// ── Retry policy ────────────────────────────────────────────────────────────
12
13/// Retry policy for transient LLM provider failures (network timeouts, 5xx).
14///
15/// Shared across all provider implementations to keep retry semantics
16/// consistent. Each provider stores one instance in its struct and calls
17/// [`RetryPolicy::backoff_for`] after every failed HTTP attempt.
18#[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    /// Returns `Some(backoff)` if the caller should sleep-and-retry, or `None`
37    /// to propagate the error. `attempt` is 0-indexed (0 = after the first failure).
38    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// ── Model pricing ────────────────────────────────────────────────────────────
58
59/// Per-million-token pricing for one model. USD.
60#[derive(Debug, Clone, Copy, PartialEq)]
61pub struct ModelPricing {
62    pub input_per_million: f64,
63    pub output_per_million: f64,
64    /// Price per million tokens for cache-hit prompts.
65    /// Defaults to 10% of input rate (DeepSeek's known discount).
66    pub cache_hit_input_per_million: f64,
67}
68
69impl ModelPricing {
70    /// USD cost for the given usage at this pricing.
71    pub fn cost_usd(&self, usage: TokenUsage) -> f64 {
72        let in_cost = if usage.cache_hit_tokens > 0 {
73            // Apply cache-hit discount: use cache_hit_input_per_million for cached tokens
74            let cache_hit =
75                usage.cache_hit_tokens as f64 * self.cache_hit_input_per_million / 1_000_000.0;
76            // Use full rate for cache-miss tokens (which is prompt - cache_hit)
77            // Note: cache_miss_tokens may not equal prompt - cache_hit due to rounding,
78            // but the difference is negligible for billing purposes
79            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
92// ── Lookup helpers ────────────────────────────────────────────────────────────
93
94/// Returns the context window size in tokens for the given model.
95///
96/// The value is looked up from the bundled `providers.toml` preset catalog.
97/// Unknown models (not listed in any preset) fall back to a conservative
98/// 128 K token default — the minimum window common to all current-generation
99/// frontier models.
100pub 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    // Conservative fallback for models not listed in providers.toml.
110    128_000
111}
112
113/// Like [`context_window_tokens_for_model`] but consults the **effective**
114/// catalog (remote cache + bundled + `providers.d/`) rather than the
115/// compile-time `providers.toml` alone. Mirrors [`find_model_pricing_effective`]:
116/// the TUI's `/model` picker lists models with their effective
117/// `context_window`, so the input-box context gauge must read the same
118/// source — otherwise it shows a stale bundled value for a model the user
119/// has overridden to a different window in `providers.d/`.
120pub 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                // A 0 context_window in an override means "unspecified"
125                // (e.g. an incomplete `providers.d` entry for a model the
126                // upstream catalog hasn't filled in yet). Fall through to
127                // the bundled lookup rather than reporting a nonsensical 0,
128                // so the gauge never shows "ctx x/0 - 100%".
129                if spec.context_window > 0 {
130                    return spec.context_window;
131                }
132            }
133        }
134    }
135    // No effective spec with a positive window — fall back to the bundled
136    // catalog (which itself falls back to 128K for unknown models).
137    context_window_tokens_for_model(model)
138}
139
140/// Compute the default compaction character-count threshold for a model.
141///
142/// Strategy (mirrors fake-cc `getAutoCompactThreshold`):
143/// 1. Start from the model's context window in tokens.
144/// 2. Reserve 20 000 tokens for the compaction summary output.
145/// 3. Take 80 % of the remainder as the trigger point (leaves a comfortable
146///    20 % buffer before the hard limit is hit).
147/// 4. Convert tokens → characters using a conservative 4 chars/token ratio.
148///
149/// **Limitation**: the 4 chars/token ratio works well for English but
150/// significantly underestimates token density in CJK languages (~1 char ≈
151/// 2-3 tokens). Prefer [`default_compact_threshold_tokens`] when actual
152/// prompt-token counts are available from the API response.
153pub 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    // 80 % of effective window, then 4 chars per token.
158    (effective_tokens as f64 * 0.8 * 4.0) as usize
159}
160
161/// Compute the default compaction token threshold for a model.
162///
163/// Uses the same 80 % / reserve-for-summary strategy as
164/// [`default_compact_threshold_chars`] but returns the raw token count
165/// instead of converting to characters. This threshold is more reliable
166/// than the character-based one for non-English content where the
167/// 4-char/token assumption breaks down (e.g. CJK text uses ~1 char/token).
168///
169/// When actual `prompt_tokens` data is available from the API response,
170/// comparing against this threshold is preferred over the char estimate.
171pub 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    // 80 % of effective window.
176    (effective_tokens as f64 * 0.8) as u32
177}
178
179/// Returns pricing for a model by looking it up in the **effective** preset
180/// catalog (remote cache + bundled + `providers.d/`), so per-token cost
181/// reflects upstream catalog refreshes rather than just the compile-time
182/// `providers.toml`. Returns `None` if the model is not listed or has no
183/// pricing field.
184pub 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    // ── RetryPolicy::backoff_for ─────────────────────────────────────────────
212
213    #[test]
214    fn retry_policy_returns_none_when_attempt_exceeds_max() {
215        let policy = RetryPolicy::default(); // max_retries: 2
216        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(); // would be 8s without cap
295        assert_eq!(
296            b3,
297            Duration::from_secs(5),
298            "backoff must be capped at max_backoff"
299        );
300    }
301
302    // ── ModelPricing::cost_usd ───────────────────────────────────────────────
303
304    #[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        // 500K cache hits + 500K cache miss = $0.05 + $0.50 = $0.55
378        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; // 0.05 + 0.50
385        assert!(
386            (cost - expected).abs() < 1e-9,
387            "cache-hit tokens use discounted rate, got {cost} vs {expected}"
388        );
389    }
390
391    // ── context_window_tokens_for_model ─────────────────────────────────────
392
393    #[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    // ── context_window_tokens_for_model_effective ───────────────────────────
400
401    #[test]
402    fn context_window_effective_unknown_model_returns_fallback() {
403        // The effective catalog falls back to the same 128K as the bundled
404        // path for models it doesn't list. Asserted as an exact value (not
405        // machine-dependent): no `providers.d` override can invent a spec
406        // for an unknown model name.
407        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        // A catalogued model must resolve to a positive window. We avoid
414        // asserting an exact value because the effective catalog honours
415        // `providers.d` overrides + the remote cache, which differ per
416        // machine — only the non-zero invariant is portable.
417        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    // ── default_compact_threshold_chars ─────────────────────────────────────
422
423    #[test]
424    fn compact_threshold_unknown_model_uses_fallback_128k() {
425        let threshold = default_compact_threshold_chars("unknown-xyz");
426        // 128_000 - min(20_000, 32_000)=20_000 → 108_000 * 0.8 * 4 = 345_600
427        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    // ── default_compact_threshold_tokens ────────────────────────────────────
437
438    #[test]
439    fn compact_threshold_tokens_unknown_model_uses_fallback_128k() {
440        let threshold = default_compact_threshold_tokens("unknown-xyz");
441        // 128_000 - min(20_000, 32_000)=20_000 → 108_000 * 0.8 = 86_400
442        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        // 200_000 - min(20_000, 50_000)=20_000 → 180_000 * 0.8 = 144_000
449        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        // 1_000_000 - min(20_000, 250_000)=20_000 → 980_000 * 0.8 = 784_000
456        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}