@prismnetwork/mcp 0.9.0 → 0.9.2
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 +35 -2
- package/budget.mjs +316 -0
- package/package.json +9 -5
- package/server.mjs +190 -66
package/README.md
CHANGED
|
@@ -17,6 +17,7 @@ real prices. Leasing spends money, so those tools ask for a wallet and say so.
|
|
|
17
17
|
|
|
18
18
|
| Tool | Wallet |
|
|
19
19
|
| --- | --- |
|
|
20
|
+
| `prism_budget` | no |
|
|
20
21
|
| `prism_list_gpus` | no |
|
|
21
22
|
| `prism_price_index` | no |
|
|
22
23
|
| `prism_receipts` | no |
|
|
@@ -37,6 +38,8 @@ real prices. Leasing spends money, so those tools ask for a wallet and say so.
|
|
|
37
38
|
| `prism_vault_delete` | yes |
|
|
38
39
|
| `prism_vault_release` | yes |
|
|
39
40
|
|
|
41
|
+
- `prism_budget`: the spending limits this server enforces, what it has spent
|
|
42
|
+
in the last 24 hours, and the recent charges.
|
|
40
43
|
- `prism_wallet`: the agent's address and USDG/ETH balances.
|
|
41
44
|
- `prism_list_gpus`: GPUs available to lease, with price per second and per hour.
|
|
42
45
|
- `prism_price_index`: sourced and settled pricing per GPU model, for cost estimates.
|
|
@@ -66,6 +69,9 @@ real prices. Leasing spends money, so those tools ask for a wallet and say so.
|
|
|
66
69
|
terminates.
|
|
67
70
|
- `prism_lease_and_run`: lease a GPU, run a command, return the output (one shot).
|
|
68
71
|
- `prism_lease`: lease a GPU and keep it; returns a `lease_id` and SSH access.
|
|
72
|
+
The SSH block carries `host_key_fingerprint` and `host_key_claim` when the
|
|
73
|
+
network can say which machine should answer, and says so plainly when it
|
|
74
|
+
cannot. Check it before connecting by hand; `prism_run` checks it for you.
|
|
69
75
|
- `prism_run`: run a command on an existing lease.
|
|
70
76
|
- `prism_batch_run`: fund a lease that runs one command with no interactive
|
|
71
77
|
access; the node reports the signed output. Matches only suppliers at trust
|
|
@@ -77,6 +83,33 @@ real prices. Leasing spends money, so those tools ask for a wallet and say so.
|
|
|
77
83
|
- `prism_vault_delete`: permanently delete an item.
|
|
78
84
|
- `prism_vault_release`: authorize an item into a lease that clears its trust floor.
|
|
79
85
|
|
|
86
|
+
## Spending limits
|
|
87
|
+
|
|
88
|
+
Two ceilings bound what this server can spend, and a refusal quotes both.
|
|
89
|
+
|
|
90
|
+
| Variable | Default | What it bounds |
|
|
91
|
+
| --- | --- | --- |
|
|
92
|
+
| `PRISM_MAX_USDG` | 1 | Any single lease or generation. `max_usdg` on a call may lower it, never raise it past this. |
|
|
93
|
+
| `PRISM_DAILY_BUDGET_USDG` | 5 | Everything in a rolling 24 hours. `0` removes the ceiling. |
|
|
94
|
+
| `PRISM_LEDGER_PATH` | `~/.prism/spend.json` | Where the spend is recorded. |
|
|
95
|
+
|
|
96
|
+
A `max_usdg` above `PRISM_MAX_USDG` is clamped back to it. The argument is
|
|
97
|
+
written by the agent being bounded, so it is treated as a request for a lower
|
|
98
|
+
ceiling and never as permission for a higher one.
|
|
99
|
+
|
|
100
|
+
Spend is written before the money moves, so a crash between funding an escrow
|
|
101
|
+
and answering is counted rather than forgiven, and a restart does not hand back
|
|
102
|
+
a fresh day's allowance. Only an attempt that provably never reached the chain
|
|
103
|
+
is reverted. Two clients pointed at one wallet share one ceiling, because they
|
|
104
|
+
share the ledger file.
|
|
105
|
+
|
|
106
|
+
None of this is the real limit. Fund a dedicated wallet with what you are
|
|
107
|
+
willing to lose: that balance is what survives a bug in everything above.
|
|
108
|
+
|
|
109
|
+
Tools that spend are annotated `destructiveHint` and carry
|
|
110
|
+
`anthropic/requiresUserInteraction`, so Claude Code asks before every one of
|
|
111
|
+
them even in modes that otherwise approve tools automatically.
|
|
112
|
+
|
|
80
113
|
## Vault
|
|
81
114
|
|
|
82
115
|
An agent that handles a card, an identity document or a credential should not
|
|
@@ -102,7 +135,7 @@ Point your MCP client (Claude Desktop / Code) at the published server:
|
|
|
102
135
|
"args": ["-y", "@prismnetwork/mcp"],
|
|
103
136
|
"env": {
|
|
104
137
|
"PRISM_AGENT_KEY": "0x<agent wallet private key>",
|
|
105
|
-
"PRISM_ESCROW": "
|
|
138
|
+
"PRISM_ESCROW": "0xfD4228eEEfC49e4b76A0CD40af9fdd546220B2FD"
|
|
106
139
|
}
|
|
107
140
|
}
|
|
108
141
|
}
|
|
@@ -115,7 +148,7 @@ Or add it to Claude Code in one line:
|
|
|
115
148
|
|
|
116
149
|
```sh
|
|
117
150
|
claude mcp add prism \
|
|
118
|
-
--env PRISM_ESCROW=
|
|
151
|
+
--env PRISM_ESCROW=0xfD4228eEEfC49e4b76A0CD40af9fdd546220B2FD \
|
|
119
152
|
--env PRISM_AGENT_KEY=0x<agent wallet private key> \
|
|
120
153
|
-- npx -y @prismnetwork/mcp
|
|
121
154
|
```
|
package/budget.mjs
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
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
|
+
// What a single call is allowed to spend, given what it asked for. The caller's
|
|
82
|
+
// figure is written by the thing being bounded, so it lowers the operator's
|
|
83
|
+
// ceiling and can never lift it.
|
|
84
|
+
export function callCeiling(maxUsdg, ceilingMicros) {
|
|
85
|
+
if (maxUsdg === undefined || maxUsdg === null) return ceilingMicros;
|
|
86
|
+
if (typeof maxUsdg !== "number" || !Number.isFinite(maxUsdg) || maxUsdg <= 0) {
|
|
87
|
+
throw new BudgetError("max_usdg must be a positive number of USDG.");
|
|
88
|
+
}
|
|
89
|
+
return Math.min(Math.round(maxUsdg * MICROS), ceilingMicros);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// A lock rather than last-write-wins, because two clients sharing one wallet is
|
|
93
|
+
// the case this file exists for. A lock older than LOCK_STALE_MS belonged to a
|
|
94
|
+
// process that died; breaking it is safe and not breaking it wedges the wallet.
|
|
95
|
+
function withLock(path, fn, waitMs = LOCK_WAIT_MS) {
|
|
96
|
+
const lock = `${path}.lock`;
|
|
97
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
98
|
+
const deadline = Date.now() + waitMs;
|
|
99
|
+
for (;;) {
|
|
100
|
+
let fd;
|
|
101
|
+
try {
|
|
102
|
+
fd = openSync(lock, "wx");
|
|
103
|
+
} catch (err) {
|
|
104
|
+
if (err?.code !== "EEXIST") throw err;
|
|
105
|
+
let age = 0;
|
|
106
|
+
try {
|
|
107
|
+
age = Date.now() - statSync(lock).mtimeMs;
|
|
108
|
+
} catch {
|
|
109
|
+
continue; // it vanished between the open and the stat; retry immediately
|
|
110
|
+
}
|
|
111
|
+
if (age > LOCK_STALE_MS) {
|
|
112
|
+
try {
|
|
113
|
+
unlinkSync(lock);
|
|
114
|
+
} catch {
|
|
115
|
+
/* another process broke it first, which is the outcome we wanted */
|
|
116
|
+
}
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (Date.now() > deadline) {
|
|
120
|
+
throw new BudgetError(
|
|
121
|
+
`the spend ledger at ${path} is locked by another Prism process; nothing was charged. Retry in a moment.`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
// Busy-wait deliberately: this holds for milliseconds and the alternative
|
|
125
|
+
// is making every caller of a synchronous ledger asynchronous.
|
|
126
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
closeSync(fd);
|
|
131
|
+
return fn();
|
|
132
|
+
} finally {
|
|
133
|
+
try {
|
|
134
|
+
unlinkSync(lock);
|
|
135
|
+
} catch {
|
|
136
|
+
/* already gone */
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function readState(path) {
|
|
143
|
+
try {
|
|
144
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
145
|
+
if (!parsed || !Array.isArray(parsed.entries)) return { entries: [] };
|
|
146
|
+
return { entries: parsed.entries.filter((e) => e && Number.isFinite(e.at) && Number.isFinite(e.micros)) };
|
|
147
|
+
} catch (err) {
|
|
148
|
+
if (err?.code === "ENOENT") return { entries: [] };
|
|
149
|
+
// A corrupt ledger must not read as an empty one: that would hand the
|
|
150
|
+
// caller a fresh day's budget every time the file got truncated.
|
|
151
|
+
throw new BudgetError(
|
|
152
|
+
`the spend ledger at ${path} is unreadable (${err?.message ?? err}), so spending is refused. Move or repair the file.`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function writeState(path, state, now) {
|
|
158
|
+
const entries = state.entries.filter((e) => now - e.at < MEMORY_MS);
|
|
159
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
160
|
+
writeFileSync(tmp, `${JSON.stringify({ version: 1, entries }, null, 2)}\n`, { mode: 0o600 });
|
|
161
|
+
renameSync(tmp, path);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function spentInWindow(entries, now) {
|
|
165
|
+
return entries.reduce((total, e) => (now - e.at < DAY_MS ? total + e.micros : total), 0);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export class SpendLedger {
|
|
169
|
+
constructor({ ledgerPath, dailyMicros, maxPerCallMicros, lockWaitMs = LOCK_WAIT_MS }) {
|
|
170
|
+
this.path = ledgerPath;
|
|
171
|
+
this.dailyMicros = dailyMicros;
|
|
172
|
+
this.maxPerCallMicros = maxPerCallMicros;
|
|
173
|
+
this.lockWaitMs = lockWaitMs;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// What is left of today, for the caller to show before it asks to spend.
|
|
177
|
+
remaining(now = Date.now()) {
|
|
178
|
+
if (this.dailyMicros <= 0) return null;
|
|
179
|
+
const { entries } = readState(this.path);
|
|
180
|
+
return Math.max(0, this.dailyMicros - spentInWindow(entries, now));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
status(now = Date.now()) {
|
|
184
|
+
const { entries } = readState(this.path);
|
|
185
|
+
const spent = spentInWindow(entries, now);
|
|
186
|
+
return {
|
|
187
|
+
daily_budget: this.dailyMicros > 0 ? usdg(this.dailyMicros) : "unlimited (PRISM_DAILY_BUDGET_USDG=0)",
|
|
188
|
+
spent_last_24h: usdg(spent),
|
|
189
|
+
remaining_today: this.dailyMicros > 0 ? usdg(Math.max(0, this.dailyMicros - spent)) : "unlimited",
|
|
190
|
+
max_per_call: usdg(this.maxPerCallMicros),
|
|
191
|
+
ledger: this.path,
|
|
192
|
+
charges_last_24h: entries
|
|
193
|
+
.filter((e) => now - e.at < DAY_MS)
|
|
194
|
+
.sort((a, b) => b.at - a.at)
|
|
195
|
+
.slice(0, 20)
|
|
196
|
+
.map((e) => ({
|
|
197
|
+
at: new Date(e.at).toISOString(),
|
|
198
|
+
tool: e.tool,
|
|
199
|
+
amount: usdg(e.micros),
|
|
200
|
+
...(e.reference ? { reference: e.reference } : {}),
|
|
201
|
+
})),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Records the spend before the money moves. Returns a handle the caller
|
|
206
|
+
// reverts only when it can prove nothing was charged.
|
|
207
|
+
commit({ tool, micros, now = Date.now() }) {
|
|
208
|
+
if (!Number.isFinite(micros) || micros <= 0) {
|
|
209
|
+
throw new BudgetError("a spend must be a positive number of micros");
|
|
210
|
+
}
|
|
211
|
+
if (micros > this.maxPerCallMicros) {
|
|
212
|
+
throw new BudgetError(
|
|
213
|
+
`${tool} would commit up to ${usdg(micros)}, past the ${usdg(this.maxPerCallMicros)} per-call cap. ` +
|
|
214
|
+
`Lower max_usdg for this call, or raise PRISM_MAX_USDG.`,
|
|
215
|
+
{ required: micros, cap: this.maxPerCallMicros },
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
return withLock(
|
|
219
|
+
this.path,
|
|
220
|
+
() => {
|
|
221
|
+
const state = readState(this.path);
|
|
222
|
+
const spent = spentInWindow(state.entries, now);
|
|
223
|
+
if (this.dailyMicros > 0 && spent + micros > this.dailyMicros) {
|
|
224
|
+
throw new BudgetError(
|
|
225
|
+
`${tool} would take today's Prism spend to ${usdg(spent + micros)}, past the ${usdg(this.dailyMicros)} ` +
|
|
226
|
+
`daily cap (${usdg(spent)} already spent). Nothing was charged. Raise PRISM_DAILY_BUDGET_USDG to continue.`,
|
|
227
|
+
{ spent, requested: micros, cap: this.dailyMicros },
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
const id = `${now.toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
231
|
+
state.entries.push({ id, at: now, tool, micros });
|
|
232
|
+
writeState(this.path, state, now);
|
|
233
|
+
return id;
|
|
234
|
+
},
|
|
235
|
+
this.lockWaitMs,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Only for an attempt that provably cost nothing. A funded lease whose command
|
|
240
|
+
// failed is not one of those.
|
|
241
|
+
revert(id) {
|
|
242
|
+
if (!id) return false;
|
|
243
|
+
return withLock(this.path, () => {
|
|
244
|
+
const state = readState(this.path);
|
|
245
|
+
const before = state.entries.length;
|
|
246
|
+
state.entries = state.entries.filter((e) => e.id !== id);
|
|
247
|
+
if (state.entries.length === before) return false;
|
|
248
|
+
writeState(this.path, state, Date.now());
|
|
249
|
+
return true;
|
|
250
|
+
}, this.lockWaitMs);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Replaces the reserved figure with what was actually committed on-chain and
|
|
254
|
+
// pins the receipt to it, so the ledger reads like a statement rather than a
|
|
255
|
+
// list of intentions.
|
|
256
|
+
//
|
|
257
|
+
// A payment the endpoint never consumed is redeemed by the next attempt at the
|
|
258
|
+
// same request, so one transaction can settle more than one reservation.
|
|
259
|
+
// Booking each would charge the day twice for money that moved once: a
|
|
260
|
+
// reference already on file keeps its entry and this one is released.
|
|
261
|
+
settle(id, { micros, reference } = {}) {
|
|
262
|
+
if (!id) return false;
|
|
263
|
+
return withLock(this.path, () => {
|
|
264
|
+
const state = readState(this.path);
|
|
265
|
+
const entry = state.entries.find((e) => e.id === id);
|
|
266
|
+
if (!entry) return false;
|
|
267
|
+
const booked = reference ? state.entries.find((e) => e.id !== id && e.reference === reference) : undefined;
|
|
268
|
+
const target = booked ?? entry;
|
|
269
|
+
if (Number.isFinite(micros) && micros >= 0) target.micros = micros;
|
|
270
|
+
if (reference) target.reference = reference;
|
|
271
|
+
if (booked) state.entries = state.entries.filter((e) => e.id !== id);
|
|
272
|
+
writeState(this.path, state, Date.now());
|
|
273
|
+
return true;
|
|
274
|
+
}, this.lockWaitMs);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/// Records a spend before the money moves, then reconciles it against what
|
|
279
|
+
/// happened. A failure that never reached the chain is given back; anything
|
|
280
|
+
/// that funded an escrow or paid an endpoint keeps its entry and gains the
|
|
281
|
+
/// transaction that proves it, because a ledger that forgets a spend is worse
|
|
282
|
+
/// than no ledger at all.
|
|
283
|
+
export async function recordSpend(book, tool, micros, run) {
|
|
284
|
+
const id = book.commit({ tool, micros });
|
|
285
|
+
// Reconciling is bookkeeping and must never be the reason a caller loses a
|
|
286
|
+
// machine it paid for, or the reason a failure is reported as the wrong
|
|
287
|
+
// failure. A ledger that cannot be written says so on stderr and the
|
|
288
|
+
// committed figure stands, which errs towards counting the spend.
|
|
289
|
+
const reconcile = (action, ...args) => {
|
|
290
|
+
try {
|
|
291
|
+
book[action](id, ...args);
|
|
292
|
+
} catch (err) {
|
|
293
|
+
console.error(`prism mcp: could not ${action} the ledger entry for ${tool}: ${err?.message ?? err}`);
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
try {
|
|
297
|
+
const { value, settledMicros, reference } = await run();
|
|
298
|
+
reconcile("settle", { micros: settledMicros, reference });
|
|
299
|
+
return value;
|
|
300
|
+
} catch (err) {
|
|
301
|
+
const paid = err?.body?.funding_hash ?? err?.body?.payment_tx;
|
|
302
|
+
if (paid) {
|
|
303
|
+
reconcile("settle", { reference: paid });
|
|
304
|
+
} else if (err?.code === "chain_error") {
|
|
305
|
+
// Signed, broadcast, and then something went wrong reading it back, which
|
|
306
|
+
// is not the same as never having reached the chain. Handing the
|
|
307
|
+
// reservation back would let one wallet fund escrow after escrow through
|
|
308
|
+
// an rpc having a bad hour while the day's ceiling reported nothing
|
|
309
|
+
// spent, so the entry stands at what it reserved.
|
|
310
|
+
reconcile("settle");
|
|
311
|
+
} else {
|
|
312
|
+
reconcile("revert");
|
|
313
|
+
}
|
|
314
|
+
throw err;
|
|
315
|
+
}
|
|
316
|
+
}
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prismnetwork/mcp",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"description": "MCP server for leasing and running on Prism Network GPUs.",
|
|
5
|
-
"mcpName": "io.github.
|
|
5
|
+
"mcpName": "io.github.winter0x/mcp",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
8
|
"prism-mcp": "server.mjs"
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
|
+
"budget.mjs",
|
|
11
12
|
"server.mjs",
|
|
12
13
|
"README.md"
|
|
13
14
|
],
|
|
@@ -17,7 +18,7 @@
|
|
|
17
18
|
"dependencies": {
|
|
18
19
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
19
20
|
"@phala/dcap-qvl": "^0.6.1",
|
|
20
|
-
"@prismnetwork/agent-sdk": "^0.7.
|
|
21
|
+
"@prismnetwork/agent-sdk": "^0.7.3",
|
|
21
22
|
"jose": "^6",
|
|
22
23
|
"viem": "^2"
|
|
23
24
|
},
|
|
@@ -32,14 +33,17 @@
|
|
|
32
33
|
"homepage": "https://prismnetwork.tech",
|
|
33
34
|
"repository": {
|
|
34
35
|
"type": "git",
|
|
35
|
-
"url": "git+https://github.com/
|
|
36
|
+
"url": "git+https://github.com/winter0x/prism.git",
|
|
36
37
|
"directory": "mcp"
|
|
37
38
|
},
|
|
38
39
|
"bugs": {
|
|
39
|
-
"url": "https://github.com/
|
|
40
|
+
"url": "https://github.com/winter0x/prism/issues"
|
|
40
41
|
},
|
|
41
42
|
"license": "Apache-2.0",
|
|
42
43
|
"publishConfig": {
|
|
43
44
|
"access": "public"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"test": "node --test"
|
|
44
48
|
}
|
|
45
49
|
}
|
package/server.mjs
CHANGED
|
@@ -9,17 +9,21 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprot
|
|
|
9
9
|
import {
|
|
10
10
|
DEFAULT_IMAGE,
|
|
11
11
|
DEFAULT_TRUST_FLOOR,
|
|
12
|
+
hostKeyPolicy,
|
|
12
13
|
PrismAgent,
|
|
13
14
|
TRUST_CLASSES,
|
|
14
15
|
verifyConfidential,
|
|
15
16
|
} from "@prismnetwork/agent-sdk";
|
|
17
|
+
import { BudgetError, SpendLedger, callCeiling, readBudget, recordSpend, stripUnexpanded } from "./budget.mjs";
|
|
18
|
+
|
|
19
|
+
stripUnexpanded(process.env);
|
|
16
20
|
|
|
17
21
|
const IMAGE = process.env.PRISM_DEFAULT_IMAGE ?? DEFAULT_IMAGE;
|
|
18
22
|
|
|
19
23
|
const PUBLIC_API = process.env.PRISM_PUBLIC_API ?? "https://api.prismnetwork.tech";
|
|
20
24
|
// The live lease escrow. Overridable, but its absence must not silently
|
|
21
25
|
// disable the wallet the way a missing key does.
|
|
22
|
-
const DEFAULT_ESCROW = "
|
|
26
|
+
const DEFAULT_ESCROW = "0xfD4228eEEfC49e4b76A0CD40af9fdd546220B2FD";
|
|
23
27
|
// Matches the limit the SDK and the control plane enforce, so a command that
|
|
24
28
|
// cannot run is rejected before an escrow is funded.
|
|
25
29
|
const MAX_COMMAND_BYTES = 8 * 1024;
|
|
@@ -48,6 +52,18 @@ if (!agent) {
|
|
|
48
52
|
);
|
|
49
53
|
}
|
|
50
54
|
|
|
55
|
+
// A budget the operator got wrong must stop spending, not fall back to none.
|
|
56
|
+
// Reading capacity and prices is unaffected, so a typo is discoverable rather
|
|
57
|
+
// than fatal.
|
|
58
|
+
let ledger = null;
|
|
59
|
+
let budgetProblem = null;
|
|
60
|
+
try {
|
|
61
|
+
ledger = new SpendLedger(readBudget());
|
|
62
|
+
} catch (err) {
|
|
63
|
+
budgetProblem = err?.message ?? String(err);
|
|
64
|
+
console.error(`prism mcp: ${budgetProblem}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
51
67
|
function requireWallet(tool, reason = "spends money") {
|
|
52
68
|
if (!agent) {
|
|
53
69
|
throw new Error(
|
|
@@ -57,6 +73,17 @@ function requireWallet(tool, reason = "spends money") {
|
|
|
57
73
|
return agent;
|
|
58
74
|
}
|
|
59
75
|
|
|
76
|
+
function requireLedger(tool) {
|
|
77
|
+
if (!ledger) {
|
|
78
|
+
throw new Error(`${tool} needs the spend limits, and they are unusable: ${budgetProblem}`);
|
|
79
|
+
}
|
|
80
|
+
return ledger;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The ledger has to be usable before anything is spent, and the refusal names
|
|
84
|
+
// the tool that asked.
|
|
85
|
+
const spending = (tool, micros, run) => recordSpend(requireLedger(tool), tool, micros, run);
|
|
86
|
+
|
|
60
87
|
function requireCommand(value) {
|
|
61
88
|
if (typeof value !== "string" || value.trim() === "") {
|
|
62
89
|
throw new Error("command is required: the shell command to run on the GPU, e.g. 'nvidia-smi'.");
|
|
@@ -67,12 +94,29 @@ function requireCommand(value) {
|
|
|
67
94
|
return value;
|
|
68
95
|
}
|
|
69
96
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
return
|
|
97
|
+
// What the caller can check the machine against if they open their own session.
|
|
98
|
+
// A lease that publishes nothing says so, rather than leaving the field out and
|
|
99
|
+
// letting its absence read as "fine".
|
|
100
|
+
function hostKey(access) {
|
|
101
|
+
const policy = hostKeyPolicy(access);
|
|
102
|
+
return policy.fingerprint === null
|
|
103
|
+
? { host_key: "unpublished: this lease cannot tell you which machine answers" }
|
|
104
|
+
: { host_key_fingerprint: policy.fingerprint, host_key_claim: policy.mode };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// What the escrow actually holds, which is what the day's budget should count.
|
|
108
|
+
// Booking the caller's ceiling instead charged a 0.2 USDG lease against a 0.5
|
|
109
|
+
// USDG cap, so a 5 USDG day bought ten leases where it could afford twenty-five.
|
|
110
|
+
function escrowed(quote) {
|
|
111
|
+
const held = Number(quote?.maximum_escrow);
|
|
112
|
+
return Number.isFinite(held) && held > 0 ? held : undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The per-call ceiling is the operator's, not the model's: an omitted max_usdg
|
|
116
|
+
// takes PRISM_MAX_USDG rather than a hardcoded number, and a stated one above it
|
|
117
|
+
// is clamped back down to it.
|
|
118
|
+
function maxDeposit(tool, args) {
|
|
119
|
+
return callCeiling(args.max_usdg, requireLedger(tool).maxPerCallMicros);
|
|
76
120
|
}
|
|
77
121
|
|
|
78
122
|
const PROOF_FEED = process.env.PRISM_PROOF_URL ?? "https://prismnetwork.tech/api/proof";
|
|
@@ -129,13 +173,14 @@ async function inferenceOffer(base, requested) {
|
|
|
129
173
|
return { offer, model, unit: BigInt(offer.price_micros ?? 0) };
|
|
130
174
|
}
|
|
131
175
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if (price <= 0n || price > BigInt(
|
|
138
|
-
|
|
176
|
+
// The quoted price against the lower of what this call asked for and what the
|
|
177
|
+
// operator allows, so the refusal names whichever ceiling stopped it.
|
|
178
|
+
function withinCap(tool, price, maxUsdg, fallback, what) {
|
|
179
|
+
const operator = requireLedger(tool).maxPerCallMicros;
|
|
180
|
+
const cap = callCeiling(maxUsdg ?? fallback, operator);
|
|
181
|
+
if (price <= 0n || price > BigInt(cap)) {
|
|
182
|
+
const named = cap === operator ? "PRISM_MAX_USDG" : "max_usdg";
|
|
183
|
+
throw new Error(`the endpoint quotes ${usdg(price)} ${what}, past the ${named} cap of ${usdg(cap)}.`);
|
|
139
184
|
}
|
|
140
185
|
}
|
|
141
186
|
|
|
@@ -156,11 +201,28 @@ function leaseId(value) {
|
|
|
156
201
|
return id;
|
|
157
202
|
}
|
|
158
203
|
|
|
204
|
+
// Hints a client uses to decide what it may run unattended. `reads` is anything
|
|
205
|
+
// that cannot change state or move money; `spends` is anything that can, and it
|
|
206
|
+
// carries the Claude Code marker that forces a confirmation prompt on every call
|
|
207
|
+
// even in modes that otherwise auto-approve.
|
|
208
|
+
const reads = { readOnlyHint: true, openWorldHint: true };
|
|
209
|
+
const spends = {
|
|
210
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
211
|
+
_meta: { "anthropic/requiresUserInteraction": true },
|
|
212
|
+
};
|
|
213
|
+
|
|
159
214
|
const TOOLS = [
|
|
215
|
+
{
|
|
216
|
+
name: "prism_budget",
|
|
217
|
+
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.",
|
|
218
|
+
inputSchema: { type: "object", properties: {} },
|
|
219
|
+
annotations: { title: "Spending limits", readOnlyHint: true, openWorldHint: false },
|
|
220
|
+
},
|
|
160
221
|
{
|
|
161
222
|
name: "prism_wallet",
|
|
162
223
|
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.",
|
|
163
224
|
inputSchema: { type: "object", properties: {} },
|
|
225
|
+
annotations: { title: "Wallet balances", ...reads },
|
|
164
226
|
},
|
|
165
227
|
{
|
|
166
228
|
name: "prism_list_gpus",
|
|
@@ -175,11 +237,13 @@ const TOOLS = [
|
|
|
175
237
|
},
|
|
176
238
|
},
|
|
177
239
|
},
|
|
240
|
+
annotations: { title: "Available GPUs", ...reads },
|
|
178
241
|
},
|
|
179
242
|
{
|
|
180
243
|
name: "prism_price_index",
|
|
181
244
|
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.",
|
|
182
245
|
inputSchema: { type: "object", properties: {} },
|
|
246
|
+
annotations: { title: "GPU price index", ...reads },
|
|
183
247
|
},
|
|
184
248
|
{
|
|
185
249
|
name: "prism_receipts",
|
|
@@ -190,11 +254,13 @@ const TOOLS = [
|
|
|
190
254
|
limit: { type: "integer", description: "Max receipts to return (default 10, max 50)." },
|
|
191
255
|
},
|
|
192
256
|
},
|
|
257
|
+
annotations: { title: "Settled receipts", ...reads },
|
|
193
258
|
},
|
|
194
259
|
{
|
|
195
260
|
name: "prism_leases",
|
|
196
261
|
description: "List this wallet's leases on Prism Network with their current state.",
|
|
197
262
|
inputSchema: { type: "object", properties: {} },
|
|
263
|
+
annotations: { title: "Your leases", ...reads },
|
|
198
264
|
},
|
|
199
265
|
{
|
|
200
266
|
name: "prism_batch_run",
|
|
@@ -205,10 +271,12 @@ const TOOLS = [
|
|
|
205
271
|
command: { type: "string", description: "Shell command to run (max 8 KiB)." },
|
|
206
272
|
duration_seconds: { type: "integer", description: "Paid window in seconds (default 900, max 21600). A command still running at the end is killed and reported exit 124." },
|
|
207
273
|
min_vram_mib: { type: "integer", description: "Minimum GPU memory in MiB (default 16000)." },
|
|
208
|
-
max_usdg: { type: "number", description: "
|
|
274
|
+
max_usdg: { type: "number", description: "Cost ceiling for this lease in USDG. It lowers the operator's PRISM_MAX_USDG and cannot raise it; omitted, that ceiling applies. See prism_budget." },
|
|
209
275
|
},
|
|
210
276
|
required: ["command"],
|
|
211
277
|
},
|
|
278
|
+
...spends,
|
|
279
|
+
annotations: { title: "Rent a GPU for one command", ...spends.annotations },
|
|
212
280
|
},
|
|
213
281
|
{
|
|
214
282
|
name: "prism_batch_result",
|
|
@@ -220,6 +288,7 @@ const TOOLS = [
|
|
|
220
288
|
},
|
|
221
289
|
required: ["lease_id"],
|
|
222
290
|
},
|
|
291
|
+
annotations: { title: "Batch result", ...reads },
|
|
223
292
|
},
|
|
224
293
|
{
|
|
225
294
|
name: "prism_infer",
|
|
@@ -229,10 +298,12 @@ const TOOLS = [
|
|
|
229
298
|
properties: {
|
|
230
299
|
prompt: { type: "string", description: "The prompt to generate from (max 32 KiB)." },
|
|
231
300
|
model: { type: "string", description: "Model to use; defaults to the endpoint's first offered model." },
|
|
232
|
-
max_usdg: { type: "number", description: "Refuse if the quoted price exceeds this (default 0.05)." },
|
|
301
|
+
max_usdg: { type: "number", description: "Refuse if the quoted price exceeds this (default 0.05). The operator's PRISM_MAX_USDG binds it either way." },
|
|
233
302
|
},
|
|
234
303
|
required: ["prompt"],
|
|
235
304
|
},
|
|
305
|
+
...spends,
|
|
306
|
+
annotations: { title: "Buy one LLM generation", ...spends.annotations },
|
|
236
307
|
},
|
|
237
308
|
{
|
|
238
309
|
name: "prism_infer_batch",
|
|
@@ -248,10 +319,12 @@ const TOOLS = [
|
|
|
248
319
|
description: "Independent prompts, answered in the order given (each max 32 KiB).",
|
|
249
320
|
},
|
|
250
321
|
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)." },
|
|
322
|
+
max_usdg: { type: "number", description: "Refuse if the quoted total exceeds this (default 0.5). The operator's PRISM_MAX_USDG binds it either way." },
|
|
252
323
|
},
|
|
253
324
|
required: ["prompts"],
|
|
254
325
|
},
|
|
326
|
+
...spends,
|
|
327
|
+
annotations: { title: "Buy many LLM generations", ...spends.annotations },
|
|
255
328
|
},
|
|
256
329
|
{
|
|
257
330
|
name: "prism_confidential_infer",
|
|
@@ -262,11 +335,13 @@ const TOOLS = [
|
|
|
262
335
|
prompt: { type: "string", description: "The prompt to generate from." },
|
|
263
336
|
model: { type: "string", description: "Confidential model to use; defaults to the endpoint's first." },
|
|
264
337
|
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)." },
|
|
338
|
+
max_usdg: { type: "number", description: "Refuse if the quoted price exceeds this (default 0.25). The operator's PRISM_MAX_USDG binds it either way." },
|
|
266
339
|
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
340
|
},
|
|
268
341
|
required: ["prompt"],
|
|
269
342
|
},
|
|
343
|
+
...spends,
|
|
344
|
+
annotations: { title: "Buy one confidential LLM generation", ...spends.annotations },
|
|
270
345
|
},
|
|
271
346
|
{
|
|
272
347
|
name: "prism_verify_attestation",
|
|
@@ -279,6 +354,7 @@ const TOOLS = [
|
|
|
279
354
|
},
|
|
280
355
|
required: ["receipt_id"],
|
|
281
356
|
},
|
|
357
|
+
annotations: { title: "Check a confidential generation", ...reads },
|
|
282
358
|
},
|
|
283
359
|
{
|
|
284
360
|
name: "prism_lease_and_run",
|
|
@@ -294,10 +370,12 @@ const TOOLS = [
|
|
|
294
370
|
enum: TRUST_CLASSES,
|
|
295
371
|
description: "Refuse suppliers below this trust class (default 'open'). Raise it for anything the host operator must not read.",
|
|
296
372
|
},
|
|
297
|
-
max_usdg: { type: "number", description: "
|
|
373
|
+
max_usdg: { type: "number", description: "Cost ceiling for this lease in USDG. It lowers the operator's PRISM_MAX_USDG and cannot raise it; omitted, that ceiling applies. See prism_budget." },
|
|
298
374
|
},
|
|
299
375
|
required: ["command"],
|
|
300
376
|
},
|
|
377
|
+
...spends,
|
|
378
|
+
annotations: { title: "Rent a GPU and run a command", ...spends.annotations },
|
|
301
379
|
},
|
|
302
380
|
{
|
|
303
381
|
name: "prism_lease",
|
|
@@ -312,9 +390,11 @@ const TOOLS = [
|
|
|
312
390
|
enum: TRUST_CLASSES,
|
|
313
391
|
description: "Refuse suppliers below this trust class (default 'open'). Raise it for anything the host operator must not read.",
|
|
314
392
|
},
|
|
315
|
-
max_usdg: { type: "number", description: "
|
|
393
|
+
max_usdg: { type: "number", description: "Cost ceiling for this lease in USDG. It lowers the operator's PRISM_MAX_USDG and cannot raise it; omitted, that ceiling applies. See prism_budget." },
|
|
316
394
|
},
|
|
317
395
|
},
|
|
396
|
+
...spends,
|
|
397
|
+
annotations: { title: "Rent a GPU", ...spends.annotations },
|
|
318
398
|
},
|
|
319
399
|
{
|
|
320
400
|
name: "prism_run",
|
|
@@ -328,6 +408,7 @@ const TOOLS = [
|
|
|
328
408
|
},
|
|
329
409
|
required: ["lease_id", "command"],
|
|
330
410
|
},
|
|
411
|
+
annotations: { title: "Run a command on a lease", readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
331
412
|
},
|
|
332
413
|
{
|
|
333
414
|
name: "prism_end_lease",
|
|
@@ -337,6 +418,7 @@ const TOOLS = [
|
|
|
337
418
|
properties: { lease_id: { type: "integer" } },
|
|
338
419
|
required: ["lease_id"],
|
|
339
420
|
},
|
|
421
|
+
annotations: { title: "Release a lease", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
340
422
|
},
|
|
341
423
|
{
|
|
342
424
|
name: "prism_vault_store",
|
|
@@ -354,11 +436,13 @@ const TOOLS = [
|
|
|
354
436
|
},
|
|
355
437
|
required: ["value"],
|
|
356
438
|
},
|
|
439
|
+
annotations: { title: "Seal a secret", readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
357
440
|
},
|
|
358
441
|
{
|
|
359
442
|
name: "prism_vault_list",
|
|
360
443
|
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.",
|
|
361
444
|
inputSchema: { type: "object", properties: {} },
|
|
445
|
+
annotations: { title: "List sealed items", ...reads },
|
|
362
446
|
},
|
|
363
447
|
{
|
|
364
448
|
name: "prism_vault_read",
|
|
@@ -368,6 +452,8 @@ const TOOLS = [
|
|
|
368
452
|
properties: { item_id: { type: "string", description: "The item_id from prism_vault_store or prism_vault_list." } },
|
|
369
453
|
required: ["item_id"],
|
|
370
454
|
},
|
|
455
|
+
_meta: { "anthropic/requiresUserInteraction": true },
|
|
456
|
+
annotations: { title: "Decrypt one sealed item", readOnlyHint: true, openWorldHint: true },
|
|
371
457
|
},
|
|
372
458
|
{
|
|
373
459
|
name: "prism_vault_delete",
|
|
@@ -377,6 +463,8 @@ const TOOLS = [
|
|
|
377
463
|
properties: { item_id: { type: "string" } },
|
|
378
464
|
required: ["item_id"],
|
|
379
465
|
},
|
|
466
|
+
...spends,
|
|
467
|
+
annotations: { title: "Delete a sealed item", ...spends.annotations },
|
|
380
468
|
},
|
|
381
469
|
{
|
|
382
470
|
name: "prism_vault_release",
|
|
@@ -389,10 +477,13 @@ const TOOLS = [
|
|
|
389
477
|
},
|
|
390
478
|
required: ["item_id", "lease_id"],
|
|
391
479
|
},
|
|
480
|
+
...spends,
|
|
481
|
+
annotations: { title: "Release a secret into a lease", ...spends.annotations },
|
|
392
482
|
},
|
|
393
483
|
];
|
|
394
484
|
|
|
395
485
|
async function handle(name, args) {
|
|
486
|
+
if (name === "prism_budget") return requireLedger(name).status();
|
|
396
487
|
if (name === "prism_wallet") {
|
|
397
488
|
const b = await requireWallet("prism_wallet").balances();
|
|
398
489
|
return { address: b.address, usdg: usdg(b.usdg), eth_wei: b.eth };
|
|
@@ -483,22 +574,28 @@ async function handle(name, args) {
|
|
|
483
574
|
if (name === "prism_batch_run") {
|
|
484
575
|
requireCommand(args.command);
|
|
485
576
|
requireWallet(name);
|
|
486
|
-
const cap = maxDeposit(args);
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
577
|
+
const cap = maxDeposit(name, args);
|
|
578
|
+
return spending(name, cap, async () => {
|
|
579
|
+
const batch = await agent.lease({
|
|
580
|
+
image: IMAGE,
|
|
581
|
+
durationSeconds: args.duration_seconds ?? 900,
|
|
582
|
+
minVramMib: args.min_vram_mib ?? 16000,
|
|
583
|
+
maxDeposit: cap,
|
|
584
|
+
command: args.command,
|
|
585
|
+
});
|
|
586
|
+
return {
|
|
587
|
+
reference: batch.fundingHash,
|
|
588
|
+
settledMicros: escrowed(batch.quote),
|
|
589
|
+
value: {
|
|
590
|
+
lease_id: batch.leaseId,
|
|
591
|
+
funding_tx: batch.fundingHash,
|
|
592
|
+
exit_code: batch.result?.exit_code,
|
|
593
|
+
stdout: batch.result?.stdout,
|
|
594
|
+
stderr: batch.result?.stderr,
|
|
595
|
+
truncated: batch.result?.truncated ?? false,
|
|
596
|
+
},
|
|
597
|
+
};
|
|
493
598
|
});
|
|
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
|
-
};
|
|
502
599
|
}
|
|
503
600
|
if (name === "prism_batch_result") {
|
|
504
601
|
requireWallet(name, "reads this wallet's leases");
|
|
@@ -512,14 +609,17 @@ async function handle(name, args) {
|
|
|
512
609
|
requireWallet(name);
|
|
513
610
|
const base = inferenceBase();
|
|
514
611
|
const { offer, model, unit } = await inferenceOffer(base, args.model);
|
|
515
|
-
withinCap(unit, args.max_usdg, 0.05, "per generation");
|
|
516
|
-
return
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
612
|
+
withinCap(name, unit, args.max_usdg, 0.05, "per generation");
|
|
613
|
+
return spending(name, Number(unit), async () => {
|
|
614
|
+
const value = await payAndPost({
|
|
615
|
+
base,
|
|
616
|
+
path: "/v1/inference",
|
|
617
|
+
price: unit,
|
|
618
|
+
payTo: offer.pay_to,
|
|
619
|
+
body: { model, prompt: args.prompt },
|
|
620
|
+
tool: "prism_infer",
|
|
621
|
+
});
|
|
622
|
+
return { value, settledMicros: Number(unit), reference: value.payment_tx };
|
|
523
623
|
});
|
|
524
624
|
}
|
|
525
625
|
if (name === "prism_infer_batch") {
|
|
@@ -537,14 +637,17 @@ async function handle(name, args) {
|
|
|
537
637
|
const base = inferenceBase();
|
|
538
638
|
const { offer, model, unit } = await inferenceOffer(base, args.model);
|
|
539
639
|
const price = unit * BigInt(prompts.length);
|
|
540
|
-
withinCap(price, args.max_usdg, 0.5, `for ${prompts.length} generations`);
|
|
541
|
-
return
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
640
|
+
withinCap(name, price, args.max_usdg, 0.5, `for ${prompts.length} generations`);
|
|
641
|
+
return spending(name, Number(price), async () => {
|
|
642
|
+
const value = await payAndPost({
|
|
643
|
+
base,
|
|
644
|
+
path: "/v1/batch",
|
|
645
|
+
price,
|
|
646
|
+
payTo: offer.pay_to,
|
|
647
|
+
body: { model, prompts },
|
|
648
|
+
tool: "prism_infer_batch",
|
|
649
|
+
});
|
|
650
|
+
return { value, settledMicros: Number(price), reference: value.payment_tx };
|
|
548
651
|
});
|
|
549
652
|
}
|
|
550
653
|
if (name === "prism_confidential_infer") {
|
|
@@ -553,13 +656,19 @@ async function handle(name, args) {
|
|
|
553
656
|
}
|
|
554
657
|
requireWallet(name);
|
|
555
658
|
const base = inferenceBase();
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
659
|
+
// The endpoint quotes inside the SDK, so the day is charged the ceiling up
|
|
660
|
+
// front and corrected to the quoted price once the call has been served.
|
|
661
|
+
const cap = callCeiling(args.max_usdg ?? 0.25, requireLedger(name).maxPerCallMicros);
|
|
662
|
+
const run = await spending(name, cap, async () => {
|
|
663
|
+
const served = await agent.confidentialInfer({
|
|
664
|
+
prompt: args.prompt,
|
|
665
|
+
model: args.model,
|
|
666
|
+
maxTokens: args.max_tokens ?? 512,
|
|
667
|
+
maxUsdg: cap / 1e6,
|
|
668
|
+
e2ee: args.e2ee ?? true,
|
|
669
|
+
endpoint: base,
|
|
670
|
+
});
|
|
671
|
+
return { value: served, settledMicros: Number(served.priceMicros), reference: served.tx };
|
|
563
672
|
});
|
|
564
673
|
rememberConfidentialCall(run.receiptId, {
|
|
565
674
|
base,
|
|
@@ -622,20 +731,32 @@ async function handle(name, args) {
|
|
|
622
731
|
if (name === "prism_lease_and_run" || name === "prism_lease") {
|
|
623
732
|
if (name === "prism_lease_and_run") requireCommand(args.command);
|
|
624
733
|
requireWallet(name);
|
|
625
|
-
const cap = maxDeposit(args);
|
|
734
|
+
const cap = maxDeposit(name, args);
|
|
626
735
|
sweepExpiredLeases();
|
|
627
|
-
const lease = await
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
736
|
+
const lease = await spending(name, cap, async () => {
|
|
737
|
+
const funded = await agent.lease({
|
|
738
|
+
image: IMAGE,
|
|
739
|
+
durationSeconds: args.duration_seconds ?? 900,
|
|
740
|
+
minVramMib: args.min_vram_mib ?? 16000,
|
|
741
|
+
maxDeposit: cap,
|
|
742
|
+
minTrustClass: args.min_trust_class ?? "open",
|
|
743
|
+
});
|
|
744
|
+
return { value: funded, reference: funded.fundingHash, settledMicros: escrowed(funded.quote) };
|
|
633
745
|
});
|
|
634
746
|
leases.set(lease.leaseId, lease);
|
|
635
747
|
const summary = {
|
|
636
748
|
lease_id: lease.leaseId,
|
|
637
749
|
funding_tx: lease.fundingHash,
|
|
638
|
-
|
|
750
|
+
// `prism_run` checks this itself. It is in the summary because the
|
|
751
|
+
// caller is being handed an address they may connect to by hand, and an
|
|
752
|
+
// address with no key to check is an invitation to accept whatever
|
|
753
|
+
// answers.
|
|
754
|
+
ssh: {
|
|
755
|
+
host: lease.access.ssh_host,
|
|
756
|
+
port: lease.access.ssh_port,
|
|
757
|
+
user: lease.access.ssh_user,
|
|
758
|
+
...hostKey(lease.access),
|
|
759
|
+
},
|
|
639
760
|
trust: lease.quote?.trust_class ?? "open",
|
|
640
761
|
expires_at: lease.access.expires_at,
|
|
641
762
|
};
|
|
@@ -734,6 +855,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
734
855
|
const result = await handle(request.params.name, request.params.arguments ?? {});
|
|
735
856
|
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
736
857
|
} catch (err) {
|
|
858
|
+
if (err instanceof BudgetError) {
|
|
859
|
+
return { isError: true, content: [{ type: "text", text: `${err.message} See prism_budget.` }] };
|
|
860
|
+
}
|
|
737
861
|
const body = err?.body ?? {};
|
|
738
862
|
const detail = [
|
|
739
863
|
body.cause,
|