Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,34 @@
# Changelog

## 0.55.10

### Fixed — chat costs are final only when the response proves a settlement

`blockrun_chat` on a wallet reported every paid call as a final cost
(`cost_is_estimate: false`). The figure came from the SDK's spend counter, which
adds the signed amount whenever a paid request comes back 2xx. That is not proof
the payment settled. The gateway settles a stream after its headers are sent,
and on its enterprise local-trust path it can serve a call whose settlement then
fails. On 2026-11-12 four `zai/glm-5.3` calls from a wallet holding $0.001 were
served and none settled, yet this server reported $0.104 as final.

- A paid call is now final only when the response names its settlement
transaction (`PAYMENT-RESPONSE` with a transaction, or `X-Payment-Receipt`,
and no `X-Payment-Settled: false`). That transaction is returned as
`settlement_tx`.
- Without that proof, `cost_is_estimate` is `true` and the reply says the amount
was signed but not confirmed as settled. Streamed calls carry no receipt, so
this is the usual case for paid chat.
- Budget caps and delegated agent limits still count the signed amount, so
enforcement stays conservative.
- A paid call that fails without a receipt is booked as a possible charge
("cannot tell whether the gateway settled it"), not "the charge stands".
Either way, no other model is tried and you are told not to retry.
- The gateway's in-band error frame (`model: "error"`, `[Error: …]` content) is
now treated as a failure. Before, it came back as a successful reply with
`served_model: "error"` and a final cost.
- The native Claude path (`thinking`) uses the same rule.

## 0.55.9

### Changed — Seedance synced audio defaults on for every input
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.55.9
0.55.10
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@blockrun/mcp",
"version": "0.55.9",
"version": "0.55.10",
"mcpName": "io.github.BlockRunAI/blockrun-mcp",
"description": "BlockRun MCP Server - Give your AI agent web search, deep research, prediction markets, and crypto data. Pay per call from a USDC wallet (Solana or Base) or a BlockRun API key.",
"type": "module",
Expand Down
47 changes: 37 additions & 10 deletions src/tools/chat-anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { recordActualSpend } from "../utils/budget.js";
import { isApiKeyMode } from "../utils/auth.js";
import { OBSERVED_GATEWAY_TX_FEE_USD } from "../utils/tx-fee.js";
import { CHAT_PRICE_PER_MTOKEN, GATEWAY_CHARS_PER_TOKEN_OBSERVED } from "../utils/constants.js";
import { AcceptedThenFailedError, settlementOnThrow, settledCostFromHeaders } from "../utils/chat-stream.js";
import { AcceptedThenFailedError, settlementOnThrow, settledCostFromHeaders, settlementTxFromHeaders } from "../utils/chat-stream.js";
import type { BudgetState } from "../types.js";

