@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/README.md +45 -10
- package/dist/index.cjs +246 -169
- package/dist/index.d.cts +101 -1
- package/dist/index.d.ts +101 -1
- package/dist/index.js +246 -169
- package/package.json +4 -5
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
|
|
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.
|
|
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
|
|
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}).
|
|
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
|
-
|
|
8649
|
-
|
|
8650
|
-
|
|
8651
|
-
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
9931
|
-
|
|
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
|
-
|
|
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
|
-
|
|
10036
|
-
|
|
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
|
-
|
|
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
|
-
|
|
10140
|
-
|
|
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 (!
|
|
10213
|
+
if (!response.ok) {
|
|
10146
10214
|
let errorBody;
|
|
10147
10215
|
try {
|
|
10148
|
-
errorBody = await
|
|
10216
|
+
errorBody = await response.json();
|
|
10149
10217
|
} catch {
|
|
10150
10218
|
errorBody = { error: "Request failed" };
|
|
10151
10219
|
}
|
|
10152
|
-
throw new APIError(`API error after payment: ${
|
|
10220
|
+
throw new APIError(`API error after payment: ${response.status}`, response.status, sanitizeErrorResponse(errorBody));
|
|
10153
10221
|
}
|
|
10154
|
-
|
|
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
|
|
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.
|
|
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": {
|