@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/dist/index.cjs CHANGED
@@ -332,7 +332,13 @@ function resolveApiKeyAuth(options) {
332
332
  return new ApiKeyAuth(key.trim(), base);
333
333
  }
334
334
  function accountErrorDisposition(err) {
335
- return err instanceof APIError && err.statusCode >= 400 && err.statusCode < 500 ? "unpaid" : "paid-or-in-doubt";
335
+ if (!(err instanceof APIError)) return "paid-or-in-doubt";
336
+ if (err.statusCode >= 400 && err.statusCode < 500) return "unpaid";
337
+ if (err.statusCode >= 500 && hasGatewayErrorEnvelope(err.response)) return "unpaid";
338
+ return "paid-or-in-doubt";
339
+ }
340
+ function hasGatewayErrorEnvelope(response) {
341
+ return !!response && typeof response === "object" && typeof response.message === "string" && response.message.length > 0;
336
342
  }
337
343
  function requireWallet(value) {
338
344
  if (value === void 0) throw new Error("This operation requires a wallet; the client uses API key account billing.");
@@ -4684,7 +4690,7 @@ function getCostSummary() {
4684
4690
  }
4685
4691
 
4686
4692
  // src/version.ts
4687
- var SDK_VERSION = "3.20.0";
4693
+ var SDK_VERSION = "3.21.0";
4688
4694
  var USER_AGENT = `blockrun-ts/${SDK_VERSION}`;
4689
4695
 
4690
4696
  // src/client.ts
@@ -9449,11 +9455,11 @@ var WALLET_DIR = path4.join(os3.homedir(), ".blockrun");
9449
9455
  var SOLANA_WALLET_FILE = path4.join(WALLET_DIR, ".solana-session");
9450
9456
  async function createSolanaWallet() {
9451
9457
  const { Keypair } = await loadSolanaWeb3("Solana wallet operations");
9452
- const bs582 = await import("bs58");
9458
+ const bs583 = await import("bs58");
9453
9459
  const keypair = Keypair.generate();
9454
9460
  return {
9455
9461
  address: keypair.publicKey.toBase58(),
9456
- privateKey: (bs582.default ?? bs582).encode(keypair.secretKey)
9462
+ privateKey: (bs583.default ?? bs583).encode(keypair.secretKey)
9457
9463
  };
9458
9464
  }
9459
9465
  async function solanaKeyToBytes(privateKey) {
@@ -9491,8 +9497,8 @@ async function solanaKeyToBytes(privateKey) {
9491
9497
  return bytes;
9492
9498
  }
9493
9499
  try {
9494
- const bs582 = await import("bs58");
9495
- const bytes = (bs582.default ?? bs582).decode(key);
9500
+ const bs583 = await import("bs58");
9501
+ const bytes = (bs583.default ?? bs583).decode(key);
9496
9502
  if (bytes.length !== 64) {
9497
9503
  throw new Error(`Invalid Solana key length: expected 64 bytes, got ${bytes.length}`);
9498
9504
  }
@@ -9671,12 +9677,52 @@ async function getOrCreateSolanaWallet() {
9671
9677
  return { address, privateKey, isNew: true };
9672
9678
  }
9673
9679
 
9680
+ // src/poll-ownership.ts
9681
+ var import_crypto = require("crypto");
9682
+ var import_bs58 = __toESM(require("bs58"), 1);
9683
+ var POLL_AUTH_HEADERS = {
9684
+ wallet: "x-poll-wallet",
9685
+ timestamp: "x-poll-timestamp",
9686
+ signature: "x-poll-signature"
9687
+ };
9688
+ function pollAuthMessage(kind, jobId, timestampS) {
9689
+ return `blockrun-poll:v1:${kind}:${jobId}:${timestampS}`;
9690
+ }
9691
+ function pollJobIdFromUrl(pollUrl) {
9692
+ const path7 = new URL(pollUrl, "https://placeholder.invalid").pathname.replace(/\/+$/, "");
9693
+ const segment = path7.slice(path7.lastIndexOf("/") + 1);
9694
+ if (!segment) throw new Error(`poll_url has no job id segment: ${pollUrl}`);
9695
+ return decodeURIComponent(segment);
9696
+ }
9697
+ var ED25519_PKCS8_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
9698
+ function signPollOwnership(secretKey, kind, jobId, nowMs = Date.now()) {
9699
+ if (secretKey.length !== 64 && secretKey.length !== 32) {
9700
+ throw new Error(`Solana secret key must be 64 bytes (or a 32-byte seed), got ${secretKey.length}`);
9701
+ }
9702
+ const seed = Buffer.from(secretKey.subarray(0, 32));
9703
+ const privateKey = (0, import_crypto.createPrivateKey)({
9704
+ key: Buffer.concat([ED25519_PKCS8_PREFIX, seed]),
9705
+ format: "der",
9706
+ type: "pkcs8"
9707
+ });
9708
+ const jwk = (0, import_crypto.createPublicKey)(privateKey).export({ format: "jwk" });
9709
+ if (!jwk.x) throw new Error("Could not derive the Ed25519 public key");
9710
+ const publicKey = Buffer.from(jwk.x, "base64url");
9711
+ const timestampS = Math.floor(nowMs / 1e3);
9712
+ const signature = (0, import_crypto.sign)(null, Buffer.from(pollAuthMessage(kind, jobId, timestampS), "utf8"), privateKey);
9713
+ return {
9714
+ [POLL_AUTH_HEADERS.wallet]: import_bs58.default.encode(publicKey),
9715
+ [POLL_AUTH_HEADERS.timestamp]: String(timestampS),
9716
+ [POLL_AUTH_HEADERS.signature]: import_bs58.default.encode(signature)
9717
+ };
9718
+ }
9719
+
9674
9720
  // src/solana-batch.ts
9675
9721
  var import_async_hooks = require("async_hooks");
9676
- var import_crypto = require("crypto");
9722
+ var import_crypto2 = require("crypto");
9677
9723
  var fs6 = __toESM(require("fs"), 1);
9678
9724
  var path5 = __toESM(require("path"), 1);
9679
- var import_bs58 = __toESM(require("bs58"), 1);
9725
+ var import_bs582 = __toESM(require("bs58"), 1);
9680
9726
  var BATCH_SCHEME = "batch-settlement";
9681
9727
  var BLOCKRUN_SOL_OPERATOR = "5YKPQUFjw5WQqhSUkEGKNNfYYVqnRRNbpYyL71qQ1vm3";
9682
9728
  var DEFAULT_BATCH_RATE_LIMIT = { maxAttempts: 3, maxWaitMs: 6e4 };
@@ -9934,7 +9980,7 @@ var FileIntentJournal = class _FileIntentJournal {
9934
9980
  fsyncDirectory(dir);
9935
9981
  return;
9936
9982
  }
9937
- const tmp = `${this.file}.${process.pid}.${(0, import_crypto.randomUUID)()}.tmp`;
9983
+ const tmp = `${this.file}.${process.pid}.${(0, import_crypto2.randomUUID)()}.tmp`;
9938
9984
  const fd = fs6.openSync(tmp, "w", 384);
9939
9985
  try {
9940
9986
  fs6.writeSync(fd, JSON.stringify({ version: 1, intents }, null, 2));
@@ -10060,7 +10106,7 @@ function lockChannelFile(file) {
10060
10106
  if (registry2.locks.has(file)) return void 0;
10061
10107
  const lock = `${file}.lock`;
10062
10108
  fs6.mkdirSync(path5.dirname(lock), { recursive: true, mode: 448 });
10063
- const token = (0, import_crypto.randomUUID)();
10109
+ const token = (0, import_crypto2.randomUUID)();
10064
10110
  for (let attempt = 0; attempt < 2; attempt += 1) {
10065
10111
  const tmp = `${lock}.${process.pid}.${token}.tmp`;
10066
10112
  try {
@@ -10236,7 +10282,7 @@ function decodeChannelAccount(owner, data) {
10236
10282
  return void 0;
10237
10283
  }
10238
10284
  const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
10239
- const key = (offset) => import_bs58.default.encode(data.subarray(offset, offset + 32));
10285
+ const key = (offset) => import_bs582.default.encode(data.subarray(offset, offset + 32));
10240
10286
  return {
10241
10287
  deposit: view.getBigUint64(CHANNEL.deposit, true),
10242
10288
  settled: view.getBigUint64(CHANNEL.settled, true),
@@ -11532,7 +11578,7 @@ var SolanaBatchPayer = class {
11532
11578
  cause: await afterPaymentError(response)
11533
11579
  };
11534
11580
  }
11535
- return { kind: "not_charged", reason: "payment_required", status: status2, depositSafe: false };
11581
+ return { kind: "not_charged", reason: "payment_required", status: status2, errorReason: receipt?.errorReason, depositSafe: provesNothingBroadcast(receipt) };
11536
11582
  }
11537
11583
  const refusal = await batchRefusal(response);
11538
11584
  if (refusal) {
@@ -11796,6 +11842,39 @@ var DEFAULT_MAX_TOKENS2 = 1024;
11796
11842
  var DEFAULT_TIMEOUT14 = 6e4;
11797
11843
  var STALE_BLOCKHASH_RETRY_BACKOFFS_MS = [500, 2e3];
11798
11844
  var MAX_PAYMENT_FAILURE_BYTES = 64 * 1024;
11845
+ var DEFAULT_VIDEO_MODEL = "xai/grok-imagine-video";
11846
+ var DEFAULT_MUSIC_MODEL = "minimax/music-2.5+";
11847
+ var DEFAULT_VIDEO_BUDGET_MS = 9e5;
11848
+ var DEFAULT_MUSIC_BUDGET_MS = 3e5;
11849
+ var VIDEO_POLL_INTERVAL_MS = 5e3;
11850
+ var MAX_PAID_POLL_REFUSALS = 4;
11851
+ var MAX_RETRY_AFTER_MS2 = 12e4;
11852
+ var MEDIA_SUBMIT_TIMEOUT_MS = 12e4;
11853
+ function sleep3(ms) {
11854
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
11855
+ }
11856
+ async function readJsonOrEmpty(response) {
11857
+ try {
11858
+ const parsed = await response.json();
11859
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
11860
+ } catch {
11861
+ return {};
11862
+ }
11863
+ }
11864
+ function isPendingPollStatus(status2) {
11865
+ return status2 === 202 || status2 === 503 || status2 === 504;
11866
+ }
11867
+ function retryAfterMs(response) {
11868
+ const raw = response.headers.get("retry-after");
11869
+ if (!raw || !/^\d+$/.test(raw.trim())) return void 0;
11870
+ return Math.min(Number(raw.trim()) * 1e3, MAX_RETRY_AFTER_MS2);
11871
+ }
11872
+ function pollKindFor(url) {
11873
+ const path7 = new URL(url).pathname;
11874
+ if (path7.includes("/v1/videos")) return "video";
11875
+ if (path7.includes("/v1/audio/")) return "audio";
11876
+ return "image";
11877
+ }
11799
11878
  var SafeStaleBlockhashError = class extends PaymentError {
11800
11879
  constructor() {
11801
11880
  super("Payment verification used an expired Solana blockhash; retrying with a fresh quote.");
@@ -12305,6 +12384,54 @@ var SolanaLLMClient = class {
12305
12384
  const data = await this.requestWithPaymentRaw("/v1/images/image2image", body);
12306
12385
  return data;
12307
12386
  }
12387
+ /**
12388
+ * Generate a short video on Solana.
12389
+ *
12390
+ * The POST is verified, not charged. The job is then polled with a wallet
12391
+ * signature (no payment) until it finishes, and ONE payment is signed for
12392
+ * the finished video; a failed job is never charged. If the gateway does not
12393
+ * accept poll signatures for the job, it is polled with a payment per poll
12394
+ * instead, which is likewise only charged on completion.
12395
+ *
12396
+ * @param prompt - text description of the video.
12397
+ * @param options - `model` (default `xai/grok-imagine-video`), `budgetMs`
12398
+ * (total wait, default 15 min); any other keys are sent verbatim, so use
12399
+ * the gateway's snake_case names (`duration_seconds`, `image_url`,
12400
+ * `aspect_ratio`, `resolution`, …).
12401
+ */
12402
+ async video(prompt, options = {}) {
12403
+ const { model, budgetMs, ...rest } = options;
12404
+ const body = { ...rest, model: model || DEFAULT_VIDEO_MODEL, prompt };
12405
+ const data = await this.requestWithPaymentRaw(
12406
+ "/v1/videos/generations",
12407
+ body,
12408
+ { pollBudgetMs: budgetMs ?? DEFAULT_VIDEO_BUDGET_MS, submitTimeoutMs: Math.max(this.timeout, MEDIA_SUBMIT_TIMEOUT_MS) }
12409
+ );
12410
+ return data;
12411
+ }
12412
+ /**
12413
+ * Generate a music track on Solana. Paid at the POST; a slow track is then
12414
+ * polled with a wallet signature, never another payment.
12415
+ *
12416
+ * @param prompt - music style, mood, or description.
12417
+ * @param options - `model` (default `minimax/music-2.5+`), `instrumental`
12418
+ * (default true), `lyrics` (requires `instrumental: false`), `budgetMs`
12419
+ * (total wait, default 5 min); other keys are sent verbatim.
12420
+ */
12421
+ async music(prompt, options = {}) {
12422
+ const { model, instrumental: instrumentalOption, lyrics: rawLyrics, budgetMs, ...rest } = options;
12423
+ const instrumental = instrumentalOption ?? true;
12424
+ const lyrics = rawLyrics?.trim();
12425
+ if (instrumental && lyrics) throw new Error("Cannot specify lyrics when instrumental is true");
12426
+ const body = { ...rest, model: model || DEFAULT_MUSIC_MODEL, prompt, instrumental };
12427
+ if (lyrics) body.lyrics = lyrics;
12428
+ const data = await this.requestWithPaymentRaw(
12429
+ "/v1/audio/generations",
12430
+ body,
12431
+ { pollBudgetMs: budgetMs ?? DEFAULT_MUSIC_BUDGET_MS, submitTimeoutMs: Math.max(this.timeout, MEDIA_SUBMIT_TIMEOUT_MS) }
12432
+ );
12433
+ return data;
12434
+ }
12308
12435
  /** Standalone search (Solana payment). */
12309
12436
  async search(query, options) {
12310
12437
  const body = { query, max_results: options?.maxResults || 10 };
@@ -12600,106 +12727,221 @@ var SolanaLLMClient = class {
12600
12727
  }
12601
12728
  }
12602
12729
  /**
12603
- * Turn a `402` into a signed Solana payment payload.
12604
- *
12605
- * Extracted because four call sites need it — chat, the raw POST helpers, the
12606
- * raw GET helper, and {@link SolanaLLMClient.stream} — and it had been
12607
- * written out three times before this. That mattered more than ordinary
12608
- * duplication: this is the code that signs a transfer of the caller's USDC,
12609
- * so three copies meant every fix to it had to be applied three times or
12610
- * quietly apply to two thirds of the paths.
12611
- *
12612
- * @param url - the request being paid for.
12613
- * @param response - the gateway's `402`, not yet consumed.
12614
- * @param forceFreshBlockhash - set on a re-sign after a stale-blockhash
12615
- * rejection, so the retry cannot produce byte-identical transaction bytes.
12616
- * @param resourceFallback - resource URL to claim when the 402 states none.
12617
- * @returns the header value to replay with, and what it will settle for.
12618
- * @throws PaymentError when the 402 carries no usable Solana requirements.
12730
+ * Follow a 202 `{ id, poll_url }` to completion on a Solana media route
12731
+ * (image, music, video).
12732
+ *
12733
+ * **Polls prove ownership, they do not pay.** A Solana payment is a
12734
+ * transaction pinned to a recent blockhash (~60s), so paying every poll of a
12735
+ * multi-minute job meant signing a fresh transaction every few seconds. Each
12736
+ * poll here instead carries the `x-poll-*` headers: the submitting wallet's
12737
+ * ed25519 signature over `blockrun-poll:v1:<kind>:<job id>:<unix seconds>`
12738
+ * (see `poll-ownership.ts`). That proof never charges.
12739
+ *
12740
+ * **Who pays when:**
12741
+ * - image / music: the POST already settled (sol settles at submit because the
12742
+ * signed transaction would be dead by completion). The caller recorded the
12743
+ * cost at POST; this loop only fetches the result and never signs a payment.
12744
+ * A batch-billed image job still answers its proof poll with 402: that job
12745
+ * falls back to the per-poll payment below.
12746
+ * - video: settles on completion, and a failed job is never charged. A
12747
+ * finished, unpaid job answers the proof poll with 402 `{status:"completed"}`
12748
+ * and a normal x402 challenge; ONE payment is signed from that challenge and
12749
+ * the same poll is re-sent with it. The cost is recorded here, on delivery.
12750
+ * A paid poll the gateway answers 402 (settlement in progress on another
12751
+ * poll, outcome unknown, stale blockhash) is re-polled after its
12752
+ * Retry-After — the gateway's per-job settlement claim makes a later fresh
12753
+ * payment safe — up to `MAX_PAID_POLL_REFUSALS` times.
12754
+ *
12755
+ * **Fallback, once per job:** if the gateway refuses the proof with its "no
12756
+ * such job" answer (video 400, image/music 404) or answers it with a plain
12757
+ * payment challenge (a gateway or job that predates poll proofs), the rest of
12758
+ * this job is polled the old way — an x-payment signed per poll, which the
12759
+ * gateway verifies against the job's payer and does not charge until a video
12760
+ * completes. Logged to stderr; never switches back.
12619
12761
  */
12620
- /**
12621
- * Follow a 202 `{ id, poll_url }` to completion, on Solana **image** routes.
12622
- *
12623
- * Two things differ from Base and both come from the same constraint — a
12624
- * Solana payment is a transaction pinned to a recent blockhash, valid for
12625
- * ~150 blocks (~60s), and a long render outlives it:
12626
- *
12627
- * 1. **The POST already settled.** Base settles on the completed poll; Solana
12628
- * cannot, because by then the signed transaction has expired. So sol
12629
- * settles optimistically at submit, and this loop only fetches the result.
12630
- * The cost is recorded by the caller at POST, never here — recording it on
12631
- * completion would lose the charge whenever a paid job then fails.
12632
- * Solana VIDEO is the exception: it settles on the completed poll and a
12633
- * failed job is not charged. Do not route video through this loop as-is —
12634
- * it would book spend for jobs that were never charged.
12635
- * 2. **Every poll re-signs.** One authorization cannot be replayed across a
12636
- * long render. The gateway binds the job to the payer address rather than
12637
- * to the signature, so a fresh signature from the same wallet is accepted
12638
- * and is never charged again.
12639
- */
12640
- async followSolanaJob(submitBody, budgetMs, intervalMs = 2e3) {
12762
+ async followSolanaJob(submitBody, budgetMs, kind, intervalMs = kind === "video" ? VIDEO_POLL_INTERVAL_MS : 2e3) {
12641
12763
  const id = submitBody.id;
12642
12764
  const pollPath = submitBody.poll_url;
12643
12765
  if (!id || !pollPath) return submitBody;
12644
- const pollUrl = pollPath.startsWith("http") ? pollPath : `${this.apiUrl}${pollPath.startsWith("/") ? "" : "/"}${pollPath}`;
12766
+ const pollUrl = this.resolvePollUrl(pollPath);
12767
+ const jobId = pollJobIdFromUrl(pollUrl);
12645
12768
  const deadline = Date.now() + budgetMs;
12646
12769
  let lastStatus = submitBody.status || "queued";
12770
+ let ownershipPolls = true;
12771
+ let waitMs = intervalMs;
12772
+ let paidRefusalsLeft = MAX_PAID_POLL_REFUSALS;
12773
+ let videoCostUsd;
12774
+ let settleAttempted = false;
12775
+ const fallBack = (why) => {
12776
+ ownershipPolls = false;
12777
+ waitMs = 0;
12778
+ console.warn(
12779
+ `[@blockrun/llm] ${kind} job ${id}: ${why}; falling back to x-payment polling for this job`
12780
+ );
12781
+ };
12782
+ const deliver = (data, response) => {
12783
+ if (kind === "video") {
12784
+ if (videoCostUsd !== void 0) this.recordSettlement(videoCostUsd);
12785
+ const txHash = response.headers.get("x-payment-receipt");
12786
+ if (txHash) data.txHash = txHash;
12787
+ }
12788
+ return data;
12789
+ };
12790
+ const failIfFailed = (data, status2) => {
12791
+ if (lastStatus === "failed") {
12792
+ throw new APIError(
12793
+ `Upstream job failed: ${data.error || "unknown"}`,
12794
+ status2,
12795
+ sanitizeErrorResponse(data)
12796
+ );
12797
+ }
12798
+ };
12647
12799
  while (Date.now() < deadline) {
12648
- await new Promise((r) => setTimeout(r, intervalMs));
12649
- const challenge = await this.fetchWithTimeout(pollUrl, {
12650
- method: "GET",
12651
- headers: { "User-Agent": USER_AGENT }
12652
- });
12653
- if (challenge.status === 200) {
12654
- const done = await challenge.json();
12655
- return done;
12800
+ if (waitMs > 0) await sleep3(Math.min(waitMs, Math.max(0, deadline - Date.now())));
12801
+ waitMs = intervalMs;
12802
+ let challenge;
12803
+ if (ownershipPolls) {
12804
+ const proof = signPollOwnership(await solanaKeyToBytes(this.privateKey), kind, jobId);
12805
+ const polled = await this.fetchWithTimeout(pollUrl, {
12806
+ method: "GET",
12807
+ headers: { "User-Agent": USER_AGENT, ...proof }
12808
+ });
12809
+ const data2 = await readJsonOrEmpty(polled);
12810
+ if (polled.status === 402) {
12811
+ if (kind === "video" && data2.status === "completed") {
12812
+ lastStatus = "completed";
12813
+ settleAttempted = true;
12814
+ challenge = new Response(JSON.stringify(data2), { status: 402, headers: polled.headers });
12815
+ } else {
12816
+ fallBack(
12817
+ 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)"
12818
+ );
12819
+ continue;
12820
+ }
12821
+ } else if (polled.status === (kind === "video" ? 400 : 404)) {
12822
+ fallBack(`the gateway refused the poll proof (HTTP ${polled.status})`);
12823
+ continue;
12824
+ } else {
12825
+ lastStatus = data2.status || lastStatus;
12826
+ failIfFailed(data2, polled.status);
12827
+ if (polled.status === 200 && lastStatus === "completed") return deliver(data2, polled);
12828
+ if (!isPendingPollStatus(polled.status)) {
12829
+ throw new APIError(`Poll failed: ${polled.status}`, polled.status, sanitizeErrorResponse(data2));
12830
+ }
12831
+ waitMs = retryAfterMs(polled) ?? intervalMs;
12832
+ continue;
12833
+ }
12656
12834
  }
12657
- if (challenge.status !== 402) {
12658
- let errorBody;
12659
- try {
12660
- errorBody = await challenge.json();
12661
- } catch {
12662
- errorBody = { error: "Poll failed" };
12835
+ if (!challenge) {
12836
+ const unpaid = await this.fetchWithTimeout(pollUrl, {
12837
+ method: "GET",
12838
+ headers: { "User-Agent": USER_AGENT }
12839
+ });
12840
+ if (unpaid.status === 200) {
12841
+ return deliver(await unpaid.json(), unpaid);
12842
+ }
12843
+ if (unpaid.status !== 402) {
12844
+ let errorBody;
12845
+ try {
12846
+ errorBody = await unpaid.json();
12847
+ } catch {
12848
+ errorBody = { error: "Poll failed" };
12849
+ }
12850
+ throw new APIError(`Poll failed: ${unpaid.status}`, unpaid.status, sanitizeErrorResponse(errorBody));
12663
12851
  }
12664
- throw new APIError(`Poll failed: ${challenge.status}`, challenge.status, sanitizeErrorResponse(errorBody));
12852
+ challenge = unpaid;
12665
12853
  }
12666
- const { paymentPayload } = await this.signPaymentFrom402(
12854
+ const { paymentPayload, costUsd } = await this.signPaymentFrom402(
12667
12855
  pollUrl,
12668
12856
  challenge,
12669
12857
  true,
12670
12858
  // always a fresh blockhash; the last one is stale by now
12671
12859
  pollUrl
12672
12860
  );
12673
- const paid = await this.fetchWithTimeout(pollUrl, {
12674
- method: "GET",
12675
- headers: { "User-Agent": USER_AGENT, "PAYMENT-SIGNATURE": paymentPayload }
12676
- });
12677
- let data = {};
12861
+ if (kind === "video") videoCostUsd = costUsd;
12862
+ let paid;
12678
12863
  try {
12679
- data = await paid.json();
12680
- } catch {
12864
+ paid = await this.fetchWithTimeout(pollUrl, {
12865
+ method: "GET",
12866
+ headers: { "User-Agent": USER_AGENT, "PAYMENT-SIGNATURE": paymentPayload }
12867
+ });
12868
+ } catch (error) {
12869
+ if (kind === "video" && lastStatus === "completed") settleAttempted = true;
12870
+ if (--paidRefusalsLeft <= 0) {
12871
+ throw withDisposition(
12872
+ new PaymentError(
12873
+ `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.`
12874
+ ),
12875
+ "paid-or-in-doubt"
12876
+ );
12877
+ }
12878
+ continue;
12681
12879
  }
12880
+ const data = await readJsonOrEmpty(paid);
12682
12881
  lastStatus = data.status || lastStatus;
12683
- if (lastStatus === "failed") {
12684
- throw new APIError(
12685
- `Upstream job failed: ${data.error || "unknown"}`,
12686
- paid.status,
12687
- sanitizeErrorResponse(data)
12688
- );
12689
- }
12690
- if (paid.status === 200 && lastStatus === "completed") {
12691
- return data;
12882
+ failIfFailed(data, paid.status);
12883
+ if (paid.status === 200 && lastStatus === "completed") return deliver(data, paid);
12884
+ if (paid.status === 402 && kind === "video") {
12885
+ if (data.error === "Payment verification failed" && !await isSafeStaleBlockhashResponse(new Response(JSON.stringify(data), { status: 402 }))) {
12886
+ throw new PaymentError(
12887
+ `Payment for the finished video was rejected. Check your Solana USDC balance, then resume the job by polling ${pollUrl} with the same wallet.`
12888
+ );
12889
+ }
12890
+ settleAttempted = true;
12891
+ if (--paidRefusalsLeft <= 0) {
12892
+ throw withDisposition(
12893
+ new PaymentError(
12894
+ `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.`
12895
+ ),
12896
+ "paid-or-in-doubt"
12897
+ );
12898
+ }
12899
+ waitMs = retryAfterMs(paid) ?? intervalMs;
12900
+ continue;
12692
12901
  }
12693
- if (paid.status !== 200 && paid.status !== 202 && paid.status !== 504) {
12902
+ if (!isPendingPollStatus(paid.status) && paid.status !== 200) {
12694
12903
  throw new APIError(`Poll failed: ${paid.status}`, paid.status, sanitizeErrorResponse(data));
12695
12904
  }
12905
+ waitMs = retryAfterMs(paid) ?? intervalMs;
12696
12906
  }
12907
+ 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.";
12697
12908
  throw new APIError(
12698
- `Job did not complete within ${Math.round(budgetMs / 1e3)}s (last status: ${lastStatus}). No payment was taken.`,
12909
+ `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.`,
12699
12910
  504,
12700
- { id, last_status: lastStatus }
12911
+ { id, last_status: lastStatus, poll_url: pollUrl }
12701
12912
  );
12702
12913
  }
12914
+ /**
12915
+ * Absolute URL for a job's `poll_url`. The gateway returns `/api/v1/...`;
12916
+ * {@link buildUrl} drops the `/api` that `apiUrl` already ends with. An
12917
+ * absolute poll_url must be on the gateway's own origin: the poll carries a
12918
+ * wallet signature (and, for a finished video, a payment).
12919
+ */
12920
+ resolvePollUrl(pollPath) {
12921
+ if (!/^https?:\/\//i.test(pollPath)) return this.buildUrl(pollPath);
12922
+ if (new URL(pollPath).origin !== new URL(this.apiUrl).origin) {
12923
+ throw new APIError(`poll_url is not on the gateway origin: ${pollPath}`, 502, { poll_url: pollPath });
12924
+ }
12925
+ return pollPath;
12926
+ }
12927
+ /**
12928
+ * Turn a `402` into a signed Solana payment payload.
12929
+ *
12930
+ * Extracted because four call sites need it — chat, the raw POST helpers, the
12931
+ * raw GET helper, and {@link SolanaLLMClient.stream} — and it had been
12932
+ * written out three times before this. That mattered more than ordinary
12933
+ * duplication: this is the code that signs a transfer of the caller's USDC,
12934
+ * so three copies meant every fix to it had to be applied three times or
12935
+ * quietly apply to two thirds of the paths.
12936
+ *
12937
+ * @param url - the request being paid for.
12938
+ * @param response - the gateway's `402`, not yet consumed.
12939
+ * @param forceFreshBlockhash - set on a re-sign after a stale-blockhash
12940
+ * rejection, so the retry cannot produce byte-identical transaction bytes.
12941
+ * @param resourceFallback - resource URL to claim when the 402 states none.
12942
+ * @returns the header value to replay with, and what it will settle for.
12943
+ * @throws PaymentError when the 402 carries no usable Solana requirements.
12944
+ */
12703
12945
  async signPaymentFrom402(url, response, forceFreshBlockhash, resourceFallback = url) {
12704
12946
  return this.signExactPayment(
12705
12947
  url,
@@ -12789,17 +13031,18 @@ var SolanaLLMClient = class {
12789
13031
  this.recordSettlement(costUsd);
12790
13032
  return this.readPaidJson(retryResponse);
12791
13033
  }
12792
- async requestWithPaymentRaw(endpoint, body) {
13034
+ async requestWithPaymentRaw(endpoint, body, media = {}) {
12793
13035
  const url = `${this.apiUrl}${endpoint}`;
13036
+ const pollBudgetMs = media.pollBudgetMs ?? this.timeout;
12794
13037
  for (let staleRetries = 0; ; ) {
12795
13038
  const response = await this.fetchWithTimeout(url, {
12796
13039
  method: "POST",
12797
13040
  headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT },
12798
13041
  body: JSON.stringify(body)
12799
- });
13042
+ }, media.submitTimeoutMs);
12800
13043
  if (response.status === 402) {
12801
13044
  try {
12802
- return await this.handlePaymentAndRetryRaw(url, body, response, staleRetries > 0);
13045
+ return await this.handlePaymentAndRetryRaw(url, body, response, staleRetries > 0, media);
12803
13046
  } catch (error) {
12804
13047
  if (!(error instanceof SafeStaleBlockhashError) || staleRetries >= STALE_BLOCKHASH_RETRY_BACKOFFS_MS.length) throw error;
12805
13048
  await waitForStaleRetry(staleRetries++);
@@ -12815,26 +13058,38 @@ var SolanaLLMClient = class {
12815
13058
  }
12816
13059
  throw new APIError(`API error: ${response.status}`, response.status, sanitizeErrorResponse(errorBody));
12817
13060
  }
12818
- if (this.apiAuth && endpoint.startsWith("/v1/images/")) return this.apiAuth.poll(response, this.timeout, 2e3);
13061
+ if (this.apiAuth && /^\/v1\/(images|videos|audio)\//.test(endpoint)) {
13062
+ return this.apiAuth.poll(
13063
+ response,
13064
+ pollBudgetMs,
13065
+ endpoint.startsWith("/v1/videos/") ? VIDEO_POLL_INTERVAL_MS : 2e3
13066
+ );
13067
+ }
12819
13068
  return response.json();
12820
13069
  }
12821
13070
  }
12822
- async handlePaymentAndRetryRaw(url, body, response, forceFreshBlockhash = false) {
13071
+ async handlePaymentAndRetryRaw(url, body, response, forceFreshBlockhash = false, media = {}) {
12823
13072
  const { paymentPayload, costUsd } = await this.signPaymentFrom402(url, response, forceFreshBlockhash);
12824
- const retryResponse = await this.fetchWithTimeout(url, {
12825
- method: "POST",
12826
- headers: {
12827
- "Content-Type": "application/json",
12828
- "User-Agent": USER_AGENT,
12829
- "PAYMENT-SIGNATURE": paymentPayload
12830
- },
12831
- body: JSON.stringify(body)
12832
- });
13073
+ let retryResponse;
13074
+ try {
13075
+ retryResponse = await this.fetchWithTimeout(url, {
13076
+ method: "POST",
13077
+ headers: {
13078
+ "Content-Type": "application/json",
13079
+ "User-Agent": USER_AGENT,
13080
+ "PAYMENT-SIGNATURE": paymentPayload
13081
+ },
13082
+ body: JSON.stringify(body)
13083
+ }, media.submitTimeoutMs);
13084
+ } catch (error) {
13085
+ throw withDisposition(error, "paid-or-in-doubt");
13086
+ }
12833
13087
  await this.assertPaid(retryResponse);
12834
13088
  if (retryResponse.status === 202) {
12835
- this.recordSettlement(costUsd);
13089
+ const kind = pollKindFor(url);
13090
+ if (kind !== "video") this.recordSettlement(costUsd);
12836
13091
  const submitted = await retryResponse.json();
12837
- return this.followSolanaJob(submitted, this.timeout);
13092
+ return this.followSolanaJob(submitted, media.pollBudgetMs ?? this.timeout, kind);
12838
13093
  }
12839
13094
  this.recordSettlement(costUsd);
12840
13095
  return retryResponse.json();
@@ -12911,9 +13166,10 @@ var SolanaLLMClient = class {
12911
13166
  } catch {
12912
13167
  errorBody = { error: "Request failed" };
12913
13168
  }
13169
+ const unverified = response.status === 503 && errorBody?.code === "PAYMENT_VERIFICATION_UNAVAILABLE";
12914
13170
  throw withDisposition(
12915
13171
  new APIError(`API error after payment: ${response.status}`, response.status, sanitizeErrorResponse(errorBody)),
12916
- "paid-or-in-doubt"
13172
+ unverified ? "unpaid" : "paid-or-in-doubt"
12917
13173
  );
12918
13174
  }
12919
13175
  }
@@ -12922,9 +13178,9 @@ var SolanaLLMClient = class {
12922
13178
  this.sessionCalls += 1;
12923
13179
  this.sessionTotalUsd += costUsd;
12924
13180
  }
12925
- async fetchWithTimeout(url, options) {
13181
+ async fetchWithTimeout(url, options, timeoutMs = this.timeout) {
12926
13182
  const controller = new AbortController();
12927
- const timeoutId = setTimeout(() => controller.abort(), this.timeout);
13183
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
12928
13184
  try {
12929
13185
  return await (this.apiAuth ? this.apiAuth.fetch.bind(this.apiAuth) : fetch)(url, { ...options, signal: controller.signal });
12930
13186
  } finally {
@@ -13385,8 +13641,12 @@ var AnthropicClient = class {
13385
13641
  baseURL: this._apiUrl,
13386
13642
  apiKey: "blockrun",
13387
13643
  fetch: this._x402Fetch.bind(this),
13388
- // Account POSTs may already be billed when an upstream error arrives.
13389
- ...this.apiAuth ? { maxRetries: 0 } : {}
13644
+ // Never let the Anthropic SDK retry. It retries 408/409/429/5xx and
13645
+ // connection errors by calling this fetch again, which in wallet mode
13646
+ // runs the whole x402 flow and signs a NEW payment each time (three
13647
+ // payments for one call at the default maxRetries of 2); in account
13648
+ // mode the POST itself may already be billed.
13649
+ maxRetries: 0
13390
13650
  });
13391
13651
  return this._client;
13392
13652
  })();