mycomesh-consumer 0.1.53 → 0.2.1

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
@@ -1,508 +1,173 @@
1
- import { spawn as defaultSpawn } from "node:child_process";
2
- import { existsSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
1
+ // V11 Consumer runtime: one custodied deposit, any Provider, relay-blind requests.
2
+ import { readFileSync } from "node:fs";
3
+ import { createServer, request as httpRequest } from "node:http";
3
4
  import { request as httpsRequest } from "node:https";
5
+ import { dirname, resolve } from "node:path";
4
6
  import { rootCertificates } from "node:tls";
5
- import { join, resolve } from "node:path";
6
- import { fileURLToPath } from "node:url";
7
- import * as readline from "node:readline/promises";
8
- import { CONSUMER_RELEASE_VERSION } from "./release.mjs";
9
- export { CONSUMER_RELEASE_VERSION } from "./release.mjs";
7
+ import { addressOf } from "./eip712.mjs";
8
+ import { openResponse, prepareRequest, verifiedProvider } from "./protocol.mjs";
9
+ import { recordRequest } from "./disputes.mjs";
10
+ import { chatSse, responseSse } from "./sse.mjs";
10
11
 
11
- import {
12
- DEFAULT_BASE_URL,
13
- DEFAULT_MAX_FEE_UNITS,
14
- DEFAULT_RELAY_URL,
15
- NativeConsumerState,
16
- createConsumerServer,
17
- parseNetworkConfig,
18
- paymentKeyAddress,
19
- } from "./consumer-runtime.mjs";
12
+ const PROVIDER_CACHE_MS = 30_000;
13
+ const MAX_BODY = 16 * 1024 * 1024;
20
14
 
21
- // V10 is a controlled committee testnet. Keep its manifest in the package,
22
- // but make selecting it an explicit opt-in so the legacy V8 default remains
23
- // safe for existing installs.
24
- export const V10_CONTROLLED_TEST_NETWORK = fileURLToPath(
25
- new URL("../networks/v10-controlled-test.json", import.meta.url),
26
- );
27
- export const V10_DYNAMIC_PROVIDER_AI_NETWORK = fileURLToPath(
28
- new URL("../networks/v10-dynamic-20260926.json", import.meta.url),
29
- );
30
- export const API_COMMANDS = new Set(["health", "models", "responses", "chat"]);
31
- const API_VALUE_OPTIONS = new Set([
32
- "--base-url",
33
- "--api-key",
34
- "--timeout",
35
- "--session-id",
36
- "--json",
37
- "--model",
38
- "--input",
39
- "--message",
40
- "--system",
41
- "--max-output-tokens",
42
- "--max-tokens",
43
- "--max-completion-tokens",
44
- ]);
45
-
46
- export const CONSUMER_HELP = `Usage: mycomesh-consumer [options] [-- codex-options]
47
-
48
- Start the local MycoMesh Consumer without Docker, Python, or a public Gateway.
49
- The process manages a persisted access key, selects healthy Relays, and
50
- exposes an OpenAI-compatible loopback API.
51
-
52
- Options:
53
- --no-browser Print the local credentials URL without opening it
54
- --codex Start an optional Codex client after Relay readiness
55
- --no-codex Keep only the Consumer API running (default)
56
- --stop Stop a previously started native Consumer
57
- --reset-local Confirm and delete the local Consumer data directory
58
- --codex-command PATH Codex executable for --codex (default: codex on PATH)
59
- --ready-timeout SEC Relay readiness timeout (default: 1800)
60
- --data-dir DIR Payment key and history directory
61
- --relay URLS Comma-separated Relay URLs for automatic failover
62
- --network-config FILE Use a trusted network manifest and its backup Relays
63
- --ca-file FILE Private CA PEM for an explicit controlled-test network
64
- --controlled-test Explicitly opt into a controlled test committee
65
- --v10-controlled-test Select the bundled V10 fixed-budget controlled testnet
66
- --v10-dynamic-provider-ai Select the bundled V10 dynamic Provider-AI testnet
67
- --proxy URL Optional outbound HTTP proxy
68
- --host HOST Listen address (default: 127.0.0.1)
69
- --port PORT Listen port (default: 8110)
70
- --max-fee UNITS Maximum fee per request (default: 100000)
71
- --dry-run Print the native startup plan
72
- --doctor Check local Consumer configuration without starting or paying
73
- --doctor-json Emit the same check as stable JSON for automation
74
- -h, --help Show this help
75
- -v, --version Show the package version
76
-
77
- Each Consumer process starts locked. Connect your browser wallet on the setup
78
- page. For first use, choose Enable API access and confirm the wallet request;
79
- returning users reuse the existing authorization. Copy the API URL and key (or
80
- the combined export) into your client. Never enter a wallet private key.
81
- The page has no browser conversation state.`;
82
-
83
- class ConsumerCliError extends Error {
84
- constructor(message, exitCode = 1) {
85
- super(message);
86
- this.name = "ConsumerCliError";
87
- this.exitCode = exitCode;
88
- }
89
- }
90
-
91
- export async function main(argv, dependencies = {}) {
92
- const env = dependencies.env ?? process.env;
93
- const stdout = dependencies.stdout ?? process.stdout;
94
- const stderr = dependencies.stderr ?? process.stderr;
95
- try {
96
- const parsed = parseArguments(argv, env);
97
- if (parsed.help) {
98
- stdout.write(`${CONSUMER_HELP}\n`);
99
- return 0;
100
- }
101
- if (parsed.version) {
102
- stdout.write(`${CONSUMER_RELEASE_VERSION}\n`);
103
- return 0;
104
- }
105
- if (parsed.doctor || parsed.doctorJson) {
106
- return await consumerDoctor({ parsed, env, stdout, fetch: dependencies.fetch, json: parsed.doctorJson });
107
- }
108
- if (parsed.dryRun) {
109
- stdout.write(`Native Consumer: ${parsed.host}:${parsed.port}\n`);
110
- stdout.write(`Data directory: ${parsed.dataDir}\n`);
111
- stdout.write(`Relays: ${!parsed.relayUrlsExplicit && parsed.networkConfig ? "from selected network manifest (legacy default if absent)" : parsed.relayUrls}\n`);
112
- stdout.write("Docker: disabled\n");
113
- return 0;
114
- }
115
- if (parsed.stop) return stopConsumer(parsed, stdout, stderr);
116
- if (parsed.resetLocal) return await resetConsumer(parsed, env, stdout, stderr);
117
-
118
- const state = dependencies.createState
119
- ? dependencies.createState(parsed, env)
120
- : new NativeConsumerState({
121
- env: { ...env, MYCOMESH_CONSUMER_DATA_DIR: parsed.dataDir },
122
- dataDir: parsed.dataDir,
123
- relayUrls: parsed.relayUrlsExplicit ? parsed.relayUrls : undefined,
124
- networkConfig: parsed.networkConfig,
125
- caFile: parsed.caFile,
126
- allowControlledTest: parsed.allowControlledTest,
127
- proxy: parsed.proxy,
128
- baseUrl: parsed.baseUrl,
129
- maxFeeUnits: parsed.maxFeeUnits,
130
- });
131
- const runtime = dependencies.createServer
132
- ? dependencies.createServer(state, parsed)
133
- : createConsumerServer(state, { host: parsed.host, port: parsed.port });
134
- await runtime.listen();
135
- const rootUrl = `${parsed.scheme}://${parsed.hostForUrl}:${parsed.port}`;
136
- const credentialsUrl = `${rootUrl}/`;
137
- writePid(parsed.dataDir);
138
- stdout.write(`MycoMesh Consumer setup: ${credentialsUrl}\n`);
139
- stdout.write(`OpenAI API: ${state.baseUrl}\n`);
140
- if (!parsed.noBrowser) openBrowser(credentialsUrl, stderr);
141
-
142
- const shutdown = async () => {
143
- clearPid(parsed.dataDir);
144
- await state.stopShare?.();
145
- await runtime.close();
146
- };
147
- if (parsed.noCodex) {
148
- await waitForSignal(shutdown);
149
- return 0;
150
- }
151
- await waitUntilReady(state, parsed.readyTimeout, stdout);
152
- await waitUntilUnlocked(state, parsed.readyTimeout, stdout);
153
- const code = await runCodex(parsed, state, dependencies.spawn ?? defaultSpawn, stdout, stderr);
154
- await shutdown();
155
- return code;
156
- } catch (error) {
157
- const exitCode = error instanceof ConsumerCliError ? error.exitCode : 1;
158
- const message = error?.code === "EADDRINUSE"
159
- ? `port ${error.port || "requested"} is already in use; stop the old Consumer or choose --port with a matching --base-url`
160
- : error instanceof Error ? error.message : String(error);
161
- stderr.write(`mycomesh consumer: ${message}\n`);
162
- return exitCode;
163
- }
164
- }
165
-
166
- export function parseArguments(argv, env = process.env) {
167
- const parsed = {
168
- baseUrl: env.MYCOMESH_CONSUMER_PUBLIC_BASE_URL || DEFAULT_BASE_URL,
169
- baseUrlExplicit: Boolean(env.MYCOMESH_CONSUMER_PUBLIC_BASE_URL),
170
- dataDir: env.MYCOMESH_CONSUMER_DATA_DIR || join(env.HOME || process.cwd(), ".mycomesh", "consumer"),
171
- relayUrls: env.MYCOMESH_V8_RELAY_URLS || env.MYCOMESH_CONSUMER_RELAY_URL || DEFAULT_RELAY_URL,
172
- relayUrlsExplicit: Boolean(env.MYCOMESH_V8_RELAY_URLS || env.MYCOMESH_CONSUMER_RELAY_URL),
173
- networkConfig: env.MYCOMESH_CONSUMER_NETWORK_CONFIG || undefined,
174
- caFile: env.MYCOMESH_CONSUMER_CA_FILE || undefined,
175
- proxy: env.MYCOMESH_CONSUMER_PROXY || "",
176
- codexCommand: env.MYCOMESH_CODEX_COMMAND || "codex",
177
- readyTimeout: parsePositive(env.MYCOMESH_CONSUMER_READY_TIMEOUT_SECONDS || "1800", "ready timeout", 86400),
178
- maxFeeUnits: parsePositive(env.MYCOMESH_V8_MAX_FEE_UNITS || String(DEFAULT_MAX_FEE_UNITS), "max fee"),
179
- host: env.MYCOMESH_CONSUMER_HOST || "127.0.0.1",
180
- hostForUrl: env.MYCOMESH_CONSUMER_HOST || "127.0.0.1",
181
- port: parsePositive(env.MYCOMESH_CONSUMER_PORT || "8110", "port", 65535),
182
- scheme: "http",
183
- allowControlledTest: false,
184
- v10ControlledTest: false,
185
- v10DynamicProviderAi: false,
186
- noBrowser: false,
187
- noCodex: env.MYCOMESH_CONSUMER_START_CODEX !== "1",
188
- stop: false,
189
- resetLocal: false,
190
- dryRun: false,
191
- doctor: false,
192
- doctorJson: false,
193
- help: false,
194
- version: false,
195
- codexArgs: [],
196
- };
197
- for (let index = 0; index < argv.length; index += 1) {
198
- const token = argv[index];
199
- if (token === "--") { parsed.codexArgs = argv.slice(index + 1); break; }
200
- if (token === "-h" || token === "--help") { parsed.help = true; continue; }
201
- if (token === "-v" || token === "--version") { parsed.version = true; continue; }
202
- if (token === "--controlled-test") { parsed.allowControlledTest = true; continue; }
203
- if (token === "--v10-controlled-test") {
204
- parsed.v10ControlledTest = true;
205
- parsed.allowControlledTest = true;
206
- continue;
207
- }
208
- if (token === "--v10-dynamic-provider-ai") {
209
- parsed.v10DynamicProviderAi = true;
210
- parsed.allowControlledTest = true;
211
- continue;
212
- }
213
- if (token === "--no-browser") { parsed.noBrowser = true; continue; }
214
- if (token === "--codex") { parsed.noCodex = false; continue; }
215
- if (token === "--no-codex") { parsed.noCodex = true; continue; }
216
- if (token === "--stop") { parsed.stop = true; continue; }
217
- if (token === "--reset-local") { parsed.resetLocal = true; continue; }
218
- if (token === "--dry-run") { parsed.dryRun = true; continue; }
219
- if (token === "--doctor") { parsed.doctor = true; continue; }
220
- if (token === "--doctor-json") { parsed.doctorJson = true; continue; }
221
- const separator = token.indexOf("=");
222
- const name = separator === -1 ? token : token.slice(0, separator);
223
- let value = separator === -1 ? undefined : token.slice(separator + 1);
224
- const options = new Set(["--base-url", "--data-dir", "--relay", "--network-config", "--ca-file", "--proxy", "--codex-command", "--ready-timeout", "--host", "--port", "--max-fee"]);
225
- if (!options.has(name)) throw new ConsumerCliError(`unknown option: ${token}`, 2);
226
- if (value === undefined) { index += 1; value = argv[index]; }
227
- if (!value) throw new ConsumerCliError(`${name} requires a value`, 2);
228
- if (name === "--base-url") { parsed.baseUrl = value; parsed.baseUrlExplicit = true; }
229
- if (name === "--data-dir") parsed.dataDir = value;
230
- if (name === "--relay") { parsed.relayUrls = value; parsed.relayUrlsExplicit = true; }
231
- if (name === "--network-config") parsed.networkConfig = value;
232
- if (name === "--ca-file") parsed.caFile = value;
233
- if (name === "--proxy") parsed.proxy = value;
234
- if (name === "--codex-command") parsed.codexCommand = value;
235
- if (name === "--ready-timeout") parsed.readyTimeout = parsePositive(value, name, 86400);
236
- if (name === "--host") { parsed.host = value; parsed.hostForUrl = value.includes(":") ? `[${value}]` : value; }
237
- if (name === "--port") parsed.port = parsePositive(value, name, 65535);
238
- if (name === "--max-fee") parsed.maxFeeUnits = parsePositive(value, name);
239
- }
240
- if (parsed.v10ControlledTest && !parsed.networkConfig) {
241
- parsed.networkConfig = env.MYCOMESH_V10_NETWORK_CONFIG || V10_CONTROLLED_TEST_NETWORK;
242
- }
243
- if (parsed.v10DynamicProviderAi && !parsed.networkConfig) {
244
- parsed.networkConfig = env.MYCOMESH_V10_DYNAMIC_NETWORK_CONFIG || V10_DYNAMIC_PROVIDER_AI_NETWORK;
245
- }
246
- if (!parsed.baseUrlExplicit) parsed.baseUrl = `http://${parsed.hostForUrl}:${parsed.port}/v1`;
247
- try { new URL(parsed.baseUrl); } catch { throw new ConsumerCliError("--base-url must be an absolute URL", 2); }
248
- return parsed;
249
- }
250
-
251
- // Read-only preflight. This deliberately does not construct NativeConsumerState:
252
- // its constructor creates a payment key on first run. A doctor command must be
253
- // safe to run from installers, CI and support scripts without changing state.
254
- async function consumerDoctor({ parsed, env, stdout, fetch: injectedFetch, json }) {
255
- const checks = [];
256
- const add = (id, status, message, details = undefined) => {
257
- const check = { id, status, message };
258
- if (details !== undefined) check.details = details;
259
- checks.push(check);
260
- };
261
- const dataDir = parsed.dataDir;
262
- if (existsSync(dataDir)) add("data_dir", "ready", "Consumer data directory exists", { path: dataDir });
263
- else add("data_dir", "setup_required", "Consumer data directory has not been created", { path: dataDir });
264
-
265
- const keyPath = join(dataDir, "payment-key");
266
- const configuredKey = String(env.MYCOMESH_V8_PAYMENT_KEY || "").trim();
267
- let paymentAddress;
268
- try {
269
- if (configuredKey) paymentAddress = paymentKeyAddress(configuredKey);
270
- else if (existsSync(keyPath)) paymentAddress = paymentKeyAddress(readFileSync(keyPath, "utf8").trim());
271
- else throw Object.assign(new Error("payment key is not initialized"), { code: "setup_required" });
272
- add("payment_key", "ready", "Payment key is present and valid", {
273
- source: configuredKey ? "environment" : "data_dir",
274
- address: paymentAddress,
275
- });
276
- } catch (error) {
277
- add("payment_key", error.code === "setup_required" ? "setup_required" : "blocked", error.message);
278
- }
279
-
280
- let network;
281
- try {
282
- network = parseNetworkConfig(parsed.networkConfig, { allowControlledTest: parsed.allowControlledTest });
283
- add("network_manifest", "ready", parsed.networkConfig ? "Settlement network manifest is valid" : "Using built-in settlement network", {
284
- path: parsed.networkConfig || null,
285
- protocol_version: network.protocol_version,
286
- chain_id: network.chain_id,
287
- settlement_contract: network.settlement_contract,
288
- });
289
- } catch (error) {
290
- add("network_manifest", "blocked", error.message, { path: parsed.networkConfig || null });
291
- }
292
-
293
- const relayUrls = network?.relay_urls?.length ? network.relay_urls : String(parsed.relayUrls || "")
294
- .split(",").map((value) => value.trim()).filter(Boolean);
295
- const validRelays = [];
296
- for (const value of relayUrls) {
297
- try {
298
- const url = new URL(value);
299
- if (!/^https?:$/.test(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error("URL must be an HTTPS origin without credentials or query");
300
- validRelays.push(url.href.replace(/\/+$/, ""));
301
- } catch (error) {
302
- add("relay_urls", "blocked", `Invalid Relay URL: ${error.message}`, { url: value });
303
- }
304
- }
305
- if (validRelays.length) add("relay_urls", "ready", `${validRelays.length} Relay URL${validRelays.length === 1 ? "" : "s"} configured`, { urls: validRelays });
306
- else if (!checks.some((check) => check.id === "relay_urls")) add("relay_urls", "blocked", "No usable Relay URL is configured");
307
-
308
- const pidPath = join(dataDir, "consumer.pid");
309
- if (!existsSync(pidPath)) add("local_process", "ready", "No stale Consumer pid file found", { running: false });
310
- else {
311
- const pid = Number(readFileSync(pidPath, "utf8").trim());
312
- let running = false;
313
- try { if (Number.isInteger(pid) && pid > 1) { process.kill(pid, 0); running = true; } } catch (error) { if (error.code !== "ESRCH") running = true; }
314
- add("local_process", running ? "ready" : "blocked", running ? "Consumer process is running" : "Consumer pid file is stale", { pid, running });
315
- }
316
-
317
- const doctorFetchConfig = {
318
- ...parsed,
319
- caFile: parsed.caFile || network?.tls_ca_file,
320
- };
321
- const fetchImpl = injectedFetch || ((url, options) => consumerDoctorFetch(url, options, doctorFetchConfig));
322
- const health = [];
323
- if (validRelays.length && typeof fetchImpl === "function") {
324
- await Promise.all(validRelays.map(async (relay) => {
325
- const controller = new AbortController();
326
- const timer = setTimeout(() => controller.abort(), 1500);
327
- try {
328
- const response = await fetchImpl(`${relay}/health`, { method: "GET", redirect: "error", signal: controller.signal });
329
- health.push({ url: relay, status: response.ok ? "ready" : "blocked", http_status: response.status });
330
- await response.body?.cancel?.();
331
- } catch (error) { health.push({ url: relay, status: "blocked", error: error.message }); }
332
- finally { clearTimeout(timer); }
333
- }));
334
- const healthy = health.filter((item) => item.status === "ready").length;
335
- const relayStatus = healthy === 0 ? "blocked" : healthy < health.length ? "degraded" : "ready";
336
- add("relay_health", relayStatus, healthy ? `${healthy}/${health.length} Relay health checks passed` : "All Relay health checks failed", { relays: health });
337
- } else add("relay_health", "setup_required", "Relay health was not checked because no usable Relay URL is configured");
338
-
339
- const hasBlocked = checks.some((check) => check.status === "blocked");
340
- const hasSetup = checks.some((check) => check.status === "setup_required");
341
- const hasDegraded = checks.some((check) => check.status === "degraded");
342
- const report = {
343
- schema: "mycomesh.consumer.doctor.v1",
344
- status: hasBlocked ? "blocked" : hasSetup ? "setup_required" : hasDegraded ? "degraded" : "ready",
345
- release: { version: CONSUMER_RELEASE_VERSION },
346
- config: { data_dir: dataDir, host: parsed.host, port: parsed.port, base_url: parsed.baseUrl },
347
- checks,
348
- };
349
- if (json) stdout.write(`${JSON.stringify(report)}\n`);
350
- else {
351
- stdout.write(`Consumer doctor: ${report.status}\n`);
352
- for (const check of checks) stdout.write(`${check.status === "ready" ? "✓" : check.status === "setup_required" ? "!" : check.status === "degraded" ? "~" : "✗"} ${check.id}: ${check.message}\n`);
353
- }
354
- return report.status === "blocked" ? 1 : 0;
15
+ export function loadNetwork(path) {
16
+ const network = JSON.parse(readFileSync(path, "utf8"));
17
+ if (network.schema !== "mycomesh.v11.network.v1") throw new Error("not a MycoMesh V11 network manifest");
18
+ if (network.tls_ca_file) network.tls_ca = readFileSync(resolve(dirname(path), network.tls_ca_file), "utf8");
19
+ return network;
355
20
  }
356
21
 
357
- export function consumerDoctorFetch(url, options = {}, parsed, requestImpl = httpsRequest) {
22
+ export function httpJson(url, { method = "GET", body, ca, timeoutMs = 330_000 } = {}) {
358
23
  const target = new URL(url);
359
- if (target.protocol !== "https:" || !parsed.caFile) return globalThis.fetch(url, options);
360
- const ca = readFileSync(resolve(parsed.caFile)).toString("utf8");
24
+ const data = body === undefined ? undefined : Buffer.from(JSON.stringify(body));
25
+ const send = target.protocol === "https:" ? httpsRequest : httpRequest;
361
26
  return new Promise((resolvePromise, reject) => {
362
- const request = requestImpl({
363
- protocol: target.protocol,
364
- hostname: target.hostname,
365
- port: target.port || 443,
366
- path: `${target.pathname}${target.search}`,
367
- method: options.method || "GET",
368
- // A private manifest CA supplements, rather than replaces, public trust.
369
- ca: [...rootCertificates, ca],
370
- rejectUnauthorized: true,
371
- }, (response) => {
372
- response.resume();
373
- resolvePromise({
374
- ok: (response.statusCode || 0) >= 200 && (response.statusCode || 0) < 300,
375
- status: response.statusCode || 0,
376
- body: { cancel: async () => {} },
27
+ const req = send(target, {
28
+ method, headers: { accept: "application/json", ...(data ? { "content-type": "application/json", "content-length": data.length } : {}) },
29
+ ...(target.protocol === "https:" && ca ? { ca: [...rootCertificates, ca] } : {}),
30
+ }, (res) => {
31
+ const chunks = [];
32
+ let size = 0;
33
+ res.on("data", (chunk) => {
34
+ size += chunk.length;
35
+ if (size > MAX_BODY) { req.destroy(new Error("response too large")); return; }
36
+ chunks.push(chunk);
37
+ });
38
+ res.on("end", () => {
39
+ try { resolvePromise({ status: res.statusCode, body: JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}") }); }
40
+ catch (error) { reject(error); }
377
41
  });
378
42
  });
379
- const abort = () => request.destroy(options.signal?.reason || new Error("request aborted"));
380
- options.signal?.addEventListener("abort", abort, { once: true });
381
- request.setTimeout(1500, () => request.destroy(new Error("request timed out")));
382
- request.once("error", reject);
383
- request.once("close", () => options.signal?.removeEventListener("abort", abort));
384
- request.end();
43
+ req.setTimeout(timeoutMs, () => req.destroy(new Error("request timed out")));
44
+ req.on("error", reject);
45
+ if (data) req.write(data);
46
+ req.end();
385
47
  });
386
48
  }
387
49
 
388
- function parsePositive(value, label, maximum = Number.MAX_SAFE_INTEGER) {
389
- if (!/^[1-9][0-9]*$/.test(String(value))) throw new ConsumerCliError(`${label} must be a positive integer`, 2);
390
- const parsed = Number(value);
391
- if (!Number.isSafeInteger(parsed) || parsed > maximum) throw new ConsumerCliError(`${label} is too large`, 2);
392
- return parsed;
393
- }
50
+ export class Consumer {
51
+ constructor({ network, keyPrivate, maxFee, journalDir, fetchJson = httpJson, now = () => Math.floor(Date.now() / 1000) }) {
52
+ this.journalDir = journalDir;
53
+ this.network = network;
54
+ this.keyPrivate = keyPrivate;
55
+ this.key = addressOf(keyPrivate);
56
+ this.maxFee = maxFee;
57
+ this.fetchJson = fetchJson;
58
+ this.now = now;
59
+ this.deployment = { chainId: network.chain_id, settlement: network.settlement.toLowerCase() };
60
+ this.cache = new Map();
61
+ }
394
62
 
395
- export function isApiInvocation(argv) {
396
- for (let index = 0; index < argv.length; index += 1) {
397
- const token = argv[index];
398
- if (token === "--") return false;
399
- if (token.startsWith("--")) {
400
- const name = token.split("=", 1)[0];
401
- if (!token.includes("=") && API_VALUE_OPTIONS.has(name)) index += 1;
402
- continue;
403
- }
404
- if (token.startsWith("-")) continue;
405
- return API_COMMANDS.has(token);
63
+ async providers(relay) {
64
+ const cached = this.cache.get(relay.url);
65
+ if (cached && Date.now() - cached.at < PROVIDER_CACHE_MS) return cached.providers;
66
+ const { status, body } = await this.fetchJson(`${relay.url}/providers`, { ca: this.network.tls_ca, timeoutMs: 10_000 });
67
+ if (status !== 200) throw new Error(`Relay ${relay.url} /providers returned ${status}`);
68
+ const now = this.now();
69
+ const providers = (body.providers || []).filter((descriptor) => {
70
+ try { verifiedProvider(descriptor, this.deployment, now); return true; } catch { return false; }
71
+ });
72
+ this.cache.set(relay.url, { at: Date.now(), providers });
73
+ return providers;
406
74
  }
407
- return false;
408
- }
409
75
 
410
- async function waitUntilReady(state, timeoutSeconds, stdout) {
411
- const started = Date.now();
412
- stdout.write(`Waiting for a healthy Settlement V${state.network?.protocol_version || 8} Relay...\n`);
413
- while (true) {
414
- try { await state.chooseRelay(); return; } catch (error) {
415
- if (Date.now() - started >= timeoutSeconds * 1000) throw new ConsumerCliError(`timed out waiting for a healthy Relay: ${error.message}`);
416
- await new Promise((resolve) => setTimeout(resolve, 2000));
76
+ async models() {
77
+ const names = new Set();
78
+ for (const relay of this.network.relays) {
79
+ try { for (const provider of await this.providers(relay)) for (const model of provider.models || []) names.add(model); } catch {}
417
80
  }
81
+ return [...names].sort();
418
82
  }
419
- }
420
83
 
421
- async function waitUntilUnlocked(state, timeoutSeconds, stdout) {
422
- const started = Date.now();
423
- stdout.write("Waiting for wallet verification in the local Consumer page...\n");
424
- while (!state.paymentUnlocked) {
425
- if (Date.now() - started >= timeoutSeconds * 1000) {
426
- throw new ConsumerCliError("timed out waiting for the payment-key owner wallet to unlock Consumer");
84
+ /** Try Relays in order; fail over only while the request provably was not dispatched. */
85
+ async request({ endpoint, model, content, maxOutputTokens = 4096, options = {}, provider }) {
86
+ let lastError;
87
+ for (const relay of this.network.relays) {
88
+ let candidates;
89
+ try {
90
+ candidates = (await this.providers(relay)).filter((descriptor) => (descriptor.models || []).includes(model)
91
+ && (!provider || descriptor.provider_signer === provider.toLowerCase()));
92
+ }
93
+ catch (error) { lastError = error; continue; }
94
+ candidates.sort((a, b) => (a.prices.output_per_1k - b.prices.output_per_1k) || (a.prices.input_per_1k - b.prices.input_per_1k));
95
+ for (const descriptor of candidates) {
96
+ const prepared = prepareRequest({
97
+ descriptor, deployment: this.deployment, keyPrivate: this.keyPrivate, relaySigner: relay.signer,
98
+ endpoint, model, content, maxOutputTokens, maxFee: this.maxFee, options, now: this.now(),
99
+ });
100
+ let reply;
101
+ try {
102
+ reply = await this.fetchJson(`${relay.url}/v11/requests`, { method: "POST", body: prepared.payload, ca: this.network.tls_ca });
103
+ } catch (error) {
104
+ // The request may have reached the Relay: never replay it elsewhere.
105
+ throw Object.assign(new Error(`request outcome unknown: ${error.message}`), { code: "outcome_unknown", requestId: prepared.authorization.request_id });
106
+ }
107
+ if (reply.status === 200) {
108
+ const opened = openResponse(prepared, reply.body, this.deployment, this.now());
109
+ // Only this machine can ever reveal the plaintexts, so keep them for the dispute window.
110
+ if (this.journalDir) recordRequest(this.journalDir, prepared, opened, relay.url);
111
+ return opened;
112
+ }
113
+ lastError = Object.assign(new Error(reply.body?.error || `Relay returned ${reply.status}`), { status: reply.status });
114
+ if (reply.body?.dispatched !== false || ![502, 503].includes(reply.status)) throw lastError;
115
+ }
427
116
  }
428
- await new Promise((resolve) => setTimeout(resolve, 1000));
117
+ throw lastError || new Error(`no Provider serves model ${model}`);
429
118
  }
430
119
  }
431
120
 
432
- function runCodex(parsed, state, spawn, stdout, stderr) {
433
- return new Promise((resolve) => {
434
- const args = [
435
- "-c", 'model="gpt-5.5"',
436
- "-c", 'model_provider="mycomesh"',
437
- "-c", 'model_providers.mycomesh.name="MycoMesh"',
438
- "-c", `model_providers.mycomesh.base_url="${state.baseUrl}"`,
439
- "-c", 'model_providers.mycomesh.env_key="OPENAI_API_KEY"',
440
- "-c", 'model_providers.mycomesh.wire_api="responses"',
441
- ...parsed.codexArgs,
442
- ];
443
- const env = { ...process.env, OPENAI_BASE_URL: state.baseUrl, OPENAI_API_KEY: state.paymentKey };
444
- let child;
445
- try { child = spawn(parsed.codexCommand, args, { env, stdio: "inherit" }); }
446
- catch (error) { stderr.write(`mycomesh consumer: could not start Codex: ${error.message}\n`); resolve(127); return; }
447
- stdout.write("Opening Codex through the native MycoMesh Consumer.\n");
448
- child.once("error", (error) => { stderr.write(`mycomesh consumer: Codex failed: ${error.message}\n`); resolve(127); });
449
- child.once("exit", (code, signal) => resolve(typeof code === "number" ? code : 128 + ({ SIGINT: 2, SIGTERM: 15 }[signal] || 1)));
450
- });
451
- }
452
-
453
- function writePid(dataDir) {
454
- try { writeFileSync(join(dataDir, "consumer.pid"), `${process.pid}\n`, { mode: 0o600 }); } catch {}
455
- }
456
-
457
- function clearPid(dataDir) {
458
- try { unlinkSync(join(dataDir, "consumer.pid")); } catch {}
121
+ function openaiError(message, code = "mycomesh_error") {
122
+ return { error: { message, type: "mycomesh_error", code } };
459
123
  }
460
124
 
461
- function stopConsumer(parsed, stdout, stderr) {
462
- const pidPath = join(parsed.dataDir, "consumer.pid");
463
- if (!existsSync(pidPath)) { stdout.write("No native Consumer is running.\n"); return 0; }
464
- const pid = Number(readFileSync(pidPath, "utf8").trim());
465
- if (!Number.isInteger(pid) || pid <= 1) { clearPid(parsed.dataDir); stderr.write("Removed an invalid Consumer pid file.\n"); return 0; }
466
- try { process.kill(pid, "SIGTERM"); stdout.write(`Stopped native Consumer process ${pid}.\n`); }
467
- catch (error) { if (error.code !== "ESRCH") throw error; stdout.write("Native Consumer was already stopped.\n"); }
468
- clearPid(parsed.dataDir);
469
- return 0;
470
- }
471
-
472
- async function resetConsumer(parsed, env, stdout, stderr) {
473
- const confirmed = env.MYCOMESH_CONFIRM_RESET === "RESET";
474
- if (!confirmed && process.stdin.isTTY) {
475
- const prompt = readline.createInterface({ input: process.stdin, output: process.stdout });
476
- const answer = await prompt.question("This removes the local payment key and history. Type RESET to continue: ");
477
- prompt.close();
478
- if (answer !== "RESET") throw new ConsumerCliError("local Consumer reset cancelled", 2);
479
- } else if (!confirmed) {
480
- throw new ConsumerCliError("set MYCOMESH_CONFIRM_RESET=RESET to reset a non-interactive Consumer", 2);
125
+ async function readJson(req) {
126
+ const chunks = [];
127
+ let size = 0;
128
+ for await (const chunk of req) {
129
+ size += chunk.length;
130
+ if (size > MAX_BODY) throw new Error("request body too large");
131
+ chunks.push(chunk);
481
132
  }
482
- clearPid(parsed.dataDir);
483
- rmSync(parsed.dataDir, { recursive: true, force: true });
484
- stdout.write("Native Consumer local state removed.\n");
485
- return 0;
486
- }
487
-
488
- function openBrowser(url, stderr) {
489
- const platform = process.platform;
490
- let command;
491
- let args;
492
- if (platform === "darwin") { command = "open"; args = [url]; }
493
- else if (platform === "win32") { command = "cmd"; args = ["/c", "start", "", url]; }
494
- else { command = "xdg-open"; args = [url]; }
495
- try {
496
- const child = defaultSpawn(command, args, { stdio: "ignore", detached: true });
497
- child.unref?.();
498
- } catch { stderr.write(`Open ${url} in a browser.\n`); }
133
+ return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
499
134
  }
500
135
 
501
- function waitForSignal(shutdown) {
502
- return new Promise((resolve) => {
503
- let closed = false;
504
- const finish = async () => { if (closed) return; closed = true; process.removeListener("SIGINT", finish); process.removeListener("SIGTERM", finish); await shutdown(); resolve(); };
505
- process.once("SIGINT", finish);
506
- process.once("SIGTERM", finish);
136
+ /** 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 } = {}) {
138
+ const server = createServer(async (req, res) => {
139
+ const send = (status, body, type = "application/json") => {
140
+ res.writeHead(status, { "content-type": type, "cache-control": "no-store" });
141
+ res.end(type === "application/json" ? JSON.stringify(body) : body);
142
+ };
143
+ try {
144
+ if (apiKey && req.headers.authorization !== `Bearer ${apiKey}`) return send(401, openaiError("invalid API key", "unauthorized"));
145
+ const path = new URL(req.url, "http://localhost").pathname.replace(/^\/v1\/v1\//, "/v1/");
146
+ if (req.method === "GET" && path === "/health") return send(200, { ok: true, protocol: 11, key: consumer.key });
147
+ if (req.method === "GET" && path === "/v1/models") {
148
+ return send(200, { object: "list", data: (await consumer.models()).map((id) => ({ id, object: "model", owned_by: "mycomesh" })) });
149
+ }
150
+ if (req.method !== "POST" || !["/v1/responses", "/v1/chat/completions", "/responses", "/chat/completions"].includes(path)) {
151
+ return send(404, openaiError("not found", "not_found"));
152
+ }
153
+ const body = await readJson(req);
154
+ const chat = path.endsWith("/chat/completions");
155
+ const { model, stream, stream_options: streamOptions, ...rest } = body;
156
+ const content = chat ? rest.messages : rest.input;
157
+ delete rest.messages;
158
+ delete rest.input;
159
+ const maxOutputTokens = rest.max_output_tokens ?? rest.max_tokens ?? rest.max_completion_tokens ?? 4096;
160
+ delete rest.max_output_tokens;
161
+ delete rest.max_tokens;
162
+ 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);
167
+ } catch (error) {
168
+ const status = error.code === "outcome_unknown" ? 504 : (error.status && error.status < 500 ? error.status : 502);
169
+ return send(status, openaiError(error.message, error.code || "request_failed"));
170
+ }
507
171
  });
172
+ return new Promise((resolvePromise) => server.listen(port, host, () => resolvePromise(server)));
508
173
  }