The allowance: what Whiskers may spend, kept by the hub so one allowance covers every device and no child's tablet can raise it.
Two different things are metered, and they are metered differently on purpose:
- Thinking is a rolling window, like a session limit: so many tokens in any
window_hours. What a question costs is only known after it is answered, so it is asked first, charged after: the last question may overshoot. That is the documented intent of a rolling allowance, not a race. - The natural voice is a hard daily cap that protects a paid plan. What a line costs is known before it is spoken (its characters), so the cap is enforced by reserve, then settle: the characters are taken up front, atomically, and given back if the line did not come out. Two lines asked at once can therefore never both pass a cap with room for one.
The rules (what rolls off the window, when a day turns, what refunds) are pure structs here
([ThinkingLedger], [VoiceDay]) so every backend applies the same ones; an adapter only decides
where the struct is kept and how access to it is serialised.
The longest window the parents can choose is a day, but a changed window must find the spending still there, so spending is kept a week.
30const KEEP_MS: u64 = 7 * 24 * HOUR_MS;
A count of the model's tokens.
What the parents' screen shows about thinking.
When the oldest spending leaves the window, so room starts to return; None if nothing is spent.
Whether another question may be asked now.
The window is spent. frees_up_at is when the oldest spending leaves it. (If the window is spent
something was spent, so there is always such a moment.)
What was spent on thinking and when. How much may be spent is the grown-ups' choice, held in the household document, so it is passed in on every question and can change at any time. The JSON is the service's token ledger file, unchanged.
75impl ThinkingLedger { 76 pub fn entries(&self) -> usize { 77 self.spent.len() 78 } 79 80 fn used_in(&self, now: Millis, window_hours: u64) -> u64 { 81 let window = window_hours.saturating_mul(HOUR_MS); 82 self.spent.iter().filter(|(at, _)| at.saturating_add(window) > now.get()).map(|(_, t)| t).sum() 83 } 84 85 fn oldest_leaving(&self, now: Millis, window_hours: u64) -> Option<Millis> { 86 let window = window_hours.saturating_mul(HOUR_MS); 87 self.spent.iter().filter(|(at, _)| at.saturating_add(window) > now.get()).map(|(at, _)| Millis::new(at.saturating_add(window))).min() 88 }
Whether another question may be asked now under limit.
91 pub fn room(&self, now: Millis, limit: &TokenLimit) -> ThinkingRoom { 92 let used = self.used_in(now, limit.window_hours()); 93 if limit.per_window().is_none_or(|l| used < l) { 94 trace!("thinking allowance: room ({used} used of {:?})", limit.per_window()); 95 return ThinkingRoom::Room; 96 } 97 info!("thinking allowance: no room ({used} used of {:?} in {} h)", limit.per_window(), limit.window_hours()); 98 match self.oldest_leaving(now, limit.window_hours()) { 99 Some(frees_up_at) => ThinkingRoom::Spent { frees_up_at }, 100 // Unreachable: a spent window has spending in it. Say "now" rather than invent a wait. 101 None => ThinkingRoom::Room, 102 } 103 }
Records what a question cost.
106 pub fn charge(&mut self, now: Millis, cost: Tokens) { 107 let before = self.spent.len(); 108 self.spent.retain(|(at, _)| at.saturating_add(KEEP_MS) > now.get()); 109 if self.spent.len() != before { 110 trace!("thinking ledger pruned {} old entries", before - self.spent.len()); 111 } 112 self.spent.push((now.get(), cost.get())); 113 debug!("thinking allowance: {} tokens charged; {} entries kept", cost.get(), self.spent.len()); 114 }
116 pub fn usage(&self, now: Millis, limit: &TokenLimit) -> ThinkingUsage { 117 ThinkingUsage { 118 used: Tokens::new(self.used_in(now, limit.window_hours())), 119 limit: limit.per_window().map(Tokens::new), 120 window_hours: limit.window_hours(), 121 frees_up_at: self.oldest_leaving(now, limit.window_hours()), 122 } 123 } 124}
The tokens a Messages API reply cost: what went in plus what came out, cache writes included. Cache reads are not counted: they are the same prompt read back cheaply, and Anthropic does not count them against its own limits either. A reply that does not parse costs nothing.
129pub fn tokens_in(reply: &str) -> Tokens { 130 let Ok(v) = serde_json::from_str::<serde_json::Value>(reply) else { 131 warn!("tokens_in: the gateway reply ({} bytes) is not JSON; charging nothing", reply.len()); 132 return Tokens::new(0); 133 }; 134 let u = &v["usage"]; 135 Tokens::new(["input_tokens", "output_tokens", "cache_creation_input_tokens"].iter().filter_map(|k| u[*k].as_u64()).sum()) 136}
Characters of natural voice taken from today's allowance for one line, until it is settled.
Only [VoiceDay::reserve] makes one, and an adapter must settle it ([Allowance::settle_voice]).
Why a line may not be spoken.
Today's characters are spent. frees_up_at is when the next day begins.
How many characters of the natural voice have been spent on which day. The JSON is the service's voice spend file, unchanged.
179impl VoiceDay {
What has been spent on today (a day that has turned since is a fresh allowance).
Takes chars from the day containing now, or refuses if they would pass cap.
191 pub fn reserve(&mut self, now: Millis, chars: u32, cap: u32) -> Result<VoiceReservation, ReserveError> { 192 let today = Day::containing(now); 193 if today != self.day { 194 info!("voice: a new day ({} -> {}); the spend starts from zero (was {})", self.day.number(), today.number(), self.spent); 195 *self = Self { day: today, spent: 0 }; 196 } 197 if self.spent.saturating_add(chars) > cap { 198 info!("speech refused: {chars} chars on top of {} spent would pass today's cap of {cap}", self.spent); 199 return Err(ReserveError::DailyCap { spent: self.spent, cap, frees_up_at: today.ends_at() }); 200 } 201 self.spent += chars; 202 debug!("speech reserved: {chars} chars, {} of {cap} spent today", self.spent); 203 Ok(VoiceReservation { day: today, chars }) 204 }
The line was not spoken: gives its characters back, if the day has not turned since.
207 pub fn release(&mut self, reservation: VoiceReservation) { 208 if self.day == reservation.day { 209 self.spent = self.spent.saturating_sub(reservation.chars); 210 debug!("speech reservation of {} chars released; {} spent today", reservation.chars, self.spent); 211 } else { 212 debug!("speech reservation of {} chars released after the day turned; nothing to give back", reservation.chars); 213 } 214 } 215}
The line was spoken: the characters stay spent, and any earlier failure is forgotten.
221 Spoken,
The line was not spoken: the characters are given back, and this is remembered as the last failure.
What the parents' screen shows about the natural voice's allowance.
Characters spent on the day containing the now asked about.
230 pub spent_today: u32,
Why the last attempt to speak failed, until one succeeds: what shows when the natural voice has quietly turned into the tablet's.
The allowance.
Contract, for every adapter:
- Atomic. Every method is atomic with respect to every other. In particular two
reserve_voicecalls never both succeed if together they pass the cap. - Shared. One allowance per household, whichever device or request asks.
- Durable. Spending survives the hub restarting: a restart must not grant a fresh allowance.
A charge is durable once
charge_thinkingreturnsOk, a voice line once it is settled (a reservation is a promise about a line not yet spoken, and a crash may forget it: the spend is then a line short, never a line over). If the store cannot be written the call still keeps the spending in memory but returnsErrrather than pretend it was kept. - The rules are the ledgers'. An adapter holds a [
ThinkingLedger] and a [VoiceDay] and calls their methods; it adds nothing to the arithmetic.
Whether another question may be asked now under limit.
253 async fn thinking_room(&self, now: Millis, limit: &TokenLimit) -> Result<ThinkingRoom, StoreError>;
Records what an answered question cost.
256 async fn charge_thinking(&self, now: Millis, cost: Tokens) -> Result<(), StoreError>;
258 async fn thinking_usage(&self, now: Millis, limit: &TokenLimit) -> Result<ThinkingUsage, StoreError>;
Takes chars from today's allowance, or refuses with [ReserveError::DailyCap].
261 async fn reserve_voice(&self, now: Millis, chars: u32, cap: u32) -> Result<VoiceReservation, ReserveError>;
Ends a reservation: keeps the characters spent if the line was spoken, gives them back if not.
264 async fn settle_voice(&self, reservation: VoiceReservation, how: Settlement) -> Result<(), StoreError>;
266 async fn voice_spend(&self, now: Millis) -> Result<VoiceSpend, StoreError>; 267} 268 269#[cfg(test)] 270mod tests { 271 use super::*; 272 273 const H: u64 = HOUR_MS; 274 275 fn limit(tokens: u64, hours: u64) -> TokenLimit { 276 TokenLimit::new(Some(tokens), hours).unwrap() 277 } 278 279 fn at(ms: u64) -> Millis { 280 Millis::new(ms) 281 } 282 283 #[test] 284 fn it_has_room_until_the_window_is_spent_and_then_stops() { 285 let (mut b, l) = (ThinkingLedger::default(), limit(1000, 5)); 286 assert_eq!(b.room(at(0), &l), ThinkingRoom::Room); 287 b.charge(at(0), Tokens::new(600)); 288 assert_eq!(b.room(at(1), &l), ThinkingRoom::Room); 289 b.charge(at(1), Tokens::new(600)); 290 assert!(matches!(b.room(at(2), &l), ThinkingRoom::Spent { .. }), "1200 of 1000"); 291 } 292 293 #[test] 294 fn spending_ages_out_of_a_rolling_window_not_a_clock_day() { 295 let (mut b, l) = (ThinkingLedger::default(), limit(1000, 5)); 296 b.charge(at(0), Tokens::new(1000)); 297 assert_eq!(b.room(at(4 * H), &l), ThinkingRoom::Spent { frees_up_at: at(5 * H) }, "and it says when"); 298 assert_eq!(b.room(at(5 * H + 1), &l), ThinkingRoom::Room, "the old spending has left the window"); 299 assert_eq!(b.usage(at(5 * H + 1), &l).used, Tokens::new(0)); 300 } 301 302 #[test] 303 fn changing_the_limit_or_the_window_takes_effect_at_once() { 304 let mut b = ThinkingLedger::default(); 305 b.charge(at(0), Tokens::new(900)); 306 assert!(matches!(b.room(at(H), &limit(500, 5)), ThinkingRoom::Spent { .. }), "a lower allowance stops it now"); 307 assert_eq!(b.room(at(H), &limit(5000, 5)), ThinkingRoom::Room, "a higher one reopens it"); 308 assert_eq!(b.room(at(3 * H), &limit(500, 2)), ThinkingRoom::Room, "a shorter window forgets the spending sooner"); 309 assert_eq!(b.room(at(H), &TokenLimit::new(None, 5).unwrap()), ThinkingRoom::Room, "no limit at all"); 310 } 311 312 #[test] 313 fn it_says_when_room_starts_to_return() { 314 let (mut b, l) = (ThinkingLedger::default(), limit(1000, 5)); 315 assert_eq!(b.usage(at(0), &l).frees_up_at, None); 316 b.charge(at(2 * H), Tokens::new(300)); 317 b.charge(at(3 * H), Tokens::new(300)); 318 assert_eq!(b.usage(at(4 * H), &l).frees_up_at, Some(at(7 * H))); 319 } 320 321 #[test] 322 fn the_ledger_is_the_service_token_file_unchanged() { 323 let mut b = ThinkingLedger::default(); 324 b.charge(at(10), Tokens::new(700)); 325 let json = serde_json::to_string(&b).unwrap(); 326 assert_eq!(json, r#"{"spent":[[10,700]]}"#); 327 let again: ThinkingLedger = serde_json::from_str(&json).unwrap(); 328 assert_eq!(again.usage(at(20), &limit(1000, 5)).used, Tokens::new(700)); 329 } 330 331 #[test] 332 fn it_reads_what_a_reply_cost() { 333 let reply = r#"{"content":[],"usage":{"input_tokens":120,"output_tokens":30,"cache_read_input_tokens":50}}"#; 334 assert_eq!(tokens_in(reply), Tokens::new(150), "cache reads are free"); 335 assert_eq!(tokens_in("not json"), Tokens::new(0)); 336 assert_eq!(tokens_in(r#"{"content":[]}"#), Tokens::new(0)); 337 } 338 339 #[test] 340 fn the_voice_cap_is_reserved_up_front_and_given_back_when_the_line_does_not_come_out() { 341 let mut v = VoiceDay::default(); 342 let a = v.reserve(at(1000), 6, 10).unwrap(); 343 assert_eq!(v.spent_on(Day::containing(at(1000))), 6); 344 assert!(matches!(v.reserve(at(1001), 6, 10), Err(ReserveError::DailyCap { spent: 6, cap: 10, .. })), "the second would pass the cap"); 345 v.release(a); 346 assert_eq!(v.spent_on(Day::containing(at(1000))), 0); 347 assert!(v.reserve(at(1002), 6, 10).is_ok(), "the characters came back"); 348 } 349 350 #[test] 351 fn a_refused_line_says_when_the_day_turns_and_a_new_day_starts_from_zero() { 352 let mut v = VoiceDay::default(); 353 let _kept = v.reserve(at(5), 10, 10).unwrap(); 354 match v.reserve(at(6), 1, 10) { 355 Err(ReserveError::DailyCap { frees_up_at, .. }) => assert_eq!(frees_up_at, Day::containing(at(6)).ends_at()), 356 other => panic!("{other:?}"), 357 } 358 assert!(v.reserve(Day::containing(at(5)).ends_at(), 10, 10).is_ok(), "tomorrow is a fresh allowance"); 359 } 360 361 #[test] 362 fn a_reservation_released_after_the_day_turned_gives_nothing_back() { 363 let mut v = VoiceDay::default(); 364 let old = v.reserve(at(5), 4, 10).unwrap(); 365 let tomorrow = Day::containing(at(5)).ends_at(); 366 let _new = v.reserve(tomorrow, 3, 10).unwrap(); 367 v.release(old); 368 assert_eq!(v.spent_on(Day::containing(tomorrow)), 3, "yesterday's refund does not touch today's"); 369 } 370 371 #[test] 372 fn the_voice_file_keeps_its_old_shape() { 373 let mut v = VoiceDay::default(); 374 let _r = v.reserve(at(86_400_000 * 3 + 5), 7, 10).unwrap(); 375 assert_eq!(serde_json::to_string(&v).unwrap(), r#"{"day":3,"spent":7}"#); 376 } 377}