mycomesh-consumer 0.9.10 → 0.9.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mycomesh-consumer",
3
- "version": "0.9.10",
3
+ "version": "0.9.12",
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,4 +1,5 @@
1
1
  // V11 Consumer runtime: one custodied deposit, any Provider, relay-blind requests.
2
+ import { createHash } from "node:crypto";
2
3
  import { readFileSync } from "node:fs";
3
4
  import { Agent as HttpAgent, createServer, request as httpRequest } from "node:http";
4
5
  import { Agent as HttpsAgent, request as httpsRequest } from "node:https";
@@ -42,6 +43,29 @@ class Cached {
42
43
  }
43
44
  }
44
45
 
46
+ // Session affinity: a conversation goes back to the Provider that answered its last turn, whose upstream
47
+ // account holds the conversation's prompt cache (measured: a new Codex thread with the same 10k-token prefix
48
+ // read 9,728 tokens from cache on the same account, none on another).
49
+ const SESSION_LIMIT = 10_000;
50
+ const SESSION_IDLE_MS = 3_600_000;
51
+
52
+ /**
53
+ * The conversation a request continues: the client's own session id when it sends one (a `session_id` header,
54
+ * OpenAI `prompt_cache_key`, Anthropic `metadata.user_id`), else the conversation's opening (instructions,
55
+ * system messages and the first user message), which every later turn sends again unchanged.
56
+ */
57
+ export function sessionKey({ endpoint, model, content, options = {}, session }) {
58
+ const explicit = session || options.prompt_cache_key;
59
+ if (explicit) return `session:${model}:${explicit}`;
60
+ const messages = Array.isArray(content) ? content : [{ role: "user", content }];
61
+ const head = [];
62
+ for (const message of messages) {
63
+ head.push(message);
64
+ if (message?.role === "user" || typeof message === "string") break;
65
+ }
66
+ return `head:${createHash("sha256").update(JSON.stringify([endpoint, model, options.instructions ?? null, head])).digest("hex")}`;
67
+ }
68
+
45
69
  // Consumers of one network (a node and its tenants) share what they discover.
46
70
  const DISCOVERY = new WeakMap();
47
71
  const VERIFIED = new WeakMap(); // descriptor -> the times its verification holds
@@ -206,6 +230,7 @@ export class Consumer {
206
230
  this.streamJson = streamJson;
207
231
  this.journalDir = journalDir;
208
232
  this.inFlight = new Map(); // Provider signer -> requests this Consumer has with it now
233
+ this.sessions = new Map(); // session key -> { provider, at }, least recently used first
209
234
  this.network = network;
210
235
  this.keyPrivate = keyPrivate;
211
236
  this.key = addressOf(keyPrivate);
@@ -339,9 +364,41 @@ export class Consumer {
339
364
  }
340
365
  }
341
366
 
