Skip to content

feat(payments): client-side spend limits (1.9.0)#34

Merged
VickyXAI merged 1 commit into
mainfrom
feat/client-side-spend-cap
Jul 21, 2026
Merged

feat(payments): client-side spend limits (1.9.0)#34
VickyXAI merged 1 commit into
mainfrom
feat/client-side-spend-cap

Conversation

@VickyXAI

Copy link
Copy Markdown
Contributor

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 each become a real charge with no local check between the quote and the signature.

Usage

from blockrun_llm import LLMClient, SpendLimitError

client = LLMClient(max_cost_per_call=0.25, max_session_cost=10.00)

try:
    client.chat_completion("anthropic/claude-opus-4.8", messages)
except SpendLimitError as e:
    print(e.quoted_usd, e.limit_usd, e.scope)   # 1.0  0.25  "call"

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_usd only after the paid POST returns, because they prefer the price echoed on the response. Putting the guard next to cost_usd there 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

SpendLimitError subclasses PaymentError, so existing except PaymentError 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.

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; black and ruff clean.

18 new tests. Each drives a real client through a MockTransport and asserts no PAYMENT-SIGNATURE was ever sent, rather than testing the helper in isolation — the failure mode being guarded against is "the limit ran, but too late".

Mutation-verified:

mutation result
per-call check → no-op 6 fail
session check → no-op 2 fail
drop sync-chat enforcement 7 fail
drop async enforcement 1 fail
disable env-var resolution 2 fail
make SpendLimitError fallback-eligible 1 fail

Also updates the chat_completion docstring, which until now promised a guard that did not exist.

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.
@VickyXAI
VickyXAI merged commit 1f77d4a into main Jul 21, 2026
3 checks passed
@VickyXAI
VickyXAI deleted the feat/client-side-spend-cap branch July 21, 2026 21:25
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