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 +1 -1
- package/src/consumer.mjs +70 -15
- package/src/dialects.mjs +3 -1
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];
|
|
@@ -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
|
-
|
|
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) {
|