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/README.md +19 -13
- package/networks/mycomesh-v11-sepolia.json +2 -0
- package/package.json +2 -1
- package/src/account.mjs +93 -0
- package/src/cli.mjs +75 -53
- package/src/console.mjs +86 -0
- package/src/consumer.mjs +148 -14
- package/src/disputes.mjs +29 -1
- package/src/protocol.mjs +23 -0
- package/src/sse.mjs +116 -29
- package/src/wallet.mjs +114 -0
- package/src/web/console.html +282 -0
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 {
|
|
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 {
|
|
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.
|
|
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.
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
//
|
|
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
|
+
}
|