diff --git a/CHANGELOG.md b/CHANGELOG.md index 821aaa4..13d60fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/VERSION b/VERSION index 7b68569..f107f5a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.55.9 +0.55.10 diff --git a/package-lock.json b/package-lock.json index 0ce3572..7690698 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@blockrun/mcp", - "version": "0.55.9", + "version": "0.55.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@blockrun/mcp", - "version": "0.55.9", + "version": "0.55.10", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.123.0", diff --git a/package.json b/package.json index b774311..e0aaa40 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/tools/chat-anthropic.ts b/src/tools/chat-anthropic.ts index 59e29fe..467f9df 100644 --- a/src/tools/chat-anthropic.ts +++ b/src/tools/chat-anthropic.ts @@ -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"; /** @@ -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 }); @@ -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 { @@ -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.`; } @@ -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 @@ -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 }; } @@ -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); @@ -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, }, }; diff --git a/src/tools/chat.ts b/src/tools/chat.ts index c51fb67..213c5a9 100644 --- a/src/tools/chat.ts +++ b/src/tools/chat.ts @@ -324,15 +324,28 @@ async function withSettledCost( 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" }); } @@ -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}` @@ -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 } { - 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 } { + const fields: Record = { 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, }; } @@ -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, diff --git a/src/utils/chat-stream.ts b/src/utils/chat-stream.ts index 5548803..b9b84a6 100644 --- a/src/utils/chat-stream.ts +++ b/src/utils/chat-stream.ts @@ -55,6 +55,12 @@ export interface ChatUsage { * only this field tells you (D55). `finishReason` "length" means the reply was * cut at max_tokens (D57). `settledUsd` is `x-blockrun-cost-usd` when the rail * sent it — a settled zero included — and null when it did not. + * + * `settlementTx` is the wallet rails' PROOF of payment: the transaction the + * response named for this call's x402 settlement (settlementTxFromHeaders), + * null when it named none. The SDK's spend counter is not that proof — it + * counts the signed amount on any paid 2xx. On a stream it is always null: + * the gateway sends a stream's headers before it settles. */ export interface ChatOutcome { text: string; @@ -62,6 +68,7 @@ export interface ChatOutcome { finishReason: string | null; usage: ChatUsage | null; settledUsd: number | null; + settlementTx: string | null; } /** @@ -78,10 +85,17 @@ export interface ChatOutcome { */ export class AcceptedThenFailedError extends Error { readonly partialText: string; - constructor(message: string, partialText = "", options?: { cause?: unknown }) { + /** + * The settlement transaction the accepted response named, when it named one + * (see ChatOutcome.settlementTx). Accepted is not settled: without this, a + * wallet-rail caller books the charge as a maybe, not as certain. + */ + settlementTx: string | null; + constructor(message: string, partialText = "", options?: { cause?: unknown; settlementTx?: string | null }) { super(message, options); this.name = "AcceptedThenFailedError"; this.partialText = partialText; + this.settlementTx = options?.settlementTx ?? null; } } @@ -107,6 +121,59 @@ export function settledCostFromHeaders(headers: { get(name: string): string | nu return parseCostHeader(headers?.get("x-blockrun-cost-usd")); } +/** + * The settlement transaction a paid response PROVES, or null. + * + * Proof is a receipt naming a transaction: the x402 `PAYMENT-RESPONSE` + * (legacy `X-PAYMENT-RESPONSE`) header, base64 JSON, with a non-empty + * `transaction` and no `success: false`, or the gateway's `X-Payment-Receipt`. + * `X-Payment-Settled: false` overrides both. Nothing else is proof: + * + * - The gateway's non-streaming route writes `success: true` with an EMPTY + * transaction when its settle failed and it served anyway, and says so in + * `X-Payment-Settled: false` (blockrun route.ts). Reading `success` alone + * would call that paid. + * - A stream carries no receipt at all: its headers leave before the gateway + * settles (at the first token, or after the last). On the enterprise + * local-trust path the settle runs after the stream and can fail with the + * answer already delivered — 2026-11-12, four zai/glm-5.3 calls from a wallet + * holding $0.001 were served, none settled, and this server booked $0.104 of + * them as final (`cost_is_estimate: false`), because the SDK's counter adds + * the signed amount on every paid 2xx. + */ +export function settlementTxFromHeaders(headers: { get(name: string): string | null } | null | undefined): string | null { + if (typeof headers?.get !== "function") return null; + if ((headers.get("x-payment-settled") ?? "").trim().toLowerCase() === "false") return null; + const raw = headers.get("payment-response") ?? headers.get("x-payment-response"); + if (raw) { + try { + const receipt = JSON.parse(Buffer.from(raw, "base64").toString("utf8")) as { success?: unknown; transaction?: unknown } | null; + if (receipt?.success === false) return null; + if (typeof receipt?.transaction === "string" && receipt.transaction.trim()) return receipt.transaction.trim(); + } catch { + // An unreadable receipt proves nothing; fall through to X-Payment-Receipt. + } + } + return headers.get("x-payment-receipt")?.trim() || null; +} + +/** + * The gateway's in-band error frame (its sseError helper): an ordinary chat + * chunk with `model: "error"`, an `error-` id, and the message as delta + * content `"\n\n[Error: …]"`. The gateway writes it when an upstream fails + * after the 200 and when its settle gate refuses a stream. Read as content, it + * came back as a SUCCESSFUL reply: "[Error: …]" as the answer, `served_model: + * "error"`, booked as a final charge (2026-11-12). It is a failure. + */ +function gatewayErrorFrameMessage(ev: { id?: unknown; model?: unknown; choices?: Array<{ delta?: { content?: unknown } }> }): string | null { + const marked = ev?.model === "error" || (typeof ev?.id === "string" && ev.id.startsWith("error-")); + if (!marked) return null; + const content = ev.choices?.[0]?.delta?.content; + const text = typeof content === "string" ? content.trim() : ""; + const m = /^\[Error: ([\s\S]*)\]$/.exec(text); + return (m ? m[1] : text) || "the gateway reported an error"; +} + function stallError(ms: number): AcceptedThenFailedError { return new AcceptedThenFailedError(`stream stalled: no data from the gateway for ${Math.round(ms / 1000)}s`); } @@ -143,6 +210,7 @@ class ChatAccumulator { fold(event: unknown): void { const ev = event as { + id?: unknown; error?: { message?: string } | string; model?: unknown; usage?: { prompt_tokens?: unknown; completion_tokens?: unknown }; @@ -156,6 +224,10 @@ class ChatAccumulator { const msg = typeof ev.error === "string" ? ev.error : ev.error.message ?? JSON.stringify(ev.error); throw new AcceptedThenFailedError(`upstream error mid-stream: ${msg}`, this.result().text); } + const frameError = gatewayErrorFrameMessage(ev); + if (frameError !== null) { + throw new AcceptedThenFailedError(`upstream error mid-stream: ${frameError}`, this.result().text); + } if (typeof ev?.model === "string" && ev.model) this.servedModel = ev.model; const u = ev?.usage; if (u && typeof u.prompt_tokens === "number" && typeof u.completion_tokens === "number") { @@ -171,7 +243,7 @@ class ChatAccumulator { if (choice?.finish_reason) this.finishReason = choice.finish_reason; } - result(): Omit { + result(): Omit { return { text: this.text || this.reasoning, finishReason: this.finishReason, servedModel: this.servedModel, usage: this.usage }; } } @@ -187,7 +259,7 @@ class ChatAccumulator { export async function assembleSseChatStream( resp: { body: ReadableStream | null }, idleTimeoutMs = 120_000, -): Promise> { +): Promise> { if (!resp.body) throw new AcceptedThenFailedError("streaming response had no body"); const reader = resp.body.getReader(); const decoder = new TextDecoder(); @@ -233,7 +305,7 @@ export async function assembleChatFrames( frames: AsyncIterator | AsyncIterable, idleTimeoutMs = 120_000, acc = new ChatAccumulator(), -): Promise> { +): Promise> { const it: AsyncIterator = Symbol.asyncIterator in (frames as object) ? (frames as AsyncIterable)[Symbol.asyncIterator]() : (frames as AsyncIterator); @@ -261,7 +333,7 @@ export async function assembleChatFrames( * 4000 max_tokens, 125s of keepalives, 0 chars). Returning "" would be * indistinguishable from success; say what happened and what to change. */ -function rejectEmptyLength(model: string, out: Omit): void { +function rejectEmptyLength(model: string, out: Omit): void { if (!out.text && out.finishReason === "length") { throw new AcceptedThenFailedError( `${model} spent the entire max_tokens budget on internal reasoning and produced no visible answer. ` + @@ -275,13 +347,16 @@ function rejectEmptyLength(model: string, out: Omit): * its type: a body-read error, a JSON parse error on a non-SSE answer, the * assembler's own throws. Wrap it so the caller can tell it from a refusal. */ -async function afterAccept(run: () => Promise): Promise { +async function afterAccept(run: () => Promise, settlementTx: string | null = null): Promise { try { return await run(); } catch (err) { - if (err instanceof AcceptedThenFailedError) throw err; + if (err instanceof AcceptedThenFailedError) { + err.settlementTx ??= settlementTx; + throw err; + } const msg = err instanceof Error ? err.message : String(err); - throw new AcceptedThenFailedError(msg, "", { cause: err }); + throw new AcceptedThenFailedError(msg, "", { cause: err, settlementTx }); } } @@ -301,6 +376,7 @@ export async function streamChatText( // IS the acceptance — money has moved (wallet) or will be billed (account). const resp = await client.chatCompletionStream(model, messages, options); const settledUsd = settledCostFromHeaders(resp.headers); + const settlementTx = settlementTxFromHeaders(resp.headers); return afterAccept(async () => { // A provider/route that ignores `stream:true` answers with a plain JSON // completion. Feeding that to the SSE parser would "succeed" with an empty @@ -312,12 +388,12 @@ export async function streamChatText( acc.fold(data); const out = acc.result(); rejectEmptyLength(model, out); - return { ...out, settledUsd }; + return { ...out, settledUsd, settlementTx }; } const out = await assembleSseChatStream(resp, idleTimeoutMs); rejectEmptyLength(model, out); - return { ...out, settledUsd }; - }); + return { ...out, settledUsd, settlementTx }; + }, settlementTx); } /** @@ -376,17 +452,26 @@ export async function completeChat( if (!out.text && !out.finishReason && !out.servedModel) { throw new AcceptedThenFailedError(`${model}: the paid stream ended with no frames — the route answered a streaming request with a body this client could not read`); } - return { ...out, settledUsd: null }; + // SolanaLLMClient.stream() does not expose the response headers, so a + // frame stream never proves its settlement here. + return { ...out, settledUsd: null, settlementTx: null }; }); } const r = await client.chatCompletion(model, messages as unknown as Parameters[1], options); + // The SDK lifts the receipt off the non-streaming response into + // `settlement` (its readSettlement): `transaction` only when one was named, + // `success: false` when the receipt said so. + const settlement = (r as { settlement?: { transaction?: unknown; success?: unknown } }).settlement; + const settlementTx = settlement?.success !== false && typeof settlement?.transaction === "string" && settlement.transaction.trim() + ? settlement.transaction.trim() + : null; return afterAccept(async () => { const acc = new ChatAccumulator(); acc.fold(r); const out = acc.result(); rejectEmptyLength(model, out); - return { ...out, settledUsd: null }; - }); + return { ...out, settledUsd: null, settlementTx }; + }, settlementTx); } // --------------------------------------------------------------------------- diff --git a/test/chat-anthropic-settled.test.ts b/test/chat-anthropic-settled.test.ts index d89be76..1afe384 100644 --- a/test/chat-anthropic-settled.test.ts +++ b/test/chat-anthropic-settled.test.ts @@ -43,7 +43,7 @@ function message(over: Partial = {}): Anthropic.Message { } as Anthropic.Message; } -type FakeScript = { connect: boolean; events?: number; result: Anthropic.Message | Error }; +type FakeScript = { connect: boolean; events?: number; result: Anthropic.Message | Error; headers?: Record }; let lastOptions: Record | undefined; let lastParams: Record | undefined; @@ -61,7 +61,7 @@ function fakeClient(s: FakeScript) { on(ev: string, cb: (...a: unknown[]) => void) { (listeners[ev] ??= []).push(cb); return self; }, off() { return self; }, abort() {}, - response: null, + response: s.headers ? new Response(null, { headers: s.headers }) : null, finalMessage: async () => { await Promise.resolve(); if (s.connect) for (const cb of listeners.connect ?? []) cb(); @@ -121,16 +121,53 @@ test("a 5xx/524 with no stream connected MAY have settled — booked at the reco assert.doesNotMatch(res.content[0].text, /needs funding/, res.content[0].text); }); -test("a failure AFTER the stream connected is a settled call — booked, and said plainly", async () => { +// Connected is not settled: the gateway settles a stream after its headers, +// and its enterprise local-trust path can serve a payment that never settles +// (2026-11-12). Without a receipt the quote is booked as a maybe. +test("a failure AFTER the stream connected, with no settlement receipt, is booked as a maybe", async () => { const budget = newBudget(); const client = fakeClient({ connect: true, events: 2, result: sdkError(undefined, "Connection error.", new Error("terminated")) }); const res = await handleAnthropicNative({ client: client as never, ...baseArgs, budget }); assert.equal(res.isError, true); assert.ok(budget.spent > 0, `booked ${budget.spent}`); - assert.match(res.content[0].text, /charge stands/, res.content[0].text); + assert.match(res.content[0].text, /cannot tell whether the gateway settled it/, res.content[0].text); + assert.doesNotMatch(res.content[0].text, /charge stands/, res.content[0].text); assert.match(res.content[0].text, /second charge/i, res.content[0].text); }); +test("a failure AFTER the stream connected, with a settlement receipt, says the charge stands", async () => { + const budget = newBudget(); + const client = fakeClient({ connect: true, events: 2, headers: { "X-Payment-Receipt": "0xfeed" }, result: sdkError(undefined, "Connection error.", new Error("terminated")) }); + const res = await handleAnthropicNative({ client: client as never, ...baseArgs, budget }); + assert.equal(res.isError, true); + assert.ok(budget.spent > 0, `booked ${budget.spent}`); + assert.match(res.content[0].text, /charge stands/, res.content[0].text); +}); + +test("a native success with no settlement receipt is booked but labelled an estimate", async () => { + const budget = newBudget(); + const client = fakeClient({ connect: true, events: 1, result: message() }); + const res = await handleAnthropicNative({ client: client as never, ...baseArgs, budget }); + assert.equal(res.isError, undefined, res.content[0].text); + const expected = anthropicCallCost("claude-haiku-4-5-20251001", 2, 1024)!; + assert.ok(near(budget.spent, expected), `booked ${budget.spent}, expected ${expected}`); + const sc = res.structuredContent as Record; + assert.equal(sc.cost_is_estimate, true, "no receipt, so the cost must not be called final"); + assert.equal(sc.settlement_tx, undefined); + assert.match(res.content[0].text, /not confirmed as settled/, res.content[0].text); +}); + +test("a native success whose response names its settlement is final", async () => { + const budget = newBudget(); + const receipt = Buffer.from(JSON.stringify({ success: true, transaction: "0xabc123", network: "eip155:8453" })).toString("base64"); + const client = fakeClient({ connect: true, events: 1, headers: { "PAYMENT-RESPONSE": receipt }, result: message() }); + const res = await handleAnthropicNative({ client: client as never, ...baseArgs, budget }); + const sc = res.structuredContent as Record; + assert.equal(sc.cost_is_estimate, false); + assert.equal(sc.settlement_tx, "0xabc123"); + assert.doesNotMatch(res.content[0].text, /not confirmed as settled/, res.content[0].text); +}); + test("a thinking budget above the SDK's non-streaming ceiling is sent, not refused in-process (D51)", async () => { const client = fakeClient({ connect: true, events: 1, result: message() }); const res = await handleAnthropicNative({ @@ -263,7 +300,7 @@ test("partial text streamed before a post-acceptance failure rides along on the }; const res = await handleAnthropicNative({ client: client as never, ...baseArgs, budget }); assert.equal(res.isError, true); - assert.match(res.content[0].text, /charge stands/); + assert.match(res.content[0].text, /cannot tell whether the gateway settled it/); assert.match(res.content[0].text, /Partial response received before the failure/); assert.match(res.content[0].text, /The first half of the ans/); assert.equal((res.structuredContent as { partial_response?: string } | undefined)?.partial_response, "The first half of the ans"); diff --git a/test/chat-settled-on-error.test.ts b/test/chat-settled-on-error.test.ts index dda2400..48c4a0d 100644 --- a/test/chat-settled-on-error.test.ts +++ b/test/chat-settled-on-error.test.ts @@ -73,6 +73,11 @@ function makeHarness(limit = 100) { return { budget, call: (args: Record) => handler!(args) }; } +// Since 0.55.10 the counter is not proof of settlement: the SDK adds the +// signed amount on any paid 2xx, and the gateway can serve a call it then +// fails to settle (its enterprise local-trust path, 2026-11-12). A failure with +// no settlement receipt is booked at the delta as a MAYBE — same ledger entry, +// same "do not retry", but the text no longer says the charge stands. test("a settled-then-failed chat is BOOKED, not silently forgotten", async () => { script = new Map([["openai/gpt-5.6-terra", { settleUsd: 0.0412, fail: true }]]); attempts = []; @@ -91,7 +96,9 @@ test("a settled-then-failed chat is BOOKED, not silently forgotten", async () => // text on this path was a bare "Error: stream failed…" — indistinguishable from // a free failure, so the obvious next step (retry) settled a second payment. // The routing loop has said this since 0.40.1; the two direct paths never did. - assert.match(res.content[0].text, /charge stands \(\$0\.041200\)/, res.content[0].text); + assert.match(res.content[0].text, /cannot tell whether the gateway settled it/, res.content[0].text); + assert.match(res.content[0].text, /\$0\.041200 has been recorded against your budget as a precaution/, res.content[0].text); + assert.doesNotMatch(res.content[0].text, /charge stands/, "no receipt came back, so the charge is not certain"); assert.match(res.content[0].text, /second charge/i, res.content[0].text); // The note says "payment". Fed to formatError's keyword classifier that reads // as an empty wallet — "needs funding" is the exact wrong advice for a call @@ -113,7 +120,7 @@ test("a multi-turn chat that settled and then failed says so too", async () => { }); assert.equal(res.isError, true); - assert.match(res.content[0].text, /charge stands \(\$0\.030800\)/, res.content[0].text); + assert.match(res.content[0].text, /\$0\.030800 has been recorded against your budget as a precaution/, res.content[0].text); assert.doesNotMatch(res.content[0].text, /needs funding/, res.content[0].text); assert.ok(Math.abs(budget.spent - 0.0308) < 1e-9, `booked ${budget.spent}`); }); @@ -153,7 +160,7 @@ test("the routing loop stops after a payment settles — one reservation, one ch "once a payment has settled the loop must stop — retrying charges the caller twice for one tool call", ); assert.equal(res.isError, true); - assert.match(res.content[0].text, /charge stands \(\$0\.021700\)/, res.content[0].text); + assert.match(res.content[0].text, /\$0\.021700 has been recorded against your budget as a precaution/, res.content[0].text); assert.match(res.content[0].text, /No fallback model was tried/, res.content[0].text); // Same classifier trap as the direct paths: this note used to end in "your // wallet needs funding" because formatError saw the word "payment" in it. diff --git a/test/chat-wallet-after-payment.test.ts b/test/chat-wallet-after-payment.test.ts index e39c121..9d9aa9a 100644 --- a/test/chat-wallet-after-payment.test.ts +++ b/test/chat-wallet-after-payment.test.ts @@ -27,8 +27,10 @@ import assert from "node:assert/strict"; import type { BudgetState } from "../src/types.js"; import { MODEL_TIERS } from "../src/utils/constants.js"; -type Fail = false | "unpaid-400" | "after-payment-524" | "after-payment-400" | "abort" | "midstream"; -type Attempt = { settleUsd: number; fail: Fail; served?: string; finish?: string; text?: string }; +type Fail = false | "unpaid-400" | "after-payment-524" | "after-payment-400" | "abort" | "midstream" | "error-frame" | "error-frame-after-text"; +// `receipt`: headers the paid response carries (Base only — SolanaLLMClient's +// frame stream exposes none). The real gateway sends none on a stream. +type Attempt = { settleUsd: number; fail: Fail; served?: string; finish?: string; text?: string; receipt?: Record }; let script: Map; let attempts: string[]; let chain: "base" | "solana" = "solana"; @@ -52,8 +54,14 @@ function paidRetry(model: string, total: { usd: number }): { a: Attempt; frames: // A 200: the SDK counts the settlement before the body is read. total.usd += a.settleUsd; const served = a.served ?? model; + // The gateway's sseError frame, verbatim in shape (blockrun src/lib/ai-stream.ts). + const errorFrame = { id: "error-1762905600000", object: "chat.completion.chunk", created: 1762905600, model: "error", choices: [{ index: 0, delta: { content: "\n\n[Error: The upstream provider failed before completing the response.]" }, finish_reason: "stop" }] }; const frames: unknown[] = a.fail === "midstream" ? [{ model: served, choices: [{ delta: { content: "partial " } }] }, { error: { message: "upstream exploded" } }] + : a.fail === "error-frame" + ? [errorFrame] + : a.fail === "error-frame-after-text" + ? [{ model: served, choices: [{ delta: { content: "partial " } }] }, errorFrame] : [ { model: served, choices: [{ delta: { content: a.text ?? `reply from ${model}` }, finish_reason: null }] }, { model: served, choices: [{ delta: {}, finish_reason: a.finish ?? "stop" }] }, @@ -90,11 +98,11 @@ function makeBaseFake() { return { model: a.served ?? model, choices: [{ message: { content: a.text ?? `reply from ${model}` }, finish_reason: a.finish ?? "stop" }] }; }, chatCompletionStream: async (model: string) => { - const { frames } = paidRetry(model, total); + const { a, frames } = paidRetry(model, total); const lines = frames.map((f) => `data: ${JSON.stringify(f)}\n\n`).concat("data: [DONE]\n\n"); return new Response( new ReadableStream({ start(c) { for (const l of lines) c.enqueue(enc.encode(l)); c.close(); } }), - { status: 200, headers: { "content-type": "text/event-stream" } }, + { status: 200, headers: { "content-type": "text/event-stream", ...(a.receipt ?? {}) } }, ); }, }; @@ -216,7 +224,7 @@ for (const c of ["solana", "base"] as const) { assert.equal(budget.spent, 0); }); - test(`${c}: a stream that settled and then died books the counter's delta and keeps the partial text`, async () => { + test(`${c}: a paid stream that died, with no settlement receipt, books the counter's delta as a maybe and keeps the partial text`, async () => { chain = c; script = new Map([["openai/gpt-5.6-terra", { settleUsd: 0.0412, fail: "midstream" }]]); attempts = []; @@ -226,11 +234,70 @@ for (const c of ["solana", "base"] as const) { assert.equal(res.isError, true); assert.ok(near(budget.spent, 0.0412), `booked ${budget.spent}`); - assert.match(res.content[0].text, /charge stands \(\$0\.041200\)/, res.content[0].text); + assert.match(res.content[0].text, /cannot tell whether the gateway settled it/, res.content[0].text); + assert.match(res.content[0].text, /\$0\.041200 has been recorded against your budget as a precaution/, res.content[0].text); + assert.doesNotMatch(res.content[0].text, /charge stands/, res.content[0].text); assert.match(res.content[0].text, /partial /, "the paid partial text must be surfaced"); assert.equal(res.structuredContent?.partial_response, "partial "); }); + // 2026-11-12: a wallet holding $0.001 was served four zai/glm-5.3 calls on + // the gateway's enterprise local-trust path; none settled, and every one was + // reported cost_is_estimate:false. A paid stream carries no settlement + // receipt (the gateway settles after the headers), so its cost is booked — + // the budget counts a maybe-charge — but never called final. + test(`${c}: a paid stream with no settlement receipt is booked but labelled an estimate`, async () => { + chain = c; + script = new Map([["zai/glm-5.3", { settleUsd: 0.026, fail: false, text: "glm answer" }]]); + attempts = []; + const { budget, call } = makeHarness(); + + const res = await call({ message: "hi", model: "zai/glm-5.3", max_tokens: 1024, temperature: 1 }); + + assert.equal(res.isError, undefined, res.content[0].text); + assert.ok(near(budget.spent, 0.026), `the budget still counts it; booked ${budget.spent}`); + assert.equal(res.structuredContent?.cost_usd, 0.026); + assert.equal(res.structuredContent?.cost_is_estimate, true, "nothing proved a settlement"); + assert.equal(res.structuredContent?.settlement_tx, undefined); + assert.match(res.content[0].text, /not confirmed as settled/, res.content[0].text); + }); + + // The gateway's in-band error frame (model:"error", "[Error: …]" content) + // used to be read as the answer: served_model "error", a success, a final cost. + test(`${c}: the gateway's error frame is a failure, booked as a maybe, never a final cost`, async () => { + chain = c; + script = new Map([ + ["deepseek/deepseek-v4-pro", { settleUsd: 0.04443, fail: "error-frame" }], + ["qwen/qwen3.7-flash", { settleUsd: 0.0031, fail: false }], + ]); + attempts = []; + const { budget, call } = makeHarness(); + + const res = await call({ message: "hi", mode: "cheap", max_tokens: 1024, temperature: 1 }); + + assert.equal(res.isError, true, res.content[0].text); + assert.deepEqual(attempts, ["deepseek/deepseek-v4-pro"], "a payment that may have settled must not be followed by a second one"); + assert.notEqual(res.structuredContent?.served_model, "error"); + assert.equal(res.structuredContent?.cost_is_estimate, undefined, "a failure reports no final cost"); + assert.ok(near(budget.spent, 0.04443), `booked as a precaution; got ${budget.spent}`); + assert.match(res.content[0].text, /upstream provider failed/, res.content[0].text); + assert.match(res.content[0].text, /cannot tell whether the gateway settled it/, res.content[0].text); + assert.doesNotMatch(res.content[0].text, /charge stands/, res.content[0].text); + }); + + test(`${c}: an error frame after some text keeps the partial text`, async () => { + chain = c; + script = new Map([["openai/gpt-5.6-terra", { settleUsd: 0.0412, fail: "error-frame-after-text" }]]); + attempts = []; + const { call } = makeHarness(); + + const res = await call({ message: "hi", model: "openai/gpt-5.6-terra", max_tokens: 1024, temperature: 1 }); + + assert.equal(res.isError, true); + assert.equal(res.structuredContent?.partial_response, "partial "); + assert.doesNotMatch(String(res.structuredContent?.partial_response), /\[Error:/); + }); + test(`${c}: a paid call that streams to completion books the settled delta once and reports what served`, async () => { chain = c; script = new Map([["openai/gpt-5.6-terra", { settleUsd: 0.0217, fail: false, served: "openai/gpt-5.6-terra", text: "done" }]]); @@ -249,6 +316,50 @@ for (const c of ["solana", "base"] as const) { }); } +test("base: a paid stream whose response names its settlement transaction is final", async () => { + chain = "base"; + const receipt = Buffer.from(JSON.stringify({ success: true, transaction: "0xabc123", network: "eip155:8453" })).toString("base64"); + script = new Map([["openai/gpt-5.6-terra", { settleUsd: 0.0217, fail: false, text: "done", receipt: { "PAYMENT-RESPONSE": receipt } }]]); + attempts = []; + const { budget, call } = makeHarness(); + + const res = await call({ message: "hi", model: "openai/gpt-5.6-terra", max_tokens: 1024, temperature: 1 }); + + assert.equal(res.isError, undefined, res.content[0].text); + assert.ok(near(budget.spent, 0.0217), `booked ${budget.spent}`); + assert.equal(res.structuredContent?.cost_is_estimate, false); + assert.equal(res.structuredContent?.settlement_tx, "0xabc123"); + assert.doesNotMatch(res.content[0].text, /not confirmed as settled/, res.content[0].text); +}); + +test("base: a receipt the gateway marks unsettled is not proof", async () => { + // The gateway's non-streaming route writes success:true with an EMPTY + // transaction plus X-Payment-Settled: false when its settle failed and it + // served anyway. + chain = "base"; + const receipt = Buffer.from(JSON.stringify({ success: true, transaction: "", network: "eip155:8453" })).toString("base64"); + script = new Map([["openai/gpt-5.6-terra", { settleUsd: 0.0217, fail: false, text: "done", receipt: { "PAYMENT-RESPONSE": receipt, "X-Payment-Settled": "false" } }]]); + attempts = []; + const { call } = makeHarness(); + + const res = await call({ message: "hi", model: "openai/gpt-5.6-terra", max_tokens: 1024, temperature: 1 }); + + assert.equal(res.structuredContent?.cost_is_estimate, true); +}); + +test("base: a paid stream that died with a settlement receipt says the charge stands", async () => { + chain = "base"; + script = new Map([["openai/gpt-5.6-terra", { settleUsd: 0.0412, fail: "midstream", receipt: { "X-Payment-Receipt": "0xfeed" } }]]); + attempts = []; + const { budget, call } = makeHarness(); + + const res = await call({ message: "hi", model: "openai/gpt-5.6-terra", max_tokens: 1024, temperature: 1 }); + + assert.equal(res.isError, true); + assert.ok(near(budget.spent, 0.0412), `booked ${budget.spent}`); + assert.match(res.content[0].text, /charge stands \(\$0\.041200\)/, res.content[0].text); +}); + test("D55: an aliased id is reported under the id that actually answered", async () => { chain = "solana"; script = new Map([["moonshot/kimi-k2.6", { settleUsd: 0.0105, fail: false, served: "moonshot/kimi-k3", text: "kimi here" }]]);