@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.cjs CHANGED
@@ -40,6 +40,7 @@ __export(index_exports, {
40
40
  BatchPaymentUnresolvedError: () => BatchPaymentUnresolvedError,
41
41
  BlockrunClient: () => BlockrunClient,
42
42
  BlockrunError: () => BlockrunError,
43
+ DEFAULT_BATCH_IN_DOUBT: () => DEFAULT_BATCH_IN_DOUBT,
43
44
  DEFAULT_BATCH_RATE_LIMIT: () => DEFAULT_BATCH_RATE_LIMIT,
44
45
  EVM_NETWORKS: () => EVM_NETWORKS,
45
46
  ImageClient: () => ImageClient,
@@ -103,6 +104,7 @@ __export(index_exports, {
103
104
  loadWallet: () => loadWallet,
104
105
  logCost: () => logCost,
105
106
  parsePaymentRequired: () => parsePaymentRequired,
107
+ readSettlement: () => readSettlement,
106
108
  retryDisposition: () => retryDisposition,
107
109
  saveSolanaWallet: () => saveSolanaWallet,
108
110
  saveToCache: () => saveToCache,
@@ -117,6 +119,8 @@ __export(index_exports, {
117
119
  solanaKeyToBytes: () => solanaKeyToBytes,
118
120
  solanaPublicKey: () => solanaPublicKey,
119
121
  status: () => status,
122
+ toWireSearchParameters: () => toWireSearchParameters,
123
+ toWireTools: () => toWireTools,
120
124
  validateMaxTokens: () => validateMaxTokens,
121
125
  validateModel: () => validateModel,
122
126
  validateTemperature: () => validateTemperature,
@@ -439,6 +443,122 @@ var ApiKeyAuth = class {
439
443
  }
440
444
  };
441
445
 
446
+ // src/search-wire.ts
447
+ function put(out, key, value) {
448
+ if (value !== void 0) out[key] = value;
449
+ }
450
+ function handles(list) {
451
+ return list?.map((h) => h.trim().replace(/^@+/, ""));
452
+ }
453
+ function sourceToWire(source) {
454
+ const raw = source;
455
+ const out = { ...raw };
456
+ for (const key of Object.keys(out)) if (/[A-Z]/.test(key)) delete out[key];
457
+ switch (source.type) {
458
+ case "x":
459
+ put(out, "included_x_handles", handles(source.includedXHandles) ?? out.included_x_handles);
460
+ put(out, "excluded_x_handles", handles(source.excludedXHandles) ?? out.excluded_x_handles);
461
+ put(out, "post_favorite_count", source.postFavoriteCount);
462
+ put(out, "post_view_count", source.postViewCount);
463
+ put(out, "enable_image_understanding", source.enableImageUnderstanding);
464
+ put(out, "enable_video_understanding", source.enableVideoUnderstanding);
465
+ break;
466
+ case "web":
467
+ put(out, "country", source.country);
468
+ put(out, "allowed_websites", source.allowedWebsites);
469
+ put(out, "excluded_websites", source.excludedWebsites);
470
+ put(out, "safe_search", source.safeSearch);
471
+ put(out, "enable_image_understanding", source.enableImageUnderstanding);
472
+ break;
473
+ case "news":
474
+ put(out, "country", source.country);
475
+ put(out, "allowed_websites", source.allowedWebsites);
476
+ put(out, "excluded_websites", source.excludedWebsites);
477
+ put(out, "safe_search", source.safeSearch);
478
+ break;
479
+ case "rss":
480
+ put(out, "links", source.links);
481
+ break;
482
+ }
483
+ return out;
484
+ }
485
+ function toWireSearchParameters(sp) {
486
+ const raw = sp;
487
+ const out = {};
488
+ for (const [key, value] of Object.entries(raw)) if (!/[A-Z]/.test(key) && key !== "sources") out[key] = value;
489
+ put(out, "mode", sp.mode);
490
+ if (sp.sources) out.sources = sp.sources.map(sourceToWire);
491
+ put(out, "return_citations", sp.returnCitations);
492
+ put(out, "from_date", sp.fromDate);
493
+ put(out, "to_date", sp.toDate);
494
+ put(out, "max_search_results", sp.maxSearchResults);
495
+ put(out, "max_turns", sp.maxTurns);
496
+ return out;
497
+ }
498
+ function toWireTools(tools) {
499
+ return tools.map((tool) => {
500
+ if (tool.type === "x_search") {
501
+ const out = { type: "x_search" };
502
+ put(out, "allowed_x_handles", handles(tool.allowedXHandles));
503
+ put(out, "excluded_x_handles", handles(tool.excludedXHandles));
504
+ put(out, "from_date", tool.fromDate);
505
+ put(out, "to_date", tool.toDate);
506
+ put(out, "enable_image_understanding", tool.enableImageUnderstanding);
507
+ put(out, "enable_video_understanding", tool.enableVideoUnderstanding);
508
+ return out;
509
+ }
510
+ if (tool.type === "web_search") {
511
+ const out = { type: "web_search" };
512
+ put(out, "allowed_domains", tool.allowedDomains);
513
+ put(out, "excluded_domains", tool.excludedDomains);
514
+ put(out, "enable_image_understanding", tool.enableImageUnderstanding);
515
+ return out;
516
+ }
517
+ return tool;
518
+ });
519
+ }
520
+
521
+ // src/receipt.ts
522
+ function atomicToUsd(amount) {
523
+ return typeof amount === "string" && /^\d+$/.test(amount) ? Number(amount) / 1e6 : void 0;
524
+ }
525
+ function receiptChargedUsd(receipt) {
526
+ return atomicToUsd(receipt?.extra?.chargedAmount) ?? atomicToUsd(receipt?.amount);
527
+ }
528
+ function decode(header) {
529
+ try {
530
+ const json = typeof Buffer !== "undefined" ? Buffer.from(header, "base64").toString("utf8") : atob(header);
531
+ const parsed = JSON.parse(json);
532
+ return parsed && typeof parsed === "object" ? parsed : void 0;
533
+ } catch {
534
+ return void 0;
535
+ }
536
+ }
537
+ function readSettlement(response, quotedUsd, scheme) {
538
+ const header = response.headers.get("PAYMENT-RESPONSE") ?? response.headers.get("X-PAYMENT-RESPONSE");
539
+ const receipt = header ? decode(header) : void 0;
540
+ const extra = receipt?.extra;
541
+ const costUsd = receiptChargedUsd({
542
+ amount: receipt?.amount,
543
+ extra: extra && typeof extra === "object" ? extra : void 0
544
+ }) ?? quotedUsd;
545
+ const receiptTx = typeof receipt?.transaction === "string" && receipt.transaction ? receipt.transaction : void 0;
546
+ const transaction = receiptTx ?? (response.headers.get("X-Payment-Receipt")?.trim() || void 0);
547
+ const settlement = {
548
+ scheme,
549
+ quotedUsd,
550
+ ...transaction ? { transaction } : {},
551
+ ...typeof receipt?.network === "string" ? { network: receipt.network } : {},
552
+ ...receipt?.success === false ? { success: false } : {}
553
+ };
554
+ return { costUsd, settlement };
555
+ }
556
+ function withCost(body, response, bookedUsd, scheme) {
557
+ body.costUsd = bookedUsd;
558
+ body.settlement = readSettlement(response, bookedUsd, scheme).settlement;
559
+ return body;
560
+ }
561
+
442
562
  // src/client.ts
443
563
  var import_accounts2 = require("viem/accounts");
444
564
 
@@ -4225,6 +4345,11 @@ function errSummary(err) {
4225
4345
  }
4226
4346
  return String(err).slice(0, 100);
4227
4347
  }
4348
+ function requestsSearch(options) {
4349
+ if (options.tools?.some((tool) => tool.type === "x_search" || tool.type === "web_search")) return true;
4350
+ if (options.searchParameters !== void 0) return options.searchParameters.mode !== "off";
4351
+ return options.search === true;
4352
+ }
4228
4353
  function routingProfileForModel(model) {
4229
4354
  return AUTO_ROUTING_PROFILES[model.toLowerCase()];
4230
4355
  }
@@ -4252,7 +4377,7 @@ function routingText(messages) {
4252
4377
  };
4253
4378
  }
4254
4379
  function routeWithCatalog(prompt, systemPrompt, maxOutputTokens, modelPricing, options = {}) {
4255
- const tools = options.tools ?? [];
4380
+ const tools = (options.tools ?? []).filter((tool) => tool.type === "function");
4256
4381
  const requiresTools = options.toolChoice === "none" ? false : options.toolChoice === "required" || typeof options.toolChoice === "object" ? true : void 0;
4257
4382
  const decision = route(prompt, systemPrompt, maxOutputTokens, {
4258
4383
  config: DEFAULT_ROUTING_CONFIG,
@@ -4272,6 +4397,15 @@ function routeWithCatalog(prompt, systemPrompt, maxOutputTokens, modelPricing, o
4272
4397
  const resolved = !id.startsWith("free/") ? id : modelPricing.has(`nvidia/${id.slice(5)}`) ? `nvidia/${id.slice(5)}` : null;
4273
4398
  if (resolved && !callable.includes(resolved)) callable.push(resolved);
4274
4399
  }
4400
+ if (requestsSearch(options)) {
4401
+ const searchable = callable.filter((id) => id.startsWith("xai/"));
4402
+ if (searchable.length === 0) {
4403
+ throw new Error(
4404
+ `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.`
4405
+ );
4406
+ }
4407
+ callable.splice(0, callable.length, ...searchable);
4408
+ }
4275
4409
  const estimatedInputTokens = Math.ceil(
4276
4410
  Math.max(options.conversationChars ?? 0, `${systemPrompt ?? ""} ${prompt}`.length) / 4
4277
4411
  );
@@ -4690,7 +4824,7 @@ function getCostSummary() {
4690
4824
  }
4691
4825
 
4692
4826
  // src/version.ts
4693
- var SDK_VERSION = "3.20.1";
4827
+ var SDK_VERSION = "3.22.0";
4694
4828
  var USER_AGENT = `blockrun-ts/${SDK_VERSION}`;
4695
4829
 
4696
4830
  // src/client.ts
@@ -4836,7 +4970,9 @@ var LLMClient = class _LLMClient {
4836
4970
  options?.maxOutputTokens ?? options?.maxTokens ?? DEFAULT_MAX_TOKENS,
4837
4971
  {
4838
4972
  routingProfile: options?.routingProfile,
4839
- requiresStructuredOutput: options?.responseFormat !== void 0
4973
+ requiresStructuredOutput: options?.responseFormat !== void 0,
4974
+ search: options?.search,
4975
+ searchParameters: options?.searchParameters
4840
4976
  }
4841
4977
  );
4842
4978
  }
@@ -4903,6 +5039,8 @@ var LLMClient = class _LLMClient {
4903
5039
  requiresStructuredOutput: options.responseFormat !== void 0,
4904
5040
  tools: options.tools,
4905
5041
  toolChoice: options.toolChoice,
5042
+ search: options.search,
5043
+ searchParameters: options.searchParameters,
4906
5044
  conversationChars,
4907
5045
  hasVision
4908
5046
  }
@@ -4917,6 +5055,7 @@ var LLMClient = class _LLMClient {
4917
5055
  toolChoice: options.toolChoice,
4918
5056
  responseFormat: options.responseFormat,
4919
5057
  stop: options.stop,
5058
+ timeout: options.timeout,
4920
5059
  // An explicit caller-supplied chain wins over the routed one.
4921
5060
  fallbackModels: options.fallbackModels ?? decision.fallbacks
4922
5061
  });
@@ -4993,11 +5132,11 @@ var LLMClient = class _LLMClient {
4993
5132
  if (options?.temperature !== void 0) body.temperature = options.temperature;
4994
5133
  if (options?.topP !== void 0) body.top_p = options.topP;
4995
5134
  if (options?.searchParameters !== void 0) {
4996
- body.search_parameters = options.searchParameters;
5135
+ body.search_parameters = toWireSearchParameters(options.searchParameters);
4997
5136
  } else if (options?.search === true) {
4998
5137
  body.search_parameters = { mode: "on" };
4999
5138
  }
5000
- if (options?.tools !== void 0) body.tools = options.tools;
5139
+ if (options?.tools !== void 0) body.tools = toWireTools(options.tools);
5001
5140
  if (options?.toolChoice !== void 0) body.tool_choice = options.toolChoice;
5002
5141
  if (options?.responseFormat !== void 0) body.response_format = options.responseFormat;
5003
5142
  if (options?.stop !== void 0) body.stop = options.stop;
@@ -5008,7 +5147,7 @@ var LLMClient = class _LLMClient {
5008
5147
  for (let i = 0; i < chain.length; i++) {
5009
5148
  const candidate = chain[i];
5010
5149
  try {
5011
- return await this.requestWithPayment("/v1/chat/completions", buildBody(candidate));
5150
+ return await this.requestWithPayment("/v1/chat/completions", buildBody(candidate), options?.timeout);
5012
5151
  } catch (err) {
5013
5152
  lastErr = err;
5014
5153
  const next = chain[i + 1];
@@ -5079,19 +5218,19 @@ var LLMClient = class _LLMClient {
5079
5218
  * is `"paid-or-in-doubt"`. With an API key the request itself is billed, so
5080
5219
  * only the account API's explicit 4xx refusal is `"unpaid"`.
5081
5220
  */
5082
- async requestWithPayment(endpoint, body) {
5221
+ async requestWithPayment(endpoint, body, timeoutMs) {
5083
5222
  const url = `${this.apiUrl}${endpoint}`;
5084
5223
  const unpaid = {
5085
5224
  method: "POST",
5086
5225
  headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT },
5087
5226
  body: JSON.stringify(body)
5088
5227
  };
5089
- const response = await this.sendUnpaid(url, unpaid);
5228
+ const response = await this.sendUnpaid(url, unpaid, timeoutMs);
5090
5229
  if (response.status === 502 || response.status === 503) {
5091
5230
  await new Promise((r) => setTimeout(r, 1e3));
5092
- const retryResp = await this.sendUnpaid(url, unpaid);
5231
+ const retryResp = await this.sendUnpaid(url, unpaid, timeoutMs);
5093
5232
  if (retryResp.status !== 502 && retryResp.status !== 503) {
5094
- if (retryResp.status === 402) return this.handlePaymentAndRetry(url, body, retryResp);
5233
+ if (retryResp.status === 402) return this.handlePaymentAndRetry(url, body, retryResp, timeoutMs);
5095
5234
  if (!retryResp.ok) {
5096
5235
  let errorBody;
5097
5236
  try {
@@ -5108,7 +5247,7 @@ var LLMClient = class _LLMClient {
5108
5247
  }
5109
5248
  }
5110
5249
  if (response.status === 402) {
5111
- return this.handlePaymentAndRetry(url, body, response);
5250
+ return this.handlePaymentAndRetry(url, body, response, timeoutMs);
5112
5251
  }
5113
5252
  if (!response.ok) {
5114
5253
  let errorBody;
@@ -5133,9 +5272,9 @@ var LLMClient = class _LLMClient {
5133
5272
  * {@link RetryDisposition}: `"unpaid"` with a wallet; with an API key, see
5134
5273
  * {@link accountErrorDisposition} (the request itself is billed).
5135
5274
  */
5136
- async sendUnpaid(url, init) {
5275
+ async sendUnpaid(url, init, timeoutMs) {
5137
5276
  try {
5138
- return await this.fetchWithTimeout(url, init);
5277
+ return await this.fetchWithTimeout(url, init, timeoutMs);
5139
5278
  } catch (err) {
5140
5279
  throw withDisposition(err, this.apiAuth ? accountErrorDisposition(err) : "unpaid");
5141
5280
  }
@@ -5143,13 +5282,22 @@ var LLMClient = class _LLMClient {
5143
5282
  /**
5144
5283
  * Parse a response to a request that carried no payment: the free tier,
5145
5284
  * or, with an API key, the billed request itself.
5285
+ *
5286
+ * With a wallet nothing was paid, so `costUsd` is 0 (a free model). With an
5287
+ * API key the call is billed to the account and `costUsd` stays undefined.
5146
5288
  */
5147
5289
  async parseUnpaidChatResponse(response) {
5290
+ let served;
5148
5291
  try {
5149
- return await this.parseChatResponse(response);
5292
+ served = await this.parseChatResponse(response);
5150
5293
  } catch (err) {
5151
5294
  throw withDisposition(err, this.apiAuth ? "paid-or-in-doubt" : "unpaid");
5152
5295
  }
5296
+ if (!this.apiAuth) {
5297
+ served.costUsd = 0;
5298
+ served.settlement = { scheme: "free", quotedUsd: 0 };
5299
+ }
5300
+ return served;
5153
5301
  }
5154
5302
  /**
5155
5303
  * Handle 402 response: parse requirements, sign payment, retry.
@@ -5157,7 +5305,7 @@ var LLMClient = class _LLMClient {
5157
5305
  * Anything thrown before the signed payment is sent is `"unpaid"`; anything
5158
5306
  * after it, `"paid-or-in-doubt"` (see {@link RetryDisposition}).
5159
5307
  */
5160
- async handlePaymentAndRetry(url, body, response) {
5308
+ async handlePaymentAndRetry(url, body, response, timeoutMs) {
5161
5309
  let signed;
5162
5310
  try {
5163
5311
  signed = await this.signFrom402(url, response);
@@ -5165,7 +5313,7 @@ var LLMClient = class _LLMClient {
5165
5313
  throw withDisposition(err, "unpaid");
5166
5314
  }
5167
5315
  try {
5168
- return await this.sendPaid(url, body, signed.paymentPayload, signed.details);
5316
+ return await this.sendPaid(url, body, signed.paymentPayload, signed.details, timeoutMs);
5169
5317
  } catch (err) {
5170
5318
  throw withDisposition(err, "paid-or-in-doubt");
5171
5319
  }
@@ -5210,7 +5358,7 @@ var LLMClient = class _LLMClient {
5210
5358
  return { paymentPayload, details };
5211
5359
  }
5212
5360
  /** Send the signed payment and read the answer. The payment may settle from here on. */
5213
- async sendPaid(url, body, paymentPayload, details) {
5361
+ async sendPaid(url, body, paymentPayload, details, timeoutMs) {
5214
5362
  const retryResponse = await this.fetchWithTimeout(url, {
5215
5363
  method: "POST",
5216
5364
  headers: {
@@ -5219,7 +5367,7 @@ var LLMClient = class _LLMClient {
5219
5367
  "PAYMENT-SIGNATURE": paymentPayload
5220
5368
  },
5221
5369
  body: JSON.stringify(body)
5222
- });
5370
+ }, timeoutMs);
5223
5371
  if (retryResponse.status === 502 || retryResponse.status === 503) {
5224
5372
  await new Promise((r) => setTimeout(r, 1e3));
5225
5373
  const retryResp2 = await this.fetchWithTimeout(url, {
@@ -5230,7 +5378,7 @@ var LLMClient = class _LLMClient {
5230
5378
  "PAYMENT-SIGNATURE": paymentPayload
5231
5379
  },
5232
5380
  body: JSON.stringify(body)
5233
- });
5381
+ }, timeoutMs);
5234
5382
  if (retryResp2.status !== 502 && retryResp2.status !== 503) {
5235
5383
  if (retryResp2.status === 402) throw new PaymentError("Payment was rejected. Check your wallet balance.");
5236
5384
  if (!retryResp2.ok) {
@@ -5246,7 +5394,7 @@ var LLMClient = class _LLMClient {
5246
5394
  this.sessionCalls += 1;
5247
5395
  this.sessionTotalUsd += costUsd2;
5248
5396
  this.recordCost(url, costUsd2, { body, network: details.network });
5249
- return this.parseChatResponse(retryResp2);
5397
+ return withCost(await this.parseChatResponse(retryResp2), retryResp2, costUsd2, "exact");
5250
5398
  }
5251
5399
  }
5252
5400
  if (retryResponse.status === 402) {
@@ -5269,7 +5417,7 @@ var LLMClient = class _LLMClient {
5269
5417
  this.sessionCalls += 1;
5270
5418
  this.sessionTotalUsd += costUsd;
5271
5419
  this.recordCost(url, costUsd, { body, network: details.network });
5272
- return this.parseChatResponse(retryResponse);
5420
+ return withCost(await this.parseChatResponse(retryResponse), retryResponse, costUsd, "exact");
5273
5421
  }
5274
5422
  /**
5275
5423
  * Sign a payment header and return the PAYMENT-SIGNATURE value.
@@ -5339,7 +5487,7 @@ var LLMClient = class _LLMClient {
5339
5487
  };
5340
5488
  if (options?.temperature !== void 0) body.temperature = options.temperature;
5341
5489
  if (options?.topP !== void 0) body.top_p = options.topP;
5342
- if (options?.tools !== void 0) body.tools = options.tools;
5490
+ if (options?.tools !== void 0) body.tools = toWireTools(options.tools);
5343
5491
  if (options?.toolChoice !== void 0) body.tool_choice = options.toolChoice;
5344
5492
  if (options?.responseFormat !== void 0) body.response_format = options.responseFormat;
5345
5493
  if (options?.stop !== void 0) body.stop = options.stop;
@@ -5737,11 +5885,11 @@ var LLMClient = class _LLMClient {
5737
5885
  return retryResponse.json();
5738
5886
  }
5739
5887
  /**
5740
- * Fetch with timeout.
5888
+ * Fetch with timeout: `timeoutMs` (a per-call override), else the client's `timeout`.
5741
5889
  */
5742
- async fetchWithTimeout(url, options) {
5890
+ async fetchWithTimeout(url, options, timeoutMs = this.timeout) {
5743
5891
  const controller = new AbortController();
5744
- const timeoutId = setTimeout(() => controller.abort(), this.timeout);
5892
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
5745
5893
  try {
5746
5894
  const response = await (this.apiAuth ? this.apiAuth.fetch.bind(this.apiAuth) : fetch)(url, {
5747
5895
  ...options,
@@ -9455,11 +9603,11 @@ var WALLET_DIR = path4.join(os3.homedir(), ".blockrun");
9455
9603
  var SOLANA_WALLET_FILE = path4.join(WALLET_DIR, ".solana-session");
9456
9604
  async function createSolanaWallet() {
9457
9605
  const { Keypair } = await loadSolanaWeb3("Solana wallet operations");
9458
- const bs582 = await import("bs58");
9606
+ const bs583 = await import("bs58");
9459
9607
  const keypair = Keypair.generate();
9460
9608
  return {
9461
9609
  address: keypair.publicKey.toBase58(),
9462
- privateKey: (bs582.default ?? bs582).encode(keypair.secretKey)
9610
+ privateKey: (bs583.default ?? bs583).encode(keypair.secretKey)
9463
9611
  };
9464
9612
  }
9465
9613
  async function solanaKeyToBytes(privateKey) {
@@ -9497,8 +9645,8 @@ async function solanaKeyToBytes(privateKey) {
9497
9645
  return bytes;
9498
9646
  }
9499
9647
  try {
9500
- const bs582 = await import("bs58");
9501
- const bytes = (bs582.default ?? bs582).decode(key);
9648
+ const bs583 = await import("bs58");
9649
+ const bytes = (bs583.default ?? bs583).decode(key);
9502
9650
  if (bytes.length !== 64) {
9503
9651
  throw new Error(`Invalid Solana key length: expected 64 bytes, got ${bytes.length}`);
9504
9652
  }
@@ -9677,15 +9825,56 @@ async function getOrCreateSolanaWallet() {
9677
9825
  return { address, privateKey, isNew: true };
9678
9826
  }
9679
9827
 
9828
+ // src/poll-ownership.ts
9829
+ var import_crypto = require("crypto");
9830
+ var import_bs58 = __toESM(require("bs58"), 1);
9831
+ var POLL_AUTH_HEADERS = {
9832
+ wallet: "x-poll-wallet",
9833
+ timestamp: "x-poll-timestamp",
9834
+ signature: "x-poll-signature"
9835
+ };
9836
+ function pollAuthMessage(kind, jobId, timestampS) {
9837
+ return `blockrun-poll:v1:${kind}:${jobId}:${timestampS}`;
9838
+ }
9839
+ function pollJobIdFromUrl(pollUrl) {
9840
+ const path7 = new URL(pollUrl, "https://placeholder.invalid").pathname.replace(/\/+$/, "");
9841
+ const segment = path7.slice(path7.lastIndexOf("/") + 1);
9842
+ if (!segment) throw new Error(`poll_url has no job id segment: ${pollUrl}`);
9843
+ return decodeURIComponent(segment);
9844
+ }
9845
+ var ED25519_PKCS8_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
9846
+ function signPollOwnership(secretKey, kind, jobId, nowMs = Date.now()) {
9847
+ if (secretKey.length !== 64 && secretKey.length !== 32) {
9848
+ throw new Error(`Solana secret key must be 64 bytes (or a 32-byte seed), got ${secretKey.length}`);
9849
+ }
9850
+ const seed = Buffer.from(secretKey.subarray(0, 32));
9851
+ const privateKey = (0, import_crypto.createPrivateKey)({
9852
+ key: Buffer.concat([ED25519_PKCS8_PREFIX, seed]),
9853
+ format: "der",
9854
+ type: "pkcs8"
9855
+ });
9856
+ const jwk = (0, import_crypto.createPublicKey)(privateKey).export({ format: "jwk" });
9857
+ if (!jwk.x) throw new Error("Could not derive the Ed25519 public key");
9858
+ const publicKey = Buffer.from(jwk.x, "base64url");
9859
+ const timestampS = Math.floor(nowMs / 1e3);
9860
+ const signature = (0, import_crypto.sign)(null, Buffer.from(pollAuthMessage(kind, jobId, timestampS), "utf8"), privateKey);
9861
+ return {
9862
+ [POLL_AUTH_HEADERS.wallet]: import_bs58.default.encode(publicKey),
9863
+ [POLL_AUTH_HEADERS.timestamp]: String(timestampS),
9864
+ [POLL_AUTH_HEADERS.signature]: import_bs58.default.encode(signature)
9865
+ };
9866
+ }
9867
+
9680
9868
  // src/solana-batch.ts
9681
9869
  var import_async_hooks = require("async_hooks");
9682
- var import_crypto = require("crypto");
9870
+ var import_crypto2 = require("crypto");
9683
9871
  var fs6 = __toESM(require("fs"), 1);
9684
9872
  var path5 = __toESM(require("path"), 1);
9685
- var import_bs58 = __toESM(require("bs58"), 1);
9873
+ var import_bs582 = __toESM(require("bs58"), 1);
9686
9874
  var BATCH_SCHEME = "batch-settlement";
9687
9875
  var BLOCKRUN_SOL_OPERATOR = "5YKPQUFjw5WQqhSUkEGKNNfYYVqnRRNbpYyL71qQ1vm3";
9688
9876
  var DEFAULT_BATCH_RATE_LIMIT = { maxAttempts: 3, maxWaitMs: 6e4 };
9877
+ var DEFAULT_BATCH_IN_DOUBT = { maxWaitMs: 3e5 };
9689
9878
  var BatchPaymentUnresolvedError = class extends PaymentError {
9690
9879
  /** The {@link SolanaBatchEvent.reason} of the `unresolved` event reported with it. */
9691
9880
  reason;
@@ -9940,7 +10129,7 @@ var FileIntentJournal = class _FileIntentJournal {
9940
10129
  fsyncDirectory(dir);
9941
10130
  return;
9942
10131
  }
9943
- const tmp = `${this.file}.${process.pid}.${(0, import_crypto.randomUUID)()}.tmp`;
10132
+ const tmp = `${this.file}.${process.pid}.${(0, import_crypto2.randomUUID)()}.tmp`;
9944
10133
  const fd = fs6.openSync(tmp, "w", 384);
9945
10134
  try {
9946
10135
  fs6.writeSync(fd, JSON.stringify({ version: 1, intents }, null, 2));
@@ -10066,7 +10255,7 @@ function lockChannelFile(file) {
10066
10255
  if (registry2.locks.has(file)) return void 0;
10067
10256
  const lock = `${file}.lock`;
10068
10257
  fs6.mkdirSync(path5.dirname(lock), { recursive: true, mode: 448 });
10069
- const token = (0, import_crypto.randomUUID)();
10258
+ const token = (0, import_crypto2.randomUUID)();
10070
10259
  for (let attempt = 0; attempt < 2; attempt += 1) {
10071
10260
  const tmp = `${lock}.${process.pid}.${token}.tmp`;
10072
10261
  try {
@@ -10242,7 +10431,7 @@ function decodeChannelAccount(owner, data) {
10242
10431
  return void 0;
10243
10432
  }
10244
10433
  const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
10245
- const key = (offset) => import_bs58.default.encode(data.subarray(offset, offset + 32));
10434
+ const key = (offset) => import_bs582.default.encode(data.subarray(offset, offset + 32));
10246
10435
  return {
10247
10436
  deposit: view.getBigUint64(CHANNEL.deposit, true),
10248
10437
  settled: view.getBigUint64(CHANNEL.settled, true),
@@ -10362,6 +10551,46 @@ function withoutClientSignedBatch(paymentRequired) {
10362
10551
  const accepts = paymentRequired.accepts ?? [];
10363
10552
  return accepts.some(clientSignedBatch) ? { ...paymentRequired, accepts: accepts.filter((accept) => !clientSignedBatch(accept)) } : paymentRequired;
10364
10553
  }
10554
+ var PAYMENT_IDENTIFIER = "payment-identifier";
10555
+ function withPaymentIdentifier(paymentRequired) {
10556
+ const declared = paymentRequired.extensions?.[PAYMENT_IDENTIFIER];
10557
+ if (typeof declared?.info?.required !== "boolean") return paymentRequired;
10558
+ const id = `pay_${(0, import_crypto2.randomUUID)().replace(/-/g, "")}`;
10559
+ return {
10560
+ ...paymentRequired,
10561
+ extensions: { ...paymentRequired.extensions, [PAYMENT_IDENTIFIER]: { ...declared, info: { ...declared.info, id } } }
10562
+ };
10563
+ }
10564
+ function peekReceipt(http, getHeader) {
10565
+ try {
10566
+ return http.getPaymentSettleResponse(getHeader);
10567
+ } catch {
10568
+ return void 0;
10569
+ }
10570
+ }
10571
+ function receiptSucceeded(http, answer) {
10572
+ return peekReceipt(http, (name) => answer.headers.get(name))?.success === true;
10573
+ }
10574
+ function hasPaymentIdentifier(payload) {
10575
+ const info = payload.extensions?.[PAYMENT_IDENTIFIER]?.info;
10576
+ return typeof info?.id === "string";
10577
+ }
10578
+ var IN_DOUBT_MIN_WAIT_MS = 1e3;
10579
+ var IN_DOUBT_MAX_WAIT_MS = 3e4;
10580
+ function replayMayResolve(doubt) {
10581
+ if (doubt.unanswered) return true;
10582
+ const answer = doubt.held;
10583
+ if (!answer) return false;
10584
+ return answer.status >= 500 || answer.status === 429 || answer.status === 409 && Boolean(answer.headers.get("retry-after")?.trim());
10585
+ }
10586
+ function inDoubtWait(doubt, replay) {
10587
+ const asked = doubt.held ? parseRetryAfter(doubt.held.headers.get("retry-after")) : void 0;
10588
+ return Math.min(IN_DOUBT_MAX_WAIT_MS, Math.max(IN_DOUBT_MIN_WAIT_MS, asked ?? backoffDelay(replay)));
10589
+ }
10590
+ function replayDoubt(outcome) {
10591
+ if (outcome.kind === "charged" || outcome.kind === "in_doubt") return outcome;
10592
+ return { kind: "in_doubt", reason: "replay_unresolved", replayable: false, settled: true, detail: "the replay was refused" };
10593
+ }
10365
10594
  function trustedBatchAccepts(paymentRequired, operators) {
10366
10595
  return (paymentRequired.accepts ?? []).filter((candidate) => {
10367
10596
  const extra = candidate.extra;
@@ -10371,9 +10600,6 @@ function trustedBatchAccepts(paymentRequired, operators) {
10371
10600
  function trustedBatchAccept(paymentRequired, operators) {
10372
10601
  return trustedBatchAccepts(paymentRequired, operators)[0];
10373
10602
  }
10374
- function atomicToUsd(amount) {
10375
- return typeof amount === "string" && /^\d+$/.test(amount) ? Number(amount) / 1e6 : void 0;
10376
- }
10377
10603
  function chargedUsd(getReceipt) {
10378
10604
  let receipt;
10379
10605
  try {
@@ -10381,7 +10607,7 @@ function chargedUsd(getReceipt) {
10381
10607
  } catch {
10382
10608
  return 0;
10383
10609
  }
10384
- return atomicToUsd(receipt?.extra?.chargedAmount) ?? atomicToUsd(receipt?.amount) ?? 0;
10610
+ return receiptChargedUsd(receipt) ?? 0;
10385
10611
  }
10386
10612
  function rebuiltCharge(settled, payload) {
10387
10613
  if (settled?.success !== true) return 0n;
@@ -10501,13 +10727,42 @@ function parseRetryAfter(value, now = Date.now()) {
10501
10727
  }
10502
10728
  return Number.isFinite(ms) && ms <= MAX_RETRY_AFTER_MS ? ms : void 0;
10503
10729
  }
10504
- var NOT_BROADCAST_429 = /* @__PURE__ */ new Set([
10730
+ var NOT_BROADCAST_REFUSALS = /* @__PURE__ */ new Set([
10731
+ // 429
10505
10732
  "batch_account_channel_capacity_exhausted",
10506
10733
  "batch_channel_capacity_exhausted",
10507
- "batch_deposit_rate_limited"
10734
+ "batch_deposit_rate_limited",
10735
+ "batch_deposit_ip_rate_limited",
10736
+ // 503
10737
+ "batch_signer_funding_headroom_exhausted",
10738
+ "batch_new_channels_disabled",
10739
+ "batch_public_new_channels_disabled",
10740
+ // 403
10741
+ "batch_account_admission_paused",
10742
+ "batch_channel_owner_mismatch",
10743
+ "batch_fee_payer_unavailable",
10744
+ "batch_fee_payer_not_dedicated",
10745
+ "batch_fee_payer_lane_not_authorized",
10746
+ "batch_transaction_fee_payer_mismatch",
10747
+ // 400
10748
+ "batch_initial_deposit_out_of_range",
10749
+ "batch_deposit_asset_not_supported",
10750
+ "batch_withdraw_delay_out_of_range"
10508
10751
  ]);
10509
10752
  function provesNothingBroadcast(receipt) {
10510
- return receipt?.success === false && !receipt.transaction && typeof receipt.errorReason === "string" && NOT_BROADCAST_429.has(receipt.errorReason);
10753
+ return receipt?.success === false && !receipt.transaction && typeof receipt.errorReason === "string" && NOT_BROADCAST_REFUSALS.has(receipt.errorReason);
10754
+ }
10755
+ var CONFIG_REFUSALS = /* @__PURE__ */ new Set([
10756
+ "batch_initial_deposit_out_of_range",
10757
+ "batch_deposit_asset_not_supported",
10758
+ "batch_withdraw_delay_out_of_range"
10759
+ ]);
10760
+ var REFUSAL_COOLDOWN_MS = 6e4;
10761
+ var CONFIG_REFUSAL_COOLDOWN_MS = 10 * 6e4;
10762
+ var REFUSAL_COOLDOWN_MAX_MS = 15 * 6e4;
10763
+ function refusalCooldownMs(errorReason, retryAfterMs2) {
10764
+ if (retryAfterMs2 !== void 0) return Math.min(retryAfterMs2, REFUSAL_COOLDOWN_MAX_MS);
10765
+ return errorReason !== void 0 && CONFIG_REFUSALS.has(errorReason) ? CONFIG_REFUSAL_COOLDOWN_MS : REFUSAL_COOLDOWN_MS;
10511
10766
  }
10512
10767
  function backoffDelay(attempt) {
10513
10768
  const base = 1e3 * 2 ** Math.max(0, attempt - 1);
@@ -10640,7 +10895,7 @@ function intentTarget(intent) {
10640
10895
  var SolanaBatchPayer = class {
10641
10896
  constructor(init) {
10642
10897
  this.init = init;
10643
- const { operators, rateLimit } = init.options;
10898
+ const { operators, rateLimit, inDoubt } = init.options;
10644
10899
  if (!Array.isArray(operators) || operators.length === 0 || operators.some((o) => typeof o !== "string" || !o)) {
10645
10900
  throw new Error("batch.operators must list at least one base58 operator public key");
10646
10901
  }
@@ -10652,11 +10907,16 @@ var SolanaBatchPayer = class {
10652
10907
  if (!Number.isFinite(this.maxWaitMs) || this.maxWaitMs < 0) {
10653
10908
  throw new Error("batch.rateLimit.maxWaitMs must be a finite number of ms >= 0");
10654
10909
  }
10910
+ this.inDoubtMaxWaitMs = inDoubt?.maxWaitMs ?? DEFAULT_BATCH_IN_DOUBT.maxWaitMs;
10911
+ if (!Number.isFinite(this.inDoubtMaxWaitMs) || this.inDoubtMaxWaitMs < 0) {
10912
+ throw new Error("batch.inDoubt.maxWaitMs must be a finite number of ms >= 0");
10913
+ }
10655
10914
  }
10656
10915
  init;
10657
10916
  state;
10658
10917
  maxAttempts;
10659
10918
  maxWaitMs;
10919
+ inDoubtMaxWaitMs;
10660
10920
  counters = {
10661
10921
  fallbacks: 0,
10662
10922
  fallbacksByReason: {},
@@ -11025,14 +11285,19 @@ var SolanaBatchPayer = class {
11025
11285
  * the call's result (`served`, nothing paid); one that fails otherwise
11026
11286
  * is the call's error (`failed`, `"unpaid"`), never an exact payment
11027
11287
  * against the stale challenge;
11028
- * - in doubt: ends only with a definitive success receipt from the one
11029
- * replay a receipt-less 429 is allowed ({@link resolveInDoubt}), or a
11030
- * thrown {@link BatchPaymentUnresolvedError}. No new authorization, no
11031
- * new deposit, no exact payment and no fallback model is ever paid for a
11032
- * call in doubt.
11288
+ * - in doubt: ends only with a definitive success receipt from a replay
11289
+ * of the identical payment ({@link resolveInDoubt}: the one replay a
11290
+ * receipt-less 429 is allowed, or the replays of a payment carrying a
11291
+ * payment identifier while the gateway says one can succeed, within
11292
+ * `inDoubt.maxWaitMs`), or a thrown {@link BatchPaymentUnresolvedError}.
11293
+ * No new authorization, no new deposit, no exact payment and no fallback
11294
+ * model is ever paid for a call in doubt.
11033
11295
  *
11034
11296
  * The wallet stays in cooldown after a 429, so its other calls wait
11035
- * instead of sending opens of their own.
11297
+ * instead of sending opens of their own. After a deposit the facilitator
11298
+ * refused before broadcasting it (a 402 with a failed `batch_*` admission
11299
+ * receipt), its calls do not wait: they pay exact at once
11300
+ * (`admission_refused`) until the refusal cooldown ends.
11036
11301
  *
11037
11302
  * @param closesAtStart - {@link closeFence}, taken before the call's first
11038
11303
  * (unpaid) request. If a close completed since, the call pays exact
@@ -11096,6 +11361,15 @@ var SolanaBatchPayer = class {
11096
11361
  try {
11097
11362
  for (; ; ) {
11098
11363
  const now = Date.now();
11364
+ if ((wallet.refusedUntil ?? 0) > now) {
11365
+ return fallback({
11366
+ reason: "admission_refused",
11367
+ errorReason: wallet.refusedFor,
11368
+ retryAfterMs: (wallet.refusedUntil ?? 0) - now,
11369
+ attempt: budget.attempt,
11370
+ detail: `the facilitator refused this wallet's last batch deposit (${wallet.refusedFor ?? "unknown"}); batch is skipped until the refusal cooldown ends`
11371
+ });
11372
+ }
11099
11373
  if (wallet.cooldownUntil > now && wallet.cooldownUntil !== waitedUntil) {
11100
11374
  const wait = wallet.cooldownUntil - now;
11101
11375
  if (budget.waited + wait > this.maxWaitMs) {
@@ -11185,12 +11459,23 @@ var SolanaBatchPayer = class {
11185
11459
  if (built.kind !== "authorization") {
11186
11460
  if (outcome.depositSafe) this.forgetIntent(wallet, built.key);
11187
11461
  else this.distrust(wallet, built, "deposit_refused");
11462
+ if (outcome.refusedForMs !== void 0) {
11463
+ const until = Date.now() + outcome.refusedForMs;
11464
+ if (until > (wallet.refusedUntil ?? 0)) {
11465
+ wallet.refusedUntil = until;
11466
+ wallet.refusedFor = outcome.errorReason;
11467
+ }
11468
+ }
11188
11469
  }
11189
11470
  sent = void 0;
11190
11471
  release();
11191
11472
  if (!outcome.rateLimited) {
11192
- const { kind: _kind, rateLimited: _limited, depositSafe: _safe, ...event } = outcome;
11193
- return fallback({ ...event, attempt: budget.attempt });
11473
+ const { kind: _kind, rateLimited: _limited, depositSafe: _safe, refusedForMs, ...event } = outcome;
11474
+ return fallback({
11475
+ ...event,
11476
+ ...refusedForMs !== void 0 && built.kind !== "authorization" ? { retryAfterMs: refusedForMs } : {},
11477
+ attempt: budget.attempt
11478
+ });
11194
11479
  }
11195
11480
  const wait = this.coolDown(wallet, outcome.rateLimited.retryAfterMs ?? backoffDelay(budget.attempt));
11196
11481
  if (budget.attempt >= this.maxAttempts || budget.waited + wait > this.maxWaitMs) {
@@ -11236,7 +11521,7 @@ var SolanaBatchPayer = class {
11236
11521
  if (built.kind !== "authorization" && !(built.key && wallet.resyncs.has(built.key))) this.forgetIntent(wallet, built.key);
11237
11522
  sent = void 0;
11238
11523
  release();
11239
- await this.report({ type: "recovered", reason: "rate_limited", attempt: budget.attempt });
11524
+ await this.report({ type: "recovered", reason: outcome.status === 429 ? "rate_limited" : "in_doubt", attempt: budget.attempt });
11240
11525
  return { kind: "paid", response: resolved.response, chargedUsd: resolved.chargedUsd };
11241
11526
  }
11242
11527
  return await this.raiseUnresolved(wallet, built, resolved, budget.attempt);
@@ -11285,21 +11570,24 @@ var SolanaBatchPayer = class {
11285
11570
  /**
11286
11571
  * Try to end a payment in doubt with the gateway's own word.
11287
11572
  *
11288
- * The one source of evidence this SDK accepts today is a definitive
11289
- * success receipt on the single byte-identical replay a 429 without a
11290
- * receipt is allowed, after its backoff (the owner's bounded safety
11291
- * attempt). Any other answer to it (a 402 or `duplicate_settlement`, which
11292
- * mean the original reached the gateway; another 429; a 5xx; a timeout)
11293
- * leaves it in doubt. Every other in-doubt outcome is never replayed.
11573
+ * The one source of evidence this SDK accepts is a definitive success
11574
+ * receipt on a byte-identical replay of the payment. Any other answer to a
11575
+ * replay (a 402 or `duplicate_settlement`, which mean the original reached
11576
+ * the gateway; a 409; another 429; a 5xx; a timeout) leaves it in doubt.
11577
+ *
11578
+ * - A payment carrying a payment identifier is replayed for as long as
11579
+ * the gateway says a later replay can succeed ({@link replayInDoubt}).
11580
+ * - Any other payment gets a replay only after a 429 without a receipt:
11581
+ * one, after its backoff (the owner's bounded safety attempt), within
11582
+ * `rateLimit`. Every other in-doubt outcome is never replayed.
11294
11583
  *
11295
11584
  * Chain state is not evidence here: it can settle a deposit (see
11296
- * `resync`), never whether the gateway charged. Gateway-side evidence (a
11297
- * receipt on every response, a request-status endpoint with a fence)
11298
- * plugs in here, before the caller raises.
11585
+ * `resync`), never whether the gateway charged.
11299
11586
  *
11300
11587
  * @returns the charged replay, or the doubt to raise.
11301
11588
  */
11302
11589
  async resolveInDoubt(wallet, sent, doubt, send, budget) {
11590
+ if (hasPaymentIdentifier(sent.payload)) return this.replayInDoubt(wallet, sent, doubt, send, budget.attempt);
11303
11591
  if (!doubt.replayable) return doubt;
11304
11592
  const wait = this.coolDown(wallet, doubt.retryAfterMs ?? backoffDelay(budget.attempt));
11305
11593
  if (budget.attempt >= this.maxAttempts || budget.waited + wait > this.maxWaitMs) {
@@ -11319,9 +11607,94 @@ var SolanaBatchPayer = class {
11319
11607
  budget.waited += wait;
11320
11608
  await sleep2(wait);
11321
11609
  budget.attempt += 1;
11322
- const replayed = await this.sendOnce(wallet, sent, send, true, true);
11323
- if (replayed.kind === "charged") return replayed;
11324
- return replayed.kind === "in_doubt" ? replayed : { kind: "in_doubt", reason: "replay_unresolved", replayable: false, settled: true, detail: "the replay was refused" };
11610
+ return replayDoubt(await this.sendOnce(wallet, sent, send, true, true));
11611
+ }
11612
+ /**
11613
+ * Replay a payment in doubt that carries a payment identifier until the
11614
+ * gateway's answer settles it.
11615
+ *
11616
+ * A gateway that declares `payment-identifier` stores each paid response
11617
+ * under its identifier and answers a byte-identical replay of a completed
11618
+ * request with it, the original receipt included; while the original is
11619
+ * still running, it answers 409 with a `Retry-After`. So the identical
11620
+ * payment (the same `PAYMENT-SIGNATURE` header and the same body) is
11621
+ * replayed for as long as the last answer says a later one can succeed
11622
+ * ({@link replayMayResolve}), after {@link inDoubtWait} each time, as long
11623
+ * as the wait fits in `inDoubt.maxWaitMs`, counted from the first doubt:
11624
+ * every wait (as long as it really took) and every replay's round trip. A
11625
+ * replay already sent is not cut short. These replays do not count
11626
+ * against `rateLimit`; a 429 among them still puts the wallet in cooldown.
11627
+ *
11628
+ * Every answer that does not settle it is held back from the scheme
11629
+ * ({@link InDoubt.held}) and handed over only once no replay follows, so
11630
+ * the scheme rolls the payment back once, or takes the receipt that answer
11631
+ * carries. That is the last answer, unless an earlier one carried a
11632
+ * success receipt and the later ones do not: a success receipt is the
11633
+ * gateway's word that it charged, and handing the scheme a receipt-less
11634
+ * refusal of a replay instead would roll a real charge back and desync the
11635
+ * channel's cumulative.
11636
+ *
11637
+ * @returns the charged replay, or the doubt to raise.
11638
+ */
11639
+ async replayInDoubt(wallet, sent, first, send, attempt) {
11640
+ let doubt = first;
11641
+ let held = first.held;
11642
+ let heldSucceeded = held !== void 0 && receiptSucceeded(sent.http, held);
11643
+ let replays = 0;
11644
+ let spent = 0;
11645
+ const stop = async (note) => {
11646
+ if (!doubt.settled && held) {
11647
+ const answer = held;
11648
+ await this.settle(sent.http, sent.payload, (name) => answer.headers.get(name), answer.status);
11649
+ }
11650
+ const notes = [replays > 0 ? `${replays} replay${replays === 1 ? "" : "s"} did not resolve it` : "", note ?? ""];
11651
+ return {
11652
+ ...doubt,
11653
+ settled: doubt.settled || held !== void 0,
11654
+ held: void 0,
11655
+ firstReason: first.reason,
11656
+ detail: [doubt.detail, ...notes].filter(Boolean).join("; ")
11657
+ };
11658
+ };
11659
+ for (; ; ) {
11660
+ if (!replayMayResolve(doubt)) {
11661
+ return stop(doubt.held?.status === 409 ? "the gateway sent no Retry-After, so no replay can resolve it" : void 0);
11662
+ }
11663
+ const wait = inDoubtWait(doubt, replays + 1);
11664
+ const status2 = doubt.held?.status;
11665
+ if (status2 === 429) this.coolDown(wallet, (doubt.held && parseRetryAfter(doubt.held.headers.get("retry-after"))) ?? wait);
11666
+ if (spent + wait > this.inDoubtMaxWaitMs) {
11667
+ return stop(`batch.inDoubt.maxWaitMs (${this.inDoubtMaxWaitMs} ms) leaves no room for ${replays > 0 ? "another replay" : "a replay"}`);
11668
+ }
11669
+ await this.report({
11670
+ type: "backoff",
11671
+ reason: status2 === 429 ? "rate_limited" : "in_doubt",
11672
+ status: status2,
11673
+ errorReason: doubt.errorReason,
11674
+ retryAfterMs: wait,
11675
+ attempt,
11676
+ detail: `replay ${replays + 1} of the payment in doubt`
11677
+ });
11678
+ const sleptFrom = Date.now();
11679
+ await sleep2(wait);
11680
+ spent += Math.max(wait, Date.now() - sleptFrom);
11681
+ replays += 1;
11682
+ const startedAt = Date.now();
11683
+ const replayed = replayDoubt(await this.sendOnce(wallet, sent, send, true, true));
11684
+ spent += Math.max(0, Date.now() - startedAt);
11685
+ if (replayed.kind === "charged") return replayed;
11686
+ doubt = replayed;
11687
+ if (doubt.held) {
11688
+ const succeeded = receiptSucceeded(sent.http, doubt.held);
11689
+ if (succeeded || !heldSucceeded) {
11690
+ held = doubt.held;
11691
+ heldSucceeded = succeeded;
11692
+ }
11693
+ } else if (doubt.settled) {
11694
+ held = void 0;
11695
+ heldSucceeded = false;
11696
+ }
11697
+ }
11325
11698
  }
11326
11699
  /**
11327
11700
  * Raise a payment in doubt: release the scheme's hold on it (the channel
@@ -11332,10 +11705,11 @@ var SolanaBatchPayer = class {
11332
11705
  async raiseUnresolved(wallet, sent, doubt, attempt) {
11333
11706
  if (!doubt.settled) await this.settle(sent.http, sent.payload, () => null, 0);
11334
11707
  if (sent.kind !== "authorization") {
11708
+ const reason = doubt.firstReason ?? doubt.reason;
11335
11709
  this.distrust(
11336
11710
  wallet,
11337
11711
  sent,
11338
- doubt.reason === "no_response" ? "deposit_unanswered" : doubt.reason === "replay_unresolved" ? "deposit_rate_limited" : "deposit_failed"
11712
+ reason === "no_response" ? "deposit_unanswered" : reason === "replay_unresolved" ? "deposit_rate_limited" : "deposit_failed"
11339
11713
  );
11340
11714
  }
11341
11715
  await this.report({
@@ -11422,8 +11796,8 @@ var SolanaBatchPayer = class {
11422
11796
  * here, and only for a first send.
11423
11797
  *
11424
11798
  * @param retry - count this send in `retries` (it follows this call's own 429).
11425
- * @param replay - this is the one replay of a receipt-less 429: only a 2xx
11426
- * with a success receipt counts, everything else stays in doubt.
11799
+ * @param replay - this is a replay of a payment in doubt: only a 2xx with
11800
+ * a success receipt counts, everything else stays in doubt.
11427
11801
  */
11428
11802
  async sendOnce(wallet, sent, send, retry, replay) {
11429
11803
  if (retry) this.counters.retries += 1;
@@ -11437,6 +11811,7 @@ var SolanaBatchPayer = class {
11437
11811
  reason: replay ? "replay_unresolved" : "no_response",
11438
11812
  replayable: false,
11439
11813
  settled: false,
11814
+ unanswered: true,
11440
11815
  detail: `${replay ? "the replay" : "the payment"} got no answer (${err instanceof Error ? `${err.name}: ${err.message}` : String(err)})`,
11441
11816
  cause: err
11442
11817
  };
@@ -11461,6 +11836,7 @@ var SolanaBatchPayer = class {
11461
11836
  const getHeader = (name) => response.headers.get(name);
11462
11837
  const receipted = Boolean(getHeader("PAYMENT-RESPONSE") || getHeader("X-PAYMENT-RESPONSE"));
11463
11838
  const status2 = response.status;
11839
+ const hold = hasPaymentIdentifier(payload) && (!response.ok || replay && peekReceipt(http, getHeader)?.success !== true);
11464
11840
  if (status2 === 429 && !receipted) {
11465
11841
  return {
11466
11842
  kind: "in_doubt",
@@ -11470,12 +11846,27 @@ var SolanaBatchPayer = class {
11470
11846
  status: status2,
11471
11847
  retryAfterMs: parseRetryAfter(getHeader("retry-after")),
11472
11848
  errorReason: await rateLimitReason(response, void 0),
11473
- detail: replay ? "the replay was answered 429 without a receipt" : "a 429 without a receipt"
11849
+ detail: replay ? "the replay was answered 429 without a receipt" : "a 429 without a receipt",
11850
+ ...hold ? { held: response } : {}
11474
11851
  };
11475
11852
  }
11853
+ const outcome = await this.classifyAnswer(wallet, sent, response, replay, hold);
11854
+ if (!hold) return outcome;
11855
+ if (outcome.kind === "in_doubt") return { ...outcome, settled: false, held: response };
11856
+ await this.settle(http, payload, getHeader, status2);
11857
+ return outcome;
11858
+ }
11859
+ /**
11860
+ * {@link classify}, past a receipt-less 429. With `hold`, the answer is not
11861
+ * handed to the scheme: its receipt is only read.
11862
+ */
11863
+ async classifyAnswer(wallet, sent, response, replay, hold) {
11864
+ const { http, payload } = sent;
11865
+ const getHeader = (name) => response.headers.get(name);
11866
+ const status2 = response.status;
11476
11867
  const channelId = payloadChannelId(payload);
11477
11868
  const before = channelId ? confirmedState(wallet.book.find(channelId)?.record) : void 0;
11478
- const reconciled = await this.settle(http, payload, getHeader, status2);
11869
+ const reconciled = hold ? { settleResponse: peekReceipt(http, getHeader), ok: false } : await this.settle(http, payload, getHeader, status2);
11479
11870
  const receipt = reconciled.settleResponse;
11480
11871
  const definitive = receipt?.success === true && reconciled.ok && recordAdvanced(before, channelId ? confirmedState(wallet.book.find(channelId)?.record) : void 0, payload, receipt);
11481
11872
  if (!response.ok && !replay && sent.kind === "authorization" && cancelledReceipt(receipt)) {
@@ -11538,7 +11929,15 @@ var SolanaBatchPayer = class {
11538
11929
  cause: await afterPaymentError(response)
11539
11930
  };
11540
11931
  }
11541
- return { kind: "not_charged", reason: "payment_required", status: status2, errorReason: receipt?.errorReason, depositSafe: provesNothingBroadcast(receipt) };
11932
+ const refused = provesNothingBroadcast(receipt);
11933
+ return {
11934
+ kind: "not_charged",
11935
+ reason: "payment_required",
11936
+ status: status2,
11937
+ errorReason: receipt?.errorReason,
11938
+ depositSafe: refused,
11939
+ ...refused ? { refusedForMs: refusalCooldownMs(receipt?.errorReason, parseRetryAfter(getHeader("retry-after"))) } : {}
11940
+ };
11542
11941
  }
11543
11942
  const refusal = await batchRefusal(response);
11544
11943
  if (refusal) {
@@ -11569,7 +11968,7 @@ var SolanaBatchPayer = class {
11569
11968
  const http = (await this.build(wallet)).http;
11570
11969
  try {
11571
11970
  await this.prepareTopUp(wallet, paymentRequired);
11572
- return { http, payload: await http.createPaymentPayload(withoutClientSignedBatch(paymentRequired)) };
11971
+ return { http, payload: await http.createPaymentPayload(withPaymentIdentifier(withoutClientSignedBatch(paymentRequired))) };
11573
11972
  } catch (err) {
11574
11973
  if (pass > 0 || !isResyncRequired(err) || wallet.resyncs.size === 0) throw err;
11575
11974
  } finally {
@@ -11662,13 +12061,7 @@ var SolanaBatchPayer = class {
11662
12061
  console.error(
11663
12062
  `[@blockrun/llm] batch-settlement receipt not reconciled: ${JSON.stringify(err instanceof Error ? err.message : String(err))}`
11664
12063
  );
11665
- let settleResponse;
11666
- try {
11667
- settleResponse = http.getPaymentSettleResponse(getHeader);
11668
- } catch {
11669
- settleResponse = void 0;
11670
- }
11671
- return { settleResponse, ok: false };
12064
+ return { settleResponse: peekReceipt(http, getHeader), ok: false };
11672
12065
  }
11673
12066
  }
11674
12067
  /**
@@ -11802,6 +12195,39 @@ var DEFAULT_MAX_TOKENS2 = 1024;
11802
12195
  var DEFAULT_TIMEOUT14 = 6e4;
11803
12196
  var STALE_BLOCKHASH_RETRY_BACKOFFS_MS = [500, 2e3];
11804
12197
  var MAX_PAYMENT_FAILURE_BYTES = 64 * 1024;
12198
+ var DEFAULT_VIDEO_MODEL = "xai/grok-imagine-video";
12199
+ var DEFAULT_MUSIC_MODEL = "minimax/music-2.5+";
12200
+ var DEFAULT_VIDEO_BUDGET_MS = 9e5;
12201
+ var DEFAULT_MUSIC_BUDGET_MS = 3e5;
12202
+ var VIDEO_POLL_INTERVAL_MS = 5e3;
12203
+ var MAX_PAID_POLL_REFUSALS = 4;
12204
+ var MAX_RETRY_AFTER_MS2 = 12e4;
12205
+ var MEDIA_SUBMIT_TIMEOUT_MS = 12e4;
12206
+ function sleep3(ms) {
12207
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
12208
+ }
12209
+ async function readJsonOrEmpty(response) {
12210
+ try {
12211
+ const parsed = await response.json();
12212
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
12213
+ } catch {
12214
+ return {};
12215
+ }
12216
+ }
12217
+ function isPendingPollStatus(status2) {
12218
+ return status2 === 202 || status2 === 503 || status2 === 504;
12219
+ }
12220
+ function retryAfterMs(response) {
12221
+ const raw = response.headers.get("retry-after");
12222
+ if (!raw || !/^\d+$/.test(raw.trim())) return void 0;
12223
+ return Math.min(Number(raw.trim()) * 1e3, MAX_RETRY_AFTER_MS2);
12224
+ }
12225
+ function pollKindFor(url) {
12226
+ const path7 = new URL(url).pathname;
12227
+ if (path7.includes("/v1/videos")) return "video";
12228
+ if (path7.includes("/v1/audio/")) return "audio";
12229
+ return "image";
12230
+ }
11805
12231
  var SafeStaleBlockhashError = class extends PaymentError {
11806
12232
  constructor() {
11807
12233
  super("Payment verification used an expired Solana blockhash; retrying with a fresh quote.");
@@ -12018,9 +12444,9 @@ var SolanaLLMClient = class {
12018
12444
  };
12019
12445
  if (options?.temperature !== void 0) body.temperature = options.temperature;
12020
12446
  if (options?.topP !== void 0) body.top_p = options.topP;
12021
- if (options?.searchParameters !== void 0) body.search_parameters = options.searchParameters;
12447
+ if (options?.searchParameters !== void 0) body.search_parameters = toWireSearchParameters(options.searchParameters);
12022
12448
  else if (options?.search === true) body.search_parameters = { mode: "on" };
12023
- if (options?.tools !== void 0) body.tools = options.tools;
12449
+ if (options?.tools !== void 0) body.tools = toWireTools(options.tools);
12024
12450
  if (options?.toolChoice !== void 0) body.tool_choice = options.toolChoice;
12025
12451
  if (options?.responseFormat !== void 0) body.response_format = options.responseFormat;
12026
12452
  if (options?.stop !== void 0) body.stop = options.stop;
@@ -12031,7 +12457,7 @@ var SolanaLLMClient = class {
12031
12457
  for (let index = 0; index < chain.length; index += 1) {
12032
12458
  const candidate = chain[index];
12033
12459
  try {
12034
- return await this.requestWithPayment("/v1/chat/completions", buildBody(candidate));
12460
+ return await this.requestWithPayment("/v1/chat/completions", buildBody(candidate), options?.timeout);
12035
12461
  } catch (error) {
12036
12462
  lastError = error;
12037
12463
  if (!isTransientError(error) || index === chain.length - 1) throw error;
@@ -12081,6 +12507,8 @@ var SolanaLLMClient = class {
12081
12507
  {
12082
12508
  routingProfile: options?.routingProfile,
12083
12509
  requiresStructuredOutput: options?.responseFormat !== void 0,
12510
+ search: options?.search,
12511
+ searchParameters: options?.searchParameters,
12084
12512
  minimumPaymentUsd: this.apiAuth ? 0 : SOLANA_MINIMUM_PAYMENT_USD
12085
12513
  }
12086
12514
  );
@@ -12108,6 +12536,8 @@ var SolanaLLMClient = class {
12108
12536
  requiresStructuredOutput: options.responseFormat !== void 0,
12109
12537
  tools: options.tools,
12110
12538
  toolChoice: options.toolChoice,
12539
+ search: options.search,
12540
+ searchParameters: options.searchParameters,
12111
12541
  conversationChars,
12112
12542
  hasVision,
12113
12543
  minimumPaymentUsd: this.apiAuth ? 0 : SOLANA_MINIMUM_PAYMENT_USD
@@ -12123,6 +12553,7 @@ var SolanaLLMClient = class {
12123
12553
  toolChoice: options.toolChoice,
12124
12554
  responseFormat: options.responseFormat,
12125
12555
  stop: options.stop,
12556
+ timeout: options.timeout,
12126
12557
  // An explicit caller-supplied chain wins over the routed one.
12127
12558
  fallbackModels: options.fallbackModels ?? decision.fallbacks
12128
12559
  });
@@ -12260,7 +12691,7 @@ var SolanaLLMClient = class {
12260
12691
  *
12261
12692
  * @returns USDC balance as a float
12262
12693
  */
12263
- async getBalance() {
12694
+ async getBalance(options) {
12264
12695
  const usdc_mint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
12265
12696
  const address = await this.getWalletAddress();
12266
12697
  try {
@@ -12275,6 +12706,9 @@ var SolanaLLMClient = class {
12275
12706
  })
12276
12707
  });
12277
12708
  const data = await response.json();
12709
+ if (options?.strict && (!response.ok || data.error)) {
12710
+ throw new Error(`Solana RPC balance lookup failed: ${data.error?.message ?? `HTTP ${response.status}`}`);
12711
+ }
12278
12712
  const accounts = data.result?.value || [];
12279
12713
  if (!accounts.length) return 0;
12280
12714
  let total = 0;
@@ -12282,7 +12716,8 @@ var SolanaLLMClient = class {
12282
12716
  total += acct.account?.data?.parsed?.info?.tokenAmount?.uiAmount || 0;
12283
12717
  }
12284
12718
  return total;
12285
- } catch {
12719
+ } catch (error) {
12720
+ if (options?.strict) throw error;
12286
12721
  return 0;
12287
12722
  }
12288
12723
  }
@@ -12311,6 +12746,54 @@ var SolanaLLMClient = class {
12311
12746
  const data = await this.requestWithPaymentRaw("/v1/images/image2image", body);
12312
12747
  return data;
12313
12748
  }
12749
+ /**
12750
+ * Generate a short video on Solana.
12751
+ *
12752
+ * The POST is verified, not charged. The job is then polled with a wallet
12753
+ * signature (no payment) until it finishes, and ONE payment is signed for
12754
+ * the finished video; a failed job is never charged. If the gateway does not
12755
+ * accept poll signatures for the job, it is polled with a payment per poll
12756
+ * instead, which is likewise only charged on completion.
12757
+ *
12758
+ * @param prompt - text description of the video.
12759
+ * @param options - `model` (default `xai/grok-imagine-video`), `budgetMs`
12760
+ * (total wait, default 15 min); any other keys are sent verbatim, so use
12761
+ * the gateway's snake_case names (`duration_seconds`, `image_url`,
12762
+ * `aspect_ratio`, `resolution`, …).
12763
+ */
12764
+ async video(prompt, options = {}) {
12765
+ const { model, budgetMs, ...rest } = options;
12766
+ const body = { ...rest, model: model || DEFAULT_VIDEO_MODEL, prompt };
12767
+ const data = await this.requestWithPaymentRaw(
12768
+ "/v1/videos/generations",
12769
+ body,
12770
+ { pollBudgetMs: budgetMs ?? DEFAULT_VIDEO_BUDGET_MS, submitTimeoutMs: Math.max(this.timeout, MEDIA_SUBMIT_TIMEOUT_MS) }
12771
+ );
12772
+ return data;
12773
+ }
12774
+ /**
12775
+ * Generate a music track on Solana. Paid at the POST; a slow track is then
12776
+ * polled with a wallet signature, never another payment.
12777
+ *
12778
+ * @param prompt - music style, mood, or description.
12779
+ * @param options - `model` (default `minimax/music-2.5+`), `instrumental`
12780
+ * (default true), `lyrics` (requires `instrumental: false`), `budgetMs`
12781
+ * (total wait, default 5 min); other keys are sent verbatim.
12782
+ */
12783
+ async music(prompt, options = {}) {
12784
+ const { model, instrumental: instrumentalOption, lyrics: rawLyrics, budgetMs, ...rest } = options;
12785
+ const instrumental = instrumentalOption ?? true;
12786
+ const lyrics = rawLyrics?.trim();
12787
+ if (instrumental && lyrics) throw new Error("Cannot specify lyrics when instrumental is true");
12788
+ const body = { ...rest, model: model || DEFAULT_MUSIC_MODEL, prompt, instrumental };
12789
+ if (lyrics) body.lyrics = lyrics;
12790
+ const data = await this.requestWithPaymentRaw(
12791
+ "/v1/audio/generations",
12792
+ body,
12793
+ { pollBudgetMs: budgetMs ?? DEFAULT_MUSIC_BUDGET_MS, submitTimeoutMs: Math.max(this.timeout, MEDIA_SUBMIT_TIMEOUT_MS) }
12794
+ );
12795
+ return data;
12796
+ }
12314
12797
  /** Standalone search (Solana payment). */
12315
12798
  async search(query, options) {
12316
12799
  const body = { query, max_results: options?.maxResults || 10 };
@@ -12509,15 +12992,16 @@ var SolanaLLMClient = class {
12509
12992
  * `"paid-or-in-doubt"`, and so is any error with an API key once the billed
12510
12993
  * request was sent, except the account API's explicit 4xx refusal.
12511
12994
  */
12512
- async requestWithPayment(endpoint, body) {
12995
+ async requestWithPayment(endpoint, body, timeoutMs) {
12513
12996
  const url = `${this.apiUrl}${endpoint}`;
12997
+ const json = JSON.stringify(body);
12514
12998
  const closesAtStart = await this.batchPayer?.closeFence();
12515
12999
  for (let staleRetries = 0; ; ) {
12516
13000
  const response = await this.sendUnpaid(url, {
12517
13001
  method: "POST",
12518
13002
  headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT },
12519
- body: JSON.stringify(body)
12520
- });
13003
+ body: json
13004
+ }, timeoutMs);
12521
13005
  if (response.status === 402) {
12522
13006
  try {
12523
13007
  let paymentRequired;
@@ -12533,16 +13017,16 @@ var SolanaLLMClient = class {
12533
13017
  (paymentHeaders) => this.fetchWithTimeout(url, {
12534
13018
  method: "POST",
12535
13019
  headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT, ...paymentHeaders },
12536
- body: JSON.stringify(body)
12537
- }),
13020
+ body: json
13021
+ }, timeoutMs),
12538
13022
  // A fresh, unpaid challenge after a 429 wait: the request itself,
12539
13023
  // sent again without a payment. Every error it throws is unpaid.
12540
13024
  async () => {
12541
13025
  const challenge = await this.sendUnpaid(url, {
12542
13026
  method: "POST",
12543
13027
  headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT },
12544
- body: JSON.stringify(body)
12545
- });
13028
+ body: json
13029
+ }, timeoutMs);
12546
13030
  if (challenge.status === 402) {
12547
13031
  try {
12548
13032
  return { kind: "challenge", paymentRequired: await this.readPaymentRequired(challenge) };
@@ -12557,13 +13041,14 @@ var SolanaLLMClient = class {
12557
13041
  );
12558
13042
  if (batch.kind === "paid") {
12559
13043
  this.recordSettlement(batch.chargedUsd);
12560
- return await this.readPaidJson(batch.response);
13044
+ const paid = await this.readPaidJson(batch.response);
13045
+ return withCost(paid, batch.response, batch.chargedUsd, "batch");
12561
13046
  }
12562
- if (batch.kind === "served") return await this.readUnpaidJson(batch.response);
13047
+ if (batch.kind === "served") return this.withUnpaidCost(await this.readUnpaidJson(batch.response));
12563
13048
  if (batch.kind === "failed") throw batch.error;
12564
13049
  exactRequired = batch.paymentRequired ?? paymentRequired;
12565
13050
  }
12566
- return await this.handlePaymentAndRetry(url, body, exactRequired, staleRetries > 0);
13051
+ return await this.handlePaymentAndRetry(url, json, exactRequired, staleRetries > 0, timeoutMs);
12567
13052
  } catch (error) {
12568
13053
  if (!(error instanceof SafeStaleBlockhashError) || staleRetries >= STALE_BLOCKHASH_RETRY_BACKOFFS_MS.length) throw error;
12569
13054
  await waitForStaleRetry(staleRetries++);
@@ -12571,8 +13056,20 @@ var SolanaLLMClient = class {
12571
13056
  }
12572
13057
  }
12573
13058
  if (!response.ok) throw await unpaidApiError(response);
12574
- return await this.readUnpaidJson(response);
13059
+ return this.withUnpaidCost(await this.readUnpaidJson(response));
13060
+ }
13061
+ }
13062
+ /**
13063
+ * Cost of a chat response served without a payment: 0 with a wallet (a free
13064
+ * model). With an API key the call is billed to the account, so costUsd stays
13065
+ * undefined (see the dashboard).
13066
+ */
13067
+ withUnpaidCost(served) {
13068
+ if (!this.apiAuth) {
13069
+ served.costUsd = 0;
13070
+ served.settlement = { scheme: "free", quotedUsd: 0 };
12575
13071
  }
13072
+ return served;
12576
13073
  }
12577
13074
  /**
12578
13075
  * Read the JSON body of a 2xx served without a payment: the free tier, or
@@ -12590,9 +13087,9 @@ var SolanaLLMClient = class {
12590
13087
  * {@link RetryDisposition}: `"unpaid"` with a wallet; with an API key, see
12591
13088
  * {@link accountErrorDisposition} (the request itself is billed).
12592
13089
  */
12593
- async sendUnpaid(url, init) {
13090
+ async sendUnpaid(url, init, timeoutMs) {
12594
13091
  try {
12595
- return await this.fetchWithTimeout(url, init);
13092
+ return await this.fetchWithTimeout(url, init, timeoutMs);
12596
13093
  } catch (error) {
12597
13094
  throw withDisposition(error, this.apiAuth ? accountErrorDisposition(error) : "unpaid");
12598
13095
  }
@@ -12606,106 +13103,221 @@ var SolanaLLMClient = class {
12606
13103
  }
12607
13104
  }
12608
13105
  /**
12609
- * Turn a `402` into a signed Solana payment payload.
12610
- *
12611
- * Extracted because four call sites need it — chat, the raw POST helpers, the
12612
- * raw GET helper, and {@link SolanaLLMClient.stream} — and it had been
12613
- * written out three times before this. That mattered more than ordinary
12614
- * duplication: this is the code that signs a transfer of the caller's USDC,
12615
- * so three copies meant every fix to it had to be applied three times or
12616
- * quietly apply to two thirds of the paths.
12617
- *
12618
- * @param url - the request being paid for.
12619
- * @param response - the gateway's `402`, not yet consumed.
12620
- * @param forceFreshBlockhash - set on a re-sign after a stale-blockhash
12621
- * rejection, so the retry cannot produce byte-identical transaction bytes.
12622
- * @param resourceFallback - resource URL to claim when the 402 states none.
12623
- * @returns the header value to replay with, and what it will settle for.
12624
- * @throws PaymentError when the 402 carries no usable Solana requirements.
12625
- */
12626
- /**
12627
- * Follow a 202 `{ id, poll_url }` to completion, on Solana **image** routes.
12628
- *
12629
- * Two things differ from Base and both come from the same constraint — a
12630
- * Solana payment is a transaction pinned to a recent blockhash, valid for
12631
- * ~150 blocks (~60s), and a long render outlives it:
12632
- *
12633
- * 1. **The POST already settled.** Base settles on the completed poll; Solana
12634
- * cannot, because by then the signed transaction has expired. So sol
12635
- * settles optimistically at submit, and this loop only fetches the result.
12636
- * The cost is recorded by the caller at POST, never here — recording it on
12637
- * completion would lose the charge whenever a paid job then fails.
12638
- * Solana VIDEO is the exception: it settles on the completed poll and a
12639
- * failed job is not charged. Do not route video through this loop as-is —
12640
- * it would book spend for jobs that were never charged.
12641
- * 2. **Every poll re-signs.** One authorization cannot be replayed across a
12642
- * long render. The gateway binds the job to the payer address rather than
12643
- * to the signature, so a fresh signature from the same wallet is accepted
12644
- * and is never charged again.
13106
+ * Follow a 202 `{ id, poll_url }` to completion on a Solana media route
13107
+ * (image, music, video).
13108
+ *
13109
+ * **Polls prove ownership, they do not pay.** A Solana payment is a
13110
+ * transaction pinned to a recent blockhash (~60s), so paying every poll of a
13111
+ * multi-minute job meant signing a fresh transaction every few seconds. Each
13112
+ * poll here instead carries the `x-poll-*` headers: the submitting wallet's
13113
+ * ed25519 signature over `blockrun-poll:v1:<kind>:<job id>:<unix seconds>`
13114
+ * (see `poll-ownership.ts`). That proof never charges.
13115
+ *
13116
+ * **Who pays when:**
13117
+ * - image / music: the POST already settled (sol settles at submit because the
13118
+ * signed transaction would be dead by completion). The caller recorded the
13119
+ * cost at POST; this loop only fetches the result and never signs a payment.
13120
+ * A batch-billed image job still answers its proof poll with 402: that job
13121
+ * falls back to the per-poll payment below.
13122
+ * - video: settles on completion, and a failed job is never charged. A
13123
+ * finished, unpaid job answers the proof poll with 402 `{status:"completed"}`
13124
+ * and a normal x402 challenge; ONE payment is signed from that challenge and
13125
+ * the same poll is re-sent with it. The cost is recorded here, on delivery.
13126
+ * A paid poll the gateway answers 402 (settlement in progress on another
13127
+ * poll, outcome unknown, stale blockhash) is re-polled after its
13128
+ * Retry-After — the gateway's per-job settlement claim makes a later fresh
13129
+ * payment safe — up to `MAX_PAID_POLL_REFUSALS` times.
13130
+ *
13131
+ * **Fallback, once per job:** if the gateway refuses the proof with its "no
13132
+ * such job" answer (video 400, image/music 404) or answers it with a plain
13133
+ * payment challenge (a gateway or job that predates poll proofs), the rest of
13134
+ * this job is polled the old way — an x-payment signed per poll, which the
13135
+ * gateway verifies against the job's payer and does not charge until a video
13136
+ * completes. Logged to stderr; never switches back.
12645
13137
  */
12646
- async followSolanaJob(submitBody, budgetMs, intervalMs = 2e3) {
13138
+ async followSolanaJob(submitBody, budgetMs, kind, intervalMs = kind === "video" ? VIDEO_POLL_INTERVAL_MS : 2e3) {
12647
13139
  const id = submitBody.id;
12648
13140
  const pollPath = submitBody.poll_url;
12649
13141
  if (!id || !pollPath) return submitBody;
12650
- const pollUrl = pollPath.startsWith("http") ? pollPath : `${this.apiUrl}${pollPath.startsWith("/") ? "" : "/"}${pollPath}`;
13142
+ const pollUrl = this.resolvePollUrl(pollPath);
13143
+ const jobId = pollJobIdFromUrl(pollUrl);
12651
13144
  const deadline = Date.now() + budgetMs;
12652
13145
  let lastStatus = submitBody.status || "queued";
13146
+ let ownershipPolls = true;
13147
+ let waitMs = intervalMs;
13148
+ let paidRefusalsLeft = MAX_PAID_POLL_REFUSALS;
13149
+ let videoCostUsd;
13150
+ let settleAttempted = false;
13151
+ const fallBack = (why) => {
13152
+ ownershipPolls = false;
13153
+ waitMs = 0;
13154
+ console.warn(
13155
+ `[@blockrun/llm] ${kind} job ${id}: ${why}; falling back to x-payment polling for this job`
13156
+ );
13157
+ };
13158
+ const deliver = (data, response) => {
13159
+ if (kind === "video") {
13160
+ if (videoCostUsd !== void 0) this.recordSettlement(videoCostUsd);
13161
+ const txHash = response.headers.get("x-payment-receipt");
13162
+ if (txHash) data.txHash = txHash;
13163
+ }
13164
+ return data;
13165
+ };
13166
+ const failIfFailed = (data, status2) => {
13167
+ if (lastStatus === "failed") {
13168
+ throw new APIError(
13169
+ `Upstream job failed: ${data.error || "unknown"}`,
13170
+ status2,
13171
+ sanitizeErrorResponse(data)
13172
+ );
13173
+ }
13174
+ };
12653
13175
  while (Date.now() < deadline) {
12654
- await new Promise((r) => setTimeout(r, intervalMs));
12655
- const challenge = await this.fetchWithTimeout(pollUrl, {
12656
- method: "GET",
12657
- headers: { "User-Agent": USER_AGENT }
12658
- });
12659
- if (challenge.status === 200) {
12660
- const done = await challenge.json();
12661
- return done;
13176
+ if (waitMs > 0) await sleep3(Math.min(waitMs, Math.max(0, deadline - Date.now())));
13177
+ waitMs = intervalMs;
13178
+ let challenge;
13179
+ if (ownershipPolls) {
13180
+ const proof = signPollOwnership(await solanaKeyToBytes(this.privateKey), kind, jobId);
13181
+ const polled = await this.fetchWithTimeout(pollUrl, {
13182
+ method: "GET",
13183
+ headers: { "User-Agent": USER_AGENT, ...proof }
13184
+ });
13185
+ const data2 = await readJsonOrEmpty(polled);
13186
+ if (polled.status === 402) {
13187
+ if (kind === "video" && data2.status === "completed") {
13188
+ lastStatus = "completed";
13189
+ settleAttempted = true;
13190
+ challenge = new Response(JSON.stringify(data2), { status: 402, headers: polled.headers });
13191
+ } else {
13192
+ fallBack(
13193
+ 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)"
13194
+ );
13195
+ continue;
13196
+ }
13197
+ } else if (polled.status === (kind === "video" ? 400 : 404)) {
13198
+ fallBack(`the gateway refused the poll proof (HTTP ${polled.status})`);
13199
+ continue;
13200
+ } else {
13201
+ lastStatus = data2.status || lastStatus;
13202
+ failIfFailed(data2, polled.status);
13203
+ if (polled.status === 200 && lastStatus === "completed") return deliver(data2, polled);
13204
+ if (!isPendingPollStatus(polled.status)) {
13205
+ throw new APIError(`Poll failed: ${polled.status}`, polled.status, sanitizeErrorResponse(data2));
13206
+ }
13207
+ waitMs = retryAfterMs(polled) ?? intervalMs;
13208
+ continue;
13209
+ }
12662
13210
  }
12663
- if (challenge.status !== 402) {
12664
- let errorBody;
12665
- try {
12666
- errorBody = await challenge.json();
12667
- } catch {
12668
- errorBody = { error: "Poll failed" };
13211
+ if (!challenge) {
13212
+ const unpaid = await this.fetchWithTimeout(pollUrl, {
13213
+ method: "GET",
13214
+ headers: { "User-Agent": USER_AGENT }
13215
+ });
13216
+ if (unpaid.status === 200) {
13217
+ return deliver(await unpaid.json(), unpaid);
12669
13218
  }
12670
- throw new APIError(`Poll failed: ${challenge.status}`, challenge.status, sanitizeErrorResponse(errorBody));
13219
+ if (unpaid.status !== 402) {
13220
+ let errorBody;
13221
+ try {
13222
+ errorBody = await unpaid.json();
13223
+ } catch {
13224
+ errorBody = { error: "Poll failed" };
13225
+ }
13226
+ throw new APIError(`Poll failed: ${unpaid.status}`, unpaid.status, sanitizeErrorResponse(errorBody));
13227
+ }
13228
+ challenge = unpaid;
12671
13229
  }
12672
- const { paymentPayload } = await this.signPaymentFrom402(
13230
+ const { paymentPayload, costUsd } = await this.signPaymentFrom402(
12673
13231
  pollUrl,
12674
13232
  challenge,
12675
13233
  true,
12676
13234
  // always a fresh blockhash; the last one is stale by now
12677
13235
  pollUrl
12678
13236
  );
12679
- const paid = await this.fetchWithTimeout(pollUrl, {
12680
- method: "GET",
12681
- headers: { "User-Agent": USER_AGENT, "PAYMENT-SIGNATURE": paymentPayload }
12682
- });
12683
- let data = {};
13237
+ if (kind === "video") videoCostUsd = costUsd;
13238
+ let paid;
12684
13239
  try {
12685
- data = await paid.json();
12686
- } catch {
13240
+ paid = await this.fetchWithTimeout(pollUrl, {
13241
+ method: "GET",
13242
+ headers: { "User-Agent": USER_AGENT, "PAYMENT-SIGNATURE": paymentPayload }
13243
+ });
13244
+ } catch (error) {
13245
+ if (kind === "video" && lastStatus === "completed") settleAttempted = true;
13246
+ if (--paidRefusalsLeft <= 0) {
13247
+ throw withDisposition(
13248
+ new PaymentError(
13249
+ `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.`
13250
+ ),
13251
+ "paid-or-in-doubt"
13252
+ );
13253
+ }
13254
+ continue;
12687
13255
  }
13256
+ const data = await readJsonOrEmpty(paid);
12688
13257
  lastStatus = data.status || lastStatus;
12689
- if (lastStatus === "failed") {
12690
- throw new APIError(
12691
- `Upstream job failed: ${data.error || "unknown"}`,
12692
- paid.status,
12693
- sanitizeErrorResponse(data)
12694
- );
12695
- }
12696
- if (paid.status === 200 && lastStatus === "completed") {
12697
- return data;
13258
+ failIfFailed(data, paid.status);
13259
+ if (paid.status === 200 && lastStatus === "completed") return deliver(data, paid);
13260
+ if (paid.status === 402 && kind === "video") {
13261
+ if (data.error === "Payment verification failed" && !await isSafeStaleBlockhashResponse(new Response(JSON.stringify(data), { status: 402 }))) {
13262
+ throw new PaymentError(
13263
+ `Payment for the finished video was rejected. Check your Solana USDC balance, then resume the job by polling ${pollUrl} with the same wallet.`
13264
+ );
13265
+ }
13266
+ settleAttempted = true;
13267
+ if (--paidRefusalsLeft <= 0) {
13268
+ throw withDisposition(
13269
+ new PaymentError(
13270
+ `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.`
13271
+ ),
13272
+ "paid-or-in-doubt"
13273
+ );
13274
+ }
13275
+ waitMs = retryAfterMs(paid) ?? intervalMs;
13276
+ continue;
12698
13277
  }
12699
- if (paid.status !== 200 && paid.status !== 202 && paid.status !== 504) {
13278
+ if (!isPendingPollStatus(paid.status) && paid.status !== 200) {
12700
13279
  throw new APIError(`Poll failed: ${paid.status}`, paid.status, sanitizeErrorResponse(data));
12701
13280
  }
13281
+ waitMs = retryAfterMs(paid) ?? intervalMs;
12702
13282
  }
13283
+ 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.";
12703
13284
  throw new APIError(
12704
- `Job did not complete within ${Math.round(budgetMs / 1e3)}s (last status: ${lastStatus}). No payment was taken.`,
13285
+ `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.`,
12705
13286
  504,
12706
- { id, last_status: lastStatus }
13287
+ { id, last_status: lastStatus, poll_url: pollUrl }
12707
13288
  );
12708
13289
  }
13290
+ /**
13291
+ * Absolute URL for a job's `poll_url`. The gateway returns `/api/v1/...`;
13292
+ * {@link buildUrl} drops the `/api` that `apiUrl` already ends with. An
13293
+ * absolute poll_url must be on the gateway's own origin: the poll carries a
13294
+ * wallet signature (and, for a finished video, a payment).
13295
+ */
13296
+ resolvePollUrl(pollPath) {
13297
+ if (!/^https?:\/\//i.test(pollPath)) return this.buildUrl(pollPath);
13298
+ if (new URL(pollPath).origin !== new URL(this.apiUrl).origin) {
13299
+ throw new APIError(`poll_url is not on the gateway origin: ${pollPath}`, 502, { poll_url: pollPath });
13300
+ }
13301
+ return pollPath;
13302
+ }
13303
+ /**
13304
+ * Turn a `402` into a signed Solana payment payload.
13305
+ *
13306
+ * Extracted because four call sites need it — chat, the raw POST helpers, the
13307
+ * raw GET helper, and {@link SolanaLLMClient.stream} — and it had been
13308
+ * written out three times before this. That mattered more than ordinary
13309
+ * duplication: this is the code that signs a transfer of the caller's USDC,
13310
+ * so three copies meant every fix to it had to be applied three times or
13311
+ * quietly apply to two thirds of the paths.
13312
+ *
13313
+ * @param url - the request being paid for.
13314
+ * @param response - the gateway's `402`, not yet consumed.
13315
+ * @param forceFreshBlockhash - set on a re-sign after a stale-blockhash
13316
+ * rejection, so the retry cannot produce byte-identical transaction bytes.
13317
+ * @param resourceFallback - resource URL to claim when the 402 states none.
13318
+ * @returns the header value to replay with, and what it will settle for.
13319
+ * @throws PaymentError when the 402 carries no usable Solana requirements.
13320
+ */
12709
13321
  async signPaymentFrom402(url, response, forceFreshBlockhash, resourceFallback = url) {
12710
13322
  return this.signExactPayment(
12711
13323
  url,
@@ -12746,7 +13358,7 @@ var SolanaLLMClient = class {
12746
13358
  if (!feePayer) throw new PaymentError("Missing feePayer in 402 extra field");
12747
13359
  const fromAddress = await this.getWalletAddress();
12748
13360
  const secretKey = await solanaKeyToBytes(this.privateKey);
12749
- const extensions = paymentRequired.extensions;
13361
+ const extensions = withPaymentIdentifier(paymentRequired).extensions;
12750
13362
  const paymentPayload = await createSolanaPaymentPayload(
12751
13363
  secretKey,
12752
13364
  fromAddress,
@@ -12769,7 +13381,7 @@ var SolanaLLMClient = class {
12769
13381
  );
12770
13382
  return { paymentPayload, costUsd: parseFloat(details.amount) / 1e6 };
12771
13383
  }
12772
- async handlePaymentAndRetry(url, body, paymentRequired, forceFreshBlockhash = false) {
13384
+ async handlePaymentAndRetry(url, json, paymentRequired, forceFreshBlockhash = false, timeoutMs) {
12773
13385
  let signed;
12774
13386
  try {
12775
13387
  signed = await this.signExactPayment(url, paymentRequired, forceFreshBlockhash, `${this.apiUrl}/v1/chat/completions`);
@@ -12786,26 +13398,28 @@ var SolanaLLMClient = class {
12786
13398
  "User-Agent": USER_AGENT,
12787
13399
  "PAYMENT-SIGNATURE": paymentPayload
12788
13400
  },
12789
- body: JSON.stringify(body)
12790
- });
13401
+ body: json
13402
+ }, timeoutMs);
12791
13403
  } catch (error) {
12792
13404
  throw withDisposition(error, "paid-or-in-doubt");
12793
13405
  }
12794
13406
  await this.assertPaid(retryResponse);
12795
13407
  this.recordSettlement(costUsd);
12796
- return this.readPaidJson(retryResponse);
13408
+ const paid = await this.readPaidJson(retryResponse);
13409
+ return withCost(paid, retryResponse, costUsd, "exact");
12797
13410
  }
12798
- async requestWithPaymentRaw(endpoint, body) {
13411
+ async requestWithPaymentRaw(endpoint, body, media = {}) {
12799
13412
  const url = `${this.apiUrl}${endpoint}`;
13413
+ const pollBudgetMs = media.pollBudgetMs ?? this.timeout;
12800
13414
  for (let staleRetries = 0; ; ) {
12801
13415
  const response = await this.fetchWithTimeout(url, {
12802
13416
  method: "POST",
12803
13417
  headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT },
12804
13418
  body: JSON.stringify(body)
12805
- });
13419
+ }, media.submitTimeoutMs);
12806
13420
  if (response.status === 402) {
12807
13421
  try {
12808
- return await this.handlePaymentAndRetryRaw(url, body, response, staleRetries > 0);
13422
+ return await this.handlePaymentAndRetryRaw(url, body, response, staleRetries > 0, media);
12809
13423
  } catch (error) {
12810
13424
  if (!(error instanceof SafeStaleBlockhashError) || staleRetries >= STALE_BLOCKHASH_RETRY_BACKOFFS_MS.length) throw error;
12811
13425
  await waitForStaleRetry(staleRetries++);
@@ -12821,26 +13435,38 @@ var SolanaLLMClient = class {
12821
13435
  }
12822
13436
  throw new APIError(`API error: ${response.status}`, response.status, sanitizeErrorResponse(errorBody));
12823
13437
  }
12824
- if (this.apiAuth && endpoint.startsWith("/v1/images/")) return this.apiAuth.poll(response, this.timeout, 2e3);
13438
+ if (this.apiAuth && /^\/v1\/(images|videos|audio)\//.test(endpoint)) {
13439
+ return this.apiAuth.poll(
13440
+ response,
13441
+ pollBudgetMs,
13442
+ endpoint.startsWith("/v1/videos/") ? VIDEO_POLL_INTERVAL_MS : 2e3
13443
+ );
13444
+ }
12825
13445
  return response.json();
12826
13446
  }
12827
13447
  }
12828
- async handlePaymentAndRetryRaw(url, body, response, forceFreshBlockhash = false) {
13448
+ async handlePaymentAndRetryRaw(url, body, response, forceFreshBlockhash = false, media = {}) {
12829
13449
  const { paymentPayload, costUsd } = await this.signPaymentFrom402(url, response, forceFreshBlockhash);
12830
- const retryResponse = await this.fetchWithTimeout(url, {
12831
- method: "POST",
12832
- headers: {
12833
- "Content-Type": "application/json",
12834
- "User-Agent": USER_AGENT,
12835
- "PAYMENT-SIGNATURE": paymentPayload
12836
- },
12837
- body: JSON.stringify(body)
12838
- });
13450
+ let retryResponse;
13451
+ try {
13452
+ retryResponse = await this.fetchWithTimeout(url, {
13453
+ method: "POST",
13454
+ headers: {
13455
+ "Content-Type": "application/json",
13456
+ "User-Agent": USER_AGENT,
13457
+ "PAYMENT-SIGNATURE": paymentPayload
13458
+ },
13459
+ body: JSON.stringify(body)
13460
+ }, media.submitTimeoutMs);
13461
+ } catch (error) {
13462
+ throw withDisposition(error, "paid-or-in-doubt");
13463
+ }
12839
13464
  await this.assertPaid(retryResponse);
12840
13465
  if (retryResponse.status === 202) {
12841
- this.recordSettlement(costUsd);
13466
+ const kind = pollKindFor(url);
13467
+ if (kind !== "video") this.recordSettlement(costUsd);
12842
13468
  const submitted = await retryResponse.json();
12843
- return this.followSolanaJob(submitted, this.timeout);
13469
+ return this.followSolanaJob(submitted, media.pollBudgetMs ?? this.timeout, kind);
12844
13470
  }
12845
13471
  this.recordSettlement(costUsd);
12846
13472
  return retryResponse.json();
@@ -12929,9 +13555,9 @@ var SolanaLLMClient = class {
12929
13555
  this.sessionCalls += 1;
12930
13556
  this.sessionTotalUsd += costUsd;
12931
13557
  }
12932
- async fetchWithTimeout(url, options) {
13558
+ async fetchWithTimeout(url, options, timeoutMs = this.timeout) {
12933
13559
  const controller = new AbortController();
12934
- const timeoutId = setTimeout(() => controller.abort(), this.timeout);
13560
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
12935
13561
  try {
12936
13562
  return await (this.apiAuth ? this.apiAuth.fetch.bind(this.apiAuth) : fetch)(url, { ...options, signal: controller.signal });
12937
13563
  } finally {
@@ -13496,6 +14122,7 @@ var AnthropicClient = class {
13496
14122
  BatchPaymentUnresolvedError,
13497
14123
  BlockrunClient,
13498
14124
  BlockrunError,
14125
+ DEFAULT_BATCH_IN_DOUBT,
13499
14126
  DEFAULT_BATCH_RATE_LIMIT,
13500
14127
  EVM_NETWORKS,
13501
14128
  ImageClient,
@@ -13558,6 +14185,7 @@ var AnthropicClient = class {
13558
14185
  loadWallet,
13559
14186
  logCost,
13560
14187
  parsePaymentRequired,
14188
+ readSettlement,
13561
14189
  retryDisposition,
13562
14190
  saveSolanaWallet,
13563
14191
  saveToCache,
@@ -13572,6 +14200,8 @@ var AnthropicClient = class {
13572
14200
  solanaKeyToBytes,
13573
14201
  solanaPublicKey,
13574
14202
  status,
14203
+ toWireSearchParameters,
14204
+ toWireTools,
13575
14205
  validateMaxTokens,
13576
14206
  validateModel,
13577
14207
  validateTemperature,