@blockrun/llm 3.20.0 → 3.21.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/README.md +35 -10
- package/dist/index.cjs +366 -106
- package/dist/index.d.cts +75 -21
- package/dist/index.d.ts +75 -21
- package/dist/index.js +363 -103
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -206,7 +206,13 @@ function resolveApiKeyAuth(options) {
|
|
|
206
206
|
return new ApiKeyAuth(key.trim(), base);
|
|
207
207
|
}
|
|
208
208
|
function accountErrorDisposition(err) {
|
|
209
|
-
|
|
209
|
+
if (!(err instanceof APIError)) return "paid-or-in-doubt";
|
|
210
|
+
if (err.statusCode >= 400 && err.statusCode < 500) return "unpaid";
|
|
211
|
+
if (err.statusCode >= 500 && hasGatewayErrorEnvelope(err.response)) return "unpaid";
|
|
212
|
+
return "paid-or-in-doubt";
|
|
213
|
+
}
|
|
214
|
+
function hasGatewayErrorEnvelope(response) {
|
|
215
|
+
return !!response && typeof response === "object" && typeof response.message === "string" && response.message.length > 0;
|
|
210
216
|
}
|
|
211
217
|
function requireWallet(value) {
|
|
212
218
|
if (value === void 0) throw new Error("This operation requires a wallet; the client uses API key account billing.");
|
|
@@ -4558,7 +4564,7 @@ function getCostSummary() {
|
|
|
4558
4564
|
}
|
|
4559
4565
|
|
|
4560
4566
|
// src/version.ts
|
|
4561
|
-
var SDK_VERSION = "3.
|
|
4567
|
+
var SDK_VERSION = "3.21.0";
|
|
4562
4568
|
var USER_AGENT = `blockrun-ts/${SDK_VERSION}`;
|
|
4563
4569
|
|
|
4564
4570
|
// src/client.ts
|
|
@@ -9323,11 +9329,11 @@ var WALLET_DIR = path4.join(os3.homedir(), ".blockrun");
|
|
|
9323
9329
|
var SOLANA_WALLET_FILE = path4.join(WALLET_DIR, ".solana-session");
|
|
9324
9330
|
async function createSolanaWallet() {
|
|
9325
9331
|
const { Keypair } = await loadSolanaWeb3("Solana wallet operations");
|
|
9326
|
-
const
|
|
9332
|
+
const bs583 = await import("bs58");
|
|
9327
9333
|
const keypair = Keypair.generate();
|
|
9328
9334
|
return {
|
|
9329
9335
|
address: keypair.publicKey.toBase58(),
|
|
9330
|
-
privateKey: (
|
|
9336
|
+
privateKey: (bs583.default ?? bs583).encode(keypair.secretKey)
|
|
9331
9337
|
};
|
|
9332
9338
|
}
|
|
9333
9339
|
async function solanaKeyToBytes(privateKey) {
|
|
@@ -9365,8 +9371,8 @@ async function solanaKeyToBytes(privateKey) {
|
|
|
9365
9371
|
return bytes;
|
|
9366
9372
|
}
|
|
9367
9373
|
try {
|
|
9368
|
-
const
|
|
9369
|
-
const bytes = (
|
|
9374
|
+
const bs583 = await import("bs58");
|
|
9375
|
+
const bytes = (bs583.default ?? bs583).decode(key);
|
|
9370
9376
|
if (bytes.length !== 64) {
|
|
9371
9377
|
throw new Error(`Invalid Solana key length: expected 64 bytes, got ${bytes.length}`);
|
|
9372
9378
|
}
|
|
@@ -9545,12 +9551,52 @@ async function getOrCreateSolanaWallet() {
|
|
|
9545
9551
|
return { address, privateKey, isNew: true };
|
|
9546
9552
|
}
|
|
9547
9553
|
|
|
9554
|
+
// src/poll-ownership.ts
|
|
9555
|
+
import { createPrivateKey, createPublicKey, sign as ed25519Sign } from "crypto";
|
|
9556
|
+
import bs58 from "bs58";
|
|
9557
|
+
var POLL_AUTH_HEADERS = {
|
|
9558
|
+
wallet: "x-poll-wallet",
|
|
9559
|
+
timestamp: "x-poll-timestamp",
|
|
9560
|
+
signature: "x-poll-signature"
|
|
9561
|
+
};
|
|
9562
|
+
function pollAuthMessage(kind, jobId, timestampS) {
|
|
9563
|
+
return `blockrun-poll:v1:${kind}:${jobId}:${timestampS}`;
|
|
9564
|
+
}
|
|
9565
|
+
function pollJobIdFromUrl(pollUrl) {
|
|
9566
|
+
const path7 = new URL(pollUrl, "https://placeholder.invalid").pathname.replace(/\/+$/, "");
|
|
9567
|
+
const segment = path7.slice(path7.lastIndexOf("/") + 1);
|
|
9568
|
+
if (!segment) throw new Error(`poll_url has no job id segment: ${pollUrl}`);
|
|
9569
|
+
return decodeURIComponent(segment);
|
|
9570
|
+
}
|
|
9571
|
+
var ED25519_PKCS8_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
|
|
9572
|
+
function signPollOwnership(secretKey, kind, jobId, nowMs = Date.now()) {
|
|
9573
|
+
if (secretKey.length !== 64 && secretKey.length !== 32) {
|
|
9574
|
+
throw new Error(`Solana secret key must be 64 bytes (or a 32-byte seed), got ${secretKey.length}`);
|
|
9575
|
+
}
|
|
9576
|
+
const seed = Buffer.from(secretKey.subarray(0, 32));
|
|
9577
|
+
const privateKey = createPrivateKey({
|
|
9578
|
+
key: Buffer.concat([ED25519_PKCS8_PREFIX, seed]),
|
|
9579
|
+
format: "der",
|
|
9580
|
+
type: "pkcs8"
|
|
9581
|
+
});
|
|
9582
|
+
const jwk = createPublicKey(privateKey).export({ format: "jwk" });
|
|
9583
|
+
if (!jwk.x) throw new Error("Could not derive the Ed25519 public key");
|
|
9584
|
+
const publicKey = Buffer.from(jwk.x, "base64url");
|
|
9585
|
+
const timestampS = Math.floor(nowMs / 1e3);
|
|
9586
|
+
const signature = ed25519Sign(null, Buffer.from(pollAuthMessage(kind, jobId, timestampS), "utf8"), privateKey);
|
|
9587
|
+
return {
|
|
9588
|
+
[POLL_AUTH_HEADERS.wallet]: bs58.encode(publicKey),
|
|
9589
|
+
[POLL_AUTH_HEADERS.timestamp]: String(timestampS),
|
|
9590
|
+
[POLL_AUTH_HEADERS.signature]: bs58.encode(signature)
|
|
9591
|
+
};
|
|
9592
|
+
}
|
|
9593
|
+
|
|
9548
9594
|
// src/solana-batch.ts
|
|
9549
9595
|
import { AsyncLocalStorage } from "async_hooks";
|
|
9550
9596
|
import { randomUUID } from "crypto";
|
|
9551
9597
|
import * as fs6 from "fs";
|
|
9552
9598
|
import * as path5 from "path";
|
|
9553
|
-
import
|
|
9599
|
+
import bs582 from "bs58";
|
|
9554
9600
|
var BATCH_SCHEME = "batch-settlement";
|
|
9555
9601
|
var BLOCKRUN_SOL_OPERATOR = "5YKPQUFjw5WQqhSUkEGKNNfYYVqnRRNbpYyL71qQ1vm3";
|
|
9556
9602
|
var DEFAULT_BATCH_RATE_LIMIT = { maxAttempts: 3, maxWaitMs: 6e4 };
|
|
@@ -10110,7 +10156,7 @@ function decodeChannelAccount(owner, data) {
|
|
|
10110
10156
|
return void 0;
|
|
10111
10157
|
}
|
|
10112
10158
|
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
10113
|
-
const key = (offset) =>
|
|
10159
|
+
const key = (offset) => bs582.encode(data.subarray(offset, offset + 32));
|
|
10114
10160
|
return {
|
|
10115
10161
|
deposit: view.getBigUint64(CHANNEL.deposit, true),
|
|
10116
10162
|
settled: view.getBigUint64(CHANNEL.settled, true),
|
|
@@ -11406,7 +11452,7 @@ var SolanaBatchPayer = class {
|
|
|
11406
11452
|
cause: await afterPaymentError(response)
|
|
11407
11453
|
};
|
|
11408
11454
|
}
|
|
11409
|
-
return { kind: "not_charged", reason: "payment_required", status: status2, depositSafe:
|
|
11455
|
+
return { kind: "not_charged", reason: "payment_required", status: status2, errorReason: receipt?.errorReason, depositSafe: provesNothingBroadcast(receipt) };
|
|
11410
11456
|
}
|
|
11411
11457
|
const refusal = await batchRefusal(response);
|
|
11412
11458
|
if (refusal) {
|
|
@@ -11670,6 +11716,39 @@ var DEFAULT_MAX_TOKENS2 = 1024;
|
|
|
11670
11716
|
var DEFAULT_TIMEOUT14 = 6e4;
|
|
11671
11717
|
var STALE_BLOCKHASH_RETRY_BACKOFFS_MS = [500, 2e3];
|
|
11672
11718
|
var MAX_PAYMENT_FAILURE_BYTES = 64 * 1024;
|
|
11719
|
+
var DEFAULT_VIDEO_MODEL = "xai/grok-imagine-video";
|
|
11720
|
+
var DEFAULT_MUSIC_MODEL = "minimax/music-2.5+";
|
|
11721
|
+
var DEFAULT_VIDEO_BUDGET_MS = 9e5;
|
|
11722
|
+
var DEFAULT_MUSIC_BUDGET_MS = 3e5;
|
|
11723
|
+
var VIDEO_POLL_INTERVAL_MS = 5e3;
|
|
11724
|
+
var MAX_PAID_POLL_REFUSALS = 4;
|
|
11725
|
+
var MAX_RETRY_AFTER_MS2 = 12e4;
|
|
11726
|
+
var MEDIA_SUBMIT_TIMEOUT_MS = 12e4;
|
|
11727
|
+
function sleep3(ms) {
|
|
11728
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
11729
|
+
}
|
|
11730
|
+
async function readJsonOrEmpty(response) {
|
|
11731
|
+
try {
|
|
11732
|
+
const parsed = await response.json();
|
|
11733
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
11734
|
+
} catch {
|
|
11735
|
+
return {};
|
|
11736
|
+
}
|
|
11737
|
+
}
|
|
11738
|
+
function isPendingPollStatus(status2) {
|
|
11739
|
+
return status2 === 202 || status2 === 503 || status2 === 504;
|
|
11740
|
+
}
|
|
11741
|
+
function retryAfterMs(response) {
|
|
11742
|
+
const raw = response.headers.get("retry-after");
|
|
11743
|
+
if (!raw || !/^\d+$/.test(raw.trim())) return void 0;
|
|
11744
|
+
return Math.min(Number(raw.trim()) * 1e3, MAX_RETRY_AFTER_MS2);
|
|
11745
|
+
}
|
|
11746
|
+
function pollKindFor(url) {
|
|
11747
|
+
const path7 = new URL(url).pathname;
|
|
11748
|
+
if (path7.includes("/v1/videos")) return "video";
|
|
11749
|
+
if (path7.includes("/v1/audio/")) return "audio";
|
|
11750
|
+
return "image";
|
|
11751
|
+
}
|
|
11673
11752
|
var SafeStaleBlockhashError = class extends PaymentError {
|
|
11674
11753
|
constructor() {
|
|
11675
11754
|
super("Payment verification used an expired Solana blockhash; retrying with a fresh quote.");
|
|
@@ -12179,6 +12258,54 @@ var SolanaLLMClient = class {
|
|
|
12179
12258
|
const data = await this.requestWithPaymentRaw("/v1/images/image2image", body);
|
|
12180
12259
|
return data;
|
|
12181
12260
|
}
|
|
12261
|
+
/**
|
|
12262
|
+
* Generate a short video on Solana.
|
|
12263
|
+
*
|
|
12264
|
+
* The POST is verified, not charged. The job is then polled with a wallet
|
|
12265
|
+
* signature (no payment) until it finishes, and ONE payment is signed for
|
|
12266
|
+
* the finished video; a failed job is never charged. If the gateway does not
|
|
12267
|
+
* accept poll signatures for the job, it is polled with a payment per poll
|
|
12268
|
+
* instead, which is likewise only charged on completion.
|
|
12269
|
+
*
|
|
12270
|
+
* @param prompt - text description of the video.
|
|
12271
|
+
* @param options - `model` (default `xai/grok-imagine-video`), `budgetMs`
|
|
12272
|
+
* (total wait, default 15 min); any other keys are sent verbatim, so use
|
|
12273
|
+
* the gateway's snake_case names (`duration_seconds`, `image_url`,
|
|
12274
|
+
* `aspect_ratio`, `resolution`, …).
|
|
12275
|
+
*/
|
|
12276
|
+
async video(prompt, options = {}) {
|
|
12277
|
+
const { model, budgetMs, ...rest } = options;
|
|
12278
|
+
const body = { ...rest, model: model || DEFAULT_VIDEO_MODEL, prompt };
|
|
12279
|
+
const data = await this.requestWithPaymentRaw(
|
|
12280
|
+
"/v1/videos/generations",
|
|
12281
|
+
body,
|
|
12282
|
+
{ pollBudgetMs: budgetMs ?? DEFAULT_VIDEO_BUDGET_MS, submitTimeoutMs: Math.max(this.timeout, MEDIA_SUBMIT_TIMEOUT_MS) }
|
|
12283
|
+
);
|
|
12284
|
+
return data;
|
|
12285
|
+
}
|
|
12286
|
+
/**
|
|
12287
|
+
* Generate a music track on Solana. Paid at the POST; a slow track is then
|
|
12288
|
+
* polled with a wallet signature, never another payment.
|
|
12289
|
+
*
|
|
12290
|
+
* @param prompt - music style, mood, or description.
|
|
12291
|
+
* @param options - `model` (default `minimax/music-2.5+`), `instrumental`
|
|
12292
|
+
* (default true), `lyrics` (requires `instrumental: false`), `budgetMs`
|
|
12293
|
+
* (total wait, default 5 min); other keys are sent verbatim.
|
|
12294
|
+
*/
|
|
12295
|
+
async music(prompt, options = {}) {
|
|
12296
|
+
const { model, instrumental: instrumentalOption, lyrics: rawLyrics, budgetMs, ...rest } = options;
|
|
12297
|
+
const instrumental = instrumentalOption ?? true;
|
|
12298
|
+
const lyrics = rawLyrics?.trim();
|
|
12299
|
+
if (instrumental && lyrics) throw new Error("Cannot specify lyrics when instrumental is true");
|
|
12300
|
+
const body = { ...rest, model: model || DEFAULT_MUSIC_MODEL, prompt, instrumental };
|
|
12301
|
+
if (lyrics) body.lyrics = lyrics;
|
|
12302
|
+
const data = await this.requestWithPaymentRaw(
|
|
12303
|
+
"/v1/audio/generations",
|
|
12304
|
+
body,
|
|
12305
|
+
{ pollBudgetMs: budgetMs ?? DEFAULT_MUSIC_BUDGET_MS, submitTimeoutMs: Math.max(this.timeout, MEDIA_SUBMIT_TIMEOUT_MS) }
|
|
12306
|
+
);
|
|
12307
|
+
return data;
|
|
12308
|
+
}
|
|
12182
12309
|
/** Standalone search (Solana payment). */
|
|
12183
12310
|
async search(query, options) {
|
|
12184
12311
|
const body = { query, max_results: options?.maxResults || 10 };
|
|
@@ -12474,106 +12601,221 @@ var SolanaLLMClient = class {
|
|
|
12474
12601
|
}
|
|
12475
12602
|
}
|
|
12476
12603
|
/**
|
|
12477
|
-
*
|
|
12478
|
-
*
|
|
12479
|
-
*
|
|
12480
|
-
*
|
|
12481
|
-
*
|
|
12482
|
-
*
|
|
12483
|
-
*
|
|
12484
|
-
*
|
|
12485
|
-
*
|
|
12486
|
-
*
|
|
12487
|
-
*
|
|
12488
|
-
*
|
|
12489
|
-
*
|
|
12490
|
-
*
|
|
12491
|
-
*
|
|
12492
|
-
*
|
|
12493
|
-
|
|
12494
|
-
|
|
12495
|
-
*
|
|
12496
|
-
*
|
|
12497
|
-
*
|
|
12498
|
-
*
|
|
12499
|
-
*
|
|
12500
|
-
*
|
|
12501
|
-
*
|
|
12502
|
-
*
|
|
12503
|
-
*
|
|
12504
|
-
*
|
|
12505
|
-
*
|
|
12506
|
-
*
|
|
12507
|
-
*
|
|
12508
|
-
* it would book spend for jobs that were never charged.
|
|
12509
|
-
* 2. **Every poll re-signs.** One authorization cannot be replayed across a
|
|
12510
|
-
* long render. The gateway binds the job to the payer address rather than
|
|
12511
|
-
* to the signature, so a fresh signature from the same wallet is accepted
|
|
12512
|
-
* and is never charged again.
|
|
12604
|
+
* Follow a 202 `{ id, poll_url }` to completion on a Solana media route
|
|
12605
|
+
* (image, music, video).
|
|
12606
|
+
*
|
|
12607
|
+
* **Polls prove ownership, they do not pay.** A Solana payment is a
|
|
12608
|
+
* transaction pinned to a recent blockhash (~60s), so paying every poll of a
|
|
12609
|
+
* multi-minute job meant signing a fresh transaction every few seconds. Each
|
|
12610
|
+
* poll here instead carries the `x-poll-*` headers: the submitting wallet's
|
|
12611
|
+
* ed25519 signature over `blockrun-poll:v1:<kind>:<job id>:<unix seconds>`
|
|
12612
|
+
* (see `poll-ownership.ts`). That proof never charges.
|
|
12613
|
+
*
|
|
12614
|
+
* **Who pays when:**
|
|
12615
|
+
* - image / music: the POST already settled (sol settles at submit because the
|
|
12616
|
+
* signed transaction would be dead by completion). The caller recorded the
|
|
12617
|
+
* cost at POST; this loop only fetches the result and never signs a payment.
|
|
12618
|
+
* A batch-billed image job still answers its proof poll with 402: that job
|
|
12619
|
+
* falls back to the per-poll payment below.
|
|
12620
|
+
* - video: settles on completion, and a failed job is never charged. A
|
|
12621
|
+
* finished, unpaid job answers the proof poll with 402 `{status:"completed"}`
|
|
12622
|
+
* and a normal x402 challenge; ONE payment is signed from that challenge and
|
|
12623
|
+
* the same poll is re-sent with it. The cost is recorded here, on delivery.
|
|
12624
|
+
* A paid poll the gateway answers 402 (settlement in progress on another
|
|
12625
|
+
* poll, outcome unknown, stale blockhash) is re-polled after its
|
|
12626
|
+
* Retry-After — the gateway's per-job settlement claim makes a later fresh
|
|
12627
|
+
* payment safe — up to `MAX_PAID_POLL_REFUSALS` times.
|
|
12628
|
+
*
|
|
12629
|
+
* **Fallback, once per job:** if the gateway refuses the proof with its "no
|
|
12630
|
+
* such job" answer (video 400, image/music 404) or answers it with a plain
|
|
12631
|
+
* payment challenge (a gateway or job that predates poll proofs), the rest of
|
|
12632
|
+
* this job is polled the old way — an x-payment signed per poll, which the
|
|
12633
|
+
* gateway verifies against the job's payer and does not charge until a video
|
|
12634
|
+
* completes. Logged to stderr; never switches back.
|
|
12513
12635
|
*/
|
|
12514
|
-
async followSolanaJob(submitBody, budgetMs, intervalMs = 2e3) {
|
|
12636
|
+
async followSolanaJob(submitBody, budgetMs, kind, intervalMs = kind === "video" ? VIDEO_POLL_INTERVAL_MS : 2e3) {
|
|
12515
12637
|
const id = submitBody.id;
|
|
12516
12638
|
const pollPath = submitBody.poll_url;
|
|
12517
12639
|
if (!id || !pollPath) return submitBody;
|
|
12518
|
-
const pollUrl =
|
|
12640
|
+
const pollUrl = this.resolvePollUrl(pollPath);
|
|
12641
|
+
const jobId = pollJobIdFromUrl(pollUrl);
|
|
12519
12642
|
const deadline = Date.now() + budgetMs;
|
|
12520
12643
|
let lastStatus = submitBody.status || "queued";
|
|
12644
|
+
let ownershipPolls = true;
|
|
12645
|
+
let waitMs = intervalMs;
|
|
12646
|
+
let paidRefusalsLeft = MAX_PAID_POLL_REFUSALS;
|
|
12647
|
+
let videoCostUsd;
|
|
12648
|
+
let settleAttempted = false;
|
|
12649
|
+
const fallBack = (why) => {
|
|
12650
|
+
ownershipPolls = false;
|
|
12651
|
+
waitMs = 0;
|
|
12652
|
+
console.warn(
|
|
12653
|
+
`[@blockrun/llm] ${kind} job ${id}: ${why}; falling back to x-payment polling for this job`
|
|
12654
|
+
);
|
|
12655
|
+
};
|
|
12656
|
+
const deliver = (data, response) => {
|
|
12657
|
+
if (kind === "video") {
|
|
12658
|
+
if (videoCostUsd !== void 0) this.recordSettlement(videoCostUsd);
|
|
12659
|
+
const txHash = response.headers.get("x-payment-receipt");
|
|
12660
|
+
if (txHash) data.txHash = txHash;
|
|
12661
|
+
}
|
|
12662
|
+
return data;
|
|
12663
|
+
};
|
|
12664
|
+
const failIfFailed = (data, status2) => {
|
|
12665
|
+
if (lastStatus === "failed") {
|
|
12666
|
+
throw new APIError(
|
|
12667
|
+
`Upstream job failed: ${data.error || "unknown"}`,
|
|
12668
|
+
status2,
|
|
12669
|
+
sanitizeErrorResponse(data)
|
|
12670
|
+
);
|
|
12671
|
+
}
|
|
12672
|
+
};
|
|
12521
12673
|
while (Date.now() < deadline) {
|
|
12522
|
-
|
|
12523
|
-
|
|
12524
|
-
|
|
12525
|
-
|
|
12526
|
-
|
|
12527
|
-
|
|
12528
|
-
|
|
12529
|
-
|
|
12674
|
+
if (waitMs > 0) await sleep3(Math.min(waitMs, Math.max(0, deadline - Date.now())));
|
|
12675
|
+
waitMs = intervalMs;
|
|
12676
|
+
let challenge;
|
|
12677
|
+
if (ownershipPolls) {
|
|
12678
|
+
const proof = signPollOwnership(await solanaKeyToBytes(this.privateKey), kind, jobId);
|
|
12679
|
+
const polled = await this.fetchWithTimeout(pollUrl, {
|
|
12680
|
+
method: "GET",
|
|
12681
|
+
headers: { "User-Agent": USER_AGENT, ...proof }
|
|
12682
|
+
});
|
|
12683
|
+
const data2 = await readJsonOrEmpty(polled);
|
|
12684
|
+
if (polled.status === 402) {
|
|
12685
|
+
if (kind === "video" && data2.status === "completed") {
|
|
12686
|
+
lastStatus = "completed";
|
|
12687
|
+
settleAttempted = true;
|
|
12688
|
+
challenge = new Response(JSON.stringify(data2), { status: 402, headers: polled.headers });
|
|
12689
|
+
} else {
|
|
12690
|
+
fallBack(
|
|
12691
|
+
kind === "image" ? "the gateway asks for a payment on this poll (a batch-billed job, or a gateway without poll proofs)" : "the gateway answered the poll proof with a payment challenge (a gateway without poll proofs)"
|
|
12692
|
+
);
|
|
12693
|
+
continue;
|
|
12694
|
+
}
|
|
12695
|
+
} else if (polled.status === (kind === "video" ? 400 : 404)) {
|
|
12696
|
+
fallBack(`the gateway refused the poll proof (HTTP ${polled.status})`);
|
|
12697
|
+
continue;
|
|
12698
|
+
} else {
|
|
12699
|
+
lastStatus = data2.status || lastStatus;
|
|
12700
|
+
failIfFailed(data2, polled.status);
|
|
12701
|
+
if (polled.status === 200 && lastStatus === "completed") return deliver(data2, polled);
|
|
12702
|
+
if (!isPendingPollStatus(polled.status)) {
|
|
12703
|
+
throw new APIError(`Poll failed: ${polled.status}`, polled.status, sanitizeErrorResponse(data2));
|
|
12704
|
+
}
|
|
12705
|
+
waitMs = retryAfterMs(polled) ?? intervalMs;
|
|
12706
|
+
continue;
|
|
12707
|
+
}
|
|
12530
12708
|
}
|
|
12531
|
-
if (challenge
|
|
12532
|
-
|
|
12533
|
-
|
|
12534
|
-
|
|
12535
|
-
}
|
|
12536
|
-
|
|
12709
|
+
if (!challenge) {
|
|
12710
|
+
const unpaid = await this.fetchWithTimeout(pollUrl, {
|
|
12711
|
+
method: "GET",
|
|
12712
|
+
headers: { "User-Agent": USER_AGENT }
|
|
12713
|
+
});
|
|
12714
|
+
if (unpaid.status === 200) {
|
|
12715
|
+
return deliver(await unpaid.json(), unpaid);
|
|
12716
|
+
}
|
|
12717
|
+
if (unpaid.status !== 402) {
|
|
12718
|
+
let errorBody;
|
|
12719
|
+
try {
|
|
12720
|
+
errorBody = await unpaid.json();
|
|
12721
|
+
} catch {
|
|
12722
|
+
errorBody = { error: "Poll failed" };
|
|
12723
|
+
}
|
|
12724
|
+
throw new APIError(`Poll failed: ${unpaid.status}`, unpaid.status, sanitizeErrorResponse(errorBody));
|
|
12537
12725
|
}
|
|
12538
|
-
|
|
12726
|
+
challenge = unpaid;
|
|
12539
12727
|
}
|
|
12540
|
-
const { paymentPayload } = await this.signPaymentFrom402(
|
|
12728
|
+
const { paymentPayload, costUsd } = await this.signPaymentFrom402(
|
|
12541
12729
|
pollUrl,
|
|
12542
12730
|
challenge,
|
|
12543
12731
|
true,
|
|
12544
12732
|
// always a fresh blockhash; the last one is stale by now
|
|
12545
12733
|
pollUrl
|
|
12546
12734
|
);
|
|
12547
|
-
|
|
12548
|
-
|
|
12549
|
-
headers: { "User-Agent": USER_AGENT, "PAYMENT-SIGNATURE": paymentPayload }
|
|
12550
|
-
});
|
|
12551
|
-
let data = {};
|
|
12735
|
+
if (kind === "video") videoCostUsd = costUsd;
|
|
12736
|
+
let paid;
|
|
12552
12737
|
try {
|
|
12553
|
-
|
|
12554
|
-
|
|
12738
|
+
paid = await this.fetchWithTimeout(pollUrl, {
|
|
12739
|
+
method: "GET",
|
|
12740
|
+
headers: { "User-Agent": USER_AGENT, "PAYMENT-SIGNATURE": paymentPayload }
|
|
12741
|
+
});
|
|
12742
|
+
} catch (error) {
|
|
12743
|
+
if (kind === "video" && lastStatus === "completed") settleAttempted = true;
|
|
12744
|
+
if (--paidRefusalsLeft <= 0) {
|
|
12745
|
+
throw withDisposition(
|
|
12746
|
+
new PaymentError(
|
|
12747
|
+
`Paid poll failed in transit (${error instanceof Error ? error.message : String(error)}). The payment may have settled: resume the job by polling ${pollUrl} with the same wallet before submitting again.`
|
|
12748
|
+
),
|
|
12749
|
+
"paid-or-in-doubt"
|
|
12750
|
+
);
|
|
12751
|
+
}
|
|
12752
|
+
continue;
|
|
12555
12753
|
}
|
|
12754
|
+
const data = await readJsonOrEmpty(paid);
|
|
12556
12755
|
lastStatus = data.status || lastStatus;
|
|
12557
|
-
|
|
12558
|
-
|
|
12559
|
-
|
|
12560
|
-
|
|
12561
|
-
|
|
12562
|
-
|
|
12563
|
-
|
|
12564
|
-
|
|
12565
|
-
|
|
12756
|
+
failIfFailed(data, paid.status);
|
|
12757
|
+
if (paid.status === 200 && lastStatus === "completed") return deliver(data, paid);
|
|
12758
|
+
if (paid.status === 402 && kind === "video") {
|
|
12759
|
+
if (data.error === "Payment verification failed" && !await isSafeStaleBlockhashResponse(new Response(JSON.stringify(data), { status: 402 }))) {
|
|
12760
|
+
throw new PaymentError(
|
|
12761
|
+
`Payment for the finished video was rejected. Check your Solana USDC balance, then resume the job by polling ${pollUrl} with the same wallet.`
|
|
12762
|
+
);
|
|
12763
|
+
}
|
|
12764
|
+
settleAttempted = true;
|
|
12765
|
+
if (--paidRefusalsLeft <= 0) {
|
|
12766
|
+
throw withDisposition(
|
|
12767
|
+
new PaymentError(
|
|
12768
|
+
`Payment for the finished video did not settle after ${MAX_PAID_POLL_REFUSALS} attempts (last: ${data.error || "HTTP 402"}). It may still settle: resume the job by polling ${pollUrl} with the same wallet before submitting again.`
|
|
12769
|
+
),
|
|
12770
|
+
"paid-or-in-doubt"
|
|
12771
|
+
);
|
|
12772
|
+
}
|
|
12773
|
+
waitMs = retryAfterMs(paid) ?? intervalMs;
|
|
12774
|
+
continue;
|
|
12566
12775
|
}
|
|
12567
|
-
if (paid.status
|
|
12776
|
+
if (!isPendingPollStatus(paid.status) && paid.status !== 200) {
|
|
12568
12777
|
throw new APIError(`Poll failed: ${paid.status}`, paid.status, sanitizeErrorResponse(data));
|
|
12569
12778
|
}
|
|
12779
|
+
waitMs = retryAfterMs(paid) ?? intervalMs;
|
|
12570
12780
|
}
|
|
12781
|
+
const billing = kind === "video" ? settleAttempted ? "A payment for the finished video was sent and may still settle." : "Nothing was charged: a video is charged only when a completed poll is paid." : "The submit was already paid.";
|
|
12571
12782
|
throw new APIError(
|
|
12572
|
-
`Job did not complete within ${Math.round(budgetMs / 1e3)}s (last status: ${lastStatus}).
|
|
12783
|
+
`Job did not complete within ${Math.round(budgetMs / 1e3)}s (last status: ${lastStatus}). ${billing} Resume by polling the same poll_url with the same wallet before submitting again.`,
|
|
12573
12784
|
504,
|
|
12574
|
-
{ id, last_status: lastStatus }
|
|
12785
|
+
{ id, last_status: lastStatus, poll_url: pollUrl }
|
|
12575
12786
|
);
|
|
12576
12787
|
}
|
|
12788
|
+
/**
|
|
12789
|
+
* Absolute URL for a job's `poll_url`. The gateway returns `/api/v1/...`;
|
|
12790
|
+
* {@link buildUrl} drops the `/api` that `apiUrl` already ends with. An
|
|
12791
|
+
* absolute poll_url must be on the gateway's own origin: the poll carries a
|
|
12792
|
+
* wallet signature (and, for a finished video, a payment).
|
|
12793
|
+
*/
|
|
12794
|
+
resolvePollUrl(pollPath) {
|
|
12795
|
+
if (!/^https?:\/\//i.test(pollPath)) return this.buildUrl(pollPath);
|
|
12796
|
+
if (new URL(pollPath).origin !== new URL(this.apiUrl).origin) {
|
|
12797
|
+
throw new APIError(`poll_url is not on the gateway origin: ${pollPath}`, 502, { poll_url: pollPath });
|
|
12798
|
+
}
|
|
12799
|
+
return pollPath;
|
|
12800
|
+
}
|
|
12801
|
+
/**
|
|
12802
|
+
* Turn a `402` into a signed Solana payment payload.
|
|
12803
|
+
*
|
|
12804
|
+
* Extracted because four call sites need it — chat, the raw POST helpers, the
|
|
12805
|
+
* raw GET helper, and {@link SolanaLLMClient.stream} — and it had been
|
|
12806
|
+
* written out three times before this. That mattered more than ordinary
|
|
12807
|
+
* duplication: this is the code that signs a transfer of the caller's USDC,
|
|
12808
|
+
* so three copies meant every fix to it had to be applied three times or
|
|
12809
|
+
* quietly apply to two thirds of the paths.
|
|
12810
|
+
*
|
|
12811
|
+
* @param url - the request being paid for.
|
|
12812
|
+
* @param response - the gateway's `402`, not yet consumed.
|
|
12813
|
+
* @param forceFreshBlockhash - set on a re-sign after a stale-blockhash
|
|
12814
|
+
* rejection, so the retry cannot produce byte-identical transaction bytes.
|
|
12815
|
+
* @param resourceFallback - resource URL to claim when the 402 states none.
|
|
12816
|
+
* @returns the header value to replay with, and what it will settle for.
|
|
12817
|
+
* @throws PaymentError when the 402 carries no usable Solana requirements.
|
|
12818
|
+
*/
|
|
12577
12819
|
async signPaymentFrom402(url, response, forceFreshBlockhash, resourceFallback = url) {
|
|
12578
12820
|
return this.signExactPayment(
|
|
12579
12821
|
url,
|
|
@@ -12663,17 +12905,18 @@ var SolanaLLMClient = class {
|
|
|
12663
12905
|
this.recordSettlement(costUsd);
|
|
12664
12906
|
return this.readPaidJson(retryResponse);
|
|
12665
12907
|
}
|
|
12666
|
-
async requestWithPaymentRaw(endpoint, body) {
|
|
12908
|
+
async requestWithPaymentRaw(endpoint, body, media = {}) {
|
|
12667
12909
|
const url = `${this.apiUrl}${endpoint}`;
|
|
12910
|
+
const pollBudgetMs = media.pollBudgetMs ?? this.timeout;
|
|
12668
12911
|
for (let staleRetries = 0; ; ) {
|
|
12669
12912
|
const response = await this.fetchWithTimeout(url, {
|
|
12670
12913
|
method: "POST",
|
|
12671
12914
|
headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT },
|
|
12672
12915
|
body: JSON.stringify(body)
|
|
12673
|
-
});
|
|
12916
|
+
}, media.submitTimeoutMs);
|
|
12674
12917
|
if (response.status === 402) {
|
|
12675
12918
|
try {
|
|
12676
|
-
return await this.handlePaymentAndRetryRaw(url, body, response, staleRetries > 0);
|
|
12919
|
+
return await this.handlePaymentAndRetryRaw(url, body, response, staleRetries > 0, media);
|
|
12677
12920
|
} catch (error) {
|
|
12678
12921
|
if (!(error instanceof SafeStaleBlockhashError) || staleRetries >= STALE_BLOCKHASH_RETRY_BACKOFFS_MS.length) throw error;
|
|
12679
12922
|
await waitForStaleRetry(staleRetries++);
|
|
@@ -12689,26 +12932,38 @@ var SolanaLLMClient = class {
|
|
|
12689
12932
|
}
|
|
12690
12933
|
throw new APIError(`API error: ${response.status}`, response.status, sanitizeErrorResponse(errorBody));
|
|
12691
12934
|
}
|
|
12692
|
-
if (this.apiAuth &&
|
|
12935
|
+
if (this.apiAuth && /^\/v1\/(images|videos|audio)\//.test(endpoint)) {
|
|
12936
|
+
return this.apiAuth.poll(
|
|
12937
|
+
response,
|
|
12938
|
+
pollBudgetMs,
|
|
12939
|
+
endpoint.startsWith("/v1/videos/") ? VIDEO_POLL_INTERVAL_MS : 2e3
|
|
12940
|
+
);
|
|
12941
|
+
}
|
|
12693
12942
|
return response.json();
|
|
12694
12943
|
}
|
|
12695
12944
|
}
|
|
12696
|
-
async handlePaymentAndRetryRaw(url, body, response, forceFreshBlockhash = false) {
|
|
12945
|
+
async handlePaymentAndRetryRaw(url, body, response, forceFreshBlockhash = false, media = {}) {
|
|
12697
12946
|
const { paymentPayload, costUsd } = await this.signPaymentFrom402(url, response, forceFreshBlockhash);
|
|
12698
|
-
|
|
12699
|
-
|
|
12700
|
-
|
|
12701
|
-
|
|
12702
|
-
|
|
12703
|
-
|
|
12704
|
-
|
|
12705
|
-
|
|
12706
|
-
|
|
12947
|
+
let retryResponse;
|
|
12948
|
+
try {
|
|
12949
|
+
retryResponse = await this.fetchWithTimeout(url, {
|
|
12950
|
+
method: "POST",
|
|
12951
|
+
headers: {
|
|
12952
|
+
"Content-Type": "application/json",
|
|
12953
|
+
"User-Agent": USER_AGENT,
|
|
12954
|
+
"PAYMENT-SIGNATURE": paymentPayload
|
|
12955
|
+
},
|
|
12956
|
+
body: JSON.stringify(body)
|
|
12957
|
+
}, media.submitTimeoutMs);
|
|
12958
|
+
} catch (error) {
|
|
12959
|
+
throw withDisposition(error, "paid-or-in-doubt");
|
|
12960
|
+
}
|
|
12707
12961
|
await this.assertPaid(retryResponse);
|
|
12708
12962
|
if (retryResponse.status === 202) {
|
|
12709
|
-
|
|
12963
|
+
const kind = pollKindFor(url);
|
|
12964
|
+
if (kind !== "video") this.recordSettlement(costUsd);
|
|
12710
12965
|
const submitted = await retryResponse.json();
|
|
12711
|
-
return this.followSolanaJob(submitted, this.timeout);
|
|
12966
|
+
return this.followSolanaJob(submitted, media.pollBudgetMs ?? this.timeout, kind);
|
|
12712
12967
|
}
|
|
12713
12968
|
this.recordSettlement(costUsd);
|
|
12714
12969
|
return retryResponse.json();
|
|
@@ -12785,9 +13040,10 @@ var SolanaLLMClient = class {
|
|
|
12785
13040
|
} catch {
|
|
12786
13041
|
errorBody = { error: "Request failed" };
|
|
12787
13042
|
}
|
|
13043
|
+
const unverified = response.status === 503 && errorBody?.code === "PAYMENT_VERIFICATION_UNAVAILABLE";
|
|
12788
13044
|
throw withDisposition(
|
|
12789
13045
|
new APIError(`API error after payment: ${response.status}`, response.status, sanitizeErrorResponse(errorBody)),
|
|
12790
|
-
"paid-or-in-doubt"
|
|
13046
|
+
unverified ? "unpaid" : "paid-or-in-doubt"
|
|
12791
13047
|
);
|
|
12792
13048
|
}
|
|
12793
13049
|
}
|
|
@@ -12796,9 +13052,9 @@ var SolanaLLMClient = class {
|
|
|
12796
13052
|
this.sessionCalls += 1;
|
|
12797
13053
|
this.sessionTotalUsd += costUsd;
|
|
12798
13054
|
}
|
|
12799
|
-
async fetchWithTimeout(url, options) {
|
|
13055
|
+
async fetchWithTimeout(url, options, timeoutMs = this.timeout) {
|
|
12800
13056
|
const controller = new AbortController();
|
|
12801
|
-
const timeoutId = setTimeout(() => controller.abort(),
|
|
13057
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
12802
13058
|
try {
|
|
12803
13059
|
return await (this.apiAuth ? this.apiAuth.fetch.bind(this.apiAuth) : fetch)(url, { ...options, signal: controller.signal });
|
|
12804
13060
|
} finally {
|
|
@@ -13259,8 +13515,12 @@ var AnthropicClient = class {
|
|
|
13259
13515
|
baseURL: this._apiUrl,
|
|
13260
13516
|
apiKey: "blockrun",
|
|
13261
13517
|
fetch: this._x402Fetch.bind(this),
|
|
13262
|
-
//
|
|
13263
|
-
|
|
13518
|
+
// Never let the Anthropic SDK retry. It retries 408/409/429/5xx and
|
|
13519
|
+
// connection errors by calling this fetch again, which in wallet mode
|
|
13520
|
+
// runs the whole x402 flow and signs a NEW payment each time (three
|
|
13521
|
+
// payments for one call at the default maxRetries of 2); in account
|
|
13522
|
+
// mode the POST itself may already be billed.
|
|
13523
|
+
maxRetries: 0
|
|
13264
13524
|
});
|
|
13265
13525
|
return this._client;
|
|
13266
13526
|
})();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blockrun/llm",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.21.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",
|