@blockrun/llm 3.14.3 → 3.15.0

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.js CHANGED
@@ -158,6 +158,21 @@ function validateResourceUrl(url, baseUrl) {
158
158
  var TRANSIENT_STATUS = /* @__PURE__ */ new Set([502, 503, 504, 522, 524]);
159
159
  var TRANSIENT_RETRIES = 2;
160
160
  var TRANSIENT_BACKOFF_MS = 1e3;
161
+ function retryDelay(ms, signal) {
162
+ signal?.throwIfAborted();
163
+ return new Promise((resolve, reject) => {
164
+ const onAbort = () => {
165
+ clearTimeout(timer);
166
+ signal?.removeEventListener("abort", onAbort);
167
+ reject(signal?.reason);
168
+ };
169
+ const timer = setTimeout(() => {
170
+ signal?.removeEventListener("abort", onAbort);
171
+ resolve();
172
+ }, ms);
173
+ signal?.addEventListener("abort", onAbort, { once: true });
174
+ });
175
+ }
161
176
  var API_KEY_URL = "https://api.blockrun.ai";
162
177
  var PORTAL_URL = "https://user.blockrun.ai";
163
178
  function resolveApiKeyAuth(options) {
@@ -207,6 +222,7 @@ var ApiKeyAuth = class {
207
222
  headers.set("authorization", `Bearer ${this.#key}`);
208
223
  const method = (init?.method ?? request?.method ?? "GET").toUpperCase();
209
224
  const retries = method === "GET" || method === "HEAD" ? TRANSIENT_RETRIES : 0;
225
+ const signal = init?.signal ?? request?.signal;
210
226
  let response;
211
227
  for (let attempt = 0; ; attempt++) {
212
228
  response = await globalThis.fetch(request ? new Request(url, request) : url, {
@@ -215,7 +231,8 @@ var ApiKeyAuth = class {
215
231
  redirect: "error"
216
232
  });
217
233
  if (attempt >= retries || !TRANSIENT_STATUS.has(response.status)) break;
218
- await new Promise((resolve) => setTimeout(resolve, TRANSIENT_BACKOFF_MS * (attempt + 1)));
234
+ await response.body?.cancel();
235
+ await retryDelay(TRANSIENT_BACKOFF_MS * (attempt + 1), signal);
219
236
  }
220
237
  if (raiseErrors && !response.ok) {
221
238
  let body;
@@ -4483,7 +4500,7 @@ function getCostSummary() {
4483
4500
  }
4484
4501
 
4485
4502
  // src/version.ts
4486
- var SDK_VERSION = "3.14.3";
4503
+ var SDK_VERSION = "3.15.0";
4487
4504
  var USER_AGENT = `blockrun-ts/${SDK_VERSION}`;
4488
4505
 
4489
4506
  // src/client.ts
@@ -6744,7 +6761,7 @@ var VideoClient = class {
6744
6761
  * Submits an async job, then polls until the video is ready. Typical total
6745
6762
  * wall-time is 60-180s, but upstream status can lag several minutes behind
6746
6763
  * actual completion. If upstream runs past the budget (default 15min),
6747
- * throws without charging — the job stays claimable ~48h via poll_url.
6764
+ * throws with the existing poll URL; check billing before submitting again.
6748
6765
  *
6749
6766
  * @param prompt - Text description of the video
6750
6767
  * @param options - Optional generation parameters
@@ -6944,7 +6961,7 @@ var VideoClient = class {
6944
6961
  }
6945
6962
  }
6946
6963
  throw new APIError(
6947
- `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.`,
6964
+ `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.`,
6948
6965
  504,
6949
6966
  { id: submitData.id, last_status: lastStatus, poll_url: pollUrl }
6950
6967
  );
@@ -8436,6 +8453,35 @@ var RpcClient = class {
8436
8453
  }
8437
8454
  };
8438
8455
 
8456
+ // src/sse.ts
8457
+ async function* readSseFrames(response, onMissingBody) {
8458
+ if (!response.body) throw onMissingBody(response.status);
8459
+ const reader = response.body.getReader();
8460
+ const decoder = new TextDecoder();
8461
+ let buffer = "";
8462
+ try {
8463
+ while (true) {
8464
+ const { done, value } = await reader.read();
8465
+ if (done) break;
8466
+ buffer += decoder.decode(value, { stream: true });
8467
+ const lines = buffer.split("\n");
8468
+ buffer = lines.pop() || "";
8469
+ for (const line of lines) {
8470
+ const trimmed = line.trim();
8471
+ if (!trimmed || !trimmed.startsWith("data: ")) continue;
8472
+ const data = trimmed.slice(6);
8473
+ if (data === "[DONE]") return;
8474
+ try {
8475
+ yield JSON.parse(data);
8476
+ } catch {
8477
+ }
8478
+ }
8479
+ }
8480
+ } finally {
8481
+ reader.releaseLock();
8482
+ }
8483
+ }
8484
+
8439
8485
  // src/blockrun.ts
8440
8486
  import { privateKeyToAccount as privateKeyToAccount13 } from "viem/accounts";
8441
8487
  var DEFAULT_API_URL13 = "https://blockrun.ai/api";
@@ -8645,33 +8691,10 @@ var BlockrunClient = class {
8645
8691
  await this.throwApiError(resp402, `stream failed (${url})`);
8646
8692
  return;
8647
8693
  }
8648
- if (!streamResp.body) {
8649
- throw new APIError("Stream response has no body", streamResp.status, {});
8650
- }
8651
- const reader = streamResp.body.getReader();
8652
- const decoder = new TextDecoder();
8653
- let buffer = "";
8654
- try {
8655
- while (true) {
8656
- const { done, value } = await reader.read();
8657
- if (done) break;
8658
- buffer += decoder.decode(value, { stream: true });
8659
- const lines = buffer.split("\n");
8660
- buffer = lines.pop() || "";
8661
- for (const line of lines) {
8662
- const trimmed = line.trim();
8663
- if (!trimmed || !trimmed.startsWith("data: ")) continue;
8664
- const data = trimmed.slice(6);
8665
- if (data === "[DONE]") return;
8666
- try {
8667
- yield JSON.parse(data);
8668
- } catch {
8669
- }
8670
- }
8671
- }
8672
- } finally {
8673
- reader.releaseLock();
8674
- }
8694
+ yield* readSseFrames(
8695
+ streamResp,
8696
+ (status2) => new APIError("Stream response has no body", status2, {})
8697
+ );
8675
8698
  }
8676
8699
  // --------------------------------------------------------------------
8677
8700
  // Internal: shared infrastructure
@@ -9602,6 +9625,123 @@ var SolanaLLMClient = class {
9602
9625
  response.routing = decision;
9603
9626
  return { response, model: decision.model, routing: decision };
9604
9627
  }
9628
+ /**
9629
+ * Stream a Server-Sent Events endpoint, paid on Solana.
9630
+ *
9631
+ * The Solana counterpart to `BlockrunClient.stream`, and the reason it had to
9632
+ * exist: a streaming harness cannot use this client at all without it, so
9633
+ * "BlockRun supports Solana" stopped being true the moment a caller streamed.
9634
+ * `chatCompletion` buffers the whole answer, which is the wrong shape for an
9635
+ * agent loop and for anything that shows tokens as they arrive.
9636
+ *
9637
+ * The handshake is the one the non-streaming paths use — `402`, sign an SPL
9638
+ * TransferChecked authorization locally, replay with `PAYMENT-SIGNATURE` —
9639
+ * with the same verification-phase re-sign on a stale blockhash. What differs
9640
+ * is that the paid response is not read as JSON: it is handed to the SSE
9641
+ * reader with its body untouched.
9642
+ *
9643
+ * A `200` on the first request is returned as-is and settles nothing. That is
9644
+ * the free tier (the gateway answers a `billing_mode: "free"` model without a
9645
+ * 402 at all) and it is also API-key mode, where billing is on the account
9646
+ * rather than on a wallet.
9647
+ *
9648
+ * Yields each `data:` frame parsed as JSON, and stops at `data: [DONE]`.
9649
+ * Malformed frames are skipped rather than thrown — see {@link readSseFrames}.
9650
+ *
9651
+ * @example
9652
+ * for await (const chunk of client.stream<ChatChunk>("/v1/chat/completions", {
9653
+ * model: "deepseek/deepseek-chat",
9654
+ * messages: [{ role: "user", content: "Hi" }],
9655
+ * stream: true,
9656
+ * })) {
9657
+ * process.stdout.write(chunk.choices?.[0]?.delta?.content ?? "");
9658
+ * }
9659
+ *
9660
+ * @param path - endpoint after the API root; a leading `/api` is tolerated.
9661
+ * @param body - JSON request body. Set `stream: true` yourself — this method
9662
+ * does not inject it, because the gateway prices a streaming and a
9663
+ * non-streaming request the same and silently rewriting a caller's body is
9664
+ * how you end up debugging a request you did not send.
9665
+ * @returns each decoded SSE frame, in order.
9666
+ */
9667
+ async *stream(path6, body) {
9668
+ const url = this.buildUrl(path6);
9669
+ const response = await this.openPaidStream(url, JSON.stringify(body ?? {}));
9670
+ yield* readSseFrames(
9671
+ response,
9672
+ (status2) => new APIError("Stream response has no body", status2, {})
9673
+ );
9674
+ }
9675
+ /**
9676
+ * Get to a streaming response, paying for it if the gateway asks.
9677
+ *
9678
+ * Separate from {@link SolanaLLMClient.stream} because a generator cannot
9679
+ * retry cleanly around a `yield`: the stale-blockhash re-sign has to finish
9680
+ * before the first frame is handed out, and putting the loop here keeps the
9681
+ * payment decision entirely ahead of any output the caller has seen.
9682
+ *
9683
+ * @param url - resolved endpoint URL.
9684
+ * @param requestBody - the serialized body, reused verbatim on the paid retry
9685
+ * so the gateway prices and answers the same request it quoted for.
9686
+ * @returns a response whose body has not been read.
9687
+ */
9688
+ async openPaidStream(url, requestBody) {
9689
+ for (let staleRetries = 0; ; ) {
9690
+ const response = await this.fetchWithTimeout(url, {
9691
+ method: "POST",
9692
+ headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT },
9693
+ body: requestBody
9694
+ });
9695
+ if (response.ok) return response;
9696
+ if (response.status !== 402) {
9697
+ let errorBody;
9698
+ try {
9699
+ errorBody = await response.json();
9700
+ } catch {
9701
+ errorBody = { error: "Request failed" };
9702
+ }
9703
+ throw new APIError(`API error: ${response.status}`, response.status, sanitizeErrorResponse(errorBody));
9704
+ }
9705
+ try {
9706
+ const { paymentPayload, costUsd } = await this.signPaymentFrom402(
9707
+ url,
9708
+ response,
9709
+ staleRetries > 0
9710
+ );
9711
+ const paid = await this.fetchWithTimeout(url, {
9712
+ method: "POST",
9713
+ headers: {
9714
+ "Content-Type": "application/json",
9715
+ "User-Agent": USER_AGENT,
9716
+ "PAYMENT-SIGNATURE": paymentPayload
9717
+ },
9718
+ body: requestBody
9719
+ });
9720
+ await this.assertPaid(paid);
9721
+ this.recordSettlement(costUsd);
9722
+ return paid;
9723
+ } catch (error) {
9724
+ if (!(error instanceof SafeStaleBlockhashError) || staleRetries >= STALE_BLOCKHASH_RETRY_BACKOFFS_MS.length) throw error;
9725
+ await waitForStaleRetry(staleRetries++);
9726
+ continue;
9727
+ }
9728
+ }
9729
+ }
9730
+ /**
9731
+ * Resolve an endpoint path against this client's API root.
9732
+ *
9733
+ * A leading `/api` is stripped for the same reason `BlockrunClient` strips
9734
+ * it: the documented paths are written `/api/v1/…` on the website and
9735
+ * `/v1/…` in this SDK, and a caller who copies one into the other should get
9736
+ * their request rather than a 404.
9737
+ * @param path - endpoint path, with or without a leading slash.
9738
+ * @returns the absolute URL to call.
9739
+ */
9740
+ buildUrl(path6) {
9741
+ let normalized = path6.startsWith("/") ? path6 : `/${path6}`;
9742
+ if (normalized.startsWith("/api/")) normalized = normalized.slice(4);
9743
+ return `${this.apiUrl}${normalized}`;
9744
+ }
9605
9745
  /** List available models. */
9606
9746
  async listModels() {
9607
9747
  const response = await this.fetchWithTimeout(`${this.apiUrl}/v1/models`, { method: "GET" });
@@ -9872,7 +10012,25 @@ var SolanaLLMClient = class {
9872
10012
  return response.json();
9873
10013
  }
9874
10014
  }
9875
- async handlePaymentAndRetry(url, body, response, forceFreshBlockhash = false) {
10015
+ /**
10016
+ * Turn a `402` into a signed Solana payment payload.
10017
+ *
10018
+ * Extracted because four call sites need it — chat, the raw POST helpers, the
10019
+ * raw GET helper, and {@link SolanaLLMClient.stream} — and it had been
10020
+ * written out three times before this. That mattered more than ordinary
10021
+ * duplication: this is the code that signs a transfer of the caller's USDC,
10022
+ * so three copies meant every fix to it had to be applied three times or
10023
+ * quietly apply to two thirds of the paths.
10024
+ *
10025
+ * @param url - the request being paid for.
10026
+ * @param response - the gateway's `402`, not yet consumed.
10027
+ * @param forceFreshBlockhash - set on a re-sign after a stale-blockhash
10028
+ * rejection, so the retry cannot produce byte-identical transaction bytes.
10029
+ * @param resourceFallback - resource URL to claim when the 402 states none.
10030
+ * @returns the header value to replay with, and what it will settle for.
10031
+ * @throws PaymentError when the 402 carries no usable Solana requirements.
10032
+ */
10033
+ async signPaymentFrom402(url, response, forceFreshBlockhash, resourceFallback = url) {
9876
10034
  let paymentHeader = response.headers.get("payment-required");
9877
10035
  if (!paymentHeader) {
9878
10036
  try {
@@ -9906,7 +10064,7 @@ var SolanaLLMClient = class {
9906
10064
  feePayer,
9907
10065
  {
9908
10066
  resourceUrl: validateResourceUrl(
9909
- details.resource?.url || `${this.apiUrl}/v1/chat/completions`,
10067
+ details.resource?.url || resourceFallback,
9910
10068
  this.apiUrl
9911
10069
  ),
9912
10070
  resourceDescription: details.resource?.description || "BlockRun Solana AI API call",
@@ -9918,6 +10076,15 @@ var SolanaLLMClient = class {
9918
10076
  forceFreshBlockhash
9919
10077
  }
9920
10078
  );
10079
+ return { paymentPayload, costUsd: parseFloat(details.amount) / 1e6 };
10080
+ }
10081
+ async handlePaymentAndRetry(url, body, response, forceFreshBlockhash = false) {
10082
+ const { paymentPayload, costUsd } = await this.signPaymentFrom402(
10083
+ url,
10084
+ response,
10085
+ forceFreshBlockhash,
10086
+ `${this.apiUrl}/v1/chat/completions`
10087
+ );
9921
10088
  const retryResponse = await this.fetchWithTimeout(url, {
9922
10089
  method: "POST",
9923
10090
  headers: {
@@ -9927,24 +10094,8 @@ var SolanaLLMClient = class {
9927
10094
  },
9928
10095
  body: JSON.stringify(body)
9929
10096
  });
9930
- if (retryResponse.status === 402) {
9931
- if (await isSafeStaleBlockhashResponse(retryResponse)) {
9932
- throw new SafeStaleBlockhashError();
9933
- }
9934
- throw new PaymentError("Payment was rejected. Check your Solana USDC balance.");
9935
- }
9936
- if (!retryResponse.ok) {
9937
- let errorBody;
9938
- try {
9939
- errorBody = await retryResponse.json();
9940
- } catch {
9941
- errorBody = { error: "Request failed" };
9942
- }
9943
- throw new APIError(`API error after payment: ${retryResponse.status}`, retryResponse.status, sanitizeErrorResponse(errorBody));
9944
- }
9945
- const costUsd = parseFloat(details.amount) / 1e6;
9946
- this.sessionCalls += 1;
9947
- this.sessionTotalUsd += costUsd;
10097
+ await this.assertPaid(retryResponse);
10098
+ this.recordSettlement(costUsd);
9948
10099
  return retryResponse.json();
9949
10100
  }
9950
10101
  async requestWithPaymentRaw(endpoint, body) {
@@ -9978,51 +10129,7 @@ var SolanaLLMClient = class {
9978
10129
  }
9979
10130
  }
9980
10131
  async handlePaymentAndRetryRaw(url, body, response, forceFreshBlockhash = false) {
9981
- let paymentHeader = response.headers.get("payment-required");
9982
- if (!paymentHeader) {
9983
- try {
9984
- const respBody = await response.json();
9985
- if (respBody.accepts || respBody.x402Version) {
9986
- paymentHeader = btoa(JSON.stringify(respBody));
9987
- }
9988
- } catch {
9989
- }
9990
- }
9991
- if (!paymentHeader) {
9992
- throw new PaymentError("402 response but no payment requirements found");
9993
- }
9994
- const paymentRequired = parsePaymentRequired(paymentHeader);
9995
- const details = extractPaymentDetails(paymentRequired, SOLANA_NETWORK);
9996
- if (!details.network?.startsWith("solana:")) {
9997
- throw new PaymentError(
9998
- `Expected Solana payment network, got: ${details.network}. Use LLMClient for Base payments.`
9999
- );
10000
- }
10001
- const feePayer = details.extra?.feePayer;
10002
- if (!feePayer) throw new PaymentError("Missing feePayer in 402 extra field");
10003
- const fromAddress = await this.getWalletAddress();
10004
- const secretKey = await solanaKeyToBytes(this.privateKey);
10005
- const extensions = paymentRequired.extensions;
10006
- const paymentPayload = await createSolanaPaymentPayload(
10007
- secretKey,
10008
- fromAddress,
10009
- details.recipient,
10010
- details.amount,
10011
- feePayer,
10012
- {
10013
- resourceUrl: validateResourceUrl(
10014
- details.resource?.url || url,
10015
- this.apiUrl
10016
- ),
10017
- resourceDescription: details.resource?.description || "BlockRun Solana AI API call",
10018
- maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
10019
- extra: details.extra,
10020
- extensions,
10021
- rpcUrl: this.rpcUrl,
10022
- rpcHeaders: this.rpcHeaders,
10023
- forceFreshBlockhash
10024
- }
10025
- );
10132
+ const { paymentPayload, costUsd } = await this.signPaymentFrom402(url, response, forceFreshBlockhash);
10026
10133
  const retryResponse = await this.fetchWithTimeout(url, {
10027
10134
  method: "POST",
10028
10135
  headers: {
@@ -10032,24 +10139,8 @@ var SolanaLLMClient = class {
10032
10139
  },
10033
10140
  body: JSON.stringify(body)
10034
10141
  });
10035
- if (retryResponse.status === 402) {
10036
- if (await isSafeStaleBlockhashResponse(retryResponse)) {
10037
- throw new SafeStaleBlockhashError();
10038
- }
10039
- throw new PaymentError("Payment was rejected. Check your Solana USDC balance.");
10040
- }
10041
- if (!retryResponse.ok) {
10042
- let errorBody;
10043
- try {
10044
- errorBody = await retryResponse.json();
10045
- } catch {
10046
- errorBody = { error: "Request failed" };
10047
- }
10048
- throw new APIError(`API error after payment: ${retryResponse.status}`, retryResponse.status, sanitizeErrorResponse(errorBody));
10049
- }
10050
- const costUsd = parseFloat(details.amount) / 1e6;
10051
- this.sessionCalls += 1;
10052
- this.sessionTotalUsd += costUsd;
10142
+ await this.assertPaid(retryResponse);
10143
+ this.recordSettlement(costUsd);
10053
10144
  return retryResponse.json();
10054
10145
  }
10055
10146
  async getWithPaymentRaw(endpoint, params) {
@@ -10082,51 +10173,7 @@ var SolanaLLMClient = class {
10082
10173
  }
10083
10174
  }
10084
10175
  async handleGetPaymentAndRetryRaw(url, endpoint, params, response, forceFreshBlockhash = false) {
10085
- let paymentHeader = response.headers.get("payment-required");
10086
- if (!paymentHeader) {
10087
- try {
10088
- const respBody = await response.json();
10089
- if (respBody.accepts || respBody.x402Version) {
10090
- paymentHeader = btoa(JSON.stringify(respBody));
10091
- }
10092
- } catch {
10093
- }
10094
- }
10095
- if (!paymentHeader) {
10096
- throw new PaymentError("402 response but no payment requirements found");
10097
- }
10098
- const paymentRequired = parsePaymentRequired(paymentHeader);
10099
- const details = extractPaymentDetails(paymentRequired, SOLANA_NETWORK);
10100
- if (!details.network?.startsWith("solana:")) {
10101
- throw new PaymentError(
10102
- `Expected Solana payment network, got: ${details.network}. Use LLMClient for Base payments.`
10103
- );
10104
- }
10105
- const feePayer = details.extra?.feePayer;
10106
- if (!feePayer) throw new PaymentError("Missing feePayer in 402 extra field");
10107
- const fromAddress = await this.getWalletAddress();
10108
- const secretKey = await solanaKeyToBytes(this.privateKey);
10109
- const extensions = paymentRequired.extensions;
10110
- const paymentPayload = await createSolanaPaymentPayload(
10111
- secretKey,
10112
- fromAddress,
10113
- details.recipient,
10114
- details.amount,
10115
- feePayer,
10116
- {
10117
- resourceUrl: validateResourceUrl(
10118
- details.resource?.url || url,
10119
- this.apiUrl
10120
- ),
10121
- resourceDescription: details.resource?.description || "BlockRun Solana AI API call",
10122
- maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
10123
- extra: details.extra,
10124
- extensions,
10125
- rpcUrl: this.rpcUrl,
10126
- rpcHeaders: this.rpcHeaders,
10127
- forceFreshBlockhash
10128
- }
10129
- );
10176
+ const { paymentPayload, costUsd } = await this.signPaymentFrom402(url, response, forceFreshBlockhash);
10130
10177
  const query = params ? "?" + new URLSearchParams(params).toString() : "";
10131
10178
  const retryUrl = `${this.apiUrl}${endpoint}${query}`;
10132
10179
  const retryResponse = await this.fetchWithTimeout(retryUrl, {
@@ -10136,25 +10183,44 @@ var SolanaLLMClient = class {
10136
10183
  "PAYMENT-SIGNATURE": paymentPayload
10137
10184
  }
10138
10185
  });
10139
- if (retryResponse.status === 402) {
10140
- if (await isSafeStaleBlockhashResponse(retryResponse)) {
10186
+ await this.assertPaid(retryResponse);
10187
+ this.recordSettlement(costUsd);
10188
+ return retryResponse.json();
10189
+ }
10190
+ /**
10191
+ * Fail a post-payment response, telling a re-signable rejection from a real one.
10192
+ *
10193
+ * A `402` here is not "pay again": it is the gateway refusing the payment we
10194
+ * just signed. Only a rejection the gateway attributes to the VERIFICATION
10195
+ * phase is safe to re-sign — anything settled, or ambiguous about which
10196
+ * phase it failed in, could already have moved USDC, and re-signing it would
10197
+ * pay twice. {@link isSafeStaleBlockhashResponse} is where that judgement
10198
+ * lives.
10199
+ * @param response - the reply to the paid request.
10200
+ * @throws SafeStaleBlockhashError when the caller should re-sign, PaymentError
10201
+ * when it should not, APIError for any other failure.
10202
+ */
10203
+ async assertPaid(response) {
10204
+ if (response.status === 402) {
10205
+ if (await isSafeStaleBlockhashResponse(response)) {
10141
10206
  throw new SafeStaleBlockhashError();
10142
10207
  }
10143
10208
  throw new PaymentError("Payment was rejected. Check your Solana USDC balance.");
10144
10209
  }
10145
- if (!retryResponse.ok) {
10210
+ if (!response.ok) {
10146
10211
  let errorBody;
10147
10212
  try {
10148
- errorBody = await retryResponse.json();
10213
+ errorBody = await response.json();
10149
10214
  } catch {
10150
10215
  errorBody = { error: "Request failed" };
10151
10216
  }
10152
- throw new APIError(`API error after payment: ${retryResponse.status}`, retryResponse.status, sanitizeErrorResponse(errorBody));
10217
+ throw new APIError(`API error after payment: ${response.status}`, response.status, sanitizeErrorResponse(errorBody));
10153
10218
  }
10154
- const costUsd = parseFloat(details.amount) / 1e6;
10219
+ }
10220
+ /** Count one settled x402 payment against the session total. */
10221
+ recordSettlement(costUsd) {
10155
10222
  this.sessionCalls += 1;
10156
10223
  this.sessionTotalUsd += costUsd;
10157
- return retryResponse.json();
10158
10224
  }
10159
10225
  async fetchWithTimeout(url, options) {
10160
10226
  const controller = new AbortController();
@@ -10558,7 +10624,8 @@ var OpenAI = class {
10558
10624
  }
10559
10625
  client;
10560
10626
  constructor(options = {}) {
10561
- const privateKey = options.walletKey || options.privateKey;
10627
+ const aliases = [options.walletKey, options.privateKey].filter((k) => k !== void 0);
10628
+ const privateKey = aliases.find((k) => k !== "") ?? aliases[0];
10562
10629
  const apiUrl = resolveApiKeyAuth({ apiKey: options.apiKey, privateKey, apiUrl: options.baseURL })?.apiUrl ?? options.baseURL ?? "https://blockrun.ai/api";
10563
10630
  const timeout = options.timeout ?? DEFAULT_TIMEOUT;
10564
10631
  this.client = new LLMClient({
@@ -10617,7 +10684,9 @@ var AnthropicClient = class {
10617
10684
  this._client = new Anthropic({
10618
10685
  baseURL: this._apiUrl,
10619
10686
  apiKey: "blockrun",
10620
- fetch: this._x402Fetch.bind(this)
10687
+ fetch: this._x402Fetch.bind(this),
10688
+ // Account POSTs may already be billed when an upstream error arrives.
10689
+ ...this.apiAuth ? { maxRetries: 0 } : {}
10621
10690
  });
10622
10691
  return this._client;
10623
10692
  })();
@@ -10626,6 +10695,10 @@ var AnthropicClient = class {
10626
10695
  async _x402Fetch(input, init) {
10627
10696
  const controller = new AbortController();
10628
10697
  const timeoutId = setTimeout(() => controller.abort(), this._timeout);
10698
+ const callerSignal = init?.signal ?? (input instanceof Request ? input.signal : void 0);
10699
+ const onAbort = () => controller.abort(callerSignal?.reason);
10700
+ callerSignal?.addEventListener("abort", onAbort, { once: true });
10701
+ if (callerSignal?.aborted) onAbort();
10629
10702
  try {
10630
10703
  const mergedInit = { ...init, signal: controller.signal };
10631
10704
  if (this.apiAuth) return await this.apiAuth.fetch(input, mergedInit, false);
@@ -10679,6 +10752,7 @@ var AnthropicClient = class {
10679
10752
  }
10680
10753
  return response;
10681
10754
  } finally {
10755
+ callerSignal?.removeEventListener("abort", onAbort);
10682
10756
  clearTimeout(timeoutId);
10683
10757
  }
10684
10758
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/llm",
3
- "version": "3.14.3",
3
+ "version": "3.15.0",
4
4
  "type": "module",
5
5
  "description": "TypeScript SDK for BlockRun - every frontier model behind one OpenAI-compatible client. Pay with a BlockRun API key or per-call in USDC. Smart routing picks the cheapest capable model. No rate limits.",
6
6
  "main": "dist/index.cjs",
@@ -22,7 +22,8 @@
22
22
  "dev": "tsup --watch",
23
23
  "test": "vitest",
24
24
  "lint": "eslint src/",
25
- "typecheck": "tsc --noEmit"
25
+ "typecheck": "tsc --noEmit",
26
+ "test:package": "npm run build && node scripts/check-package.mjs"
26
27
  },
27
28
  "keywords": [
28
29
  "llm",
@@ -52,9 +53,7 @@
52
53
  },
53
54
  "dependencies": {
54
55
  "bs58": "^6.0.0",
55
- "viem": "^2.56.3"
56
- },
57
- "optionalDependencies": {
56
+ "viem": "^2.56.3",
58
57
  "@anthropic-ai/sdk": "^0.123.0"
59
58
  },
60
59
  "devDependencies": {