mycomesh-consumer 0.2.1 → 0.3.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.
@@ -5,6 +5,7 @@
5
5
  "settlement": "0xd46b7efb1e650e83cdeb4a36fdfef33499e54842",
6
6
  "stablecoin": "0xbce27efad4191277167fe76819581a97959048cf",
7
7
  "registry": "0x0ff3210e8e8abf5f5bfa73a39e60db7416b6c8c7",
8
+ "relay_directory": "0xa0aaad9393f51cb5786c69f152133a174588bdab",
8
9
  "deployment_block": 11809185,
9
10
  "rpc_urls": [
10
11
  "https://ethereum-sepolia-rpc.publicnode.com",
@@ -12,6 +13,7 @@
12
13
  "https://sepolia.gateway.tenderly.co"
13
14
  ],
14
15
  "tls_ca_file": "mycomesh-testnet-ca.crt",
16
+ "faucet_url": "https://136.0.3.126:10443",
15
17
  "relays": [
16
18
  {
17
19
  "url": "https://136.0.3.126:10443",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mycomesh-consumer",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "MycoMesh V11 Consumer: a custodied deposit, relay-blind encrypted requests to any Provider, and a local OpenAI-compatible endpoint.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/cli.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // mycomesh-consumer (V11): init, setup, balance, request, serve.
1
+ // mycomesh-consumer (V11): init, wallet, faucet, setup, balance, request, serve, dispute.
2
2
  import { randomBytes } from "node:crypto";
3
3
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import { homedir } from "node:os";
@@ -7,26 +7,32 @@ import { fileURLToPath } from "node:url";
7
7
  import { parseArgs } from "node:util";
8
8
  import { addressOf, encodeCall, settlementKey } from "./eip712.mjs";
9
9
  import { availableBalance, rpcCall, sendTransaction } from "./chain.mjs";
10
- import { Consumer, httpJson, loadNetwork, serveConsumer } from "./consumer.mjs";
10
+ import { Consumer, directoryRelays, httpJson, loadNetwork, serveConsumer } from "./consumer.mjs";
11
+ import { createWallet, ownerAddress, ownerKey, walletAddress } from "./wallet.mjs";
11
12
  import { buildEvidence, evidenceHash, loadRequest, openDisputeCall, reportId } from "./disputes.mjs";
12
13
 
13
14
  const DEFAULT_NETWORK = join(dirname(fileURLToPath(import.meta.url)), "../networks/mycomesh-v11-sepolia.json");
14
15
 
15
16
  const USAGE = `Usage: mycomesh-consumer <command> [options]
16
17
 
17
- init create the local payment key (never leaves this machine)
18
+ init create the local payment key and the password-protected owner wallet
18
19
  address print the payment key address
19
- setup --owner-key-file F --deposit UNITS [--max-per-request UNITS] [--faucet UNITS]
20
+ wallet print the owner wallet address (a V3 keystore; MetaMask can import it)
21
+ faucet testnet: get gas ETH and tUSDC for the owner wallet
22
+ setup --deposit UNITS [--max-per-request UNITS]
20
23
  deposit into the settlement contract and authorize the payment key
21
- (--faucet first mints testnet tUSDC to the owner)
24
+ (on testnets, tops the wallet up from the faucet first)
22
25
  balance [--owner ADDR] show the custodied deposit
23
- request "prompt" send one request and print the verified answer
24
- serve [--port 8110] run the local OpenAI-compatible endpoint (default)
25
- dispute <settlement-key|last> --owner-key-file F [--reason CODE] [--statement TEXT]
26
+ request "prompt" [--stream]
27
+ send one request and print the verified answer
28
+ serve [--port 8110] run the local OpenAI-compatible endpoint with streaming (default)
29
+ dispute <settlement-key|last> [--reason CODE] [--statement TEXT]
26
30
  reveal a recorded request and response to a Provider-AI jury
27
31
  (within 24 hours; the reporter bond is returned if fraud is confirmed)
28
32
 
29
- Common: --network FILE (default: the bundled MycoMesh V11 Sepolia manifest), --data-dir DIR, --model ID, --max-fee UNITS, --provider SIGNER`;
33
+ Common: --network FILE (default: the bundled MycoMesh V11 Sepolia manifest), --data-dir DIR, --model ID,
34
+ --max-fee UNITS, --provider SIGNER, --owner-key-file F (raw key or keystore instead of the local wallet).
35
+ The wallet password comes from MYCOMESH_WALLET_PASSWORD or a terminal prompt.`;
30
36
 
31
37
  function dataDir(value) {
32
38
  const dir = value || process.env.MYCOMESH_CONSUMER_DATA_DIR || join(homedir(), ".mycomesh", "v11");
@@ -59,26 +65,55 @@ export async function main(argv = process.argv.slice(2), { stdout = process.stdo
59
65
  model: { type: "string", default: "gpt-5.5" }, "max-fee": { type: "string", default: "1000000" },
60
66
  port: { type: "string", default: "8110" }, host: { type: "string", default: "127.0.0.1" },
61
67
  "max-output-tokens": { type: "string", default: "4096" }, help: { type: "boolean" },
62
- reason: { type: "string", default: "unrelated_response" }, faucet: { type: "string" }, provider: { type: "string" }, statement: { type: "string", default: "" },
68
+ reason: { type: "string", default: "unrelated_response" }, faucet: { type: "boolean" }, provider: { type: "string" },
69
+ statement: { type: "string", default: "" }, stream: { type: "boolean" },
63
70
  },
64
71
  });
65
72
  const [command = "serve", ...rest] = positionals;
66
73
  if (values.help || command === "help") { stdout.write(`${USAGE}\n`); return 0; }
67
74
  const dir = dataDir(values["data-dir"]);
68
- if (command === "init" || command === "address") {
69
- stdout.write(`${addressOf(paymentKey(dir, command === "init"))}\n`);
75
+ if (command === "address") {
76
+ stdout.write(`${addressOf(paymentKey(dir))}\n`);
77
+ return 0;
78
+ }
79
+ if (command === "init") {
80
+ stdout.write(`${addressOf(paymentKey(dir, true))}\n`);
81
+ if (!values["owner-key-file"]) {
82
+ stdout.write(`owner wallet ${walletAddress(await createWallet(dir))} (${join(dir, "owner-wallet.json")})\n`);
83
+ }
84
+ return 0;
85
+ }
86
+ if (command === "wallet") {
87
+ stdout.write(`${values["owner-key-file"] ? ownerAddress(values["owner-key-file"], dir) : walletAddress(await createWallet(dir))}\n`);
70
88
  return 0;
71
89
  }
72
90
  const network = loadNetwork(values.network || process.env.MYCOMESH_NETWORK || DEFAULT_NETWORK);
91
+ const faucet = async (address) => {
92
+ if (!network.faucet_url) throw new Error("this network has no faucet");
93
+ const reply = await httpJson(`${network.faucet_url}/v11/faucet`, { method: "POST", body: { address }, ca: network.tls_ca, timeoutMs: 400_000 });
94
+ if (reply.status !== 200) throw new Error(`faucet: ${reply.body?.error || reply.status}`);
95
+ return reply.body;
96
+ };
97
+ if (command === "faucet") {
98
+ const address = values.owner || ownerAddress(values["owner-key-file"], dir) || walletAddress(await createWallet(dir));
99
+ stdout.write(`${JSON.stringify(await faucet(address))}\n`);
100
+ return 0;
101
+ }
73
102
  if (command === "setup") {
74
103
  const keyPrivate = paymentKey(dir);
75
- const owner = readKey(values["owner-key-file"]);
104
+ if (!values.deposit) throw new Error("--deposit UNITS is required");
105
+ if (!values["owner-key-file"]) await createWallet(dir);
106
+ const address = ownerAddress(values["owner-key-file"], dir);
76
107
  const deposit = BigInt(values.deposit);
77
108
  const limit = BigInt(values["max-per-request"] || values["max-fee"]);
78
- if (values.faucet) {
79
- await sendTransaction(network.rpc_urls, owner, { to: network.stablecoin,
80
- data: encodeCall("mint(address,uint256)", [["address", addressOf(owner)], ["uint", BigInt(values.faucet)]]) });
109
+ const eth = BigInt(await rpcCall(network.rpc_urls, "eth_getBalance", [address, "latest"]));
110
+ const tokens = BigInt(await rpcCall(network.rpc_urls, "eth_call", [{ to: network.stablecoin,
111
+ data: encodeCall("balanceOf(address)", [["address", address]]) }, "latest"]));
112
+ if (network.faucet_url && (values.faucet || eth < 2_000_000_000_000_000n || tokens < deposit)) {
113
+ stdout.write(`funding ${address} from the testnet faucet...\n`);
114
+ await faucet(address);
81
115
  }
116
+ const owner = await ownerKey(values["owner-key-file"], dir);
82
117
  await sendTransaction(network.rpc_urls, owner, { to: network.stablecoin,
83
118
  data: encodeCall("approve(address,uint256)", [["address", network.settlement], ["uint", deposit]]) });
84
119
  await sendTransaction(network.rpc_urls, owner, { to: network.settlement, data: encodeCall("deposit(uint256)", [["uint", deposit]]) });
@@ -88,13 +123,13 @@ export async function main(argv = process.argv.slice(2), { stdout = process.stdo
88
123
  return 0;
89
124
  }
90
125
  if (command === "balance") {
91
- const owner = values.owner || (values["owner-key-file"] ? addressOf(readKey(values["owner-key-file"])) : null);
92
- if (!owner) throw new Error("--owner or --owner-key-file is required");
126
+ const owner = values.owner || ownerAddress(values["owner-key-file"], dir);
127
+ if (!owner) throw new Error("--owner, --owner-key-file or a local wallet (`init`) is required");
93
128
  stdout.write(`${await availableBalance(network, owner)}\n`);
94
129
  return 0;
95
130
  }
96
131
  if (command === "dispute") {
97
- const owner = readKey(values["owner-key-file"]);
132
+ const owner = await ownerKey(values["owner-key-file"], dir);
98
133
  const record = loadRequest(dir, rest[0] || "last");
99
134
  const evidence = buildEvidence(record, { reasonCode: values.reason, statement: values.statement || rest.slice(1).join(" ") });
100
135
  const digest = evidenceHash(evidence);
@@ -107,7 +142,9 @@ export async function main(argv = process.argv.slice(2), { stdout = process.stdo
107
142
  }
108
143
  await sendTransaction(network.rpc_urls, owner, { to: network.settlement, data: openDisputeCall(record.settlement_key, digest) });
109
144
  const accepted = [];
110
- for (const relay of network.relays) {
145
+ let relays = network.relays;
146
+ try { relays = [...relays, ...await directoryRelays(network)]; } catch {}
147
+ for (const relay of relays) {
111
148
  try {
112
149
  const reply = await httpJson(`${relay.url}/v11/evidence`, { method: "POST", body: evidence, ca: network.tls_ca, timeoutMs: 30_000 });
113
150
  if (reply.status === 200) accepted.push(relay.url);
@@ -122,8 +159,9 @@ export async function main(argv = process.argv.slice(2), { stdout = process.stdo
122
159
  if (command === "request") {
123
160
  const { response, receipt } = await consumer.request({
124
161
  endpoint: "responses", model: values.model, content: rest.join(" "), maxOutputTokens: Number(values["max-output-tokens"]),
125
- provider: values.provider,
162
+ provider: values.provider, onDelta: values.stream ? (text) => process.stderr.write(text) : undefined,
126
163
  });
164
+ if (values.stream) process.stderr.write("\n");
127
165
  const output = response.output;
128
166
  stdout.write(`${JSON.stringify({ output_text: output.output_text ?? output, fee: receipt.receipt.actual_fee,
129
167
  provider: receipt.authorization.provider_signer, request_id: receipt.authorization.request_id,
package/src/consumer.mjs CHANGED
@@ -4,12 +4,14 @@ import { createServer, request as httpRequest } from "node:http";
4
4
  import { request as httpsRequest } from "node:https";
5
5
  import { dirname, resolve } from "node:path";
6
6
  import { rootCertificates } from "node:tls";
7
- import { addressOf } from "./eip712.mjs";
8
- import { openResponse, prepareRequest, verifiedProvider } from "./protocol.mjs";
7
+ import { addressOf, encodeCall } from "./eip712.mjs";
8
+ import { rpcCall } from "./chain.mjs";
9
+ import { openDelta, openResponse, outputText, prepareRequest, verifiedProvider } from "./protocol.mjs";
9
10
  import { recordRequest } from "./disputes.mjs";
10
- import { chatSse, responseSse } from "./sse.mjs";
11
+ import { chatStream, responseStream } from "./sse.mjs";
11
12
 
12
13
  const PROVIDER_CACHE_MS = 30_000;
14
+ const RELAY_CACHE_MS = 300_000;
13
15
  const MAX_BODY = 16 * 1024 * 1024;
14
16
 
15
17
  export function loadNetwork(path) {
@@ -47,8 +49,71 @@ export function httpJson(url, { method = "GET", body, ca, timeoutMs = 330_000 }
47
49
  });
48
50
  }
49
51
 
52
+ /** POST expecting NDJSON; each line goes to onLine. Non-streamed replies resolve as JSON. */
53
+ export function httpNdjson(url, { body, ca, timeoutMs = 340_000, onLine }) {
54
+ const target = new URL(url);
55
+ const data = Buffer.from(JSON.stringify(body));
56
+ const send = target.protocol === "https:" ? httpsRequest : httpRequest;
57
+ return new Promise((resolvePromise, reject) => {
58
+ const req = send(target, {
59
+ method: "POST",
60
+ headers: { accept: "application/x-ndjson", "content-type": "application/json", "content-length": data.length },
61
+ ...(target.protocol === "https:" && ca ? { ca: [...rootCertificates, ca] } : {}),
62
+ }, (res) => {
63
+ const streaming = res.statusCode === 200 && String(res.headers["content-type"] || "").includes("ndjson");
64
+ let buffered = "";
65
+ const chunks = [];
66
+ res.setEncoding("utf8");
67
+ res.on("data", (chunk) => {
68
+ if (!streaming) { chunks.push(chunk); return; }
69
+ buffered += chunk;
70
+ let index;
71
+ while ((index = buffered.indexOf("\n")) >= 0) {
72
+ const line = buffered.slice(0, index);
73
+ buffered = buffered.slice(index + 1);
74
+ if (line.trim()) {
75
+ try { onLine(JSON.parse(line)); } catch (error) { req.destroy(error); return; }
76
+ }
77
+ }
78
+ });
79
+ res.on("end", () => {
80
+ if (streaming) return resolvePromise({ status: 200, streamed: true });
81
+ try { resolvePromise({ status: res.statusCode, body: JSON.parse(chunks.join("") || "{}") }); } catch (error) { reject(error); }
82
+ });
83
+ });
84
+ req.setTimeout(timeoutMs, () => req.destroy(new Error("request timed out")));
85
+ req.on("error", reject);
86
+ req.end(data);
87
+ });
88
+ }
89
+
90
+ function decodeString(raw, offset) {
91
+ const length = Number(BigInt(`0x${raw.slice(offset * 2, offset * 2 + 64)}`));
92
+ return Buffer.from(raw.slice(offset * 2 + 64, offset * 2 + 64 + length * 2), "hex").toString("utf8");
93
+ }
94
+
95
+ /** Active Relays announced in the on-chain RelayDirectoryV11. */
96
+ export async function directoryRelays(network) {
97
+ if (!network.relay_directory) return [];
98
+ const count = Number(BigInt(await rpcCall(network.rpc_urls, "eth_call", [{ to: network.relay_directory, data: encodeCall("relayCount()", []) }, "latest"])));
99
+ const relays = [];
100
+ for (let index = 0; index < count; index += 1) {
101
+ const raw = (await rpcCall(network.rpc_urls, "eth_call", [{ to: network.relay_directory,
102
+ data: encodeCall("relayAt(uint256)", [["uint", BigInt(index)]]) }, "latest"])).slice(2);
103
+ const base = Number(BigInt(`0x${raw.slice(0, 64)}`));
104
+ if (!Number(BigInt(`0x${raw.slice(64, 128)}`))) continue;
105
+ const entry = raw.slice(base * 2);
106
+ relays.push({
107
+ signer: `0x${entry.slice(64 + 24, 128)}`.toLowerCase(),
108
+ url: decodeString(entry, Number(BigInt(`0x${entry.slice(128, 192)}`))).replace(/\/+$/, ""),
109
+ });
110
+ }
111
+ return relays;
112
+ }
113
+
50
114
  export class Consumer {
51
- constructor({ network, keyPrivate, maxFee, journalDir, fetchJson = httpJson, now = () => Math.floor(Date.now() / 1000) }) {
115
+ constructor({ network, keyPrivate, maxFee, journalDir, fetchJson = httpJson, streamJson = httpNdjson, now = () => Math.floor(Date.now() / 1000) }) {
116
+ this.streamJson = streamJson;
52
117
  this.journalDir = journalDir;
53
118
  this.network = network;
54
119
  this.keyPrivate = keyPrivate;
@@ -58,6 +123,29 @@ export class Consumer {
58
123
  this.now = now;
59
124
  this.deployment = { chainId: network.chain_id, settlement: network.settlement.toLowerCase() };
60
125
  this.cache = new Map();
126
+ this.relayCache = null;
127
+ }
128
+
129
+ /** Manifest Relays first, then directory Relays whose /health proves the announced signer. */
130
+ async relays() {
131
+ if (this.relayCache && Date.now() - this.relayCache.at < RELAY_CACHE_MS) return this.relayCache.relays;
132
+ const relays = [...this.network.relays];
133
+ const known = new Set(relays.map((relay) => relay.signer.toLowerCase()));
134
+ try {
135
+ for (const relay of await directoryRelays(this.network)) {
136
+ if (known.has(relay.signer)) continue;
137
+ try {
138
+ const { status, body } = await this.fetchJson(`${relay.url}/health`, { ca: this.network.tls_ca, timeoutMs: 5_000 });
139
+ if (status === 200 && String(body.relay_signer).toLowerCase() === relay.signer
140
+ && String(body.settlement).toLowerCase() === this.deployment.settlement) {
141
+ relays.push(relay);
142
+ known.add(relay.signer);
143
+ }
144
+ } catch {}
145
+ }
146
+ } catch {}
147
+ this.relayCache = { at: Date.now(), relays };
148
+ return relays;
61
149
  }
62
150
 
63
151
  async providers(relay) {
@@ -75,16 +163,16 @@ export class Consumer {
75
163
 
76
164
  async models() {
77
165
  const names = new Set();
78
- for (const relay of this.network.relays) {
166
+ for (const relay of await this.relays()) {
79
167
  try { for (const provider of await this.providers(relay)) for (const model of provider.models || []) names.add(model); } catch {}
80
168
  }
81
169
  return [...names].sort();
82
170
  }
83
171
 
84
172
  /** Try Relays in order; fail over only while the request provably was not dispatched. */
85
- async request({ endpoint, model, content, maxOutputTokens = 4096, options = {}, provider }) {
173
+ async request({ endpoint, model, content, maxOutputTokens = 4096, options = {}, provider, onDelta }) {
86
174
  let lastError;
87
- for (const relay of this.network.relays) {
175
+ for (const relay of await this.relays()) {
88
176
  let candidates;
89
177
  try {
90
178
  candidates = (await this.providers(relay)).filter((descriptor) => (descriptor.models || []).includes(model)
@@ -98,14 +186,33 @@ export class Consumer {
98
186
  endpoint, model, content, maxOutputTokens, maxFee: this.maxFee, options, now: this.now(),
99
187
  });
100
188
  let reply;
189
+ const streamed = [];
190
+ let final = null;
101
191
  try {
102
- reply = await this.fetchJson(`${relay.url}/v11/requests`, { method: "POST", body: prepared.payload, ca: this.network.tls_ca });
192
+ reply = onDelta
193
+ ? await this.streamJson(`${relay.url}/v11/requests`, { body: prepared.payload, ca: this.network.tls_ca, onLine: (line) => {
194
+ if (line.type === "delta") {
195
+ streamed.push(openDelta(prepared, line.sealed, streamed.length, this.now()));
196
+ onDelta(streamed.at(-1));
197
+ } else {
198
+ final = line;
199
+ }
200
+ } })
201
+ : await this.fetchJson(`${relay.url}/v11/requests`, { method: "POST", body: prepared.payload, ca: this.network.tls_ca });
103
202
  } catch (error) {
104
203
  // The request may have reached the Relay: never replay it elsewhere.
105
204
  throw Object.assign(new Error(`request outcome unknown: ${error.message}`), { code: "outcome_unknown", requestId: prepared.authorization.request_id });
106
205
  }
206
+ if (reply.streamed) {
207
+ if (!final) throw Object.assign(new Error("stream ended without a result"), { code: "outcome_unknown" });
208
+ if (final.type === "error") throw Object.assign(new Error(final.error), { status: final.status, dispatched: final.dispatched });
209
+ reply = { status: 200, body: final };
210
+ }
107
211
  if (reply.status === 200) {
108
212
  const opened = openResponse(prepared, reply.body, this.deployment, this.now());
213
+ if (streamed.length && streamed.join("") !== outputText(opened.response.output)) {
214
+ throw new Error("streamed text differs from the receipted response");
215
+ }
109
216
  // Only this machine can ever reveal the plaintexts, so keep them for the dispute window.
110
217
  if (this.journalDir) recordRequest(this.journalDir, prepared, opened, relay.url);
111
218
  return opened;
@@ -136,6 +243,7 @@ async function readJson(req) {
136
243
  /** Local OpenAI-compatible endpoint; nothing leaves this machine unsealed except pricing and routing. */
137
244
  export function serveConsumer(consumer, { host = "127.0.0.1", port = 8110, apiKey } = {}) {
138
245
  const server = createServer(async (req, res) => {
246
+ let writer = null;
139
247
  const send = (status, body, type = "application/json") => {
140
248
  res.writeHead(status, { "content-type": type, "cache-control": "no-store" });
141
249
  res.end(type === "application/json" ? JSON.stringify(body) : body);
@@ -160,11 +268,22 @@ export function serveConsumer(consumer, { host = "127.0.0.1", port = 8110, apiKe
160
268
  delete rest.max_output_tokens;
161
269
  delete rest.max_tokens;
162
270
  delete rest.max_completion_tokens;
163
- const { response } = await consumer.request({ endpoint: chat ? "chat" : "responses", model, content, maxOutputTokens, options: rest });
164
- const output = response.output;
165
- if (stream) return send(200, chat ? chatSse(output, { includeUsage: streamOptions?.include_usage === true }) : responseSse(output), "text/event-stream");
166
- return send(200, output);
271
+ if (!stream) {
272
+ const { response } = await consumer.request({ endpoint: chat ? "chat" : "responses", model, content, maxOutputTokens, options: rest });
273
+ return send(200, response.output);
274
+ }
275
+ // Headers go out with the first delta, so a request that fails before dispatch still gets an HTTP error.
276
+ const write = (text) => {
277
+ if (!res.headersSent) res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
278
+ res.write(text);
279
+ };
280
+ writer = chat ? chatStream(write, { model }, { includeUsage: streamOptions?.include_usage === true }) : responseStream(write, { model });
281
+ const { response } = await consumer.request({ endpoint: chat ? "chat" : "responses", model, content, maxOutputTokens,
282
+ options: rest, onDelta: (text) => writer.delta(text) });
283
+ writer.finish(response.output);
284
+ return res.end();
167
285
  } catch (error) {
286
+ if (res.headersSent) { writer?.fail(error.message); return res.end(); }
168
287
  const status = error.code === "outcome_unknown" ? 504 : (error.status && error.status < 500 ? error.status : 502);
169
288
  return send(status, openaiError(error.message, error.code || "request_failed"));
170
289
  }
package/src/protocol.mjs CHANGED
@@ -12,6 +12,7 @@ export const REQUEST_SCHEMA = "mycomesh.v11.request.v1";
12
12
  export const RESPONSE_SCHEMA = "mycomesh.v11.response.v1";
13
13
  export const SEALED_REQUEST_PURPOSE = "mycomesh.v11.sealed-request";
14
14
  export const SEALED_RESPONSE_PURPOSE = "mycomesh.v11.sealed-response";
15
+ export const SEALED_DELTA_PURPOSE = "mycomesh.v11.sealed-delta";
15
16
 
16
17
  export const sha256Hex = (bytes) => `0x${createHash("sha256").update(bytes).digest("hex")}`;
17
18
 
@@ -70,6 +71,28 @@ export function prepareRequest({ descriptor, deployment, keyPrivate, relaySigner
70
71
  };
71
72
  }
72
73
 
74
+ /** The assistant text of a Responses, Chat Completions or Anthropic Messages payload. */
75
+ export function outputText(output) {
76
+ if (output && typeof output === "object") {
77
+ if (typeof output.output_text === "string") return output.output_text;
78
+ if (Array.isArray(output.choices) && output.choices.length) return String(output.choices[0].message?.content ?? "");
79
+ if (Array.isArray(output.content)) return output.content.map((part) => part?.text ?? "").join("");
80
+ return (output.output || []).flatMap((item) => item?.content || []).map((part) => part?.text ?? "").join("");
81
+ }
82
+ return output == null ? "" : String(output);
83
+ }
84
+
85
+ /** Decrypt one streamed delta; a Relay can drop or stall deltas but never reorder or forge them. */
86
+ export function openDelta(prepared, sealed, expectedSeq, now = Math.floor(Date.now() / 1000)) {
87
+ const opened = openFrame(Buffer.from(sealed, "base64"), {
88
+ recipientKey: prepared.replyKey, expectedPurpose: SEALED_DELTA_PURPOSE, replaySet: new Set(),
89
+ expectedSenderPeerId: prepared.providerPeerId, now,
90
+ });
91
+ const value = JSON.parse(opened.payload.toString("utf8"));
92
+ if (value.seq !== expectedSeq || typeof value.delta !== "string") throw new Error("streamed delta is out of order");
93
+ return value.delta;
94
+ }
95
+
73
96
  /** Decrypt the response and prove it is exactly what the Provider signed for. */
74
97
  export function openResponse(prepared, result, deployment, now = Math.floor(Date.now() / 1000)) {
75
98
  const signed = result.receipt;
package/src/sse.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // Buffered SSE for OpenAI clients (Codex needs the Responses event sequence).
1
+ // SSE for OpenAI clients (Codex needs the Responses event sequence): buffered, or incremental from sealed deltas.
2
2
  // Port of the event order in the former gateway/openai_protocol.py.
3
3
  import { randomUUID } from "node:crypto";
4
4
 
@@ -34,6 +34,35 @@ function added(item) {
34
34
  return copy;
35
35
  }
36
36
 
37
+ function emitItem(emit, item, output_index) {
38
+ const item_id = item.id;
39
+ emit("response.output_item.added", { output_index, item: added(item) });
40
+ if (item.type === "message") {
41
+ (item.content || []).forEach((part, content_index) => {
42
+ emit("response.content_part.added", { item_id, output_index, content_index, part: { ...part, text: part.type === "output_text" ? "" : part.text } });
43
+ if (part.type === "output_text") {
44
+ if (part.text) emit("response.output_text.delta", { item_id, output_index, content_index, delta: part.text, logprobs: [] });
45
+ emit("response.output_text.done", { item_id, output_index, content_index, text: part.text || "", logprobs: [] });
46
+ }
47
+ emit("response.content_part.done", { item_id, output_index, content_index, part });
48
+ });
49
+ } else if (item.type === "reasoning") {
50
+ (item.summary || []).forEach((part, summary_index) => {
51
+ emit("response.reasoning_summary_part.added", { item_id, output_index, summary_index, part: { ...part, type: "summary_text", text: "" } });
52
+ if (part.text) emit("response.reasoning_summary_text.delta", { item_id, output_index, summary_index, delta: part.text });
53
+ emit("response.reasoning_summary_text.done", { item_id, output_index, summary_index, text: part.text || "" });
54
+ emit("response.reasoning_summary_part.done", { item_id, output_index, summary_index, part });
55
+ });
56
+ } else if (item.type === "function_call") {
57
+ if (item.arguments) emit("response.function_call_arguments.delta", { item_id, output_index, delta: item.arguments });
58
+ emit("response.function_call_arguments.done", { item_id, output_index, call_id: item.call_id || "", name: item.name || "", arguments: item.arguments || "" });
59
+ } else if (item.type === "custom_tool_call") {
60
+ if (item.input) emit("response.custom_tool_call_input.delta", { item_id, output_index, delta: item.input });
61
+ emit("response.custom_tool_call_input.done", { item_id, output_index, call_id: item.call_id || "", name: item.name || "", input: item.input || "" });
62
+ }
63
+ emit("response.output_item.done", { output_index, item });
64
+ }
65
+
37
66
  export function responseSse(payload) {
38
67
  const final = normalize(payload);
39
68
  const out = [];
@@ -42,38 +71,66 @@ export function responseSse(payload) {
42
71
  const created = { ...structuredClone(final), status: "in_progress", output: [], output_text: "", usage: null };
43
72
  emit("response.created", { response: created });
44
73
  emit("response.in_progress", { response: created });
45
- final.output.forEach((item, output_index) => {
46
- const item_id = item.id;
47
- emit("response.output_item.added", { output_index, item: added(item) });
48
- if (item.type === "message") {
49
- (item.content || []).forEach((part, content_index) => {
50
- emit("response.content_part.added", { item_id, output_index, content_index, part: { ...part, text: part.type === "output_text" ? "" : part.text } });
51
- if (part.type === "output_text") {
52
- if (part.text) emit("response.output_text.delta", { item_id, output_index, content_index, delta: part.text, logprobs: [] });
53
- emit("response.output_text.done", { item_id, output_index, content_index, text: part.text || "", logprobs: [] });
54
- }
55
- emit("response.content_part.done", { item_id, output_index, content_index, part });
56
- });
57
- } else if (item.type === "reasoning") {
58
- (item.summary || []).forEach((part, summary_index) => {
59
- emit("response.reasoning_summary_part.added", { item_id, output_index, summary_index, part: { ...part, type: "summary_text", text: "" } });
60
- if (part.text) emit("response.reasoning_summary_text.delta", { item_id, output_index, summary_index, delta: part.text });
61
- emit("response.reasoning_summary_text.done", { item_id, output_index, summary_index, text: part.text || "" });
62
- emit("response.reasoning_summary_part.done", { item_id, output_index, summary_index, part });
63
- });
64
- } else if (item.type === "function_call") {
65
- if (item.arguments) emit("response.function_call_arguments.delta", { item_id, output_index, delta: item.arguments });
66
- emit("response.function_call_arguments.done", { item_id, output_index, call_id: item.call_id || "", name: item.name || "", arguments: item.arguments || "" });
67
- } else if (item.type === "custom_tool_call") {
68
- if (item.input) emit("response.custom_tool_call_input.delta", { item_id, output_index, delta: item.input });
69
- emit("response.custom_tool_call_input.done", { item_id, output_index, call_id: item.call_id || "", name: item.name || "", input: item.input || "" });
70
- }
71
- emit("response.output_item.done", { output_index, item });
72
- });
74
+ final.output.forEach((item, output_index) => emitItem(emit, item, output_index));
73
75
  emit(TERMINAL[final.status], { response: final });
74
76
  return out.join("");
75
77
  }
76
78
 
79
+ /**
80
+ * Incremental Responses events: the assistant message streams first as text
81
+ * deltas; when the verified response arrives its other items follow and the
82
+ * terminal event carries the full response (message first, with the streamed id).
83
+ */
84
+ export function responseStream(write, { model }) {
85
+ let sequence = 0;
86
+ const emit = (type, fields) => write(sse({ type, sequence_number: sequence++, ...fields }));
87
+ const id = `resp_${randomUUID().replace(/-/g, "")}`;
88
+ const item_id = `msg_${randomUUID().replace(/-/g, "")}`;
89
+ const created = { id, object: "response", created_at: Math.floor(Date.now() / 1000), model, status: "in_progress",
90
+ output: [], output_text: "", usage: null, error: null, incomplete_details: null };
91
+ let started = false;
92
+ let streamed = "";
93
+ const start = () => {
94
+ if (started) return;
95
+ started = true;
96
+ emit("response.created", { response: created });
97
+ emit("response.in_progress", { response: created });
98
+ emit("response.output_item.added", { output_index: 0, item: { type: "message", id: item_id, role: "assistant", status: "in_progress", content: [] } });
99
+ emit("response.content_part.added", { item_id, output_index: 0, content_index: 0, part: { type: "output_text", text: "", annotations: [] } });
100
+ };
101
+ return {
102
+ delta(text) {
103
+ start();
104
+ streamed += text;
105
+ emit("response.output_text.delta", { item_id, output_index: 0, content_index: 0, delta: text, logprobs: [] });
106
+ },
107
+ finish(payload) {
108
+ start();
109
+ const final = normalize(payload);
110
+ final.id = id;
111
+ const index = final.output.findIndex((item) => item.type === "message");
112
+ const message = index >= 0 ? final.output.splice(index, 1)[0]
113
+ : { type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: final.output_text, annotations: [] }] };
114
+ message.id = item_id;
115
+ final.output.unshift(message);
116
+ const text = (message.content || []).filter((part) => part.type === "output_text").map((part) => part.text || "").join("");
117
+ if (text.startsWith(streamed) && text.length > streamed.length) {
118
+ emit("response.output_text.delta", { item_id, output_index: 0, content_index: 0, delta: text.slice(streamed.length), logprobs: [] });
119
+ }
120
+ const part = { type: "output_text", text, annotations: [] };
121
+ emit("response.output_text.done", { item_id, output_index: 0, content_index: 0, text, logprobs: [] });
122
+ emit("response.content_part.done", { item_id, output_index: 0, content_index: 0, part });
123
+ emit("response.output_item.done", { output_index: 0, item: message });
124
+ final.output.slice(1).forEach((item, offset) => emitItem(emit, item, offset + 1));
125
+ emit(TERMINAL[final.status], { response: final });
126
+ },
127
+ fail(message) {
128
+ start();
129
+ emit("response.failed", { response: { ...created, status: "failed", error: { code: "mycomesh_error", message } } });
130
+ },
131
+ };
132
+ }
133
+
77
134
  export function chatSse(payload, { includeUsage = false } = {}) {
78
135
  const id = payload.id || `chatcmpl_${randomUUID().replace(/-/g, "")}`;
79
136
  const model = payload.model || "";
@@ -99,3 +156,33 @@ export function chatSse(payload, { includeUsage = false } = {}) {
99
156
  out.push("data: [DONE]\n\n");
100
157
  return out.join("");
101
158
  }
159
+
160
+ /** Incremental Chat Completions chunks from sealed deltas. */
161
+ export function chatStream(write, { model }, { includeUsage = false } = {}) {
162
+ const id = `chatcmpl_${randomUUID().replace(/-/g, "")}`;
163
+ const created = Math.floor(Date.now() / 1000);
164
+ const chunk = (delta, finish_reason = null) =>
165
+ write(`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta, finish_reason }] })}\n\n`);
166
+ let started = false;
167
+ let streamed = "";
168
+ const start = () => { if (!started) { started = true; chunk({ role: "assistant" }); } };
169
+ return {
170
+ delta(text) { start(); streamed += text; chunk({ content: text }); },
171
+ finish(payload) {
172
+ start();
173
+ const choice = (payload?.choices || [])[0] || {};
174
+ const text = typeof choice.message?.content === "string" ? choice.message.content : "";
175
+ if (text.startsWith(streamed) && text.length > streamed.length) chunk({ content: text.slice(streamed.length) });
176
+ chunk({}, choice.finish_reason || "stop");
177
+ if (includeUsage && payload?.usage) {
178
+ write(`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, choices: [], usage: payload.usage })}\n\n`);
179
+ }
180
+ write("data: [DONE]\n\n");
181
+ },
182
+ fail(message) {
183
+ start();
184
+ write(`data: ${JSON.stringify({ error: { message, type: "mycomesh_error" } })}\n\n`);
185
+ write("data: [DONE]\n\n");
186
+ },
187
+ };
188
+ }
package/src/wallet.mjs ADDED
@@ -0,0 +1,106 @@
1
+ // The Consumer's owner wallet: an Ethereum V3 keystore (the format MetaMask and geth import),
2
+ // so the key that holds the deposit never sits in a plaintext file.
3
+ import { createCipheriv, createDecipheriv, randomBytes, scryptSync, pbkdf2Sync, randomUUID } from "node:crypto";
4
+ import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { addressOf, keccak } from "./eip712.mjs";
7
+
8
+ const SCRYPT = { n: 1 << 15, r: 8, p: 1, dklen: 32 };
9
+
10
+ export function encryptKeystore(privateKey, password) {
11
+ const salt = randomBytes(32);
12
+ const iv = randomBytes(16);
13
+ const derived = scryptSync(Buffer.from(password, "utf8"), salt, SCRYPT.dklen,
14
+ { N: SCRYPT.n, r: SCRYPT.r, p: SCRYPT.p, maxmem: 128 * SCRYPT.n * SCRYPT.r * 2 });
15
+ const cipher = createCipheriv("aes-128-ctr", derived.subarray(0, 16), iv);
16
+ const ciphertext = Buffer.concat([cipher.update(Buffer.from(privateKey.replace(/^0x/, ""), "hex")), cipher.final()]);
17
+ return {
18
+ version: 3, id: randomUUID(), address: addressOf(privateKey).slice(2),
19
+ crypto: {
20
+ cipher: "aes-128-ctr", cipherparams: { iv: iv.toString("hex") }, ciphertext: ciphertext.toString("hex"),
21
+ kdf: "scrypt", kdfparams: { ...SCRYPT, salt: salt.toString("hex") },
22
+ mac: keccak(Buffer.concat([derived.subarray(16, 32), ciphertext])).toString("hex"),
23
+ },
24
+ };
25
+ }
26
+
27
+ export function decryptKeystore(keystore, password) {
28
+ const crypto = keystore.crypto || keystore.Crypto;
29
+ if (keystore.version !== 3 || crypto?.cipher !== "aes-128-ctr") throw new Error("unsupported keystore");
30
+ const params = crypto.kdfparams;
31
+ const salt = Buffer.from(params.salt, "hex");
32
+ const secret = Buffer.from(password, "utf8");
33
+ let derived;
34
+ if (crypto.kdf === "scrypt") {
35
+ derived = scryptSync(secret, salt, params.dklen, { N: params.n, r: params.r, p: params.p, maxmem: 128 * params.n * params.r * 2 });
36
+ } else if (crypto.kdf === "pbkdf2" && params.prf === "hmac-sha256") {
37
+ derived = pbkdf2Sync(secret, salt, params.c, params.dklen, "sha256");
38
+ } else {
39
+ throw new Error(`unsupported keystore KDF ${crypto.kdf}`);
40
+ }
41
+ const ciphertext = Buffer.from(crypto.ciphertext, "hex");
42
+ const mac = keccak(Buffer.concat([derived.subarray(16, 32), ciphertext])).toString("hex");
43
+ if (mac !== crypto.mac.toLowerCase()) throw new Error("wrong wallet password");
44
+ const decipher = createDecipheriv("aes-128-ctr", derived.subarray(0, 16), Buffer.from(crypto.cipherparams.iv, "hex"));
45
+ const privateKey = `0x${Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("hex")}`;
46
+ if (keystore.address && addressOf(privateKey).slice(2) !== keystore.address.toLowerCase().replace(/^0x/, "")) {
47
+ throw new Error("keystore address does not match its key");
48
+ }
49
+ return privateKey;
50
+ }
51
+
52
+ /** Password from MYCOMESH_WALLET_PASSWORD, else a muted terminal prompt. */
53
+ export async function password(prompt, { confirm = false } = {}) {
54
+ if (process.env.MYCOMESH_WALLET_PASSWORD) return process.env.MYCOMESH_WALLET_PASSWORD;
55
+ if (!process.stdin.isTTY) throw new Error("set MYCOMESH_WALLET_PASSWORD or run in a terminal");
56
+ const ask = (label) => new Promise((resolve, reject) => {
57
+ process.stderr.write(label);
58
+ const input = [];
59
+ process.stdin.setRawMode(true);
60
+ process.stdin.resume();
61
+ const onData = (chunk) => {
62
+ for (const char of chunk.toString("utf8")) {
63
+ if (char === "\r" || char === "\n") { done(); return resolve(input.join("")); }
64
+ if (char === "\u0003") { done(); return reject(new Error("cancelled")); }
65
+ if (char === "\u007f") input.pop(); else input.push(char);
66
+ }
67
+ };
68
+ const done = () => { process.stdin.setRawMode(false); process.stdin.pause(); process.stdin.off("data", onData); process.stderr.write("\n"); };
69
+ process.stdin.on("data", onData);
70
+ });
71
+ const first = await ask(prompt);
72
+ if (first.length < 8) throw new Error("use a wallet password of at least 8 characters");
73
+ if (confirm && (await ask("Repeat the password: ")) !== first) throw new Error("passwords differ");
74
+ return first;
75
+ }
76
+
77
+ export const walletPath = (dir) => join(dir, "owner-wallet.json");
78
+
79
+ export async function createWallet(dir) {
80
+ const path = walletPath(dir);
81
+ if (existsSync(path)) return JSON.parse(readFileSync(path, "utf8"));
82
+ const keystore = encryptKeystore(`0x${randomBytes(32).toString("hex")}`,
83
+ await password("New wallet password (protects your deposit): ", { confirm: true }));
84
+ writeFileSync(path, `${JSON.stringify(keystore, null, 2)}\n`, { mode: 0o600 });
85
+ chmodSync(path, 0o600);
86
+ return keystore;
87
+ }
88
+
89
+ export const walletAddress = (keystore) => `0x${keystore.address.toLowerCase().replace(/^0x/, "")}`;
90
+
91
+ /** The owner key: --owner-key-file (raw hex or a keystore), else this machine's wallet. */
92
+ export async function ownerKey(file, dir) {
93
+ const path = file || walletPath(dir);
94
+ if (!existsSync(path)) throw new Error("no owner wallet; run `mycomesh-consumer init` or pass --owner-key-file");
95
+ const text = readFileSync(path, "utf8").trim();
96
+ if (/^(0x)?[0-9a-fA-F]{64}$/.test(text)) return text.startsWith("0x") ? text : `0x${text}`;
97
+ return decryptKeystore(JSON.parse(text), await password("Wallet password: "));
98
+ }
99
+
100
+ export function ownerAddress(file, dir) {
101
+ const path = file || walletPath(dir);
102
+ if (!existsSync(path)) return null;
103
+ const text = readFileSync(path, "utf8").trim();
104
+ if (/^(0x)?[0-9a-fA-F]{64}$/.test(text)) return addressOf(text.startsWith("0x") ? text : `0x${text}`);
105
+ return walletAddress(JSON.parse(text));
106
+ }