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