Skip to main content

sparrow/cost/
mod.rs

1// ─── Cost comparison: Sparrow vs competitors (§ cost-routing) ──────────────────
2//
3// Every run tracks its own token usage and cost. This module answers:
4//   "What would this exact run have cost on Claude Code / Codex / OpenCode?"
5//
6// The answer is Sparrow's competitive moat — because Claude Code and Codex sell
7// their own tokens (structural conflict of interest), they can never route a
8// trivial "read this file" to a free local model. Sparrow does.
9
10use crate::event::TokenUsage;
11
12// ─── Competitor catalogue ───────────────────────────────────────────────────────
13
14/// A competing AI coding tool with known per-token pricing.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Competitor {
17    ClaudeCode, // Anthropic Claude Code (uses Claude Sonnet pricing)
18    Codex,      // OpenAI Codex CLI (uses GPT-4o pricing)
19    OpenCode,   // OpenCode (typically Claude/GPT routing)
20    Cursor,     // Cursor (GPT-4o / Claude)
21    Copilot,    // GitHub Copilot (GPT-4o / Claude)
22    Aider,      // Aider (user's own API key — variable, use market avg)
23}
24
25impl Competitor {
26    /// Human-readable name for display.
27    pub fn display_name(&self) -> &str {
28        match self {
29            Competitor::ClaudeCode => "Claude Code",
30            Competitor::Codex => "Codex CLI",
31            Competitor::OpenCode => "OpenCode",
32            Competitor::Cursor => "Cursor",
33            Competitor::Copilot => "GitHub Copilot",
34            Competitor::Aider => "Aider",
35        }
36    }
37
38    /// Cost per million tokens — (input, output) in USD.
39    /// Prices as of 2026-06. Conservative estimates (list price, no bulk discount).
40    pub fn price_per_mtok(&self) -> (f64, f64) {
41        match self {
42            // Claude 3.5/4 Sonnet pricing (what Claude Code uses)
43            Competitor::ClaudeCode => (3.0, 15.0),
44            // GPT-4o pricing (what Codex uses)
45            Competitor::Codex => (2.5, 10.0),
46            // OpenCode typically routes to Claude or GPT-4o — use Claude pricing
47            Competitor::OpenCode => (3.0, 15.0),
48            // Cursor Pro — GPT-4o / Claude 3.5 Sonnet
49            Competitor::Cursor => (2.5, 10.0),
50            // GitHub Copilot — uses GPT-4o / Claude 3.5
51            Competitor::Copilot => (2.5, 10.0),
52            // Aider — BYO key, use market-heavyweight average (Claude)
53            Competitor::Aider => (3.0, 15.0),
54        }
55    }
56
57    /// Compute what this competitor would have charged for the given token usage.
58    pub fn estimate_cost(&self, tokens: &TokenUsage) -> f64 {
59        let (in_price, out_price) = self.price_per_mtok();
60        let input_cost = tokens.input as f64 * in_price / 1_000_000.0;
61        let output_cost = tokens.output as f64 * out_price / 1_000_000.0;
62        input_cost + output_cost
63    }
64}
65
66// ─── Comparison engine ───────────────────────────────────────────────────────────
67
68/// A single competitor-vs-Sparrow cost line.
69#[derive(Debug, Clone)]
70pub struct CostLine {
71    pub competitor: Competitor,
72    pub estimated_cost: f64,
73    pub savings: f64, // Sparrow cost minus competitor cost (negative = Sparrow cheaper)
74    pub savings_percent: f64, // e.g. 93.0 = Sparrow is 93% cheaper
75}
76
77/// Builds a full cost comparison report.
78///
79/// `sparrow_cost` is the ACTUAL cost from the Sparrow run.
80/// `tokens` is the token usage from the run (used to estimate competitor cost).
81pub fn compare(sparrow_cost: f64, tokens: &TokenUsage) -> Vec<CostLine> {
82    let competitors = [
83        Competitor::ClaudeCode,
84        Competitor::Codex,
85        Competitor::OpenCode,
86    ];
87
88    competitors
89        .iter()
90        .map(|c| {
91            let estimated = c.estimate_cost(tokens);
92            let savings = sparrow_cost - estimated; // negative = Sparrow cheaper
93            let savings_percent = if estimated > 0.0 {
94                ((estimated - sparrow_cost) / estimated * 100.0).max(0.0)
95            } else {
96                0.0
97            };
98            CostLine {
99                competitor: *c,
100                estimated_cost: estimated,
101                savings,
102                savings_percent,
103            }
104        })
105        .collect()
106}
107
108/// Format a dollar amount without lying at small scales: sub-cent amounts
109/// keep 4 decimals instead of rounding to a silly "$0.00".
110fn fmt_usd(amount: f64) -> String {
111    if amount.abs() >= 0.01 {
112        format!("${:.2}", amount)
113    } else {
114        format!("${:.4}", amount)
115    }
116}
117
118/// Format a cost comparison report as a string suitable for terminal output.
119///
120/// Example output:
121///   ── Cost ──────────────────────────────────────────
122///   Sparrow .............. $0.0412 (2,412 in / 847 out)
123///   Claude Code .......... $0.6130 (save 93% — $0.57 cheaper)
124///   Codex CLI ............ $0.4150 (save 90% — $0.37 cheaper)
125///   OpenCode ............. $0.6130 (save 93% — $0.57 cheaper)
126pub fn format_comparison(sparrow_cost: f64, tokens: &TokenUsage) -> String {
127    let lines = compare(sparrow_cost, tokens);
128    let mut out = String::new();
129
130    out.push_str("── Cost ──────────────────────────────────────────\n");
131    out.push_str(&format!(
132        "Sparrow .............. ${:.4} ({} in / {} out)\n",
133        sparrow_cost, tokens.input, tokens.output
134    ));
135
136    for line in &lines {
137        if line.savings_percent > 0.0 {
138            out.push_str(&format!(
139                "{} .......... ~${:.4} est. (save {:.0}% — {} cheaper)\n",
140                line.competitor.display_name(),
141                line.estimated_cost,
142                line.savings_percent,
143                fmt_usd(-line.savings)
144            ));
145        } else {
146            // Sparrow was not cheaper on this run (e.g. the task was routed to
147            // the same premium model). Say so honestly instead of "same cost".
148            out.push_str(&format!(
149                "{} .......... ~${:.4} est. (comparable on this run)\n",
150                line.competitor.display_name(),
151                line.estimated_cost
152            ));
153        }
154    }
155
156    // Killer line — the marketing hook. Estimates only: same token volume
157    // priced at the competitor's list price, so keep the tilde.
158    if let Some(best) = lines
159        .iter()
160        .max_by(|a, b| a.savings_percent.partial_cmp(&b.savings_percent).unwrap())
161    {
162        if best.savings_percent > 50.0 {
163            out.push_str(&format!(
164                "\n💡 Same tokens on {} would have cost ~{} more (est. at list price).",
165                best.competitor.display_name(),
166                fmt_usd(-best.savings)
167            ));
168        }
169    }
170
171    out
172}
173
174/// One-liner for compact display (chat, subagent output).
175pub fn format_comparison_oneliner(sparrow_cost: f64, tokens: &TokenUsage) -> String {
176    let lines = compare(sparrow_cost, tokens);
177    let best = lines
178        .iter()
179        .max_by(|a, b| a.savings_percent.partial_cmp(&b.savings_percent).unwrap());
180
181    match best {
182        Some(line) if line.savings_percent > 30.0 => format!(
183            "(vs {}: ~{} est. — save {:.0}%)",
184            line.competitor.display_name(),
185            fmt_usd(line.estimated_cost),
186            line.savings_percent
187        ),
188        _ => String::new(),
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn test_competitor_pricing_known() {
198        let cc = Competitor::ClaudeCode.price_per_mtok();
199        assert_eq!(cc, (3.0, 15.0));
200
201        let cx = Competitor::Codex.price_per_mtok();
202        assert_eq!(cx, (2.5, 10.0));
203    }
204
205    #[test]
206    fn test_estimate_cost_zero_tokens() {
207        let tokens = TokenUsage {
208            input: 0,
209            output: 0,
210        };
211        assert_eq!(Competitor::ClaudeCode.estimate_cost(&tokens), 0.0);
212    }
213
214    #[test]
215    fn test_estimate_cost_typical_run() {
216        // Typical small coding task: 5k input, 1k output
217        let tokens = TokenUsage {
218            input: 5_000,
219            output: 1_000,
220        };
221        let cost = Competitor::ClaudeCode.estimate_cost(&tokens);
222        // input: 5000 * 3.0 / 1_000_000 = 0.015
223        // output: 1000 * 15.0 / 1_000_000 = 0.015
224        // total = 0.030
225        assert!((cost - 0.030).abs() < 0.001);
226    }
227
228    #[test]
229    fn test_compare_returns_three_competitors() {
230        let tokens = TokenUsage {
231            input: 10_000,
232            output: 2_000,
233        };
234        let lines = compare(0.05, &tokens);
235        assert_eq!(lines.len(), 3);
236    }
237
238    #[test]
239    fn test_format_comparison_shows_savings() {
240        let tokens = TokenUsage {
241            input: 10_000,
242            output: 2_000,
243        };
244        let report = format_comparison(0.02, &tokens);
245        assert!(report.contains("Sparrow"));
246        assert!(report.contains("Claude Code"));
247        assert!(report.contains("save"));
248        assert!(report.contains("cheaper"));
249    }
250
251    #[test]
252    fn test_oneliner_includes_savings() {
253        let tokens = TokenUsage {
254            input: 100_000,
255            output: 20_000,
256        };
257        let oneliner = format_comparison_oneliner(0.10, &tokens);
258        assert!(oneliner.contains("save"));
259        assert!(oneliner.contains("%"));
260    }
261}