mycomesh-consumer 0.9.4 → 0.9.5

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/consumer.mjs +62 -14
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mycomesh-consumer",
3
- "version": "0.9.4",
3
+ "version": "0.9.5",
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
@@ -1,7 +1,7 @@
1
1
  // V11 Consumer runtime: one custodied deposit, any Provider, relay-blind requests.
2
2
  import { readFileSync } from "node:fs";
3
- import { createServer, request as httpRequest } from "node:http";
4
- import { request as httpsRequest } from "node:https";
3
+ import { Agent as HttpAgent, createServer, request as httpRequest } from "node:http";
4
+ import { Agent as HttpsAgent, request as httpsRequest } from "node:https";
5
5
  import { dirname, resolve } from "node:path";
6
6
  import { rootCertificates } from "node:tls";
7
7
  import { addressOf, encodeCall } from "./eip712.mjs";
@@ -28,10 +28,19 @@ export function loadNetwork(path) {
28
28
  return network;
29
29
  }
30
30
 
31
+ /**
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.)
35
+ */
31
36
  async function tlsOptions(target, ca, pin) {
32
- if (target.protocol !== "https:") return {};
33
- if (pin) return pinnedOptions(target.href, pin);
34
- return ca ? { ca: [...rootCertificates, ca] } : {};
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 };
42
+ }
43
+ return ca ? { agent, ca: [...rootCertificates, ca] } : { agent };
35
44
  }
36
45
 
37
46
  export async function httpJson(url, { method = "GET", body, ca, pin, timeoutMs = 330_000 } = {}) {
@@ -56,7 +65,10 @@ export async function httpJson(url, { method = "GET", body, ca, pin, timeoutMs =
56
65
  catch (error) { reject(error); }
57
66
  });
58
67
  });
59
- req.setTimeout(timeoutMs, () => req.destroy(new Error("request timed out")));
68
+ // A wall-clock deadline, not a socket idle timeout: keep-alive sockets are reused, and an idle timeout
69
+ // left on one by an earlier short request (a 10 s /providers call) would later cut a long answer.
70
+ const timer = setTimeout(() => req.destroy(new Error(`request to ${target.host}${target.pathname} timed out after ${timeoutMs / 1000} s`)), timeoutMs);
71
+ req.on("close", () => clearTimeout(timer));
60
72
  req.on("error", reject);
61
73
  if (data) req.write(data);
62
74
  req.end();
@@ -96,7 +108,10 @@ export async function httpNdjson(url, { body, ca, pin, timeoutMs = 340_000, onLi
96
108
  try { resolvePromise({ status: res.statusCode, body: JSON.parse(chunks.join("") || "{}") }); } catch (error) { reject(error); }
97
109
  });
98
110
  });
99
- req.setTimeout(timeoutMs, () => req.destroy(new Error("request timed out")));
111
+ // A wall-clock deadline, not a socket idle timeout: keep-alive sockets are reused, and an idle timeout
112
+ // left on one by an earlier short request (a 10 s /providers call) would later cut a long answer.
113
+ const timer = setTimeout(() => req.destroy(new Error(`request to ${target.host}${target.pathname} timed out after ${timeoutMs / 1000} s`)), timeoutMs);
114
+ req.on("close", () => clearTimeout(timer));
100
115
  req.on("error", reject);
101
116
  req.end(data);
102
117
  });
