lightnode-sdk 0.3.1 → 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/README.md +38 -0
- package/dist/add.d.ts +51 -0
- package/dist/add.js +935 -0
- package/dist/cli.js +87 -8
- package/dist/errors.d.ts +55 -0
- package/dist/errors.js +64 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +5 -2
- package/dist/inference.d.ts +130 -0
- package/dist/inference.js +293 -0
- package/package.json +1 -1
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
|
+
"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",
|