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