@blockrun/llm 3.20.1 → 3.22.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
@@ -313,6 +313,122 @@ var ApiKeyAuth = class {
313
313
  }
314
314
  };
315
315
 
316
+ // src/search-wire.ts
317
+ function put(out, key, value) {
318
+ if (value !== void 0) out[key] = value;
319
+ }
320
+ function handles(list) {
321
+ return list?.map((h) => h.trim().replace(/^@+/, ""));
322
+ }
323
+ function sourceToWire(source) {
324
+ const raw = source;
325
+ const out = { ...raw };
326
+ for (const key of Object.keys(out)) if (/[A-Z]/.test(key)) delete out[key];
327
+ switch (source.type) {
328
+ case "x":
329
+ put(out, "included_x_handles", handles(source.includedXHandles) ?? out.included_x_handles);
330
+ put(out, "excluded_x_handles", handles(source.excludedXHandles) ?? out.excluded_x_handles);
331
+ put(out, "post_favorite_count", source.postFavoriteCount);
332
+ put(out, "post_view_count", source.postViewCount);
333
+ put(out, "enable_image_understanding", source.enableImageUnderstanding);
334
+ put(out, "enable_video_understanding", source.enableVideoUnderstanding);
335
+ break;
336
+ case "web":
337
+ put(out, "country", source.country);
338
+ put(out, "allowed_websites", source.allowedWebsites);
339
+ put(out, "excluded_websites", source.excludedWebsites);
340
+ put(out, "safe_search", source.safeSearch);
341
+ put(out, "enable_image_understanding", source.enableImageUnderstanding);
342
+ break;
343
+ case "news":
344
+ put(out, "country", source.country);
345
+ put(out, "allowed_websites", source.allowedWebsites);
346
+ put(out, "excluded_websites", source.excludedWebsites);
347
+ put(out, "safe_search", source.safeSearch);
348
+ break;
349
+ case "rss":
350
+ put(out, "links", source.links);
351
+ break;
352
+ }
353
+ return out;
354
+ }
355
+ function toWireSearchParameters(sp) {
356
+ const raw = sp;
357
+ const out = {};
358
+ for (const [key, value] of Object.entries(raw)) if (!/[A-Z]/.test(key) && key !== "sources") out[key] = value;
359
+ put(out, "mode", sp.mode);
360
+ if (sp.sources) out.sources = sp.sources.map(sourceToWire);
361
+ put(out, "return_citations", sp.returnCitations);
362
+ put(out, "from_date", sp.fromDate);
363
+ put(out, "to_date", sp.toDate);
364
+ put(out, "max_search_results", sp.maxSearchResults);
365
+ put(out, "max_turns", sp.maxTurns);
366
+ return out;
367
+ }
368
+ function toWireTools(tools) {
369
+ return tools.map((tool) => {
370
+ if (tool.type === "x_search") {
371
+ const out = { type: "x_search" };
372
+ put(out, "allowed_x_handles", handles(tool.allowedXHandles));
373
+ put(out, "excluded_x_handles", handles(tool.excludedXHandles));
374
+ put(out, "from_date", tool.fromDate);
375
+ put(out, "to_date", tool.toDate);
376
+ put(out, "enable_image_understanding", tool.enableImageUnderstanding);
377
+ put(out, "enable_video_understanding", tool.enableVideoUnderstanding);
378
+ return out;
379
+ }
380
+ if (tool.type === "web_search") {
381
+ const out = { type: "web_search" };
382
+ put(out, "allowed_domains", tool.allowedDomains);
383
+ put(out, "excluded_domains", tool.excludedDomains);
384
+ put(out, "enable_image_understanding", tool.enableImageUnderstanding);
385
+ return out;
386
+ }
387
+ return tool;
388
+ });
389
+ }
390
+
391
+ // src/receipt.ts
392
+ function atomicToUsd(amount) {
393
+ return typeof amount === "string" && /^\d+$/.test(amount) ? Number(amount) / 1e6 : void 0;
394
+ }
395
+ function receiptChargedUsd(receipt) {
396
+ return atomicToUsd(receipt?.extra?.chargedAmount) ?? atomicToUsd(receipt?.amount);
397
+ }
398
+ function decode(header) {
399
+ try {
400
+ const json = typeof Buffer !== "undefined" ? Buffer.from(header, "base64").toString("utf8") : atob(header);
401
+ const parsed = JSON.parse(json);
402
+ return parsed && typeof parsed === "object" ? parsed : void 0;
403
+ } catch {
404
+ return void 0;
405
+ }
406
+ }
407
+ function readSettlement(response, quotedUsd, scheme) {
408
+ const header = response.headers.get("PAYMENT-RESPONSE") ?? response.headers.get("X-PAYMENT-RESPONSE");
409
+ const receipt = header ? decode(header) : void 0;
410
+ const extra = receipt?.extra;
411
+ const costUsd = receiptChargedUsd({
412
+ amount: receipt?.amount,
413
+ extra: extra && typeof extra === "object" ? extra : void 0
414
+ }) ?? quotedUsd;
415
+ const receiptTx = typeof receipt?.transaction === "string" && receipt.transaction ? receipt.transaction : void 0;
416
+ const transaction = receiptTx ?? (response.headers.get("X-Payment-Receipt")?.trim() || void 0);
417
+ const settlement = {
418
+ scheme,
419
+ quotedUsd,
420
+ ...transaction ? { transaction } : {},
421
+ ...typeof receipt?.network === "string" ? { network: receipt.network } : {},
422
+ ...receipt?.success === false ? { success: false } : {}
423
+ };
424
+ return { costUsd, settlement };
425
+ }
426
+ function withCost(body, response, bookedUsd, scheme) {
427
+ body.costUsd = bookedUsd;
428
+ body.settlement = readSettlement(response, bookedUsd, scheme).settlement;
429
+ return body;
430
+ }
431
+
316
432
  // src/client.ts
317
433
  import { privateKeyToAccount } from "viem/accounts";
318
434
 
@@ -4099,6 +4215,11 @@ function errSummary(err) {
4099
4215
  }
4100
4216
  return String(err).slice(0, 100);
4101
4217
  }
4218
+ function requestsSearch(options) {
4219
+ if (options.tools?.some((tool) => tool.type === "x_search" || tool.type === "web_search")) return true;
4220
+ if (options.searchParameters !== void 0) return options.searchParameters.mode !== "off";
4221
+ return options.search === true;
4222
+ }
4102
4223
  function routingProfileForModel(model) {
4103
4224
  return AUTO_ROUTING_PROFILES[model.toLowerCase()];
4104
4225
  }
@@ -4126,7 +4247,7 @@ function routingText(messages) {
4126
4247
  };
4127
4248
  }
