mycomesh-consumer 0.9.10 → 0.9.11

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.11",
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];
@@ -407,6 +454,7 @@ export class Consumer {
407
454
  }
408
455
  // Only this machine can ever reveal the plaintexts, so keep them for the dispute window.
409
456
  if (this.journalDir) recordRequest(this.journalDir, prepared, opened, relay.url);
457
+ this.remember(conversation, signer);
410
458
  return opened;
411
459
  }
412
460
  lastError = Object.assign(new Error(reply.body?.error || `Relay returned ${reply.status}`),
@@ -450,7 +498,8 @@ async function dialectRequest(req, res, active, path, setWriter) {
450
498
  const gemini = geminiRoute(path);
451
499
  const ask = gemini ? fromGemini(body, gemini.model) : fromAnthropic(body);
452
500
  const stream = gemini ? gemini.stream : ask.stream;
453
- const request = { endpoint: "chat", model: ask.model, content: ask.messages, maxOutputTokens: ask.maxOutputTokens, options: ask.options };
501
+ const request = { endpoint: "chat", model: ask.model, content: ask.messages, maxOutputTokens: ask.maxOutputTokens, options: ask.options,
502
+ session: ask.session || req.headers["x-claude-code-session-id"] || req.headers["session_id"] || req.headers["x-session-id"] || undefined };
454
503
  if (!stream) {
455
504
  const { response, receipt } = await active.request(request);
456
505
  const answer = outputText(response.output);
@@ -524,8 +573,9 @@ export function serveConsumer(consumer, { host = "127.0.0.1", port = 8110, apiKe
524
573
  delete rest.max_output_tokens;
525
574
  delete rest.max_tokens;
526
575
  delete rest.max_completion_tokens;
576
+ const session = req.headers["session_id"] || req.headers["x-session-id"] || undefined;
527
577
  if (!stream) {
528
- const { response } = await active.request({ endpoint: chat ? "chat" : "responses", model, content, maxOutputTokens, options: rest });
578
+ const { response } = await active.request({ endpoint: chat ? "chat" : "responses", model, content, maxOutputTokens, options: rest, session });
529
579
  return send(200, response.output);
530
580
  }
531
581
  // Headers go out with the first delta, so a request that fails before dispatch still gets an HTTP error.
@@ -535,7 +585,7 @@ export function serveConsumer(consumer, { host = "127.0.0.1", port = 8110, apiKe
535
585
  };
536
586
  writer = chat ? chatStream(write, { model }, { includeUsage: streamOptions?.include_usage === true }) : responseStream(write, { model });
537
587
  const { response } = await active.request({ endpoint: chat ? "chat" : "responses", model, content, maxOutputTokens,
538
- options: rest, onDelta: (text) => writer.delta(text) });
588
+ options: rest, session, onDelta: (text) => writer.delta(text) });
539
589
  writer.finish(response.output);
540
590
  return res.end();
541
591
  } 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) {