llm_browser_testkit/
pricing.rs1use std::collections::HashMap;
12
13use crate::scenario::EndpointConfig;
14
15#[derive(Debug, Clone, Copy, PartialEq)]
17pub struct ModelPricing {
18 pub input_per_1m: f64,
20 pub output_per_1m: f64,
22 pub cached_input_per_1m: Option<f64>,
24 pub cache_write_per_1m: Option<f64>,
26}
27
28#[must_use]
35pub fn parse_openrouter_models(json: &serde_json::Value, model: &str) -> Option<ModelPricing> {
36 let wanted = model.to_ascii_lowercase();
37 let leaf = wanted.rsplit('/').next().unwrap_or(wanted.as_str());
38 let data = json["data"].as_array()?;
39 let entry = data.iter().find(|m| {
40 let id = m["id"].as_str().unwrap_or_default().to_ascii_lowercase();
41 id == wanted || id.rsplit('/').next().unwrap_or_default() == leaf
42 })?;
43 let pricing = &entry["pricing"];
44 Some(ModelPricing {
45 input_per_1m: per_million(pricing, "prompt")?,
46 output_per_1m: per_million(pricing, "completion")?,
47 cached_input_per_1m: per_million(pricing, "input_cache_read"),
48 cache_write_per_1m: per_million(pricing, "input_cache_write"),
49 })
50}
51
52fn per_million(pricing: &serde_json::Value, key: &str) -> Option<f64> {
56 let value = match &pricing[key] {
57 serde_json::Value::String(s) => s.parse::<f64>().ok()?,
58 serde_json::Value::Number(n) => n.as_f64()?,
59 _ => return None,
60 };
61 (value >= 0.0).then_some(value * 1_000_000.0)
62}
63
64pub async fn fetch_openrouter_pricing(
71 client: &reqwest::Client,
72 model: &str,
73) -> Result<ModelPricing, String> {
74 let resp = client
75 .get("https://openrouter.ai/api/v1/models")
76 .send()
77 .await
78 .map_err(|e| format!("OpenRouter models request failed: {e}"))?;
79 if !resp.status().is_success() {
80 return Err(format!(
81 "OpenRouter models request returned HTTP {}",
82 resp.status()
83 ));
84 }
85 let json: serde_json::Value = resp
86 .json()
87 .await
88 .map_err(|e| format!("OpenRouter models response was not JSON: {e}"))?;
89 parse_openrouter_models(&json, model)
90 .ok_or_else(|| format!("model `{model}` not found in OpenRouter pricing"))
91}
92
93#[must_use]
95fn wants_openrouter_pricing(ec: &EndpointConfig) -> bool {
96 match ec.pricing_source.as_deref() {
97 Some("openrouter") => true,
98 Some("auto") => ec
99 .url
100 .as_deref()
101 .is_some_and(|url| url.to_ascii_lowercase().contains("openrouter.ai")),
102 _ => false,
103 }
104}
105
106pub fn apply_pricing(ec: &mut EndpointConfig, pricing: &ModelPricing) {
109 let p = ec.pricing.get_or_insert_with(Default::default);
110 if p.input_per_1m_tokens == 0.0 {
111 p.input_per_1m_tokens = pricing.input_per_1m;
112 }
113 if p.output_per_1m_tokens == 0.0 {
114 p.output_per_1m_tokens = pricing.output_per_1m;
115 }
116 if p.cached_input_per_1m_tokens.is_none() {
117 p.cached_input_per_1m_tokens = pricing.cached_input_per_1m;
118 }
119 if p.cache_write_per_1m_tokens.is_none() {
120 p.cache_write_per_1m_tokens = pricing.cache_write_per_1m;
121 }
122}
123
124#[allow(clippy::implicit_hasher)]
136pub async fn apply_auto_pricing(
137 endpoints: &mut HashMap<String, EndpointConfig>,
138 client: &reqwest::Client,
139) -> Result<usize, String> {
140 let mut priced = 0;
141 let mut first_error: Option<String> = None;
142 for ec in endpoints.values_mut() {
143 if !wants_openrouter_pricing(ec) {
144 continue;
145 }
146 let Some(model) = ec.model.clone() else {
147 continue;
148 };
149 match fetch_openrouter_pricing(client, &model).await {
150 Ok(pricing) => {
151 apply_pricing(ec, &pricing);
152 priced += 1;
153 }
154 Err(e) => {
155 first_error.get_or_insert(e);
156 }
157 };
158 }
159 match first_error {
160 Some(e) if priced == 0 => Err(e),
161 _ => Ok(priced),
162 }
163}
164
165#[cfg(test)]
166mod tests {
167 use super::{apply_pricing, parse_openrouter_models};
168 use crate::scenario::{EndpointConfig, PricingConfig};
169
170 fn catalog() -> serde_json::Value {
171 serde_json::json!({
172 "data": [
173 {"id": "anthropic/claude-3.5-sonnet", "pricing": {
174 "prompt": "0.000003", "completion": "0.000015",
175 "input_cache_read": "0.0000003", "input_cache_write": "0.00000375"
176 }},
177 {"id": "openrouter/auto", "pricing": {"prompt": "-1", "completion": "-1"}}
178 ]
179 })
180 }
181
182 #[test]
183 fn parses_exact_match_with_cache_prices() {
184 let p = parse_openrouter_models(&catalog(), "anthropic/claude-3.5-sonnet").unwrap();
185 assert!((p.input_per_1m - 3.0).abs() < 1e-9);
186 assert!((p.output_per_1m - 15.0).abs() < 1e-9);
187 assert!((p.cached_input_per_1m.unwrap() - 0.3).abs() < 1e-9);
188 assert!((p.cache_write_per_1m.unwrap() - 3.75).abs() < 1e-9);
189 }
190
191 #[test]
192 fn parses_by_leaf_when_namespaced_omitted() {
193 let p = parse_openrouter_models(&catalog(), "claude-3.5-sonnet").unwrap();
194 assert!((p.input_per_1m - 3.0).abs() < 1e-9);
195 }
196
197 #[test]
198 fn dynamic_negative_prices_are_not_usable() {
199 assert!(parse_openrouter_models(&catalog(), "openrouter/auto").is_none());
200 }
201
202 #[test]
203 fn unknown_model_is_none() {
204 assert!(parse_openrouter_models(&catalog(), "does/not-exist").is_none());
205 }
206
207 #[test]
208 fn apply_pricing_keeps_explicit_values() {
209 let mut ec = EndpointConfig {
210 model: Some("anthropic/claude-3.5-sonnet".to_owned()),
211 pricing: Some(PricingConfig {
212 input_per_1m_tokens: 9.0,
213 output_per_1m_tokens: 0.0,
214 ..PricingConfig::default()
215 }),
216 ..EndpointConfig::default()
217 };
218 let p = parse_openrouter_models(&catalog(), "anthropic/claude-3.5-sonnet").unwrap();
219 apply_pricing(&mut ec, &p);
220 let pricing = ec.pricing.unwrap();
221 assert!(
222 (pricing.input_per_1m_tokens - 9.0).abs() < 1e-9,
223 "explicit wins"
224 );
225 assert!(
226 (pricing.output_per_1m_tokens - 15.0).abs() < 1e-9,
227 "fetched fills gap"
228 );
229 assert!(pricing.cached_input_per_1m_tokens.is_some());
230 assert!(pricing.cache_write_per_1m_tokens.is_some());
231 }
232}