forge_agent/env/token_usage/
pricing.rs1use std::collections::HashMap;
8use std::time::Duration;
9
10use forge_primitives::token_usage::ModelPricing;
11
12const LITELLM_URL: &str =
15 "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
16
17const FETCH_TIMEOUT: Duration = Duration::from_secs(20);
19
20pub struct PricingTable {
22 map: HashMap<String, ModelPricing>,
23}
24
25impl PricingTable {
26 pub fn from_litellm_json(json: &str) -> Self {
30 let value: serde_json::Value = match serde_json::from_str(json) {
31 Ok(value) => value,
32 Err(error) => {
33 tracing::warn!(
34 target: "forge_agent::env::token_usage::pricing",
35 %error,
36 "litellm pricing json did not parse; using an empty table",
37 );
38 return Self { map: HashMap::new() };
39 }
40 };
41 let Some(entries) = value.as_object() else {
42 return Self { map: HashMap::new() };
43 };
44 let mut map = HashMap::with_capacity(entries.len());
45 for (model_id, entry) in entries {
46 if model_id == "sample_spec" {
49 continue;
50 }
51 let field = |name: &str| entry.get(name).and_then(serde_json::Value::as_f64);
52 let (Some(input), Some(output)) =
53 (field("input_cost_per_token"), field("output_cost_per_token"))
54 else {
55 continue;
56 };
57 map.insert(
58 model_id.clone(),
59 ModelPricing {
60 input,
61 output,
62 cache_write_5m: field("cache_creation_input_token_cost").unwrap_or(0.0),
63 cache_write_1h: field("cache_creation_input_token_cost_above_1hr")
64 .unwrap_or(0.0),
65 cache_read: field("cache_read_input_token_cost").unwrap_or(0.0),
66 },
67 );
68 }
69 Self { map }
70 }
71
72 pub fn price(&self, model_id: &str) -> Option<&ModelPricing> {
75 self.map.get(model_id)
76 }
77
78 pub fn is_empty(&self) -> bool {
81 self.map.is_empty()
82 }
83}
84
85pub async fn fetch_litellm() -> Option<String> {
90 if let Some(body) = attempt_fetch(false).await {
91 return Some(body);
92 }
93 attempt_fetch(true).await
94}
95
96async fn attempt_fetch(direct: bool) -> Option<String> {
97 let mut builder =
98 crate::http_trust::with_extra_roots(reqwest::Client::builder().timeout(FETCH_TIMEOUT));
99 if direct {
100 builder = builder.no_proxy();
101 }
102 let client = match builder.build() {
103 Ok(client) => client,
104 Err(error) => {
105 tracing::warn!(
106 target: "forge_agent::env::token_usage::pricing",
107 %error,
108 direct,
109 "pricing http client build failed",
110 );
111 return None;
112 }
113 };
114 let response = match client.get(LITELLM_URL).send().await {
115 Ok(response) => response,
116 Err(error) => {
117 tracing::warn!(
118 target: "forge_agent::env::token_usage::pricing",
119 %error,
120 direct,
121 "litellm pricing fetch failed",
122 );
123 return None;
124 }
125 };
126 if !response.status().is_success() {
127 tracing::warn!(
128 target: "forge_agent::env::token_usage::pricing",
129 status = %response.status(),
130 direct,
131 "litellm pricing fetch returned non-success",
132 );
133 return None;
134 }
135 match response.text().await {
136 Ok(body) => Some(body),
137 Err(error) => {
138 tracing::warn!(
139 target: "forge_agent::env::token_usage::pricing",
140 %error,
141 direct,
142 "reading the pricing response body failed",
143 );
144 None
145 }
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 fn exact(actual: f64, expected: f64) {
156 assert_eq!(actual.to_bits(), expected.to_bits(), "expected {expected}, got {actual}");
157 }
158
159 const SAMPLE: &str = r#"{
160 "sample_spec": {"input_cost_per_token": 1.0, "output_cost_per_token": 1.0},
161 "claude-opus-4-8": {
162 "input_cost_per_token": 5e-06,
163 "output_cost_per_token": 2.5e-05,
164 "cache_creation_input_token_cost": 6.25e-06,
165 "cache_creation_input_token_cost_above_1hr": 1e-05,
166 "cache_read_input_token_cost": 5e-07
167 },
168 "gpt-5-codex": {
169 "input_cost_per_token": 1.25e-06,
170 "output_cost_per_token": 1e-05,
171 "cache_read_input_token_cost": 1.25e-07
172 },
173 "text-embedding-3-small": {"max_tokens": 8191}
174 }"#;
175
176 #[test]
177 fn maps_litellm_fields_onto_model_pricing() {
178 let table = PricingTable::from_litellm_json(SAMPLE);
179 let opus = table.price("claude-opus-4-8").expect("opus priced");
180 exact(opus.input, 5e-6);
181 exact(opus.output, 2.5e-5);
182 exact(opus.cache_write_5m, 6.25e-6);
183 exact(opus.cache_write_1h, 1e-5);
184 exact(opus.cache_read, 5e-7);
185 }
186
187 #[test]
188 fn gpt_entry_without_cache_creation_zeroes_cache_write() {
189 let table = PricingTable::from_litellm_json(SAMPLE);
190 let codex = table.price("gpt-5-codex").expect("codex priced");
191 exact(codex.input, 1.25e-6);
192 exact(codex.cache_read, 1.25e-7);
193 exact(codex.cache_write_5m, 0.0);
194 exact(codex.cache_write_1h, 0.0);
195 }
196
197 #[test]
198 fn skips_doc_entry_and_entries_without_core_costs() {
199 let table = PricingTable::from_litellm_json(SAMPLE);
200 assert!(table.price("sample_spec").is_none(), "the doc entry is skipped");
201 assert!(table.price("text-embedding-3-small").is_none(), "no core cost -> skipped");
202 assert!(table.price("no-such-model").is_none());
203 assert!(table.price("<synthetic>").is_none());
204 }
205
206 #[test]
207 fn malformed_json_is_an_empty_table() {
208 assert!(PricingTable::from_litellm_json("not json").is_empty());
209 assert!(PricingTable::from_litellm_json("{}").is_empty());
210 }
211}