1use serde::Serialize;
15use serde_json::Number;
16
17pub type RateNanosPerMtok = i64;
22
23use crate::identity::{canonical_json, IdentityError};
24
25pub const NANOS_PER_USD: u64 = 1_000_000_000;
27
28pub const TOKENS_PER_MTOK: u64 = 1_000_000;
30
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33pub enum Tokens {
34 Exact(u64),
36 Bound(u64),
38}
39
40impl Tokens {
41 pub fn bound_of_text(text: &str) -> Self {
43 Self::Bound(text.len() as u64)
44 }
45
46 pub fn bound_of_json<T: Serialize + ?Sized>(value: &T) -> Result<Self, IdentityError> {
50 Ok(Self::Bound(canonical_json(value)?.len() as u64))
51 }
52
53 pub fn count(self) -> u64 {
54 match self {
55 Self::Exact(count) | Self::Bound(count) => count,
56 }
57 }
58
59 pub fn is_exact(self) -> bool {
60 matches!(self, Self::Exact(_))
61 }
62}
63
64#[derive(Clone, Copy, Debug, PartialEq, Eq)]
66#[non_exhaustive]
67pub struct ItemCostInput {
68 pub state: Tokens,
70 pub schema: Tokens,
73 pub images: u64,
75 pub image_tokens_each: u64,
78 pub overhead_tokens: u64,
81 pub max_passes: u64,
84 pub pass_tokens: Option<u64>,
88}
89
90impl ItemCostInput {
91 pub fn new(
92 state: Tokens,
93 schema: Tokens,
94 image_tokens_each: u64,
95 overhead_tokens: u64,
96 ) -> Self {
97 Self {
98 state,
99 schema,
100 images: 0,
101 image_tokens_each,
102 overhead_tokens,
103 max_passes: 1,
104 pass_tokens: None,
105 }
106 }
107
108 pub fn with_images(mut self, images: u64) -> Self {
109 self.images = images;
110 self
111 }
112
113 pub fn with_max_passes(mut self, max_passes: u64) -> Self {
116 self.max_passes = max_passes;
117 self
118 }
119
120 pub fn with_pass_tokens(mut self, pass_tokens: u64) -> Self {
121 self.pass_tokens = Some(pass_tokens);
122 self
123 }
124}
125
126#[derive(Clone, Copy, Debug, PartialEq, Eq)]
128#[non_exhaustive]
129pub struct Estimate {
130 pub max_passes: u64,
132 pub input_tokens: u64,
134 pub cost_nanos: u64,
136}
137
138impl Estimate {
139 pub fn new(max_passes: u64, input_tokens: u64, cost_nanos: u64) -> Self {
140 Self {
141 max_passes,
142 input_tokens,
143 cost_nanos,
144 }
145 }
146}
147
148#[derive(Clone, Copy, Debug, PartialEq, Eq)]
150pub enum EstimateError {
151 DoesNotFit { state_tokens: u64, pass_tokens: u64 },
154 NoPasses,
157 Overflow,
159 NegativeRate,
161}
162
163impl std::fmt::Display for EstimateError {
164 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165 match self {
166 Self::DoesNotFit {
167 state_tokens,
168 pass_tokens,
169 } => write!(
170 f,
171 "the state is {state_tokens} tokens; a pass holds {pass_tokens}"
172 ),
173 Self::NoPasses => {
174 f.write_str("max_passes is zero; a provider call makes at least one pass")
175 }
176 Self::Overflow => f.write_str("the cost estimate overflows"),
177 Self::NegativeRate => f.write_str("the catalog rate is negative"),
178 }
179 }
180}
181
182impl std::error::Error for EstimateError {}
183
184pub fn cost_nanos(tokens: u64, rate: RateNanosPerMtok) -> Result<u64, EstimateError> {
187 let rate = u64::try_from(rate).map_err(|_| EstimateError::NegativeRate)?;
188 let product = u128::from(tokens) * u128::from(rate);
189 let nanos = product.div_ceil(u128::from(TOKENS_PER_MTOK));
190 u64::try_from(nanos).map_err(|_| EstimateError::Overflow)
191}
192
193pub fn estimate_item(
209 input: &ItemCostInput,
210 input_rate: RateNanosPerMtok,
211) -> Result<Estimate, EstimateError> {
212 let state = input.state.count();
213 if let (Some(pass_tokens), true) = (input.pass_tokens, input.state.is_exact()) {
214 if state >= pass_tokens {
215 return Err(EstimateError::DoesNotFit {
216 state_tokens: state,
217 pass_tokens,
218 });
219 }
220 }
221 if input.max_passes == 0 {
222 return Err(EstimateError::NoPasses);
223 }
224 let image_tokens = input
225 .images
226 .checked_mul(input.image_tokens_each)
227 .ok_or(EstimateError::Overflow)?;
228 let per_pass = [image_tokens, input.overhead_tokens]
229 .into_iter()
230 .try_fold(state, u64::checked_add)
231 .ok_or(EstimateError::Overflow)?;
232 let input_tokens = per_pass
233 .checked_mul(input.max_passes)
234 .and_then(|passes| passes.checked_add(input.schema.count()))
235 .ok_or(EstimateError::Overflow)?;
236 Ok(Estimate {
237 max_passes: input.max_passes,
238 input_tokens,
239 cost_nanos: cost_nanos(input_tokens, input_rate)?,
240 })
241}
242
243pub fn crosses_ceiling(spent_nanos: u64, estimate: &Estimate, ceiling_nanos: u64) -> bool {
247 spent_nanos
248 .checked_add(estimate.cost_nanos)
249 .is_none_or(|total| total > ceiling_nanos)
250}
251
252#[derive(Clone, Copy, Debug, PartialEq, Eq)]
254pub enum Rounding {
255 Down,
257 Up,
259}
260
261pub fn usd_to_nanos(usd: &Number, rounding: Rounding) -> Option<u64> {
265 decimal_to_nanos(&usd.to_string(), rounding)
266}
267
268fn decimal_to_nanos(text: &str, rounding: Rounding) -> Option<u64> {
269 if let Some(magnitude) = text.strip_prefix('-') {
270 let zero = magnitude
272 .split(['e', 'E'])
273 .next()
274 .is_some_and(|mantissa| mantissa.bytes().all(|b| matches!(b, b'0' | b'.')));
275 return zero.then_some(0);
276 }
277 let (mantissa, exponent) = match text.find(['e', 'E']) {
278 Some(at) => (&text[..at], text[at + 1..].parse::<i64>().ok()?),
279 None => (text, 0),
280 };
281 let (int, frac) = mantissa.split_once('.').unwrap_or((mantissa, ""));
282 let digits: String = format!("{int}{frac}");
283 if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
284 return None;
285 }
286 let digits = digits.trim_start_matches('0');
287 if digits.is_empty() {
288 return Some(0);
289 }
290 let scale = exponent.checked_sub(frac.len() as i64)?.checked_add(9)?;
292 let whole: u128 = if digits.len() > 38 {
293 return None;
294 } else {
295 digits.parse().ok()?
296 };
297 let nanos = if scale >= 0 {
298 let factor = 10u128.checked_pow(u32::try_from(scale).ok()?)?;
299 whole.checked_mul(factor)?
300 } else {
301 let shift = u32::try_from(-scale).ok()?;
302 match 10u128.checked_pow(shift) {
303 Some(divisor) => {
304 let (quotient, remainder) = (whole / divisor, whole % divisor);
305 if rounding == Rounding::Up && remainder > 0 {
306 quotient + 1
307 } else {
308 quotient
309 }
310 }
311 None => u128::from(rounding == Rounding::Up),
313 }
314 };
315 u64::try_from(nanos).ok()
316}
317
318pub fn nanos_to_usd_text(nanos: u64) -> String {
321 let whole = nanos / NANOS_PER_USD;
322 let frac = nanos % NANOS_PER_USD;
323 if frac == 0 {
324 format!("{whole}")
325 } else {
326 let frac = format!("{frac:09}");
327 format!("{whole}.{}", frac.trim_end_matches('0'))
328 }
329}
330
331pub fn nanos_to_usd(nanos: u64) -> Number {
334 serde_json::from_str(&nanos_to_usd_text(nanos)).expect("a plain decimal is a JSON number")
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340
341 fn num(text: &str) -> Number {
342 serde_json::from_str(text).unwrap()
343 }
344
345 fn probe_05(state: Tokens, schema: Tokens) -> ItemCostInput {
349 ItemCostInput::new(state, schema, 0, 0)
350 .with_max_passes(40)
351 .with_pass_tokens(8192)
352 }
353
354 #[test]
355 fn exact_counts_estimate_what_clairic_billed_for_probe_05() {
356 let input = probe_05(Tokens::Exact(85), Tokens::Exact(26_488));
357 assert_eq!(
359 estimate_item(&input, 110_000_000),
360 Ok(Estimate::new(40, 29_888, 3_287_680))
361 );
362 }
363
364 #[test]
365 fn byte_bounds_never_underestimate_probe_05() {
366 let input = probe_05(Tokens::Bound(231), Tokens::Bound(51_089));
369 let estimate = estimate_item(&input, 110_000_000).unwrap();
370 assert_eq!(estimate.input_tokens, 51_089 + 40 * 231);
371 assert!(estimate.input_tokens >= 29_888);
372 }
373
374 #[test]
375 fn a_large_state_over_many_passes_bills_on_every_pass() {
376 let input = ItemCostInput::new(Tokens::Bound(4000), Tokens::Bound(400), 0, 0)
381 .with_max_passes(40)
382 .with_pass_tokens(8192);
383 let estimate = estimate_item(&input, 1_000).unwrap();
384 assert_eq!(estimate.input_tokens, 400 + 40 * 4000);
385 assert!(estimate.input_tokens > 4_400);
386 }
387
388 #[test]
389 fn images_and_overhead_bill_on_every_pass() {
390 let input = ItemCostInput::new(Tokens::Bound(1000), Tokens::Bound(9000), 300, 50)
391 .with_images(2)
392 .with_max_passes(2)
393 .with_pass_tokens(8192);
394 assert_eq!(
396 estimate_item(&input, 110_000_000),
397 Ok(Estimate::new(2, 12_300, 1_353_000))
398 );
399 let one_pass = ItemCostInput::new(Tokens::Bound(1000), Tokens::Bound(9000), 300, 50);
400 assert_eq!(
401 one_pass.max_passes, 1,
402 "one pass unless the module says more"
403 );
404 assert_eq!(estimate_item(&one_pass, 0).unwrap().input_tokens, 10_050);
405 }
406
407 #[test]
408 fn a_byte_bound_past_the_pass_is_estimated_and_an_exact_count_does_not_fit() {
409 let bound = ItemCostInput::new(Tokens::Bound(9000), Tokens::Bound(10), 0, 0)
410 .with_max_passes(3)
411 .with_pass_tokens(8192);
412 assert_eq!(
413 estimate_item(&bound, 1000),
414 Ok(Estimate::new(3, 27_010, 28))
415 );
416 let exact =
417 ItemCostInput::new(Tokens::Exact(9000), Tokens::Bound(10), 0, 0).with_pass_tokens(8192);
418 assert_eq!(
419 estimate_item(&exact, 1000),
420 Err(EstimateError::DoesNotFit {
421 state_tokens: 9000,
422 pass_tokens: 8192
423 })
424 );
425 let at_the_budget =
426 ItemCostInput::new(Tokens::Exact(8192), Tokens::Bound(10), 0, 0).with_pass_tokens(8192);
427 assert!(matches!(
428 estimate_item(&at_the_budget, 1000),
429 Err(EstimateError::DoesNotFit { .. })
430 ));
431 }
432
433 #[test]
434 fn zero_passes_are_refused() {
435 let input = ItemCostInput::new(Tokens::Exact(1), Tokens::Exact(1), 0, 0).with_max_passes(0);
436 assert_eq!(estimate_item(&input, 1), Err(EstimateError::NoPasses));
437 }
438
439 #[test]
440 fn overflow_refuses() {
441 let input = ItemCostInput::new(Tokens::Exact(u64::MAX), Tokens::Exact(1), 0, 0);
442 assert_eq!(estimate_item(&input, 1), Err(EstimateError::Overflow));
443 let passes =
444 ItemCostInput::new(Tokens::Exact(2), Tokens::Exact(1), 0, 0).with_max_passes(u64::MAX);
445 assert_eq!(estimate_item(&passes, 1), Err(EstimateError::Overflow));
446 assert_eq!(cost_nanos(1, -1), Err(EstimateError::NegativeRate));
447 assert!(crosses_ceiling(u64::MAX, &Estimate::new(1, 1, 1), u64::MAX));
448 }
449
450 #[test]
451 fn cost_rounds_up_to_a_nanodollar() {
452 assert_eq!(cost_nanos(1, 1), Ok(1));
453 assert_eq!(cost_nanos(1_000_000, 3), Ok(3));
454 assert_eq!(cost_nanos(0, 5), Ok(0));
455 }
456
457 #[test]
458 fn usd_scales_by_decimal_text() {
459 assert_eq!(
460 usd_to_nanos(&num("0.25"), Rounding::Down),
461 Some(250_000_000)
462 );
463 assert_eq!(usd_to_nanos(&num("1e-7"), Rounding::Down), Some(100));
464 assert_eq!(usd_to_nanos(&num("3"), Rounding::Down), Some(3_000_000_000));
465 assert_eq!(usd_to_nanos(&num("1e-10"), Rounding::Down), Some(0));
466 assert_eq!(usd_to_nanos(&num("1e-10"), Rounding::Up), Some(1));
467 assert_eq!(usd_to_nanos(&num("0"), Rounding::Up), Some(0));
468 assert_eq!(usd_to_nanos(&num("-1"), Rounding::Down), None);
469 assert_eq!(usd_to_nanos(&num("1e300"), Rounding::Down), None);
470 assert_eq!(nanos_to_usd(420_000_000).to_string(), "0.42");
471 assert_eq!(nanos_to_usd(3_000_000_000).to_string(), "3");
472 assert_eq!(nanos_to_usd(2_343_000).to_string(), "0.002343");
473 assert_eq!(nanos_to_usd_text(36_960), "0.00003696");
474 }
475}