@blockrun/llm 3.20.1 → 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/dist/index.js CHANGED
@@ -4564,7 +4564,7 @@ function getCostSummary() {
4564
4564
  }
4565
4565
 
4566
4566
  // src/version.ts
4567
- var SDK_VERSION = "3.20.1";
4567
+ var SDK_VERSION = "3.21.0";
4568
4568
  var USER_AGENT = `blockrun-ts/${SDK_VERSION}`;
4569
4569
 
4570
4570
  // src/client.ts
@@ -9329,11 +9329,11 @@ var WALLET_DIR = path4.join(os3.homedir(), ".blockrun");
9329
9329
  var SOLANA_WALLET_FILE = path4.join(WALLET_DIR, ".solana-session");
9330
9330
  async function createSolanaWallet() {
9331
9331
  const { Keypair } = await loadSolanaWeb3("Solana wallet operations");
9332
- const bs582 = await import("bs58");
9332
+ const bs583 = await import("bs58");
9333
9333
  const keypair = Keypair.generate();
9334
9334
  return {
9335
9335
  address: keypair.publicKey.toBase58(),
9336
- privateKey: (bs582.default ?? bs582).encode(keypair.secretKey)
9336
+ privateKey: (bs583.default ?? bs583).encode(keypair.secretKey)
9337
9337
  };
9338
9338
  }
