@prismnetwork/agent-sdk 0.6.0 → 0.7.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/LICENSE +201 -0
- package/NOTICE +10 -0
- package/attest.d.mts +125 -0
- package/attest.mjs +826 -0
- package/e2ee.d.mts +89 -0
- package/e2ee.mjs +215 -0
- package/package.json +27 -6
- package/prism.d.mts +63 -0
- package/prism.mjs +461 -42
- package/vendor/aci-verifier/crypto.mjs +100 -0
- package/vendor/aci-verifier/digest.mjs +39 -0
- package/vendor/aci-verifier/errors.mjs +21 -0
- package/vendor/aci-verifier/index.mjs +32 -0
- package/vendor/aci-verifier/receipt.mjs +88 -0
- package/vendor/aci-verifier/report.mjs +206 -0
- package/vendor/aci-verifier/session.mjs +33 -0
package/prism.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Prism Network agent SDK: headless GPU leasing for wallet-holding agents.
|
|
2
2
|
// No browser, no Privy. Authenticate with a wallet signature, pay on-chain, run.
|
|
3
3
|
import { execFileSync, spawn } from "node:child_process";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
4
5
|
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
5
6
|
import { tmpdir } from "node:os";
|
|
6
7
|
import { join } from "node:path";
|
|
@@ -14,10 +15,14 @@ import {
|
|
|
14
15
|
stringToBytes,
|
|
15
16
|
} from "viem";
|
|
16
17
|
import { privateKeyToAccount } from "viem/accounts";
|
|
18
|
+
import { appraiseWorkload, DEFAULT_CONFIDENTIAL_BASE, EXPECTED_WORKLOAD, verifyConfidential } from "./attest.mjs";
|
|
19
|
+
import { decryptResponse, encryptChatRequest } from "./e2ee.mjs";
|
|
17
20
|
import { openRelayForwarder } from "./relay.mjs";
|
|
18
21
|
import { PrismVault } from "./vault.mjs";
|
|
22
|
+
import { toHex, verifyComposeMeasurement, verifyQuote, verifyReportBinding } from "./vendor/aci-verifier/index.mjs";
|
|
19
23
|
import { PrismWorkspace } from "./workspace.mjs";
|
|
20
24
|
|
|
25
|
+
export { DEFAULT_CONFIDENTIAL_BASE, EXPECTED_WORKLOAD, renderChecks, verifyConfidential } from "./attest.mjs";
|
|
21
26
|
export { PrismVault, VaultError, DEFAULT_TRUST_FLOOR, VAULT_KEY_STATEMENT } from "./vault.mjs";
|
|
22
27
|
export {
|
|
23
28
|
PrismWorkspace,
|
|
@@ -46,6 +51,12 @@ export const TRUST_CLASSES = ["open", "isolated", "attested", "confidential"];
|
|
|
46
51
|
|
|
47
52
|
const CONFIRMATIONS = 12;
|
|
48
53
|
const FETCH_TIMEOUT_MS = 30_000;
|
|
54
|
+
// A generation can wait on a cold box, so the paid call gets its own budget and
|
|
55
|
+
// keeps the payment across the wait rather than paying twice.
|
|
56
|
+
const PAID_CALL_TIMEOUT_MS = 620_000;
|
|
57
|
+
const PAID_CALL_DEADLINE_MS = 600_000;
|
|
58
|
+
const PAID_CALL_RETRY_MS = 15_000;
|
|
59
|
+
const DEFAULT_MAX_TOKENS = 512;
|
|
49
60
|
|
|
50
61
|
const erc20Abi = parseAbi([
|
|
51
62
|
"function approve(address spender, uint256 value) returns (bool)",
|
|
@@ -105,6 +116,32 @@ function isSshWarmup(res) {
|
|
|
105
116
|
|
|
106
117
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
107
118
|
|
|
119
|
+
const asBytes = (body) =>
|
|
120
|
+
typeof body === "string" || body instanceof Uint8Array ? body : Buffer.from(JSON.stringify(body), "utf8");
|
|
121
|
+
|
|
122
|
+
/// A sealed answer, or an error that names the reason it did not open. The
|
|
123
|
+
/// service marks an encrypted answer with `x-e2ee-applied`, and a plaintext one
|
|
124
|
+
/// fails the AEAD for a reason that has nothing to do with the key.
|
|
125
|
+
function decryptAnswer(bytes, clientKey, headers, receiptId) {
|
|
126
|
+
const applied = headers.get("x-e2ee-applied");
|
|
127
|
+
const paidFor = `the generation is paid for, and receipt ${receiptId} still verifies what the workload served`;
|
|
128
|
+
if (applied !== null && applied.toLowerCase() !== "true") {
|
|
129
|
+
throw new PrismError(502, "e2ee_not_applied", {
|
|
130
|
+
cause: `the endpoint answered with x-e2ee-applied: ${applied}`,
|
|
131
|
+
hint: `the enclave returned the answer unencrypted; ${paidFor}`,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
return decryptResponse(bytes, clientKey);
|
|
136
|
+
} catch (err) {
|
|
137
|
+
if (applied !== null) throw err;
|
|
138
|
+
throw new PrismError(502, "e2ee_not_applied", {
|
|
139
|
+
cause: err?.message ?? String(err),
|
|
140
|
+
hint: `the endpoint marked no answer as encrypted and this one did not open under this call's key; ${paidFor}`,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
108
145
|
export class PrismAgent {
|
|
109
146
|
constructor({ privateKey, apiBase = "https://prismnetwork.tech", escrow, rpcUrl }) {
|
|
110
147
|
if (!escrow) throw new Error("escrow address is required");
|
|
@@ -175,12 +212,14 @@ export class PrismAgent {
|
|
|
175
212
|
|
|
176
213
|
async transferUsdg(to, amountMicros) {
|
|
177
214
|
try {
|
|
178
|
-
const hash = await this
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
215
|
+
const hash = await this.#submit(() =>
|
|
216
|
+
this.walletClient.writeContract({
|
|
217
|
+
address: USDG,
|
|
218
|
+
abi: erc20Abi,
|
|
219
|
+
functionName: "transfer",
|
|
220
|
+
args: [to, BigInt(amountMicros)],
|
|
221
|
+
}),
|
|
222
|
+
);
|
|
184
223
|
const receipt = await this.publicClient.waitForTransactionReceipt({ hash });
|
|
185
224
|
if (receipt.status !== "success") throw new PrismError(502, "transfer_reverted", { hash });
|
|
186
225
|
return hash;
|
|
@@ -213,6 +252,10 @@ export class PrismAgent {
|
|
|
213
252
|
// Approve USDG and create the on-chain lease bound to the quote. The escrow
|
|
214
253
|
// binds funding to keccak256(quote_id), so reproduce it exactly or confirm rejects.
|
|
215
254
|
async fund(quote) {
|
|
255
|
+
return this.#submit(() => this.#fundNow(quote));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async #fundNow(quote) {
|
|
216
259
|
if (typeof quote?.quote_id !== "string" || typeof quote?.node_id !== "string") {
|
|
217
260
|
throw new PrismError(400, "invalid_quote");
|
|
218
261
|
}
|
|
@@ -220,28 +263,37 @@ export class PrismAgent {
|
|
|
220
263
|
const duration = parseDuration(quote.duration_seconds);
|
|
221
264
|
const clientReference = keccak256(stringToBytes(quote.quote_id));
|
|
222
265
|
try {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
});
|
|
229
|
-
if (allowance < deposit) {
|
|
230
|
-
const approveHash = await this.walletClient.writeContract({
|
|
266
|
+
// Approving and spending are one indivisible step. The approval covers
|
|
267
|
+
// exactly this deposit, so a second lease that read the allowance before
|
|
268
|
+
// this one spent it would find it gone by the time the chain ran it.
|
|
269
|
+
const hash = await (async () => {
|
|
270
|
+
const allowance = await this.publicClient.readContract({
|
|
231
271
|
address: USDG,
|
|
232
272
|
abi: erc20Abi,
|
|
233
|
-
functionName: "
|
|
234
|
-
args: [this.
|
|
273
|
+
functionName: "allowance",
|
|
274
|
+
args: [this.address, this.escrow],
|
|
235
275
|
});
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
276
|
+
if (allowance < deposit) {
|
|
277
|
+
const approveHash = await this.walletClient.writeContract({
|
|
278
|
+
address: USDG,
|
|
279
|
+
abi: erc20Abi,
|
|
280
|
+
functionName: "approve",
|
|
281
|
+
args: [this.escrow, deposit],
|
|
282
|
+
});
|
|
283
|
+
const approved = await this.publicClient.waitForTransactionReceipt({ hash: approveHash });
|
|
284
|
+
if (approved.status !== "success") throw new PrismError(402, "approve_reverted", { hash: approveHash });
|
|
285
|
+
}
|
|
286
|
+
const funding = await this.walletClient.writeContract({
|
|
287
|
+
address: this.escrow,
|
|
288
|
+
abi: escrowAbi,
|
|
289
|
+
functionName: "createLease",
|
|
290
|
+
args: [quote.node_id, duration, clientReference],
|
|
291
|
+
});
|
|
292
|
+
// One confirmation here, not for the control-plane's benefit but so the
|
|
293
|
+
// allowance and the nonce are settled before the next lease reads them.
|
|
294
|
+
await this.publicClient.waitForTransactionReceipt({ hash: funding });
|
|
295
|
+
return funding;
|
|
296
|
+
})();
|
|
245
297
|
// 12 confirmations: the control-plane rejects funding until the tx is final.
|
|
246
298
|
const receipt = await this.publicClient.waitForTransactionReceipt({ hash, confirmations: CONFIRMATIONS });
|
|
247
299
|
if (receipt.status !== "success") throw new PrismError(402, "lease_funding_reverted", { hash });
|
|
@@ -325,7 +377,10 @@ export class PrismAgent {
|
|
|
325
377
|
return res.body;
|
|
326
378
|
}
|
|
327
379
|
if (res.status !== 404 && res.status !== 429 && res.status < 500) {
|
|
328
|
-
|
|
380
|
+
// The control plane names the reason in `code`; without it a lease that
|
|
381
|
+
// will never open access reports as a generic `access_error` and the
|
|
382
|
+
// caller has to go and read the body to learn anything.
|
|
383
|
+
throw new PrismError(res.status, res.body?.error ?? res.body?.code ?? "access_error", res.body);
|
|
329
384
|
}
|
|
330
385
|
await sleep(intervalMs);
|
|
331
386
|
}
|
|
@@ -355,26 +410,35 @@ export class PrismAgent {
|
|
|
355
410
|
hint: "the wallet needs USDG for the deposit and native ETH for gas on Robinhood Chain (id 4663) before it can lease",
|
|
356
411
|
});
|
|
357
412
|
}
|
|
358
|
-
const quote = await this.quote({
|
|
359
|
-
image,
|
|
360
|
-
durationSeconds,
|
|
361
|
-
minVramMib,
|
|
362
|
-
preferredNodeId,
|
|
363
|
-
minTrustClass,
|
|
364
|
-
command,
|
|
365
|
-
});
|
|
366
|
-
if (maxDeposit != null && parseBaseUnits(quote.maximum_escrow, "maximum_escrow") > BigInt(maxDeposit)) {
|
|
367
|
-
throw new PrismError(402, "cost_exceeds_max", { required: quote.maximum_escrow, max: String(maxDeposit) });
|
|
368
|
-
}
|
|
369
413
|
const key = this.#generateSshKey();
|
|
414
|
+
let quote = null;
|
|
370
415
|
let funded = null;
|
|
371
416
|
let leaseId = null;
|
|
372
417
|
try {
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
418
|
+
// One renter takes one machine at a time, up to the point the chain knows
|
|
419
|
+
// it is taken. Asking for a quote releases this renter's other open
|
|
420
|
+
// quotes, so two quotes held at once can name the same machine and the
|
|
421
|
+
// second lease reverts against a node that is no longer free. Only the
|
|
422
|
+
// claim is serialised: provisioning, which is the part that takes
|
|
423
|
+
// minutes, still runs in parallel.
|
|
424
|
+
const record = await this.#submit(async () => {
|
|
425
|
+
quote = await this.quote({
|
|
426
|
+
image,
|
|
427
|
+
durationSeconds,
|
|
428
|
+
minVramMib,
|
|
429
|
+
preferredNodeId,
|
|
430
|
+
minTrustClass,
|
|
431
|
+
command,
|
|
432
|
+
});
|
|
433
|
+
if (maxDeposit != null && parseBaseUnits(quote.maximum_escrow, "maximum_escrow") > BigInt(maxDeposit)) {
|
|
434
|
+
throw new PrismError(402, "cost_exceeds_max", { required: quote.maximum_escrow, max: String(maxDeposit) });
|
|
435
|
+
}
|
|
436
|
+
funded = await this.#fundNow(quote);
|
|
437
|
+
return this.confirm({
|
|
438
|
+
quoteId: quote.quote_id,
|
|
439
|
+
transactionHash: funded.hash,
|
|
440
|
+
sshAuthorizedKey: key.publicKey,
|
|
441
|
+
});
|
|
378
442
|
});
|
|
379
443
|
if (!Number.isInteger(record?.lease_id)) {
|
|
380
444
|
throw new PrismError(502, "malformed_lease_record", { funding_hash: funded.hash });
|
|
@@ -493,6 +557,361 @@ export class PrismAgent {
|
|
|
493
557
|
}
|
|
494
558
|
}
|
|
495
559
|
|
|
560
|
+
// Unconsumed inference payments, keyed by the endpoint, the price and the
|
|
561
|
+
// request they paid for, so a generation that never happened is retried with
|
|
562
|
+
// the payment already made instead of paying for it twice, and a different
|
|
563
|
+
// prompt never inherits it.
|
|
564
|
+
#pendingPayments = new Map();
|
|
565
|
+
|
|
566
|
+
/// Pay for one call to a metered endpoint and keep the payment until the
|
|
567
|
+
/// endpoint actually serves. A 503 from an upstream that is merely
|
|
568
|
+
/// unavailable and a 402 for a payment that is only too young both heal by
|
|
569
|
+
/// themselves, so both retry with the same payment; everything else is final
|
|
570
|
+
/// and the payment stays cached for the next attempt at the same request.
|
|
571
|
+
///
|
|
572
|
+
/// `body` is sent verbatim when it is already bytes, which is what a signed
|
|
573
|
+
/// receipt over the request needs: nothing between the caller and the workload
|
|
574
|
+
/// re-serializes it. Pass `seal` instead for a request that has to be built
|
|
575
|
+
/// fresh per attempt, such as an end-to-end encrypted one whose timestamp the
|
|
576
|
+
/// service only accepts inside a five-minute window, and pass `fingerprint`
|
|
577
|
+
/// so the cache still recognises two attempts as the same request.
|
|
578
|
+
async payAndPost({
|
|
579
|
+
base,
|
|
580
|
+
path,
|
|
581
|
+
price,
|
|
582
|
+
payTo,
|
|
583
|
+
body = null,
|
|
584
|
+
headers = {},
|
|
585
|
+
seal = null,
|
|
586
|
+
fingerprint = null,
|
|
587
|
+
retryDelayMs = PAID_CALL_RETRY_MS,
|
|
588
|
+
caller = "call",
|
|
589
|
+
}) {
|
|
590
|
+
let sent = seal ? seal() : { bytes: asBytes(body), headers };
|
|
591
|
+
const identity = createHash("sha256").update(fingerprint ?? sent.bytes).digest("hex");
|
|
592
|
+
const key = `${base}${path}:${price}:${identity}`;
|
|
593
|
+
let pending = this.#pendingPayments.get(key);
|
|
594
|
+
if (!pending) {
|
|
595
|
+
const tx = await this.transferUsdg(payTo, price);
|
|
596
|
+
const signature = await this.account.signMessage({ message: tx });
|
|
597
|
+
pending = { tx, header: Buffer.from(JSON.stringify({ txHash: tx, signature })).toString("base64") };
|
|
598
|
+
this.#pendingPayments.set(key, pending);
|
|
599
|
+
}
|
|
600
|
+
// The transfer is on-chain and irreversible from here. The signed header is
|
|
601
|
+
// the only thing that redeems it, and it lives in this process.
|
|
602
|
+
const kept = {
|
|
603
|
+
payment_tx: pending.tx,
|
|
604
|
+
payment_header: pending.header,
|
|
605
|
+
hint:
|
|
606
|
+
`the payment (tx ${pending.tx}) settled on-chain and the endpoint did not serve. While this process lives, ` +
|
|
607
|
+
`the next ${caller} for this same request redeems it without paying again. payment_header is what redeems ` +
|
|
608
|
+
"it, so keep it to do that from anywhere else.",
|
|
609
|
+
};
|
|
610
|
+
const deadline = Date.now() + PAID_CALL_DEADLINE_MS;
|
|
611
|
+
for (;;) {
|
|
612
|
+
let res;
|
|
613
|
+
let bytes;
|
|
614
|
+
try {
|
|
615
|
+
res = await fetch(`${base}${path}`, {
|
|
616
|
+
method: "POST",
|
|
617
|
+
headers: { "content-type": "application/json", "x-payment": pending.header, ...sent.headers },
|
|
618
|
+
body: sent.bytes,
|
|
619
|
+
signal: AbortSignal.timeout(PAID_CALL_TIMEOUT_MS),
|
|
620
|
+
});
|
|
621
|
+
bytes = Buffer.from(await res.arrayBuffer());
|
|
622
|
+
} catch (err) {
|
|
623
|
+
throw new PrismError(504, "endpoint_unreachable", { cause: err?.message ?? String(err), ...kept });
|
|
624
|
+
}
|
|
625
|
+
if (res.status === 200) {
|
|
626
|
+
this.#pendingPayments.delete(key);
|
|
627
|
+
// The endpoint replays a stored answer when it sees a payment it has
|
|
628
|
+
// already consumed. That is an answer to an earlier call, so it is not
|
|
629
|
+
// this one's, whatever the status line says.
|
|
630
|
+
if (String(res.headers.get("x-prism-replayed") ?? "").toLowerCase() === "true") {
|
|
631
|
+
throw new PrismError(409, "payment_replayed", {
|
|
632
|
+
cause: `the endpoint replayed an earlier answer for tx ${pending.tx}`,
|
|
633
|
+
hint: "this payment was already consumed by another call; pay again to have this request served",
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
return { status: 200, headers: res.headers, bytes, tx: pending.tx, sent };
|
|
637
|
+
}
|
|
638
|
+
const answered = (() => {
|
|
639
|
+
try {
|
|
640
|
+
return JSON.parse(bytes.toString("utf8"));
|
|
641
|
+
} catch {
|
|
642
|
+
return null;
|
|
643
|
+
}
|
|
644
|
+
})();
|
|
645
|
+
// A payment the endpoint has already consumed will never serve anything
|
|
646
|
+
// again, so it stops being something to retry with.
|
|
647
|
+
if (answered?.error === "payment_reused") this.#pendingPayments.delete(key);
|
|
648
|
+
const retryAfter = Number(res.headers.get("retry-after") ?? 0);
|
|
649
|
+
const retryable =
|
|
650
|
+
(res.status === 503 && answered?.error === "upstream_unavailable" && !(retryAfter > 120)) ||
|
|
651
|
+
(res.status === 402 && ["insufficient_confirmations", "tx_not_found"].includes(answered?.error));
|
|
652
|
+
if (!retryable || Date.now() > deadline) {
|
|
653
|
+
const said = [answered?.detail, answered?.retry].filter(Boolean).join("; ");
|
|
654
|
+
throw new PrismError(res.status, answered?.error ?? "generation_failed", {
|
|
655
|
+
cause: said || answered?.error || `status ${res.status}`,
|
|
656
|
+
...(this.#pendingPayments.has(key) ? kept : { payment_tx: pending.tx }),
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
await sleep(retryDelayMs);
|
|
660
|
+
if (seal) sent = seal();
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/// Buy one generation from the confidential tier: an OpenAI-shaped chat
|
|
665
|
+
/// request served by a model running in a GPU TEE, answered with a signed
|
|
666
|
+
/// receipt over the exact bytes of the exchange.
|
|
667
|
+
///
|
|
668
|
+
/// With `e2ee` on (the default) the message contents are encrypted to a key
|
|
669
|
+
/// the enclave's own attestation quote commits to, established here before
|
|
670
|
+
/// anything is sent or paid, so the relay in between carries ciphertext. The
|
|
671
|
+
/// returned handle keeps those bytes and `verify()` checks the whole chain
|
|
672
|
+
/// against them.
|
|
673
|
+
///
|
|
674
|
+
/// `expectedWorkload` is the code that enclave must be running, defaulting to
|
|
675
|
+
/// the deployment this SDK ships pinned. Passing `null` skips that appraisal
|
|
676
|
+
/// and leaves the prompt protected only by "some TDX enclave holds the key".
|
|
677
|
+
async confidentialInfer({
|
|
678
|
+
prompt = null,
|
|
679
|
+
messages = null,
|
|
680
|
+
model = null,
|
|
681
|
+
maxUsdg = 0.25,
|
|
682
|
+
maxTokens = DEFAULT_MAX_TOKENS,
|
|
683
|
+
e2ee = true,
|
|
684
|
+
expectedWorkload = EXPECTED_WORKLOAD,
|
|
685
|
+
endpoint = DEFAULT_CONFIDENTIAL_BASE,
|
|
686
|
+
} = {}) {
|
|
687
|
+
const chat = messages ?? (typeof prompt === "string" && prompt.trim() !== "" ? [{ role: "user", content: prompt }] : null);
|
|
688
|
+
if (!Array.isArray(chat) || chat.length === 0) {
|
|
689
|
+
throw new PrismError(400, "prompt_required", { hint: "pass a prompt string or a messages array" });
|
|
690
|
+
}
|
|
691
|
+
if (!Number.isInteger(maxTokens) || maxTokens <= 0) throw new PrismError(400, "invalid_max_tokens");
|
|
692
|
+
const base = String(endpoint).replace(/\/$/, "");
|
|
693
|
+
const chosen = await this.#confidentialModel(base, model, maxTokens);
|
|
694
|
+
|
|
695
|
+
// Everything that protects the prompt happens before it is sent: the key it
|
|
696
|
+
// is encrypted to has to be one the hardware quote commits to and the code
|
|
697
|
+
// behind that quote has to be the code this SDK pins, not whatever the
|
|
698
|
+
// relay offered.
|
|
699
|
+
const body = { model: chosen.model, messages: chat, max_tokens: maxTokens };
|
|
700
|
+
const plaintext = Buffer.from(JSON.stringify(body), "utf8");
|
|
701
|
+
let keysetDigest = null;
|
|
702
|
+
let seal = null;
|
|
703
|
+
if (e2ee) {
|
|
704
|
+
const established = await this.#establishKeyset(base, expectedWorkload);
|
|
705
|
+
keysetDigest = established.digest;
|
|
706
|
+
// The service rejects a request whose timestamp is more than five minutes
|
|
707
|
+
// old, and the retry budget is longer than that, so each attempt seals
|
|
708
|
+
// its own envelope with a fresh nonce and clock.
|
|
709
|
+
seal = () => encryptChatRequest(body, established.keyset);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Encryption roughly doubles the body, and the relay refuses an oversized
|
|
713
|
+
// one after the payment has been made, so one envelope is built here purely
|
|
714
|
+
// to measure and the attempts seal their own.
|
|
715
|
+
const sized = seal ? seal().bytes : plaintext;
|
|
716
|
+
if (Number.isInteger(chosen.card.max_body_bytes) && sized.length > chosen.card.max_body_bytes) {
|
|
717
|
+
throw new PrismError(413, "request_too_large", {
|
|
718
|
+
required: String(sized.length),
|
|
719
|
+
max: String(chosen.card.max_body_bytes),
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const quote = await this.#confidentialQuote(base, chosen, maxTokens);
|
|
724
|
+
const cap = BigInt(Math.round(maxUsdg * 1e6));
|
|
725
|
+
if (quote.price <= 0n || quote.price > cap) {
|
|
726
|
+
throw new PrismError(402, "cost_exceeds_max", { required: quote.price.toString(), max: cap.toString() });
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const served = await this.payAndPost({
|
|
730
|
+
base,
|
|
731
|
+
path: "/v1/chat/completions",
|
|
732
|
+
price: quote.price,
|
|
733
|
+
payTo: quote.payTo,
|
|
734
|
+
...(seal ? { seal, fingerprint: plaintext } : { body: plaintext }),
|
|
735
|
+
caller: "confidentialInfer",
|
|
736
|
+
});
|
|
737
|
+
const receiptId = served.headers.get("x-receipt-id");
|
|
738
|
+
const sent = served.sent;
|
|
739
|
+
const answer = seal
|
|
740
|
+
? decryptAnswer(served.bytes, sent.clientKey, served.headers, receiptId)
|
|
741
|
+
: JSON.parse(served.bytes.toString("utf8"));
|
|
742
|
+
|
|
743
|
+
// The workload keeps receipts in memory only, so this one is fetched now
|
|
744
|
+
// and kept, whether or not the caller ever verifies it.
|
|
745
|
+
const receipt = receiptId ? await this.#confidentialReceipt(base, receiptId) : null;
|
|
746
|
+
|
|
747
|
+
return {
|
|
748
|
+
model: chosen.model,
|
|
749
|
+
content: answer?.choices?.[0]?.message?.content ?? null,
|
|
750
|
+
usage: answer?.usage ?? null,
|
|
751
|
+
receiptId,
|
|
752
|
+
receipt,
|
|
753
|
+
keysetDigest,
|
|
754
|
+
e2ee: Boolean(seal),
|
|
755
|
+
priceMicros: quote.price.toString(),
|
|
756
|
+
priceUsdg: (Number(quote.price) / 1e6).toFixed(6),
|
|
757
|
+
tx: served.tx,
|
|
758
|
+
bytes: {
|
|
759
|
+
request: sent.bytes,
|
|
760
|
+
response: served.bytes,
|
|
761
|
+
...(seal ? { restoredRequest: sent.restored } : {}),
|
|
762
|
+
},
|
|
763
|
+
verify: (options = {}) =>
|
|
764
|
+
verifyConfidential({
|
|
765
|
+
base,
|
|
766
|
+
model: chosen.model,
|
|
767
|
+
receiptId,
|
|
768
|
+
receipt,
|
|
769
|
+
requestBytes: sent.bytes,
|
|
770
|
+
responseBytes: served.bytes,
|
|
771
|
+
restoredRequestBytes: seal ? sent.restored : null,
|
|
772
|
+
e2ee: Boolean(seal),
|
|
773
|
+
expectedWorkload,
|
|
774
|
+
expectedKeysetDigest: keysetDigest,
|
|
775
|
+
...options,
|
|
776
|
+
}),
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/// The confidential half of the endpoint's rate card, and the model this call
|
|
781
|
+
/// should use. The card also states the caps the endpoint enforces, so a
|
|
782
|
+
/// request it would refuse is refused here instead, before it is paid for.
|
|
783
|
+
async #confidentialModel(base, requested, maxTokens) {
|
|
784
|
+
const offer = await this.#publicJson(`${base}/v1/models`, "inference_endpoint_unavailable");
|
|
785
|
+
const card = offer.confidential;
|
|
786
|
+
const models = Object.keys(card?.models ?? {});
|
|
787
|
+
if (models.length === 0) {
|
|
788
|
+
throw new PrismError(503, "no_confidential_model", { hint: `${base} offers no confidential model right now` });
|
|
789
|
+
}
|
|
790
|
+
const model = requested ?? models[0];
|
|
791
|
+
if (!models.includes(model)) {
|
|
792
|
+
throw new PrismError(400, "unknown_model", { hint: `confidential models: ${models.join(", ")}` });
|
|
793
|
+
}
|
|
794
|
+
if (Number.isInteger(card.max_tokens) && maxTokens > card.max_tokens) {
|
|
795
|
+
throw new PrismError(400, "invalid_max_tokens", { hint: `the endpoint caps max_tokens at ${card.max_tokens}` });
|
|
796
|
+
}
|
|
797
|
+
return { model, card, payTo: offer.pay_to ?? null };
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/// The keyset the enclave's quote commits to, and the code behind that quote.
|
|
801
|
+
/// Only the checks that protect the prompt run here, which is every check that
|
|
802
|
+
/// says who can read it: the quote verifies to Intel's root and commits to
|
|
803
|
+
/// this key set and this nonce, the boot log replays to the measurement that
|
|
804
|
+
/// quote states, and the measured compose runs the pinned launcher and source.
|
|
805
|
+
/// The rest of the transcript, receipt included, runs after the answer comes
|
|
806
|
+
/// back. Anything short of all of that refuses to hand over a prompt.
|
|
807
|
+
async #establishKeyset(base, expectedWorkload = EXPECTED_WORKLOAD) {
|
|
808
|
+
const nonce = Buffer.from(globalThis.crypto.getRandomValues(new Uint8Array(32))).toString("hex");
|
|
809
|
+
const report = await this.#publicJson(`${base}/v1/attestation?nonce=${nonce}`, "attestation_unavailable");
|
|
810
|
+
const binding = await verifyReportBinding(report, nonce);
|
|
811
|
+
if (!binding.ok) {
|
|
812
|
+
const bad = binding.checks.find((c) => !c.ok);
|
|
813
|
+
throw new PrismError(502, "attestation_unverified", { cause: bad?.detail ?? bad?.name });
|
|
814
|
+
}
|
|
815
|
+
// Which code the report describes is appraised before its quote is, so a
|
|
816
|
+
// report naming the wrong workload is refused whatever hardware signed it.
|
|
817
|
+
let measurement;
|
|
818
|
+
try {
|
|
819
|
+
measurement = await verifyComposeMeasurement(report);
|
|
820
|
+
} catch (err) {
|
|
821
|
+
throw new PrismError(502, "attestation_unverified", {
|
|
822
|
+
cause: `the report's boot evidence could not be read: ${err?.message ?? err}`,
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
const composeHash = measurement.checks.find((c) => c.name === "compose_hash");
|
|
826
|
+
if (!composeHash.ok) throw new PrismError(502, "attestation_unverified", { cause: composeHash.detail });
|
|
827
|
+
const identity = await appraiseWorkload(report, measurement, expectedWorkload);
|
|
828
|
+
if (!identity.ok) {
|
|
829
|
+
throw new PrismError(502, "attestation_unverified", {
|
|
830
|
+
cause: identity.detail,
|
|
831
|
+
hint: "the enclave quoting this key set is not running the code this SDK pins, so no prompt was sent to it",
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
const quote = await verifyQuote(report);
|
|
836
|
+
if (!quote.ok) throw new PrismError(502, "quote_unverified", { cause: quote.detail });
|
|
837
|
+
if (quote.status !== "UpToDate") {
|
|
838
|
+
throw new PrismError(502, "quote_unverified", { cause: `the platform TCB is ${quote.status}` });
|
|
839
|
+
}
|
|
840
|
+
// What makes the measurement above authentic: the log replays to the RTMR3
|
|
841
|
+
// the verified quote itself states.
|
|
842
|
+
if (toHex(quote.report.rtMr3) !== toHex(measurement.rtmr3)) {
|
|
843
|
+
throw new PrismError(502, "attestation_unverified", {
|
|
844
|
+
cause: "the boot event log does not replay to the RTMR3 the verified quote states",
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
return { digest: binding.workloadKeysetDigest, keyset: binding.keyset, provenance: identity.provenance ?? null };
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/// The endpoint prices each request itself, so the figure comes from an
|
|
851
|
+
/// unpaid request rather than from arithmetic on the rate card.
|
|
852
|
+
async #confidentialQuote(base, chosen, maxTokens) {
|
|
853
|
+
let res;
|
|
854
|
+
try {
|
|
855
|
+
res = await fetch(`${base}/v1/chat/completions`, {
|
|
856
|
+
method: "POST",
|
|
857
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
858
|
+
body: JSON.stringify({ model: chosen.model, max_tokens: maxTokens }),
|
|
859
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
860
|
+
});
|
|
861
|
+
} catch (err) {
|
|
862
|
+
throw new PrismError(504, "inference_endpoint_unavailable", { cause: err?.message ?? String(err) });
|
|
863
|
+
}
|
|
864
|
+
if (res.status !== 402) {
|
|
865
|
+
throw new PrismError(res.status, "no_quote", { hint: "the endpoint did not answer an unpaid request with a price" });
|
|
866
|
+
}
|
|
867
|
+
const body = await res.json().catch(() => null);
|
|
868
|
+
const accepted = (body?.accepts ?? []).find((a) => a.network === "eip155:4663" || a.network === "robinhood");
|
|
869
|
+
const micros = body?.quote?.price_micros ?? accepted?.amount ?? accepted?.maxAmountRequired;
|
|
870
|
+
const payTo = accepted?.payTo ?? chosen.payTo;
|
|
871
|
+
if (micros == null || !payTo) throw new PrismError(502, "no_quote", { cause: "the 402 named no USDG price to pay" });
|
|
872
|
+
return { price: BigInt(micros), payTo };
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
async #confidentialReceipt(base, receiptId) {
|
|
876
|
+
try {
|
|
877
|
+
return await this.#publicJson(`${base}/v1/receipts/${encodeURIComponent(receiptId)}`, "receipt_unavailable");
|
|
878
|
+
} catch {
|
|
879
|
+
// The generation is paid for and delivered; a receipt that cannot be
|
|
880
|
+
// fetched right now is a verification the caller loses, not a failure of
|
|
881
|
+
// the call. verify() says so plainly when it runs.
|
|
882
|
+
return null;
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
async #publicJson(url, code) {
|
|
887
|
+
let res;
|
|
888
|
+
try {
|
|
889
|
+
res = await fetch(url, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
890
|
+
} catch (err) {
|
|
891
|
+
throw new PrismError(504, code, { cause: err?.message ?? String(err) });
|
|
892
|
+
}
|
|
893
|
+
if (!res.ok) throw new PrismError(res.status, code, { cause: `${url} answered ${res.status}` });
|
|
894
|
+
const body = await res.json().catch(() => null);
|
|
895
|
+
if (body === null) throw new PrismError(502, code, { cause: `${url} answered with something that is not JSON` });
|
|
896
|
+
return body;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// A wallet has one nonce, so two transactions prepared at the same moment are
|
|
900
|
+
// handed the same one and the chain refuses the second. Everything that
|
|
901
|
+
// submits from this wallet queues here, which is what lets one wallet fund
|
|
902
|
+
// several leases at once: they provision in parallel, they just do not sign
|
|
903
|
+
// at the same instant.
|
|
904
|
+
#submitting = Promise.resolve();
|
|
905
|
+
|
|
906
|
+
#submit(send) {
|
|
907
|
+
const done = this.#submitting.then(send, send);
|
|
908
|
+
this.#submitting = done.then(
|
|
909
|
+
() => {},
|
|
910
|
+
() => {},
|
|
911
|
+
);
|
|
912
|
+
return done;
|
|
913
|
+
}
|
|
914
|
+
|
|
496
915
|
#generateSshKey() {
|
|
497
916
|
const dir = mkdtempSync(join(tmpdir(), "prism-ssh-"));
|
|
498
917
|
try {
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Ported from Dstack-TEE/private-ai-gateway clients/verifier-ts @ b6b5c1b, Apache-2.0.
|
|
2
|
+
//
|
|
3
|
+
// Every primitive goes through the Web Crypto API on `globalThis.crypto`, so
|
|
4
|
+
// the same code runs in a browser and in Node 20+ with no dependencies. ACI's
|
|
5
|
+
// only signature algorithm is Ed25519 and its only hash is SHA-256 (spec
|
|
6
|
+
// Appendix B); both are in Web Crypto, so nothing needs injecting.
|
|
7
|
+
import { AciFormatError } from "./errors.mjs";
|
|
8
|
+
|
|
9
|
+
const subtle = globalThis.crypto.subtle;
|
|
10
|
+
|
|
11
|
+
export function toHex(bytes) {
|
|
12
|
+
let out = "";
|
|
13
|
+
for (const b of bytes) out += b.toString(16).padStart(2, "0");
|
|
14
|
+
return out;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function fromHex(hex) {
|
|
18
|
+
const h = hex.startsWith("0x") || hex.startsWith("0X") ? hex.slice(2) : hex;
|
|
19
|
+
if (h.length % 2 !== 0) throw new AciFormatError(`hex string has odd length: ${hex.length} chars`);
|
|
20
|
+
const out = new Uint8Array(h.length / 2);
|
|
21
|
+
for (let i = 0; i < out.length; i++) {
|
|
22
|
+
const byte = Number.parseInt(h.slice(i * 2, i * 2 + 2), 16);
|
|
23
|
+
if (Number.isNaN(byte)) {
|
|
24
|
+
throw new AciFormatError(`invalid hex at offset ${i * 2}: "${h.slice(i * 2, i * 2 + 2)}"`);
|
|
25
|
+
}
|
|
26
|
+
out[i] = byte;
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/// Standard base64 with padding (RFC 4648 §4), the `_b64` field form (Appendix A).
|
|
32
|
+
export function toBase64(bytes) {
|
|
33
|
+
let bin = "";
|
|
34
|
+
for (const b of bytes) bin += String.fromCharCode(b);
|
|
35
|
+
return btoa(bin);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function fromBase64(b64) {
|
|
39
|
+
let bin;
|
|
40
|
+
try {
|
|
41
|
+
bin = atob(b64);
|
|
42
|
+
} catch {
|
|
43
|
+
throw new AciFormatError("invalid base64");
|
|
44
|
+
}
|
|
45
|
+
const out = new Uint8Array(bin.length);
|
|
46
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/// JCS (RFC 8785) bytes of a parsed JSON value under the ACI artifact
|
|
51
|
+
/// constraints (ASCII member names, integer numbers): compact serialization
|
|
52
|
+
/// with sorted member names (§7.2, §8).
|
|
53
|
+
export function jcsBytes(value) {
|
|
54
|
+
return new TextEncoder().encode(JSON.stringify(sortedValue(value)));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function sortedValue(value) {
|
|
58
|
+
if (Array.isArray(value)) return value.map(sortedValue);
|
|
59
|
+
if (value !== null && typeof value === "object") {
|
|
60
|
+
const out = {};
|
|
61
|
+
for (const key of Object.keys(value).sort()) out[key] = sortedValue(value[key]);
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function sha256(bytes) {
|
|
68
|
+
return new Uint8Array(await subtle.digest("SHA-256", bytes));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/// The dstack RTMR replay hash (§9.1 policy).
|
|
72
|
+
export async function sha384(bytes) {
|
|
73
|
+
return new Uint8Array(await subtle.digest("SHA-384", bytes));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function sha256Hex(bytes) {
|
|
77
|
+
return toHex(await sha256(bytes));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/// `sha256:<lowercase-hex>`, the ACI digest form (Appendix A) used for keyset
|
|
81
|
+
/// digests, body hashes and session ids.
|
|
82
|
+
export async function sha256Prefixed(bytes) {
|
|
83
|
+
return `sha256:${await sha256Hex(bytes)}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/// RFC 8032 over `message`. `publicKeyRaw` is the 32-byte raw key, `signature`
|
|
87
|
+
/// the 64-byte value. A bad signature or a malformed key is false, never a throw.
|
|
88
|
+
export async function verifyEd25519(publicKeyRaw, signature, message) {
|
|
89
|
+
let key;
|
|
90
|
+
try {
|
|
91
|
+
key = await subtle.importKey("raw", publicKeyRaw, { name: "Ed25519" }, false, ["verify"]);
|
|
92
|
+
} catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
return await subtle.verify({ name: "Ed25519" }, key, signature, message);
|
|
97
|
+
} catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|