mycomesh-consumer 0.2.1 → 0.4.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/src/consumer.mjs CHANGED
@@ -4,12 +4,15 @@ 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";
12
+ import { consoleRoutes, localRequestAllowed } from "./console.mjs";
11
13
 
12
14
  const PROVIDER_CACHE_MS = 30_000;
15
+ const RELAY_CACHE_MS = 300_000;
13
16
  const MAX_BODY = 16 * 1024 * 1024;
14
17
 
15
18
  export function loadNetwork(path) {
@@ -47,8 +50,71 @@ export function httpJson(url, { method = "GET", body, ca, timeoutMs = 330_000 }
47
50
  });
48
51
  }
49
52
 
53
+ /** POST expecting NDJSON; each line goes to onLine. Non-streamed replies resolve as JSON. */
54
+ export function httpNdjson(url, { body, ca, timeoutMs = 340_000, onLine }) {
55
+ const target = new URL(url);
56
+ const data = Buffer.from(JSON.stringify(body));
57
+ const send = target.protocol === "https:" ? httpsRequest : httpRequest;
58
+ return new Promise((resolvePromise, reject) => {
59
+ const req = send(target, {
60
+ method: "POST",
61
+ headers: { accept: "application/x-ndjson", "content-type": "application/json", "content-length": data.length },
62
+ ...(target.protocol === "https:" && ca ? { ca: [...rootCertificates, ca] } : {}),
63
+ }, (res) => {
64
+ const streaming = res.statusCode === 200 && String(res.headers["content-type"] || "").includes("ndjson");
65
+ let buffered = "";
66
+ const chunks = [];
67
+ res.setEncoding("utf8");
68
+ res.on("data", (chunk) => {
69
+ if (!streaming) { chunks.push(chunk); return; }
70
+ buffered += chunk;
71
+ let index;
72
+ while ((index = buffered.indexOf("\n")) >= 0) {
73
+ const line = buffered.slice(0, index);
74
+ buffered = buffered.slice(index + 1);
75
+ if (line.trim()) {
76
+ try { onLine(JSON.parse(line)); } catch (error) { req.destroy(error); return; }
77
+ }
78
+ }
79
+ });
80
+ res.on("end", () => {
81
+ if (streaming) return resolvePromise({ status: 200, streamed: true });
82
+ try { resolvePromise({ status: res.statusCode, body: JSON.parse(chunks.join("") || "{}") }); } catch (error) { reject(error); }
83
+ });
84
+ });
85
+ req.setTimeout(timeoutMs, () => req.destroy(new Error("request timed out")));
86
+ req.on("error", reject);
87
+ req.end(data);
88
+ });
89
+ }
90
+
91
+ function decodeString(raw, offset) {
92
+ const length = Number(BigInt(`0x${raw.slice(offset * 2, offset * 2 + 64)}`));
93
+ return Buffer.from(raw.slice(offset * 2 + 64, offset * 2 + 64 + length * 2), "hex").toString("utf8");
94
+ }
95
+
96
+ /** Active Relays announced in the on-chain RelayDirectoryV11. */
97
+ export async function directoryRelays(network) {
98
+ if (!network.relay_directory) return [];
99
+ const count = Number(BigInt(await rpcCall(network.rpc_urls, "eth_call", [{ to: network.relay_directory, data: encodeCall("relayCount()", []) }, "latest"])));
100
+ const relays = [];
101
+ for (let index = 0; index < count; index += 1) {
102
+ const raw = (await rpcCall(network.rpc_urls, "eth_call", [{ to: network.relay_directory,
103
+ data: encodeCall("relayAt(uint256)", [["uint", BigInt(index)]]) }, "latest"])).slice(2);
104
+ const base = Number(BigInt(`0x${raw.slice(0, 64)}`));
105
+ if (!Number(BigInt(`0x${raw.slice(64, 128)}`))) continue;
106
+ const entry = raw.slice(base * 2);
107
+ relays.push({
108
+ signer: `0x${entry.slice(64 + 24, 128)}`.toLowerCase(),
109
+ url: decodeString(entry, Number(BigInt(`0x${entry.slice(128, 192)}`))).replace(/\/+$/, ""),
110
+ });
111
+ }
112
+ return relays;
113
+ }
114
+
50
115
  export class Consumer {
51
- constructor({ network, keyPrivate, maxFee, journalDir, fetchJson = httpJson, now = () => Math.floor(Date.now() / 1000) }) {
116
+ constructor({ network, keyPrivate, maxFee, journalDir, fetchJson = httpJson, streamJson = httpNdjson, now = () => Math.floor(Date.now() / 1000) }) {
117
+ this.streamJson = streamJson;
52
118
  this.journalDir = journalDir;
53
119
  this.network = network;
54
120
  this.keyPrivate = keyPrivate;
@@ -58,6 +124,29 @@ export class Consumer {
58
124
  this.now = now;
59
125
  this.deployment = { chainId: network.chain_id, settlement: network.settlement.toLowerCase() };
60
126
  this.cache = new Map();
127
+ this.relayCache = null;
128
+ }
129
+
130
+ /** Manifest Relays first, then directory Relays whose /health proves the announced signer. */
131
+ async relays() {
132
+ if (this.relayCache && Date.now() - this.relayCache.at < RELAY_CACHE_MS) return this.relayCache.relays;
133
+ const relays = [...this.network.relays];
134
+ const known = new Set(relays.map((relay) => relay.signer.toLowerCase()));
135
+ try {
136
+ for (const relay of await directoryRelays(this.network)) {
137
+ if (known.has(relay.signer)) continue;
138
+ try {
139
+ const { status, body } = await this.fetchJson(`${relay.url}/health`, { ca: this.network.tls_ca, timeoutMs: 5_000 });
140
+ if (status === 200 && String(body.relay_signer).toLowerCase() === relay.signer
141
+ && String(body.settlement).toLowerCase() === this.deployment.settlement) {
142
+ relays.push(relay);
143
+ known.add(relay.signer);
144
+ }
145
+ } catch {}
146
+ }
147
+ } catch {}
148
+ this.relayCache = { at: Date.now(), relays };
149
+ return relays;
61
150
  }
62
151
 
63
152
  async providers(relay) {
@@ -75,16 +164,16 @@ export class Consumer {
75
164
 
76
165
  async models() {
77
166
  const names = new Set();
78
- for (const relay of this.network.relays) {
167
+ for (const relay of await this.relays()) {
79
168
  try { for (const provider of await this.providers(relay)) for (const model of provider.models || []) names.add(model); } catch {}
80
169
  }
81
170
  return [...names].sort();
82
171
  }
83
172
 
84
173
  /** Try Relays in order; fail over only while the request provably was not dispatched. */
85
- async request({ endpoint, model, content, maxOutputTokens = 4096, options = {}, provider }) {
174
+ async request({ endpoint, model, content, maxOutputTokens = 4096, options = {}, provider, onDelta }) {
86
175
  let lastError;
87
- for (const relay of this.network.relays) {
176
+ for (const relay of await this.relays()) {
88
177
  let candidates;
89
178
  try {
90
179
  candidates = (await this.providers(relay)).filter((descriptor) => (descriptor.models || []).includes(model)
@@ -98,14 +187,33 @@ export class Consumer {
98
187
  endpoint, model, content, maxOutputTokens, maxFee: this.maxFee, options, now: this.now(),
99
188
  });
100
189
  let reply;
190
+ const streamed = [];
191
+ let final = null;
101
192
  try {
102
- reply = await this.fetchJson(`${relay.url}/v11/requests`, { method: "POST", body: prepared.payload, ca: this.network.tls_ca });
193
+ reply = onDelta
194
+ ? await this.streamJson(`${relay.url}/v11/requests`, { body: prepared.payload, ca: this.network.tls_ca, onLine: (line) => {
195
+ if (line.type === "delta") {
196
+ streamed.push(openDelta(prepared, line.sealed, streamed.length, this.now()));
197
+ onDelta(streamed.at(-1));
198
+ } else {
199
+ final = line;
200
+ }
201
+ } })
202
+ : await this.fetchJson(`${relay.url}/v11/requests`, { method: "POST", body: prepared.payload, ca: this.network.tls_ca });
103
203
  } catch (error) {
104
204
  // The request may have reached the Relay: never replay it elsewhere.
105
205
  throw Object.assign(new Error(`request outcome unknown: ${error.message}`), { code: "outcome_unknown", requestId: prepared.authorization.request_id });
106
206
  }
207
+ if (reply.streamed) {
208
+ if (!final) throw Object.assign(new Error("stream ended without a result"), { code: "outcome_unknown" });
209
+ if (final.type === "error") throw Object.assign(new Error(final.error), { status: final.status, dispatched: final.dispatched });
210
+ reply = { status: 200, body: final };
211
+ }
107
212
  if (reply.status === 200) {
108
213
  const opened = openResponse(prepared, reply.body, this.deployment, this.now());
214
+ if (streamed.length && streamed.join("") !== outputText(opened.response.output)) {
215
+ throw new Error("streamed text differs from the receipted response");
216
+ }
109
217
  // Only this machine can ever reveal the plaintexts, so keep them for the dispute window.
110
218
  if (this.journalDir) recordRequest(this.journalDir, prepared, opened, relay.url);
111
219
  return opened;
@@ -134,15 +242,26 @@ async function readJson(req) {
134
242
  }
135
243
 
136
244
  /** Local OpenAI-compatible endpoint; nothing leaves this machine unsealed except pricing and routing. */
137
- export function serveConsumer(consumer, { host = "127.0.0.1", port = 8110, apiKey } = {}) {
245
+ export function serveConsumer(consumer, { host = "127.0.0.1", port = 8110, apiKey, dataDir } = {}) {
246
+ const routes = dataDir ? consoleRoutes({ consumer, dataDir }) : {};
138
247
  const server = createServer(async (req, res) => {
248
+ let writer = null;
139
249
  const send = (status, body, type = "application/json") => {
140
250
  res.writeHead(status, { "content-type": type, "cache-control": "no-store" });
141
251
  res.end(type === "application/json" ? JSON.stringify(body) : body);
142
252
  };
143
253
  try {
144
- if (apiKey && req.headers.authorization !== `Bearer ${apiKey}`) return send(401, openaiError("invalid API key", "unauthorized"));
254
+ if (!localRequestAllowed(req, server.address().port)) return send(403, openaiError("only this machine may use the local node", "forbidden"));
145
255
  const path = new URL(req.url, "http://localhost").pathname.replace(/^\/v1\/v1\//, "/v1/");
256
+ const route = routes[`${req.method} ${path}`];
257
+ if (route) {
258
+ if (req.method === "POST" && !String(req.headers["content-type"] || "").startsWith("application/json")) {
259
+ return send(415, { error: "JSON body required" });
260
+ }
261
+ const result = await route(req.method === "POST" ? await readJson(req) : {});
262
+ return result?.type ? send(200, result.body, result.type) : send(200, result);
263
+ }
264
+ if (apiKey && req.headers.authorization !== `Bearer ${apiKey}`) return send(401, openaiError("invalid API key", "unauthorized"));
146
265
  if (req.method === "GET" && path === "/health") return send(200, { ok: true, protocol: 11, key: consumer.key });
147
266
  if (req.method === "GET" && path === "/v1/models") {
148
267
  return send(200, { object: "list", data: (await consumer.models()).map((id) => ({ id, object: "model", owned_by: "mycomesh" })) });
@@ -160,11 +279,26 @@ export function serveConsumer(consumer, { host = "127.0.0.1", port = 8110, apiKe
160
279
  delete rest.max_output_tokens;
161
280
  delete rest.max_tokens;
162
281
  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);
282
+ if (!stream) {
283
+ const { response } = await consumer.request({ endpoint: chat ? "chat" : "responses", model, content, maxOutputTokens, options: rest });
284
+ return send(200, response.output);
285
+ }
286
+ // Headers go out with the first delta, so a request that fails before dispatch still gets an HTTP error.
287
+ const write = (text) => {
288
+ if (!res.headersSent) res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
289
+ res.write(text);
290
+ };
291
+ writer = chat ? chatStream(write, { model }, { includeUsage: streamOptions?.include_usage === true }) : responseStream(write, { model });
292
+ const { response } = await consumer.request({ endpoint: chat ? "chat" : "responses", model, content, maxOutputTokens,
293
+ options: rest, onDelta: (text) => writer.delta(text) });
294
+ writer.finish(response.output);
295
+ return res.end();
167
296
  } catch (error) {
297
+ if (res.headersSent) { writer?.fail(error.message); return res.end(); }
298
+ if (error.message === "wrong wallet password") return send(401, { error: error.message });
299
+ if (error.status && error.status < 500 && !error.dispatched && routes[`${req.method} ${new URL(req.url, "http://x").pathname}`]) {
300
+ return send(error.status, { error: error.message });
301
+ }
168
302
  const status = error.code === "outcome_unknown" ? 504 : (error.status && error.status < 500 ? error.status : 502);
169
303
  return send(status, openaiError(error.message, error.code || "request_failed"));
170
304
  }
package/src/disputes.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // V11 Consumer disputes: keep each request's plaintexts locally, reveal them as evidence on demand.
2
- import { chmodSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { canonicalJson } from "./secure-envelope.mjs";
5
5
  import { encodeCall, encodeWords, hex, keccak, settlementKey } from "./eip712.mjs";
@@ -52,3 +52,31 @@ export const reportId = (key, reporter, digest) =>
52
52
  hex(keccak(encodeWords([["bytes32", key], ["address", reporter], ["bytes32", digest]])));
53
53
 
54
54
  export const openDisputeCall = (key, digest) => encodeCall("openDispute(bytes32,bytes32)", [["bytes32", key], ["bytes32", digest]]);
55
+
56
+ function preview(document) {
57
+ const content = document.endpoint === "chat" ? (document.messages || []).filter((m) => m.role === "user").at(-1)?.content : document.input;
58
+ const text = typeof content === "string" ? content : Array.isArray(content)
59
+ ? content.map((part) => (typeof part === "string" ? part : part?.text ?? part?.content ?? "")).join(" ") : JSON.stringify(content ?? "");
60
+ return String(text).slice(0, 400);
61
+ }
62
+
63
+ /** Recorded requests, newest first, with short previews (the plaintexts never leave this machine). */
64
+ export function listRequests(dir, outputText, limit = 100) {
65
+ const folder = journal(dir);
66
+ if (!existsSync(folder)) return [];
67
+ return readdirSync(folder).filter((name) => name.endsWith(".json"))
68
+ .map((name) => JSON.parse(readFileSync(join(folder, name), "utf8")))
69
+ .sort((a, b) => b.recorded_at - a.recorded_at).slice(0, limit)
70
+ .map((record) => {
71
+ let request = {}; let response = {};
72
+ try { request = JSON.parse(Buffer.from(record.request, "base64").toString("utf8")); } catch {}
73
+ try { response = JSON.parse(Buffer.from(record.response, "base64").toString("utf8")); } catch {}
74
+ const { authorization, receipt } = record.signed_receipt;
75
+ return {
76
+ settlement_key: record.settlement_key, recorded_at: record.recorded_at, relay_url: record.relay_url,
77
+ model: request.model, endpoint: request.endpoint, prompt: preview(request),
78
+ answer: String(outputText(response.output)).slice(0, 600), fee: String(receipt.actual_fee),
79
+ input_tokens: receipt.input_tokens, output_tokens: receipt.output_tokens, provider: authorization.provider_signer,
80
+ };
81
+ });
82
+ }
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,114 @@
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, secret) {
80
+ const path = walletPath(dir);
81
+ if (existsSync(path)) return JSON.parse(readFileSync(path, "utf8"));
82
+ if (secret !== undefined && String(secret).length < 8) throw new Error("use a wallet password of at least 8 characters");
83
+ const keystore = encryptKeystore(`0x${randomBytes(32).toString("hex")}`,
84
+ secret ?? await password("New wallet password (protects your deposit): ", { confirm: true }));
85
+ writeFileSync(path, `${JSON.stringify(keystore, null, 2)}\n`, { mode: 0o600 });
86
+ chmodSync(path, 0o600);
87
+ return keystore;
88
+ }
89
+
90
+ export const walletAddress = (keystore) => `0x${keystore.address.toLowerCase().replace(/^0x/, "")}`;
91
+
92
+ /** The owner key: --owner-key-file (raw hex or a keystore), else this machine's wallet. */
93
+ export async function ownerKey(file, dir) {
94
+ const path = file || walletPath(dir);
95
+ if (!existsSync(path)) throw new Error("no owner wallet; run `mycomesh-consumer init` or pass --owner-key-file");
96
+ const text = readFileSync(path, "utf8").trim();
97
+ if (/^(0x)?[0-9a-fA-F]{64}$/.test(text)) return text.startsWith("0x") ? text : `0x${text}`;
98
+ return decryptKeystore(JSON.parse(text), await password("Wallet password: "));
99
+ }
100
+
101
+ export function ownerAddress(file, dir) {
102
+ const path = file || walletPath(dir);
103
+ if (!existsSync(path)) return null;
104
+ const text = readFileSync(path, "utf8").trim();
105
+ if (/^(0x)?[0-9a-fA-F]{64}$/.test(text)) return addressOf(text.startsWith("0x") ? text : `0x${text}`);
106
+ return walletAddress(JSON.parse(text));
107
+ }
108
+
109
+ /** Unlock this machine's wallet with a password given by the local console. */
110
+ export function unlockWallet(dir, secret) {
111
+ const path = walletPath(dir);
112
+ if (!existsSync(path)) throw new Error("no owner wallet yet");
113
+ return decryptKeystore(JSON.parse(readFileSync(path, "utf8")), String(secret ?? ""));
114
+ }