feat(payments): client-side spend limits (1.9.0)#34
Merged
Conversation
The SDK had no spend ceiling anywhere. client.py computed cost_usd and signed the quote in the next statement, with nothing compared against anything, while chat_completion documented a "PaymentError: If budget is set and would be exceeded" for a budget parameter that never existed. A typo in max_tokens, a model swap, or a gateway price change could all become a real charge with no local check between the quote and the signature. max_cost_per_call and max_session_cost on all four clients (Base and Solana, sync and async), also settable per-deployment via BLOCKRUN_MAX_COST_PER_CALL and BLOCKRUN_MAX_SESSION_COST. Both unset by default, so nothing changes for existing callers. Enforcement happens before the paid request is sent, which is what makes it free: signing alone moves no money, the gateway submitting the signed authorization does. Placement mattered more than expected — three handlers compute cost_usd only AFTER the paid POST returns (they prefer the price echoed on the response), so the guard there reads the quote off the 402 instead. The async Base handler had this wrong at first and the mutation test caught it. SpendLimitError subclasses PaymentError, so existing handlers keep working and _should_fallback refuses it — shopping the next model for a cheaper quote would defeat the limit and sign a second one. It carries quoted_usd, limit_usd and scope for callers that want to react rather than just fail. A malformed env var is ignored rather than raising: a bad deploy variable must not brick every client. An explicit non-positive argument still raises, since that is a programming error. 18 new tests. Every one drives a real client through a MockTransport and asserts no PAYMENT-SIGNATURE was ever sent, rather than testing the helper in isolation. Mutation-verified: no-oping either check, dropping the sync or async enforcement, disabling env resolution, or making SpendLimitError fallback-eligible each fails tests.
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.
The SDK had no spend ceiling anywhere.
client.pycomputedcost_usdand signed the quote in the next statement, with nothing compared against anything — whilechat_completiondocumented aPaymentError: If budget is set and would be exceededfor abudgetparameter that never existed.A typo in
max_tokens, a model swap, or a gateway price change could each become a real charge with no local check between the quote and the signature.Usage
Or per-deployment, without code changes:
BLOCKRUN_MAX_COST_PER_CALL,BLOCKRUN_MAX_SESSION_COST. An explicit argument wins over the env var.Both limits are opt-in and unset by default, so nothing changes for existing callers.
Why it costs nothing to refuse
Signing alone moves no money — the gateway submitting the signed authorization does. Refusing before the request is sent means nothing settles.
Placement mattered more than expected. Three handlers compute
cost_usdonly after the paid POST returns, because they prefer the price echoed on the response. Putting the guard next tocost_usdthere would run it after the money moved. Those sites read the quote off the 402 instead. The async Base handler had exactly this wrong in the first draft, and only the mutation test caught it.Error contract
SpendLimitErrorsubclassesPaymentError, so existingexcept PaymentErrorhandlers keep working, and_should_fallbackrefuses it — shopping the next model for a cheaper quote would defeat the limit and sign a second one.A malformed env var is ignored rather than raising: a bad deploy variable must not brick every client in a fleet. An explicit non-positive argument still raises, since that is a programming error, not a config accident.
Verification
436 pass;
blackandruffclean.18 new tests. Each drives a real client through a
MockTransportand asserts noPAYMENT-SIGNATUREwas ever sent, rather than testing the helper in isolation — the failure mode being guarded against is "the limit ran, but too late".Mutation-verified:
SpendLimitErrorfallback-eligibleAlso updates the
chat_completiondocstring, which until now promised a guard that did not exist.