Skip to main content

opi_coding_agent/
pricing.rs

1//! Built-in model pricing lookup table.
2//!
3//! Used by `SessionCoordinator::cost_summary` to convert accumulated token
4//! usage into a USD cost breakdown. Prices are per-million-tokens (USD) and
5//! reflect public list prices at time of writing. The lookup is best-effort:
6//! unknown models return `None`, in which case the runtime simply shows
7//! token totals without a cost figure.
8//!
9//! The table is intentionally small — covering only the default models for
10//! each supported provider. Users wanting full coverage can supply their own
11//! pricing externally.
12
13use opi_ai::stream::Pricing;
14
15/// Look up pricing for a model spec of the form `provider:model`.
16///
17/// Returns `None` for unknown specs or models without published pricing.
18pub fn lookup_pricing(model_spec: &str) -> Option<Pricing> {
19    let (provider, model) = model_spec.split_once(':')?;
20    match provider {
21        "anthropic" => anthropic_pricing(model),
22        "openai" | "openai-responses" => openai_pricing(model),
23        "openrouter" => openrouter_pricing(model),
24        "gemini" => gemini_pricing(model),
25        "mistral" => mistral_pricing(model),
26        _ => None,
27    }
28}
29
30fn anthropic_pricing(model: &str) -> Option<Pricing> {
31    if model.contains("opus") {
32        Some(Pricing {
33            input_cost_per_mtok: 15.0,
34            output_cost_per_mtok: 75.0,
35            cache_read_cost_per_mtok: 1.5,
36            cache_write_cost_per_mtok: 18.75,
37        })
38    } else if model.contains("sonnet") {
39        Some(Pricing {
40            input_cost_per_mtok: 3.0,
41            output_cost_per_mtok: 15.0,
42            cache_read_cost_per_mtok: 0.3,
43            cache_write_cost_per_mtok: 3.75,
44        })
45    } else if model.contains("haiku") {
46        Some(Pricing {
47            input_cost_per_mtok: 0.8,
48            output_cost_per_mtok: 4.0,
49            cache_read_cost_per_mtok: 0.08,
50            cache_write_cost_per_mtok: 1.0,
51        })
52    } else {
53        None
54    }
55}
56
57fn openai_pricing(model: &str) -> Option<Pricing> {
58    if model.starts_with("gpt-4o-mini") {
59        Some(Pricing {
60            input_cost_per_mtok: 0.15,
61            output_cost_per_mtok: 0.60,
62            cache_read_cost_per_mtok: 0.075,
63            cache_write_cost_per_mtok: 0.0,
64        })
65    } else if model.starts_with("gpt-4o") {
66        Some(Pricing {
67            input_cost_per_mtok: 2.50,
68            output_cost_per_mtok: 10.0,
69            cache_read_cost_per_mtok: 1.25,
70            cache_write_cost_per_mtok: 0.0,
71        })
72    } else if model.starts_with("gpt-4-turbo") {
73        Some(Pricing {
74            input_cost_per_mtok: 10.0,
75            output_cost_per_mtok: 30.0,
76            cache_read_cost_per_mtok: 0.0,
77            cache_write_cost_per_mtok: 0.0,
78        })
79    } else if model.starts_with("gpt-3.5") {
80        Some(Pricing {
81            input_cost_per_mtok: 0.50,
82            output_cost_per_mtok: 1.50,
83            cache_read_cost_per_mtok: 0.0,
84            cache_write_cost_per_mtok: 0.0,
85        })
86    } else {
87        None
88    }
89}
90
91fn openrouter_pricing(model: &str) -> Option<Pricing> {
92    // OpenRouter forwards to many backends; try common prefixes.
93    if let Some(stripped) = model.strip_prefix("anthropic/") {
94        return anthropic_pricing(stripped);
95    }
96    if let Some(stripped) = model.strip_prefix("openai/") {
97        return openai_pricing(stripped);
98    }
99    if let Some(stripped) = model.strip_prefix("google/") {
100        return gemini_pricing(stripped);
101    }
102    if let Some(stripped) = model.strip_prefix("mistralai/") {
103        return mistral_pricing(stripped);
104    }
105    None
106}
107
108fn gemini_pricing(model: &str) -> Option<Pricing> {
109    if model.contains("flash") {
110        Some(Pricing {
111            input_cost_per_mtok: 0.075,
112            output_cost_per_mtok: 0.30,
113            cache_read_cost_per_mtok: 0.01875,
114            cache_write_cost_per_mtok: 0.0,
115        })
116    } else if model.contains("pro") {
117        Some(Pricing {
118            input_cost_per_mtok: 1.25,
119            output_cost_per_mtok: 5.0,
120            cache_read_cost_per_mtok: 0.3125,
121            cache_write_cost_per_mtok: 0.0,
122        })
123    } else {
124        None
125    }
126}
127
128fn mistral_pricing(model: &str) -> Option<Pricing> {
129    if model.contains("large") {
130        Some(Pricing {
131            input_cost_per_mtok: 2.0,
132            output_cost_per_mtok: 6.0,
133            cache_read_cost_per_mtok: 0.0,
134            cache_write_cost_per_mtok: 0.0,
135        })
136    } else if model.contains("medium") {
137        Some(Pricing {
138            input_cost_per_mtok: 2.7,
139            output_cost_per_mtok: 8.1,
140            cache_read_cost_per_mtok: 0.0,
141            cache_write_cost_per_mtok: 0.0,
142        })
143    } else if model.contains("small") {
144        Some(Pricing {
145            input_cost_per_mtok: 0.20,
146            output_cost_per_mtok: 0.60,
147            cache_read_cost_per_mtok: 0.0,
148            cache_write_cost_per_mtok: 0.0,
149        })
150    } else {
151        None
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn anthropic_sonnet_resolves() {
161        let p = lookup_pricing("anthropic:claude-sonnet-4").unwrap();
162        assert_eq!(p.input_cost_per_mtok, 3.0);
163        assert_eq!(p.output_cost_per_mtok, 15.0);
164    }
165
166    #[test]
167    fn openai_gpt4o_mini_resolves() {
168        let p = lookup_pricing("openai:gpt-4o-mini").unwrap();
169        assert_eq!(p.input_cost_per_mtok, 0.15);
170    }
171
172    #[test]
173    fn gemini_flash_resolves() {
174        let p = lookup_pricing("gemini:gemini-1.5-flash").unwrap();
175        assert_eq!(p.input_cost_per_mtok, 0.075);
176    }
177
178    #[test]
179    fn mistral_large_resolves() {
180        let p = lookup_pricing("mistral:mistral-large-latest").unwrap();
181        assert_eq!(p.input_cost_per_mtok, 2.0);
182    }
183
184    #[test]
185    fn openrouter_forwards_to_underlying() {
186        let p = lookup_pricing("openrouter:anthropic/claude-sonnet-4").unwrap();
187        assert_eq!(p.input_cost_per_mtok, 3.0);
188    }
189
190    #[test]
191    fn unknown_model_returns_none() {
192        assert!(lookup_pricing("anthropic:not-a-real-model").is_none());
193        assert!(lookup_pricing("malformed").is_none());
194        assert!(lookup_pricing("future-provider:foo").is_none());
195    }
196}