Add prepaid balances and credit pools to ProviderUsage - #11
Merged
Conversation
Providers that sell credit alongside a subscription report a balance with no period, which the existing shape cannot carry: a rate window is a percentage of a period, and a pool is an amount with neither. Today that data is fetched by producers and discarded, so an account with a depleted window and a live credit pool reads as unusable when it would have served the request. Kept apart from `usage` rather than folded in, because the two fail in opposite directions: over-consuming a window gets you throttled and recovers by waiting, over-consuming a balance gets you billed and recovers by paying. A balance therefore never becomes a window, never carries a reset, and never appears as a percentage. Three choices in here were forced by real payloads rather than picked. Amounts are integer minor units because DeepSeek and MiniMax both send decimal strings and Anthropic sends minor units with an exponent -- and because a balance is compared against zero on every routing decision that reads it, where binary floats are not safe. Pool ids carry the provider's own name, since wallets separate voucher from cash and credit without defining which is a gift, and renaming one `granted` would invent the label a spend policy keys on. And `basis` distinguishes a reported remainder from one derived against a shared total, because DeepSeek reports per-pool remainders while others report only grants -- which is the difference between an exact policy and a ceiling. Additive: an entry without pools serializes exactly as before, pinned by a test on the rendered text rather than the field.
PoolFunding and PoolBasis were closed enums, and this payload crosses a repository boundary: one project produces it, others consume it, and their versions move independently. So the first funding kind added after a consumer is built fails deserialization of the ENTIRE ProviderUsage entry rather than one field. Measured rather than argued: an entry carrying a healthy 42% window and two pools, one with an unrecognised funding, loses everything -- "unknown variant `crypto_grant`". An account's rate windows would vanish because of a credit pool the consumer had never heard of, and a vanished entry reads as the provider being unavailable. For funding the fallback costs nothing, because the fallback and the semantics already agree: a kind this consumer cannot name is one it must not spend from, which is what Unknown already meant. Basis needed a decision rather than a default. Its two poles are not symmetrical -- treating an exact remainder as a ceiling under-spends and costs nothing, while treating a ceiling as exact spends money that may not be there -- so an unrecognised value must fold to the conservative side. It folds to a new Unstated variant rather than to Derived, because both are read the same way but Derived is a claim about how a number was obtained, and answering "I do not know" with it would assert a fact the producer does not hold. That is the failure this type exists to prevent, one level up. Both proven by removing the fallbacks: each test reddens by name.
There was a problem hiding this comment.
All reported issues were addressed across 2 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds an additive
spend: Option<Vec<Pool>>toProviderUsage, plusPool,Amount,PoolFundingandPoolBasis. Bumps to 0.5.0.Why
Providers that sell credit alongside a subscription report a balance with no period. The current shape cannot carry it — a
RateWindowis a percentage of a period, a pool is an amount with neither — so producers fetch that data and throw it away. The concrete defect: an account with a depleted plan window and a live credit pool is a working account, and today it publishes as 100% used.Asked for three times by one downstream consumer (insula issues #1 FEAT-3, #1 FEAT-4, #2 F-D). Design and the evidence behind it:
docs/balance-axis-design.mdin cortexkit/insula.Why it is separate from
usageThe two fail in opposite directions:
Nothing in a routing loop can undo the second. So a balance never becomes a window, never carries a reset, and never appears as a percentage — a consumer that finds one where it expects headroom paces into a bill.
Three choices forced by real payloads
Amounts are integer minor units, not
f64. DeepSeek and MiniMax both send decimal strings; Anthropic sends{amount_minor, currency, exponent}. Two of three deliberately avoid a binary float, and the third uses the standard money representation. It matters concretely: a balance is compared against zero on every routing decision that reads it.Pool.idis the provider's own name. MiniMax's wallet separatesvoucher_balance,cash_balanceandcredit_balanceand publicly defines none of them. Publishingvoucherlets a consumer decide; publishinggrantedwould invent the label a spend policy keys on, and being wrong there spends real money.PoolFunding::Unknownis a correct answer, not a failure one.basisseparates reported from derived. DeepSeek reportsgranted_balanceandtopped_up_balanceas live remainders, so "spend only granted credits" is exactly expressible. Others report grants per pool with consumption against their sum, where the same policy can only be a ceiling. One field tells a consumer which it holds.spendableis likewise read from the provider rather than inferred fromremaining > 0— Anthropic publishes enable flags directly, and a pool can be non-empty and closed.Compatibility
Additive. An entry with no pools serializes byte-identically to 0.4.1, pinned by a test asserting the rendered JSON contains no
spendkey rather than asserting the field isNone.12 tests green, clippy clean.
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.Summary by cubic
Add prepaid balances and credit pools to
ProviderUsageasspend, so providers can publish wallet balances and consumers don’t misreport usable accounts as exhausted. Adds safe fallbacks for unknown pool kinds and bumpscortexkit-provider-usageto0.5.0.New Features
spend: Option<Vec<Pool>>toProviderUsage.Pool,Amount(integer minor units),PoolFunding,PoolBasis, and optionalspendable.Pool.iduses the provider’s own name; kept separate fromusagedue to different failure modes.Bug Fixes
PoolFundingnow deserializes toUnknown; unknownPoolBasistoUnstated, so mixed entries don’t fail or dropusage.Written for commit ec9a015. Summary will update on new commits.