@prismnetwork/agent-sdk 0.7.9 → 0.7.11
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/attest.mjs +1 -1
- package/decision.mjs +130 -0
- package/e2ee.mjs +6 -1
- package/package.json +5 -1
- package/prism.mjs +3 -2
package/attest.mjs
CHANGED
|
@@ -56,7 +56,7 @@ export const EXPECTED_WORKLOAD = {
|
|
|
56
56
|
"ghcr.io/redpill-ai/private-ai-launcher@sha256:c083ff9e6a5ddf10f6c9e9bb1f74cc618deebecfea5208b563c574399db4637c",
|
|
57
57
|
repoUrl: "https://github.com/Dstack-TEE/private-ai-gateway.git",
|
|
58
58
|
osImageHash: "bd369a8c2f9edb2b52dad48ac8e0b32dde5f1337c423a506b48d07403a7d8033",
|
|
59
|
-
repoCommit: "
|
|
59
|
+
repoCommit: "3e56bd30dd459d0df90afeb6d63eca7a919bc22f",
|
|
60
60
|
};
|
|
61
61
|
|
|
62
62
|
const NRAS_ATTEST_URL = "https://nras.attestation.nvidia.com/v3/attest/gpu";
|
package/decision.mjs
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/// Authorise a spend before it happens, and bind the reason to the lease.
|
|
2
|
+
///
|
|
3
|
+
/// An agent with a wallet funds an escrow on its own judgement. The operator who
|
|
4
|
+
/// owns that wallet gets the bill and, today, no answer to "why did it spend
|
|
5
|
+
/// this". A log written afterwards by the process that spent the money is worth
|
|
6
|
+
/// what that process says it is worth.
|
|
7
|
+
///
|
|
8
|
+
/// So the reason is decided first, checked against a policy the caller wrote,
|
|
9
|
+
/// and hashed into the one field the escrow already commits on chain. A lease
|
|
10
|
+
/// that was not authorised is never funded, and one that was carries a
|
|
11
|
+
/// reference nobody can recompute without producing the decision behind it.
|
|
12
|
+
///
|
|
13
|
+
/// What this proves: a specific decision existed before the deposit was
|
|
14
|
+
/// broadcast, and the caller's own thresholds admitted it. What it does not: any
|
|
15
|
+
/// claim that the decision was sound. A confidently wrong judgement binds
|
|
16
|
+
/// exactly as well as a correct one.
|
|
17
|
+
///
|
|
18
|
+
/// The decision can come from anywhere. A System One model returning calibrated
|
|
19
|
+
/// probabilities, a general model, or a rule in the caller's own code all
|
|
20
|
+
/// produce the same record, and none of them is a dependency of this SDK.
|
|
21
|
+
/// Nothing but a hash leaves the caller's process.
|
|
22
|
+
import { keccak256, stringToBytes, concatBytes, hexToBytes } from "viem";
|
|
23
|
+
|
|
24
|
+
const NAME = /^[a-z][a-z0-9_]{0,63}$/;
|
|
25
|
+
|
|
26
|
+
export class DecisionRefused extends Error {
|
|
27
|
+
constructor(reasons) {
|
|
28
|
+
super(reasons.join("; "));
|
|
29
|
+
this.name = "DecisionRefused";
|
|
30
|
+
this.reasons = reasons;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/// One typed answer. `value` is the selected option, the score, or the
|
|
35
|
+
/// probability that a yes/no question is yes. `confidence` is how sure the
|
|
36
|
+
/// source is that the value is right, which is a different axis and is usually
|
|
37
|
+
/// what a spending threshold should read.
|
|
38
|
+
export function answer(name, value, confidence = null) {
|
|
39
|
+
if (!NAME.test(name)) throw new Error(`answer name ${JSON.stringify(name)} is not a lowercase identifier`);
|
|
40
|
+
if (confidence !== null && !(confidence >= 0 && confidence <= 1)) {
|
|
41
|
+
throw new Error(`${name}: confidence ${confidence} is outside 0..1`);
|
|
42
|
+
}
|
|
43
|
+
return { name, value, confidence };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// Why a spend is about to happen. `source` names what judged, for example
|
|
47
|
+
/// "jev-1.13.0" or "policy:cpu-first". It is recorded rather than trusted: this
|
|
48
|
+
/// SDK cannot tell a model's answer from one a caller typed, and does not
|
|
49
|
+
/// pretend to.
|
|
50
|
+
export function decision({ action, source, answers = [], policyId = null }) {
|
|
51
|
+
if (!action?.trim()) throw new Error("a decision needs an action");
|
|
52
|
+
if (!source?.trim()) throw new Error("a decision needs a source, so the record says what judged");
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
for (const a of answers) {
|
|
55
|
+
if (seen.has(a.name)) throw new Error(`duplicate answer ${JSON.stringify(a.name)}`);
|
|
56
|
+
seen.add(a.name);
|
|
57
|
+
}
|
|
58
|
+
return { action, source, answers, policyId };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// The exact bytes that get hashed. Sorted by name and separator-pinned, so the
|
|
62
|
+
/// same decision produces the same reference in any language. The Python SDK
|
|
63
|
+
/// builds this byte for byte; a cross-language mismatch here would make a lease
|
|
64
|
+
/// funded by one unverifiable by the other.
|
|
65
|
+
export function canonical(d) {
|
|
66
|
+
return stringToBytes(JSON.stringify({
|
|
67
|
+
action: d.action,
|
|
68
|
+
source: d.source,
|
|
69
|
+
policy_id: d.policyId ?? null,
|
|
70
|
+
answers: [...d.answers]
|
|
71
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
72
|
+
.map((a) => ({ name: a.name, value: a.value, confidence: a.confidence ?? null })),
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/// Every reason this decision is not authorised. Empty means it is.
|
|
77
|
+
export function refusals(policy, d) {
|
|
78
|
+
const reasons = [];
|
|
79
|
+
const allow = policy.allow ?? [];
|
|
80
|
+
if (allow.length && !allow.includes(d.action)) {
|
|
81
|
+
reasons.push(
|
|
82
|
+
`action ${JSON.stringify(d.action)} is not in the policy's allowed set (${[...allow].sort().join(", ")})`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
if (d.policyId != null && d.policyId !== policy.policyId) {
|
|
86
|
+
reasons.push(`decision cites policy ${JSON.stringify(d.policyId)}, this is ${JSON.stringify(policy.policyId)}`);
|
|
87
|
+
}
|
|
88
|
+
for (const name of [...(policy.require ?? [])].sort()) {
|
|
89
|
+
if (!d.answers.some((a) => a.name === name)) {
|
|
90
|
+
reasons.push(`policy requires an answer for ${JSON.stringify(name)} and none was given`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
for (const [name, floor] of Object.entries(policy.minimums ?? {}).sort()) {
|
|
94
|
+
const a = d.answers.find((x) => x.name === name);
|
|
95
|
+
if (!a) reasons.push(`policy sets a floor for ${JSON.stringify(name)} and no answer was given`);
|
|
96
|
+
else if (a.confidence == null) {
|
|
97
|
+
reasons.push(`${JSON.stringify(name)} carries no confidence, so the ${floor} floor cannot be met`);
|
|
98
|
+
} else if (a.confidence < floor) {
|
|
99
|
+
reasons.push(`${JSON.stringify(name)} confidence ${a.confidence} is under the ${floor} floor`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return reasons;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/// Return the decision, or throw before anything is signed.
|
|
106
|
+
export function authorise(policy, d) {
|
|
107
|
+
const reasons = refusals(policy, d);
|
|
108
|
+
if (reasons.length) throw new DecisionRefused(reasons);
|
|
109
|
+
return d;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/// The bytes32 the escrow records for this lease.
|
|
113
|
+
///
|
|
114
|
+
/// Without a decision this is what it has always been, the hash of the quote id,
|
|
115
|
+
/// so an unauthorised caller's leases stay byte-identical to before.
|
|
116
|
+
///
|
|
117
|
+
/// With one, the quote id is hashed together with the decision's digest. The
|
|
118
|
+
/// escrow refuses a reference it has already seen, so the quote id has to stay
|
|
119
|
+
/// in the preimage: two runs of the same decision are two different leases and
|
|
120
|
+
/// must not collide.
|
|
121
|
+
export function leaseReference(quoteId, d) {
|
|
122
|
+
const quoteDigest = keccak256(stringToBytes(quoteId));
|
|
123
|
+
if (!d) return quoteDigest;
|
|
124
|
+
return keccak256(concatBytes([hexToBytes(quoteDigest), hexToBytes(keccak256(canonical(d)))]));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/// Whether this decision is the one that funded that lease.
|
|
128
|
+
export function referenceMatches(quoteId, d, reference) {
|
|
129
|
+
return leaseReference(quoteId, d).toLowerCase() === String(reference).toLowerCase();
|
|
130
|
+
}
|
package/e2ee.mjs
CHANGED
|
@@ -156,7 +156,12 @@ export function encryptChatRequest(body, keyset, { now = Math.floor(Date.now() /
|
|
|
156
156
|
if (typeof message?.content !== "string") {
|
|
157
157
|
// Any plaintext string at a protected path fails the request upstream, so
|
|
158
158
|
// a body this client cannot fully protect is refused here instead.
|
|
159
|
-
throw new E2eeError(
|
|
159
|
+
throw new E2eeError(
|
|
160
|
+
`messages.${index}.content is not a string, and the sealed envelope covers string ` +
|
|
161
|
+
"content only: the protocol binds each ciphertext to the field path " +
|
|
162
|
+
"messages.N.content, which a content-parts array has no equivalent of. An image " +
|
|
163
|
+
"message can still be served in the enclave with e2ee off, where the relay sees it.",
|
|
164
|
+
);
|
|
160
165
|
}
|
|
161
166
|
const field = `messages.${index}.content`;
|
|
162
167
|
const aad = requestAad({ algo: serviceKey.algo, model: body.model, field, nonce, ts });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prismnetwork/agent-sdk",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.11",
|
|
4
4
|
"description": "Headless GPU leasing and renter-encrypted storage on Prism Network for wallet-holding agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "prism.mjs",
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
"types": "./prism.d.mts",
|
|
10
10
|
"default": "./prism.mjs"
|
|
11
11
|
},
|
|
12
|
+
"./decision": {
|
|
13
|
+
"default": "./decision.mjs"
|
|
14
|
+
},
|
|
12
15
|
"./attest": {
|
|
13
16
|
"types": "./attest.d.mts",
|
|
14
17
|
"default": "./attest.mjs"
|
|
@@ -39,6 +42,7 @@
|
|
|
39
42
|
"prism.d.mts",
|
|
40
43
|
"attest.mjs",
|
|
41
44
|
"attest.d.mts",
|
|
45
|
+
"decision.mjs",
|
|
42
46
|
"e2ee.mjs",
|
|
43
47
|
"e2ee.d.mts",
|
|
44
48
|
"hostkey.mjs",
|
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 { authorise, leaseReference } from "./decision.mjs";
|
|
4
5
|
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
5
6
|
import { tmpdir } from "node:os";
|
|
6
7
|
import { join } from "node:path";
|
|
@@ -274,13 +275,13 @@ export class PrismAgent {
|
|
|
274
275
|
return this.#submit(() => this.#fundNow(quote));
|
|
275
276
|
}
|
|
276
277
|
|
|
277
|
-
async #fundNow(quote) {
|
|
278
|
+
async #fundNow(quote, decision = null) {
|
|
278
279
|
if (typeof quote?.quote_id !== "string" || typeof quote?.node_id !== "string") {
|
|
279
280
|
throw new PrismError(400, "invalid_quote");
|
|
280
281
|
}
|
|
281
282
|
const deposit = parseBaseUnits(quote.maximum_escrow, "maximum_escrow");
|
|
282
283
|
const duration = parseDuration(quote.duration_seconds);
|
|
283
|
-
const clientReference =
|
|
284
|
+
const clientReference = leaseReference(quote.quote_id, decision);
|
|
284
285
|
let broadcast = null;
|
|
285
286
|
try {
|
|
286
287
|
// Approving and spending are one indivisible step. The approval covers
|