lightnode-sdk 0.3.2 → 0.4.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/inference.js CHANGED
@@ -126,3 +126,296 @@ export async function decryptResponse(sessionKey, ciphertext) {
126
126
  export { consumerGatewayUrl, GatewayClient } from "./gateway.js";
127
127
  /** Optional helper: generate the caller's own ECDH keypair if they want one (e.g. acting as the disputer). */
128
128
  export { generateEcdhKeyPair };
129
+ // ----------------------------------------------------------------------------
130
+ // runInference - one call, full flow.
131
+ //
132
+ // Turns the seven-stage protocol (auth -> prepare -> createSession -> open relay
133
+ // -> uploadBlob -> submitJob -> stream + decrypt -> wait JobCompleted) into a
134
+ // single async call. Supports:
135
+ //
136
+ // - onChunk callback for live streaming to a UI / stdout
137
+ // - maxRetries auto-retry on StalledWorkerError (default 2)
138
+ // - WebSocket inject a constructor (Node: `ws`. Browser: omit and
139
+ // globalThis.WebSocket is used.)
140
+ //
141
+ // This is the API a builder should reach for first. The lower-level helpers
142
+ // (prepareSession, submitPrompt, decryptResponse) are still exported for
143
+ // builders who want to do something the orchestrator doesn't cover (e.g.
144
+ // reuse a session across multiple prompts, custom retry policy).
145
+ // ----------------------------------------------------------------------------
146
+ import { StalledWorkerError, OnChainRevertError, RelayTokenTimeoutError } from "./errors.js";
147
+ const JOB_REGISTRY_ABI_PARSED = [
148
+ {
149
+ type: "function",
150
+ name: "createSession",
151
+ stateMutability: "payable",
152
+ inputs: [
153
+ { name: "paramsHash", type: "bytes32" },
154
+ { name: "worker", type: "address" },
155
+ { name: "encWorkerKey", type: "bytes" },
156
+ { name: "ephemeralPubKey", type: "bytes" },
157
+ { name: "initState", type: "bytes" },
158
+ { name: "expiry", type: "uint256" },
159
+ ],
160
+ outputs: [{ name: "sessionId", type: "uint256" }],
161
+ },
162
+ {
163
+ type: "function",
164
+ name: "submitJob",
165
+ stateMutability: "payable",
166
+ inputs: [
167
+ { name: "sessionId", type: "uint256" },
168
+ { name: "promptHash", type: "bytes32" },
169
+ ],
170
+ outputs: [{ name: "jobId", type: "uint256" }],
171
+ },
172
+ ];
173
+ // Pre-computed topic hashes for the three events we listen for.
174
+ // keccak256("SessionCreated(uint256,address,bytes32,address,bytes,bytes)")
175
+ const SESSION_CREATED_TOPIC = "0xedf9fab204f0bb366f5b33ff07f441f4e387a833e86bfe1364a42ae2c7e05d73";
176
+ // keccak256("JobSubmitted(uint256,uint256,address)")
177
+ const JOB_SUBMITTED_TOPIC = "0xfb47370368875d7490803c5653d9496d0a3c5e1b49a17f013ec37abd9d86d356";
178
+ // keccak256("JobCompleted(uint256,address,bytes32,bytes32)")
179
+ const JOB_COMPLETED_TOPIC = "0xdb545db74bae046337ed01971cf61569fd1a1460ff8ed511ab19ceaac1326377";
180
+ function pickWebSocket(provided) {
181
+ if (provided)
182
+ return provided;
183
+ const g = globalThis.WebSocket;
184
+ if (!g) {
185
+ throw new Error("no WebSocket available - either run in a browser or pass { WebSocket: require('ws') }");
186
+ }
187
+ return g;
188
+ }
189
+ function topicAsUint(hex) {
190
+ return BigInt(hex);
191
+ }
192
+ async function runOneAttempt(args, attempt) {
193
+ const { prompt, gateway, wallet, publicClient, network, model = "llama3-8b", onChunk, jobCompletedTimeoutMs = 90000, } = args;
194
+ const WS = pickWebSocket(args.WebSocket);
195
+ const relayUrl = args.relayUrl ?? `wss://relay.${network.id}.lightchain.ai/ws`;
196
+ // 1. prepareSession
197
+ const prepared = await prepareSession(gateway, model);
198
+ const fee = await estimateJobFee(network, model);
199
+ // 2. createSession on-chain
200
+ const createTx = await wallet.writeContract({
201
+ address: network.jobRegistry,
202
+ abi: JOB_REGISTRY_ABI_PARSED,
203
+ functionName: "createSession",
204
+ args: [
205
+ prepared.createSessionArgs.paramsHash,
206
+ prepared.createSessionArgs.worker,
207
+ prepared.createSessionArgs.encWorkerKey,
208
+ prepared.createSessionArgs.ephemeralPubKey,
209
+ prepared.createSessionArgs.initState,
210
+ prepared.createSessionArgs.expiry,
211
+ ],
212
+ gas: 1000000n,
213
+ });
214
+ const createReceipt = await publicClient.waitForTransactionReceipt({ hash: createTx });
215
+ if (createReceipt.status !== "success")
216
+ throw new OnChainRevertError("createSession", createTx);
217
+ const createLog = (await publicClient.getLogs({ address: network.jobRegistry, blockHash: createReceipt.blockHash })).find((l) => l.transactionHash === createTx && l.topics[0] === SESSION_CREATED_TOPIC);
218
+ if (!createLog)
219
+ throw new Error("SessionCreated log missing in createSession receipt");
220
+ const sessionId = topicAsUint(createLog.topics[1]);
221
+ // 3. relay token + WebSocket
222
+ let relayToken;
223
+ for (let i = 0; i < 30 && !relayToken; i++) {
224
+ const r = await gateway.getSessionToken(Number(sessionId));
225
+ if ("token" in r && r.token)
226
+ relayToken = r.token;
227
+ else
228
+ await new Promise((res) => setTimeout(res, 1000));
229
+ }
230
+ if (!relayToken)
231
+ throw new RelayTokenTimeoutError();
232
+ const ws = new WS(`${relayUrl}?token=${encodeURIComponent(relayToken)}`);
233
+ try {
234
+ ws.binaryType = "arraybuffer";
235
+ }
236
+ catch {
237
+ /* not a browser-style WS; ignore */
238
+ }
239
+ // Wait for open, supporting both browser (addEventListener) and Node ws (once).
240
+ await new Promise((resolve, reject) => {
241
+ const onOpen = () => resolve();
242
+ const onError = (e) => reject(e instanceof Error ? e : new Error("WebSocket open failed"));
243
+ if (ws.once) {
244
+ ws.once("open", onOpen);
245
+ ws.once("error", onError);
246
+ }
247
+ else if (ws.addEventListener) {
248
+ ws.addEventListener("open", onOpen, { once: true });
249
+ ws.addEventListener("error", onError, { once: true });
250
+ }
251
+ else {
252
+ reject(new Error("WebSocket has neither once nor addEventListener"));
253
+ }
254
+ setTimeout(() => reject(new Error("relay WebSocket open timeout")), 20000);
255
+ });
256
+ const chunks = [];
257
+ const handleMessage = async (rawData) => {
258
+ const raw = typeof rawData === "string"
259
+ ? rawData
260
+ : rawData instanceof ArrayBuffer
261
+ ? new TextDecoder().decode(rawData)
262
+ : typeof rawData.toString === "function"
263
+ ? rawData.toString()
264
+ : "";
265
+ let frame;
266
+ try {
267
+ frame = JSON.parse(raw);
268
+ }
269
+ catch {
270
+ return;
271
+ }
272
+ if (!frame?.payload)
273
+ return;
274
+ if (frame.type === "chunk") {
275
+ try {
276
+ const piece = await decryptResponse(prepared.sessionKey, frame.payload);
277
+ chunks.push(piece);
278
+ if (onChunk)
279
+ onChunk(piece, chunks.join(""));
280
+ }
281
+ catch {
282
+ /* control frame */
283
+ }
284
+ }
285
+ else if (frame.type === "complete" && chunks.length === 0) {
286
+ try {
287
+ const piece = await decryptResponse(prepared.sessionKey, frame.payload);
288
+ chunks.push(piece);
289
+ if (onChunk)
290
+ onChunk(piece, chunks.join(""));
291
+ }
292
+ catch {
293
+ /* ignore */
294
+ }
295
+ }
296
+ };
297
+ if (ws.on) {
298
+ ws.on("message", handleMessage);
299
+ }
300
+ else if (ws.addEventListener) {
301
+ ws.addEventListener("message", (ev) => handleMessage(ev.data));
302
+ }
303
+ // 4. encrypt + upload prompt
304
+ const promptHash = await submitPrompt(gateway, prepared.sessionKey, prompt);
305
+ // 5. submitJob on-chain
306
+ const submitTx = await wallet.writeContract({
307
+ address: network.jobRegistry,
308
+ abi: JOB_REGISTRY_ABI_PARSED,
309
+ functionName: "submitJob",
310
+ args: [sessionId, promptHash],
311
+ value: BigInt(Math.round(fee * 1e18)),
312
+ gas: 500000n,
313
+ });
314
+ const submitReceipt = await publicClient.waitForTransactionReceipt({ hash: submitTx });
315
+ if (submitReceipt.status !== "success") {
316
+ try {
317
+ ws.close();
318
+ }
319
+ catch {
320
+ /* ignore */
321
+ }
322
+ throw new OnChainRevertError("submitJob", submitTx);
323
+ }
324
+ const jobLog = (await publicClient.getLogs({ address: network.jobRegistry, blockHash: submitReceipt.blockHash })).find((l) => l.transactionHash === submitTx && l.topics[0] === JOB_SUBMITTED_TOPIC);
325
+ if (!jobLog)
326
+ throw new Error("JobSubmitted log missing in submitJob receipt");
327
+ const jobId = topicAsUint(jobLog.topics[1]);
328
+ // 6. wait for JobCompleted
329
+ const deadline = Date.now() + jobCompletedTimeoutMs;
330
+ const jobIdTopic = (`0x${jobId.toString(16).padStart(64, "0")}`);
331
+ let completed = null;
332
+ while (!completed && Date.now() < deadline) {
333
+ await new Promise((res) => setTimeout(res, 3000));
334
+ const logs = await publicClient.getLogs({
335
+ address: network.jobRegistry,
336
+ fromBlock: submitReceipt.blockNumber,
337
+ toBlock: "latest",
338
+ });
339
+ completed =
340
+ logs.find((l) => l.topics[0] === JOB_COMPLETED_TOPIC && l.topics[1] === jobIdTopic) ?? null;
341
+ }
342
+ if (!completed) {
343
+ try {
344
+ ws.close();
345
+ }
346
+ catch {
347
+ /* ignore */
348
+ }
349
+ throw new StalledWorkerError({
350
+ jobId,
351
+ worker: prepared.createSessionArgs.worker,
352
+ submitTx,
353
+ feeLcai: fee,
354
+ });
355
+ }
356
+ // 7. grace period for the last relay frame, then close
357
+ await new Promise((res) => setTimeout(res, 4000));
358
+ try {
359
+ ws.close();
360
+ }
361
+ catch {
362
+ /* ignore */
363
+ }
364
+ return {
365
+ answer: chunks.join(""),
366
+ txs: { createSession: createTx, submitJob: submitTx, jobCompleted: completed.transactionHash },
367
+ worker: prepared.createSessionArgs.worker,
368
+ sessionId,
369
+ jobId,
370
+ attempts: attempt,
371
+ stalled: [],
372
+ };
373
+ }
374
+ /**
375
+ * One call, full encrypted inference. Same code path the live playground at
376
+ * lightnode.app/playground drives, condensed into a single function.
377
+ *
378
+ * @example
379
+ * ```ts
380
+ * import { LightNode, runInference, GatewayClient } from "lightnode-sdk";
381
+ * import { createPublicClient, createWalletClient, http } from "viem";
382
+ * import { privateKeyToAccount } from "viem/accounts";
383
+ * import WS from "ws";
384
+ *
385
+ * const ln = new LightNode("testnet");
386
+ * const wallet = createWalletClient({ account: privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`), transport: http(ln.network.rpc) });
387
+ * const publicClient = createPublicClient({ transport: http(ln.network.rpc) });
388
+ * const gateway = new GatewayClient({ network: "testnet", bearer: await getJwt() });
389
+ *
390
+ * const { answer, txs } = await runInference({
391
+ * prompt: "Reply with a one-sentence fun fact about the ocean.",
392
+ * gateway, wallet, publicClient, network: ln.network,
393
+ * WebSocket: WS, // omit in the browser
394
+ * onChunk: (chunk) => process.stdout.write(chunk),
395
+ * maxRetries: 2,
396
+ * });
397
+ *
398
+ * console.log("\n", txs);
399
+ * ```
400
+ */
401
+ export async function runInference(args) {
402
+ const maxRetries = args.maxRetries ?? 2;
403
+ const stalled = [];
404
+ for (let attempt = 1; attempt <= maxRetries + 1; attempt++) {
405
+ try {
406
+ const result = await runOneAttempt(args, attempt);
407
+ return { ...result, stalled };
408
+ }
409
+ catch (err) {
410
+ if (err instanceof StalledWorkerError && attempt <= maxRetries) {
411
+ stalled.push({ jobId: err.jobId, worker: err.worker, submitTx: err.submitTx });
412
+ continue;
413
+ }
414
+ throw err;
415
+ }
416
+ }
417
+ // Unreachable - the loop either returns or throws.
418
+ throw new StalledWorkerError({ jobId: 0n, worker: "0x0000000000000000000000000000000000000000", submitTx: "0x", feeLcai: 0 });
419
+ }
420
+ /** Re-export the typed errors at this layer so a single import covers everything. */
421
+ export { StalledWorkerError, OnChainRevertError, RelayTokenTimeoutError, GatewayAuthError, isStalledWorker } from "./errors.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lightnode-sdk",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Read-only TypeScript client for LightChain AI: workers, jobs, models, on-chain registration, and per-model network analytics. Independent, community-built (not an official LightChain package).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",