Everybody knows not to store money in a float. The advice is universal, correct, and slightly harder to follow than it sounds, because the number does not arrive as an integer.
Where the float is legitimate
The router prices a request by multiplying token counts by a per-million rate:
price_in: 0.60 # USD per 1M tokens
price_out: 0.80
(1_234 / 1_000_000) * 0.60 is a float operation and there is no honest way for
it not to be. The rate is a decimal fraction, the token count is small, and the
product is a fraction of a cent. Refusing to use floats here means implementing
decimal arithmetic for a number that will immediately be rounded.
So the router computes cost as a float. That is fine, and it is fine because of where it stops.
The boundary
One function, called once, at the edge of the ledger:
MICROCENTS_PER_USD = 100_000_000 # 1 USD = 100 cents = 1e8 micro-cents
def usd_to_microcents(usd: float) -> int:
scaled = usd * MICROCENTS_PER_USD
return int(scaled + (0.5 if scaled >= 0 else -0.5))
After that, every stored, summed and compared amount is a signed integer. The
conversion happens once and never goes back — microcents_to_usd exists, is
documented "for display only", and its result is never fed into a stored amount.
The reason for the granularity: a routed request can cost a small fraction of a
cent. Storing cents would round most individual charges to zero, and an account
could then make thousands of requests for nothing. Micro-cents give eight
decimal places of headroom, and BIGINT because an INT overflows at about $21.
Why not round()
That + 0.5 looks like something a linter would replace with round(). It is
deliberate, and this is the part worth the post.
Python's round() is banker's rounding: exact halves go to the nearest even
number. round(0.5) is 0, round(1.5) is 2, round(2.5) is 2.
For statistics that is the right default — it does not bias a large sample in either direction. For money it is wrong in a specific way: whether a half-unit charge rounds up or down depends on whether the neighbouring digit is even, which is not a property anybody's billing policy mentions. Across millions of rows it produces a systematic pattern nobody chose and nobody can explain to an auditor.
Half away from zero is the convention people expect. It is also the one that behaves symmetrically for negative amounts, which matters because debits are stored negative:
assert usd_to_microcents( 0.000000005) == 1
assert usd_to_microcents(-0.000000005) == -1
Both tested, because "rounds the same in both directions" is exactly the sort of thing that stays true until somebody simplifies the expression.
The type check that looks redundant
if not isinstance(amount_microcents, int):
raise TypeError("amounts are integer micro-cents — see CON-money-is-integer-microcents")
A type annotation says int already. This raises anyway, at runtime, in the one
function that writes to the ledger.
Because annotations are not enforced, and the failure mode is quiet: a float
that reaches the database becomes a value that sums almost correctly. Postgres
will accept 1000.0 into a BIGINT column. You do not find out from an error;
you find out from a balance that is off by a cent in a way nobody can reproduce.
The error message names the constraint it is protecting, so the next person to hit it can read the argument rather than deleting the check.
What it does not solve
Currency. Everything here is USD, and multi-currency is a different problem than precision — it needs a currency on every row and a rate at the moment of conversion, which is a decision about when a rate is captured rather than about how many decimal places to keep.
Doing the precision properly first at least means that when currency arrives, it arrives into a ledger where every number already means exactly one thing.