@@ -129,6 +144,7 @@ export class Consumer {
129
144
  constructor({ network, keyPrivate, maxFee, journalDir, fetchJson = httpJson, streamJson = httpNdjson, now = () => Math.floor(Date.now() / 1000) }) {
130
145
  this.streamJson = streamJson;
131
146
  this.journalDir = journalDir;
147
+ this.inFlight = new Map(); // Provider signer -> requests this Consumer has with it now
132
148
  this.network = network;
133
149
  this.keyPrivate = keyPrivate;
134
150
  this.key = addressOf(keyPrivate);
@@ -213,8 +229,24 @@ export class Consumer {
213
229
  return [...names].sort();
214
230
  }
215
231
 
216
- /** Try Relays in order; fail over only while the request provably was not dispatched. */
217
- async request({ endpoint, model, content, maxOutputTokens = 4096, options = {}, provider, onDelta }) {
232
+ /**
233
+ * Try Relays and Providers in order; fail over only while the request provably was not dispatched. When
234
+ * every Provider is busy, wait and try again (a short queue) rather than failing a burst of requests.
235
+ */
236
+ async request(args) {
237
+ const deadline = Date.now() + (this.queueMs ?? 120_000);
238
+ for (let attempt = 0; ; attempt += 1) {
239
+ try {
240
+ return await this.attempt(args);
241
+ } catch (error) {
242
+ const busy = error.dispatched === false && [502, 503].includes(error.status);
243
+ if (!busy || Date.now() > deadline) throw error;
244
+ await new Promise((resolve) => setTimeout(resolve, Math.min(4_000, 500 * 2 ** attempt) * (0.5 + Math.random())));
245
+ }
246
+ }
247
+ }
248
+
249
+ async attempt({ endpoint, model, content, maxOutputTokens = 4096, options = {}, provider, onDelta }) {
218
250
  let lastError;
219
251
  for (const relay of await this.relays()) {
220
252
  let candidates;
@@ -223,9 +255,18 @@ export class Consumer {
223
255
  && (!provider || descriptor.provider_signer === provider.toLowerCase()));
224
256
  }
225
257
  catch (error) { lastError = error; continue; }
226
- // What the chain proves ranks first: recent fraud, verified probe failures, reputation; then price.
258
+ // What the chain proves ranks first (recent fraud, a suspected downgrade, verified probe failures); then
259
+ // Providers this Consumer is not already keeping busy; then reputation in coarse bands, shuffled within a
260
+ // band so that many Consumers spread over the network instead of all queueing at its single best Provider.
227
261
  const records = await this.reputations().catch(() => ({}));
228
- candidates.sort((a, b) => compareRank(rankKey(a, records[a.provider_signer]), rankKey(b, records[b.provider_signer])));
262
+ const key = (descriptor) => {
263
+ const [fraud, downgrade, failure, volume] = rankKey(descriptor, records[descriptor.provider_signer]);
264
+ const busy = (this.inFlight.get(descriptor.provider_signer) || 0) >= Math.max(1, Number(descriptor.capacity) || 1) ? 1 : 0;
265
+ return [fraud, downgrade, busy, failure, -Math.floor(Math.log10(1 - volume)), Math.random()];
266
+ };
267
+ const keyed = candidates.map((descriptor) => [key(descriptor), descriptor]);
268
+ keyed.sort((a, b) => compareRank(a[0], b[0]));
269
+ candidates = keyed.map(([, descriptor]) => descriptor);
229
270
  for (const descriptor of candidates) {
230
271
  const prepared = prepareRequest({
231
272
  descriptor, deployment: this.deployment, keyPrivate: this.keyPrivate, relaySigner: relay.signer,
@@ -234,6 +275,8 @@ export class Consumer {
234
275
  let reply;
235
276
  const streamed = [];
236
277
  let final = null;
278
+ const signer = descriptor.provider_signer;
279
+ this.inFlight.set(signer, (this.inFlight.get(signer) || 0) + 1);
237
280
  try {
238
281
  reply = onDelta
239
282
  ? await this.streamJson(`${relay.url}/v11/requests`, { body: prepared.payload, ca: this.network.tls_ca, pin: relay.pin, onLine: (line) => {
@@ -248,11 +291,15 @@ export class Consumer {
248
291
  } catch (error) {
249
292
  // The request may have reached the Relay: never replay it elsewhere.
250
293
  throw Object.assign(new Error(`request outcome unknown: ${error.message}`), { code: "outcome_unknown", requestId: prepared.authorization.request_id });
294
+ } finally {
295
+ this.inFlight.set(signer, (this.inFlight.get(signer) || 1) - 1);
251
296
  }
252
297
  if (reply.streamed) {
253
298
  if (!final) throw Object.assign(new Error("stream ended without a result"), { code: "outcome_unknown" });
254
- if (final.type === "error") throw Object.assign(new Error(final.error), { status: final.status, dispatched: final.dispatched });
255
- reply = { status: 200, body: final };
299
+ // An error before dispatch (a busy Provider, say) is safe to fail over, exactly as without streaming.
300
+ reply = final.type === "error"
301
+ ? { status: final.status || 502, body: { error: final.error, dispatched: final.dispatched } }
302
+ : { status: 200, body: final };
256
303
  }
257
304
  if (reply.status === 200) {
258
305
  const opened = openResponse(prepared, reply.body, this.deployment, this.now());
@@ -263,7 +310,8 @@ export class Consumer {
263
310
  if (this.journalDir) recordRequest(this.journalDir, prepared, opened, relay.url);
264
311
  return opened;
265
312
  }
266
- lastError = Object.assign(new Error(reply.body?.error || `Relay returned ${reply.status}`), { status: reply.status });
313
+ lastError = Object.assign(new Error(reply.body?.error || `Relay returned ${reply.status}`),
314
+ { status: reply.status, dispatched: reply.body?.dispatched });
267
315
  if (reply.body?.dispatched !== false || ![502, 503].includes(reply.status)) throw lastError;
268
316
  }
269
317
  }