lightnode-sdk 0.4.7 → 0.4.9
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/cli.js +98 -1
- package/dist/crypto.d.ts +26 -19
- package/dist/crypto.js +90 -83
- package/dist/index.d.ts +4 -4
- package/dist/index.js +5 -3
- package/dist/inference.d.ts +34 -0
- package/dist/inference.js +107 -6
- package/package.json +3 -1
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { LightNode, modelStatsCsv, workerStatsCsv, workerJobsCsv } from "./index.js";
|
|
2
|
+
import { LightNode, modelStatsCsv, workerStatsCsv, workerJobsCsv, runInferenceWithKey, isStalledWorker } from "./index.js";
|
|
3
3
|
import { addInference, addAnalyticsDashboard, addNftMint, addChat, addAgent } from "./add.js";
|
|
4
|
+
import { createPublicClient, http, parseEther } from "viem";
|
|
5
|
+
import { privateKeyToAccount, generatePrivateKey } from "viem/accounts";
|
|
4
6
|
function flag(name) {
|
|
5
7
|
const i = process.argv.indexOf(name);
|
|
6
8
|
return i >= 0 ? process.argv[i + 1] : undefined;
|
|
@@ -17,6 +19,16 @@ const lcai = (wei) => (wei ? Number(BigInt(wei)) / 1e18 : 0);
|
|
|
17
19
|
const rate = (r) => (r == null ? "-" : `${Math.round(r * 100)}%`);
|
|
18
20
|
const HELP = `lightnode <command> [--net mainnet|testnet]
|
|
19
21
|
|
|
22
|
+
Run one inference (needs PRIVATE_KEY in env):
|
|
23
|
+
chat <prompt> stream one encrypted inference answer to stdout
|
|
24
|
+
([--model llama3-8b] [--key 0x...])
|
|
25
|
+
|
|
26
|
+
Wallet helpers:
|
|
27
|
+
wallet new generate a fresh testnet key, print it
|
|
28
|
+
wallet address print the address of PRIVATE_KEY
|
|
29
|
+
wallet balance [--net] print LCAI balance for PRIVATE_KEY's address
|
|
30
|
+
|
|
31
|
+
Read-only network commands (no key):
|
|
20
32
|
network network summary (workers, jobs, models, earnings)
|
|
21
33
|
models registered models + per-job fee
|
|
22
34
|
worker <addr> a worker: on-chain registration + recent jobs
|
|
@@ -26,6 +38,7 @@ const HELP = `lightnode <command> [--net mainnet|testnet]
|
|
|
26
38
|
analytics [--csv] per-model performance (completion, p50/p95, incomplete)
|
|
27
39
|
reliability [--csv] per-worker reliability, busiest first
|
|
28
40
|
|
|
41
|
+
Scaffold templates into the current project:
|
|
29
42
|
add inference end-to-end encrypted inference route/script
|
|
30
43
|
add chat chat-style UI with conversation history
|
|
31
44
|
add agent scheduled/loop inference (cron-style)
|
|
@@ -34,9 +47,93 @@ const HELP = `lightnode <command> [--net mainnet|testnet]
|
|
|
34
47
|
(all add commands: [--template auto|nextjs-api|hono|node] [--force])
|
|
35
48
|
|
|
36
49
|
To scaffold a new project instead, run: npm create lightnode-app my-app`;
|
|
50
|
+
function pickKey() {
|
|
51
|
+
const k = flag("--key") ?? process.env.PRIVATE_KEY;
|
|
52
|
+
if (!k || !k.startsWith("0x") || k.length !== 66) {
|
|
53
|
+
die("set PRIVATE_KEY=0x... in your env, or pass --key 0x... (need a funded EVM key)");
|
|
54
|
+
}
|
|
55
|
+
return k;
|
|
56
|
+
}
|
|
37
57
|
async function main() {
|
|
38
58
|
const ln = new LightNode(net);
|
|
39
59
|
switch (cmd) {
|
|
60
|
+
case "chat": {
|
|
61
|
+
// One-shot encrypted inference straight from the CLI. Pipe the prompt as
|
|
62
|
+
// positional args (or read from stdin if there are none) so this composes
|
|
63
|
+
// with shell scripts: `cat doc.md | lightnode chat` works.
|
|
64
|
+
const inlinePrompt = positionals.slice(1).join(" ").trim();
|
|
65
|
+
const prompt = inlinePrompt ||
|
|
66
|
+
(await new Promise((resolve) => {
|
|
67
|
+
let buf = "";
|
|
68
|
+
process.stdin.setEncoding("utf8");
|
|
69
|
+
process.stdin.on("data", (d) => (buf += d));
|
|
70
|
+
process.stdin.on("end", () => resolve(buf.trim()));
|
|
71
|
+
}));
|
|
72
|
+
if (!prompt)
|
|
73
|
+
die("usage: lightnode chat <prompt> (or pipe the prompt to stdin)");
|
|
74
|
+
const model = flag("--model") ?? "llama3-8b";
|
|
75
|
+
const privateKey = pickKey();
|
|
76
|
+
try {
|
|
77
|
+
const { answer, txs, worker, jobId } = await runInferenceWithKey({
|
|
78
|
+
network: net,
|
|
79
|
+
privateKey,
|
|
80
|
+
prompt,
|
|
81
|
+
model,
|
|
82
|
+
onChunk: (chunk) => process.stdout.write(chunk),
|
|
83
|
+
});
|
|
84
|
+
process.stdout.write("\n");
|
|
85
|
+
// Tiny one-liner trailer so the receipt is reachable without burying
|
|
86
|
+
// the answer. JSON is grep-friendly for shell pipelines.
|
|
87
|
+
const explorer = ln.network.explorer;
|
|
88
|
+
process.stderr.write(JSON.stringify({
|
|
89
|
+
chars: answer.length,
|
|
90
|
+
worker,
|
|
91
|
+
jobId: jobId.toString(),
|
|
92
|
+
createSession: `${explorer}/tx/${txs.createSession}`,
|
|
93
|
+
submitJob: `${explorer}/tx/${txs.submitJob}`,
|
|
94
|
+
jobCompleted: txs.jobCompleted ? `${explorer}/tx/${txs.jobCompleted}` : null,
|
|
95
|
+
}) + "\n");
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
if (isStalledWorker(e))
|
|
99
|
+
die("3 workers stalled in a row. Protocol refunds the fees; try again later.");
|
|
100
|
+
die("inference failed: " + e.message);
|
|
101
|
+
}
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
case "wallet": {
|
|
105
|
+
const sub = positionals[1];
|
|
106
|
+
if (sub === "new") {
|
|
107
|
+
// Fresh testnet-shaped key. Plain stdout output so it's copy-pasteable
|
|
108
|
+
// out of a script: `lightnode wallet new --quiet | head -1` works.
|
|
109
|
+
const pk = generatePrivateKey();
|
|
110
|
+
const addr = privateKeyToAccount(pk).address;
|
|
111
|
+
console.log(`PRIVATE_KEY=${pk}`);
|
|
112
|
+
console.error(`# address: ${addr}`);
|
|
113
|
+
console.error(`# fund at https://lightfaucet.ai before running paid commands`);
|
|
114
|
+
}
|
|
115
|
+
else if (sub === "address") {
|
|
116
|
+
const pk = pickKey();
|
|
117
|
+
console.log(privateKeyToAccount(pk).address);
|
|
118
|
+
}
|
|
119
|
+
else if (sub === "balance") {
|
|
120
|
+
const pk = pickKey();
|
|
121
|
+
const addr = privateKeyToAccount(pk).address;
|
|
122
|
+
const pub = createPublicClient({ transport: http(ln.network.rpc) });
|
|
123
|
+
const bal = await pub.getBalance({ address: addr });
|
|
124
|
+
const lcaiVal = Number(bal) / 1e18;
|
|
125
|
+
console.log(`${lcaiVal} LCAI`);
|
|
126
|
+
if (bal < parseEther("0.05")) {
|
|
127
|
+
console.error(`# under 0.05 LCAI - too low to run one inference`);
|
|
128
|
+
if (net === "testnet")
|
|
129
|
+
console.error(`# get free testnet LCAI: https://lightfaucet.ai`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
die("usage: lightnode wallet <new|address|balance> [--net testnet|mainnet]");
|
|
134
|
+
}
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
40
137
|
case "network": {
|
|
41
138
|
console.log(JSON.stringify(await ln.getNetworkAnalytics(), null, 2));
|
|
42
139
|
break;
|
package/dist/crypto.d.ts
CHANGED
|
@@ -5,43 +5,50 @@
|
|
|
5
5
|
* The format here MUST match the workers' Go implementation byte for byte,
|
|
6
6
|
* which is what the `lcai-chat-v2` reference client also targets:
|
|
7
7
|
* - ECDH P-256 key exchange.
|
|
8
|
-
* - Raw shared secret used DIRECTLY as the AES-256 key (no HKDF).
|
|
8
|
+
* - Raw shared secret X coordinate used DIRECTLY as the AES-256 key (no HKDF).
|
|
9
9
|
* - AES-GCM ciphertext layout: nonce(12) || ciphertext || tag(16).
|
|
10
10
|
* - Encrypted session key layout: ephemeralPub(65) || nonce(12) || ciphertext || tag(16).
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
12
|
+
* Implementation note: this module used to be built on Web Crypto (subtle.*).
|
|
13
|
+
* That broke in environments where the runtime has a partial Web Crypto
|
|
14
|
+
* implementation - notably StackBlitz / Bolt WebContainer, where
|
|
15
|
+
* subtle.generateKey({name:"ECDH",namedCurve:"P-256"}) throws "Unsupported".
|
|
16
|
+
* The flow is now backed by @noble/curves (P-256) and @noble/ciphers (AES-GCM)
|
|
17
|
+
* which are pure-JS and identical across every runtime. The only Web Crypto
|
|
18
|
+
* surface left is getRandomValues, which is reliable everywhere (when missing
|
|
19
|
+
* we fall back to node:crypto).
|
|
17
20
|
*/
|
|
21
|
+
export interface EcdhKeyPair {
|
|
22
|
+
privateKey: Uint8Array;
|
|
23
|
+
publicKey: Uint8Array;
|
|
24
|
+
}
|
|
18
25
|
/** A fresh 32-byte symmetric session key (random, never derived). */
|
|
19
26
|
export declare function generateSessionKey(): Promise<Uint8Array>;
|
|
20
|
-
/** Fresh ECDH P-256 keypair (
|
|
21
|
-
export declare function generateEcdhKeyPair(): Promise<
|
|
22
|
-
/**
|
|
23
|
-
export declare function exportPublicKey(key:
|
|
24
|
-
/**
|
|
25
|
-
export declare function importPublicKey(raw: Uint8Array):
|
|
27
|
+
/** Fresh ECDH P-256 keypair (32-byte private scalar, 65-byte uncompressed pub). */
|
|
28
|
+
export declare function generateEcdhKeyPair(): Promise<EcdhKeyPair>;
|
|
29
|
+
/** Identity passthrough kept for API back-compat. Public keys are already raw bytes. */
|
|
30
|
+
export declare function exportPublicKey(key: Uint8Array): Uint8Array;
|
|
31
|
+
/** Validate that raw is a well-formed uncompressed P-256 public key (65 bytes). */
|
|
32
|
+
export declare function importPublicKey(raw: Uint8Array): Uint8Array;
|
|
26
33
|
/**
|
|
27
|
-
* Derive a 32-byte shared secret
|
|
28
|
-
*
|
|
34
|
+
* Derive a 32-byte shared secret (X coordinate of the shared point) from a
|
|
35
|
+
* local ECDH private key and a remote public key. No HKDF, matching the
|
|
29
36
|
* protocol's `priv.ECDH(remotePub)` output exactly.
|
|
30
37
|
*/
|
|
31
|
-
export declare function deriveSharedSecret(privateKey:
|
|
38
|
+
export declare function deriveSharedSecret(privateKey: Uint8Array, remotePublicKey: Uint8Array): Uint8Array;
|
|
32
39
|
/** Encrypt with AES-256-GCM. Output: nonce(12) || ciphertext || tag(16). */
|
|
33
40
|
export declare function encrypt(key: Uint8Array, plaintext: Uint8Array): Promise<Uint8Array>;
|
|
34
41
|
/** Decrypt AES-256-GCM. Input: nonce(12) || ciphertext || tag(16). */
|
|
35
42
|
export declare function decrypt(key: Uint8Array, ciphertext: Uint8Array): Promise<Uint8Array>;
|
|
36
43
|
/**
|
|
37
|
-
* Wrap (encrypt) a 32-byte session key for delivery to a remote party
|
|
38
|
-
* worker), using a fresh ephemeral ECDH keypair.
|
|
44
|
+
* Wrap (encrypt) a 32-byte session key for delivery to a remote party
|
|
45
|
+
* (e.g. the worker), using a fresh ephemeral ECDH keypair.
|
|
39
46
|
*
|
|
40
47
|
* Output: ephemeralPub(65) || nonce(12) || AES-GCM(sharedSecret, sessionKey)
|
|
41
48
|
*/
|
|
42
|
-
export declare function encryptSessionKey(sessionKey: Uint8Array, remotePublicKey:
|
|
49
|
+
export declare function encryptSessionKey(sessionKey: Uint8Array, remotePublicKey: Uint8Array): Promise<Uint8Array>;
|
|
43
50
|
/** Unwrap a session key encrypted by `encryptSessionKey` using the local ECDH private key. */
|
|
44
|
-
export declare function decryptSessionKey(encWorkerKey: Uint8Array, privateKey:
|
|
51
|
+
export declare function decryptSessionKey(encWorkerKey: Uint8Array, privateKey: Uint8Array): Promise<Uint8Array>;
|
|
45
52
|
export declare function utf8ToBytes(s: string): Uint8Array;
|
|
46
53
|
export declare function bytesToUtf8(b: Uint8Array): string;
|
|
47
54
|
export declare function bytesToHex(b: Uint8Array): `0x${string}`;
|
package/dist/crypto.js
CHANGED
|
@@ -5,114 +5,124 @@
|
|
|
5
5
|
* The format here MUST match the workers' Go implementation byte for byte,
|
|
6
6
|
* which is what the `lcai-chat-v2` reference client also targets:
|
|
7
7
|
* - ECDH P-256 key exchange.
|
|
8
|
-
* - Raw shared secret used DIRECTLY as the AES-256 key (no HKDF).
|
|
8
|
+
* - Raw shared secret X coordinate used DIRECTLY as the AES-256 key (no HKDF).
|
|
9
9
|
* - AES-GCM ciphertext layout: nonce(12) || ciphertext || tag(16).
|
|
10
10
|
* - Encrypted session key layout: ephemeralPub(65) || nonce(12) || ciphertext || tag(16).
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
12
|
+
* Implementation note: this module used to be built on Web Crypto (subtle.*).
|
|
13
|
+
* That broke in environments where the runtime has a partial Web Crypto
|
|
14
|
+
* implementation - notably StackBlitz / Bolt WebContainer, where
|
|
15
|
+
* subtle.generateKey({name:"ECDH",namedCurve:"P-256"}) throws "Unsupported".
|
|
16
|
+
* The flow is now backed by @noble/curves (P-256) and @noble/ciphers (AES-GCM)
|
|
17
|
+
* which are pure-JS and identical across every runtime. The only Web Crypto
|
|
18
|
+
* surface left is getRandomValues, which is reliable everywhere (when missing
|
|
19
|
+
* we fall back to node:crypto).
|
|
17
20
|
*/
|
|
21
|
+
import { p256 } from "@noble/curves/p256";
|
|
22
|
+
import { gcm } from "@noble/ciphers/aes";
|
|
18
23
|
const AES_KEY_BYTES = 32;
|
|
19
24
|
const GCM_NONCE_BYTES = 12;
|
|
20
|
-
const
|
|
25
|
+
const GCM_TAG_BYTES = 16;
|
|
26
|
+
const P256_UNCOMPRESSED_KEY_BYTES = 65; // 0x04 || X(32) || Y(32)
|
|
21
27
|
const SESSION_KEY_BYTES = 32;
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
// Random source: try globalThis.crypto.getRandomValues (every modern browser
|
|
29
|
+
// and Node 19+ global), fall back to node:crypto.webcrypto.getRandomValues
|
|
30
|
+
// (Node 18, WebContainer). All algorithm-side crypto is pure JS, so this is
|
|
31
|
+
// the only Web-Crypto-flavored thing the SDK still needs.
|
|
32
|
+
let resolvedRng = null;
|
|
33
|
+
let resolvingRng = null;
|
|
34
|
+
async function getRng() {
|
|
35
|
+
if (resolvedRng)
|
|
36
|
+
return resolvedRng;
|
|
37
|
+
if (resolvingRng)
|
|
38
|
+
return resolvingRng;
|
|
39
|
+
resolvingRng = (async () => {
|
|
31
40
|
const g = globalThis.crypto;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
diag.push(`globalThis.crypto.getRandomValues=${typeof g.getRandomValues}`);
|
|
37
|
-
if (g?.subtle && typeof g.getRandomValues === "function") {
|
|
38
|
-
const provider = { subtle: g.subtle, getRandomValues: g.getRandomValues.bind(g) };
|
|
39
|
-
resolvedCrypto = provider;
|
|
40
|
-
return provider;
|
|
41
|
+
if (g && typeof g.getRandomValues === "function") {
|
|
42
|
+
const bound = g.getRandomValues.bind(g);
|
|
43
|
+
resolvedRng = bound;
|
|
44
|
+
return bound;
|
|
41
45
|
}
|
|
42
|
-
// Node 18 + StackBlitz WebContainer: globalThis.crypto may be missing, but
|
|
43
|
-
// `node:crypto` exposes the same Web Crypto API via `webcrypto`.
|
|
44
|
-
let nodeCryptoError = null;
|
|
45
46
|
try {
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
// The /* webpackIgnore: true */ magic comment stops Next.js / webpack
|
|
48
|
+
// from trying to bundle node:crypto for the browser. In a real browser
|
|
49
|
+
// we never reach this line (globalThis.crypto is available), so the
|
|
50
|
+
// import is dead code there - but webpack analyzes it statically and
|
|
51
|
+
// errors on the `node:` URI scheme without the hint.
|
|
52
|
+
const mod = (await import(/* webpackIgnore: true */ "node:crypto"));
|
|
48
53
|
const wc = mod.webcrypto;
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
diag.push(`node:crypto.webcrypto.getRandomValues=${typeof wc.getRandomValues}`);
|
|
54
|
-
if (wc?.subtle && typeof wc.getRandomValues === "function") {
|
|
55
|
-
const provider = { subtle: wc.subtle, getRandomValues: wc.getRandomValues.bind(wc) };
|
|
56
|
-
resolvedCrypto = provider;
|
|
57
|
-
return provider;
|
|
54
|
+
if (wc && typeof wc.getRandomValues === "function") {
|
|
55
|
+
const bound = wc.getRandomValues.bind(wc);
|
|
56
|
+
resolvedRng = bound;
|
|
57
|
+
return bound;
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
|
-
catch
|
|
61
|
-
|
|
62
|
-
diag.push(`node:crypto=import threw: ${nodeCryptoError}`);
|
|
60
|
+
catch {
|
|
61
|
+
// browser bundle without a global crypto - fall through to the throw
|
|
63
62
|
}
|
|
64
|
-
throw new Error("
|
|
65
|
-
"
|
|
66
|
-
diag.join("; "));
|
|
63
|
+
throw new Error("Secure random source unavailable: neither globalThis.crypto.getRandomValues nor " +
|
|
64
|
+
"node:crypto.webcrypto.getRandomValues was found. Requires Node 18+ or a modern browser.");
|
|
67
65
|
})();
|
|
68
66
|
try {
|
|
69
|
-
return await
|
|
67
|
+
return await resolvingRng;
|
|
70
68
|
}
|
|
71
69
|
finally {
|
|
72
|
-
|
|
70
|
+
resolvingRng = null;
|
|
73
71
|
}
|
|
74
72
|
}
|
|
75
|
-
async function subtle() {
|
|
76
|
-
return (await getCrypto()).subtle;
|
|
77
|
-
}
|
|
78
73
|
async function randomBytes(n) {
|
|
79
74
|
const buf = new Uint8Array(n);
|
|
80
|
-
(await
|
|
75
|
+
(await getRng())(buf);
|
|
81
76
|
return buf;
|
|
82
77
|
}
|
|
83
78
|
/** A fresh 32-byte symmetric session key (random, never derived). */
|
|
84
79
|
export function generateSessionKey() {
|
|
85
80
|
return randomBytes(SESSION_KEY_BYTES);
|
|
86
81
|
}
|
|
87
|
-
/** Fresh ECDH P-256 keypair (
|
|
82
|
+
/** Fresh ECDH P-256 keypair (32-byte private scalar, 65-byte uncompressed pub). */
|
|
88
83
|
export async function generateEcdhKeyPair() {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
84
|
+
// Use our RNG (proven available) instead of noble's, so a missing
|
|
85
|
+
// globalThis.crypto fails loudly at one site rather than in noble's internals.
|
|
86
|
+
const privateKey = await randomBytes(32);
|
|
87
|
+
// P-256 private scalars must be < n; the chance of collision is astronomically
|
|
88
|
+
// small, but noble validates this when we call getPublicKey, so any bad draw
|
|
89
|
+
// throws (and we'd just retry - but it has never been hit in practice).
|
|
90
|
+
const publicKey = p256.getPublicKey(privateKey, false);
|
|
91
|
+
return { privateKey, publicKey };
|
|
92
|
+
}
|
|
93
|
+
/** Identity passthrough kept for API back-compat. Public keys are already raw bytes. */
|
|
94
|
+
export function exportPublicKey(key) {
|
|
95
|
+
return key;
|
|
96
|
+
}
|
|
97
|
+
/** Validate that raw is a well-formed uncompressed P-256 public key (65 bytes). */
|
|
98
|
+
export function importPublicKey(raw) {
|
|
99
|
+
if (raw.length !== P256_UNCOMPRESSED_KEY_BYTES || raw[0] !== 0x04) {
|
|
100
|
+
throw new Error(`expected uncompressed P-256 public key (${P256_UNCOMPRESSED_KEY_BYTES} bytes, leading 0x04)`);
|
|
101
|
+
}
|
|
102
|
+
// Round-trip through noble to confirm the point is on the curve. Throws if not.
|
|
103
|
+
p256.ProjectivePoint.fromHex(raw);
|
|
104
|
+
return raw;
|
|
98
105
|
}
|
|
99
106
|
/**
|
|
100
|
-
* Derive a 32-byte shared secret
|
|
101
|
-
*
|
|
107
|
+
* Derive a 32-byte shared secret (X coordinate of the shared point) from a
|
|
108
|
+
* local ECDH private key and a remote public key. No HKDF, matching the
|
|
102
109
|
* protocol's `priv.ECDH(remotePub)` output exactly.
|
|
103
110
|
*/
|
|
104
|
-
export
|
|
105
|
-
|
|
111
|
+
export function deriveSharedSecret(privateKey, remotePublicKey) {
|
|
112
|
+
// noble returns 65 bytes uncompressed (0x04 || X || Y) when isCompressed=false.
|
|
113
|
+
// The protocol's shared secret is just X.
|
|
114
|
+
const sharedPoint = p256.getSharedSecret(privateKey, remotePublicKey, false);
|
|
115
|
+
return sharedPoint.slice(1, 1 + AES_KEY_BYTES);
|
|
106
116
|
}
|
|
107
117
|
/** Encrypt with AES-256-GCM. Output: nonce(12) || ciphertext || tag(16). */
|
|
108
118
|
export async function encrypt(key, plaintext) {
|
|
109
119
|
if (key.length !== AES_KEY_BYTES)
|
|
110
120
|
throw new Error(`AES key must be ${AES_KEY_BYTES} bytes`);
|
|
111
|
-
const s = await subtle();
|
|
112
|
-
const aesKey = await s.importKey("raw", key, "AES-GCM", false, ["encrypt"]);
|
|
113
121
|
const nonce = await randomBytes(GCM_NONCE_BYTES);
|
|
114
|
-
|
|
115
|
-
|
|
122
|
+
// noble's gcm returns plaintext.length + 16 (tag appended). Layout in our
|
|
123
|
+
// wire format is nonce || ct+tag.
|
|
124
|
+
const ctPlusTag = gcm(key, nonce).encrypt(plaintext);
|
|
125
|
+
const out = new Uint8Array(GCM_NONCE_BYTES + ctPlusTag.length);
|
|
116
126
|
out.set(nonce, 0);
|
|
117
127
|
out.set(ctPlusTag, GCM_NONCE_BYTES);
|
|
118
128
|
return out;
|
|
@@ -121,17 +131,15 @@ export async function encrypt(key, plaintext) {
|
|
|
121
131
|
export async function decrypt(key, ciphertext) {
|
|
122
132
|
if (key.length !== AES_KEY_BYTES)
|
|
123
133
|
throw new Error(`AES key must be ${AES_KEY_BYTES} bytes`);
|
|
124
|
-
if (ciphertext.length < GCM_NONCE_BYTES +
|
|
134
|
+
if (ciphertext.length < GCM_NONCE_BYTES + GCM_TAG_BYTES)
|
|
125
135
|
throw new Error("ciphertext too short");
|
|
126
|
-
const s = await subtle();
|
|
127
|
-
const aesKey = await s.importKey("raw", key, "AES-GCM", false, ["decrypt"]);
|
|
128
136
|
const nonce = ciphertext.slice(0, GCM_NONCE_BYTES);
|
|
129
137
|
const body = ciphertext.slice(GCM_NONCE_BYTES);
|
|
130
|
-
return
|
|
138
|
+
return gcm(key, nonce).decrypt(body);
|
|
131
139
|
}
|
|
132
140
|
/**
|
|
133
|
-
* Wrap (encrypt) a 32-byte session key for delivery to a remote party
|
|
134
|
-
* worker), using a fresh ephemeral ECDH keypair.
|
|
141
|
+
* Wrap (encrypt) a 32-byte session key for delivery to a remote party
|
|
142
|
+
* (e.g. the worker), using a fresh ephemeral ECDH keypair.
|
|
135
143
|
*
|
|
136
144
|
* Output: ephemeralPub(65) || nonce(12) || AES-GCM(sharedSecret, sessionKey)
|
|
137
145
|
*/
|
|
@@ -139,22 +147,21 @@ export async function encryptSessionKey(sessionKey, remotePublicKey) {
|
|
|
139
147
|
if (sessionKey.length !== SESSION_KEY_BYTES)
|
|
140
148
|
throw new Error(`session key must be ${SESSION_KEY_BYTES} bytes`);
|
|
141
149
|
const ephemeral = await generateEcdhKeyPair();
|
|
142
|
-
const shared =
|
|
150
|
+
const shared = deriveSharedSecret(ephemeral.privateKey, remotePublicKey);
|
|
143
151
|
const ct = await encrypt(shared, sessionKey);
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
out.set(
|
|
147
|
-
out.set(ct, pub.length);
|
|
152
|
+
const out = new Uint8Array(ephemeral.publicKey.length + ct.length);
|
|
153
|
+
out.set(ephemeral.publicKey, 0);
|
|
154
|
+
out.set(ct, ephemeral.publicKey.length);
|
|
148
155
|
return out;
|
|
149
156
|
}
|
|
150
157
|
/** Unwrap a session key encrypted by `encryptSessionKey` using the local ECDH private key. */
|
|
151
158
|
export async function decryptSessionKey(encWorkerKey, privateKey) {
|
|
152
|
-
if (encWorkerKey.length < P256_UNCOMPRESSED_KEY_BYTES + GCM_NONCE_BYTES +
|
|
159
|
+
if (encWorkerKey.length < P256_UNCOMPRESSED_KEY_BYTES + GCM_NONCE_BYTES + GCM_TAG_BYTES) {
|
|
153
160
|
throw new Error("encrypted session key too short");
|
|
154
161
|
}
|
|
155
|
-
const ephemeralPub =
|
|
162
|
+
const ephemeralPub = importPublicKey(encWorkerKey.slice(0, P256_UNCOMPRESSED_KEY_BYTES));
|
|
156
163
|
const ct = encWorkerKey.slice(P256_UNCOMPRESSED_KEY_BYTES);
|
|
157
|
-
const shared =
|
|
164
|
+
const shared = deriveSharedSecret(privateKey, ephemeralPub);
|
|
158
165
|
const sk = await decrypt(shared, ct);
|
|
159
166
|
if (sk.length !== SESSION_KEY_BYTES)
|
|
160
167
|
throw new Error(`session key must be ${SESSION_KEY_BYTES} bytes`);
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { NETWORKS, WORKER_REGISTRY, REGISTRY_TOPICS } from "./networks.js";
|
|
2
2
|
import { fromWei } from "./subgraph.js";
|
|
3
3
|
import { aggregateModelStats, aggregateWorkerStats, networkAnalytics, modelStatsCsv, workerStatsCsv, workerJobsCsv } from "./analytics.js";
|
|
4
|
-
import { modelId as computeModelId, estimateJobFee, JOB_REGISTRY_CONSUMER_ABI, consumerGatewayUrl, consumerGatewayHost, prepareSession, submitPrompt, decryptResponse, generateEcdhKeyPair, runInference, runInferenceWithKey } from "./inference.js";
|
|
4
|
+
import { modelId as computeModelId, estimateJobFee, JOB_REGISTRY_CONSUMER_ABI, consumerGatewayUrl, consumerGatewayHost, prepareSession, submitPrompt, decryptResponse, generateEcdhKeyPair, runInference, runInferenceWithKey, runInferenceStream } from "./inference.js";
|
|
5
5
|
import { StalledWorkerError, OnChainRevertError, RelayTokenTimeoutError, GatewayAuthError, isStalledWorker } from "./errors.js";
|
|
6
6
|
import { GatewayClient, GatewayHttpError } from "./gateway.js";
|
|
7
7
|
import * as crypto from "./crypto.js";
|
|
@@ -66,8 +66,8 @@ export declare class LightNode {
|
|
|
66
66
|
* (especially in registry-proxy environments like StackBlitz where lockfiles
|
|
67
67
|
* may pin an older minor than the local install command suggests).
|
|
68
68
|
*/
|
|
69
|
-
export declare const SDK_VERSION = "0.4.
|
|
70
|
-
export { NETWORKS, WORKER_REGISTRY, REGISTRY_TOPICS, aggregateModelStats, aggregateWorkerStats, networkAnalytics, modelStatsCsv, workerStatsCsv, workerJobsCsv, fromWei, computeModelId as modelId, estimateJobFee, JOB_REGISTRY_CONSUMER_ABI, consumerGatewayUrl, consumerGatewayHost, GatewayClient, GatewayHttpError, prepareSession, submitPrompt, decryptResponse, generateEcdhKeyPair, crypto, runInference, runInferenceWithKey, StalledWorkerError, OnChainRevertError, RelayTokenTimeoutError, GatewayAuthError, isStalledWorker, };
|
|
69
|
+
export declare const SDK_VERSION = "0.4.9";
|
|
70
|
+
export { NETWORKS, WORKER_REGISTRY, REGISTRY_TOPICS, aggregateModelStats, aggregateWorkerStats, networkAnalytics, modelStatsCsv, workerStatsCsv, workerJobsCsv, fromWei, computeModelId as modelId, estimateJobFee, JOB_REGISTRY_CONSUMER_ABI, consumerGatewayUrl, consumerGatewayHost, GatewayClient, GatewayHttpError, prepareSession, submitPrompt, decryptResponse, generateEcdhKeyPair, crypto, runInference, runInferenceWithKey, runInferenceStream, StalledWorkerError, OnChainRevertError, RelayTokenTimeoutError, GatewayAuthError, isStalledWorker, };
|
|
71
71
|
export type { BearerSource, GatewayClientOptions, SelectSessionResult, PrepareSessionResult, UploadBlobResult, SessionTokenResult } from "./gateway.js";
|
|
72
|
-
export type { SessionPreparation, RunInferenceArgs, RunInferenceResult, RunInferenceWithKeyArgs } from "./inference.js";
|
|
72
|
+
export type { SessionPreparation, RunInferenceArgs, RunInferenceResult, RunInferenceWithKeyArgs, RunInferenceStreamResult } from "./inference.js";
|
|
73
73
|
export type { NetworkId, NetworkConfig, Worker, Job, ModelInfo, NetworkStats, ModelStat, WorkerStat, NetworkAnalytics };
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@ import { NETWORKS, WORKER_REGISTRY, REGISTRY_TOPICS } from "./networks.js";
|
|
|
2
2
|
import { fetchWorker, fetchWorkerJobs, fetchRecentJobs, fetchModels, fetchWorkers, summarize, fromWei, } from "./subgraph.js";
|
|
3
3
|
import { isRegistered } from "./onchain.js";
|
|
4
4
|
import { aggregateModelStats, aggregateWorkerStats, networkAnalytics, modelStatsCsv, workerStatsCsv, workerJobsCsv, } from "./analytics.js";
|
|
5
|
-
import { modelId as computeModelId, estimateJobFee, JOB_REGISTRY_CONSUMER_ABI, consumerGatewayUrl, consumerGatewayHost, prepareSession, submitPrompt, decryptResponse, generateEcdhKeyPair, runInference, runInferenceWithKey, } from "./inference.js";
|
|
5
|
+
import { modelId as computeModelId, estimateJobFee, JOB_REGISTRY_CONSUMER_ABI, consumerGatewayUrl, consumerGatewayHost, prepareSession, submitPrompt, decryptResponse, generateEcdhKeyPair, runInference, runInferenceWithKey, runInferenceStream, } from "./inference.js";
|
|
6
6
|
import { StalledWorkerError, OnChainRevertError, RelayTokenTimeoutError, GatewayAuthError, isStalledWorker, } from "./errors.js";
|
|
7
7
|
import { GatewayClient, GatewayHttpError } from "./gateway.js";
|
|
8
8
|
import * as crypto from "./crypto.js";
|
|
@@ -96,11 +96,13 @@ export class LightNode {
|
|
|
96
96
|
* (especially in registry-proxy environments like StackBlitz where lockfiles
|
|
97
97
|
* may pin an older minor than the local install command suggests).
|
|
98
98
|
*/
|
|
99
|
-
export const SDK_VERSION = "0.4.
|
|
99
|
+
export const SDK_VERSION = "0.4.9";
|
|
100
100
|
export { NETWORKS, WORKER_REGISTRY, REGISTRY_TOPICS, aggregateModelStats, aggregateWorkerStats, networkAnalytics, modelStatsCsv, workerStatsCsv, workerJobsCsv, fromWei, computeModelId as modelId, estimateJobFee, JOB_REGISTRY_CONSUMER_ABI, consumerGatewayUrl, consumerGatewayHost,
|
|
101
101
|
// v0.3 inference-submit surface (BETA - see README "Submitting inference").
|
|
102
102
|
GatewayClient, GatewayHttpError, prepareSession, submitPrompt, decryptResponse, generateEcdhKeyPair, crypto,
|
|
103
103
|
// v0.4 high-level orchestrator: one call, full flow.
|
|
104
104
|
runInference,
|
|
105
105
|
// v0.4.3 key-in-answer-out shortcut: same flow, no viem/SIWE wiring.
|
|
106
|
-
runInferenceWithKey,
|
|
106
|
+
runInferenceWithKey,
|
|
107
|
+
// v0.4.9 AsyncIterable<string> wrapper around runInferenceWithKey.
|
|
108
|
+
runInferenceStream, StalledWorkerError, OnChainRevertError, RelayTokenTimeoutError, GatewayAuthError, isStalledWorker, };
|
package/dist/inference.d.ts
CHANGED
|
@@ -287,3 +287,37 @@ export interface RunInferenceWithKeyArgs {
|
|
|
287
287
|
* ```
|
|
288
288
|
*/
|
|
289
289
|
export declare function runInferenceWithKey(args: RunInferenceWithKeyArgs): Promise<RunInferenceResult>;
|
|
290
|
+
export interface RunInferenceStreamResult {
|
|
291
|
+
/** Streamed chunks (decrypted, in arrival order). */
|
|
292
|
+
[Symbol.asyncIterator](): AsyncIterator<string>;
|
|
293
|
+
/**
|
|
294
|
+
* Resolves with the same shape `runInference` returns once the iterator
|
|
295
|
+
* has finished (i.e. you've consumed all chunks). `answer` is the full
|
|
296
|
+
* assembled string. Awaiting this before consuming the iterator hangs;
|
|
297
|
+
* always iterate first or in parallel with another consumer.
|
|
298
|
+
*/
|
|
299
|
+
done: Promise<RunInferenceResult>;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Stream-shaped wrapper over `runInferenceWithKey`. Returns an async-iterable
|
|
303
|
+
* of decrypted chunks plus a `done` promise that resolves to the full result
|
|
304
|
+
* once the iteration completes.
|
|
305
|
+
*
|
|
306
|
+
* @example
|
|
307
|
+
* ```ts
|
|
308
|
+
* import { runInferenceStream } from "lightnode-sdk";
|
|
309
|
+
*
|
|
310
|
+
* const stream = runInferenceStream({
|
|
311
|
+
* network: "testnet",
|
|
312
|
+
* privateKey: process.env.PRIVATE_KEY!,
|
|
313
|
+
* prompt: "Write a haiku about decentralized AI.",
|
|
314
|
+
* });
|
|
315
|
+
*
|
|
316
|
+
* for await (const chunk of stream) {
|
|
317
|
+
* process.stdout.write(chunk);
|
|
318
|
+
* }
|
|
319
|
+
* const { txs } = await stream.done;
|
|
320
|
+
* console.log("\n", txs);
|
|
321
|
+
* ```
|
|
322
|
+
*/
|
|
323
|
+
export declare function runInferenceStream(args: RunInferenceWithKeyArgs): RunInferenceStreamResult;
|
package/dist/inference.js
CHANGED
|
@@ -535,16 +535,32 @@ export async function runInferenceWithKey(args) {
|
|
|
535
535
|
if (!verify.token)
|
|
536
536
|
throw new GatewayAuthError(verifyRes.status, "auth verify returned no token");
|
|
537
537
|
const gateway = new GatewayClientCtor({ network: networkId, bearer: verify.token, baseUrl: args.gatewayUrl ?? gwBase });
|
|
538
|
-
// Pick a WebSocket: the browser global
|
|
539
|
-
//
|
|
540
|
-
//
|
|
541
|
-
|
|
538
|
+
// Pick a WebSocket: caller-supplied wins, else the browser global, else try
|
|
539
|
+
// to lazy-import the `ws` package (Node). The webpackIgnore hint keeps
|
|
540
|
+
// bundlers from blowing up trying to resolve `ws` for browser bundles where
|
|
541
|
+
// we never reach this branch.
|
|
542
|
+
let wsCtor = args.WebSocket ??
|
|
542
543
|
(typeof globalThis !== "undefined" && globalThis.WebSocket
|
|
543
544
|
? globalThis.WebSocket
|
|
544
545
|
: undefined);
|
|
545
546
|
if (!wsCtor) {
|
|
546
|
-
|
|
547
|
-
|
|
547
|
+
try {
|
|
548
|
+
// Hide the module name from TS's static resolver via a Function-built
|
|
549
|
+
// dynamic import - otherwise TS errors trying to find @types/ws (we do
|
|
550
|
+
// not want that as a SDK devDep). The webpackIgnore-style comment also
|
|
551
|
+
// keeps browser bundlers from trying to resolve `ws`.
|
|
552
|
+
const dynamicImport = Function("n", "return import(/* webpackIgnore: true */ n)");
|
|
553
|
+
const mod = await dynamicImport("ws");
|
|
554
|
+
wsCtor = mod.default ?? mod.WebSocket;
|
|
555
|
+
}
|
|
556
|
+
catch {
|
|
557
|
+
// `ws` not installed - keep wsCtor undefined and fall into the error below.
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
if (!wsCtor) {
|
|
561
|
+
throw new Error("runInferenceWithKey: no WebSocket constructor available. In Node, install `ws` " +
|
|
562
|
+
"(`npm i ws`) - the SDK will pick it up automatically. Or pass one explicitly: " +
|
|
563
|
+
"`import WS from 'ws'; runInferenceWithKey({ WebSocket: WS, ... })`.");
|
|
548
564
|
}
|
|
549
565
|
return runInference({
|
|
550
566
|
prompt: args.prompt,
|
|
@@ -560,3 +576,88 @@ export async function runInferenceWithKey(args) {
|
|
|
560
576
|
relayUrl: args.relayUrl,
|
|
561
577
|
});
|
|
562
578
|
}
|
|
579
|
+
/**
|
|
580
|
+
* Stream-shaped wrapper over `runInferenceWithKey`. Returns an async-iterable
|
|
581
|
+
* of decrypted chunks plus a `done` promise that resolves to the full result
|
|
582
|
+
* once the iteration completes.
|
|
583
|
+
*
|
|
584
|
+
* @example
|
|
585
|
+
* ```ts
|
|
586
|
+
* import { runInferenceStream } from "lightnode-sdk";
|
|
587
|
+
*
|
|
588
|
+
* const stream = runInferenceStream({
|
|
589
|
+
* network: "testnet",
|
|
590
|
+
* privateKey: process.env.PRIVATE_KEY!,
|
|
591
|
+
* prompt: "Write a haiku about decentralized AI.",
|
|
592
|
+
* });
|
|
593
|
+
*
|
|
594
|
+
* for await (const chunk of stream) {
|
|
595
|
+
* process.stdout.write(chunk);
|
|
596
|
+
* }
|
|
597
|
+
* const { txs } = await stream.done;
|
|
598
|
+
* console.log("\n", txs);
|
|
599
|
+
* ```
|
|
600
|
+
*/
|
|
601
|
+
export function runInferenceStream(args) {
|
|
602
|
+
// Bounded queue of pending chunks; consumed in order by the iterator. We
|
|
603
|
+
// can't use an unbounded array because the inference may produce chunks
|
|
604
|
+
// faster than the consumer reads them - bounding at 1024 is enough to absorb
|
|
605
|
+
// model-output bursts without unbounded memory growth.
|
|
606
|
+
const queue = [];
|
|
607
|
+
const waiters = [];
|
|
608
|
+
let finished = false;
|
|
609
|
+
let error = null;
|
|
610
|
+
const push = (chunk) => {
|
|
611
|
+
if (waiters.length > 0) {
|
|
612
|
+
const resolve = waiters.shift();
|
|
613
|
+
if (resolve)
|
|
614
|
+
resolve({ value: chunk, done: false });
|
|
615
|
+
}
|
|
616
|
+
else {
|
|
617
|
+
queue.push(chunk);
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
const finish = (err = null) => {
|
|
621
|
+
finished = true;
|
|
622
|
+
error = err;
|
|
623
|
+
while (waiters.length > 0) {
|
|
624
|
+
const resolve = waiters.shift();
|
|
625
|
+
if (!resolve)
|
|
626
|
+
continue;
|
|
627
|
+
if (err)
|
|
628
|
+
resolve({ value: undefined, done: true });
|
|
629
|
+
else
|
|
630
|
+
resolve({ value: undefined, done: true });
|
|
631
|
+
}
|
|
632
|
+
};
|
|
633
|
+
const done = runInferenceWithKey({
|
|
634
|
+
...args,
|
|
635
|
+
onChunk: (chunk) => push(chunk),
|
|
636
|
+
})
|
|
637
|
+
.then((res) => {
|
|
638
|
+
finish(null);
|
|
639
|
+
return res;
|
|
640
|
+
})
|
|
641
|
+
.catch((e) => {
|
|
642
|
+
finish(e);
|
|
643
|
+
throw e;
|
|
644
|
+
});
|
|
645
|
+
return {
|
|
646
|
+
[Symbol.asyncIterator]() {
|
|
647
|
+
return {
|
|
648
|
+
async next() {
|
|
649
|
+
if (queue.length > 0) {
|
|
650
|
+
return { value: queue.shift(), done: false };
|
|
651
|
+
}
|
|
652
|
+
if (finished) {
|
|
653
|
+
if (error)
|
|
654
|
+
throw error;
|
|
655
|
+
return { value: undefined, done: true };
|
|
656
|
+
}
|
|
657
|
+
return new Promise((resolve) => waiters.push(resolve));
|
|
658
|
+
},
|
|
659
|
+
};
|
|
660
|
+
},
|
|
661
|
+
done,
|
|
662
|
+
};
|
|
663
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lightnode-sdk",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.9",
|
|
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",
|
|
@@ -46,6 +46,8 @@
|
|
|
46
46
|
"url": "https://github.com/marinom2/lightnode/issues"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
+
"@noble/ciphers": "^1.0.0",
|
|
50
|
+
"@noble/curves": "^1.0.0",
|
|
49
51
|
"viem": ">=2"
|
|
50
52
|
},
|
|
51
53
|
"engines": {
|