Skip to content

Commit 5fc856f

Browse files
committed
Merge remote-tracking branch 'origin/main' into develop
2 parents 43f7ba7 + 98c89df commit 5fc856f

3 files changed

Lines changed: 155 additions & 24 deletions

File tree

crates/oai-runner/src/api/types.rs

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ pub struct UsageInfo {
8989
pub completion_tokens: u64,
9090
#[serde(default)]
9191
pub total_tokens: u64,
92+
#[serde(default)]
93+
pub cache_read_input_tokens: u64,
9294
}
9395

9496
impl UsageInfo {
@@ -277,13 +279,15 @@ mod tests {
277279

278280
#[test]
279281
fn usage_info_effective_total_prefers_provider_value() {
280-
let usage = UsageInfo { prompt_tokens: 100, completion_tokens: 50, total_tokens: 200 };
282+
let usage =
283+
UsageInfo { prompt_tokens: 100, completion_tokens: 50, total_tokens: 200, cache_read_input_tokens: 0 };
281284
assert_eq!(usage.effective_total(), 200);
282285
}
283286

284287
#[test]
285288
fn usage_info_effective_total_falls_back_to_sum() {
286-
let usage = UsageInfo { prompt_tokens: 100, completion_tokens: 50, total_tokens: 0 };
289+
let usage =
290+
UsageInfo { prompt_tokens: 100, completion_tokens: 50, total_tokens: 0, cache_read_input_tokens: 0 };
287291
assert_eq!(usage.effective_total(), 150);
288292
}
289293

@@ -299,4 +303,21 @@ mod tests {
299303
assert_eq!(usage.completion_tokens, 5);
300304
assert_eq!(usage.total_tokens, 15);
301305
}
306+
307+
#[test]
308+
fn usage_info_deserializes_with_cache_read_tokens() {
309+
let raw =
310+
r#"{"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150, "cache_read_input_tokens": 25}"#;
311+
let usage: UsageInfo = serde_json::from_str(raw).unwrap();
312+
assert_eq!(usage.prompt_tokens, 100);
313+
assert_eq!(usage.completion_tokens, 50);
314+
assert_eq!(usage.cache_read_input_tokens, 25);
315+
}
316+
317+
#[test]
318+
fn usage_info_defaults_cache_read_tokens_to_zero() {
319+
let raw = r#"{"prompt_tokens": 100}"#;
320+
let usage: UsageInfo = serde_json::from_str(raw).unwrap();
321+
assert_eq!(usage.cache_read_input_tokens, 0);
322+
}
302323
}

crates/oai-runner/src/pricing.rs

Lines changed: 73 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,36 @@ pub struct ModelPricing {
1515
pub input_per_million: f64,
1616
/// USD per 1M output (completion) tokens.
1717
pub output_per_million: f64,
18+
/// USD per 1M cached read input tokens (typically 10% of input price).
19+
/// If None, no cache-read pricing is applied (cached tokens are free).
20+
pub cache_read_input_per_million: Option<f64>,
1821
}
1922

2023
impl ModelPricing {
2124
pub const fn new(input_per_million: f64, output_per_million: f64) -> Self {
22-
Self { input_per_million, output_per_million }
25+
Self { input_per_million, output_per_million, cache_read_input_per_million: None }
26+
}
27+
28+
pub const fn with_cache(
29+
input_per_million: f64,
30+
output_per_million: f64,
31+
cache_read_input_per_million: f64,
32+
) -> Self {
33+
Self { input_per_million, output_per_million, cache_read_input_per_million: Some(cache_read_input_per_million) }
2334
}
2435

2536
/// Compute the USD cost for the given token counts.
37+
/// Cached read tokens are charged at cache_read_input_per_million rate if available.
2638
#[inline]
27-
pub fn cost(&self, prompt_tokens: u64, completion_tokens: u64) -> f64 {
39+
pub fn cost(&self, prompt_tokens: u64, completion_tokens: u64, cache_read_tokens: u64) -> f64 {
2840
let input_cost = (prompt_tokens as f64 / 1_000_000.0) * self.input_per_million;
2941
let output_cost = (completion_tokens as f64 / 1_000_000.0) * self.output_per_million;
30-
input_cost + output_cost
42+
let cache_cost = if let Some(cache_price) = self.cache_read_input_per_million {
43+
(cache_read_tokens as f64 / 1_000_000.0) * cache_price
44+
} else {
45+
0.0
46+
};
47+
input_cost + output_cost + cache_cost
3148
}
3249
}
3350

@@ -61,15 +78,25 @@ static PRICING_TABLE: LazyLock<Vec<PricingEntry>> = LazyLock::new(|| {
6178
PricingEntry { prefixes: &["gpt-4-turbo"], pricing: ModelPricing::new(10.00, 30.00) },
6279
PricingEntry { prefixes: &["gpt-4"], pricing: ModelPricing::new(30.00, 60.00) },
6380
// ── Anthropic ─────────────────────────────────────────────
64-
PricingEntry { prefixes: &["claude-opus-4"], pricing: ModelPricing::new(15.00, 75.00) },
65-
PricingEntry { prefixes: &["claude-sonnet-4"], pricing: ModelPricing::new(3.00, 15.00) },
66-
PricingEntry { prefixes: &["claude-3.7-sonnet", "claude-3-7-sonnet"], pricing: ModelPricing::new(3.00, 15.00) },
67-
PricingEntry { prefixes: &["claude-3.5-haiku", "claude-3-5-haiku"], pricing: ModelPricing::new(0.80, 4.00) },
68-
PricingEntry { prefixes: &["claude-3.5-sonnet", "claude-3-5-sonnet"], pricing: ModelPricing::new(3.00, 15.00) },
69-
PricingEntry { prefixes: &["claude-3-opus"], pricing: ModelPricing::new(15.00, 75.00) },
70-
PricingEntry { prefixes: &["claude-3-sonnet"], pricing: ModelPricing::new(3.00, 15.00) },
71-
PricingEntry { prefixes: &["claude-3-haiku"], pricing: ModelPricing::new(0.25, 1.25) },
72-
PricingEntry { prefixes: &["claude"], pricing: ModelPricing::new(3.00, 15.00) },
81+
// Anthropic models support prompt caching at 90% discount (10% of input price).
82+
PricingEntry { prefixes: &["claude-opus-4"], pricing: ModelPricing::with_cache(15.00, 75.00, 1.50) },
83+
PricingEntry { prefixes: &["claude-sonnet-4"], pricing: ModelPricing::with_cache(3.00, 15.00, 0.30) },
84+
PricingEntry {
85+
prefixes: &["claude-3.7-sonnet", "claude-3-7-sonnet"],
86+
pricing: ModelPricing::with_cache(3.00, 15.00, 0.30),
87+
},
88+
PricingEntry {
89+
prefixes: &["claude-3.5-haiku", "claude-3-5-haiku"],
90+
pricing: ModelPricing::with_cache(0.80, 4.00, 0.08),
91+
},
92+
PricingEntry {
93+
prefixes: &["claude-3.5-sonnet", "claude-3-5-sonnet"],
94+
pricing: ModelPricing::with_cache(3.00, 15.00, 0.30),
95+
},
96+
PricingEntry { prefixes: &["claude-3-opus"], pricing: ModelPricing::with_cache(15.00, 75.00, 1.50) },
97+
PricingEntry { prefixes: &["claude-3-sonnet"], pricing: ModelPricing::with_cache(3.00, 15.00, 0.30) },
98+
PricingEntry { prefixes: &["claude-3-haiku"], pricing: ModelPricing::with_cache(0.25, 1.25, 0.025) },
99+
PricingEntry { prefixes: &["claude"], pricing: ModelPricing::with_cache(3.00, 15.00, 0.30) },
73100
// ── Google / Gemini ───────────────────────────────────────
74101
PricingEntry { prefixes: &["gemini-2.5-pro"], pricing: ModelPricing::new(1.25, 10.00) },
75102
PricingEntry { prefixes: &["gemini-2.5-flash"], pricing: ModelPricing::new(0.15, 0.60) },
@@ -135,25 +162,44 @@ mod tests {
135162
fn model_pricing_cost_calculation() {
136163
let pricing = ModelPricing::new(2.50, 10.00);
137164
// 1M input tokens → $2.50
138-
assert!((pricing.cost(1_000_000, 0) - 2.50).abs() < 1e-9);
165+
assert!((pricing.cost(1_000_000, 0, 0) - 2.50).abs() < 1e-9);
139166
// 1M output tokens → $10.00
140-
assert!((pricing.cost(0, 1_000_000) - 10.00).abs() < 1e-9);
141-
// 100k input + 50k output
142-
let cost = pricing.cost(100_000, 50_000);
167+
assert!((pricing.cost(0, 1_000_000, 0) - 10.00).abs() < 1e-9);
168+
// 100k input + 50k output + no cache
169+
let cost = pricing.cost(100_000, 50_000, 0);
143170
assert!((cost - 0.75).abs() < 1e-9);
144171
}
145172

173+
#[test]
174+
fn model_pricing_with_cache_read_tokens() {
175+
let pricing = ModelPricing::with_cache(3.00, 15.00, 0.30);
176+
// 100k input + 50k output + 25k cache read
177+
// Cost: (100k/1M)*3.0 + (50k/1M)*15.0 + (25k/1M)*0.30 = 0.3 + 0.75 + 0.0075 = 1.0575
178+
let cost = pricing.cost(100_000, 50_000, 25_000);
179+
assert!((cost - 1.0575).abs() < 1e-9);
180+
}
181+
182+
#[test]
183+
fn model_pricing_without_cache_pricing_ignores_cache_tokens() {
184+
let pricing = ModelPricing::new(2.50, 10.00);
185+
// Even with cache tokens, cost should not include them
186+
let cost = pricing.cost(100_000, 50_000, 25_000);
187+
let cost_without_cache = pricing.cost(100_000, 50_000, 0);
188+
assert!((cost - cost_without_cache).abs() < 1e-9);
189+
}
190+
146191
#[test]
147192
fn model_pricing_zero_tokens() {
148193
let pricing = ModelPricing::new(2.50, 10.00);
149-
assert!((pricing.cost(0, 0) - 0.0).abs() < 1e-9);
194+
assert!((pricing.cost(0, 0, 0) - 0.0).abs() < 1e-9);
150195
}
151196

152197
#[test]
153198
fn lookup_openai_gpt4o() {
154199
let pricing = lookup("gpt-4o").unwrap();
155200
assert!((pricing.input_per_million - 2.50).abs() < 1e-9);
156201
assert!((pricing.output_per_million - 10.00).abs() < 1e-9);
202+
assert!(pricing.cache_read_input_per_million.is_none());
157203
}
158204

159205
#[test]
@@ -167,12 +213,14 @@ mod tests {
167213
let pricing = lookup("claude-sonnet-4-20250514").unwrap();
168214
assert!((pricing.input_per_million - 3.00).abs() < 1e-9);
169215
assert!((pricing.output_per_million - 15.00).abs() < 1e-9);
216+
assert_eq!(pricing.cache_read_input_per_million, Some(0.30));
170217
}
171218

172219
#[test]
173220
fn lookup_anthropic_claude_35_sonnet() {
174221
let pricing = lookup("claude-3-5-sonnet-20241022").unwrap();
175222
assert!((pricing.input_per_million - 3.00).abs() < 1e-9);
223+
assert_eq!(pricing.cache_read_input_per_million, Some(0.30));
176224
}
177225

178226
#[test]
@@ -194,6 +242,7 @@ mod tests {
194242
// Provider prefixes (openrouter/, etc.) should be stripped implicitly by contains()
195243
let pricing = lookup("openrouter/anthropic/claude-sonnet-4").unwrap();
196244
assert!((pricing.input_per_million - 3.00).abs() < 1e-9);
245+
assert_eq!(pricing.cache_read_input_per_million, Some(0.30));
197246
}
198247

199248
#[test]
@@ -230,4 +279,11 @@ mod tests {
230279
let pricing = lookup("qwen2.5-coder-32b-instruct").unwrap();
231280
assert!((pricing.input_per_million - 0.14).abs() < 1e-9);
232281
}
282+
283+
#[test]
284+
fn lookup_anthropic_claude_opus_4_has_cache_pricing() {
285+
let pricing = lookup("claude-opus-4").unwrap();
286+
assert!((pricing.input_per_million - 15.00).abs() < 1e-9);
287+
assert_eq!(pricing.cache_read_input_per_million, Some(1.50));
288+
}
233289
}

0 commit comments

Comments
 (0)