342
- async attempt({ endpoint, model, content, maxOutputTokens = 4096, options = {}, provider, onDelta }) {
367
+ /**
368
+ * What the chain proves ranks first (recent fraud, a suspected downgrade, verified probe failures); then
369
+ * Providers this Consumer is not already keeping busy; then the Provider holding this conversation's cache;
370
+ * then reputation in coarse bands, shuffled within a band so that many Consumers spread over the network
371
+ * instead of all queueing at its single best Provider.
372
+ */
373
+ rank(candidates, records, preferred) {
374
+ const key = (descriptor) => {
375
+ const [fraud, downgrade, failure, volume] = rankKey(descriptor, records[descriptor.provider_signer]);
376
+ const busy = (this.inFlight.get(descriptor.provider_signer) || 0) >= Math.max(1, Number(descriptor.capacity) || 1) ? 1 : 0;
377
+ const sticky = descriptor.provider_signer === preferred ? 0 : 1;
378
+ return [fraud, downgrade, busy, sticky, failure, -Math.floor(Math.log10(1 - volume)), Math.random()];
379
+ };
380
+ const keyed = candidates.map((descriptor) => [key(descriptor), descriptor]);
381
+ keyed.sort((a, b) => compareRank(a[0], b[0]));
382
+ return keyed.map(([, descriptor]) => descriptor);
383
+ }
384
+
385
+ /** The Provider that answered this conversation last, if recently. */
386
+ stickyProvider(session) {
387
+ const entry = this.sessions.get(session);
388
+ return entry && Date.now() - entry.at < SESSION_IDLE_MS ? entry.provider : undefined;
389
+ }
390
+
391
+ remember(session, provider) {
392
+ this.sessions.delete(session);
393
+ this.sessions.set(session, { provider, at: Date.now() });
394
+ if (this.sessions.size > SESSION_LIMIT) this.sessions.delete(this.sessions.keys().next().value);
395
+ }
396
+
397
+ async attempt({ endpoint, model, content, maxOutputTokens = 4096, options = {}, provider, session, onDelta }) {
343
398
  let lastError;
344
399
  let raisedFee = 0;
400
+ const conversation = sessionKey({ endpoint, model, content, options, session });
401
+ const preferred = this.stickyProvider(conversation);
345
402
  for (const relay of await this.relays()) {
346
403
  let candidates;
347
404
  try {
@@ -349,18 +406,8 @@ export class Consumer {
349
406
  && (!provider || descriptor.provider_signer === provider.toLowerCase()));
350
407
  }
351
408
  catch (error) { lastError = error; continue; }
352
- // What the chain proves ranks first (recent fraud, a suspected downgrade, verified probe failures); then
353
- // Providers this Consumer is not already keeping busy; then reputation in coarse bands, shuffled within a
354
- // band so that many Consumers spread over the network instead of all queueing at its single best Provider.
355
409
  const records = await this.reputations({ waitMs: REPUTATION_WAIT_MS }).catch(() => ({}));
356
- const key = (descriptor) => {
357
- const [fraud, downgrade, failure, volume] = rankKey(descriptor, records[descriptor.provider_signer]);
358
- const busy = (this.inFlight.get(descriptor.provider_signer) || 0) >= Math.max(1, Number(descriptor.capacity) || 1) ? 1 : 0;
359
- return [fraud, downgrade, busy, failure, -Math.floor(Math.log10(1 - volume)), Math.random()];
360
- };
361
- const keyed = candidates.map((descriptor) => [key(descriptor), descriptor]);
362
- keyed.sort((a, b) => compareRank(a[0], b[0]));
363
- candidates = keyed.map(([, descriptor]) => descriptor);
410
+ candidates = this.rank(candidates, records, preferred);
364
411
  let retried = false;
365
412
  for (let index = 0; index < candidates.length; index += 1) {
366
413
  const descriptor = candidates[index];
@@ -395,6 +442,11 @@ export class Consumer {
395
442
  }
396
443
  if (reply.streamed) {
397
444
  if (!final) throw Object.assign(new Error("stream ended without a result"), { code: "outcome_unknown" });
445
+ // Part of an answer already reached the client and cannot be taken back: never start it again, here
446
+ // or at another Provider, or the client would read half an answer followed by another one.
447
+ if (final.type === "error" && streamed.length) {
448
+ throw Object.assign(new Error(`the answer was cut off: ${final.error}`), { status: 502, dispatched: true });
449
+ }
398
450
  // An error before dispatch (a busy Provider, say) is safe to fail over, exactly as without streaming.
399
451
  reply = final.type === "error"
400
452
  ? { status: final.status || 502, body: { error: final.error, dispatched: final.dispatched } }
@@ -407,6 +459,7 @@ export class Consumer {
407
459
  }
408
460
  // Only this machine can ever reveal the plaintexts, so keep them for the dispute window.
409
461
  if (this.journalDir) recordRequest(this.journalDir, prepared, opened, relay.url);
462
+ this.remember(conversation, signer);
410
463
  return opened;
411
464
  }
412
465
  lastError = Object.assign(new Error(reply.body?.error || `Relay returned ${reply.status}`),
@@ -450,7 +503,8 @@ async function dialectRequest(req, res, active, path, setWriter) {
450
503
  const gemini = geminiRoute(path);
451
504
  const ask = gemini ? fromGemini(body, gemini.model) : fromAnthropic(body);
452
505
  const stream = gemini ? gemini.stream : ask.stream;
453
- const request = { endpoint: "chat", model: ask.model, content: ask.messages, maxOutputTokens: ask.maxOutputTokens, options: ask.options };
506
+ const request = { endpoint: "chat", model: ask.model, content: ask.messages, maxOutputTokens: ask.maxOutputTokens, options: ask.options,
507
+ session: ask.session || req.headers["x-claude-code-session-id"] || req.headers["session_id"] || req.headers["x-session-id"] || undefined };
454
508
  if (!stream) {
455
509
  const { response, receipt } = await active.request(request);
456
510
  const answer = outputText(response.output);
@@ -524,8 +578,9 @@ export function serveConsumer(consumer, { host = "127.0.0.1", port = 8110, apiKe
524
578
  delete rest.max_output_tokens;
525
579
  delete rest.max_tokens;
526
580
  delete rest.max_completion_tokens;
581
+ const session = req.headers["session_id"] || req.headers["x-session-id"] || undefined;
527
582
  if (!stream) {
528
- const { response } = await active.request({ endpoint: chat ? "chat" : "responses", model, content, maxOutputTokens, options: rest });
583
+ const { response } = await active.request({ endpoint: chat ? "chat" : "responses", model, content, maxOutputTokens, options: rest, session });
529
584
  return send(200, response.output);
530
585
  }
531
586
  // Headers go out with the first delta, so a request that fails before dispatch still gets an HTTP error.
@@ -535,7 +590,7 @@ export function serveConsumer(consumer, { host = "127.0.0.1", port = 8110, apiKe
535
590
  };
536
591
  writer = chat ? chatStream(write, { model }, { includeUsage: streamOptions?.include_usage === true }) : responseStream(write, { model });
537
592
  const { response } = await active.request({ endpoint: chat ? "chat" : "responses", model, content, maxOutputTokens,
538
- options: rest, onDelta: (text) => writer.delta(text) });
593
+ options: rest, session, onDelta: (text) => writer.delta(text) });
539
594
  writer.finish(response.output);
540
595
  return res.end();
541
596
  } catch (error) {
package/src/dialects.mjs CHANGED
@@ -40,7 +40,9 @@ export function fromAnthropic(body) {
40
40
  const options = {};
41
41
  for (const key of ["temperature", "top_p"]) if (body[key] !== undefined) options[key] = body[key];
42
42
  if (body.stop_sequences) options.stop = body.stop_sequences;
43
- return { model: body.model, messages, maxOutputTokens: body.max_tokens ?? 4096, options, stream: body.stream === true };
43
+ // Claude Code puts its session in metadata.user_id: a conversation's turns share it.
44
+ const session = typeof body.metadata?.user_id === "string" ? body.metadata.user_id : undefined;
45
+ return { model: body.model, messages, maxOutputTokens: body.max_tokens ?? 4096, options, stream: body.stream === true, session };
44
46
  }
45
47
 
46
48
  export function toAnthropic(model, answer, receipt) {