1use std::collections::BTreeMap;
27use std::fmt;
28use std::sync::Arc;
29
30use serde::{Deserialize, Serialize};
31
32use crate::ports::{Answer, AnswerValue, ClassifierProfile, Question};
33
34const PICO_PER_USD: u64 = 1_000_000_000_000;
36
37const CHARS_PER_TOKEN: usize = 4;
40
41#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
45pub struct AdapterId(String);
46
47impl AdapterId {
48 #[must_use]
50 pub fn new(id: impl Into<String>) -> Self {
51 Self(id.into())
52 }
53
54 #[must_use]
56 pub fn as_str(&self) -> &str {
57 &self.0
58 }
59}
60
61impl fmt::Display for AdapterId {
62 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63 f.write_str(&self.0)
64 }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
73pub struct QuestionKind(String);
74
75impl QuestionKind {
76 #[must_use]
78 pub fn new(kind: impl Into<String>) -> Self {
79 Self(kind.into())
80 }
81
82 #[must_use]
84 pub fn as_str(&self) -> &str {
85 &self.0
86 }
87}
88
89impl fmt::Display for QuestionKind {
90 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91 f.write_str(&self.0)
92 }
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
97#[non_exhaustive]
98pub enum TokenSource {
99 Estimated,
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
107pub struct Tokens {
108 pub input: u64,
109 pub output: u64,
110 pub source: TokenSource,
111}
112
113impl Tokens {
114 #[must_use]
128 pub fn estimate(
129 profile: &ClassifierProfile,
130 state: &str,
131 questions: &BTreeMap<String, Question>,
132 answers: Option<&BTreeMap<String, Answer>>,
133 ) -> Self {
134 let (sent, _) = profile.truncate(state);
135 let mut input = sent.chars().count();
136 for (id, question) in questions {
137 input += id.chars().count() + question.instructions().chars().count();
138 let options: Vec<(&String, &String)> = match question {
139 Question::Choice { criteria, .. } => criteria.iter().collect(),
140 Question::Score { levels, .. } => levels.iter().map(|(n, m)| (n, m)).collect(),
141 Question::Noul { .. } => Vec::new(),
142 };
143 for (name, meaning) in options {
144 input += name.chars().count() + meaning.chars().count();
145 }
146 }
147 let output = answers.map_or(0, |answers| {
148 answers
149 .iter()
150 .map(|(id, answer)| {
151 let probabilities: usize = answer
152 .probabilities
153 .iter()
154 .map(|(label, p)| label.chars().count() + format!("{p:.2}").len())
155 .sum();
156 id.chars().count() + value_text(&answer.value).chars().count() + probabilities
157 })
158 .sum()
159 });
160 Self {
161 input: tokens_for(input),
162 output: tokens_for(output),
163 source: TokenSource::Estimated,
164 }
165 }
166}
167
168fn tokens_for(chars: usize) -> u64 {
170 u64::try_from(chars.div_ceil(CHARS_PER_TOKEN)).unwrap_or(u64::MAX)
171}
172
173pub(crate) fn value_text(value: &AnswerValue) -> String {
175 match value {
176 AnswerValue::Choice(label) => label.clone(),
177 AnswerValue::Score(score) => score.to_string(),
178 AnswerValue::Noul(verdict) => verdict.to_string(),
179 }
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
186pub struct Price {
187 pub input_pico_usd_per_token: u64,
188 pub output_pico_usd_per_token: u64,
189}
190
191impl Price {
192 pub const FREE: Price = Price::per_million_tokens(0, 0);
195
196 #[must_use]
200 pub const fn per_million_tokens(input_micro_usd: u64, output_micro_usd: u64) -> Price {
201 Price {
202 input_pico_usd_per_token: input_micro_usd,
203 output_pico_usd_per_token: output_micro_usd,
204 }
205 }
206
207 #[must_use]
209 pub const fn cost(&self, tokens: &Tokens) -> Cost {
210 Cost(
211 tokens
212 .input
213 .saturating_mul(self.input_pico_usd_per_token)
214 .saturating_add(tokens.output.saturating_mul(self.output_pico_usd_per_token)),
215 )
216 }
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
222pub struct Cost(u64);
223
224impl Cost {
225 pub const ZERO: Cost = Cost(0);
227
228 #[must_use]
230 pub const fn from_pico_usd(pico_usd: u64) -> Cost {
231 Cost(pico_usd)
232 }
233
234 #[must_use]
236 pub const fn pico_usd(&self) -> u64 {
237 self.0
238 }
239}
240
241impl fmt::Display for Cost {
242 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
244 write!(
245 f,
246 "${}.{:012}",
247 self.0 / PICO_PER_USD,
248 self.0 % PICO_PER_USD
249 )
250 }
251}
252
253#[derive(Debug, Clone, Default, PartialEq)]
255pub struct PriceSheet(BTreeMap<AdapterId, Price>);
256
257impl PriceSheet {
258 #[must_use]
260 pub fn new() -> Self {
261 Self::default()
262 }
263
264 #[must_use]
266 pub fn with(mut self, adapter: AdapterId, price: Price) -> Self {
267 self.0.insert(adapter, price);
268 self
269 }
270
271 #[must_use]
274 pub fn cost_of(&self, adapter: &AdapterId, tokens: &Tokens) -> Option<Cost> {
275 self.0.get(adapter).map(|price| price.cost(tokens))
276 }
277}
278
279#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
281pub enum CallRole {
282 Served,
285 Shadow,
288 Discarded,
298}
299
300#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
303pub enum CallOutcome {
304 Ok,
305 Failed,
306}
307
308#[derive(Debug, Clone, PartialEq, Eq)]
314#[non_exhaustive]
315pub struct CallRecord {
316 pub adapter: AdapterId,
317 pub kinds: Vec<QuestionKind>,
319 pub tokens: Tokens,
320 pub cost: Option<Cost>,
321 pub role: CallRole,
322 pub outcome: CallOutcome,
323}
324
325pub trait CostLedger: Send + Sync {
331 fn record(&self, record: CallRecord);
333}
334
335#[derive(Clone)]
339pub struct Accounting {
340 prices: PriceSheet,
341 ledger: Arc<dyn CostLedger>,
342}
343
344impl Accounting {
345 #[must_use]
347 pub fn new(prices: PriceSheet, ledger: Arc<dyn CostLedger>) -> Self {
348 Self { prices, ledger }
349 }
350
351 pub(crate) fn record(
353 &self,
354 adapter: &AdapterId,
355 profile: &ClassifierProfile,
356 state: &str,
357 questions: &BTreeMap<String, Question>,
358 answers: Option<&BTreeMap<String, Answer>>,
359 role: CallRole,
360 ) {
361 let tokens = Tokens::estimate(profile, state, questions, answers);
362 self.ledger.record(CallRecord {
363 adapter: adapter.clone(),
364 kinds: questions.keys().map(QuestionKind::new).collect(),
365 tokens,
366 cost: answers.and_then(|_| self.prices.cost_of(adapter, &tokens)),
367 role,
368 outcome: if answers.is_some() {
369 CallOutcome::Ok
370 } else {
371 CallOutcome::Failed
372 },
373 });
374 }
375}
376
377impl fmt::Debug for Accounting {
378 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
379 f.debug_struct("Accounting")
380 .field("prices", &self.prices)
381 .finish_non_exhaustive()
382 }
383}
384
385#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
387pub struct LedgerTotals {
388 pub calls: u64,
389 pub input_tokens: u64,
391 pub output_tokens: u64,
392 pub pico_usd: u128,
394 pub unpriced_calls: u64,
397 pub failed_calls: u64,
400}
401
402impl LedgerTotals {
403 fn add(&mut self, record: &CallRecord) {
404 self.calls = self.calls.saturating_add(1);
405 self.input_tokens = self.input_tokens.saturating_add(record.tokens.input);
406 self.output_tokens = self.output_tokens.saturating_add(record.tokens.output);
407 match (record.cost, record.outcome) {
408 (Some(cost), _) => {
409 self.pico_usd = self.pico_usd.saturating_add(u128::from(cost.pico_usd()));
410 }
411 (None, CallOutcome::Ok) => self.unpriced_calls = self.unpriced_calls.saturating_add(1),
412 (None, CallOutcome::Failed) => {}
413 }
414 if record.outcome == CallOutcome::Failed {
415 self.failed_calls = self.failed_calls.saturating_add(1);
416 }
417 }
418}
419
420#[derive(Debug, Default)]
424pub struct InMemoryLedger {
425 #[expect(
426 clippy::disallowed_types,
427 reason = "the ledger is an explicitly wired recording cell, not ambient request state (ADR 0007)"
428 )]
429 records: std::sync::Mutex<Vec<CallRecord>>,
430}
431
432impl InMemoryLedger {
433 #[must_use]
435 pub fn new() -> Self {
436 Self::default()
437 }
438
439 fn lock(&self) -> std::sync::MutexGuard<'_, Vec<CallRecord>> {
442 self.records
443 .lock()
444 .unwrap_or_else(std::sync::PoisonError::into_inner)
445 }
446
447 #[must_use]
449 pub fn records(&self) -> Vec<CallRecord> {
450 self.lock().clone()
451 }
452
453 #[must_use]
455 pub fn totals(&self) -> BTreeMap<AdapterId, LedgerTotals> {
456 let mut totals: BTreeMap<AdapterId, LedgerTotals> = BTreeMap::new();
457 for record in self.lock().iter() {
458 totals
459 .entry(record.adapter.clone())
460 .or_default()
461 .add(record);
462 }
463 totals
464 }
465
466 #[must_use]
469 pub fn role_totals(&self) -> BTreeMap<CallRole, LedgerTotals> {
470 let mut totals: BTreeMap<CallRole, LedgerTotals> = BTreeMap::new();
471 for record in self.lock().iter() {
472 totals.entry(record.role).or_default().add(record);
473 }
474 totals
475 }
476}
477
478impl CostLedger for InMemoryLedger {
479 fn record(&self, record: CallRecord) {
480 self.lock().push(record);
481 }
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487 use crate::ports::Calibration;
488
489 fn tokens(input: u64, output: u64) -> Tokens {
490 Tokens {
491 input,
492 output,
493 source: TokenSource::Estimated,
494 }
495 }
496
497 #[test]
498 fn a_published_price_maps_one_to_one_and_displays_every_pico_usd() {
499 let price = Price::per_million_tokens(3_000_000, 15_000_000);
500 assert_eq!(
501 price.cost(&tokens(1_000_000, 0)).to_string(),
502 "$3.000000000000"
503 );
504 assert_eq!(
505 price.cost(&tokens(1_000_000, 1_000_000)).pico_usd(),
506 18 * PICO_PER_USD
507 );
508 assert_eq!(Cost::from_pico_usd(1).to_string(), "$0.000000000001");
509 assert_eq!(
510 Price::per_million_tokens(u64::MAX, u64::MAX)
511 .cost(&tokens(u64::MAX, u64::MAX))
512 .pico_usd(),
513 u64::MAX,
514 "saturates, never wraps into a credit"
515 );
516 }
517
518 #[test]
519 fn an_unpriced_adapter_is_unknown_and_a_free_one_is_zero() {
520 let free = AdapterId::new("self-hosted");
521 let sheet = PriceSheet::new().with(free.clone(), Price::FREE);
522 assert_eq!(sheet.cost_of(&free, &tokens(10, 10)), Some(Cost::ZERO));
523 assert_eq!(
524 sheet.cost_of(&AdapterId::new("mystery"), &tokens(10, 10)),
525 None
526 );
527 }
528
529 #[test]
530 fn tokens_are_estimated_from_the_truncated_state_the_questions_and_the_answers() {
531 let questions = BTreeMap::from([(
532 "ok".to_owned(),
533 Question::Choice {
534 instructions: "pick".to_owned(),
535 criteria: BTreeMap::from([
536 ("a".to_owned(), "yes".to_owned()),
537 ("b".to_owned(), "no".to_owned()),
538 ]),
539 },
540 )]);
541 let profile = ClassifierProfile::new(Calibration::Classifier, 8);
542 let asked = Tokens::estimate(&profile, &"x".repeat(20), &questions, None);
544 assert_eq!(
545 asked,
546 tokens(6, 0),
547 "ceil(22 / 4), and no output for a failed call"
548 );
549
550 let answers = BTreeMap::from([(
551 "ok".to_owned(),
552 Answer::choice(
553 "a",
554 BTreeMap::from([("a".to_owned(), 0.9), ("b".to_owned(), 0.1)]),
555 ),
556 )]);
557 let answered = Tokens::estimate(&profile, "", &questions, Some(&answers));
559 assert_eq!(answered.output, 4);
560 assert_eq!(answered.source, TokenSource::Estimated);
561 }
562
563 fn record(
564 adapter: &str,
565 cost: Option<Cost>,
566 role: CallRole,
567 outcome: CallOutcome,
568 ) -> CallRecord {
569 CallRecord {
570 adapter: AdapterId::new(adapter),
571 kinds: vec![QuestionKind::new("topic")],
572 tokens: tokens(100, 10),
573 cost,
574 role,
575 outcome,
576 }
577 }
578
579 #[test]
580 fn totals_separate_unpriced_from_failed_and_do_not_saturate_at_one_calls_ceiling() {
581 let ledger = InMemoryLedger::new();
582 let huge = Some(Cost::from_pico_usd(u64::MAX));
583 ledger.record(record("cheap", huge, CallRole::Served, CallOutcome::Ok));
584 ledger.record(record("cheap", huge, CallRole::Discarded, CallOutcome::Ok));
585 ledger.record(record("cheap", None, CallRole::Served, CallOutcome::Ok));
586 ledger.record(record("cheap", None, CallRole::Shadow, CallOutcome::Failed));
587
588 let cheap = ledger.totals()[&AdapterId::new("cheap")];
589 assert_eq!(cheap.calls, 4);
590 assert_eq!(cheap.pico_usd, u128::from(u64::MAX) * 2);
591 assert_eq!(
592 cheap.unpriced_calls, 1,
593 "only the answered, unpriced call is a gap"
594 );
595 assert_eq!(cheap.failed_calls, 1);
596 assert_eq!(cheap.input_tokens, 400);
597
598 let roles = ledger.role_totals();
599 assert_eq!(roles[&CallRole::Discarded].calls, 1);
600 assert_eq!(roles[&CallRole::Served].calls, 2);
601 assert_eq!(ledger.records()[3].role, CallRole::Shadow, "oldest first");
602 }
603}