mycomesh-consumer 0.9.9 → 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/bin/mycomesh-consumer.mjs +6 -2
- package/package.json +1 -1
- package/src/consumer.mjs +65 -15
- package/src/dialects.mjs +3 -1
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { main } from "../src/cli.mjs";
|
|
3
3
|
|
|
4
|
-
|
|
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
|
-
|
|
10
|
+
exit(1);
|
|
7
11
|
});
|
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|