9339
9339
  async function solanaKeyToBytes(privateKey) {
@@ -9371,8 +9371,8 @@ async function solanaKeyToBytes(privateKey) {
9371
9371
  return bytes;
9372
9372
  }
9373
9373
  try {
9374
- const bs582 = await import("bs58");
9375
- const bytes = (bs582.default ?? bs582).decode(key);
9374
+ const bs583 = await import("bs58");
9375
+ const bytes = (bs583.default ?? bs583).decode(key);
9376
9376
  if (bytes.length !== 64) {
9377
9377
  throw new Error(`Invalid Solana key length: expected 64 bytes, got ${bytes.length}`);
9378
9378
  }
@@ -9551,12 +9551,52 @@ async function getOrCreateSolanaWallet() {
9551
9551
  return { address, privateKey, isNew: true };
9552
9552
  }
9553
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
+
9554
9594
  // src/solana-batch.ts
9555
9595
  import { AsyncLocalStorage } from "async_hooks";
9556
9596
  import { randomUUID } from "crypto";
9557
9597
  import * as fs6 from "fs";
9558
9598
  import * as path5 from "path";
9559
- import bs58 from "bs58";
9599
+ import bs582 from "bs58";
9560
9600
  var BATCH_SCHEME = "batch-settlement";
9561
9601
  var BLOCKRUN_SOL_OPERATOR = "5YKPQUFjw5WQqhSUkEGKNNfYYVqnRRNbpYyL71qQ1vm3";
9562
9602
  var DEFAULT_BATCH_RATE_LIMIT = { maxAttempts: 3, maxWaitMs: 6e4 };
@@ -10116,7 +10156,7 @@ function decodeChannelAccount(owner, data) {
10116
10156
  return void 0;
10117
10157
  }
10118
10158
  const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
10119
- const key = (offset) => bs58.encode(data.subarray(offset, offset + 32));
10159
+ const key = (offset) => bs582.encode(data.subarray(offset, offset + 32));
10120
10160
  return {
10121
10161
  deposit: view.getBigUint64(CHANNEL.deposit, true),
10122
10162
  settled: view.getBigUint64(CHANNEL.settled, true),
@@ -11676,6 +11716,39 @@ var DEFAULT_MAX_TOKENS2 = 1024;
11676
11716
  var DEFAULT_TIMEOUT14 = 6e4;
11677
11717
  var STALE_BLOCKHASH_RETRY_BACKOFFS_MS = [500, 2e3];
11678
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
+ }
11679
11752
  var SafeStaleBlockhashError = class extends PaymentError {
11680
11753
  constructor() {
11681
11754
  super("Payment verification used an expired Solana blockhash; retrying with a fresh quote.");
@@ -12185,6 +12258,54 @@ var SolanaLLMClient = class {
12185
12258
  const data = await this.requestWithPaymentRaw("/v1/images/image2image", body);
12186
12259
  return data;
12187
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
+ }
12188
12309
  /** Standalone search (Solana payment). */
12189
12310
  async search(query, options) {
12190
12311
  const body = { query, max_results: options?.maxResults || 10 };
@@ -12480,106 +12601,221 @@ var SolanaLLMClient = class {
12480
12601
  }
12481
12602
  }
12482
12603
  /**
12483
- * Turn a `402` into a signed Solana payment payload.
12484
- *
12485
- * Extracted because four call sites need it — chat, the raw POST helpers, the
12486
- * raw GET helper, and {@link SolanaLLMClient.stream} — and it had been
12487
- * written out three times before this. That mattered more than ordinary
12488
- * duplication: this is the code that signs a transfer of the caller's USDC,
12489
- * so three copies meant every fix to it had to be applied three times or
12490
- * quietly apply to two thirds of the paths.
12491
- *
12492
- * @param url - the request being paid for.
12493
- * @param response - the gateway's `402`, not yet consumed.
12494
- * @param forceFreshBlockhash - set on a re-sign after a stale-blockhash
12495
- * rejection, so the retry cannot produce byte-identical transaction bytes.
12496
- * @param resourceFallback - resource URL to claim when the 402 states none.
12497
- * @returns the header value to replay with, and what it will settle for.
12498
- * @throws PaymentError when the 402 carries no usable Solana requirements.
12499
- */
12500
- /**
12501
- * Follow a 202 `{ id, poll_url }` to completion, on Solana **image** routes.
12502
- *
12503
- * Two things differ from Base and both come from the same constraint — a
12504
- * Solana payment is a transaction pinned to a recent blockhash, valid for
12505
- * ~150 blocks (~60s), and a long render outlives it:
12506
- *
12507
- * 1. **The POST already settled.** Base settles on the completed poll; Solana
12508
- * cannot, because by then the signed transaction has expired. So sol
12509
- * settles optimistically at submit, and this loop only fetches the result.
12510
- * The cost is recorded by the caller at POST, never here — recording it on
12511
- * completion would lose the charge whenever a paid job then fails.
12512
- * Solana VIDEO is the exception: it settles on the completed poll and a
12513
- * failed job is not charged. Do not route video through this loop as-is —
12514
- * it would book spend for jobs that were never charged.
12515
- * 2. **Every poll re-signs.** One authorization cannot be replayed across a
12516
- * long render. The gateway binds the job to the payer address rather than
12517
- * to the signature, so a fresh signature from the same wallet is accepted
12518
- * 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.
12519
12635
  */
12520
- async followSolanaJob(submitBody, budgetMs, intervalMs = 2e3) {
12636
+ async followSolanaJob(submitBody, budgetMs, kind, intervalMs = kind === "video" ? VIDEO_POLL_INTERVAL_MS : 2e3) {
12521
12637
  const id = submitBody.id;
12522
12638
  const pollPath = submitBody.poll_url;
12523
12639
  if (!id || !pollPath) return submitBody;
12524
- const pollUrl = pollPath.startsWith("http") ? pollPath : `${this.apiUrl}${pollPath.startsWith("/") ? "" : "/"}${pollPath}`;
12640
+ const pollUrl = this.resolvePollUrl(pollPath);
12641
+ const jobId = pollJobIdFromUrl(pollUrl);
12525
12642
  const deadline = Date.now() + budgetMs;
12526
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
+ };
12527
12673
  while (Date.now() < deadline) {
12528
- await new Promise((r) => setTimeout(r, intervalMs));
12529
- const challenge = await this.fetchWithTimeout(pollUrl, {
12530
- method: "GET",
12531
- headers: { "User-Agent": USER_AGENT }
12532
- });
12533
- if (challenge.status === 200) {
12534
- const done = await challenge.json();
12535
- return done;
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
+ }
12536
12708
  }
12537
- if (challenge.status !== 402) {
12538
- let errorBody;
12539
- try {
12540
- errorBody = await challenge.json();
12541
- } catch {
12542
- errorBody = { error: "Poll failed" };
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);
12543
12716
  }
12544
- throw new APIError(`Poll failed: ${challenge.status}`, challenge.status, sanitizeErrorResponse(errorBody));
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));
12725
+ }
12726
+ challenge = unpaid;
12545
12727
  }
12546
- const { paymentPayload } = await this.signPaymentFrom402(
12728
+ const { paymentPayload, costUsd } = await this.signPaymentFrom402(
12547
12729
  pollUrl,
12548
12730
  challenge,
12549
12731
  true,
12550
12732
  // always a fresh blockhash; the last one is stale by now
12551
12733
  pollUrl
12552
12734
  );
12553
- const paid = await this.fetchWithTimeout(pollUrl, {
12554
- method: "GET",
12555
- headers: { "User-Agent": USER_AGENT, "PAYMENT-SIGNATURE": paymentPayload }
12556
- });
12557
- let data = {};
12735
+ if (kind === "video") videoCostUsd = costUsd;
12736
+ let paid;
12558
12737
  try {
12559
- data = await paid.json();
12560
- } catch {
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;
12561
12753
  }
12754
+ const data = await readJsonOrEmpty(paid);
12562
12755
  lastStatus = data.status || lastStatus;
12563
- if (lastStatus === "failed") {
12564
- throw new APIError(
12565
- `Upstream job failed: ${data.error || "unknown"}`,
12566
- paid.status,
12567
- sanitizeErrorResponse(data)
12568
- );
12569
- }
12570
- if (paid.status === 200 && lastStatus === "completed") {
12571
- return data;
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;
12572
12775
  }
12573
- if (paid.status !== 200 && paid.status !== 202 && paid.status !== 504) {
12776
+ if (!isPendingPollStatus(paid.status) && paid.status !== 200) {
12574
12777
  throw new APIError(`Poll failed: ${paid.status}`, paid.status, sanitizeErrorResponse(data));
12575
12778
  }
12779
+ waitMs = retryAfterMs(paid) ?? intervalMs;
12576
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.";
12577
12782
  throw new APIError(
12578
- `Job did not complete within ${Math.round(budgetMs / 1e3)}s (last status: ${lastStatus}). No payment was taken.`,
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.`,
12579
12784
  504,
12580
- { id, last_status: lastStatus }
12785
+ { id, last_status: lastStatus, poll_url: pollUrl }
12581
12786
  );
12582
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
+ */
12583
12819
  async signPaymentFrom402(url, response, forceFreshBlockhash, resourceFallback = url) {
12584
12820
  return this.signExactPayment(
12585
12821
  url,
@@ -12669,17 +12905,18 @@ var SolanaLLMClient = class {
12669
12905
  this.recordSettlement(costUsd);
12670
12906
  return this.readPaidJson(retryResponse);
12671
12907
  }
12672
- async requestWithPaymentRaw(endpoint, body) {
12908
+ async requestWithPaymentRaw(endpoint, body, media = {}) {
12673
12909
  const url = `${this.apiUrl}${endpoint}`;
12910
+ const pollBudgetMs = media.pollBudgetMs ?? this.timeout;
12674
12911
  for (let staleRetries = 0; ; ) {
12675
12912
  const response = await this.fetchWithTimeout(url, {
12676
12913
  method: "POST",
12677
12914
  headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT },
12678
12915
  body: JSON.stringify(body)
12679
- });
12916
+ }, media.submitTimeoutMs);
12680
12917
  if (response.status === 402) {
12681
12918
  try {
12682
- return await this.handlePaymentAndRetryRaw(url, body, response, staleRetries > 0);
12919
+ return await this.handlePaymentAndRetryRaw(url, body, response, staleRetries > 0, media);
12683
12920
  } catch (error) {
12684
12921
  if (!(error instanceof SafeStaleBlockhashError) || staleRetries >= STALE_BLOCKHASH_RETRY_BACKOFFS_MS.length) throw error;
12685
12922
  await waitForStaleRetry(staleRetries++);
@@ -12695,26 +12932,38 @@ var SolanaLLMClient = class {
12695
12932
  }
12696
12933
  throw new APIError(`API error: ${response.status}`, response.status, sanitizeErrorResponse(errorBody));
12697
12934
  }
12698
- if (this.apiAuth && endpoint.startsWith("/v1/images/")) return this.apiAuth.poll(response, this.timeout, 2e3);
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
+ }
12699
12942
  return response.json();
12700
12943
  }
12701
12944
  }
12702
- async handlePaymentAndRetryRaw(url, body, response, forceFreshBlockhash = false) {
12945
+ async handlePaymentAndRetryRaw(url, body, response, forceFreshBlockhash = false, media = {}) {
12703
12946
  const { paymentPayload, costUsd } = await this.signPaymentFrom402(url, response, forceFreshBlockhash);
12704
- const retryResponse = await this.fetchWithTimeout(url, {
12705
- method: "POST",
12706
- headers: {
12707
- "Content-Type": "application/json",
12708
- "User-Agent": USER_AGENT,
12709
- "PAYMENT-SIGNATURE": paymentPayload
12710
- },
12711
- body: JSON.stringify(body)
12712
- });
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
+ }
12713
12961
  await this.assertPaid(retryResponse);
12714
12962
  if (retryResponse.status === 202) {
12715
- this.recordSettlement(costUsd);
12963
+ const kind = pollKindFor(url);
12964
+ if (kind !== "video") this.recordSettlement(costUsd);
12716
12965
  const submitted = await retryResponse.json();
12717
- return this.followSolanaJob(submitted, this.timeout);
12966
+ return this.followSolanaJob(submitted, media.pollBudgetMs ?? this.timeout, kind);
12718
12967
  }
12719
12968
  this.recordSettlement(costUsd);
12720
12969
  return retryResponse.json();
@@ -12803,9 +13052,9 @@ var SolanaLLMClient = class {
12803
13052
  this.sessionCalls += 1;
12804
13053
  this.sessionTotalUsd += costUsd;
12805
13054
  }
12806
- async fetchWithTimeout(url, options) {
13055
+ async fetchWithTimeout(url, options, timeoutMs = this.timeout) {
12807
13056
  const controller = new AbortController();
12808
- const timeoutId = setTimeout(() => controller.abort(), this.timeout);
13057
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
12809
13058
  try {
12810
13059
  return await (this.apiAuth ? this.apiAuth.fetch.bind(this.apiAuth) : fetch)(url, { ...options, signal: controller.signal });
12811
13060
  } finally {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/llm",
3
- "version": "3.20.1",
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",