@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.js CHANGED
@@ -126,9 +126,12 @@ function sanitizeErrorResponse(errorBody) {
126
126
  return { message: "API request failed" };
127
127
  }
128
128
  const body = errorBody;
129
+ const detail = typeof body.message === "string" && body.message !== body.error ? body.message : void 0;
129
130
  return {
130
131
  message: typeof body.error === "string" ? body.error : "API request failed",
131
- code: typeof body.code === "string" ? body.code : void 0
132
+ code: typeof body.code === "string" ? body.code : void 0,
133
+ ...detail ? { detail } : {},
134
+ ...typeof body.hint === "string" ? { hint: body.hint } : {}
132
135
  };
133
136
  }
134
137
  function validateResourceUrl(url, baseUrl) {
@@ -158,6 +161,21 @@ function validateResourceUrl(url, baseUrl) {
158
161
  var TRANSIENT_STATUS = /* @__PURE__ */ new Set([502, 503, 504, 522, 524]);
159
162
  var TRANSIENT_RETRIES = 2;
160
163
  var TRANSIENT_BACKOFF_MS = 1e3;
164
+ function retryDelay(ms, signal) {
165
+ signal?.throwIfAborted();
166
+ return new Promise((resolve, reject) => {
167
+ const onAbort = () => {
168
+ clearTimeout(timer);
169
+ signal?.removeEventListener("abort", onAbort);
170
+ reject(signal?.reason);
171
+ };
172
+ const timer = setTimeout(() => {
173
+ signal?.removeEventListener("abort", onAbort);
174
+ resolve();
175
+ }, ms);
176
+ signal?.addEventListener("abort", onAbort, { once: true });
177
+ });
178
+ }
161
179
  var API_KEY_URL = "https://api.blockrun.ai";
162
180
  var PORTAL_URL = "https://user.blockrun.ai";
163
181
  function resolveApiKeyAuth(options) {
@@ -207,6 +225,7 @@ var ApiKeyAuth = class {
207
225
  headers.set("authorization", `Bearer ${this.#key}`);
208
226
  const method = (init?.method ?? request?.method ?? "GET").toUpperCase();
209
227
  const retries = method === "GET" || method === "HEAD" ? TRANSIENT_RETRIES : 0;
228
+ const signal = init?.signal ?? request?.signal;
210
229
  let response;
211
230
  for (let attempt = 0; ; attempt++) {
212
231
  response = await globalThis.fetch(request ? new Request(url, request) : url, {
@@ -215,7 +234,8 @@ var ApiKeyAuth = class {
215
234
  redirect: "error"
216
235
  });
217
236
  if (attempt >= retries || !TRANSIENT_STATUS.has(response.status)) break;
218
- await new Promise((resolve) => setTimeout(resolve, TRANSIENT_BACKOFF_MS * (attempt + 1)));
237
+ await response.body?.cancel();
238
+ await retryDelay(TRANSIENT_BACKOFF_MS * (attempt + 1), signal);
219
239
  }
220
240
  if (raiseErrors && !response.ok) {
221
241
  let body;
@@ -4483,7 +4503,7 @@ function getCostSummary() {
4483
4503
  }
4484
4504
 
4485
4505
  // src/version.ts
4486
- var SDK_VERSION = "3.14.3";
4506
+ var SDK_VERSION = "3.15.1";
4487
4507
  var USER_AGENT = `blockrun-ts/${SDK_VERSION}`;
4488
4508
 
4489
4509
  // src/client.ts
@@ -6744,7 +6764,7 @@ var VideoClient = class {
6744
6764
  * Submits an async job, then polls until the video is ready. Typical total
6745
6765
  * wall-time is 60-180s, but upstream status can lag several minutes behind
6746
6766
  * actual completion. If upstream runs past the budget (default 15min),
6747
- * throws without charging — the job stays claimable ~48h via poll_url.
6767
+ * throws with the existing poll URL; check billing before submitting again.
6748
6768
  *
6749
6769
  * @param prompt - Text description of the video
6750
6770
  * @param options - Optional generation parameters
@@ -6944,7 +6964,7 @@ var VideoClient = class {
6944
6964
  }
6945
6965
  }
6946
6966
  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.`,
6967
+ `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
6968
  504,
6949
6969
  { id: submitData.id, last_status: lastStatus, poll_url: pollUrl }
6950
6970
  );
@@ -8436,6 +8456,35 @@ var RpcClient = class {
8436
8456
  }
8437
8457
  };
8438
8458
 
8459
+ // src/sse.ts
8460
+ async function* readSseFrames(response, onMissingBody) {
8461
+ if (!response.body) throw onMissingBody(response.status);
8462
+ const reader = response.body.getReader();
8463
+ const decoder = new TextDecoder();
8464
+ let buffer = "";
8465
+ try {
8466
+ while (true) {
8467
+ const { done, value } = await reader.read();
8468
+ if (done) break;
8469
+ buffer += decoder.decode(value, { stream: true });
8470
+ const lines = buffer.split("\n");
8471
+ buffer = lines.pop() || "";
8472
+ for (const line of lines) {
8473
+ const trimmed = line.trim();
8474
+ if (!trimmed || !trimmed.startsWith("data: ")) continue;
8475
+ const data = trimmed.slice(6);
8476
+ if (data === "[DONE]") return;
8477
+ try {
8478
+ yield JSON.parse(data);
8479
+ } catch {
8480
+ }
8481
+ }
8482
+ }
8483
+ } finally {
8484
+ reader.releaseLock();
8485
+ }
8486
+ }
8487
+
8439
8488
  // src/blockrun.ts
8440
8489
  import { privateKeyToAccount as privateKeyToAccount13 } from "viem/accounts";
8441
8490
  var DEFAULT_API_URL13 = "https://blockrun.ai/api";
@@ -8645,33 +8694,10 @@ var BlockrunClient = class {
8645
8694
  await this.throwApiError(resp402, `stream failed (${url})`);
8646
8695
  return;
8647
8696
  }
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
- }
8697
+ yield* readSseFrames(
8698
+ streamResp,
8699
+ (status2) => new APIError("Stream response has no body", status2, {})
8700
+ );
8675
8701
  }
8676
8702
  // --------------------------------------------------------------------
8677
8703
  // Internal: shared infrastructure
@@ -9602,6 +9628,123 @@ var SolanaLLMClient = class {
9602
9628
  response.routing = decision;
9603
9629
  return { response, model: decision.model, routing: decision };
9604
9630
  }
9631
+ /**
9632
+ * Stream a Server-Sent Events endpoint, paid on Solana.
9633
+ *
9634
+ * The Solana counterpart to `BlockrunClient.stream`, and the reason it had to
9635
+ * exist: a streaming harness cannot use this client at all without it, so
9636
+ * "BlockRun supports Solana" stopped being true the moment a caller streamed.
9637
+ * `chatCompletion` buffers the whole answer, which is the wrong shape for an
9638
+ * agent loop and for anything that shows tokens as they arrive.
9639
+ *
9640
+ * The handshake is the one the non-streaming paths use — `402`, sign an SPL
9641
+ * TransferChecked authorization locally, replay with `PAYMENT-SIGNATURE` —
9642
+ * with the same verification-phase re-sign on a stale blockhash. What differs
9643
+ * is that the paid response is not read as JSON: it is handed to the SSE
9644
+ * reader with its body untouched.
9645
+ *
9646
+ * A `200` on the first request is returned as-is and settles nothing. That is
9647
+ * the free tier (the gateway answers a `billing_mode: "free"` model without a
9648
+ * 402 at all) and it is also API-key mode, where billing is on the account
9649
+ * rather than on a wallet.
9650
+ *
9651
+ * Yields each `data:` frame parsed as JSON, and stops at `data: [DONE]`.
9652
+ * Malformed frames are skipped rather than thrown — see {@link readSseFrames}.
9653
+ *
9654
+ * @example
9655
+ * for await (const chunk of client.stream<ChatChunk>("/v1/chat/completions", {
9656
+ * model: "deepseek/deepseek-chat",
9657
+ * messages: [{ role: "user", content: "Hi" }],
9658
+ * stream: true,
9659
+ * })) {
9660
+ * process.stdout.write(chunk.choices?.[0]?.delta?.content ?? "");
9661
+ * }
9662
+ *
9663
+ * @param path - endpoint after the API root; a leading `/api` is tolerated.
9664
+ * @param body - JSON request body. Set `stream: true` yourself — this method
9665
+ * does not inject it, because the gateway prices a streaming and a
9666
+ * non-streaming request the same and silently rewriting a caller's body is
9667
+ * how you end up debugging a request you did not send.
9668
+ * @returns each decoded SSE frame, in order.
9669
+ */
9670
+ async *stream(path6, body) {
9671
+ const url = this.buildUrl(path6);
9672
+ const response = await this.openPaidStream(url, JSON.stringify(body ?? {}));
9673
+ yield* readSseFrames(
9674
+ response,
9675
+ (status2) => new APIError("Stream response has no body", status2, {})
9676
+ );
9677
+ }
9678
+ /**
9679
+ * Get to a streaming response, paying for it if the gateway asks.
9680
+ *
9681
+ * Separate from {@link SolanaLLMClient.stream} because a generator cannot
9682
+ * retry cleanly around a `yield`: the stale-blockhash re-sign has to finish
9683
+ * before the first frame is handed out, and putting the loop here keeps the
9684
+ * payment decision entirely ahead of any output the caller has seen.
9685
+ *
9686
+ * @param url - resolved endpoint URL.
9687
+ * @param requestBody - the serialized body, reused verbatim on the paid retry
9688
+ * so the gateway prices and answers the same request it quoted for.
9689
+ * @returns a response whose body has not been read.
9690
+ */
9691
+ async openPaidStream(url, requestBody) {
9692
+ for (let staleRetries = 0; ; ) {
9693
+ const response = await this.fetchWithTimeout(url, {
9694
+ method: "POST",
9695
+ headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT },
9696
+ body: requestBody
9697
+ });
9698
+ if (response.ok) return response;
9699
+ if (response.status !== 402) {
9700
+ let errorBody;
9701
+ try {
9702
+ errorBody = await response.json();
9703
+ } catch {
9704
+ errorBody = { error: "Request failed" };
9705
+ }
9706
+ throw new APIError(`API error: ${response.status}`, response.status, sanitizeErrorResponse(errorBody));
9707
+ }
9708
+ try {
9709
+ const { paymentPayload, costUsd } = await this.signPaymentFrom402(
9710
+ url,
9711
+ response,
9712
+ staleRetries > 0
9713
+ );
9714
+ const paid = await this.fetchWithTimeout(url, {
9715
+ method: "POST",
9716
+ headers: {
9717
+ "Content-Type": "application/json",
9718
+ "User-Agent": USER_AGENT,
9719
+ "PAYMENT-SIGNATURE": paymentPayload
9720
+ },
9721
+ body: requestBody
9722
+ });
9723
+ await this.assertPaid(paid);
9724
+ this.recordSettlement(costUsd);
9725
+ return paid;
9726
+ } catch (error) {
9727
+ if (!(error instanceof SafeStaleBlockhashError) || staleRetries >= STALE_BLOCKHASH_RETRY_BACKOFFS_MS.length) throw error;
9728
+ await waitForStaleRetry(staleRetries++);
9729
+ continue;
9730
+ }
9731
+ }
9732
+ }
9733
+ /**
9734
+ * Resolve an endpoint path against this client's API root.
9735
+ *
9736
+ * A leading `/api` is stripped for the same reason `BlockrunClient` strips
9737
+ * it: the documented paths are written `/api/v1/…` on the website and
9738
+ * `/v1/…` in this SDK, and a caller who copies one into the other should get
9739
+ * their request rather than a 404.
9740
+ * @param path - endpoint path, with or without a leading slash.
9741
+ * @returns the absolute URL to call.
9742
+ */
9743
+ buildUrl(path6) {
9744
+ let normalized = path6.startsWith("/") ? path6 : `/${path6}`;
9745
+ if (normalized.startsWith("/api/")) normalized = normalized.slice(4);
9746
+ return `${this.apiUrl}${normalized}`;
9747
+ }
9605
9748
  /** List available models. */
9606
9749
  async listModels() {
9607
9750
  const response = await this.fetchWithTimeout(`${this.apiUrl}/v1/models`, { method: "GET" });
@@ -9872,7 +10015,25 @@ var SolanaLLMClient = class {
9872
10015
  return response.json();
9873
10016
  }
9874
10017
  }
9875
- async handlePaymentAndRetry(url, body, response, forceFreshBlockhash = false) {
10018
+ /**
10019
+ * Turn a `402` into a signed Solana payment payload.
10020
+ *
10021
+ * Extracted because four call sites need it — chat, the raw POST helpers, the
10022
+ * raw GET helper, and {@link SolanaLLMClient.stream} — and it had been
10023
+ * written out three times before this. That mattered more than ordinary
10024
+ * duplication: this is the code that signs a transfer of the caller's USDC,
10025
+ * so three copies meant every fix to it had to be applied three times or
10026
+ * quietly apply to two thirds of the paths.
10027
+ *
10028
+ * @param url - the request being paid for.
10029
+ * @param response - the gateway's `402`, not yet consumed.
10030
+ * @param forceFreshBlockhash - set on a re-sign after a stale-blockhash
10031
+ * rejection, so the retry cannot produce byte-identical transaction bytes.
10032
+ * @param resourceFallback - resource URL to claim when the 402 states none.
10033
+ * @returns the header value to replay with, and what it will settle for.
10034
+ * @throws PaymentError when the 402 carries no usable Solana requirements.
10035
+ */
10036
+ async signPaymentFrom402(url, response, forceFreshBlockhash, resourceFallback = url) {
9876
10037
  let paymentHeader = response.headers.get("payment-required");
9877
10038
  if (!paymentHeader) {
9878
10039
  try {
@@ -9906,7 +10067,7 @@ var SolanaLLMClient = class {
9906
10067
  feePayer,
9907
10068
  {
9908
10069
  resourceUrl: validateResourceUrl(
9909
- details.resource?.url || `${this.apiUrl}/v1/chat/completions`,
10070
+ details.resource?.url || resourceFallback,
9910
10071
  this.apiUrl
9911
10072
  ),
9912
10073
  resourceDescription: details.resource?.description || "BlockRun Solana AI API call",
@@ -9918,6 +10079,15 @@ var SolanaLLMClient = class {
9918
10079
  forceFreshBlockhash
9919
10080
  }
9920
10081
  );
10082
+ return { paymentPayload, costUsd: parseFloat(details.amount) / 1e6 };
10083
+ }
10084
+ async handlePaymentAndRetry(url, body, response, forceFreshBlockhash = false) {
10085
+ const { paymentPayload, costUsd } = await this.signPaymentFrom402(
10086
+ url,
10087
+ response,
10088
+ forceFreshBlockhash,
10089
+ `${this.apiUrl}/v1/chat/completions`
10090
+ );
9921
10091
  const retryResponse = await this.fetchWithTimeout(url, {
9922
10092
  method: "POST",
9923
10093
  headers: {
@@ -9927,24 +10097,8 @@ var SolanaLLMClient = class {
9927
10097
  },
9928
10098
  body: JSON.stringify(body)
9929
10099
  });
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;
10100
+ await this.assertPaid(retryResponse);
10101
+ this.recordSettlement(costUsd);
9948
10102
  return retryResponse.json();
9949
10103
  }
9950
10104
  async requestWithPaymentRaw(endpoint, body) {
@@ -9978,51 +10132,7 @@ var SolanaLLMClient = class {
9978
10132
  }
9979
10133
  }
9980
10134
  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
- );
10135
+ const { paymentPayload, costUsd } = await this.signPaymentFrom402(url, response, forceFreshBlockhash);
10026
10136
  const retryResponse = await this.fetchWithTimeout(url, {
10027
10137
  method: "POST",
10028
10138
  headers: {
@@ -10032,24 +10142,8 @@ var SolanaLLMClient = class {
10032
10142
  },
10033
10143
  body: JSON.stringify(body)
10034
10144
  });
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;
10145
+ await this.assertPaid(retryResponse);
10146
+ this.recordSettlement(costUsd);
10053
10147
  return retryResponse.json();
10054
10148
  }
10055
10149
  async getWithPaymentRaw(endpoint, params) {
@@ -10082,51 +10176,7 @@ var SolanaLLMClient = class {
10082
10176
  }
10083
10177
  }
10084
10178
  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
- );
10179
+ const { paymentPayload, costUsd } = await this.signPaymentFrom402(url, response, forceFreshBlockhash);
10130
10180
  const query = params ? "?" + new URLSearchParams(params).toString() : "";
10131
10181
  const retryUrl = `${this.apiUrl}${endpoint}${query}`;
10132
10182
  const retryResponse = await this.fetchWithTimeout(retryUrl, {
@@ -10136,25 +10186,44 @@ var SolanaLLMClient = class {
10136
10186
  "PAYMENT-SIGNATURE": paymentPayload
10137
10187
  }
10138
10188
  });
10139
- if (retryResponse.status === 402) {
10140
- if (await isSafeStaleBlockhashResponse(retryResponse)) {
10189
+ await this.assertPaid(retryResponse);
10190
+ this.recordSettlement(costUsd);
10191
+ return retryResponse.json();
10192
+ }
10193
+ /**
10194
+ * Fail a post-payment response, telling a re-signable rejection from a real one.
10195
+ *
10196
+ * A `402` here is not "pay again": it is the gateway refusing the payment we
10197
+ * just signed. Only a rejection the gateway attributes to the VERIFICATION
10198
+ * phase is safe to re-sign — anything settled, or ambiguous about which
10199
+ * phase it failed in, could already have moved USDC, and re-signing it would
10200
+ * pay twice. {@link isSafeStaleBlockhashResponse} is where that judgement
10201
+ * lives.
10202
+ * @param response - the reply to the paid request.
10203
+ * @throws SafeStaleBlockhashError when the caller should re-sign, PaymentError
10204
+ * when it should not, APIError for any other failure.
10205
+ */
10206
+ async assertPaid(response) {
10207
+ if (response.status === 402) {
10208
+ if (await isSafeStaleBlockhashResponse(response)) {
10141
10209
  throw new SafeStaleBlockhashError();
10142
10210
  }
10143
10211
  throw new PaymentError("Payment was rejected. Check your Solana USDC balance.");
10144
10212
  }
10145
- if (!retryResponse.ok) {
10213
+ if (!response.ok) {
10146
10214
  let errorBody;
10147
10215
  try {
10148
- errorBody = await retryResponse.json();
10216
+ errorBody = await response.json();
10149
10217
  } catch {
10150
10218
  errorBody = { error: "Request failed" };
10151
10219
  }
10152
- throw new APIError(`API error after payment: ${retryResponse.status}`, retryResponse.status, sanitizeErrorResponse(errorBody));
10220
+ throw new APIError(`API error after payment: ${response.status}`, response.status, sanitizeErrorResponse(errorBody));
10153
10221
  }
10154
- const costUsd = parseFloat(details.amount) / 1e6;
10222
+ }
10223
+ /** Count one settled x402 payment against the session total. */
10224
+ recordSettlement(costUsd) {
10155
10225
  this.sessionCalls += 1;
10156
10226
  this.sessionTotalUsd += costUsd;
10157
- return retryResponse.json();
10158
10227
  }
10159
10228
  async fetchWithTimeout(url, options) {
10160
10229
  const controller = new AbortController();
@@ -10558,7 +10627,8 @@ var OpenAI = class {
10558
10627
  }
10559
10628
  client;
10560
10629
  constructor(options = {}) {
10561
- const privateKey = options.walletKey || options.privateKey;
10630
+ const aliases = [options.walletKey, options.privateKey].filter((k) => k !== void 0);
10631
+ const privateKey = aliases.find((k) => k !== "") ?? aliases[0];
10562
10632
  const apiUrl = resolveApiKeyAuth({ apiKey: options.apiKey, privateKey, apiUrl: options.baseURL })?.apiUrl ?? options.baseURL ?? "https://blockrun.ai/api";
10563
10633
  const timeout = options.timeout ?? DEFAULT_TIMEOUT;
10564
10634
  this.client = new LLMClient({
@@ -10617,7 +10687,9 @@ var AnthropicClient = class {
10617
10687
  this._client = new Anthropic({
10618
10688
  baseURL: this._apiUrl,
10619
10689
  apiKey: "blockrun",
10620
- fetch: this._x402Fetch.bind(this)
10690
+ fetch: this._x402Fetch.bind(this),
10691
+ // Account POSTs may already be billed when an upstream error arrives.
10692
+ ...this.apiAuth ? { maxRetries: 0 } : {}
10621
10693
  });
10622
10694
  return this._client;
10623
10695
  })();
@@ -10626,6 +10698,10 @@ var AnthropicClient = class {
10626
10698
  async _x402Fetch(input, init) {
10627
10699
  const controller = new AbortController();
10628
10700
  const timeoutId = setTimeout(() => controller.abort(), this._timeout);
10701
+ const callerSignal = init?.signal ?? (input instanceof Request ? input.signal : void 0);
10702
+ const onAbort = () => controller.abort(callerSignal?.reason);
10703
+ callerSignal?.addEventListener("abort", onAbort, { once: true });
10704
+ if (callerSignal?.aborted) onAbort();
10629
10705
  try {
10630
10706
  const mergedInit = { ...init, signal: controller.signal };
10631
10707
  if (this.apiAuth) return await this.apiAuth.fetch(input, mergedInit, false);
@@ -10679,6 +10755,7 @@ var AnthropicClient = class {
10679
10755
  }
10680
10756
  return response;
10681
10757
  } finally {
10758
+ callerSignal?.removeEventListener("abort", onAbort);
10682
10759
  clearTimeout(timeoutId);
10683
10760
  }
10684
10761
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/llm",
3
- "version": "3.14.3",
3
+ "version": "3.15.1",
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": {