@prismnetwork/mcp 0.8.4 → 0.9.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 +22 -38
- package/package.json +9 -11
- package/server.mjs +228 -203
- package/budget.mjs +0 -257
package/README.md
CHANGED
|
@@ -13,20 +13,19 @@ claude mcp add prism -- npx -y @prismnetwork/mcp
|
|
|
13
13
|
Then ask what you can rent. `prism_list_gpus` answers from live capacity with
|
|
14
14
|
real prices. Leasing spends money, so those tools ask for a wallet and say so.
|
|
15
15
|
|
|
16
|
-
Packaged for Claude Code and Codex as the [Prism plugin](https://plugins.prismnetwork.tech),
|
|
17
|
-
which wires this server up and prompts for the wallet at install time.
|
|
18
|
-
|
|
19
16
|
## Tools
|
|
20
17
|
|
|
21
18
|
| Tool | Wallet |
|
|
22
19
|
| --- | --- |
|
|
23
|
-
| `prism_budget` | no |
|
|
24
20
|
| `prism_list_gpus` | no |
|
|
25
21
|
| `prism_price_index` | no |
|
|
26
22
|
| `prism_receipts` | no |
|
|
27
23
|
| `prism_wallet` | yes |
|
|
28
24
|
| `prism_leases` | yes |
|
|
29
25
|
| `prism_infer` | yes |
|
|
26
|
+
| `prism_infer_batch` | yes |
|
|
27
|
+
| `prism_confidential_infer` | yes |
|
|
28
|
+
| `prism_verify_attestation` | no |
|
|
30
29
|
| `prism_lease_and_run` | yes |
|
|
31
30
|
| `prism_lease` | yes |
|
|
32
31
|
| `prism_run` | yes |
|
|
@@ -38,8 +37,6 @@ which wires this server up and prompts for the wallet at install time.
|
|
|
38
37
|
| `prism_vault_delete` | yes |
|
|
39
38
|
| `prism_vault_release` | yes |
|
|
40
39
|
|
|
41
|
-
- `prism_budget`: the spending limits in force and what has already been spent
|
|
42
|
-
today, with the recent charges.
|
|
43
40
|
- `prism_wallet`: the agent's address and USDG/ETH balances.
|
|
44
41
|
- `prism_list_gpus`: GPUs available to lease, with price per second and per hour.
|
|
45
42
|
- `prism_price_index`: sourced and settled pricing per GPU model, for cost estimates.
|
|
@@ -50,6 +47,23 @@ which wires this server up and prompts for the wallet at install time.
|
|
|
50
47
|
paying the quoted USDG price from this wallet (about 0.01 USDG). Waits
|
|
51
48
|
through a cold start; an unconsumed payment is kept and reused on the next
|
|
52
49
|
call instead of paying twice.
|
|
50
|
+
- `prism_infer_batch`: buy many generations in one paid call, at the
|
|
51
|
+
single-generation price times the number of prompts. Each prompt runs whole on
|
|
52
|
+
a rented GPU and they are spread across every GPU the endpoint holds, so a
|
|
53
|
+
list of prompts finishes far sooner than the same prompts sent one at a time.
|
|
54
|
+
Returns every answer in order with a Merkle receipt naming the leases that did
|
|
55
|
+
the work.
|
|
56
|
+
- `prism_confidential_infer`: buy one generation that runs inside a GPU TEE.
|
|
57
|
+
The message contents are encrypted to a key the enclave's own attestation
|
|
58
|
+
commits to, so this server's relay carries ciphertext and cannot read the
|
|
59
|
+
prompt or the answer. Returns the answer, the cost and a receipt id the
|
|
60
|
+
workload signed over the exact bytes of the exchange.
|
|
61
|
+
- `prism_verify_attestation`: check one of those generations against its
|
|
62
|
+
receipt and the hardware behind it. The server remembers the digests of the
|
|
63
|
+
last few confidential calls, so the verdict is bound to the bytes it actually
|
|
64
|
+
sent and received. Every check is reported with its result, including the two
|
|
65
|
+
that cannot be established today: private-key custody, and where TLS
|
|
66
|
+
terminates.
|
|
53
67
|
- `prism_lease_and_run`: lease a GPU, run a command, return the output (one shot).
|
|
54
68
|
- `prism_lease`: lease a GPU and keep it; returns a `lease_id` and SSH access.
|
|
55
69
|
- `prism_run`: run a command on an existing lease.
|
|
@@ -63,29 +77,6 @@ which wires this server up and prompts for the wallet at install time.
|
|
|
63
77
|
- `prism_vault_delete`: permanently delete an item.
|
|
64
78
|
- `prism_vault_release`: authorize an item into a lease that clears its trust floor.
|
|
65
79
|
|
|
66
|
-
## Spending limits
|
|
67
|
-
|
|
68
|
-
Two ceilings bound what this server can spend, and a refusal quotes both.
|
|
69
|
-
|
|
70
|
-
| Variable | Default | What it bounds |
|
|
71
|
-
| --- | --- | --- |
|
|
72
|
-
| `PRISM_MAX_USDG` | 1 | Any single lease or generation. `max_usdg` on a call may lower it, never raise it past this. |
|
|
73
|
-
| `PRISM_DAILY_BUDGET_USDG` | 5 | Everything in a rolling 24 hours. `0` removes the ceiling. |
|
|
74
|
-
| `PRISM_LEDGER_PATH` | `~/.prism/spend.json` | Where the spend is recorded. |
|
|
75
|
-
|
|
76
|
-
Spend is written before the money moves, so a crash between funding an escrow
|
|
77
|
-
and answering is counted rather than forgiven, and a restart does not hand back
|
|
78
|
-
a fresh day's allowance. Only an attempt that provably never reached the chain
|
|
79
|
-
is reverted. Two clients pointed at one wallet share one ceiling, because they
|
|
80
|
-
share the ledger file.
|
|
81
|
-
|
|
82
|
-
None of this is the real limit. Fund a dedicated wallet with what you are
|
|
83
|
-
willing to lose: that balance is what survives a bug in everything above.
|
|
84
|
-
|
|
85
|
-
Tools that spend are annotated `destructiveHint` and carry
|
|
86
|
-
`anthropic/requiresUserInteraction`, so Claude Code asks before every one of
|
|
87
|
-
them even in modes that otherwise approve tools automatically.
|
|
88
|
-
|
|
89
80
|
## Vault
|
|
90
81
|
|
|
91
82
|
An agent that handles a card, an identity document or a credential should not
|
|
@@ -111,8 +102,7 @@ Point your MCP client (Claude Desktop / Code) at the published server:
|
|
|
111
102
|
"args": ["-y", "@prismnetwork/mcp"],
|
|
112
103
|
"env": {
|
|
113
104
|
"PRISM_AGENT_KEY": "0x<agent wallet private key>",
|
|
114
|
-
"
|
|
115
|
-
"PRISM_DAILY_BUDGET_USDG": "5"
|
|
105
|
+
"PRISM_ESCROW": "0x62C042265991bEa17B07229322A01850974626dA"
|
|
116
106
|
}
|
|
117
107
|
}
|
|
118
108
|
}
|
|
@@ -125,17 +115,11 @@ Or add it to Claude Code in one line:
|
|
|
125
115
|
|
|
126
116
|
```sh
|
|
127
117
|
claude mcp add prism \
|
|
118
|
+
--env PRISM_ESCROW=0x62C042265991bEa17B07229322A01850974626dA \
|
|
128
119
|
--env PRISM_AGENT_KEY=0x<agent wallet private key> \
|
|
129
|
-
--env PRISM_DAILY_BUDGET_USDG=5 \
|
|
130
120
|
-- npx -y @prismnetwork/mcp
|
|
131
121
|
```
|
|
132
122
|
|
|
133
|
-
Codex takes the same server:
|
|
134
|
-
|
|
135
|
-
```sh
|
|
136
|
-
codex mcp add prism --env PRISM_AGENT_KEY=0x<agent wallet private key> -- npx -y @prismnetwork/mcp
|
|
137
|
-
```
|
|
138
|
-
|
|
139
123
|
## Timing
|
|
140
124
|
|
|
141
125
|
`prism_lease` and `prism_lease_and_run` block while a GPU provisions (usually one to four minutes, occasionally longer on a slow host). Configure your MCP client to allow long tool calls.
|
package/package.json
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prismnetwork/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "MCP server for leasing and running on Prism Network GPUs.",
|
|
5
|
-
"mcpName": "
|
|
5
|
+
"mcpName": "io.github.prismnetwork-tech/mcp",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
8
|
"prism-mcp": "server.mjs"
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"server.mjs",
|
|
12
|
-
"budget.mjs",
|
|
13
12
|
"README.md"
|
|
14
13
|
],
|
|
15
14
|
"engines": {
|
|
@@ -17,19 +16,18 @@
|
|
|
17
16
|
},
|
|
18
17
|
"dependencies": {
|
|
19
18
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
20
|
-
"@
|
|
19
|
+
"@phala/dcap-qvl": "^0.6.1",
|
|
20
|
+
"@prismnetwork/agent-sdk": "^0.7.0",
|
|
21
|
+
"jose": "^6",
|
|
21
22
|
"viem": "^2"
|
|
22
23
|
},
|
|
23
24
|
"keywords": [
|
|
25
|
+
"prism",
|
|
26
|
+
"mcp",
|
|
27
|
+
"gpu",
|
|
24
28
|
"agent",
|
|
25
29
|
"claude",
|
|
26
|
-
"
|
|
27
|
-
"compute",
|
|
28
|
-
"gpu",
|
|
29
|
-
"gpu-rental",
|
|
30
|
-
"mcp",
|
|
31
|
-
"prism",
|
|
32
|
-
"x402"
|
|
30
|
+
"compute"
|
|
33
31
|
],
|
|
34
32
|
"homepage": "https://prismnetwork.tech",
|
|
35
33
|
"repository": {
|
package/server.mjs
CHANGED
|
@@ -2,13 +2,17 @@
|
|
|
2
2
|
// Prism Network MCP server: lets an MCP client (Claude, agents) see and lease
|
|
3
3
|
// real GPUs. Looking is free and needs no configuration. Leasing spends money,
|
|
4
4
|
// so it needs a wallet: PRISM_AGENT_KEY, PRISM_ESCROW.
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
5
6
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
6
7
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
7
8
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_IMAGE,
|
|
11
|
+
DEFAULT_TRUST_FLOOR,
|
|
12
|
+
PrismAgent,
|
|
13
|
+
TRUST_CLASSES,
|
|
14
|
+
verifyConfidential,
|
|
15
|
+
} from "@prismnetwork/agent-sdk";
|
|
12
16
|
|
|
13
17
|
const IMAGE = process.env.PRISM_DEFAULT_IMAGE ?? DEFAULT_IMAGE;
|
|
14
18
|
|
|
@@ -44,18 +48,6 @@ if (!agent) {
|
|
|
44
48
|
);
|
|
45
49
|
}
|
|
46
50
|
|
|
47
|
-
// A budget the operator got wrong must stop spending, not fall back to none.
|
|
48
|
-
// Reading capacity and prices is unaffected, so a typo is discoverable rather
|
|
49
|
-
// than fatal.
|
|
50
|
-
let ledger = null;
|
|
51
|
-
let budgetProblem = null;
|
|
52
|
-
try {
|
|
53
|
-
ledger = new SpendLedger(readBudget());
|
|
54
|
-
} catch (err) {
|
|
55
|
-
budgetProblem = err?.message ?? String(err);
|
|
56
|
-
console.error(`prism mcp: ${budgetProblem}`);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
51
|
function requireWallet(tool, reason = "spends money") {
|
|
60
52
|
if (!agent) {
|
|
61
53
|
throw new Error(
|
|
@@ -65,43 +57,6 @@ function requireWallet(tool, reason = "spends money") {
|
|
|
65
57
|
return agent;
|
|
66
58
|
}
|
|
67
59
|
|
|
68
|
-
function requireLedger(tool) {
|
|
69
|
-
if (!ledger) {
|
|
70
|
-
throw new Error(`${tool} needs the spend limits, and they are unusable: ${budgetProblem}`);
|
|
71
|
-
}
|
|
72
|
-
return ledger;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Records the spend before the money moves, then reconciles. An attempt that
|
|
76
|
-
// never reached the chain is reverted; anything that funded an escrow keeps its
|
|
77
|
-
// entry and gains the transaction that proves it, because a ledger that forgets
|
|
78
|
-
// a funded lease is worse than no ledger at all.
|
|
79
|
-
async function spending(tool, micros, run) {
|
|
80
|
-
const book = requireLedger(tool);
|
|
81
|
-
const id = book.commit({ tool, micros });
|
|
82
|
-
// Reconciling is bookkeeping and must never be the reason a caller loses a
|
|
83
|
-
// machine it paid for, or the reason a failure is reported as the wrong
|
|
84
|
-
// failure. A ledger that cannot be written says so on stderr and the
|
|
85
|
-
// committed figure stands, which errs towards counting the spend.
|
|
86
|
-
const reconcile = (action, ...args) => {
|
|
87
|
-
try {
|
|
88
|
-
book[action](id, ...args);
|
|
89
|
-
} catch (err) {
|
|
90
|
-
console.error(`prism mcp: could not ${action} the ledger entry for ${tool}: ${err?.message ?? err}`);
|
|
91
|
-
}
|
|
92
|
-
};
|
|
93
|
-
try {
|
|
94
|
-
const { value, settledMicros, reference } = await run();
|
|
95
|
-
reconcile("settle", { micros: settledMicros, reference });
|
|
96
|
-
return value;
|
|
97
|
-
} catch (err) {
|
|
98
|
-
const funded = err?.body?.funding_hash;
|
|
99
|
-
if (funded) reconcile("settle", { reference: funded });
|
|
100
|
-
else reconcile("revert");
|
|
101
|
-
throw err;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
60
|
function requireCommand(value) {
|
|
106
61
|
if (typeof value !== "string" || value.trim() === "") {
|
|
107
62
|
throw new Error("command is required: the shell command to run on the GPU, e.g. 'nvidia-smi'.");
|
|
@@ -112,20 +67,8 @@ function requireCommand(value) {
|
|
|
112
67
|
return value;
|
|
113
68
|
}
|
|
114
69
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
// USDG cap, so a 5 USDG day bought ten leases where it could afford twenty-five.
|
|
118
|
-
function escrowed(quote) {
|
|
119
|
-
const held = Number(quote?.maximum_escrow);
|
|
120
|
-
return Number.isFinite(held) && held > 0 ? held : undefined;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// The per-call ceiling is the operator's, not the model's: an omitted max_usdg
|
|
124
|
-
// takes the configured cap rather than a hardcoded one, and a stated max_usdg
|
|
125
|
-
// above it is refused when the ledger checks the commit.
|
|
126
|
-
function maxDeposit(tool, args) {
|
|
127
|
-
if (args.max_usdg === undefined) return requireLedger(tool).maxPerCallMicros;
|
|
128
|
-
const cap = args.max_usdg;
|
|
70
|
+
function maxDeposit(args) {
|
|
71
|
+
const cap = args.max_usdg ?? 1;
|
|
129
72
|
if (typeof cap !== "number" || !Number.isFinite(cap) || cap <= 0) {
|
|
130
73
|
throw new Error("max_usdg must be a positive number of USDG.");
|
|
131
74
|
}
|
|
@@ -146,13 +89,56 @@ async function publicJson(url, what) {
|
|
|
146
89
|
}
|
|
147
90
|
|
|
148
91
|
const leases = new Map();
|
|
149
|
-
// Unconsumed inference payments, keyed by endpoint and price, so a failed
|
|
150
|
-
// generation is retried with the same paid header instead of paying again.
|
|
151
|
-
const pendingInference = new Map();
|
|
152
92
|
// null in, null out: a missing price must never render as 0.000000 USDG.
|
|
153
93
|
const usdg = (micros) =>
|
|
154
94
|
micros == null || !Number.isFinite(Number(micros)) ? null : `${(Number(micros) / 1e6).toFixed(6)} USDG`;
|
|
155
95
|
|
|
96
|
+
/// The SDK pays once and keeps the payment until the endpoint actually serves,
|
|
97
|
+
/// so a generation that never happened is retried with the payment already made.
|
|
98
|
+
async function payAndPost({ base, path, price, payTo, body, tool }) {
|
|
99
|
+
const served = await agent.payAndPost({ base, path, price, payTo, body, caller: tool });
|
|
100
|
+
return { ...JSON.parse(served.bytes.toString("utf8")), paid: usdg(price), payment_tx: served.tx };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// What the last few confidential calls sent and received, as digests only, so
|
|
104
|
+
// prism_verify_attestation binds its verdict to the real bytes of a call
|
|
105
|
+
// without keeping anyone's prompt in memory.
|
|
106
|
+
const confidentialCalls = new Map();
|
|
107
|
+
const CONFIDENTIAL_HISTORY = 16;
|
|
108
|
+
|
|
109
|
+
const sha256Prefixed = (bytes) => `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
110
|
+
|
|
111
|
+
function rememberConfidentialCall(receiptId, record) {
|
|
112
|
+
if (!receiptId) return;
|
|
113
|
+
confidentialCalls.set(receiptId, record);
|
|
114
|
+
while (confidentialCalls.size > CONFIDENTIAL_HISTORY) {
|
|
115
|
+
confidentialCalls.delete(confidentialCalls.keys().next().value);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const inferenceBase = () =>
|
|
120
|
+
(process.env.PRISM_INFERENCE_URL ?? "https://api.prismnetwork.tech/inference").replace(/\/$/, "");
|
|
121
|
+
|
|
122
|
+
/// What the endpoint currently offers, and the model this call should use.
|
|
123
|
+
async function inferenceOffer(base, requested) {
|
|
124
|
+
const offer = await publicJson(`${base}/v1/models`, "inference endpoint");
|
|
125
|
+
const model = requested ?? offer.models?.[0];
|
|
126
|
+
if (!model || (Array.isArray(offer.models) && !offer.models.includes(model))) {
|
|
127
|
+
throw new Error(`model must be one of ${offer.models?.join(", ") ?? "(endpoint offered none)"}`);
|
|
128
|
+
}
|
|
129
|
+
return { offer, model, unit: BigInt(offer.price_micros ?? 0) };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function withinCap(price, maxUsdg, fallback, what) {
|
|
133
|
+
const cap = maxUsdg ?? fallback;
|
|
134
|
+
if (typeof cap !== "number" || !Number.isFinite(cap) || cap <= 0) {
|
|
135
|
+
throw new Error("max_usdg must be a positive number of USDG.");
|
|
136
|
+
}
|
|
137
|
+
if (price <= 0n || price > BigInt(Math.round(cap * 1e6))) {
|
|
138
|
+
throw new Error(`the endpoint quotes ${usdg(price)} ${what}, past the ${cap} USDG cap.`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
156
142
|
function sweepExpiredLeases() {
|
|
157
143
|
const now = Date.now();
|
|
158
144
|
for (const [id, lease] of leases) {
|
|
@@ -170,28 +156,11 @@ function leaseId(value) {
|
|
|
170
156
|
return id;
|
|
171
157
|
}
|
|
172
158
|
|
|
173
|
-
// Hints a client uses to decide what it may run unattended. `reads` is anything
|
|
174
|
-
// that cannot change state or move money; `spends` is anything that can, and it
|
|
175
|
-
// carries the Claude Code marker that forces a confirmation prompt on every call
|
|
176
|
-
// even in modes that otherwise auto-approve.
|
|
177
|
-
const reads = { readOnlyHint: true, openWorldHint: true };
|
|
178
|
-
const spends = {
|
|
179
|
-
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
180
|
-
_meta: { "anthropic/requiresUserInteraction": true },
|
|
181
|
-
};
|
|
182
|
-
|
|
183
159
|
const TOOLS = [
|
|
184
|
-
{
|
|
185
|
-
name: "prism_budget",
|
|
186
|
-
description: "Show the spending limits this server enforces and what it has already spent in the last 24 hours, with the recent charges. Needs no wallet. Check this before a long job; a lease refused for budget says the same numbers.",
|
|
187
|
-
inputSchema: { type: "object", properties: {} },
|
|
188
|
-
annotations: { title: "Spending limits", readOnlyHint: true, openWorldHint: false },
|
|
189
|
-
},
|
|
190
160
|
{
|
|
191
161
|
name: "prism_wallet",
|
|
192
162
|
description: "Show the agent's wallet address and on-chain balances (USDG and ETH for gas) on Robinhood Chain. Check this before leasing to confirm the wallet can pay.",
|
|
193
163
|
inputSchema: { type: "object", properties: {} },
|
|
194
|
-
annotations: { title: "Wallet balances", ...reads },
|
|
195
164
|
},
|
|
196
165
|
{
|
|
197
166
|
name: "prism_list_gpus",
|
|
@@ -206,13 +175,11 @@ const TOOLS = [
|
|
|
206
175
|
},
|
|
207
176
|
},
|
|
208
177
|
},
|
|
209
|
-
annotations: { title: "Available GPUs", ...reads },
|
|
210
178
|
},
|
|
211
179
|
{
|
|
212
180
|
name: "prism_price_index",
|
|
213
181
|
description: "Current GPU pricing on Prism Network by model: sourced low/median/high and settled mean, in USDG per hour. Needs no wallet. Use it to estimate what an analysis job will cost before leasing.",
|
|
214
182
|
inputSchema: { type: "object", properties: {} },
|
|
215
|
-
annotations: { title: "GPU price index", ...reads },
|
|
216
183
|
},
|
|
217
184
|
{
|
|
218
185
|
name: "prism_receipts",
|
|
@@ -223,13 +190,11 @@ const TOOLS = [
|
|
|
223
190
|
limit: { type: "integer", description: "Max receipts to return (default 10, max 50)." },
|
|
224
191
|
},
|
|
225
192
|
},
|
|
226
|
-
annotations: { title: "Settled receipts", ...reads },
|
|
227
193
|
},
|
|
228
194
|
{
|
|
229
195
|
name: "prism_leases",
|
|
230
196
|
description: "List this wallet's leases on Prism Network with their current state.",
|
|
231
197
|
inputSchema: { type: "object", properties: {} },
|
|
232
|
-
annotations: { title: "Your leases", ...reads },
|
|
233
198
|
},
|
|
234
199
|
{
|
|
235
200
|
name: "prism_batch_run",
|
|
@@ -244,8 +209,6 @@ const TOOLS = [
|
|
|
244
209
|
},
|
|
245
210
|
required: ["command"],
|
|
246
211
|
},
|
|
247
|
-
...spends,
|
|
248
|
-
annotations: { title: "Rent a GPU for one command", ...spends.annotations },
|
|
249
212
|
},
|
|
250
213
|
{
|
|
251
214
|
name: "prism_batch_result",
|
|
@@ -257,7 +220,6 @@ const TOOLS = [
|
|
|
257
220
|
},
|
|
258
221
|
required: ["lease_id"],
|
|
259
222
|
},
|
|
260
|
-
annotations: { title: "Batch result", ...reads },
|
|
261
223
|
},
|
|
262
224
|
{
|
|
263
225
|
name: "prism_infer",
|
|
@@ -271,8 +233,52 @@ const TOOLS = [
|
|
|
271
233
|
},
|
|
272
234
|
required: ["prompt"],
|
|
273
235
|
},
|
|
274
|
-
|
|
275
|
-
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
name: "prism_infer_batch",
|
|
239
|
+
description: "Buy many LLM generations from Prism's managed inference endpoint in one paid call. Every prompt runs whole on a rented GPU, spread across every GPU the endpoint holds, so a list of prompts finishes far sooner than the same prompts sent one at a time. Costs the single-generation price times the number of prompts. Returns every answer in order plus a Merkle receipt naming the leases that did the work. Use it for evals, dataset passes, rollouts, or anything with more than a handful of independent prompts.",
|
|
240
|
+
inputSchema: {
|
|
241
|
+
type: "object",
|
|
242
|
+
properties: {
|
|
243
|
+
prompts: {
|
|
244
|
+
type: "array",
|
|
245
|
+
items: { type: "string" },
|
|
246
|
+
minItems: 1,
|
|
247
|
+
maxItems: 64,
|
|
248
|
+
description: "Independent prompts, answered in the order given (each max 32 KiB).",
|
|
249
|
+
},
|
|
250
|
+
model: { type: "string", description: "Model to use; defaults to the endpoint's first offered model." },
|
|
251
|
+
max_usdg: { type: "number", description: "Refuse if the quoted total exceeds this (default 0.5)." },
|
|
252
|
+
},
|
|
253
|
+
required: ["prompts"],
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
name: "prism_confidential_infer",
|
|
258
|
+
description: "Buy one LLM generation that runs inside a GPU TEE, with the message contents encrypted end to end to a key the enclave's own attestation commits to, so Prism's relay in between carries ciphertext and cannot read the prompt or the answer. Costs a little more than prism_infer. Returns the answer, the cost, and a receipt id the workload signed over the exact bytes of the exchange; pass that id to prism_verify_attestation to check the whole chain. Use it for anything the operator of an ordinary endpoint should not be able to read.",
|
|
259
|
+
inputSchema: {
|
|
260
|
+
type: "object",
|
|
261
|
+
properties: {
|
|
262
|
+
prompt: { type: "string", description: "The prompt to generate from." },
|
|
263
|
+
model: { type: "string", description: "Confidential model to use; defaults to the endpoint's first." },
|
|
264
|
+
max_tokens: { type: "integer", description: "Cap on generated tokens (default 512). The price is quoted against this cap." },
|
|
265
|
+
max_usdg: { type: "number", description: "Refuse if the quoted price exceeds this (default 0.25)." },
|
|
266
|
+
e2ee: { type: "boolean", description: "Encrypt message contents to the attested enclave key (default true). Turn it off only when the relay is allowed to read the prompt." },
|
|
267
|
+
},
|
|
268
|
+
required: ["prompt"],
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
name: "prism_verify_attestation",
|
|
273
|
+
description: "Check a confidential generation against its signed receipt and the hardware behind it: the TDX quote verifies to Intel's root and commits to the key set that signed the receipt, the boot log replays to the measurement in that quote, the receipt covers the exact bytes of this call, the upstream that ran the model was itself verified, and the GPU is attested by NVIDIA. Returns every check with its result, including the ones that cannot be established today. Needs no wallet.",
|
|
274
|
+
inputSchema: {
|
|
275
|
+
type: "object",
|
|
276
|
+
properties: {
|
|
277
|
+
receipt_id: { type: "string", description: "The receipt_id from prism_confidential_infer." },
|
|
278
|
+
model: { type: "string", description: "Model to fetch GPU evidence for; defaults to the one recorded for this receipt." },
|
|
279
|
+
},
|
|
280
|
+
required: ["receipt_id"],
|
|
281
|
+
},
|
|
276
282
|
},
|
|
277
283
|
{
|
|
278
284
|
name: "prism_lease_and_run",
|
|
@@ -292,8 +298,6 @@ const TOOLS = [
|
|
|
292
298
|
},
|
|
293
299
|
required: ["command"],
|
|
294
300
|
},
|
|
295
|
-
...spends,
|
|
296
|
-
annotations: { title: "Rent a GPU and run a command", ...spends.annotations },
|
|
297
301
|
},
|
|
298
302
|
{
|
|
299
303
|
name: "prism_lease",
|
|
@@ -311,8 +315,6 @@ const TOOLS = [
|
|
|
311
315
|
max_usdg: { type: "number", description: "Hard cap on the USDG this lease may cost (default 1). Raise it deliberately for longer leases." },
|
|
312
316
|
},
|
|
313
317
|
},
|
|
314
|
-
...spends,
|
|
315
|
-
annotations: { title: "Rent a GPU", ...spends.annotations },
|
|
316
318
|
},
|
|
317
319
|
{
|
|
318
320
|
name: "prism_run",
|
|
@@ -326,7 +328,6 @@ const TOOLS = [
|
|
|
326
328
|
},
|
|
327
329
|
required: ["lease_id", "command"],
|
|
328
330
|
},
|
|
329
|
-
annotations: { title: "Run a command on a lease", readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
330
331
|
},
|
|
331
332
|
{
|
|
332
333
|
name: "prism_end_lease",
|
|
@@ -336,7 +337,6 @@ const TOOLS = [
|
|
|
336
337
|
properties: { lease_id: { type: "integer" } },
|
|
337
338
|
required: ["lease_id"],
|
|
338
339
|
},
|
|
339
|
-
annotations: { title: "Release a lease", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
340
340
|
},
|
|
341
341
|
{
|
|
342
342
|
name: "prism_vault_store",
|
|
@@ -354,13 +354,11 @@ const TOOLS = [
|
|
|
354
354
|
},
|
|
355
355
|
required: ["value"],
|
|
356
356
|
},
|
|
357
|
-
annotations: { title: "Seal a secret", readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
358
357
|
},
|
|
359
358
|
{
|
|
360
359
|
name: "prism_vault_list",
|
|
361
360
|
description: "List the agent's sealed vault items: item_id, label, version and trust floor. Values are not returned and are not readable by Prism.",
|
|
362
361
|
inputSchema: { type: "object", properties: {} },
|
|
363
|
-
annotations: { title: "List sealed items", ...reads },
|
|
364
362
|
},
|
|
365
363
|
{
|
|
366
364
|
name: "prism_vault_read",
|
|
@@ -370,8 +368,6 @@ const TOOLS = [
|
|
|
370
368
|
properties: { item_id: { type: "string", description: "The item_id from prism_vault_store or prism_vault_list." } },
|
|
371
369
|
required: ["item_id"],
|
|
372
370
|
},
|
|
373
|
-
_meta: { "anthropic/requiresUserInteraction": true },
|
|
374
|
-
annotations: { title: "Decrypt one sealed item", readOnlyHint: true, openWorldHint: true },
|
|
375
371
|
},
|
|
376
372
|
{
|
|
377
373
|
name: "prism_vault_delete",
|
|
@@ -381,8 +377,6 @@ const TOOLS = [
|
|
|
381
377
|
properties: { item_id: { type: "string" } },
|
|
382
378
|
required: ["item_id"],
|
|
383
379
|
},
|
|
384
|
-
...spends,
|
|
385
|
-
annotations: { title: "Delete a sealed item", ...spends.annotations },
|
|
386
380
|
},
|
|
387
381
|
{
|
|
388
382
|
name: "prism_vault_release",
|
|
@@ -395,13 +389,10 @@ const TOOLS = [
|
|
|
395
389
|
},
|
|
396
390
|
required: ["item_id", "lease_id"],
|
|
397
391
|
},
|
|
398
|
-
...spends,
|
|
399
|
-
annotations: { title: "Release a secret into a lease", ...spends.annotations },
|
|
400
392
|
},
|
|
401
393
|
];
|
|
402
394
|
|
|
403
395
|
async function handle(name, args) {
|
|
404
|
-
if (name === "prism_budget") return requireLedger(name).status();
|
|
405
396
|
if (name === "prism_wallet") {
|
|
406
397
|
const b = await requireWallet("prism_wallet").balances();
|
|
407
398
|
return { address: b.address, usdg: usdg(b.usdg), eth_wei: b.eth };
|
|
@@ -492,28 +483,22 @@ async function handle(name, args) {
|
|
|
492
483
|
if (name === "prism_batch_run") {
|
|
493
484
|
requireCommand(args.command);
|
|
494
485
|
requireWallet(name);
|
|
495
|
-
const cap = maxDeposit(
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
command: args.command,
|
|
503
|
-
});
|
|
504
|
-
return {
|
|
505
|
-
reference: batch.fundingHash,
|
|
506
|
-
settledMicros: escrowed(batch.quote),
|
|
507
|
-
value: {
|
|
508
|
-
lease_id: batch.leaseId,
|
|
509
|
-
funding_tx: batch.fundingHash,
|
|
510
|
-
exit_code: batch.result?.exit_code,
|
|
511
|
-
stdout: batch.result?.stdout,
|
|
512
|
-
stderr: batch.result?.stderr,
|
|
513
|
-
truncated: batch.result?.truncated ?? false,
|
|
514
|
-
},
|
|
515
|
-
};
|
|
486
|
+
const cap = maxDeposit(args);
|
|
487
|
+
const batch = await agent.lease({
|
|
488
|
+
image: IMAGE,
|
|
489
|
+
durationSeconds: args.duration_seconds ?? 900,
|
|
490
|
+
minVramMib: args.min_vram_mib ?? 16000,
|
|
491
|
+
maxDeposit: cap,
|
|
492
|
+
command: args.command,
|
|
516
493
|
});
|
|
494
|
+
return {
|
|
495
|
+
lease_id: batch.leaseId,
|
|
496
|
+
funding_tx: batch.fundingHash,
|
|
497
|
+
exit_code: batch.result?.exit_code,
|
|
498
|
+
stdout: batch.result?.stdout,
|
|
499
|
+
stderr: batch.result?.stderr,
|
|
500
|
+
truncated: batch.result?.truncated ?? false,
|
|
501
|
+
};
|
|
517
502
|
}
|
|
518
503
|
if (name === "prism_batch_result") {
|
|
519
504
|
requireWallet(name, "reads this wallet's leases");
|
|
@@ -525,83 +510,126 @@ async function handle(name, args) {
|
|
|
525
510
|
throw new Error("prompt is required.");
|
|
526
511
|
}
|
|
527
512
|
requireWallet(name);
|
|
528
|
-
const base = (
|
|
529
|
-
const offer = await
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
513
|
+
const base = inferenceBase();
|
|
514
|
+
const { offer, model, unit } = await inferenceOffer(base, args.model);
|
|
515
|
+
withinCap(unit, args.max_usdg, 0.05, "per generation");
|
|
516
|
+
return payAndPost({
|
|
517
|
+
base,
|
|
518
|
+
path: "/v1/inference",
|
|
519
|
+
price: unit,
|
|
520
|
+
payTo: offer.pay_to,
|
|
521
|
+
body: { model, prompt: args.prompt },
|
|
522
|
+
tool: "prism_infer",
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
if (name === "prism_infer_batch") {
|
|
526
|
+
const prompts = args.prompts;
|
|
527
|
+
if (!Array.isArray(prompts) || !prompts.length) {
|
|
528
|
+
throw new Error("prompts must be a non-empty array of strings.");
|
|
533
529
|
}
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
if (typeof cap !== "number" || !Number.isFinite(cap) || cap <= 0) {
|
|
537
|
-
throw new Error("max_usdg must be a positive number of USDG.");
|
|
530
|
+
if (prompts.some((p) => typeof p !== "string" || p.trim() === "")) {
|
|
531
|
+
throw new Error("every prompt must be a non-empty string.");
|
|
538
532
|
}
|
|
539
|
-
if (
|
|
540
|
-
throw new Error(`
|
|
533
|
+
if (prompts.length > 64) {
|
|
534
|
+
throw new Error(`a batch takes at most 64 prompts; ${prompts.length} were given.`);
|
|
541
535
|
}
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
pendingInference.set(pendingKey, pending);
|
|
536
|
+
requireWallet(name);
|
|
537
|
+
const base = inferenceBase();
|
|
538
|
+
const { offer, model, unit } = await inferenceOffer(base, args.model);
|
|
539
|
+
const price = unit * BigInt(prompts.length);
|
|
540
|
+
withinCap(price, args.max_usdg, 0.5, `for ${prompts.length} generations`);
|
|
541
|
+
return payAndPost({
|
|
542
|
+
base,
|
|
543
|
+
path: "/v1/batch",
|
|
544
|
+
price,
|
|
545
|
+
payTo: offer.pay_to,
|
|
546
|
+
body: { model, prompts },
|
|
547
|
+
tool: "prism_infer_batch",
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
if (name === "prism_confidential_infer") {
|
|
551
|
+
if (typeof args.prompt !== "string" || args.prompt.trim() === "") {
|
|
552
|
+
throw new Error("prompt is required.");
|
|
560
553
|
}
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
const
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
554
|
+
requireWallet(name);
|
|
555
|
+
const base = inferenceBase();
|
|
556
|
+
const run = await agent.confidentialInfer({
|
|
557
|
+
prompt: args.prompt,
|
|
558
|
+
model: args.model,
|
|
559
|
+
maxTokens: args.max_tokens ?? 512,
|
|
560
|
+
maxUsdg: args.max_usdg ?? 0.25,
|
|
561
|
+
e2ee: args.e2ee ?? true,
|
|
562
|
+
endpoint: base,
|
|
563
|
+
});
|
|
564
|
+
rememberConfidentialCall(run.receiptId, {
|
|
565
|
+
base,
|
|
566
|
+
model: run.model,
|
|
567
|
+
e2ee: run.e2ee,
|
|
568
|
+
keysetDigest: run.keysetDigest,
|
|
569
|
+
responseHash: sha256Prefixed(run.bytes.response),
|
|
570
|
+
requestHash: sha256Prefixed(run.bytes.request),
|
|
571
|
+
restoredRequestHash: run.bytes.restoredRequest ? sha256Prefixed(run.bytes.restoredRequest) : null,
|
|
572
|
+
});
|
|
573
|
+
return {
|
|
574
|
+
model: run.model,
|
|
575
|
+
content: run.content,
|
|
576
|
+
usage: run.usage,
|
|
577
|
+
receipt_id: run.receiptId,
|
|
578
|
+
paid: usdg(run.priceMicros),
|
|
579
|
+
payment_tx: run.tx,
|
|
580
|
+
e2ee: run.e2ee
|
|
581
|
+
? "on: the prompt and the answer were encrypted to the enclave's attested key, and the relay carried ciphertext"
|
|
582
|
+
: "off: the relay could read this prompt",
|
|
583
|
+
next: `prism_verify_attestation with receipt_id ${run.receiptId} checks the hardware, the receipt and the GPU behind this answer`,
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
if (name === "prism_verify_attestation") {
|
|
587
|
+
if (typeof args.receipt_id !== "string" || args.receipt_id.trim() === "") {
|
|
588
|
+
throw new Error("receipt_id is required: the id prism_confidential_infer returned.");
|
|
589
589
|
}
|
|
590
|
+
// Without the bytes of the call, a receipt still proves what the workload
|
|
591
|
+
// signed, but not that it signed this exchange. That is `incomplete`, and it
|
|
592
|
+
// is a different thing to tell an agent than a failure.
|
|
593
|
+
const remembered = confidentialCalls.get(args.receipt_id) ?? {};
|
|
594
|
+
const bound = remembered.responseHash != null;
|
|
595
|
+
const result = await verifyConfidential({
|
|
596
|
+
base: remembered.base ?? inferenceBase(),
|
|
597
|
+
receiptId: args.receipt_id,
|
|
598
|
+
model: args.model ?? remembered.model,
|
|
599
|
+
e2ee: Boolean(remembered.e2ee),
|
|
600
|
+
requestHash: remembered.requestHash ?? null,
|
|
601
|
+
responseHash: remembered.responseHash ?? null,
|
|
602
|
+
restoredRequestHash: remembered.restoredRequestHash ?? null,
|
|
603
|
+
expectedKeysetDigest: remembered.keysetDigest ?? null,
|
|
604
|
+
});
|
|
605
|
+
const mark = { pass: "ok", fail: "FAIL", skip: "skip" };
|
|
606
|
+
return {
|
|
607
|
+
receipt_id: args.receipt_id,
|
|
608
|
+
verdict: result.verdict,
|
|
609
|
+
verdict_means: {
|
|
610
|
+
verified: "every check that ran passed, and the only skips are the documented ones",
|
|
611
|
+
incomplete: "nothing failed, and evidence some check needed was not available here",
|
|
612
|
+
failed: "a check failed",
|
|
613
|
+
}[result.verdict],
|
|
614
|
+
bound_to_this_session: bound,
|
|
615
|
+
...(bound
|
|
616
|
+
? {}
|
|
617
|
+
: { unbound_because: "this server has no record of that call, so the request and response checks could not run" }),
|
|
618
|
+
measured_source: result.provenance,
|
|
619
|
+
checks: result.checks.map((c) => `${mark[c.status]} ${c.title}${c.detail ? `: ${c.detail}` : ""}`),
|
|
620
|
+
};
|
|
590
621
|
}
|
|
591
622
|
if (name === "prism_lease_and_run" || name === "prism_lease") {
|
|
592
623
|
if (name === "prism_lease_and_run") requireCommand(args.command);
|
|
593
624
|
requireWallet(name);
|
|
594
|
-
const cap = maxDeposit(
|
|
625
|
+
const cap = maxDeposit(args);
|
|
595
626
|
sweepExpiredLeases();
|
|
596
|
-
const lease = await
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
minTrustClass: args.min_trust_class ?? "open",
|
|
603
|
-
});
|
|
604
|
-
return { value: funded, reference: funded.fundingHash, settledMicros: escrowed(funded.quote) };
|
|
627
|
+
const lease = await agent.lease({
|
|
628
|
+
image: IMAGE,
|
|
629
|
+
durationSeconds: args.duration_seconds ?? 900,
|
|
630
|
+
minVramMib: args.min_vram_mib ?? 16000,
|
|
631
|
+
maxDeposit: cap,
|
|
632
|
+
minTrustClass: args.min_trust_class ?? "open",
|
|
605
633
|
});
|
|
606
634
|
leases.set(lease.leaseId, lease);
|
|
607
635
|
const summary = {
|
|
@@ -699,16 +727,13 @@ async function handleVault(name, args) {
|
|
|
699
727
|
throw new Error(`unknown tool ${name}`);
|
|
700
728
|
}
|
|
701
729
|
|
|
702
|
-
const server = new Server({ name: "prism", version: "0.
|
|
730
|
+
const server = new Server({ name: "prism", version: "0.9.0" }, { capabilities: { tools: {} } });
|
|
703
731
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
704
732
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
705
733
|
try {
|
|
706
734
|
const result = await handle(request.params.name, request.params.arguments ?? {});
|
|
707
735
|
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
708
736
|
} catch (err) {
|
|
709
|
-
if (err instanceof BudgetError) {
|
|
710
|
-
return { isError: true, content: [{ type: "text", text: `${err.message} See prism_budget.` }] };
|
|
711
|
-
}
|
|
712
737
|
const body = err?.body ?? {};
|
|
713
738
|
const detail = [
|
|
714
739
|
body.cause,
|
package/budget.mjs
DELETED
|
@@ -1,257 +0,0 @@
|
|
|
1
|
-
// What stands between a model deciding to rent a GPU and a wallet paying for it.
|
|
2
|
-
// The real ceiling is the balance; fund the agent wallet with what you are
|
|
3
|
-
// willing to lose. This is the second line, and it exists because `max_usdg`
|
|
4
|
-
// never was one: it bounds a single lease and says nothing about the fortieth in
|
|
5
|
-
// a row, which is what an unattended agent actually does.
|
|
6
|
-
//
|
|
7
|
-
// Spend is written before the money moves and reverted only when the attempt
|
|
8
|
-
// provably cost nothing, so a crash between funding and reply is counted rather
|
|
9
|
-
// than forgiven. The file is shared across clients on purpose: one wallet gets
|
|
10
|
-
// one daily ceiling whoever is holding it.
|
|
11
|
-
import { mkdirSync, readFileSync, renameSync, writeFileSync, openSync, closeSync, unlinkSync, statSync } from "node:fs";
|
|
12
|
-
import { homedir } from "node:os";
|
|
13
|
-
import { dirname, join } from "node:path";
|
|
14
|
-
|
|
15
|
-
const MICROS = 1_000_000;
|
|
16
|
-
const DAY_MS = 86_400_000;
|
|
17
|
-
// Entries older than this are dropped on write. Two days covers the rolling
|
|
18
|
-
// window with room for a clock that stepped backwards.
|
|
19
|
-
const MEMORY_MS = 2 * DAY_MS;
|
|
20
|
-
const LOCK_STALE_MS = 15_000;
|
|
21
|
-
const LOCK_WAIT_MS = 5_000;
|
|
22
|
-
|
|
23
|
-
export class BudgetError extends Error {
|
|
24
|
-
constructor(message, detail = {}) {
|
|
25
|
-
super(message);
|
|
26
|
-
this.name = "BudgetError";
|
|
27
|
-
this.detail = detail;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export const usdg = (micros) => `${(Number(micros) / MICROS).toFixed(6)} USDG`;
|
|
32
|
-
|
|
33
|
-
function positiveNumber(raw, fallback, name) {
|
|
34
|
-
if (raw === undefined || raw === null || String(raw).trim() === "") return fallback;
|
|
35
|
-
const value = Number(raw);
|
|
36
|
-
if (!Number.isFinite(value) || value < 0) {
|
|
37
|
-
throw new BudgetError(`${name} must be a non-negative number of USDG, got ${JSON.stringify(raw)}`);
|
|
38
|
-
}
|
|
39
|
-
return value;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// One .mcp.json serves every client, and they disagree about templates: Claude
|
|
43
|
-
// Code expands ${user_config.x} before launch, Codex passes it through. Reading
|
|
44
|
-
// the literal template as a value would turn "no wallet configured" into
|
|
45
|
-
// "wallet configured and broken", so an unexpanded placeholder is nothing.
|
|
46
|
-
export function stripUnexpanded(env = process.env) {
|
|
47
|
-
for (const [key, value] of Object.entries(env)) {
|
|
48
|
-
if (key.startsWith("PRISM_") && /^\$\{[^}]*\}$/.test(String(value ?? "").trim())) delete env[key];
|
|
49
|
-
}
|
|
50
|
-
return env;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export function defaultLedgerPath(env = process.env) {
|
|
54
|
-
if (env.PRISM_LEDGER_PATH) return env.PRISM_LEDGER_PATH;
|
|
55
|
-
return join(env.HOME || homedir(), ".prism", "spend.json");
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// A missing budget is not an unlimited one. The default is deliberately small:
|
|
59
|
-
// enough for a handful of real leases, cheap enough that discovering the plugin
|
|
60
|
-
// spends money costs about the price of a coffee rather than a rent cheque.
|
|
61
|
-
export function readBudget(env = process.env) {
|
|
62
|
-
const maxPerCall = positiveNumber(env.PRISM_MAX_USDG, 1, "PRISM_MAX_USDG");
|
|
63
|
-
const daily = positiveNumber(env.PRISM_DAILY_BUDGET_USDG, 5, "PRISM_DAILY_BUDGET_USDG");
|
|
64
|
-
if (maxPerCall <= 0) throw new BudgetError("PRISM_MAX_USDG must be above zero");
|
|
65
|
-
// A per-call cap above the day's allowance is a cap in name only, and the
|
|
66
|
-
// mismatch is always a configuration mistake rather than an intention.
|
|
67
|
-
if (daily > 0 && maxPerCall > daily) {
|
|
68
|
-
throw new BudgetError(
|
|
69
|
-
`PRISM_MAX_USDG (${maxPerCall}) cannot exceed PRISM_DAILY_BUDGET_USDG (${daily}); lower the per-call cap or raise the daily one`,
|
|
70
|
-
);
|
|
71
|
-
}
|
|
72
|
-
return {
|
|
73
|
-
maxPerCallMicros: Math.round(maxPerCall * MICROS),
|
|
74
|
-
// Zero means the operator explicitly removed the daily ceiling. It is not
|
|
75
|
-
// the default and it is not what a missing variable produces.
|
|
76
|
-
dailyMicros: Math.round(daily * MICROS),
|
|
77
|
-
ledgerPath: defaultLedgerPath(env),
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// A lock rather than last-write-wins, because two clients sharing one wallet is
|
|
82
|
-
// the case this file exists for. A lock older than LOCK_STALE_MS belonged to a
|
|
83
|
-
// process that died; breaking it is safe and not breaking it wedges the wallet.
|
|
84
|
-
function withLock(path, fn, waitMs = LOCK_WAIT_MS) {
|
|
85
|
-
const lock = `${path}.lock`;
|
|
86
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
87
|
-
const deadline = Date.now() + waitMs;
|
|
88
|
-
for (;;) {
|
|
89
|
-
let fd;
|
|
90
|
-
try {
|
|
91
|
-
fd = openSync(lock, "wx");
|
|
92
|
-
} catch (err) {
|
|
93
|
-
if (err?.code !== "EEXIST") throw err;
|
|
94
|
-
let age = 0;
|
|
95
|
-
try {
|
|
96
|
-
age = Date.now() - statSync(lock).mtimeMs;
|
|
97
|
-
} catch {
|
|
98
|
-
continue; // it vanished between the open and the stat; retry immediately
|
|
99
|
-
}
|
|
100
|
-
if (age > LOCK_STALE_MS) {
|
|
101
|
-
try {
|
|
102
|
-
unlinkSync(lock);
|
|
103
|
-
} catch {
|
|
104
|
-
/* another process broke it first, which is the outcome we wanted */
|
|
105
|
-
}
|
|
106
|
-
continue;
|
|
107
|
-
}
|
|
108
|
-
if (Date.now() > deadline) {
|
|
109
|
-
throw new BudgetError(
|
|
110
|
-
`the spend ledger at ${path} is locked by another Prism process; nothing was charged. Retry in a moment.`,
|
|
111
|
-
);
|
|
112
|
-
}
|
|
113
|
-
// Busy-wait deliberately: this holds for milliseconds and the alternative
|
|
114
|
-
// is making every caller of a synchronous ledger asynchronous.
|
|
115
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
|
|
116
|
-
continue;
|
|
117
|
-
}
|
|
118
|
-
try {
|
|
119
|
-
closeSync(fd);
|
|
120
|
-
return fn();
|
|
121
|
-
} finally {
|
|
122
|
-
try {
|
|
123
|
-
unlinkSync(lock);
|
|
124
|
-
} catch {
|
|
125
|
-
/* already gone */
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function readState(path) {
|
|
132
|
-
try {
|
|
133
|
-
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
134
|
-
if (!parsed || !Array.isArray(parsed.entries)) return { entries: [] };
|
|
135
|
-
return { entries: parsed.entries.filter((e) => e && Number.isFinite(e.at) && Number.isFinite(e.micros)) };
|
|
136
|
-
} catch (err) {
|
|
137
|
-
if (err?.code === "ENOENT") return { entries: [] };
|
|
138
|
-
// A corrupt ledger must not read as an empty one: that would hand the
|
|
139
|
-
// caller a fresh day's budget every time the file got truncated.
|
|
140
|
-
throw new BudgetError(
|
|
141
|
-
`the spend ledger at ${path} is unreadable (${err?.message ?? err}), so spending is refused. Move or repair the file.`,
|
|
142
|
-
);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function writeState(path, state, now) {
|
|
147
|
-
const entries = state.entries.filter((e) => now - e.at < MEMORY_MS);
|
|
148
|
-
const tmp = `${path}.${process.pid}.tmp`;
|
|
149
|
-
writeFileSync(tmp, `${JSON.stringify({ version: 1, entries }, null, 2)}\n`, { mode: 0o600 });
|
|
150
|
-
renameSync(tmp, path);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
export function spentInWindow(entries, now) {
|
|
154
|
-
return entries.reduce((total, e) => (now - e.at < DAY_MS ? total + e.micros : total), 0);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
export class SpendLedger {
|
|
158
|
-
constructor({ ledgerPath, dailyMicros, maxPerCallMicros, lockWaitMs = LOCK_WAIT_MS }) {
|
|
159
|
-
this.path = ledgerPath;
|
|
160
|
-
this.dailyMicros = dailyMicros;
|
|
161
|
-
this.maxPerCallMicros = maxPerCallMicros;
|
|
162
|
-
this.lockWaitMs = lockWaitMs;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// What is left of today, for the caller to show before it asks to spend.
|
|
166
|
-
remaining(now = Date.now()) {
|
|
167
|
-
if (this.dailyMicros <= 0) return null;
|
|
168
|
-
const { entries } = readState(this.path);
|
|
169
|
-
return Math.max(0, this.dailyMicros - spentInWindow(entries, now));
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
status(now = Date.now()) {
|
|
173
|
-
const { entries } = readState(this.path);
|
|
174
|
-
const spent = spentInWindow(entries, now);
|
|
175
|
-
return {
|
|
176
|
-
daily_budget: this.dailyMicros > 0 ? usdg(this.dailyMicros) : "unlimited (PRISM_DAILY_BUDGET_USDG=0)",
|
|
177
|
-
spent_last_24h: usdg(spent),
|
|
178
|
-
remaining_today: this.dailyMicros > 0 ? usdg(Math.max(0, this.dailyMicros - spent)) : "unlimited",
|
|
179
|
-
max_per_call: usdg(this.maxPerCallMicros),
|
|
180
|
-
ledger: this.path,
|
|
181
|
-
charges_last_24h: entries
|
|
182
|
-
.filter((e) => now - e.at < DAY_MS)
|
|
183
|
-
.sort((a, b) => b.at - a.at)
|
|
184
|
-
.slice(0, 20)
|
|
185
|
-
.map((e) => ({
|
|
186
|
-
at: new Date(e.at).toISOString(),
|
|
187
|
-
tool: e.tool,
|
|
188
|
-
amount: usdg(e.micros),
|
|
189
|
-
...(e.reference ? { reference: e.reference } : {}),
|
|
190
|
-
})),
|
|
191
|
-
};
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// Records the spend before the money moves. Returns a handle the caller
|
|
195
|
-
// reverts only when it can prove nothing was charged.
|
|
196
|
-
commit({ tool, micros, now = Date.now() }) {
|
|
197
|
-
if (!Number.isFinite(micros) || micros <= 0) {
|
|
198
|
-
throw new BudgetError("a spend must be a positive number of micros");
|
|
199
|
-
}
|
|
200
|
-
if (micros > this.maxPerCallMicros) {
|
|
201
|
-
throw new BudgetError(
|
|
202
|
-
`${tool} would commit up to ${usdg(micros)}, past the ${usdg(this.maxPerCallMicros)} per-call cap. ` +
|
|
203
|
-
`Lower max_usdg for this call, or raise PRISM_MAX_USDG.`,
|
|
204
|
-
{ required: micros, cap: this.maxPerCallMicros },
|
|
205
|
-
);
|
|
206
|
-
}
|
|
207
|
-
return withLock(
|
|
208
|
-
this.path,
|
|
209
|
-
() => {
|
|
210
|
-
const state = readState(this.path);
|
|
211
|
-
const spent = spentInWindow(state.entries, now);
|
|
212
|
-
if (this.dailyMicros > 0 && spent + micros > this.dailyMicros) {
|
|
213
|
-
throw new BudgetError(
|
|
214
|
-
`${tool} would take today's Prism spend to ${usdg(spent + micros)}, past the ${usdg(this.dailyMicros)} ` +
|
|
215
|
-
`daily cap (${usdg(spent)} already spent). Nothing was charged. Raise PRISM_DAILY_BUDGET_USDG to continue.`,
|
|
216
|
-
{ spent, requested: micros, cap: this.dailyMicros },
|
|
217
|
-
);
|
|
218
|
-
}
|
|
219
|
-
const id = `${now.toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
220
|
-
state.entries.push({ id, at: now, tool, micros });
|
|
221
|
-
writeState(this.path, state, now);
|
|
222
|
-
return id;
|
|
223
|
-
},
|
|
224
|
-
this.lockWaitMs,
|
|
225
|
-
);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// Only for an attempt that provably cost nothing. A funded lease whose command
|
|
229
|
-
// failed is not one of those.
|
|
230
|
-
revert(id) {
|
|
231
|
-
if (!id) return false;
|
|
232
|
-
return withLock(this.path, () => {
|
|
233
|
-
const state = readState(this.path);
|
|
234
|
-
const before = state.entries.length;
|
|
235
|
-
state.entries = state.entries.filter((e) => e.id !== id);
|
|
236
|
-
if (state.entries.length === before) return false;
|
|
237
|
-
writeState(this.path, state, Date.now());
|
|
238
|
-
return true;
|
|
239
|
-
}, this.lockWaitMs);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// Replaces the reserved figure with what was actually committed on-chain and
|
|
243
|
-
// pins the receipt to it, so the ledger reads like a statement rather than a
|
|
244
|
-
// list of intentions.
|
|
245
|
-
settle(id, { micros, reference } = {}) {
|
|
246
|
-
if (!id) return false;
|
|
247
|
-
return withLock(this.path, () => {
|
|
248
|
-
const state = readState(this.path);
|
|
249
|
-
const entry = state.entries.find((e) => e.id === id);
|
|
250
|
-
if (!entry) return false;
|
|
251
|
-
if (Number.isFinite(micros) && micros >= 0) entry.micros = micros;
|
|
252
|
-
if (reference) entry.reference = reference;
|
|
253
|
-
writeState(this.path, state, Date.now());
|
|
254
|
-
return true;
|
|
255
|
-
}, this.lockWaitMs);
|
|
256
|
-
}
|
|
257
|
-
}
|