/**
Expand Down Expand Up @@ -216,7 +216,7 @@ async function streamNativeMessage(
client: AnthropicLike,
params: Anthropic.MessageStreamParams,
idleTimeoutMs = NATIVE_IDLE_TIMEOUT_MS,
): Promise<{ message: Anthropic.Message; costHeaderUsd: number | null }> {
): Promise<{ message: Anthropic.Message; costHeaderUsd: number | null; settlementTx: string | null }> {
// The @blockrun/llm proxy wraps every messages.* call in an async function,
// so the MessageStream arrives behind a promise; the SDK returns it directly.
const stream = await client.messages.stream(params, { maxRetries: 0 });
Expand Down Expand Up @@ -250,11 +250,21 @@ async function streamNativeMessage(
armIdle();
try {
const message = await Promise.race([stream.finalMessage(), stall]);
return { message, costHeaderUsd: settledCostFromHeaders(stream.response?.headers) };
return {
message,
costHeaderUsd: settledCostFromHeaders(stream.response?.headers),
// Proof of settlement on the wallet rail, when the response carried one
// (see settlementTxFromHeaders); a stream's headers normally do not.
settlementTx: settlementTxFromHeaders(stream.response?.headers),
};
} catch (error) {
if (error instanceof AcceptedThenFailedError) throw error;
const settlementTx = settlementTxFromHeaders(stream.response?.headers);
if (error instanceof AcceptedThenFailedError) {
error.settlementTx ??= settlementTx;
throw error;
}
if (accepted) {
throw new AcceptedThenFailedError(error instanceof Error ? error.message : String(error), partialText, { cause: error });
throw new AcceptedThenFailedError(error instanceof Error ? error.message : String(error), partialText, { cause: error, settlementTx });
}
throw error;
} finally {
Expand All @@ -278,7 +288,8 @@ function nativeFailedText(error: unknown, usd: number, certainty: "settled" | "u
: certainty === "settled"
? `Note: payment had already settled when this failed, so the charge stands (~${amount}, the reconstructed quote) and it has been recorded against your budget.`
: `Note: the payment for this call had been signed and sent before it failed, and this process cannot tell whether the gateway settled it — ` +
`it may have settled after the connection dropped. The reconstructed quote (${amount}) has been recorded against your budget as a precaution.`;
`no settlement receipt came back, and the gateway may settle after the response ends. The reconstructed quote (${amount}) has been recorded ` +
`against your budget as a precaution; your wallet's on-chain history is the truth.`;
return `${formatError(extractErrorMessage(error))}\n\n${what} Retrying will incur a second charge — check blockrun_wallet action:"report" first.`;
}

Expand Down Expand Up @@ -465,8 +476,9 @@ export async function handleAnthropicNative(args: AnthropicNativeArgs): Promise<

let native: Anthropic.Message;
let costHeaderUsd: number | null;
let settlementTx: string | null;
try {
({ message: native, costHeaderUsd } = await streamNativeMessage(client, params, args.idleTimeoutMs));
({ message: native, costHeaderUsd, settlementTx } = await streamNativeMessage(client, params, args.idleTimeoutMs));
} catch (error) {
// Until audit round 3 this returned formatError and booked nothing, on
// both rails — the settled-then-failed machinery the OpenAI-compat paths
Expand All @@ -476,7 +488,14 @@ export async function handleAnthropicNative(args: AnthropicNativeArgs): Promise<
// stream connected (the gateway's own refusal, or the SDK's) and a
// payment the wallet could not make are not money; a failure after the
// 2xx is; an origin that never answered may be.
const verdict = settlementOnThrow(error, { rail: isApiKeyMode() ? "account" : "wallet", estimateUsd: estimatedCost, transparentPayment: true });
const classified = settlementOnThrow(error, { rail: isApiKeyMode() ? "account" : "wallet", estimateUsd: estimatedCost, transparentPayment: true });
// On the wallet rail, accepted is not settled: the gateway settles a
// stream after its headers, and its enterprise local-trust path can serve
// a payment that never settles. "The charge stands" needs a receipt.
const verdict = classified === "settled" && !isApiKeyMode() &&
!(error instanceof AcceptedThenFailedError && error.settlementTx !== null)
? "unknown"
: classified;
if (verdict === "none") {
return { content: [{ type: "text", text: formatError(extractErrorMessage(error)) }], isError: true };
}
Expand All @@ -500,10 +519,17 @@ export async function handleAnthropicNative(args: AnthropicNativeArgs): Promise<
const bookedUsd = isApiKeyMode()
? (costHeaderUsd ?? anthropicAccountLedgerUsd(native.model, anthropicPromptChars, effectiveMax, native.usage))
: anthropicCallCost(native.model, anthropicPromptChars, effectiveMax);
const costIsEstimate = isApiKeyMode() && costHeaderUsd === null;
// The wallet rail's figure is the quote, booked whether or not it settled
// (the conservative side for a budget cap) — but called final only when the
// response named its settlement transaction. A stream's headers leave before
// the gateway settles, so on the wallet rail this is normally an estimate.
const costIsEstimate = isApiKeyMode() ? costHeaderUsd === null : settlementTx === null;
recordActualSpend(budget, bookedUsd, estimatedCost, agentId);
const costLine = costIsEstimate && bookedUsd !== null && bookedUsd > 0
? `\n\n(Cost: ~$${bookedUsd.toFixed(4)}, estimated — billed to your BlockRun account at exact usage; https://user.blockrun.ai/dashboard/activity has the figure.)`
? isApiKeyMode()
? `\n\n(Cost: ~$${bookedUsd.toFixed(4)}, estimated — billed to your BlockRun account at exact usage; https://user.blockrun.ai/dashboard/activity has the figure.)`
: `\n\n(Cost: ~$${bookedUsd.toFixed(4)}, the reconstructed quote signed for this call — not confirmed as settled: the response carried no settlement receipt. ` +
`It is counted against your budget; your wallet's on-chain history is the truth.)`
: "";

const thinkingBlocks = native.content.filter(isThinkingBlock);
Expand Down Expand Up @@ -555,6 +581,7 @@ export async function handleAnthropicNative(args: AnthropicNativeArgs): Promise<
usage: native.usage,
cost_usd: bookedUsd ?? estimatedCost,
cost_is_estimate: costIsEstimate || bookedUsd === null,
...(!isApiKeyMode() && settlementTx ? { settlement_tx: settlementTx } : {}),
native,
},
};
Expand Down
47 changes: 36 additions & 11 deletions src/tools/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,15 +324,28 @@ async function withSettledCost<T extends ChatOutcome>(
const d = client.getSpending().totalUsd - before;
return Number.isFinite(d) && d >= 0 ? d : null;
};
// THE COUNTER IS NOT A RECEIPT. The SDK adds the SIGNED amount the moment a
// paid request comes back 2xx (unless the gateway names a skipped
// settlement); it never learns whether the settle that follows landed. On a
// stream it cannot: the gateway sends the headers first and settles at the
// first token or after the last. Its enterprise local-trust path serves a
// wallet whose CDP verify failed and settles after the stream — on
// 2026-11-12 four zai/glm-5.3 calls from a wallet holding $0.001 were served,
// none settled, and every one was booked here as final. So the delta is
// still what the ledger books (the conservative side: a budget cap counts a
// maybe-charge), but it is called settled only when the response named its
// settlement transaction (ChatOutcome.settlementTx).
try {
const result = await run();
// null = the counter could not be read: book the reserve rather than $0.
// A $0 delta moved nothing: a free model, or a settlement the gateway skipped.
const d = delta();
return { result, settledUsd: d ?? estimateUsd, costIsEstimate: d === null };
return { result, settledUsd: d ?? estimateUsd, costIsEstimate: d === null || (d > 0 && result.settlementTx === null) };
} catch (error) {
const d = delta();
if (d !== null && d > 0) {
onSettledThrow({ usd: d, certainty: "settled" });
const proven = error instanceof AcceptedThenFailedError && error.settlementTx !== null;
onSettledThrow({ usd: d, certainty: proven ? "settled" : "unknown" });
} else if (settlementOnThrow(error, { rail: "wallet", estimateUsd }) === "unknown") {
onSettledThrow({ usd: estimateUsd, certainty: "unknown" });
}
Expand Down Expand Up @@ -378,7 +391,8 @@ function settledThenFailedText(error: unknown, booking: FailedBooking, tail: str
what = booking.certainty === "settled"
? `Note: payment had already settled when this failed, so the charge stands (${usd}) and it has been recorded against your budget.`
: `Note: the payment for this call had been signed and sent before it failed, and this process cannot tell whether the gateway settled it — ` +
`it may have settled after the connection dropped. The reserved ${usd} has been recorded against your budget as a precaution.`;
`no settlement receipt came back, and the gateway may settle after the response ends. ${usd} has been recorded against your budget as a precaution; ` +
`your wallet's on-chain history is the truth.`;
}
const partial = error instanceof AcceptedThenFailedError && error.partialText
? `\n\nPartial response received before the failure (${error.partialText.length.toLocaleString("en-US")} chars):\n${error.partialText}`
Expand Down Expand Up @@ -419,16 +433,27 @@ function servedNotes(requested: string, outcome: ChatOutcome, maxTokens: number
return { text, fields };
}

/** The cost line for a reply, and its structuredContent fields. */
function costNotes(settledUsd: number, costIsEstimate: boolean): { text: string; fields: Record<string, unknown> } {
const fields = { cost_usd: settledUsd, cost_is_estimate: costIsEstimate };
// Only an ESTIMATE is worth a line in the reply: a settled wallet delta is
// already in action:"report", and the account rail's exact figure lives on
// the dashboard. Saying "~" is what keeps the CHANGELOG's promise that an
/**
* The cost line for a reply, and its structuredContent fields.
*
* On the wallet rails an estimate is a SIGNED amount whose settlement the
* response did not prove — on a stream, every paid call (withSettledCost). It
* is booked against the budget all the same; the line says what it is.
* `settlement_tx` is the proof when there was one.
*/
function costNotes(settledUsd: number, costIsEstimate: boolean, settlementTx: string | null): { text: string; fields: Record<string, unknown> } {
const fields: Record<string, unknown> = { cost_usd: settledUsd, cost_is_estimate: costIsEstimate };
if (settlementTx && !costIsEstimate) fields.settlement_tx = settlementTx;
// Only an ESTIMATE is worth a line in the reply: a proven wallet settlement
// is already in action:"report", and the account rail's exact figure lives
// on the dashboard. Saying "~" is what keeps the CHANGELOG's promise that an
// estimated figure never looks like a settlement.
if (!costIsEstimate || !(settledUsd > 0)) return { text: "", fields };
return {
text: `\n\n(Cost: ~$${settledUsd.toFixed(4)}, estimated${isApiKeyMode() ? " — billed to your BlockRun account at exact usage; https://user.blockrun.ai/dashboard/activity has the figure" : ""}.)`,
text: isApiKeyMode()
? `\n\n(Cost: ~$${settledUsd.toFixed(4)}, estimated — billed to your BlockRun account at exact usage; https://user.blockrun.ai/dashboard/activity has the figure.)`
: `\n\n(Cost: ~$${settledUsd.toFixed(4)}, the amount signed for this call — not confirmed as settled: the response carried no settlement receipt. ` +
`It is counted against your budget; your wallet's on-chain history is the truth.)`,
fields,
};
}
Expand Down Expand Up @@ -607,7 +632,7 @@ Run blockrun_models to see all available models with pricing.`,
);
recordActualSpend(budget, settledUsd, estimatedCost, agent_id);
const served = servedNotes(targetModel, result, max_tokens);
const cost = costNotes(settledUsd, costIsEstimate);
const cost = costNotes(settledUsd, costIsEstimate, result.settlementTx);
const prompt = freeTierTruncationNote(promptChars, result.servedModel ?? targetModel);
return {
reply: result.text,
Expand Down
Loading
Loading