mycomesh-consumer 0.9.8 → 0.9.10

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.
@@ -1,7 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { main } from "../src/cli.mjs";
3
3
 
4
- main().then((code) => { if (typeof code === "number") process.exitCode = code; }).catch((error) => {
4
+ // A one-shot command is done when main returns (serve never does): exit then, once the output is flushed,
5
+ // rather than wait for background discovery refreshes that the next run will redo anyway.
6
+ const exit = (code) => process.stdout.write("", () => process.stderr.write("", () => process.exit(code)));
7
+
8
+ main().then((code) => exit(typeof code === "number" ? code : 0)).catch((error) => {
5
9
  process.stderr.write(`mycomesh-consumer: ${error.message}\n`);
6
- process.exitCode = 1;
10
+ exit(1);
7
11
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mycomesh-consumer",
3
- "version": "0.9.8",
3
+ "version": "0.9.10",
4
4
  "description": "MycoMesh V11 Consumer: a custodied deposit, relay-blind encrypted requests to any Provider, and a local OpenAI-compatible endpoint.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/consumer.mjs CHANGED
@@ -16,9 +16,35 @@ import { hashApiKey } from "./tenants.mjs";
16
16
  import { anthropicError, anthropicStream, fromAnthropic, fromGemini, geminiError, geminiRoute, geminiStream,
17
17
  toAnthropic, toGemini } from "./dialects.mjs";
18
18
 
19
- const PROVIDER_CACHE_MS = 30_000;
20
- const RELAY_CACHE_MS = 300_000;
21
- const REPUTATION_CACHE_MS = 600_000;
19
+ // Discovery is served from cache and refreshed in the background once it is older than its fresh period; only
20
+ // data older than its usable period (or none yet) makes a request wait for it.
21
+ const PROVIDER_CACHE_MS = [30_000, 300_000];
22
+ const RELAY_CACHE_MS = [300_000, 3_600_000];
23
+ const REPUTATION_CACHE_MS = [600_000, 3_600_000];
24
+ const REPUTATION_WAIT_MS = 3_000; // a first request ranks without reputation rather than wait longer for it
25
+ const WARM_EVERY_MS = 20_000; // serve: keep discovery fresh and a connection to every Relay open
26
+
27
+ /** A value loaded once at a time, served stale while it is refreshed. */
28
+ class Cached {
29
+ constructor(load, [freshMs, usableMs]) { Object.assign(this, { load, freshMs, usableMs, at: 0, value: undefined, pending: null }); }
30
+
31
+ get() {
32
+ const age = Date.now() - this.at;
33
+ if (this.value !== undefined && age < this.freshMs) return Promise.resolve(this.value);
34
+ if (this.value !== undefined && age < this.usableMs) { this.refresh().catch(() => {}); return Promise.resolve(this.value); }
35
+ return this.refresh();
36
+ }
37
+
38
+ refresh() {
39
+ this.pending ||= this.load().then((value) => { Object.assign(this, { value, at: Date.now() }); return value; })
40
+ .finally(() => { this.pending = null; });
41
+ return this.pending;
42
+ }
43
+ }
44
+
45
+ // Consumers of one network (a node and its tenants) share what they discover.
46
+ const DISCOVERY = new WeakMap();
47
+ const VERIFIED = new WeakMap(); // descriptor -> the times its verification holds
22
48
  const MAX_BODY = 16 * 1024 * 1024;
23
49
 
24
50
  export function loadNetwork(path) {
@@ -29,30 +55,63 @@ export function loadNetwork(path) {
29
55
  }
30
56
 
31
57
  /**
32
- * A fresh connection per request, through an agent of its own: a reused keep-alive socket that the Relay's
33
- * edge has just closed fails with ECONNRESET after the request may have been sent, which can only be
34
- * reported as outcome unknown. (Only an agent's own createConnection is honoured, hence the pinned one here.)
58
+ * Kept-alive connections, one agent per Relay origin (and pin): a request to a Relay this Consumer talked to in
59
+ * the last half minute skips the TCP and TLS handshakes (several round trips across the world). Idle sockets
60
+ * are dropped after `keepAlive.idleMs`, well inside the Relay edge's own keep-alive (nginx: 75 s), and a request
61
+ * whose reused socket turns out to have been closed by the Relay before any reply is sent again on a fresh
62
+ * one: a closed idle connection never carried it, and the Relay and Provider refuse a settlement key twice
63
+ * anyway. (Only an agent's own createConnection is honoured, hence the pinned one here.)
35
64
  */
36
- async function tlsOptions(target, ca, pin) {
37
- if (target.protocol !== "https:") return { agent: new HttpAgent({ keepAlive: false }) };
38
- const agent = new HttpsAgent({ keepAlive: false });
39
- if (pin) {
40
- agent.createConnection = pinnedOptions(target.href, pin).createConnection;
41
- return { agent };
65
+ export const keepAlive = { idleMs: 30_000 };
66
+ const AGENTS = new Map();
67
+
68
+ function tlsOptions(target, ca, pin) {
69
+ const key = `${target.protocol}//${target.host}#${pin || ""}#${ca ? "ca" : ""}`;
70
+ let options = AGENTS.get(key);
71
+ if (!options) {
72
+ const settings = { keepAlive: true, timeout: keepAlive.idleMs, scheduling: "lifo" };
73
+ if (target.protocol !== "https:") options = { agent: new HttpAgent(settings) };
74
+ else {
75
+ const agent = new HttpsAgent(settings);
76
+ if (pin) agent.createConnection = pinnedOptions(target.href, pin).createConnection;
77
+ options = pin || !ca ? { agent } : { agent, ca: [...rootCertificates, ca] };
78
+ }
79
+ AGENTS.set(key, options);
42
80
  }
43
- return ca ? { agent, ca: [...rootCertificates, ca] } : { agent };
81
+ return options;
82
+ }
83
+
84
+ /** Drop every kept-alive connection (tests, shutdown). */
85
+ export function closeConnections() {
86
+ for (const { agent } of AGENTS.values()) agent.destroy();
87
+ AGENTS.clear();
88
+ }
89
+
90
+ const STALE = new Set(["ECONNRESET", "EPIPE", "ECONNABORTED"]);
91
+
92
+ /** Send once; again if a reused connection was already closed by the Relay before anything came back. */
93
+ function withFreshRetry(send) {
94
+ return new Promise((resolvePromise, reject) => {
95
+ const attempt = (tries) => send((error, req, answered) => {
96
+ if (error && req?.reusedSocket && !answered && STALE.has(error.code) && tries < 3) return attempt(tries + 1);
97
+ return error ? reject(error) : undefined;
98
+ }, resolvePromise);
99
+ attempt(0);
100
+ });
44
101
  }
45
102
 
46
103
  export async function httpJson(url, { method = "GET", body, ca, pin, timeoutMs = 330_000 } = {}) {
47
104
  const target = new URL(url);
48
105
  const data = body === undefined ? undefined : Buffer.from(JSON.stringify(body));
49
106
  const send = target.protocol === "https:" ? httpsRequest : httpRequest;
50
- const tls = await tlsOptions(target, ca, pin);
51
- return new Promise((resolvePromise, reject) => {
107
+ const tls = tlsOptions(target, ca, pin);
108
+ return withFreshRetry((fail, resolvePromise) => {
109
+ let answered = false;
52
110
  const req = send(target, {
53
111
  method, headers: { accept: "application/json", ...(data ? { "content-type": "application/json", "content-length": data.length } : {}) },
54
112
  ...tls,
55
113
  }, (res) => {
114
+ answered = true;
56
115
  const chunks = [];
57
116
  let size = 0;
58
117
  res.on("data", (chunk) => {
@@ -62,14 +121,14 @@ export async function httpJson(url, { method = "GET", body, ca, pin, timeoutMs =
62
121
  });
63
122
  res.on("end", () => {
64
123
  try { resolvePromise({ status: res.statusCode, body: JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}") }); }
65
- catch (error) { reject(error); }
124
+ catch (error) { fail(error, req, true); }
66
125
  });
67
126
  });
68
127
  // A wall-clock deadline, not a socket idle timeout: keep-alive sockets are reused, and an idle timeout
69
128
  // left on one by an earlier short request (a 10 s /providers call) would later cut a long answer.
70
129
  const timer = setTimeout(() => req.destroy(new Error(`request to ${target.host}${target.pathname} timed out after ${timeoutMs / 1000} s`)), timeoutMs);
71
130
  req.on("close", () => clearTimeout(timer));
72
- req.on("error", reject);
131
+ req.on("error", (error) => fail(error, req, answered));
73
132
  if (data) req.write(data);
74
133
  req.end();
75
134
  });
@@ -80,13 +139,15 @@ export async function httpNdjson(url, { body, ca, pin, timeoutMs = 340_000, onLi
80
139
  const target = new URL(url);
81
140
  const data = Buffer.from(JSON.stringify(body));
82
141
  const send = target.protocol === "https:" ? httpsRequest : httpRequest;
83
- const tls = await tlsOptions(target, ca, pin);
84
- return new Promise((resolvePromise, reject) => {
142
+ const tls = tlsOptions(target, ca, pin);
143
+ return withFreshRetry((fail, resolvePromise) => {
144
+ let answered = false;
85
145
  const req = send(target, {
86
146
  method: "POST",
87
147
  headers: { accept: "application/x-ndjson", "content-type": "application/json", "content-length": data.length },
88
148
  ...tls,
89
149
  }, (res) => {
150
+ answered = true;
90
151
  const streaming = res.statusCode === 200 && String(res.headers["content-type"] || "").includes("ndjson");
91
152
  let buffered = "";
92
153
  const chunks = [];
@@ -105,14 +166,14 @@ export async function httpNdjson(url, { body, ca, pin, timeoutMs = 340_000, onLi
105
166
  });
106
167
  res.on("end", () => {
107
168
  if (streaming) return resolvePromise({ status: 200, streamed: true });
108
- try { resolvePromise({ status: res.statusCode, body: JSON.parse(chunks.join("") || "{}") }); } catch (error) { reject(error); }
169
+ try { resolvePromise({ status: res.statusCode, body: JSON.parse(chunks.join("") || "{}") }); } catch (error) { fail(error, req, true); }
109
170
  });
110
171
  });
111
172
  // A wall-clock deadline, not a socket idle timeout: keep-alive sockets are reused, and an idle timeout
112
173
  // left on one by an earlier short request (a 10 s /providers call) would later cut a long answer.
113
174
  const timer = setTimeout(() => req.destroy(new Error(`request to ${target.host}${target.pathname} timed out after ${timeoutMs / 1000} s`)), timeoutMs);
114
175
  req.on("close", () => clearTimeout(timer));
115
- req.on("error", reject);
176
+ req.on("error", (error) => fail(error, req, answered));
116
177
  req.end(data);
117
178
  });
118
179
  }
@@ -152,32 +213,55 @@ export class Consumer {
152
213
  this.fetchJson = fetchJson;
153
214
  this.now = now;
154
215
  this.deployment = { chainId: network.chain_id, settlement: network.settlement.toLowerCase() };
155
- this.cache = new Map();
156
- this.relayCache = null;
216
+ const shared = DISCOVERY.get(network) || new Map();
217
+ DISCOVERY.set(network, shared);
218
+ if (!shared.has(fetchJson)) {
219
+ shared.set(fetchJson, { relays: new Cached(() => this.loadRelays(), RELAY_CACHE_MS), providers: new Map(),
220
+ reputations: new Cached(() => this.loadReputations(), REPUTATION_CACHE_MS) });
221
+ }
222
+ this.discovery = shared.get(fetchJson);
157
223
  }
158
224
 
159
225
  /** On-chain reputation and verified probe verdicts for every Provider in view, cached. */
160
- async reputations() {
161
- if (this.reputationCache && Date.now() - this.reputationCache.at < REPUTATION_CACHE_MS) return this.reputationCache.value;
226
+ reputations({ waitMs = Infinity } = {}) {
227
+ const value = this.discovery.reputations.get();
228
+ if (!Number.isFinite(waitMs)) return value;
229
+ return Promise.race([value, new Promise((resolve) => setTimeout(resolve, waitMs, {}).unref())]);
230
+ }
231
+
232
+ async loadReputations() {
162
233
  const descriptors = new Map();
163
234
  const relaysByOwner = {};
164
- for (const relay of await this.relays()) {
235
+ await Promise.all((await this.relays()).map(async (relay) => {
165
236
  try {
166
237
  const owner = relay.owner || `0x${(await rpcCall(this.network.rpc_urls, "eth_call", [{ to: this.network.settlement,
167
238
  data: encodeCall("relaySignerOwner(address)", [["address", relay.signer]]) }, "latest"])).slice(26)}`;
168
239
  relaysByOwner[owner.toLowerCase()] = relay;
169
240
  } catch {}
170
241
  try { for (const descriptor of await this.providers(relay)) descriptors.set(descriptor.provider_signer, descriptor); } catch {}
171
- }
242
+ }));
172
243
  let value = {};
173
244
  try { value = await reputation(this, [...descriptors.values()], relaysByOwner); } catch {}
174
- this.reputationCache = { at: Date.now(), value };
175
245
  return value;
176
246
  }
177
247
 
248
+ /** Discover now what the first request would otherwise wait for; with `everyMs`, keep it fresh (serve). */
249
+ warm({ everyMs } = {}) {
250
+ const once = async () => {
251
+ try { await Promise.all((await this.relays()).map((relay) => this.providers(relay).catch(() => {}))); } catch {}
252
+ await this.reputations().catch(() => {});
253
+ };
254
+ const first = once();
255
+ if (everyMs) setInterval(once, everyMs).unref();
256
+ return first;
257
+ }
258
+
178
259
  /** Manifest Relays first, then directory Relays whose /health proves the announced signer. */
179
- async relays() {
180
- if (this.relayCache && Date.now() - this.relayCache.at < RELAY_CACHE_MS) return this.relayCache.relays;
260
+ relays() {
261
+ return this.discovery.relays.get();
262
+ }
263
+
264
+ async loadRelays() {
181
265
  const relays = this.network.relays.map((relay) => ({ ...relay }));
182
266
  const known = new Set(relays.map((relay) => relay.signer.toLowerCase()));
183
267
  try {
@@ -199,21 +283,30 @@ export class Consumer {
199
283
  } catch {}
200
284
  }
201
285
  } catch {}
202
- this.relayCache = { at: Date.now(), relays };
203
286
  return relays;
204
287
  }
205
288
 
289
+ /** A Relay's Providers whose signed descriptors verify now (a cached list is checked again on every use). */
206
290
  async providers(relay) {
207
- const cached = this.cache.get(relay.url);
208
- if (cached && Date.now() - cached.at < PROVIDER_CACHE_MS) return cached.providers;
209
- const { status, body } = await this.fetchJson(`${relay.url}/providers`, { ca: this.network.tls_ca, pin: relay.pin, timeoutMs: 10_000 });
210
- if (status !== 200) throw new Error(`Relay ${relay.url} /providers returned ${status}`);
291
+ let entry = this.discovery.providers.get(relay.url);
292
+ if (!entry) {
293
+ entry = new Cached(async () => {
294
+ const { status, body } = await this.fetchJson(`${relay.url}/providers`, { ca: this.network.tls_ca, pin: relay.pin, timeoutMs: 10_000 });
295
+ if (status !== 200) throw new Error(`Relay ${relay.url} /providers returned ${status}`);
296
+ return body.providers || [];
297
+ }, PROVIDER_CACHE_MS);
298
+ this.discovery.providers.set(relay.url, entry);
299
+ }
211
300
  const now = this.now();
212
- const providers = (body.providers || []).filter((descriptor) => {
213
- try { verifiedProvider(descriptor, this.deployment, now); return true; } catch { return false; }
301
+ return (await entry.get()).filter((descriptor) => {
302
+ // Signatures do not change: once verified, a descriptor stays good until its key or attestation expires.
303
+ const known = VERIFIED.get(descriptor);
304
+ if (known && known.from <= now && now < known.until) return true;
305
+ try { verifiedProvider(descriptor, this.deployment, now); } catch { return false; }
306
+ VERIFIED.set(descriptor, { from: now, until: Math.min(Number(descriptor.transport_key?.expires_at),
307
+ Number(descriptor.transport_attestation?.expires_at)) });
308
+ return true;
214
309
  });
215
- this.cache.set(relay.url, { at: Date.now(), providers });
216
- return providers;
217
310
  }
218
311
 
219
312
  /** The network's model catalog (manifest "models"): vendor, tier, capabilities. */
@@ -259,7 +352,7 @@ export class Consumer {
259
352
  // What the chain proves ranks first (recent fraud, a suspected downgrade, verified probe failures); then
260
353
  // Providers this Consumer is not already keeping busy; then reputation in coarse bands, shuffled within a
261
354
  // band so that many Consumers spread over the network instead of all queueing at its single best Provider.
262
- const records = await this.reputations().catch(() => ({}));
355
+ const records = await this.reputations({ waitMs: REPUTATION_WAIT_MS }).catch(() => ({}));
263
356
  const key = (descriptor) => {
264
357
  const [fraud, downgrade, failure, volume] = rankKey(descriptor, records[descriptor.provider_signer]);
265
358
  const busy = (this.inFlight.get(descriptor.provider_signer) || 0) >= Math.max(1, Number(descriptor.capacity) || 1) ? 1 : 0;
@@ -379,6 +472,7 @@ async function dialectRequest(req, res, active, path, setWriter) {
379
472
 
380
473
  /** Local OpenAI-compatible endpoint (plus the Anthropic and Gemini dialects); nothing leaves this machine unsealed except pricing and routing. */
381
474
  export function serveConsumer(consumer, { host = "127.0.0.1", port = 8110, apiKey, dataDir, tenants = () => ({}) } = {}) {
475
+ consumer.warm({ everyMs: WARM_EVERY_MS }).catch(() => {}); // tenants share it
382
476
  const routes = dataDir ? consoleRoutes({ consumer, dataDir }) : {};
383
477
  // Every dialect's way of presenting a key: OpenAI Bearer, Anthropic x-api-key, Gemini x-goog-api-key or ?key=.
384
478
  const bearer = (req) => /^Bearer (.+)$/.exec(String(req.headers.authorization || ""))?.[1]
@@ -48,11 +48,12 @@ async function ledgerEvents(network) {
48
48
  export async function reputation(consumer, descriptors, relaysByOwner) {
49
49
  const network = consumer.network;
50
50
  const records = {};
51
- for (const descriptor of descriptors) {
52
- try { records[descriptor.provider_signer] = await providerRecord(network, descriptor.provider_signer); } catch {}
53
- }
54
- let events = [];
55
- try { events = await ledgerEvents(network); } catch {}
51
+ const [, events] = await Promise.all([
52
+ Promise.all(descriptors.map(async (descriptor) => {
53
+ try { records[descriptor.provider_signer] = await providerRecord(network, descriptor.provider_signer); } catch {}
54
+ })),
55
+ ledgerEvents(network).catch(() => []),
56
+ ]);
56
57
  const byOwner = {};
57
58
  for (const record of Object.values(records)) {
58
59
  byOwner[record.owner] = { ...record, probes_passed: 0, probes_failed_verified: 0, capability_passed: 0, capability_failed_verified: 0 };