mycomesh-consumer 0.9.8 → 0.9.9
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 +134 -40
- package/src/reputation.mjs +6 -5
package/package.json
CHANGED
package/src/consumer.mjs
CHANGED
|
@@ -16,9 +16,35 @@ import { hashApiKey } from "./tenants.mjs";
|
|
|
16
16
|
import { anthropicError, anthropicStream, fromAnthropic, fromGemini, geminiError, geminiRoute, geminiStream,
|
|
17
17
|
toAnthropic, toGemini } from "./dialects.mjs";
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const
|
|
19
|
+
// Discovery is served from cache and refreshed in the background once it is older than its fresh period; only
|
|
20
|
+
// data older than its usable period (or none yet) makes a request wait for it.
|
|
21
|
+
const PROVIDER_CACHE_MS = [30_000, 300_000];
|
|
22
|
+
const RELAY_CACHE_MS = [300_000, 3_600_000];
|
|
23
|
+
const REPUTATION_CACHE_MS = [600_000, 3_600_000];
|
|
24
|
+
const REPUTATION_WAIT_MS = 3_000; // a first request ranks without reputation rather than wait longer for it
|
|
25
|
+
const WARM_EVERY_MS = 20_000; // serve: keep discovery fresh and a connection to every Relay open
|
|
26
|
+
|
|
27
|
+
/** A value loaded once at a time, served stale while it is refreshed. */
|
|
28
|
+
class Cached {
|
|
29
|
+
constructor(load, [freshMs, usableMs]) { Object.assign(this, { load, freshMs, usableMs, at: 0, value: undefined, pending: null }); }
|
|
30
|
+
|
|
31
|
+
get() {
|
|
32
|
+
const age = Date.now() - this.at;
|
|
33
|
+
if (this.value !== undefined && age < this.freshMs) return Promise.resolve(this.value);
|
|
34
|
+
if (this.value !== undefined && age < this.usableMs) { this.refresh().catch(() => {}); return Promise.resolve(this.value); }
|
|
35
|
+
return this.refresh();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
refresh() {
|
|
39
|
+
this.pending ||= this.load().then((value) => { Object.assign(this, { value, at: Date.now() }); return value; })
|
|
40
|
+
.finally(() => { this.pending = null; });
|
|
41
|
+
return this.pending;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Consumers of one network (a node and its tenants) share what they discover.
|
|
46
|
+
const DISCOVERY = new WeakMap();
|
|
47
|
+
const VERIFIED = new WeakMap(); // descriptor -> the times its verification holds
|
|
22
48
|
const MAX_BODY = 16 * 1024 * 1024;
|
|
23
49
|
|
|
24
50
|
export function loadNetwork(path) {
|
|
@@ -29,30 +55,63 @@ export function loadNetwork(path) {
|
|
|
29
55
|
}
|
|
30
56
|
|
|
31
57
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
58
|
+
* Kept-alive connections, one agent per Relay origin (and pin): a request to a Relay this Consumer talked to in
|
|
59
|
+
* the last half minute skips the TCP and TLS handshakes (several round trips across the world). Idle sockets
|
|
60
|
+
* are dropped after `keepAlive.idleMs`, well inside the Relay edge's own keep-alive (nginx: 75 s), and a request
|
|
61
|
+
* whose reused socket turns out to have been closed by the Relay before any reply is sent again on a fresh
|
|
62
|
+
* one: a closed idle connection never carried it, and the Relay and Provider refuse a settlement key twice
|
|
63
|
+
* anyway. (Only an agent's own createConnection is honoured, hence the pinned one here.)
|
|
35
64
|
*/
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
65
|
+
export const keepAlive = { idleMs: 30_000 };
|
|
66
|
+
const AGENTS = new Map();
|
|
67
|
+
|
|
68
|
+
function tlsOptions(target, ca, pin) {
|
|
69
|
+
const key = `${target.protocol}//${target.host}#${pin || ""}#${ca ? "ca" : ""}`;
|
|
70
|
+
let options = AGENTS.get(key);
|
|
71
|
+
if (!options) {
|
|
72
|
+
const settings = { keepAlive: true, timeout: keepAlive.idleMs, scheduling: "lifo" };
|
|
73
|
+
if (target.protocol !== "https:") options = { agent: new HttpAgent(settings) };
|
|
74
|
+
else {
|
|
75
|
+
const agent = new HttpsAgent(settings);
|
|
76
|
+
if (pin) agent.createConnection = pinnedOptions(target.href, pin).createConnection;
|
|
77
|
+
options = pin || !ca ? { agent } : { agent, ca: [...rootCertificates, ca] };
|
|
78
|
+
}
|
|
79
|
+
AGENTS.set(key, options);
|
|
42
80
|
}
|
|
43
|
-
return
|
|
81
|
+
return options;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Drop every kept-alive connection (tests, shutdown). */
|
|
85
|
+
export function closeConnections() {
|
|
86
|
+
for (const { agent } of AGENTS.values()) agent.destroy();
|
|
87
|
+
AGENTS.clear();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const STALE = new Set(["ECONNRESET", "EPIPE", "ECONNABORTED"]);
|
|
91
|
+
|
|
92
|
+
/** Send once; again if a reused connection was already closed by the Relay before anything came back. */
|
|
93
|
+
function withFreshRetry(send) {
|
|
94
|
+
return new Promise((resolvePromise, reject) => {
|
|
95
|
+
const attempt = (tries) => send((error, req, answered) => {
|
|
96
|
+
if (error && req?.reusedSocket && !answered && STALE.has(error.code) && tries < 3) return attempt(tries + 1);
|
|
97
|
+
return error ? reject(error) : undefined;
|
|
98
|
+
}, resolvePromise);
|
|
99
|
+
attempt(0);
|
|
100
|
+
});
|
|
44
101
|
}
|
|
45
102
|
|
|
46
103
|
export async function httpJson(url, { method = "GET", body, ca, pin, timeoutMs = 330_000 } = {}) {
|
|
47
104
|
const target = new URL(url);
|
|
48
105
|
const data = body === undefined ? undefined : Buffer.from(JSON.stringify(body));
|
|
49
106
|
const send = target.protocol === "https:" ? httpsRequest : httpRequest;
|
|
50
|
-
const tls =
|
|
51
|
-
return
|
|
107
|
+
const tls = tlsOptions(target, ca, pin);
|
|
108
|
+
return withFreshRetry((fail, resolvePromise) => {
|
|
109
|
+
let answered = false;
|
|
52
110
|
const req = send(target, {
|
|
53
111
|
method, headers: { accept: "application/json", ...(data ? { "content-type": "application/json", "content-length": data.length } : {}) },
|
|
54
112
|
...tls,
|
|
55
113
|
}, (res) => {
|
|
114
|
+
answered = true;
|
|
56
115
|
const chunks = [];
|
|
57
116
|
let size = 0;
|
|
58
117
|
res.on("data", (chunk) => {
|
|
@@ -62,14 +121,14 @@ export async function httpJson(url, { method = "GET", body, ca, pin, timeoutMs =
|
|
|
62
121
|
});
|
|
63
122
|
res.on("end", () => {
|
|
64
123
|
try { resolvePromise({ status: res.statusCode, body: JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}") }); }
|
|
65
|
-
catch (error) {
|
|
124
|
+
catch (error) { fail(error, req, true); }
|
|
66
125
|
});
|
|
67
126
|
});
|
|
68
127
|
// A wall-clock deadline, not a socket idle timeout: keep-alive sockets are reused, and an idle timeout
|
|
69
128
|
// left on one by an earlier short request (a 10 s /providers call) would later cut a long answer.
|
|
70
129
|
const timer = setTimeout(() => req.destroy(new Error(`request to ${target.host}${target.pathname} timed out after ${timeoutMs / 1000} s`)), timeoutMs);
|
|
71
130
|
req.on("close", () => clearTimeout(timer));
|
|
72
|
-
req.on("error",
|
|
131
|
+
req.on("error", (error) => fail(error, req, answered));
|
|
73
132
|
if (data) req.write(data);
|
|
74
133
|
req.end();
|
|
75
134
|
});
|
|
@@ -80,13 +139,15 @@ export async function httpNdjson(url, { body, ca, pin, timeoutMs = 340_000, onLi
|
|
|
80
139
|
const target = new URL(url);
|
|
81
140
|
const data = Buffer.from(JSON.stringify(body));
|
|
82
141
|
const send = target.protocol === "https:" ? httpsRequest : httpRequest;
|
|
83
|
-
const tls =
|
|
84
|
-
return
|
|
142
|
+
const tls = tlsOptions(target, ca, pin);
|
|
143
|
+
return withFreshRetry((fail, resolvePromise) => {
|
|
144
|
+
let answered = false;
|
|
85
145
|
const req = send(target, {
|
|
86
146
|
method: "POST",
|
|
87
147
|
headers: { accept: "application/x-ndjson", "content-type": "application/json", "content-length": data.length },
|
|
88
148
|
...tls,
|
|
89
149
|
}, (res) => {
|
|
150
|
+
answered = true;
|
|
90
151
|
const streaming = res.statusCode === 200 && String(res.headers["content-type"] || "").includes("ndjson");
|
|
91
152
|
let buffered = "";
|
|
92
153
|
const chunks = [];
|
|
@@ -105,14 +166,14 @@ export async function httpNdjson(url, { body, ca, pin, timeoutMs = 340_000, onLi
|
|
|
105
166
|
});
|
|
106
167
|
res.on("end", () => {
|
|
107
168
|
if (streaming) return resolvePromise({ status: 200, streamed: true });
|
|
108
|
-
try { resolvePromise({ status: res.statusCode, body: JSON.parse(chunks.join("") || "{}") }); } catch (error) {
|
|
169
|
+
try { resolvePromise({ status: res.statusCode, body: JSON.parse(chunks.join("") || "{}") }); } catch (error) { fail(error, req, true); }
|
|
109
170
|
});
|
|
110
171
|
});
|
|
111
172
|
// A wall-clock deadline, not a socket idle timeout: keep-alive sockets are reused, and an idle timeout
|
|
112
173
|
// left on one by an earlier short request (a 10 s /providers call) would later cut a long answer.
|
|
113
174
|
const timer = setTimeout(() => req.destroy(new Error(`request to ${target.host}${target.pathname} timed out after ${timeoutMs / 1000} s`)), timeoutMs);
|
|
114
175
|
req.on("close", () => clearTimeout(timer));
|
|
115
|
-
req.on("error",
|
|
176
|
+
req.on("error", (error) => fail(error, req, answered));
|
|
116
177
|
req.end(data);
|
|
117
178
|
});
|
|
118
179
|
}
|
|
@@ -152,32 +213,55 @@ export class Consumer {
|
|
|
152
213
|
this.fetchJson = fetchJson;
|
|
153
214
|
this.now = now;
|
|
154
215
|
this.deployment = { chainId: network.chain_id, settlement: network.settlement.toLowerCase() };
|
|
155
|
-
|
|
156
|
-
|
|
216
|
+
const shared = DISCOVERY.get(network) || new Map();
|
|
217
|
+
DISCOVERY.set(network, shared);
|
|
218
|
+
if (!shared.has(fetchJson)) {
|
|
219
|
+
shared.set(fetchJson, { relays: new Cached(() => this.loadRelays(), RELAY_CACHE_MS), providers: new Map(),
|
|
220
|
+
reputations: new Cached(() => this.loadReputations(), REPUTATION_CACHE_MS) });
|
|
221
|
+
}
|
|
222
|
+
this.discovery = shared.get(fetchJson);
|
|
157
223
|
}
|
|
158
224
|
|
|
159
225
|
/** On-chain reputation and verified probe verdicts for every Provider in view, cached. */
|
|
160
|
-
|
|
161
|
-
|
|
226
|
+
reputations({ waitMs = Infinity } = {}) {
|
|
227
|
+
const value = this.discovery.reputations.get();
|
|
228
|
+
if (!Number.isFinite(waitMs)) return value;
|
|
229
|
+
return Promise.race([value, new Promise((resolve) => setTimeout(resolve, waitMs, {}).unref())]);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async loadReputations() {
|
|
162
233
|
const descriptors = new Map();
|
|
163
234
|
const relaysByOwner = {};
|
|
164
|
-
|
|
235
|
+
await Promise.all((await this.relays()).map(async (relay) => {
|
|
165
236
|
try {
|
|
166
237
|
const owner = relay.owner || `0x${(await rpcCall(this.network.rpc_urls, "eth_call", [{ to: this.network.settlement,
|
|
167
238
|
data: encodeCall("relaySignerOwner(address)", [["address", relay.signer]]) }, "latest"])).slice(26)}`;
|
|
168
239
|
relaysByOwner[owner.toLowerCase()] = relay;
|
|
169
240
|
} catch {}
|
|
170
241
|
try { for (const descriptor of await this.providers(relay)) descriptors.set(descriptor.provider_signer, descriptor); } catch {}
|
|
171
|
-
}
|
|
242
|
+
}));
|
|
172
243
|
let value = {};
|
|
173
244
|
try { value = await reputation(this, [...descriptors.values()], relaysByOwner); } catch {}
|
|
174
|
-
this.reputationCache = { at: Date.now(), value };
|
|
175
245
|
return value;
|
|
176
246
|
}
|
|
177
247
|
|
|
248
|
+
/** Discover now what the first request would otherwise wait for; with `everyMs`, keep it fresh (serve). */
|
|
249
|
+
warm({ everyMs } = {}) {
|
|
250
|
+
const once = async () => {
|
|
251
|
+
try { await Promise.all((await this.relays()).map((relay) => this.providers(relay).catch(() => {}))); } catch {}
|
|
252
|
+
await this.reputations().catch(() => {});
|
|
253
|
+
};
|
|
254
|
+
const first = once();
|
|
255
|
+
if (everyMs) setInterval(once, everyMs).unref();
|
|
256
|
+
return first;
|
|
257
|
+
}
|
|
258
|
+
|
|
178
259
|
/** Manifest Relays first, then directory Relays whose /health proves the announced signer. */
|
|
179
|
-
|
|
180
|
-
|
|
260
|
+
relays() {
|
|
261
|
+
return this.discovery.relays.get();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async loadRelays() {
|
|
181
265
|
const relays = this.network.relays.map((relay) => ({ ...relay }));
|
|
182
266
|
const known = new Set(relays.map((relay) => relay.signer.toLowerCase()));
|
|
183
267
|
try {
|
|
@@ -199,21 +283,30 @@ export class Consumer {
|
|
|
199
283
|
} catch {}
|
|
200
284
|
}
|
|
201
285
|
} catch {}
|
|
202
|
-
this.relayCache = { at: Date.now(), relays };
|
|
203
286
|
return relays;
|
|
204
287
|
}
|
|
205
288
|
|
|
289
|
+
/** A Relay's Providers whose signed descriptors verify now (a cached list is checked again on every use). */
|
|
206
290
|
async providers(relay) {
|
|
207
|
-
|
|
208
|
-
if (
|
|
209
|
-
|
|
210
|
-
|
|
291
|
+
let entry = this.discovery.providers.get(relay.url);
|
|
292
|
+
if (!entry) {
|
|
293
|
+
entry = new Cached(async () => {
|
|
294
|
+
const { status, body } = await this.fetchJson(`${relay.url}/providers`, { ca: this.network.tls_ca, pin: relay.pin, timeoutMs: 10_000 });
|
|
295
|
+
if (status !== 200) throw new Error(`Relay ${relay.url} /providers returned ${status}`);
|
|
296
|
+
return body.providers || [];
|
|
297
|
+
}, PROVIDER_CACHE_MS);
|
|
298
|
+
this.discovery.providers.set(relay.url, entry);
|
|
299
|
+
}
|
|
211
300
|
const now = this.now();
|
|
212
|
-
|
|
213
|
-
|
|
301
|
+
return (await entry.get()).filter((descriptor) => {
|
|
302
|
+
// Signatures do not change: once verified, a descriptor stays good until its key or attestation expires.
|
|
303
|
+
const known = VERIFIED.get(descriptor);
|
|
304
|
+
if (known && known.from <= now && now < known.until) return true;
|
|
305
|
+
try { verifiedProvider(descriptor, this.deployment, now); } catch { return false; }
|
|
306
|
+
VERIFIED.set(descriptor, { from: now, until: Math.min(Number(descriptor.transport_key?.expires_at),
|
|
307
|
+
Number(descriptor.transport_attestation?.expires_at)) });
|
|
308
|
+
return true;
|
|
214
309
|
});
|
|
215
|
-
this.cache.set(relay.url, { at: Date.now(), providers });
|
|
216
|
-
return providers;
|
|
217
310
|
}
|
|
218
311
|
|
|
219
312
|
/** The network's model catalog (manifest "models"): vendor, tier, capabilities. */
|
|
@@ -259,7 +352,7 @@ export class Consumer {
|
|
|
259
352
|
// What the chain proves ranks first (recent fraud, a suspected downgrade, verified probe failures); then
|
|
260
353
|
// Providers this Consumer is not already keeping busy; then reputation in coarse bands, shuffled within a
|
|
261
354
|
// band so that many Consumers spread over the network instead of all queueing at its single best Provider.
|
|
262
|
-
const records = await this.reputations().catch(() => ({}));
|
|
355
|
+
const records = await this.reputations({ waitMs: REPUTATION_WAIT_MS }).catch(() => ({}));
|
|
263
356
|
const key = (descriptor) => {
|
|
264
357
|
const [fraud, downgrade, failure, volume] = rankKey(descriptor, records[descriptor.provider_signer]);
|
|
265
358
|
const busy = (this.inFlight.get(descriptor.provider_signer) || 0) >= Math.max(1, Number(descriptor.capacity) || 1) ? 1 : 0;
|
|
@@ -379,6 +472,7 @@ async function dialectRequest(req, res, active, path, setWriter) {
|
|
|
379
472
|
|
|
380
473
|
/** Local OpenAI-compatible endpoint (plus the Anthropic and Gemini dialects); nothing leaves this machine unsealed except pricing and routing. */
|
|
381
474
|
export function serveConsumer(consumer, { host = "127.0.0.1", port = 8110, apiKey, dataDir, tenants = () => ({}) } = {}) {
|
|
475
|
+
consumer.warm({ everyMs: WARM_EVERY_MS }).catch(() => {}); // tenants share it
|
|
382
476
|
const routes = dataDir ? consoleRoutes({ consumer, dataDir }) : {};
|
|
383
477
|
// Every dialect's way of presenting a key: OpenAI Bearer, Anthropic x-api-key, Gemini x-goog-api-key or ?key=.
|
|
384
478
|
const bearer = (req) => /^Bearer (.+)$/.exec(String(req.headers.authorization || ""))?.[1]
|
package/src/reputation.mjs
CHANGED
|
@@ -48,11 +48,12 @@ async function ledgerEvents(network) {
|
|
|
48
48
|
export async function reputation(consumer, descriptors, relaysByOwner) {
|
|
49
49
|
const network = consumer.network;
|
|
50
50
|
const records = {};
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
const [, events] = await Promise.all([
|
|
52
|
+
Promise.all(descriptors.map(async (descriptor) => {
|
|
53
|
+
try { records[descriptor.provider_signer] = await providerRecord(network, descriptor.provider_signer); } catch {}
|
|
54
|
+
})),
|
|
55
|
+
ledgerEvents(network).catch(() => []),
|
|
56
|
+
]);
|
|
56
57
|
const byOwner = {};
|
|
57
58
|
for (const record of Object.values(records)) {
|
|
58
59
|
byOwner[record.owner] = { ...record, probes_passed: 0, probes_failed_verified: 0, capability_passed: 0, capability_failed_verified: 0 };
|