@blockrun/llm 3.14.3 → 3.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -243,9 +243,12 @@ function sanitizeErrorResponse(errorBody) {
243
243
  return { message: "API request failed" };
244
244
  }
245
245
  const body = errorBody;
246
+ const detail = typeof body.message === "string" && body.message !== body.error ? body.message : void 0;
246
247
  return {
247
248
  message: typeof body.error === "string" ? body.error : "API request failed",
248
- code: typeof body.code === "string" ? body.code : void 0
249
+ code: typeof body.code === "string" ? body.code : void 0,
250
+ ...detail ? { detail } : {},
251
+ ...typeof body.hint === "string" ? { hint: body.hint } : {}
249
252
  };
250
253
  }
251
254
  function validateResourceUrl(url, baseUrl) {
@@ -275,6 +278,21 @@ function validateResourceUrl(url, baseUrl) {
275
278
  var TRANSIENT_STATUS = /* @__PURE__ */ new Set([502, 503, 504, 522, 524]);
276
279
  var TRANSIENT_RETRIES = 2;
277
280
  var TRANSIENT_BACKOFF_MS = 1e3;
281
+ function retryDelay(ms, signal) {
282
+ signal?.throwIfAborted();
283
+ return new Promise((resolve, reject) => {
284
+ const onAbort = () => {
285
+ clearTimeout(timer);
286
+ signal?.removeEventListener("abort", onAbort);
287
+ reject(signal?.reason);
288
+ };
289
+ const timer = setTimeout(() => {
290
+ signal?.removeEventListener("abort", onAbort);
291
+ resolve();
292
+ }, ms);
293
+ signal?.addEventListener("abort", onAbort, { once: true });
294
+ });
295
+ }
278
296
  var API_KEY_URL = "https://api.blockrun.ai";
279
297
  var PORTAL_URL = "https://user.blockrun.ai";
280
298
  function resolveApiKeyAuth(options) {
@@ -324,6 +342,7 @@ var ApiKeyAuth = class {
324
342
  headers.set("authorization", `Bearer ${this.#key}`);
325
343
  const method = (init?.method ?? request?.method ?? "GET").toUpperCase();
326
344
  const retries = method === "GET" || method === "HEAD" ? TRANSIENT_RETRIES : 0;
345
+ const signal = init?.signal ?? request?.signal;
327
346
  let response;
328
347
  for (let attempt = 0; ; attempt++) {
329
348
  response = await globalThis.fetch(request ? new Request(url, request) : url, {
@@ -332,7 +351,8 @@ var ApiKeyAuth = class {
332
351
  redirect: "error"
333
352
  });
334
353
  if (attempt >= retries || !TRANSIENT_STATUS.has(response.status)) break;
335
- await new Promise((resolve) => setTimeout(resolve, TRANSIENT_BACKOFF_MS * (attempt + 1)));
354
+ await response.body?.cancel();
355
+ await retryDelay(TRANSIENT_BACKOFF_MS * (attempt + 1), signal);
336
356
  }
337
357
  if (raiseErrors && !response.ok) {
338
358
  let body;
@@ -4600,7 +4620,7 @@ function getCostSummary() {
4600
4620
  }
4601
4621
 
4602
4622
  // src/version.ts
4603
- var SDK_VERSION = "3.14.3";
4623
+ var SDK_VERSION = "3.15.1";
4604
4624
  var USER_AGENT = `blockrun-ts/${SDK_VERSION}`;
4605
4625
 
4606
4626
  // src/client.ts
@@ -6861,7 +6881,7 @@ var VideoClient = class {
6861
6881
  * Submits an async job, then polls until the video is ready. Typical total
6862
6882
  * wall-time is 60-180s, but upstream status can lag several minutes behind
6863
6883
  * actual completion. If upstream runs past the budget (default 15min),
6864
- * throws without charging — the job stays claimable ~48h via poll_url.
6884
+ * throws with the existing poll URL; check billing before submitting again.
6865
6885
  *
6866
6886
  * @param prompt - Text description of the video
6867
6887
  * @param options - Optional generation parameters
@@ -7061,7 +7081,7 @@ var VideoClient = class {
7061
7081
  }
7062
7082
  }
7063
7083
  throw new APIError(
7064
- `Video generation did not complete within ${Math.round(budgetMs / 1e3)}s (last status: ${lastStatus}). No payment was taken. The job is NOT lost: it stays claimable for ~48h \u2014 re-GET poll_url with a fresh signature from the same wallet to fetch (and settle) the finished video.`,
7084
+ `Video generation did not complete within ${Math.round(budgetMs / 1e3)}s (last status: ${lastStatus}). A polling timeout does not confirm billing status. Resume the existing poll_url using the same account or wallet; check account Activity or wallet receipts before submitting another job.`,
7065
7085
  504,
7066
7086
  { id: submitData.id, last_status: lastStatus, poll_url: pollUrl }
7067
7087
  );
@@ -8553,6 +8573,35 @@ var RpcClient = class {
8553
8573
  }
8554
8574
  };
8555
8575
 
8576
+ // src/sse.ts
8577
+ async function* readSseFrames(response, onMissingBody) {
8578
+ if (!response.body) throw onMissingBody(response.status);
8579
+ const reader = response.body.getReader();
8580
+ const decoder = new TextDecoder();
8581
+ let buffer = "";
8582
+ try {
8583
+ while (true) {
8584
+ const { done, value } = await reader.read();
8585
+ if (done) break;
8586
+ buffer += decoder.decode(value, { stream: true });
8587
+ const lines = buffer.split("\n");
8588
+ buffer = lines.pop() || "";
8589
+ for (const line of lines) {
8590
+ const trimmed = line.trim();
8591
+ if (!trimmed || !trimmed.startsWith("data: ")) continue;
8592
+ const data = trimmed.slice(6);
8593
+ if (data === "[DONE]") return;
8594
+ try {
8595
+ yield JSON.parse(data);
8596
+ } catch {
8597
+ }
8598
+ }
8599
+ }
8600
+ } finally {
8601
+ reader.releaseLock();
8602
+ }
8603
+ }
8604
+
8556
8605
  // src/blockrun.ts
8557
8606
  var import_accounts14 = require("viem/accounts");
8558
8607
  var DEFAULT_API_URL13 = "https://blockrun.ai/api";
@@ -8762,33 +8811,10 @@ var BlockrunClient = class {
8762
8811
  await this.throwApiError(resp402, `stream failed (${url})`);
8763
8812
  return;
8764
8813
  }
8765
- if (!streamResp.body) {
8766
- throw new APIError("Stream response has no body", streamResp.status, {});
8767
- }
8768
- const reader = streamResp.body.getReader();
8769
- const decoder = new TextDecoder();
8770
- let buffer = "";
8771
- try {
8772
- while (true) {
8773
- const { done, value } = await reader.read();
8774
- if (done) break;
8775
- buffer += decoder.decode(value, { stream: true });
8776
- const lines = buffer.split("\n");
8777
- buffer = lines.pop() || "";
8778
- for (const line of lines) {
8779
- const trimmed = line.trim();
8780
- if (!trimmed || !trimmed.startsWith("data: ")) continue;
8781
- const data = trimmed.slice(6);
8782
- if (data === "[DONE]") return;
8783
- try {
8784
- yield JSON.parse(data);
8785
- } catch {
8786
- }
8787
- }
8788
- }
8789
- } finally {
8790
- reader.releaseLock();
8791
- }
8814
+ yield* readSseFrames(
8815
+ streamResp,
8816
+ (status2) => new APIError("Stream response has no body", status2, {})
8817
+ );
8792
8818
  }
8793
8819
  // --------------------------------------------------------------------
8794
8820
  // Internal: shared infrastructure
@@ -9719,6 +9745,123 @@ var SolanaLLMClient = class {
9719
9745
  response.routing = decision;
9720
9746
  return { response, model: decision.model, routing: decision };
9721
9747
  }
9748
+ /**
9749
+ * Stream a Server-Sent Events endpoint, paid on Solana.
9750
+ *
9751
+ * The Solana counterpart to `BlockrunClient.stream`, and the reason it had to
9752
+ * exist: a streaming harness cannot use this client at all without it, so
9753
+ * "BlockRun supports Solana" stopped being true the moment a caller streamed.
9754
+ * `chatCompletion` buffers the whole answer, which is the wrong shape for an
9755
+ * agent loop and for anything that shows tokens as they arrive.
9756
+ *
9757
+ * The handshake is the one the non-streaming paths use — `402`, sign an SPL
9758
+ * TransferChecked authorization locally, replay with `PAYMENT-SIGNATURE` —
9759
+ * with the same verification-phase re-sign on a stale blockhash. What differs
9760
+ * is that the paid response is not read as JSON: it is handed to the SSE
9761
+ * reader with its body untouched.
9762
+ *
9763
+ * A `200` on the first request is returned as-is and settles nothing. That is
9764
+ * the free tier (the gateway answers a `billing_mode: "free"` model without a
9765
+ * 402 at all) and it is also API-key mode, where billing is on the account
9766
+ * rather than on a wallet.
9767
+ *
9768
+ * Yields each `data:` frame parsed as JSON, and stops at `data: [DONE]`.
9769
+ * Malformed frames are skipped rather than thrown — see {@link readSseFrames}.
9770
+ *
9771
+ * @example
9772
+ * for await (const chunk of client.stream<ChatChunk>("/v1/chat/completions", {
9773
+ * model: "deepseek/deepseek-chat",
9774
+ * messages: [{ role: "user", content: "Hi" }],
9775
+ * stream: true,
9776
+ * })) {
9777
+ * process.stdout.write(chunk.choices?.[0]?.delta?.content ?? "");
9778
+ * }
9779
+ *
9780
+ * @param path - endpoint after the API root; a leading `/api` is tolerated.
9781
+ * @param body - JSON request body. Set `stream: true` yourself — this method
9782
+ * does not inject it, because the gateway prices a streaming and a
9783
+ * non-streaming request the same and silently rewriting a caller's body is
9784
+ * how you end up debugging a request you did not send.
9785
+ * @returns each decoded SSE frame, in order.
9786
+ */
9787
+ async *stream(path6, body) {
9788
+ const url = this.buildUrl(path6);
9789
+ const response = await this.openPaidStream(url, JSON.stringify(body ?? {}));
9790
+ yield* readSseFrames(
9791
+ response,
9792
+ (status2) => new APIError("Stream response has no body", status2, {})
9793
+ );
9794
+ }
9795
+ /**
9796
+ * Get to a streaming response, paying for it if the gateway asks.
9797
+ *
9798
+ * Separate from {@link SolanaLLMClient.stream} because a generator cannot
9799
+ * retry cleanly around a `yield`: the stale-blockhash re-sign has to finish
9800
+ * before the first frame is handed out, and putting the loop here keeps the
9801
+ * payment decision entirely ahead of any output the caller has seen.
9802
+ *
9803
+ * @param url - resolved endpoint URL.
9804
+ * @param requestBody - the serialized body, reused verbatim on the paid retry
9805
+ * so the gateway prices and answers the same request it quoted for.
9806
+ * @returns a response whose body has not been read.
9807
+ */
9808
+ async openPaidStream(url, requestBody) {
9809
+ for (let staleRetries = 0; ; ) {
9810
+ const response = await this.fetchWithTimeout(url, {
9811
+ method: "POST",
9812
+ headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT },
9813
+ body: requestBody
9814
+ });
9815
+ if (response.ok) return response;
9816
+ if (response.status !== 402) {
9817
+ let errorBody;
9818
+ try {
9819
+ errorBody = await response.json();
9820
+ } catch {
9821
+ errorBody = { error: "Request failed" };
9822
+ }
9823
+ throw new APIError(`API error: ${response.status}`, response.status, sanitizeErrorResponse(errorBody));
9824
+ }
9825
+ try {
9826
+ const { paymentPayload, costUsd } = await this.signPaymentFrom402(
9827
+ url,
9828
+ response,
9829
+ staleRetries > 0
9830
+ );
9831
+ const paid = await this.fetchWithTimeout(url, {
9832
+ method: "POST",
9833
+ headers: {
9834
+ "Content-Type": "application/json",
9835
+ "User-Agent": USER_AGENT,
9836
+ "PAYMENT-SIGNATURE": paymentPayload
9837
+ },
9838
+ body: requestBody
9839
+ });
9840
+ await this.assertPaid(paid);
9841
+ this.recordSettlement(costUsd);
9842
+ return paid;
9843
+ } catch (error) {
9844
+ if (!(error instanceof SafeStaleBlockhashError) || staleRetries >= STALE_BLOCKHASH_RETRY_BACKOFFS_MS.length) throw error;
9845
+ await waitForStaleRetry(staleRetries++);
9846
+ continue;
9847
+ }
9848
+ }
9849
+ }
9850
+ /**
9851
+ * Resolve an endpoint path against this client's API root.
9852
+ *
9853
+ * A leading `/api` is stripped for the same reason `BlockrunClient` strips
9854
+ * it: the documented paths are written `/api/v1/…` on the website and
9855
+ * `/v1/…` in this SDK, and a caller who copies one into the other should get
9856
+ * their request rather than a 404.
9857
+ * @param path - endpoint path, with or without a leading slash.
9858
+ * @returns the absolute URL to call.
9859
+ */
9860
+ buildUrl(path6) {
9861
+ let normalized = path6.startsWith("/") ? path6 : `/${path6}`;
9862
+ if (normalized.startsWith("/api/")) normalized = normalized.slice(4);
9863
+ return `${this.apiUrl}${normalized}`;
9864
+ }
9722
9865
  /** List available models. */
9723
9866
  async listModels() {
9724
9867
  const response = await this.fetchWithTimeout(`${this.apiUrl}/v1/models`, { method: "GET" });
@@ -9989,7 +10132,25 @@ var SolanaLLMClient = class {
9989
10132
  return response.json();
9990
10133
  }
9991
10134
  }
9992
- async handlePaymentAndRetry(url, body, response, forceFreshBlockhash = false) {
10135
+ /**
10136
+ * Turn a `402` into a signed Solana payment payload.
10137
+ *
10138
+ * Extracted because four call sites need it — chat, the raw POST helpers, the
10139
+ * raw GET helper, and {@link SolanaLLMClient.stream} — and it had been
10140
+ * written out three times before this. That mattered more than ordinary
10141
+ * duplication: this is the code that signs a transfer of the caller's USDC,
10142
+ * so three copies meant every fix to it had to be applied three times or
10143
+ * quietly apply to two thirds of the paths.
10144
+ *
10145
+ * @param url - the request being paid for.
10146
+ * @param response - the gateway's `402`, not yet consumed.
10147
+ * @param forceFreshBlockhash - set on a re-sign after a stale-blockhash
10148
+ * rejection, so the retry cannot produce byte-identical transaction bytes.
10149
+ * @param resourceFallback - resource URL to claim when the 402 states none.
10150
+ * @returns the header value to replay with, and what it will settle for.
10151
+ * @throws PaymentError when the 402 carries no usable Solana requirements.
10152
+ */
10153
+ async signPaymentFrom402(url, response, forceFreshBlockhash, resourceFallback = url) {
9993
10154
  let paymentHeader = response.headers.get("payment-required");
9994
10155
  if (!paymentHeader) {
9995
10156
  try {
@@ -10023,7 +10184,7 @@ var SolanaLLMClient = class {
10023
10184
  feePayer,
10024
10185
  {
10025
10186
  resourceUrl: validateResourceUrl(
10026
- details.resource?.url || `${this.apiUrl}/v1/chat/completions`,
10187
+ details.resource?.url || resourceFallback,
10027
10188
  this.apiUrl
10028
10189
  ),
10029
10190
  resourceDescription: details.resource?.description || "BlockRun Solana AI API call",
@@ -10035,6 +10196,15 @@ var SolanaLLMClient = class {
10035
10196
  forceFreshBlockhash
10036
10197
  }
10037
10198
  );
10199
+ return { paymentPayload, costUsd: parseFloat(details.amount) / 1e6 };
10200
+ }
10201
+ async handlePaymentAndRetry(url, body, response, forceFreshBlockhash = false) {
10202
+ const { paymentPayload, costUsd } = await this.signPaymentFrom402(
10203
+ url,
10204
+ response,
10205
+ forceFreshBlockhash,
10206
+ `${this.apiUrl}/v1/chat/completions`
10207
+ );
10038
10208
  const retryResponse = await this.fetchWithTimeout(url, {
10039
10209
  method: "POST",
10040
10210
  headers: {
@@ -10044,24 +10214,8 @@ var SolanaLLMClient = class {
10044
10214
  },
10045
10215
  body: JSON.stringify(body)
10046
10216
  });
10047
- if (retryResponse.status === 402) {
10048
- if (await isSafeStaleBlockhashResponse(retryResponse)) {
10049
- throw new SafeStaleBlockhashError();
10050
- }
10051
- throw new PaymentError("Payment was rejected. Check your Solana USDC balance.");
10052
- }
10053
- if (!retryResponse.ok) {
10054
- let errorBody;
10055
- try {
10056
- errorBody = await retryResponse.json();
10057
- } catch {
10058
- errorBody = { error: "Request failed" };
10059
- }
10060
- throw new APIError(`API error after payment: ${retryResponse.status}`, retryResponse.status, sanitizeErrorResponse(errorBody));
10061
- }
10062
- const costUsd = parseFloat(details.amount) / 1e6;
10063
- this.sessionCalls += 1;
10064
- this.sessionTotalUsd += costUsd;
10217
+ await this.assertPaid(retryResponse);
10218
+ this.recordSettlement(costUsd);
10065
10219
  return retryResponse.json();
10066
10220
  }
10067
10221
  async requestWithPaymentRaw(endpoint, body) {
@@ -10095,51 +10249,7 @@ var SolanaLLMClient = class {
10095
10249
  }
10096
10250
  }
10097
10251
  async handlePaymentAndRetryRaw(url, body, response, forceFreshBlockhash = false) {
10098
- let paymentHeader = response.headers.get("payment-required");
10099
- if (!paymentHeader) {
10100
- try {
10101
- const respBody = await response.json();
10102
- if (respBody.accepts || respBody.x402Version) {
10103
- paymentHeader = btoa(JSON.stringify(respBody));
10104
- }
10105
- } catch {
10106
- }
10107
- }
10108
- if (!paymentHeader) {
10109
- throw new PaymentError("402 response but no payment requirements found");
10110
- }
10111
- const paymentRequired = parsePaymentRequired(paymentHeader);
10112
- const details = extractPaymentDetails(paymentRequired, SOLANA_NETWORK);
10113
- if (!details.network?.startsWith("solana:")) {
10114
- throw new PaymentError(
10115
- `Expected Solana payment network, got: ${details.network}. Use LLMClient for Base payments.`
10116
- );
10117
- }
10118
- const feePayer = details.extra?.feePayer;
10119
- if (!feePayer) throw new PaymentError("Missing feePayer in 402 extra field");
10120
- const fromAddress = await this.getWalletAddress();
10121
- const secretKey = await solanaKeyToBytes(this.privateKey);
10122
- const extensions = paymentRequired.extensions;
10123
- const paymentPayload = await createSolanaPaymentPayload(
10124
- secretKey,
10125
- fromAddress,
10126
- details.recipient,
10127
- details.amount,
10128
- feePayer,
10129
- {
10130
- resourceUrl: validateResourceUrl(
10131
- details.resource?.url || url,
10132
- this.apiUrl
10133
- ),
10134
- resourceDescription: details.resource?.description || "BlockRun Solana AI API call",
10135
- maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
10136
- extra: details.extra,
10137
- extensions,
10138
- rpcUrl: this.rpcUrl,
10139
- rpcHeaders: this.rpcHeaders,
10140
- forceFreshBlockhash
10141
- }
10142
- );
10252
+ const { paymentPayload, costUsd } = await this.signPaymentFrom402(url, response, forceFreshBlockhash);
10143
10253
  const retryResponse = await this.fetchWithTimeout(url, {
10144
10254
  method: "POST",
10145
10255
  headers: {
@@ -10149,24 +10259,8 @@ var SolanaLLMClient = class {
10149
10259
  },
10150
10260
  body: JSON.stringify(body)
10151
10261
  });
10152
- if (retryResponse.status === 402) {
10153
- if (await isSafeStaleBlockhashResponse(retryResponse)) {
10154
- throw new SafeStaleBlockhashError();
10155
- }
10156
- throw new PaymentError("Payment was rejected. Check your Solana USDC balance.");
10157
- }
10158
- if (!retryResponse.ok) {
10159
- let errorBody;
10160
- try {
10161
- errorBody = await retryResponse.json();
10162
- } catch {
10163
- errorBody = { error: "Request failed" };
10164
- }
10165
- throw new APIError(`API error after payment: ${retryResponse.status}`, retryResponse.status, sanitizeErrorResponse(errorBody));
10166
- }
10167
- const costUsd = parseFloat(details.amount) / 1e6;
10168
- this.sessionCalls += 1;
10169
- this.sessionTotalUsd += costUsd;
10262
+ await this.assertPaid(retryResponse);
10263
+ this.recordSettlement(costUsd);
10170
10264
  return retryResponse.json();
10171
10265
  }
10172
10266
  async getWithPaymentRaw(endpoint, params) {
@@ -10199,51 +10293,7 @@ var SolanaLLMClient = class {
10199
10293
  }
10200
10294
  }
10201
10295
  async handleGetPaymentAndRetryRaw(url, endpoint, params, response, forceFreshBlockhash = false) {
10202
- let paymentHeader = response.headers.get("payment-required");
10203
- if (!paymentHeader) {
10204
- try {
10205
- const respBody = await response.json();
10206
- if (respBody.accepts || respBody.x402Version) {
10207
- paymentHeader = btoa(JSON.stringify(respBody));
10208
- }
10209
- } catch {
10210
- }
10211
- }
10212
- if (!paymentHeader) {
10213
- throw new PaymentError("402 response but no payment requirements found");
10214
- }
10215
- const paymentRequired = parsePaymentRequired(paymentHeader);
10216
- const details = extractPaymentDetails(paymentRequired, SOLANA_NETWORK);
10217
- if (!details.network?.startsWith("solana:")) {
10218
- throw new PaymentError(
10219
- `Expected Solana payment network, got: ${details.network}. Use LLMClient for Base payments.`
10220
- );
10221
- }
10222
- const feePayer = details.extra?.feePayer;
10223
- if (!feePayer) throw new PaymentError("Missing feePayer in 402 extra field");
10224
- const fromAddress = await this.getWalletAddress();
10225
- const secretKey = await solanaKeyToBytes(this.privateKey);
10226
- const extensions = paymentRequired.extensions;
10227
- const paymentPayload = await createSolanaPaymentPayload(
10228
- secretKey,
10229
- fromAddress,
10230
- details.recipient,
10231
- details.amount,
10232
- feePayer,
10233
- {
10234
- resourceUrl: validateResourceUrl(
10235
- details.resource?.url || url,
10236
- this.apiUrl
10237
- ),
10238
- resourceDescription: details.resource?.description || "BlockRun Solana AI API call",
10239
- maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
10240
- extra: details.extra,
10241
- extensions,
10242
- rpcUrl: this.rpcUrl,
10243
- rpcHeaders: this.rpcHeaders,
10244
- forceFreshBlockhash
10245
- }
10246
- );
10296
+ const { paymentPayload, costUsd } = await this.signPaymentFrom402(url, response, forceFreshBlockhash);
10247
10297
  const query = params ? "?" + new URLSearchParams(params).toString() : "";
10248
10298
  const retryUrl = `${this.apiUrl}${endpoint}${query}`;
10249
10299
  const retryResponse = await this.fetchWithTimeout(retryUrl, {
@@ -10253,25 +10303,44 @@ var SolanaLLMClient = class {
10253
10303
  "PAYMENT-SIGNATURE": paymentPayload
10254
10304
  }
10255
10305
  });
10256
- if (retryResponse.status === 402) {
10257
- if (await isSafeStaleBlockhashResponse(retryResponse)) {
10306
+ await this.assertPaid(retryResponse);
10307
+ this.recordSettlement(costUsd);
10308
+ return retryResponse.json();
10309
+ }
10310
+ /**
10311
+ * Fail a post-payment response, telling a re-signable rejection from a real one.
10312
+ *
10313
+ * A `402` here is not "pay again": it is the gateway refusing the payment we
10314
+ * just signed. Only a rejection the gateway attributes to the VERIFICATION
10315
+ * phase is safe to re-sign — anything settled, or ambiguous about which
10316
+ * phase it failed in, could already have moved USDC, and re-signing it would
10317
+ * pay twice. {@link isSafeStaleBlockhashResponse} is where that judgement
10318
+ * lives.
10319
+ * @param response - the reply to the paid request.
10320
+ * @throws SafeStaleBlockhashError when the caller should re-sign, PaymentError
10321
+ * when it should not, APIError for any other failure.
10322
+ */
10323
+ async assertPaid(response) {
10324
+ if (response.status === 402) {
10325
+ if (await isSafeStaleBlockhashResponse(response)) {
10258
10326
  throw new SafeStaleBlockhashError();
10259
10327
  }
10260
10328
  throw new PaymentError("Payment was rejected. Check your Solana USDC balance.");
10261
10329
  }
10262
- if (!retryResponse.ok) {
10330
+ if (!response.ok) {
10263
10331
  let errorBody;
10264
10332
  try {
10265
- errorBody = await retryResponse.json();
10333
+ errorBody = await response.json();
10266
10334
  } catch {
10267
10335
  errorBody = { error: "Request failed" };
10268
10336
  }
10269
- throw new APIError(`API error after payment: ${retryResponse.status}`, retryResponse.status, sanitizeErrorResponse(errorBody));
10337
+ throw new APIError(`API error after payment: ${response.status}`, response.status, sanitizeErrorResponse(errorBody));
10270
10338
  }
10271
- const costUsd = parseFloat(details.amount) / 1e6;
10339
+ }
10340
+ /** Count one settled x402 payment against the session total. */
10341
+ recordSettlement(costUsd) {
10272
10342
  this.sessionCalls += 1;
10273
10343
  this.sessionTotalUsd += costUsd;
10274
- return retryResponse.json();
10275
10344
  }
10276
10345
  async fetchWithTimeout(url, options) {
10277
10346
  const controller = new AbortController();
@@ -10675,7 +10744,8 @@ var OpenAI = class {
10675
10744
  }
10676
10745
  client;
10677
10746
  constructor(options = {}) {
10678
- const privateKey = options.walletKey || options.privateKey;
10747
+ const aliases = [options.walletKey, options.privateKey].filter((k) => k !== void 0);
10748
+ const privateKey = aliases.find((k) => k !== "") ?? aliases[0];
10679
10749
  const apiUrl = resolveApiKeyAuth({ apiKey: options.apiKey, privateKey, apiUrl: options.baseURL })?.apiUrl ?? options.baseURL ?? "https://blockrun.ai/api";
10680
10750
  const timeout = options.timeout ?? DEFAULT_TIMEOUT;
10681
10751
  this.client = new LLMClient({
@@ -10734,7 +10804,9 @@ var AnthropicClient = class {
10734
10804
  this._client = new Anthropic({
10735
10805
  baseURL: this._apiUrl,
10736
10806
  apiKey: "blockrun",
10737
- fetch: this._x402Fetch.bind(this)
10807
+ fetch: this._x402Fetch.bind(this),
10808
+ // Account POSTs may already be billed when an upstream error arrives.
10809
+ ...this.apiAuth ? { maxRetries: 0 } : {}
10738
10810
  });
10739
10811
  return this._client;
10740
10812
  })();
@@ -10743,6 +10815,10 @@ var AnthropicClient = class {
10743
10815
  async _x402Fetch(input, init) {
10744
10816
  const controller = new AbortController();
10745
10817
  const timeoutId = setTimeout(() => controller.abort(), this._timeout);
10818
+ const callerSignal = init?.signal ?? (input instanceof Request ? input.signal : void 0);
10819
+ const onAbort = () => controller.abort(callerSignal?.reason);
10820
+ callerSignal?.addEventListener("abort", onAbort, { once: true });
10821
+ if (callerSignal?.aborted) onAbort();
10746
10822
  try {
10747
10823
  const mergedInit = { ...init, signal: controller.signal };
10748
10824
  if (this.apiAuth) return await this.apiAuth.fetch(input, mergedInit, false);
@@ -10796,6 +10872,7 @@ var AnthropicClient = class {
10796
10872
  }
10797
10873
  return response;
10798
10874
  } finally {
10875
+ callerSignal?.removeEventListener("abort", onAbort);
10799
10876
  clearTimeout(timeoutId);
10800
10877
  }
10801
10878
  }