4128
4249
  function routeWithCatalog(prompt, systemPrompt, maxOutputTokens, modelPricing, options = {}) {
4129
- const tools = options.tools ?? [];
4250
+ const tools = (options.tools ?? []).filter((tool) => tool.type === "function");
4130
4251
  const requiresTools = options.toolChoice === "none" ? false : options.toolChoice === "required" || typeof options.toolChoice === "object" ? true : void 0;
4131
4252
  const decision = route(prompt, systemPrompt, maxOutputTokens, {
4132
4253
  config: DEFAULT_ROUTING_CONFIG,
@@ -4146,6 +4267,15 @@ function routeWithCatalog(prompt, systemPrompt, maxOutputTokens, modelPricing, o
4146
4267
  const resolved = !id.startsWith("free/") ? id : modelPricing.has(`nvidia/${id.slice(5)}`) ? `nvidia/${id.slice(5)}` : null;
4147
4268
  if (resolved && !callable.includes(resolved)) callable.push(resolved);
4148
4269
  }
4270
+ if (requestsSearch(options)) {
4271
+ const searchable = callable.filter((id) => id.startsWith("xai/"));
4272
+ if (searchable.length === 0) {
4273
+ throw new Error(
4274
+ `Search (x_search / web_search tools or searchParameters) runs on xai/* models only, and the ${options.routingProfile ?? "auto"} route has no xai/* candidate for this request. Call an xai/* model directly (e.g. xai/grok-4.3) or drop the search options.`
4275
+ );
4276
+ }
4277
+ callable.splice(0, callable.length, ...searchable);
4278
+ }
4149
4279
  const estimatedInputTokens = Math.ceil(
4150
4280
  Math.max(options.conversationChars ?? 0, `${systemPrompt ?? ""} ${prompt}`.length) / 4
4151
4281
  );
@@ -4564,7 +4694,7 @@ function getCostSummary() {
4564
4694
  }
4565
4695
 
4566
4696
  // src/version.ts
4567
- var SDK_VERSION = "3.20.1";
4697
+ var SDK_VERSION = "3.22.0";
4568
4698
  var USER_AGENT = `blockrun-ts/${SDK_VERSION}`;
4569
4699
 
4570
4700
  // src/client.ts
@@ -4710,7 +4840,9 @@ var LLMClient = class _LLMClient {
4710
4840
  options?.maxOutputTokens ?? options?.maxTokens ?? DEFAULT_MAX_TOKENS,
4711
4841
  {
4712
4842
  routingProfile: options?.routingProfile,
4713
- requiresStructuredOutput: options?.responseFormat !== void 0
4843
+ requiresStructuredOutput: options?.responseFormat !== void 0,
4844
+ search: options?.search,
4845
+ searchParameters: options?.searchParameters
4714
4846
  }
4715
4847
  );
4716
4848
  }
@@ -4777,6 +4909,8 @@ var LLMClient = class _LLMClient {
4777
4909
  requiresStructuredOutput: options.responseFormat !== void 0,
4778
4910
  tools: options.tools,
4779
4911
  toolChoice: options.toolChoice,
4912
+ search: options.search,
4913
+ searchParameters: options.searchParameters,
4780
4914
  conversationChars,
4781
4915
  hasVision
4782
4916
  }
@@ -4791,6 +4925,7 @@ var LLMClient = class _LLMClient {
4791
4925
  toolChoice: options.toolChoice,
4792
4926
  responseFormat: options.responseFormat,
4793
4927
  stop: options.stop,
4928
+ timeout: options.timeout,
4794
4929
  // An explicit caller-supplied chain wins over the routed one.
4795
4930
  fallbackModels: options.fallbackModels ?? decision.fallbacks
4796
4931
  });
@@ -4867,11 +5002,11 @@ var LLMClient = class _LLMClient {
4867
5002
  if (options?.temperature !== void 0) body.temperature = options.temperature;
4868
5003
  if (options?.topP !== void 0) body.top_p = options.topP;
4869
5004
  if (options?.searchParameters !== void 0) {
4870
- body.search_parameters = options.searchParameters;
5005
+ body.search_parameters = toWireSearchParameters(options.searchParameters);
4871
5006
  } else if (options?.search === true) {
4872
5007
  body.search_parameters = { mode: "on" };
4873
5008
  }
4874
- if (options?.tools !== void 0) body.tools = options.tools;
5009
+ if (options?.tools !== void 0) body.tools = toWireTools(options.tools);
4875
5010
  if (options?.toolChoice !== void 0) body.tool_choice = options.toolChoice;
4876
5011
  if (options?.responseFormat !== void 0) body.response_format = options.responseFormat;
4877
5012
  if (options?.stop !== void 0) body.stop = options.stop;
@@ -4882,7 +5017,7 @@ var LLMClient = class _LLMClient {
4882
5017
  for (let i = 0; i < chain.length; i++) {
4883
5018
  const candidate = chain[i];
4884
5019
  try {
4885
- return await this.requestWithPayment("/v1/chat/completions", buildBody(candidate));
5020
+ return await this.requestWithPayment("/v1/chat/completions", buildBody(candidate), options?.timeout);
4886
5021
  } catch (err) {
4887
5022
  lastErr = err;
4888
5023
  const next = chain[i + 1];
@@ -4953,19 +5088,19 @@ var LLMClient = class _LLMClient {
4953
5088
  * is `"paid-or-in-doubt"`. With an API key the request itself is billed, so
4954
5089
  * only the account API's explicit 4xx refusal is `"unpaid"`.
4955
5090
  */
4956
- async requestWithPayment(endpoint, body) {
5091
+ async requestWithPayment(endpoint, body, timeoutMs) {
4957
5092
  const url = `${this.apiUrl}${endpoint}`;
4958
5093
  const unpaid = {
4959
5094
  method: "POST",
4960
5095
  headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT },
4961
5096
  body: JSON.stringify(body)
4962
5097
  };
4963
- const response = await this.sendUnpaid(url, unpaid);
5098
+ const response = await this.sendUnpaid(url, unpaid, timeoutMs);
4964
5099
  if (response.status === 502 || response.status === 503) {
4965
5100
  await new Promise((r) => setTimeout(r, 1e3));
4966
- const retryResp = await this.sendUnpaid(url, unpaid);
5101
+ const retryResp = await this.sendUnpaid(url, unpaid, timeoutMs);
4967
5102
  if (retryResp.status !== 502 && retryResp.status !== 503) {
4968
- if (retryResp.status === 402) return this.handlePaymentAndRetry(url, body, retryResp);
5103
+ if (retryResp.status === 402) return this.handlePaymentAndRetry(url, body, retryResp, timeoutMs);
4969
5104
  if (!retryResp.ok) {
4970
5105
  let errorBody;
4971
5106
  try {
@@ -4982,7 +5117,7 @@ var LLMClient = class _LLMClient {
4982
5117
  }
4983
5118
  }
4984
5119
  if (response.status === 402) {
4985
- return this.handlePaymentAndRetry(url, body, response);
5120
+ return this.handlePaymentAndRetry(url, body, response, timeoutMs);
4986
5121
  }
4987
5122
  if (!response.ok) {
4988
5123
  let errorBody;
@@ -5007,9 +5142,9 @@ var LLMClient = class _LLMClient {
5007
5142
  * {@link RetryDisposition}: `"unpaid"` with a wallet; with an API key, see
5008
5143
  * {@link accountErrorDisposition} (the request itself is billed).
5009
5144
  */
5010
- async sendUnpaid(url, init) {
5145
+ async sendUnpaid(url, init, timeoutMs) {
5011
5146
  try {
5012
- return await this.fetchWithTimeout(url, init);
5147
+ return await this.fetchWithTimeout(url, init, timeoutMs);
5013
5148
  } catch (err) {
5014
5149
  throw withDisposition(err, this.apiAuth ? accountErrorDisposition(err) : "unpaid");
5015
5150
  }
@@ -5017,13 +5152,22 @@ var LLMClient = class _LLMClient {
5017
5152
  /**
5018
5153
  * Parse a response to a request that carried no payment: the free tier,
5019
5154
  * or, with an API key, the billed request itself.
5155
+ *
5156
+ * With a wallet nothing was paid, so `costUsd` is 0 (a free model). With an
5157
+ * API key the call is billed to the account and `costUsd` stays undefined.
5020
5158
  */
5021
5159
  async parseUnpaidChatResponse(response) {
5160
+ let served;
5022
5161
  try {
5023
- return await this.parseChatResponse(response);
5162
+ served = await this.parseChatResponse(response);
5024
5163
  } catch (err) {
5025
5164
  throw withDisposition(err, this.apiAuth ? "paid-or-in-doubt" : "unpaid");
5026
5165
  }
5166
+ if (!this.apiAuth) {
5167
+ served.costUsd = 0;
5168
+ served.settlement = { scheme: "free", quotedUsd: 0 };
5169
+ }
5170
+ return served;
5027
5171
  }
5028
5172
  /**
5029
5173
  * Handle 402 response: parse requirements, sign payment, retry.
@@ -5031,7 +5175,7 @@ var LLMClient = class _LLMClient {
5031
5175
  * Anything thrown before the signed payment is sent is `"unpaid"`; anything
5032
5176
  * after it, `"paid-or-in-doubt"` (see {@link RetryDisposition}).
5033
5177
  */
5034
- async handlePaymentAndRetry(url, body, response) {
5178
+ async handlePaymentAndRetry(url, body, response, timeoutMs) {
5035
5179
  let signed;
5036
5180
  try {
5037
5181
  signed = await this.signFrom402(url, response);
@@ -5039,7 +5183,7 @@ var LLMClient = class _LLMClient {
5039
5183
  throw withDisposition(err, "unpaid");
5040
5184
  }
5041
5185
  try {
5042
- return await this.sendPaid(url, body, signed.paymentPayload, signed.details);
5186
+ return await this.sendPaid(url, body, signed.paymentPayload, signed.details, timeoutMs);
5043
5187
  } catch (err) {
5044
5188
  throw withDisposition(err, "paid-or-in-doubt");
5045
5189
  }
@@ -5084,7 +5228,7 @@ var LLMClient = class _LLMClient {
5084
5228
  return { paymentPayload, details };
5085
5229
  }
5086
5230
  /** Send the signed payment and read the answer. The payment may settle from here on. */
5087
- async sendPaid(url, body, paymentPayload, details) {
5231
+ async sendPaid(url, body, paymentPayload, details, timeoutMs) {
5088
5232
  const retryResponse = await this.fetchWithTimeout(url, {
5089
5233
  method: "POST",
5090
5234
  headers: {
@@ -5093,7 +5237,7 @@ var LLMClient = class _LLMClient {
5093
5237
  "PAYMENT-SIGNATURE": paymentPayload
5094
5238
  },
5095
5239
  body: JSON.stringify(body)
5096
- });
5240
+ }, timeoutMs);
5097
5241
  if (retryResponse.status === 502 || retryResponse.status === 503) {
5098
5242
  await new Promise((r) => setTimeout(r, 1e3));
5099
5243
  const retryResp2 = await this.fetchWithTimeout(url, {
@@ -5104,7 +5248,7 @@ var LLMClient = class _LLMClient {
5104
5248
  "PAYMENT-SIGNATURE": paymentPayload
5105
5249
  },
5106
5250
  body: JSON.stringify(body)
5107
- });
5251
+ }, timeoutMs);
5108
5252
  if (retryResp2.status !== 502 && retryResp2.status !== 503) {
5109
5253
  if (retryResp2.status === 402) throw new PaymentError("Payment was rejected. Check your wallet balance.");
5110
5254
  if (!retryResp2.ok) {
@@ -5120,7 +5264,7 @@ var LLMClient = class _LLMClient {
5120
5264
  this.sessionCalls += 1;
5121
5265
  this.sessionTotalUsd += costUsd2;
5122
5266
  this.recordCost(url, costUsd2, { body, network: details.network });
5123
- return this.parseChatResponse(retryResp2);
5267
+ return withCost(await this.parseChatResponse(retryResp2), retryResp2, costUsd2, "exact");
5124
5268
  }
5125
5269
  }
5126
5270
  if (retryResponse.status === 402) {
@@ -5143,7 +5287,7 @@ var LLMClient = class _LLMClient {
5143
5287
  this.sessionCalls += 1;
5144
5288
  this.sessionTotalUsd += costUsd;
5145
5289
  this.recordCost(url, costUsd, { body, network: details.network });
5146
- return this.parseChatResponse(retryResponse);
5290
+ return withCost(await this.parseChatResponse(retryResponse), retryResponse, costUsd, "exact");
5147
5291
  }
5148
5292
  /**
5149
5293
  * Sign a payment header and return the PAYMENT-SIGNATURE value.
@@ -5213,7 +5357,7 @@ var LLMClient = class _LLMClient {
5213
5357
  };
5214
5358
  if (options?.temperature !== void 0) body.temperature = options.temperature;
5215
5359
  if (options?.topP !== void 0) body.top_p = options.topP;
5216
- if (options?.tools !== void 0) body.tools = options.tools;
5360
+ if (options?.tools !== void 0) body.tools = toWireTools(options.tools);
5217
5361
  if (options?.toolChoice !== void 0) body.tool_choice = options.toolChoice;
5218
5362
  if (options?.responseFormat !== void 0) body.response_format = options.responseFormat;
5219
5363
  if (options?.stop !== void 0) body.stop = options.stop;
@@ -5611,11 +5755,11 @@ var LLMClient = class _LLMClient {
5611
5755
  return retryResponse.json();
5612
5756
  }
5613
5757
  /**
5614
- * Fetch with timeout.
5758
+ * Fetch with timeout: `timeoutMs` (a per-call override), else the client's `timeout`.
5615
5759
  */
5616
- async fetchWithTimeout(url, options) {
5760
+ async fetchWithTimeout(url, options, timeoutMs = this.timeout) {
5617
5761
  const controller = new AbortController();
5618
- const timeoutId = setTimeout(() => controller.abort(), this.timeout);
5762
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
5619
5763
  try {
5620
5764
  const response = await (this.apiAuth ? this.apiAuth.fetch.bind(this.apiAuth) : fetch)(url, {
5621
5765
  ...options,
@@ -9329,11 +9473,11 @@ var WALLET_DIR = path4.join(os3.homedir(), ".blockrun");
9329
9473
  var SOLANA_WALLET_FILE = path4.join(WALLET_DIR, ".solana-session");
9330
9474
  async function createSolanaWallet() {
9331
9475
  const { Keypair } = await loadSolanaWeb3("Solana wallet operations");
9332
- const bs582 = await import("bs58");
9476
+ const bs583 = await import("bs58");
9333
9477
  const keypair = Keypair.generate();
9334
9478
  return {
9335
9479
  address: keypair.publicKey.toBase58(),
9336
- privateKey: (bs582.default ?? bs582).encode(keypair.secretKey)
9480
+ privateKey: (bs583.default ?? bs583).encode(keypair.secretKey)
9337
9481
  };
9338
9482
  }
9339
9483
  async function solanaKeyToBytes(privateKey) {
@@ -9371,8 +9515,8 @@ async function solanaKeyToBytes(privateKey) {
9371
9515
  return bytes;
9372
9516
  }
9373
9517
  try {
9374
- const bs582 = await import("bs58");
9375
- const bytes = (bs582.default ?? bs582).decode(key);
9518
+ const bs583 = await import("bs58");
9519
+ const bytes = (bs583.default ?? bs583).decode(key);
9376
9520
  if (bytes.length !== 64) {
9377
9521
  throw new Error(`Invalid Solana key length: expected 64 bytes, got ${bytes.length}`);
9378
9522
  }
@@ -9551,15 +9695,56 @@ async function getOrCreateSolanaWallet() {
9551
9695
  return { address, privateKey, isNew: true };
9552
9696
  }
9553
9697
 
9698
+ // src/poll-ownership.ts
9699
+ import { createPrivateKey, createPublicKey, sign as ed25519Sign } from "crypto";
9700
+ import bs58 from "bs58";
9701
+ var POLL_AUTH_HEADERS = {
9702
+ wallet: "x-poll-wallet",
9703
+ timestamp: "x-poll-timestamp",
9704
+ signature: "x-poll-signature"
9705
+ };
9706
+ function pollAuthMessage(kind, jobId, timestampS) {
9707
+ return `blockrun-poll:v1:${kind}:${jobId}:${timestampS}`;
9708
+ }
9709
+ function pollJobIdFromUrl(pollUrl) {
9710
+ const path7 = new URL(pollUrl, "https://placeholder.invalid").pathname.replace(/\/+$/, "");
9711
+ const segment = path7.slice(path7.lastIndexOf("/") + 1);
9712
+ if (!segment) throw new Error(`poll_url has no job id segment: ${pollUrl}`);
9713
+ return decodeURIComponent(segment);
9714
+ }
9715
+ var ED25519_PKCS8_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
9716
+ function signPollOwnership(secretKey, kind, jobId, nowMs = Date.now()) {
9717
+ if (secretKey.length !== 64 && secretKey.length !== 32) {
9718
+ throw new Error(`Solana secret key must be 64 bytes (or a 32-byte seed), got ${secretKey.length}`);
9719
+ }
9720
+ const seed = Buffer.from(secretKey.subarray(0, 32));
9721
+ const privateKey = createPrivateKey({
9722
+ key: Buffer.concat([ED25519_PKCS8_PREFIX, seed]),
9723
+ format: "der",
9724
+ type: "pkcs8"
9725
+ });
9726
+ const jwk = createPublicKey(privateKey).export({ format: "jwk" });
9727
+ if (!jwk.x) throw new Error("Could not derive the Ed25519 public key");
9728
+ const publicKey = Buffer.from(jwk.x, "base64url");
9729
+ const timestampS = Math.floor(nowMs / 1e3);
9730
+ const signature = ed25519Sign(null, Buffer.from(pollAuthMessage(kind, jobId, timestampS), "utf8"), privateKey);
9731
+ return {
9732
+ [POLL_AUTH_HEADERS.wallet]: bs58.encode(publicKey),
9733
+ [POLL_AUTH_HEADERS.timestamp]: String(timestampS),
9734
+ [POLL_AUTH_HEADERS.signature]: bs58.encode(signature)
9735
+ };
9736
+ }
9737
+
9554
9738
  // src/solana-batch.ts
9555
9739
  import { AsyncLocalStorage } from "async_hooks";
9556
9740
  import { randomUUID } from "crypto";
9557
9741
  import * as fs6 from "fs";
9558
9742
  import * as path5 from "path";
9559
- import bs58 from "bs58";
9743
+ import bs582 from "bs58";
9560
9744
  var BATCH_SCHEME = "batch-settlement";
9561
9745
  var BLOCKRUN_SOL_OPERATOR = "5YKPQUFjw5WQqhSUkEGKNNfYYVqnRRNbpYyL71qQ1vm3";
9562
9746
  var DEFAULT_BATCH_RATE_LIMIT = { maxAttempts: 3, maxWaitMs: 6e4 };
9747
+ var DEFAULT_BATCH_IN_DOUBT = { maxWaitMs: 3e5 };
9563
9748
  var BatchPaymentUnresolvedError = class extends PaymentError {
9564
9749
  /** The {@link SolanaBatchEvent.reason} of the `unresolved` event reported with it. */
9565
9750
  reason;
@@ -10116,7 +10301,7 @@ function decodeChannelAccount(owner, data) {
10116
10301
  return void 0;
10117
10302
  }
10118
10303
  const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
10119
- const key = (offset) => bs58.encode(data.subarray(offset, offset + 32));
10304
+ const key = (offset) => bs582.encode(data.subarray(offset, offset + 32));
10120
10305
  return {
10121
10306
  deposit: view.getBigUint64(CHANNEL.deposit, true),
10122
10307
  settled: view.getBigUint64(CHANNEL.settled, true),
@@ -10236,6 +10421,46 @@ function withoutClientSignedBatch(paymentRequired) {
10236
10421
  const accepts = paymentRequired.accepts ?? [];
10237
10422
  return accepts.some(clientSignedBatch) ? { ...paymentRequired, accepts: accepts.filter((accept) => !clientSignedBatch(accept)) } : paymentRequired;
10238
10423
  }
10424
+ var PAYMENT_IDENTIFIER = "payment-identifier";
10425
+ function withPaymentIdentifier(paymentRequired) {
10426
+ const declared = paymentRequired.extensions?.[PAYMENT_IDENTIFIER];
10427
+ if (typeof declared?.info?.required !== "boolean") return paymentRequired;
10428
+ const id = `pay_${randomUUID().replace(/-/g, "")}`;
10429
+ return {
10430
+ ...paymentRequired,
10431
+ extensions: { ...paymentRequired.extensions, [PAYMENT_IDENTIFIER]: { ...declared, info: { ...declared.info, id } } }
10432
+ };
10433
+ }
10434
+ function peekReceipt(http, getHeader) {
10435
+ try {
10436
+ return http.getPaymentSettleResponse(getHeader);
10437
+ } catch {
10438
+ return void 0;
10439
+ }
10440
+ }
10441
+ function receiptSucceeded(http, answer) {
10442
+ return peekReceipt(http, (name) => answer.headers.get(name))?.success === true;
10443
+ }
10444
+ function hasPaymentIdentifier(payload) {
10445
+ const info = payload.extensions?.[PAYMENT_IDENTIFIER]?.info;
10446
+ return typeof info?.id === "string";
10447
+ }
10448
+ var IN_DOUBT_MIN_WAIT_MS = 1e3;
10449
+ var IN_DOUBT_MAX_WAIT_MS = 3e4;
10450
+ function replayMayResolve(doubt) {
10451
+ if (doubt.unanswered) return true;
10452
+ const answer = doubt.held;
10453
+ if (!answer) return false;
10454
+ return answer.status >= 500 || answer.status === 429 || answer.status === 409 && Boolean(answer.headers.get("retry-after")?.trim());
10455
+ }
10456
+ function inDoubtWait(doubt, replay) {
10457
+ const asked = doubt.held ? parseRetryAfter(doubt.held.headers.get("retry-after")) : void 0;
10458
+ return Math.min(IN_DOUBT_MAX_WAIT_MS, Math.max(IN_DOUBT_MIN_WAIT_MS, asked ?? backoffDelay(replay)));
10459
+ }
10460
+ function replayDoubt(outcome) {
10461
+ if (outcome.kind === "charged" || outcome.kind === "in_doubt") return outcome;
10462
+ return { kind: "in_doubt", reason: "replay_unresolved", replayable: false, settled: true, detail: "the replay was refused" };
10463
+ }
10239
10464
  function trustedBatchAccepts(paymentRequired, operators) {
10240
10465
  return (paymentRequired.accepts ?? []).filter((candidate) => {
10241
10466
  const extra = candidate.extra;
@@ -10245,9 +10470,6 @@ function trustedBatchAccepts(paymentRequired, operators) {
10245
10470
  function trustedBatchAccept(paymentRequired, operators) {
10246
10471
  return trustedBatchAccepts(paymentRequired, operators)[0];
10247
10472
  }
10248
- function atomicToUsd(amount) {
10249
- return typeof amount === "string" && /^\d+$/.test(amount) ? Number(amount) / 1e6 : void 0;
10250
- }
10251
10473
  function chargedUsd(getReceipt) {
10252
10474
  let receipt;
10253
10475
  try {
@@ -10255,7 +10477,7 @@ function chargedUsd(getReceipt) {
10255
10477
  } catch {
10256
10478
  return 0;
10257
10479
  }
10258
- return atomicToUsd(receipt?.extra?.chargedAmount) ?? atomicToUsd(receipt?.amount) ?? 0;
10480
+ return receiptChargedUsd(receipt) ?? 0;
10259
10481
  }
10260
10482
  function rebuiltCharge(settled, payload) {
10261
10483
  if (settled?.success !== true) return 0n;
@@ -10375,13 +10597,42 @@ function parseRetryAfter(value, now = Date.now()) {
10375
10597
  }
10376
10598
  return Number.isFinite(ms) && ms <= MAX_RETRY_AFTER_MS ? ms : void 0;
10377
10599
  }
10378
- var NOT_BROADCAST_429 = /* @__PURE__ */ new Set([
10600
+ var NOT_BROADCAST_REFUSALS = /* @__PURE__ */ new Set([
10601
+ // 429
10379
10602
  "batch_account_channel_capacity_exhausted",
10380
10603
  "batch_channel_capacity_exhausted",
10381
- "batch_deposit_rate_limited"
10604
+ "batch_deposit_rate_limited",
10605
+ "batch_deposit_ip_rate_limited",
10606
+ // 503
10607
+ "batch_signer_funding_headroom_exhausted",
10608
+ "batch_new_channels_disabled",
10609
+ "batch_public_new_channels_disabled",
10610
+ // 403
10611
+ "batch_account_admission_paused",
10612
+ "batch_channel_owner_mismatch",
10613
+ "batch_fee_payer_unavailable",
10614
+ "batch_fee_payer_not_dedicated",
10615
+ "batch_fee_payer_lane_not_authorized",
10616
+ "batch_transaction_fee_payer_mismatch",
10617
+ // 400
10618
+ "batch_initial_deposit_out_of_range",
10619
+ "batch_deposit_asset_not_supported",
10620
+ "batch_withdraw_delay_out_of_range"
10382
10621
  ]);
10383
10622
  function provesNothingBroadcast(receipt) {
10384
- return receipt?.success === false && !receipt.transaction && typeof receipt.errorReason === "string" && NOT_BROADCAST_429.has(receipt.errorReason);
10623
+ return receipt?.success === false && !receipt.transaction && typeof receipt.errorReason === "string" && NOT_BROADCAST_REFUSALS.has(receipt.errorReason);
10624
+ }
10625
+ var CONFIG_REFUSALS = /* @__PURE__ */ new Set([
10626
+ "batch_initial_deposit_out_of_range",
10627
+ "batch_deposit_asset_not_supported",
10628
+ "batch_withdraw_delay_out_of_range"
10629
+ ]);
10630
+ var REFUSAL_COOLDOWN_MS = 6e4;
10631
+ var CONFIG_REFUSAL_COOLDOWN_MS = 10 * 6e4;
10632
+ var REFUSAL_COOLDOWN_MAX_MS = 15 * 6e4;
10633
+ function refusalCooldownMs(errorReason, retryAfterMs2) {
10634
+ if (retryAfterMs2 !== void 0) return Math.min(retryAfterMs2, REFUSAL_COOLDOWN_MAX_MS);
10635
+ return errorReason !== void 0 && CONFIG_REFUSALS.has(errorReason) ? CONFIG_REFUSAL_COOLDOWN_MS : REFUSAL_COOLDOWN_MS;
10385
10636
  }
10386
10637
  function backoffDelay(attempt) {
10387
10638
  const base = 1e3 * 2 ** Math.max(0, attempt - 1);
@@ -10514,7 +10765,7 @@ function intentTarget(intent) {
10514
10765
  var SolanaBatchPayer = class {
10515
10766
  constructor(init) {
10516
10767
  this.init = init;
10517
- const { operators, rateLimit } = init.options;
10768
+ const { operators, rateLimit, inDoubt } = init.options;
10518
10769
  if (!Array.isArray(operators) || operators.length === 0 || operators.some((o) => typeof o !== "string" || !o)) {
10519
10770
  throw new Error("batch.operators must list at least one base58 operator public key");
10520
10771
  }
@@ -10526,11 +10777,16 @@ var SolanaBatchPayer = class {
10526
10777
  if (!Number.isFinite(this.maxWaitMs) || this.maxWaitMs < 0) {
10527
10778
  throw new Error("batch.rateLimit.maxWaitMs must be a finite number of ms >= 0");
10528
10779
  }
10780
+ this.inDoubtMaxWaitMs = inDoubt?.maxWaitMs ?? DEFAULT_BATCH_IN_DOUBT.maxWaitMs;
10781
+ if (!Number.isFinite(this.inDoubtMaxWaitMs) || this.inDoubtMaxWaitMs < 0) {
10782
+ throw new Error("batch.inDoubt.maxWaitMs must be a finite number of ms >= 0");
10783
+ }
10529
10784
  }
10530
10785
  init;
10531
10786
  state;
10532
10787
  maxAttempts;
10533
10788
  maxWaitMs;
10789
+ inDoubtMaxWaitMs;
10534
10790
  counters = {
10535
10791
  fallbacks: 0,
10536
10792
  fallbacksByReason: {},
@@ -10899,14 +11155,19 @@ var SolanaBatchPayer = class {
10899
11155
  * the call's result (`served`, nothing paid); one that fails otherwise
10900
11156
  * is the call's error (`failed`, `"unpaid"`), never an exact payment
10901
11157
  * against the stale challenge;
10902
- * - in doubt: ends only with a definitive success receipt from the one
10903
- * replay a receipt-less 429 is allowed ({@link resolveInDoubt}), or a
10904
- * thrown {@link BatchPaymentUnresolvedError}. No new authorization, no
10905
- * new deposit, no exact payment and no fallback model is ever paid for a
10906
- * call in doubt.
11158
+ * - in doubt: ends only with a definitive success receipt from a replay
11159
+ * of the identical payment ({@link resolveInDoubt}: the one replay a
11160
+ * receipt-less 429 is allowed, or the replays of a payment carrying a
11161
+ * payment identifier while the gateway says one can succeed, within
11162
+ * `inDoubt.maxWaitMs`), or a thrown {@link BatchPaymentUnresolvedError}.
11163
+ * No new authorization, no new deposit, no exact payment and no fallback
11164
+ * model is ever paid for a call in doubt.
10907
11165
  *
10908
11166
  * The wallet stays in cooldown after a 429, so its other calls wait
10909
- * instead of sending opens of their own.
11167
+ * instead of sending opens of their own. After a deposit the facilitator
11168
+ * refused before broadcasting it (a 402 with a failed `batch_*` admission
11169
+ * receipt), its calls do not wait: they pay exact at once
11170
+ * (`admission_refused`) until the refusal cooldown ends.
10910
11171
  *
10911
11172
  * @param closesAtStart - {@link closeFence}, taken before the call's first
10912
11173
  * (unpaid) request. If a close completed since, the call pays exact
@@ -10970,6 +11231,15 @@ var SolanaBatchPayer = class {
10970
11231
  try {
10971
11232
  for (; ; ) {
10972
11233
  const now = Date.now();
11234
+ if ((wallet.refusedUntil ?? 0) > now) {
11235
+ return fallback({
11236
+ reason: "admission_refused",
11237
+ errorReason: wallet.refusedFor,
11238
+ retryAfterMs: (wallet.refusedUntil ?? 0) - now,
11239
+ attempt: budget.attempt,
11240
+ detail: `the facilitator refused this wallet's last batch deposit (${wallet.refusedFor ?? "unknown"}); batch is skipped until the refusal cooldown ends`
11241
+ });
11242
+ }
10973
11243
  if (wallet.cooldownUntil > now && wallet.cooldownUntil !== waitedUntil) {
10974
11244
  const wait = wallet.cooldownUntil - now;
10975
11245
  if (budget.waited + wait > this.maxWaitMs) {
@@ -11059,12 +11329,23 @@ var SolanaBatchPayer = class {
11059
11329
  if (built.kind !== "authorization") {
11060
11330
  if (outcome.depositSafe) this.forgetIntent(wallet, built.key);
11061
11331
  else this.distrust(wallet, built, "deposit_refused");
11332
+ if (outcome.refusedForMs !== void 0) {
11333
+ const until = Date.now() + outcome.refusedForMs;
11334
+ if (until > (wallet.refusedUntil ?? 0)) {
11335
+ wallet.refusedUntil = until;
11336
+ wallet.refusedFor = outcome.errorReason;
11337
+ }
11338
+ }
11062
11339
  }
11063
11340
  sent = void 0;
11064
11341
  release();
11065
11342
  if (!outcome.rateLimited) {
11066
- const { kind: _kind, rateLimited: _limited, depositSafe: _safe, ...event } = outcome;
11067
- return fallback({ ...event, attempt: budget.attempt });
11343
+ const { kind: _kind, rateLimited: _limited, depositSafe: _safe, refusedForMs, ...event } = outcome;
11344
+ return fallback({
11345
+ ...event,
11346
+ ...refusedForMs !== void 0 && built.kind !== "authorization" ? { retryAfterMs: refusedForMs } : {},
11347
+ attempt: budget.attempt
11348
+ });
11068
11349
  }
11069
11350
  const wait = this.coolDown(wallet, outcome.rateLimited.retryAfterMs ?? backoffDelay(budget.attempt));
11070
11351
  if (budget.attempt >= this.maxAttempts || budget.waited + wait > this.maxWaitMs) {
@@ -11110,7 +11391,7 @@ var SolanaBatchPayer = class {
11110
11391
  if (built.kind !== "authorization" && !(built.key && wallet.resyncs.has(built.key))) this.forgetIntent(wallet, built.key);
11111
11392
  sent = void 0;
11112
11393
  release();
11113
- await this.report({ type: "recovered", reason: "rate_limited", attempt: budget.attempt });
11394
+ await this.report({ type: "recovered", reason: outcome.status === 429 ? "rate_limited" : "in_doubt", attempt: budget.attempt });
11114
11395
  return { kind: "paid", response: resolved.response, chargedUsd: resolved.chargedUsd };
11115
11396
  }
11116
11397
  return await this.raiseUnresolved(wallet, built, resolved, budget.attempt);
@@ -11159,21 +11440,24 @@ var SolanaBatchPayer = class {
11159
11440
  /**
11160
11441
  * Try to end a payment in doubt with the gateway's own word.
11161
11442
  *
11162
- * The one source of evidence this SDK accepts today is a definitive
11163
- * success receipt on the single byte-identical replay a 429 without a
11164
- * receipt is allowed, after its backoff (the owner's bounded safety
11165
- * attempt). Any other answer to it (a 402 or `duplicate_settlement`, which
11166
- * mean the original reached the gateway; another 429; a 5xx; a timeout)
11167
- * leaves it in doubt. Every other in-doubt outcome is never replayed.
11443
+ * The one source of evidence this SDK accepts is a definitive success
11444
+ * receipt on a byte-identical replay of the payment. Any other answer to a
11445
+ * replay (a 402 or `duplicate_settlement`, which mean the original reached
11446
+ * the gateway; a 409; another 429; a 5xx; a timeout) leaves it in doubt.
11447
+ *
11448
+ * - A payment carrying a payment identifier is replayed for as long as
11449
+ * the gateway says a later replay can succeed ({@link replayInDoubt}).
11450
+ * - Any other payment gets a replay only after a 429 without a receipt:
11451
+ * one, after its backoff (the owner's bounded safety attempt), within
11452
+ * `rateLimit`. Every other in-doubt outcome is never replayed.
11168
11453
  *
11169
11454
  * Chain state is not evidence here: it can settle a deposit (see
11170
- * `resync`), never whether the gateway charged. Gateway-side evidence (a
11171
- * receipt on every response, a request-status endpoint with a fence)
11172
- * plugs in here, before the caller raises.
11455
+ * `resync`), never whether the gateway charged.
11173
11456
  *
11174
11457
  * @returns the charged replay, or the doubt to raise.
11175
11458
  */
11176
11459
  async resolveInDoubt(wallet, sent, doubt, send, budget) {
11460
+ if (hasPaymentIdentifier(sent.payload)) return this.replayInDoubt(wallet, sent, doubt, send, budget.attempt);
11177
11461
  if (!doubt.replayable) return doubt;
11178
11462
  const wait = this.coolDown(wallet, doubt.retryAfterMs ?? backoffDelay(budget.attempt));
11179
11463
  if (budget.attempt >= this.maxAttempts || budget.waited + wait > this.maxWaitMs) {
@@ -11193,9 +11477,94 @@ var SolanaBatchPayer = class {
11193
11477
  budget.waited += wait;
11194
11478
  await sleep2(wait);
11195
11479
  budget.attempt += 1;
11196
- const replayed = await this.sendOnce(wallet, sent, send, true, true);
11197
- if (replayed.kind === "charged") return replayed;
11198
- return replayed.kind === "in_doubt" ? replayed : { kind: "in_doubt", reason: "replay_unresolved", replayable: false, settled: true, detail: "the replay was refused" };
11480
+ return replayDoubt(await this.sendOnce(wallet, sent, send, true, true));
11481
+ }
11482
+ /**
11483
+ * Replay a payment in doubt that carries a payment identifier until the
11484
+ * gateway's answer settles it.
11485
+ *
11486
+ * A gateway that declares `payment-identifier` stores each paid response
11487
+ * under its identifier and answers a byte-identical replay of a completed
11488
+ * request with it, the original receipt included; while the original is
11489
+ * still running, it answers 409 with a `Retry-After`. So the identical
11490
+ * payment (the same `PAYMENT-SIGNATURE` header and the same body) is
11491
+ * replayed for as long as the last answer says a later one can succeed
11492
+ * ({@link replayMayResolve}), after {@link inDoubtWait} each time, as long
11493
+ * as the wait fits in `inDoubt.maxWaitMs`, counted from the first doubt:
11494
+ * every wait (as long as it really took) and every replay's round trip. A
11495
+ * replay already sent is not cut short. These replays do not count
11496
+ * against `rateLimit`; a 429 among them still puts the wallet in cooldown.
11497
+ *
11498
+ * Every answer that does not settle it is held back from the scheme
11499
+ * ({@link InDoubt.held}) and handed over only once no replay follows, so
11500
+ * the scheme rolls the payment back once, or takes the receipt that answer
11501
+ * carries. That is the last answer, unless an earlier one carried a
11502
+ * success receipt and the later ones do not: a success receipt is the
11503
+ * gateway's word that it charged, and handing the scheme a receipt-less
11504
+ * refusal of a replay instead would roll a real charge back and desync the
11505
+ * channel's cumulative.
11506
+ *
11507
+ * @returns the charged replay, or the doubt to raise.
11508
+ */
11509
+ async replayInDoubt(wallet, sent, first, send, attempt) {
11510
+ let doubt = first;
11511
+ let held = first.held;
11512
+ let heldSucceeded = held !== void 0 && receiptSucceeded(sent.http, held);
11513
+ let replays = 0;
11514
+ let spent = 0;
11515
+ const stop = async (note) => {
11516
+ if (!doubt.settled && held) {
11517
+ const answer = held;
11518
+ await this.settle(sent.http, sent.payload, (name) => answer.headers.get(name), answer.status);
11519
+ }
11520
+ const notes = [replays > 0 ? `${replays} replay${replays === 1 ? "" : "s"} did not resolve it` : "", note ?? ""];
11521
+ return {
11522
+ ...doubt,
11523
+ settled: doubt.settled || held !== void 0,
11524
+ held: void 0,
11525
+ firstReason: first.reason,
11526
+ detail: [doubt.detail, ...notes].filter(Boolean).join("; ")
11527
+ };
11528
+ };
11529
+ for (; ; ) {
11530
+ if (!replayMayResolve(doubt)) {
11531
+ return stop(doubt.held?.status === 409 ? "the gateway sent no Retry-After, so no replay can resolve it" : void 0);
11532
+ }
11533
+ const wait = inDoubtWait(doubt, replays + 1);
11534
+ const status2 = doubt.held?.status;
11535
+ if (status2 === 429) this.coolDown(wallet, (doubt.held && parseRetryAfter(doubt.held.headers.get("retry-after"))) ?? wait);
11536
+ if (spent + wait > this.inDoubtMaxWaitMs) {
11537
+ return stop(`batch.inDoubt.maxWaitMs (${this.inDoubtMaxWaitMs} ms) leaves no room for ${replays > 0 ? "another replay" : "a replay"}`);
11538
+ }
11539
+ await this.report({
11540
+ type: "backoff",
11541
+ reason: status2 === 429 ? "rate_limited" : "in_doubt",
11542
+ status: status2,
11543
+ errorReason: doubt.errorReason,
11544
+ retryAfterMs: wait,
11545
+ attempt,
11546
+ detail: `replay ${replays + 1} of the payment in doubt`
11547
+ });
11548
+ const sleptFrom = Date.now();
11549
+ await sleep2(wait);
11550
+ spent += Math.max(wait, Date.now() - sleptFrom);
11551
+ replays += 1;
11552
+ const startedAt = Date.now();
11553
+ const replayed = replayDoubt(await this.sendOnce(wallet, sent, send, true, true));
11554
+ spent += Math.max(0, Date.now() - startedAt);
11555
+ if (replayed.kind === "charged") return replayed;
11556
+ doubt = replayed;
11557
+ if (doubt.held) {
11558
+ const succeeded = receiptSucceeded(sent.http, doubt.held);
11559
+ if (succeeded || !heldSucceeded) {
11560
+ held = doubt.held;
11561
+ heldSucceeded = succeeded;
11562
+ }
11563
+ } else if (doubt.settled) {
11564
+ held = void 0;
11565
+ heldSucceeded = false;
11566
+ }
11567
+ }
11199
11568
  }
11200
11569
  /**
11201
11570
  * Raise a payment in doubt: release the scheme's hold on it (the channel
@@ -11206,10 +11575,11 @@ var SolanaBatchPayer = class {
11206
11575
  async raiseUnresolved(wallet, sent, doubt, attempt) {
11207
11576
  if (!doubt.settled) await this.settle(sent.http, sent.payload, () => null, 0);
11208
11577
  if (sent.kind !== "authorization") {
11578
+ const reason = doubt.firstReason ?? doubt.reason;
11209
11579
  this.distrust(
11210
11580
  wallet,
11211
11581
  sent,
11212
- doubt.reason === "no_response" ? "deposit_unanswered" : doubt.reason === "replay_unresolved" ? "deposit_rate_limited" : "deposit_failed"
11582
+ reason === "no_response" ? "deposit_unanswered" : reason === "replay_unresolved" ? "deposit_rate_limited" : "deposit_failed"
11213
11583
  );
11214
11584
  }
11215
11585
  await this.report({
@@ -11296,8 +11666,8 @@ var SolanaBatchPayer = class {
11296
11666
  * here, and only for a first send.
11297
11667
  *
11298
11668
  * @param retry - count this send in `retries` (it follows this call's own 429).
11299
- * @param replay - this is the one replay of a receipt-less 429: only a 2xx
11300
- * with a success receipt counts, everything else stays in doubt.
11669
+ * @param replay - this is a replay of a payment in doubt: only a 2xx with
11670
+ * a success receipt counts, everything else stays in doubt.
11301
11671
  */
11302
11672
  async sendOnce(wallet, sent, send, retry, replay) {
11303
11673
  if (retry) this.counters.retries += 1;
@@ -11311,6 +11681,7 @@ var SolanaBatchPayer = class {
11311
11681
  reason: replay ? "replay_unresolved" : "no_response",
11312
11682
  replayable: false,
11313
11683
  settled: false,
11684
+ unanswered: true,
11314
11685
  detail: `${replay ? "the replay" : "the payment"} got no answer (${err instanceof Error ? `${err.name}: ${err.message}` : String(err)})`,
11315
11686
  cause: err
11316
11687
  };
@@ -11335,6 +11706,7 @@ var SolanaBatchPayer = class {
11335
11706
  const getHeader = (name) => response.headers.get(name);
11336
11707
  const receipted = Boolean(getHeader("PAYMENT-RESPONSE") || getHeader("X-PAYMENT-RESPONSE"));
11337
11708
  const status2 = response.status;
11709
+ const hold = hasPaymentIdentifier(payload) && (!response.ok || replay && peekReceipt(http, getHeader)?.success !== true);
11338
11710
  if (status2 === 429 && !receipted) {
11339
11711
  return {
11340
11712
  kind: "in_doubt",
@@ -11344,12 +11716,27 @@ var SolanaBatchPayer = class {
11344
11716
  status: status2,
11345
11717
  retryAfterMs: parseRetryAfter(getHeader("retry-after")),
11346
11718
  errorReason: await rateLimitReason(response, void 0),
11347
- detail: replay ? "the replay was answered 429 without a receipt" : "a 429 without a receipt"
11719
+ detail: replay ? "the replay was answered 429 without a receipt" : "a 429 without a receipt",
11720
+ ...hold ? { held: response } : {}
11348
11721
  };
11349
11722
  }
11723
+ const outcome = await this.classifyAnswer(wallet, sent, response, replay, hold);
11724
+ if (!hold) return outcome;
11725
+ if (outcome.kind === "in_doubt") return { ...outcome, settled: false, held: response };
11726
+ await this.settle(http, payload, getHeader, status2);
11727
+ return outcome;
11728
+ }
11729
+ /**
11730
+ * {@link classify}, past a receipt-less 429. With `hold`, the answer is not
11731
+ * handed to the scheme: its receipt is only read.
11732
+ */
11733
+ async classifyAnswer(wallet, sent, response, replay, hold) {
11734
+ const { http, payload } = sent;
11735
+ const getHeader = (name) => response.headers.get(name);
11736
+ const status2 = response.status;
11350
11737
  const channelId = payloadChannelId(payload);
11351
11738
  const before = channelId ? confirmedState(wallet.book.find(channelId)?.record) : void 0;
11352
- const reconciled = await this.settle(http, payload, getHeader, status2);
11739
+ const reconciled = hold ? { settleResponse: peekReceipt(http, getHeader), ok: false } : await this.settle(http, payload, getHeader, status2);
11353
11740
  const receipt = reconciled.settleResponse;
11354
11741
  const definitive = receipt?.success === true && reconciled.ok && recordAdvanced(before, channelId ? confirmedState(wallet.book.find(channelId)?.record) : void 0, payload, receipt);
11355
11742
  if (!response.ok && !replay && sent.kind === "authorization" && cancelledReceipt(receipt)) {
@@ -11412,7 +11799,15 @@ var SolanaBatchPayer = class {
11412
11799
  cause: await afterPaymentError(response)
11413
11800
  };
11414
11801
  }
11415
- return { kind: "not_charged", reason: "payment_required", status: status2, errorReason: receipt?.errorReason, depositSafe: provesNothingBroadcast(receipt) };
11802
+ const refused = provesNothingBroadcast(receipt);
11803
+ return {
11804
+ kind: "not_charged",
11805
+ reason: "payment_required",
11806
+ status: status2,
11807
+ errorReason: receipt?.errorReason,
11808
+ depositSafe: refused,
11809
+ ...refused ? { refusedForMs: refusalCooldownMs(receipt?.errorReason, parseRetryAfter(getHeader("retry-after"))) } : {}
11810
+ };
11416
11811
  }
11417
11812
  const refusal = await batchRefusal(response);
11418
11813
  if (refusal) {
@@ -11443,7 +11838,7 @@ var SolanaBatchPayer = class {
11443
11838
  const http = (await this.build(wallet)).http;
11444
11839
  try {
11445
11840
  await this.prepareTopUp(wallet, paymentRequired);
11446
- return { http, payload: await http.createPaymentPayload(withoutClientSignedBatch(paymentRequired)) };
11841
+ return { http, payload: await http.createPaymentPayload(withPaymentIdentifier(withoutClientSignedBatch(paymentRequired))) };
11447
11842
  } catch (err) {
11448
11843
  if (pass > 0 || !isResyncRequired(err) || wallet.resyncs.size === 0) throw err;
11449
11844
  } finally {
@@ -11536,13 +11931,7 @@ var SolanaBatchPayer = class {
11536
11931
  console.error(
11537
11932
  `[@blockrun/llm] batch-settlement receipt not reconciled: ${JSON.stringify(err instanceof Error ? err.message : String(err))}`
11538
11933
  );
11539
- let settleResponse;
11540
- try {
11541
- settleResponse = http.getPaymentSettleResponse(getHeader);
11542
- } catch {
11543
- settleResponse = void 0;
11544
- }
11545
- return { settleResponse, ok: false };
11934
+ return { settleResponse: peekReceipt(http, getHeader), ok: false };
11546
11935
  }
11547
11936
  }
11548
11937
  /**
@@ -11676,6 +12065,39 @@ var DEFAULT_MAX_TOKENS2 = 1024;
11676
12065
  var DEFAULT_TIMEOUT14 = 6e4;
11677
12066
  var STALE_BLOCKHASH_RETRY_BACKOFFS_MS = [500, 2e3];
11678
12067
  var MAX_PAYMENT_FAILURE_BYTES = 64 * 1024;
12068
+ var DEFAULT_VIDEO_MODEL = "xai/grok-imagine-video";
12069
+ var DEFAULT_MUSIC_MODEL = "minimax/music-2.5+";
12070
+ var DEFAULT_VIDEO_BUDGET_MS = 9e5;
12071
+ var DEFAULT_MUSIC_BUDGET_MS = 3e5;
12072
+ var VIDEO_POLL_INTERVAL_MS = 5e3;
12073
+ var MAX_PAID_POLL_REFUSALS = 4;
12074
+ var MAX_RETRY_AFTER_MS2 = 12e4;
12075
+ var MEDIA_SUBMIT_TIMEOUT_MS = 12e4;
12076
+ function sleep3(ms) {
12077
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
12078
+ }
12079
+ async function readJsonOrEmpty(response) {
12080
+ try {
12081
+ const parsed = await response.json();
12082
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
12083
+ } catch {
12084
+ return {};
12085
+ }
12086
+ }
12087
+ function isPendingPollStatus(status2) {
12088
+ return status2 === 202 || status2 === 503 || status2 === 504;
12089
+ }
12090
+ function retryAfterMs(response) {
12091
+ const raw = response.headers.get("retry-after");
12092
+ if (!raw || !/^\d+$/.test(raw.trim())) return void 0;
12093
+ return Math.min(Number(raw.trim()) * 1e3, MAX_RETRY_AFTER_MS2);
12094
+ }
12095
+ function pollKindFor(url) {
12096
+ const path7 = new URL(url).pathname;
12097
+ if (path7.includes("/v1/videos")) return "video";
12098
+ if (path7.includes("/v1/audio/")) return "audio";
12099
+ return "image";
12100
+ }
11679
12101
  var SafeStaleBlockhashError = class extends PaymentError {
11680
12102
  constructor() {
11681
12103
  super("Payment verification used an expired Solana blockhash; retrying with a fresh quote.");
@@ -11892,9 +12314,9 @@ var SolanaLLMClient = class {
11892
12314
  };
11893
12315
  if (options?.temperature !== void 0) body.temperature = options.temperature;
11894
12316
  if (options?.topP !== void 0) body.top_p = options.topP;
11895
- if (options?.searchParameters !== void 0) body.search_parameters = options.searchParameters;
12317
+ if (options?.searchParameters !== void 0) body.search_parameters = toWireSearchParameters(options.searchParameters);
11896
12318
  else if (options?.search === true) body.search_parameters = { mode: "on" };
11897
- if (options?.tools !== void 0) body.tools = options.tools;
12319
+ if (options?.tools !== void 0) body.tools = toWireTools(options.tools);
11898
12320
  if (options?.toolChoice !== void 0) body.tool_choice = options.toolChoice;
11899
12321
  if (options?.responseFormat !== void 0) body.response_format = options.responseFormat;
11900
12322
  if (options?.stop !== void 0) body.stop = options.stop;
@@ -11905,7 +12327,7 @@ var SolanaLLMClient = class {
11905
12327
  for (let index = 0; index < chain.length; index += 1) {
11906
12328
  const candidate = chain[index];
11907
12329
  try {
11908
- return await this.requestWithPayment("/v1/chat/completions", buildBody(candidate));
12330
+ return await this.requestWithPayment("/v1/chat/completions", buildBody(candidate), options?.timeout);
11909
12331
  } catch (error) {
11910
12332
  lastError = error;
11911
12333
  if (!isTransientError(error) || index === chain.length - 1) throw error;
@@ -11955,6 +12377,8 @@ var SolanaLLMClient = class {
11955
12377
  {
11956
12378
  routingProfile: options?.routingProfile,
11957
12379
  requiresStructuredOutput: options?.responseFormat !== void 0,
12380
+ search: options?.search,
12381
+ searchParameters: options?.searchParameters,
11958
12382
  minimumPaymentUsd: this.apiAuth ? 0 : SOLANA_MINIMUM_PAYMENT_USD
11959
12383
  }
11960
12384
  );
@@ -11982,6 +12406,8 @@ var SolanaLLMClient = class {
11982
12406
  requiresStructuredOutput: options.responseFormat !== void 0,
11983
12407
  tools: options.tools,
11984
12408
  toolChoice: options.toolChoice,
12409
+ search: options.search,
12410
+ searchParameters: options.searchParameters,
11985
12411
  conversationChars,
11986
12412
  hasVision,
11987
12413
  minimumPaymentUsd: this.apiAuth ? 0 : SOLANA_MINIMUM_PAYMENT_USD
@@ -11997,6 +12423,7 @@ var SolanaLLMClient = class {
11997
12423
  toolChoice: options.toolChoice,
11998
12424
  responseFormat: options.responseFormat,
11999
12425
  stop: options.stop,
12426
+ timeout: options.timeout,
12000
12427
  // An explicit caller-supplied chain wins over the routed one.
12001
12428
  fallbackModels: options.fallbackModels ?? decision.fallbacks
12002
12429
  });
@@ -12134,7 +12561,7 @@ var SolanaLLMClient = class {
12134
12561
  *
12135
12562
  * @returns USDC balance as a float
12136
12563
  */
12137
- async getBalance() {
12564
+ async getBalance(options) {
12138
12565
  const usdc_mint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
12139
12566
  const address = await this.getWalletAddress();
12140
12567
  try {
@@ -12149,6 +12576,9 @@ var SolanaLLMClient = class {
12149
12576
  })
12150
12577
  });
12151
12578
  const data = await response.json();
12579
+ if (options?.strict && (!response.ok || data.error)) {
12580
+ throw new Error(`Solana RPC balance lookup failed: ${data.error?.message ?? `HTTP ${response.status}`}`);
12581
+ }
12152
12582
  const accounts = data.result?.value || [];
12153
12583
  if (!accounts.length) return 0;
12154
12584
  let total = 0;
@@ -12156,7 +12586,8 @@ var SolanaLLMClient = class {
12156
12586
  total += acct.account?.data?.parsed?.info?.tokenAmount?.uiAmount || 0;
12157
12587
  }
12158
12588
  return total;
12159
- } catch {
12589
+ } catch (error) {
12590
+ if (options?.strict) throw error;
12160
12591
  return 0;
12161
12592
  }
12162
12593
  }
@@ -12185,6 +12616,54 @@ var SolanaLLMClient = class {
12185
12616
  const data = await this.requestWithPaymentRaw("/v1/images/image2image", body);
12186
12617
  return data;
12187
12618
  }
12619
+ /**
12620
+ * Generate a short video on Solana.
12621
+ *
12622
+ * The POST is verified, not charged. The job is then polled with a wallet
12623
+ * signature (no payment) until it finishes, and ONE payment is signed for
12624
+ * the finished video; a failed job is never charged. If the gateway does not
12625
+ * accept poll signatures for the job, it is polled with a payment per poll
12626
+ * instead, which is likewise only charged on completion.
12627
+ *
12628
+ * @param prompt - text description of the video.
12629
+ * @param options - `model` (default `xai/grok-imagine-video`), `budgetMs`
12630
+ * (total wait, default 15 min); any other keys are sent verbatim, so use
12631
+ * the gateway's snake_case names (`duration_seconds`, `image_url`,
12632
+ * `aspect_ratio`, `resolution`, …).
12633
+ */
12634
+ async video(prompt, options = {}) {
12635
+ const { model, budgetMs, ...rest } = options;
12636
+ const body = { ...rest, model: model || DEFAULT_VIDEO_MODEL, prompt };
12637
+ const data = await this.requestWithPaymentRaw(
12638
+ "/v1/videos/generations",
12639
+ body,
12640
+ { pollBudgetMs: budgetMs ?? DEFAULT_VIDEO_BUDGET_MS, submitTimeoutMs: Math.max(this.timeout, MEDIA_SUBMIT_TIMEOUT_MS) }
12641
+ );
12642
+ return data;
12643
+ }
12644
+ /**
12645
+ * Generate a music track on Solana. Paid at the POST; a slow track is then
12646
+ * polled with a wallet signature, never another payment.
12647
+ *
12648
+ * @param prompt - music style, mood, or description.
12649
+ * @param options - `model` (default `minimax/music-2.5+`), `instrumental`
12650
+ * (default true), `lyrics` (requires `instrumental: false`), `budgetMs`
12651
+ * (total wait, default 5 min); other keys are sent verbatim.
12652
+ */
12653
+ async music(prompt, options = {}) {
12654
+ const { model, instrumental: instrumentalOption, lyrics: rawLyrics, budgetMs, ...rest } = options;
12655
+ const instrumental = instrumentalOption ?? true;
12656
+ const lyrics = rawLyrics?.trim();
12657
+ if (instrumental && lyrics) throw new Error("Cannot specify lyrics when instrumental is true");
12658
+ const body = { ...rest, model: model || DEFAULT_MUSIC_MODEL, prompt, instrumental };
12659
+ if (lyrics) body.lyrics = lyrics;
12660
+ const data = await this.requestWithPaymentRaw(
12661
+ "/v1/audio/generations",
12662
+ body,
12663
+ { pollBudgetMs: budgetMs ?? DEFAULT_MUSIC_BUDGET_MS, submitTimeoutMs: Math.max(this.timeout, MEDIA_SUBMIT_TIMEOUT_MS) }
12664
+ );
12665
+ return data;
12666
+ }
12188
12667
  /** Standalone search (Solana payment). */
12189
12668
  async search(query, options) {
12190
12669
  const body = { query, max_results: options?.maxResults || 10 };
@@ -12383,15 +12862,16 @@ var SolanaLLMClient = class {
12383
12862
  * `"paid-or-in-doubt"`, and so is any error with an API key once the billed
12384
12863
  * request was sent, except the account API's explicit 4xx refusal.
12385
12864
  */
12386
- async requestWithPayment(endpoint, body) {
12865
+ async requestWithPayment(endpoint, body, timeoutMs) {
12387
12866
  const url = `${this.apiUrl}${endpoint}`;
12867
+ const json = JSON.stringify(body);
12388
12868
  const closesAtStart = await this.batchPayer?.closeFence();
12389
12869
  for (let staleRetries = 0; ; ) {
12390
12870
  const response = await this.sendUnpaid(url, {
12391
12871
  method: "POST",
12392
12872
  headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT },
12393
- body: JSON.stringify(body)
12394
- });
12873
+ body: json
12874
+ }, timeoutMs);
12395
12875
  if (response.status === 402) {
12396
12876
  try {
12397
12877
  let paymentRequired;
@@ -12407,16 +12887,16 @@ var SolanaLLMClient = class {
12407
12887
  (paymentHeaders) => this.fetchWithTimeout(url, {
12408
12888
  method: "POST",
12409
12889
  headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT, ...paymentHeaders },
12410
- body: JSON.stringify(body)
12411
- }),
12890
+ body: json
12891
+ }, timeoutMs),
12412
12892
  // A fresh, unpaid challenge after a 429 wait: the request itself,
12413
12893
  // sent again without a payment. Every error it throws is unpaid.
12414
12894
  async () => {
12415
12895
  const challenge = await this.sendUnpaid(url, {
12416
12896
  method: "POST",
12417
12897
  headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT },
12418
- body: JSON.stringify(body)
12419
- });
12898
+ body: json
12899
+ }, timeoutMs);
12420
12900
  if (challenge.status === 402) {
12421
12901
  try {
12422
12902
  return { kind: "challenge", paymentRequired: await this.readPaymentRequired(challenge) };
@@ -12431,13 +12911,14 @@ var SolanaLLMClient = class {
12431
12911
  );
12432
12912
  if (batch.kind === "paid") {
12433
12913
  this.recordSettlement(batch.chargedUsd);
12434
- return await this.readPaidJson(batch.response);
12914
+ const paid = await this.readPaidJson(batch.response);
12915
+ return withCost(paid, batch.response, batch.chargedUsd, "batch");
12435
12916
  }
12436
- if (batch.kind === "served") return await this.readUnpaidJson(batch.response);
12917
+ if (batch.kind === "served") return this.withUnpaidCost(await this.readUnpaidJson(batch.response));
12437
12918
  if (batch.kind === "failed") throw batch.error;
12438
12919
  exactRequired = batch.paymentRequired ?? paymentRequired;
12439
12920
  }
12440
- return await this.handlePaymentAndRetry(url, body, exactRequired, staleRetries > 0);
12921
+ return await this.handlePaymentAndRetry(url, json, exactRequired, staleRetries > 0, timeoutMs);
12441
12922
  } catch (error) {
12442
12923
  if (!(error instanceof SafeStaleBlockhashError) || staleRetries >= STALE_BLOCKHASH_RETRY_BACKOFFS_MS.length) throw error;
12443
12924
  await waitForStaleRetry(staleRetries++);
@@ -12445,8 +12926,20 @@ var SolanaLLMClient = class {
12445
12926
  }
12446
12927
  }
12447
12928
  if (!response.ok) throw await unpaidApiError(response);
12448
- return await this.readUnpaidJson(response);
12929
+ return this.withUnpaidCost(await this.readUnpaidJson(response));
12930
+ }
12931
+ }
12932
+ /**
12933
+ * Cost of a chat response served without a payment: 0 with a wallet (a free
12934
+ * model). With an API key the call is billed to the account, so costUsd stays
12935
+ * undefined (see the dashboard).
12936
+ */
12937
+ withUnpaidCost(served) {
12938
+ if (!this.apiAuth) {
12939
+ served.costUsd = 0;
12940
+ served.settlement = { scheme: "free", quotedUsd: 0 };
12449
12941
  }
12942
+ return served;
12450
12943
  }
12451
12944
  /**
12452
12945
  * Read the JSON body of a 2xx served without a payment: the free tier, or
@@ -12464,9 +12957,9 @@ var SolanaLLMClient = class {
12464
12957
  * {@link RetryDisposition}: `"unpaid"` with a wallet; with an API key, see
12465
12958
  * {@link accountErrorDisposition} (the request itself is billed).
12466
12959
  */
12467
- async sendUnpaid(url, init) {
12960
+ async sendUnpaid(url, init, timeoutMs) {
12468
12961
  try {
12469
- return await this.fetchWithTimeout(url, init);
12962
+ return await this.fetchWithTimeout(url, init, timeoutMs);
12470
12963
  } catch (error) {
12471
12964
  throw withDisposition(error, this.apiAuth ? accountErrorDisposition(error) : "unpaid");
12472
12965
  }
@@ -12480,106 +12973,221 @@ var SolanaLLMClient = class {
12480
12973
  }
12481
12974
  }
12482
12975
  /**
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.
12976
+ * Follow a 202 `{ id, poll_url }` to completion on a Solana media route
12977
+ * (image, music, video).
12978
+ *
12979
+ * **Polls prove ownership, they do not pay.** A Solana payment is a
12980
+ * transaction pinned to a recent blockhash (~60s), so paying every poll of a
12981
+ * multi-minute job meant signing a fresh transaction every few seconds. Each
12982
+ * poll here instead carries the `x-poll-*` headers: the submitting wallet's
12983
+ * ed25519 signature over `blockrun-poll:v1:<kind>:<job id>:<unix seconds>`
12984
+ * (see `poll-ownership.ts`). That proof never charges.
12985
+ *
12986
+ * **Who pays when:**
12987
+ * - image / music: the POST already settled (sol settles at submit because the
12988
+ * signed transaction would be dead by completion). The caller recorded the
12989
+ * cost at POST; this loop only fetches the result and never signs a payment.
12990
+ * A batch-billed image job still answers its proof poll with 402: that job
12991
+ * falls back to the per-poll payment below.
12992
+ * - video: settles on completion, and a failed job is never charged. A
12993
+ * finished, unpaid job answers the proof poll with 402 `{status:"completed"}`
12994
+ * and a normal x402 challenge; ONE payment is signed from that challenge and
12995
+ * the same poll is re-sent with it. The cost is recorded here, on delivery.
12996
+ * A paid poll the gateway answers 402 (settlement in progress on another
12997
+ * poll, outcome unknown, stale blockhash) is re-polled after its
12998
+ * Retry-After — the gateway's per-job settlement claim makes a later fresh
12999
+ * payment safe — up to `MAX_PAID_POLL_REFUSALS` times.
13000
+ *
13001
+ * **Fallback, once per job:** if the gateway refuses the proof with its "no
13002
+ * such job" answer (video 400, image/music 404) or answers it with a plain
13003
+ * payment challenge (a gateway or job that predates poll proofs), the rest of
13004
+ * this job is polled the old way — an x-payment signed per poll, which the
13005
+ * gateway verifies against the job's payer and does not charge until a video
13006
+ * completes. Logged to stderr; never switches back.
12519
13007
  */
12520
- async followSolanaJob(submitBody, budgetMs, intervalMs = 2e3) {
13008
+ async followSolanaJob(submitBody, budgetMs, kind, intervalMs = kind === "video" ? VIDEO_POLL_INTERVAL_MS : 2e3) {
12521
13009
  const id = submitBody.id;
12522
13010
  const pollPath = submitBody.poll_url;
12523
13011
  if (!id || !pollPath) return submitBody;
12524
- const pollUrl = pollPath.startsWith("http") ? pollPath : `${this.apiUrl}${pollPath.startsWith("/") ? "" : "/"}${pollPath}`;
13012
+ const pollUrl = this.resolvePollUrl(pollPath);
13013
+ const jobId = pollJobIdFromUrl(pollUrl);
12525
13014
  const deadline = Date.now() + budgetMs;
12526
13015
  let lastStatus = submitBody.status || "queued";
13016
+ let ownershipPolls = true;
13017
+ let waitMs = intervalMs;
13018
+ let paidRefusalsLeft = MAX_PAID_POLL_REFUSALS;
13019
+ let videoCostUsd;
13020
+ let settleAttempted = false;
13021
+ const fallBack = (why) => {
13022
+ ownershipPolls = false;
13023
+ waitMs = 0;
13024
+ console.warn(
13025
+ `[@blockrun/llm] ${kind} job ${id}: ${why}; falling back to x-payment polling for this job`
13026
+ );
13027
+ };
13028
+ const deliver = (data, response) => {
13029
+ if (kind === "video") {
13030
+ if (videoCostUsd !== void 0) this.recordSettlement(videoCostUsd);
13031
+ const txHash = response.headers.get("x-payment-receipt");
13032
+ if (txHash) data.txHash = txHash;
13033
+ }
13034
+ return data;
13035
+ };
13036
+ const failIfFailed = (data, status2) => {
13037
+ if (lastStatus === "failed") {
13038
+ throw new APIError(
13039
+ `Upstream job failed: ${data.error || "unknown"}`,
13040
+ status2,
13041
+ sanitizeErrorResponse(data)
13042
+ );
13043
+ }
13044
+ };
12527
13045
  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;
13046
+ if (waitMs > 0) await sleep3(Math.min(waitMs, Math.max(0, deadline - Date.now())));
13047
+ waitMs = intervalMs;
13048
+ let challenge;
13049
+ if (ownershipPolls) {
13050
+ const proof = signPollOwnership(await solanaKeyToBytes(this.privateKey), kind, jobId);
13051
+ const polled = await this.fetchWithTimeout(pollUrl, {
13052
+ method: "GET",
13053
+ headers: { "User-Agent": USER_AGENT, ...proof }
13054
+ });
13055
+ const data2 = await readJsonOrEmpty(polled);
13056
+ if (polled.status === 402) {
13057
+ if (kind === "video" && data2.status === "completed") {
13058
+ lastStatus = "completed";
13059
+ settleAttempted = true;
13060
+ challenge = new Response(JSON.stringify(data2), { status: 402, headers: polled.headers });
13061
+ } else {
13062
+ fallBack(
13063
+ 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)"
13064
+ );
13065
+ continue;
13066
+ }
13067
+ } else if (polled.status === (kind === "video" ? 400 : 404)) {
13068
+ fallBack(`the gateway refused the poll proof (HTTP ${polled.status})`);
13069
+ continue;
13070
+ } else {
13071
+ lastStatus = data2.status || lastStatus;
13072
+ failIfFailed(data2, polled.status);
13073
+ if (polled.status === 200 && lastStatus === "completed") return deliver(data2, polled);
13074
+ if (!isPendingPollStatus(polled.status)) {
13075
+ throw new APIError(`Poll failed: ${polled.status}`, polled.status, sanitizeErrorResponse(data2));
13076
+ }
13077
+ waitMs = retryAfterMs(polled) ?? intervalMs;
13078
+ continue;
13079
+ }
12536
13080
  }
12537
- if (challenge.status !== 402) {
12538
- let errorBody;
12539
- try {
12540
- errorBody = await challenge.json();
12541
- } catch {
12542
- errorBody = { error: "Poll failed" };
13081
+ if (!challenge) {
13082
+ const unpaid = await this.fetchWithTimeout(pollUrl, {
13083
+ method: "GET",
13084
+ headers: { "User-Agent": USER_AGENT }
13085
+ });
13086
+ if (unpaid.status === 200) {
13087
+ return deliver(await unpaid.json(), unpaid);
13088
+ }
13089
+ if (unpaid.status !== 402) {
13090
+ let errorBody;
13091
+ try {
13092
+ errorBody = await unpaid.json();
13093
+ } catch {
13094
+ errorBody = { error: "Poll failed" };
13095
+ }
13096
+ throw new APIError(`Poll failed: ${unpaid.status}`, unpaid.status, sanitizeErrorResponse(errorBody));
12543
13097
  }
12544
- throw new APIError(`Poll failed: ${challenge.status}`, challenge.status, sanitizeErrorResponse(errorBody));
13098
+ challenge = unpaid;
12545
13099
  }
12546
- const { paymentPayload } = await this.signPaymentFrom402(
13100
+ const { paymentPayload, costUsd } = await this.signPaymentFrom402(
12547
13101
  pollUrl,
12548
13102
  challenge,
12549
13103
  true,
12550
13104
  // always a fresh blockhash; the last one is stale by now
12551
13105
  pollUrl
12552
13106
  );
12553
- const paid = await this.fetchWithTimeout(pollUrl, {
12554
- method: "GET",
12555
- headers: { "User-Agent": USER_AGENT, "PAYMENT-SIGNATURE": paymentPayload }
12556
- });
12557
- let data = {};
13107
+ if (kind === "video") videoCostUsd = costUsd;
13108
+ let paid;
12558
13109
  try {
12559
- data = await paid.json();
12560
- } catch {
13110
+ paid = await this.fetchWithTimeout(pollUrl, {
13111
+ method: "GET",
13112
+ headers: { "User-Agent": USER_AGENT, "PAYMENT-SIGNATURE": paymentPayload }
13113
+ });
13114
+ } catch (error) {
13115
+ if (kind === "video" && lastStatus === "completed") settleAttempted = true;
13116
+ if (--paidRefusalsLeft <= 0) {
13117
+ throw withDisposition(
13118
+ new PaymentError(
13119
+ `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.`
13120
+ ),
13121
+ "paid-or-in-doubt"
13122
+ );
13123
+ }
13124
+ continue;
12561
13125
  }
13126
+ const data = await readJsonOrEmpty(paid);
12562
13127
  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;
13128
+ failIfFailed(data, paid.status);
13129
+ if (paid.status === 200 && lastStatus === "completed") return deliver(data, paid);
13130
+ if (paid.status === 402 && kind === "video") {
13131
+ if (data.error === "Payment verification failed" && !await isSafeStaleBlockhashResponse(new Response(JSON.stringify(data), { status: 402 }))) {
13132
+ throw new PaymentError(
13133
+ `Payment for the finished video was rejected. Check your Solana USDC balance, then resume the job by polling ${pollUrl} with the same wallet.`
13134
+ );
13135
+ }
13136
+ settleAttempted = true;
13137
+ if (--paidRefusalsLeft <= 0) {
13138
+ throw withDisposition(
13139
+ new PaymentError(
13140
+ `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.`
13141
+ ),
13142
+ "paid-or-in-doubt"
13143
+ );
13144
+ }
13145
+ waitMs = retryAfterMs(paid) ?? intervalMs;
13146
+ continue;
12572
13147
  }
12573
- if (paid.status !== 200 && paid.status !== 202 && paid.status !== 504) {
13148
+ if (!isPendingPollStatus(paid.status) && paid.status !== 200) {
12574
13149
  throw new APIError(`Poll failed: ${paid.status}`, paid.status, sanitizeErrorResponse(data));
12575
13150
  }
13151
+ waitMs = retryAfterMs(paid) ?? intervalMs;
12576
13152
  }
13153
+ 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
13154
  throw new APIError(
12578
- `Job did not complete within ${Math.round(budgetMs / 1e3)}s (last status: ${lastStatus}). No payment was taken.`,
13155
+ `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
13156
  504,
12580
- { id, last_status: lastStatus }
13157
+ { id, last_status: lastStatus, poll_url: pollUrl }
12581
13158
  );
12582
13159
  }
13160
+ /**
13161
+ * Absolute URL for a job's `poll_url`. The gateway returns `/api/v1/...`;
13162
+ * {@link buildUrl} drops the `/api` that `apiUrl` already ends with. An
13163
+ * absolute poll_url must be on the gateway's own origin: the poll carries a
13164
+ * wallet signature (and, for a finished video, a payment).
13165
+ */
13166
+ resolvePollUrl(pollPath) {
13167
+ if (!/^https?:\/\//i.test(pollPath)) return this.buildUrl(pollPath);
13168
+ if (new URL(pollPath).origin !== new URL(this.apiUrl).origin) {
13169
+ throw new APIError(`poll_url is not on the gateway origin: ${pollPath}`, 502, { poll_url: pollPath });
13170
+ }
13171
+ return pollPath;
13172
+ }
13173
+ /**
13174
+ * Turn a `402` into a signed Solana payment payload.
13175
+ *
13176
+ * Extracted because four call sites need it — chat, the raw POST helpers, the
13177
+ * raw GET helper, and {@link SolanaLLMClient.stream} — and it had been
13178
+ * written out three times before this. That mattered more than ordinary
13179
+ * duplication: this is the code that signs a transfer of the caller's USDC,
13180
+ * so three copies meant every fix to it had to be applied three times or
13181
+ * quietly apply to two thirds of the paths.
13182
+ *
13183
+ * @param url - the request being paid for.
13184
+ * @param response - the gateway's `402`, not yet consumed.
13185
+ * @param forceFreshBlockhash - set on a re-sign after a stale-blockhash
13186
+ * rejection, so the retry cannot produce byte-identical transaction bytes.
13187
+ * @param resourceFallback - resource URL to claim when the 402 states none.
13188
+ * @returns the header value to replay with, and what it will settle for.
13189
+ * @throws PaymentError when the 402 carries no usable Solana requirements.
13190
+ */
12583
13191
  async signPaymentFrom402(url, response, forceFreshBlockhash, resourceFallback = url) {
12584
13192
  return this.signExactPayment(
12585
13193
  url,
@@ -12620,7 +13228,7 @@ var SolanaLLMClient = class {
12620
13228
  if (!feePayer) throw new PaymentError("Missing feePayer in 402 extra field");
12621
13229
  const fromAddress = await this.getWalletAddress();
12622
13230
  const secretKey = await solanaKeyToBytes(this.privateKey);
12623
- const extensions = paymentRequired.extensions;
13231
+ const extensions = withPaymentIdentifier(paymentRequired).extensions;
12624
13232
  const paymentPayload = await createSolanaPaymentPayload(
12625
13233
  secretKey,
12626
13234
  fromAddress,
@@ -12643,7 +13251,7 @@ var SolanaLLMClient = class {
12643
13251
  );
12644
13252
  return { paymentPayload, costUsd: parseFloat(details.amount) / 1e6 };
12645
13253
  }
12646
- async handlePaymentAndRetry(url, body, paymentRequired, forceFreshBlockhash = false) {
13254
+ async handlePaymentAndRetry(url, json, paymentRequired, forceFreshBlockhash = false, timeoutMs) {
12647
13255
  let signed;
12648
13256
  try {
12649
13257
  signed = await this.signExactPayment(url, paymentRequired, forceFreshBlockhash, `${this.apiUrl}/v1/chat/completions`);
@@ -12660,26 +13268,28 @@ var SolanaLLMClient = class {
12660
13268
  "User-Agent": USER_AGENT,
12661
13269
  "PAYMENT-SIGNATURE": paymentPayload
12662
13270
  },
12663
- body: JSON.stringify(body)
12664
- });
13271
+ body: json
13272
+ }, timeoutMs);
12665
13273
  } catch (error) {
12666
13274
  throw withDisposition(error, "paid-or-in-doubt");
12667
13275
  }
12668
13276
  await this.assertPaid(retryResponse);
12669
13277
  this.recordSettlement(costUsd);
12670
- return this.readPaidJson(retryResponse);
13278
+ const paid = await this.readPaidJson(retryResponse);
13279
+ return withCost(paid, retryResponse, costUsd, "exact");
12671
13280
  }
12672
- async requestWithPaymentRaw(endpoint, body) {
13281
+ async requestWithPaymentRaw(endpoint, body, media = {}) {
12673
13282
  const url = `${this.apiUrl}${endpoint}`;
13283
+ const pollBudgetMs = media.pollBudgetMs ?? this.timeout;
12674
13284
  for (let staleRetries = 0; ; ) {
12675
13285
  const response = await this.fetchWithTimeout(url, {
12676
13286
  method: "POST",
12677
13287
  headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT },
12678
13288
  body: JSON.stringify(body)
12679
- });
13289
+ }, media.submitTimeoutMs);
12680
13290
  if (response.status === 402) {
12681
13291
  try {
12682
- return await this.handlePaymentAndRetryRaw(url, body, response, staleRetries > 0);
13292
+ return await this.handlePaymentAndRetryRaw(url, body, response, staleRetries > 0, media);
12683
13293
  } catch (error) {
12684
13294
  if (!(error instanceof SafeStaleBlockhashError) || staleRetries >= STALE_BLOCKHASH_RETRY_BACKOFFS_MS.length) throw error;
12685
13295
  await waitForStaleRetry(staleRetries++);
@@ -12695,26 +13305,38 @@ var SolanaLLMClient = class {
12695
13305
  }
12696
13306
  throw new APIError(`API error: ${response.status}`, response.status, sanitizeErrorResponse(errorBody));
12697
13307
  }
12698
- if (this.apiAuth && endpoint.startsWith("/v1/images/")) return this.apiAuth.poll(response, this.timeout, 2e3);
13308
+ if (this.apiAuth && /^\/v1\/(images|videos|audio)\//.test(endpoint)) {
13309
+ return this.apiAuth.poll(
13310
+ response,
13311
+ pollBudgetMs,
13312
+ endpoint.startsWith("/v1/videos/") ? VIDEO_POLL_INTERVAL_MS : 2e3
13313
+ );
13314
+ }
12699
13315
  return response.json();
12700
13316
  }
12701
13317
  }
12702
- async handlePaymentAndRetryRaw(url, body, response, forceFreshBlockhash = false) {
13318
+ async handlePaymentAndRetryRaw(url, body, response, forceFreshBlockhash = false, media = {}) {
12703
13319
  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
- });
13320
+ let retryResponse;
13321
+ try {
13322
+ retryResponse = await this.fetchWithTimeout(url, {
13323
+ method: "POST",
13324
+ headers: {
13325
+ "Content-Type": "application/json",
13326
+ "User-Agent": USER_AGENT,
13327
+ "PAYMENT-SIGNATURE": paymentPayload
13328
+ },
13329
+ body: JSON.stringify(body)
13330
+ }, media.submitTimeoutMs);
13331
+ } catch (error) {
13332
+ throw withDisposition(error, "paid-or-in-doubt");
13333
+ }
12713
13334
  await this.assertPaid(retryResponse);
12714
13335
  if (retryResponse.status === 202) {
12715
- this.recordSettlement(costUsd);
13336
+ const kind = pollKindFor(url);
13337
+ if (kind !== "video") this.recordSettlement(costUsd);
12716
13338
  const submitted = await retryResponse.json();
12717
- return this.followSolanaJob(submitted, this.timeout);
13339
+ return this.followSolanaJob(submitted, media.pollBudgetMs ?? this.timeout, kind);
12718
13340
  }
12719
13341
  this.recordSettlement(costUsd);
12720
13342
  return retryResponse.json();
@@ -12803,9 +13425,9 @@ var SolanaLLMClient = class {
12803
13425
  this.sessionCalls += 1;
12804
13426
  this.sessionTotalUsd += costUsd;
12805
13427
  }
12806
- async fetchWithTimeout(url, options) {
13428
+ async fetchWithTimeout(url, options, timeoutMs = this.timeout) {
12807
13429
  const controller = new AbortController();
12808
- const timeoutId = setTimeout(() => controller.abort(), this.timeout);
13430
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
12809
13431
  try {
12810
13432
  return await (this.apiAuth ? this.apiAuth.fetch.bind(this.apiAuth) : fetch)(url, { ...options, signal: controller.signal });
12811
13433
  } finally {
@@ -13369,6 +13991,7 @@ export {
13369
13991
  BatchPaymentUnresolvedError,
13370
13992
  BlockrunClient,
13371
13993
  BlockrunError,
13994
+ DEFAULT_BATCH_IN_DOUBT,
13372
13995
  DEFAULT_BATCH_RATE_LIMIT,
13373
13996
  EVM_NETWORKS,
13374
13997
  ImageClient,
@@ -13432,6 +14055,7 @@ export {
13432
14055
  loadWallet,
13433
14056
  logCost,
13434
14057
  parsePaymentRequired,
14058
+ readSettlement,
13435
14059
  retryDisposition,
13436
14060
  saveSolanaWallet,
13437
14061
  saveToCache,
@@ -13446,6 +14070,8 @@ export {
13446
14070
  solanaKeyToBytes,
13447
14071
  solanaPublicKey,
13448
14072
  status,
14073
+ toWireSearchParameters,
14074
+ toWireTools,
13449
14075
  validateMaxTokens,
13450
14076
  validateModel,
13451
14077
  validateTemperature,