Skip to content

Add prepaid balances and credit pools to ProviderUsage - #11

Merged
ualtinok merged 2 commits into
masterfrom
balance-axis
Aug 10, 2026
Merged

Add prepaid balances and credit pools to ProviderUsage#11
ualtinok merged 2 commits into
masterfrom
balance-axis

Conversation

@ualtinok

@ualtinok ualtinok commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Adds an additive spend: Option<Vec<Pool>> to ProviderUsage, plus Pool, Amount, PoolFunding and PoolBasis. 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 RateWindow is 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.md in cortexkit/insula.

Why it is separate from usage

The two fail in opposite directions:

over-consume a window over-consume a balance
result throttled billed
recovery wait pay

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.id is the provider's own name. MiniMax's wallet separates voucher_balance, cash_balance and credit_balance and publicly defines none of them. Publishing voucher lets a consumer decide; publishing granted would invent the label a spend policy keys on, and being wrong there spends real money. PoolFunding::Unknown is a correct answer, not a failure one.

basis separates reported from derived. DeepSeek reports granted_balance and topped_up_balance as 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.

spendable is likewise read from the provider rather than inferred from remaining > 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 spend key rather than asserting the field is None.

12 tests green, clippy clean.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Summary by cubic

Add prepaid balances and credit pools to ProviderUsage as spend, so providers can publish wallet balances and consumers don’t misreport usable accounts as exhausted. Adds safe fallbacks for unknown pool kinds and bumps cortexkit-provider-usage to 0.5.0.

  • New Features

    • Added spend: Option<Vec<Pool>> to ProviderUsage.
    • Introduced Pool, Amount (integer minor units), PoolFunding, PoolBasis, and optional spendable.
    • Pool.id uses the provider’s own name; kept separate from usage due to different failure modes.
    • Backward-compatible: entries without pools serialize identically to 0.4.1 (pinned by test).
  • Bug Fixes

    • Unknown PoolFunding now deserializes to Unknown; unknown PoolBasis to Unstated, so mixed entries don’t fail or drop usage.
    • Tests cover pool round-trips and the unknown-kind fallback.

Written for commit ec9a015. Summary will update on new commits.

Review in cubic

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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread crates/cortexkit-provider-usage/src/lib.rs
@ualtinok
ualtinok merged commit c63efe1 into master Aug 10, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant