Skip to content

0.55.10 — chat costs are final only when the response proves a settlement - #215

Merged
VickyXAI merged 1 commit into
mainfrom
fix/book-settled-spend
Nov 12, 2026
Merged

VickyXAI merged 1 commit into
mainfrom
fix/book-settled-spend

Conversation

@VickyXAI

Copy link
Copy Markdown
Contributor

Problem

On 2026-11-12, blockrun_chat on a Base wallet holding about $0.001 booked $0.104 across 3-4 zai/glm-5.3 calls (sessionSpend, and cost_usd with cost_is_estimate: false), but no payment settled. CDP verify failed, the gateway served the calls on its enterprise local-trust path, and its settlement after the stream then failed ([ALERT][enterprise-unpaid] for each call). One call ended in an upstream error and still came back as a success, with served_model: "error" and a final cost_usd: 0.04443.

Root cause

  • src/tools/chat.ts:331 (withSettledCost, wallet branch) set costIsEstimate: d === null. In other words, any delta in the SDK's getSpending() counter was treated as settled. The SDK (@blockrun/llm 3.26.5, LLMClient.chatCompletionStream) adds the signed amount whenever a paid request comes back 2xx, unless the gateway names a skipped settlement. It never learns whether the settlement landed. On a stream it cannot know: the gateway sends the headers (just SSE_HEADERS, no receipt) before it settles. Line 335 did the same thing for failures and booked any delta as certainty: "settled" ("the charge stands").
  • src/utils/chat-stream.ts ChatAccumulator.fold recognised only an { error } object. The gateway's sseError frame (model: "error", id: "error-<ts>", content "\n\n[Error: …]") was read as normal content, so a failed call came back as the answer.
  • src/tools/chat-anthropic.ts:503 (the native thinking path) had the same problem: on the wallet rail it always set costIsEstimate = false.

The settled-vs-maybe split from 0.55.2/0.55.3 decided on how the call ended (it threw, or the edge returned 5xx). It never checked for a receipt, so a 2xx that was served but never settled always counted as settled.

Change

  • New settlementTxFromHeaders(). It counts as proof only a receipt with a transaction: PAYMENT-RESPONSE/X-PAYMENT-RESPONSE with a non-empty transaction and no success: false, or X-Payment-Receipt. X-Payment-Settled: false overrides both. This matches the gateway's non-stream route, which writes success: true with an empty transaction plus X-Payment-Settled: false when its settlement fails. ChatOutcome and AcceptedThenFailedError now carry settlementTx. The non-streaming path reads the SDK's settlement.transaction. The Solana frame stream has no headers, so it is always unproven.
  • Wallet rail: a cost is final (cost_is_estimate: false, settlement_tx set) only with that proof. Without it, cost_is_estimate: true and the reply says the amount was signed but not confirmed as settled. The ledger still books the signed amount, so global budgets and delegated limits stay conservative.
  • A failure after a counter move with no receipt is booked as "unknown" (a maybe-charge). The routing loop still stops and the text still warns against retrying.
  • The gateway's in-band error frame is now an AcceptedThenFailedError. Any partial text before it is kept.
  • The native Claude path follows the same rule.

Paid streams never carry a receipt today, so paid wallet chat will normally report cost_is_estimate: true. That is what this server can actually observe. The gateway does not change.

Tests

  • chat-wallet-after-payment.test.ts (Solana and Base):
    • (a) a successful paid stream with no receipt: still booked, cost_is_estimate: true, no settlement_tx.
    • (b) the gateway's model: "error" frame: isError, no served_model: "error", booked as a maybe, routing loop stops.
    • An error frame after some text keeps the partial text.
    • Base only: a receipt makes the cost final with settlement_tx; X-Payment-Settled: false plus an empty transaction is not proof; a failure that has a receipt still says "the charge stands".
  • chat-anthropic-settled.test.ts: native success with and without a receipt, and a failure after connect with and without a receipt.
  • Updated the existing "charge stands" assertions where there is no receipt, in chat-settled-on-error and the native partial-text test.
  • I checked the new compat tests against the old src: 10 of them fail there.

npm run build, npm run typecheck and npm test all exit 0 (1491 tests, 1491 pass).

Not in this PR

blockrun_wallet sessionSpend is still one figure that includes maybe-charges, as the budget design intends. If a confirmed/unconfirmed split is wanted, it should cover every paid tool, not just chat. Other tools that book the SDK counter (image, music, speech, etc.) were not audited for the same rule.

🤖 Generated with Claude Code

…ment

blockrun_chat on a wallet reported every paid call as cost_is_estimate:false.
The figure was the delta of the SDK's spend counter, which adds the signed
amount on any paid 2xx. The gateway settles a stream after its headers, and
its enterprise local-trust path serves calls whose settlement can then fail:
on 2026-11-12 four zai/glm-5.3 calls from a $0.001 wallet were served, none
settled, and $0.104 was reported as final.

- withSettledCost (chat.ts) and the native path (chat-anthropic.ts) call a
  cost final only when the response names its settlement transaction
  (PAYMENT-RESPONSE with a transaction or X-Payment-Receipt, and no
  X-Payment-Settled: false). Otherwise cost_is_estimate is true and the
  reply says the amount was signed but not confirmed. The ledger still books
  the signed amount, so budget caps stay conservative.
- A paid failure without a receipt is booked as a maybe, not "the charge
  stands"; the routing loop still stops.
- The gateway's in-band sseError frame (model:"error") is a failure, not
  the answer.
@VickyXAI
VickyXAI merged commit f8887d0 into main Nov 12, 2026
3 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