The Balance Is Cents
A top-up adds a fixed number of cents. A run subtracts the model price plus seven percent, rounded up.

The credits page shows one number. It is the cents column divided by 100, with two decimals, and the letters USD beside it. A new account starts at zero. When a top-up method is on, the line under the buttons says the funds never expire. The column has no expiry of its own.
The page reads the column when it renders. It does not list charges. The cents a run subtracted are stored on that run, as costCents, with the prompt tokens and the completion tokens. The credits page never loads them.
Seven percent, then the next cent
After a model returns, the cost is the prompt tokens times the prompt rate, plus the completion tokens times the completion rate. Both rates are the per-token prices on the model list. That sum is multiplied by 1.07. The result is turned into cents and rounded up, so a fraction of a cent becomes one cent. A call whose token math comes to $0.001 is $0.00107 after the fee, and the balance loses one cent.
A model whose prompt rate and completion rate are both zero costs nothing. The fee is not applied, and the function returns zero before the rounding.
The pricing page shows those rates per million tokens, with the same seven percent, and it also shows cache reads and web search. The subtraction uses the two token counts from the call. A cache read and a web search stay on the pricing card.
The list is cached in the process for an hour. An id that is missing from that cache is treated as free: both rates come back as zero, and the call is not charged. A helper named isFreeModel looks at the id instead. It treats an id that contains :free, or that ends in /free, as free, and it leaves an id that only starts with free/ alone. The run does not call that helper. A zero rate is what skips the charge.

Zero is the line
Before the prompt is saved, the route asks the same list about every selected model. If any rate is nonzero and the balance is zero or less, the request is a 402. The message is "Your balance is $0.00. Add funds or switch to a free model." No prompt row is written.
One cent clears the check. The route does not price the call before it starts.
A request that mixes a paid model and a free model is one check. An empty balance rejects the whole request. The free model in it does not run.
The SDK complete route has the same gate, on the model it will actually call. That model defaults to openai/gpt-4o when the body leaves it out. The model name on that route is a label for the sampling prompt. The balance check is a separate question, and it uses the rates for the id the route resolved.

The cents come off after the call
The model is called first. Token counts come back. Those counts become cents, and one update subtracts them from the column and returns the balance that remains.
The column is not allowed to go below zero. A subtraction that would cross zero is rejected, and that run is not stored. The route also treats a returned balance below zero as insufficient. Exactly zero is allowed. The next paid request then stops at the gate.
On a standard run this happens once per model, and the models run together. One of them can spend the last cents while another is still out. The second subtraction fails. The response is still the run result: the models that finished are in runs, and the rejected one is in errors. The prompt row was written before either call returned.
A free model still runs the update. It subtracts zero. The SDK route skips the update when the cents are zero, and it does not write a run. When it does subtract and the balance that comes back is below zero, the answer is a 402, "Insufficient balance after concurrent deduction," and the completion is not returned. A write the check rejects never returns that balance. It throws, and that route answers 500. costCents is on the JSON when the call succeeds.
Facet mode subtracts for the angle pass on the first model you picked, then again for each answer. The facet post is those two passes. Image mode subtracts for the line pass on the first model, then again for each picture. If the subtraction for that first pass is rejected, the later calls never start, and the route answers 500 with "Failed to execute drift run." The prompt row is already stored.

Five amounts, one session id
The top-up buttons are $5, $10, $25, $50, and $100. Any other amount is rejected. Five dollars is 500 cents. Stripe is on unless NEXT_PUBLIC_PAYMENT_STRIPE_ENABLED says otherwise. Crypto is off unless NEXT_PUBLIC_PAYMENT_CRYPTO_ENABLED says otherwise. With both off, the page says top-up is not enabled and leaves you the free models.
The first Stripe checkout creates a customer for your email and saves that customer id on the user. The next checkout uses the same id. The session is a one-time payment. Its metadata holds your user id and the amount in cents, and five dollars is the string "500". Five checkouts are allowed in a minute. The sixth in that minute is rate limited.
Coming back lands on /app/credits?success=1. The banner says "Payment received! Your balance has been updated." The number above it is whatever the column held when the page rendered, so the banner can arrive before the webhook. A cancelled checkout returns with ?cancelled=1, and the page does not read that flag.
The webhook handles checkout.session.completed, after the signature checks. It looks for a payment row with that Stripe session id. A row means the event is done, and the cents are not added again. Otherwise it records the payment, then adds the cents. The session id is unique on that table. A second delivery of the same session does not credit you twice, including a retry that finds the row after it was written and before the cents were added.

Crypto sends the same five amounts to Coinbase Commerce and sends you back to the same success URL. The charge metadata includes your user id. No route reads a Coinbase event, so that return does not add cents. The banner still says the balance was updated. The analytics call on that banner always records the method as Stripe.
What stays put at zero
Free models still run. That is what the line under a zero balance says, and the link in it goes back to the app. History, a collection, and a share link do not touch the column. The collection holds run ids. The share id sits on the prompt. The column moves in two directions. A completed Stripe session adds a fixed number of cents. A run subtracts the cents for the call that just returned.