whiskers.git / crates / whiskers-ports / src / allowance.rs

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.

18use ::log::{debug, info, trace, warn};
19use serde::{Deserialize, Serialize};
20use whiskers_core::TokenLimit;
22use crate::error::StoreError;
23use crate::time::{Day, Millis};
24use crate::voice::SpeakError;
25
26const HOUR_MS: u64 = 3_600_000;

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.

33#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
34#[serde(transparent)]
35pub struct Tokens(u64);
37impl Tokens {
38    pub const fn new(n: u64) -> Self {
39        Self(n)
40    }
41
42    pub const fn get(self) -> u64 {
43        self.0
44    }
45}

What the parents' screen shows about thinking.

48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49pub struct ThinkingUsage {
50    pub used: Tokens,
51    pub limit: Option<Tokens>,
52    pub window_hours: u64,

When the oldest spending leaves the window, so room starts to return; None if nothing is spent.

54    pub frees_up_at: Option<Millis>,
55}

Whether another question may be asked now.

58#[derive(Clone, Copy, Debug, PartialEq, Eq)]
59pub enum ThinkingRoom {
60    Room,

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.)

63    Spent { frees_up_at: Millis },
64}

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.

69#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
70pub struct ThinkingLedger {

(when in ms since the epoch, tokens)

72    spent: Vec<(u64, u64)>,
73}
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]).

140#[derive(Debug, PartialEq, Eq)]
141#[must_use = "a reservation holds characters of today's allowance until it is settled"]
142pub struct VoiceReservation {
143    day: Day,
144    chars: u32,
145}
147impl VoiceReservation {
148    pub fn chars(&self) -> u32 {
149        self.chars
150    }
151
152    pub fn day(&self) -> Day {
153        self.day
154    }
155}

Why a line may not be spoken.

158#[derive(Clone, Copy, Debug, PartialEq, Eq)]
159pub enum ReserveError {

Today's characters are spent. frees_up_at is when the next day begins.

161    DailyCap { spent: u32, cap: u32, frees_up_at: Millis },
162    Store(StoreError),
163}
165impl From<StoreError> for ReserveError {
166    fn from(e: StoreError) -> Self {
167        ReserveError::Store(e)
168    }
169}

How many characters of the natural voice have been spent on which day. The JSON is the service's voice spend file, unchanged.

173#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
174pub struct VoiceDay {
175    day: Day,
176    spent: u32,
177}
179impl VoiceDay {

The day the stored spend belongs to.

181    pub fn day(&self) -> Day {
182        self.day
183    }

What has been spent on today (a day that has turned since is a fresh allowance).

186    pub fn spent_on(&self, today: Day) -> u32 {
187        if self.day == today { self.spent } else { 0 }
188    }

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}

How a reservation ended.

218#[derive(Clone, Debug, PartialEq, Eq)]
219pub enum Settlement {

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.

223    NotSpoken(SpeakError),
224}

What the parents' screen shows about the natural voice's allowance.

227#[derive(Clone, Debug, PartialEq, Eq)]
228pub struct VoiceSpend {

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.

233    pub last_failure: Option<SpeakError>,
234}

The allowance.

Contract, for every adapter:

  • Atomic. Every method is atomic with respect to every other. In particular two reserve_voice calls 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_thinking returns Ok, 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 returns Err rather 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.
250#[expect(async_fn_in_trait, reason = "a Worker's futures hold JavaScript values and cannot be Send, so no Send bound may be required here")]
251pub trait Allowance {

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}