orrery-node 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +137 -2
- package/dist/brand.js +11 -0
- package/dist/chain/program.js +146 -0
- package/dist/chain/voucher.js +38 -0
- package/dist/cli.js +190 -0
- package/dist/keys.js +35 -0
- package/dist/manifest.js +34 -0
- package/dist/pricing.js +22 -0
- package/dist/provider/node.js +352 -0
- package/dist/router/channels.js +130 -0
- package/dist/router/console.js +49 -0
- package/dist/router/policy.js +88 -0
- package/dist/router/registry.js +125 -0
- package/dist/router/server.js +256 -0
- package/dist/seal.js +49 -0
- package/dist/sse.js +41 -0
- package/dist/usage.js +7 -0
- package/package.json +65 -4
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
// A provider node: sells inference from any OpenAI-compatible upstream (vLLM, SGLang, Ollama, llama.cpp, or an API
|
|
2
|
+
// you hold capacity on) to routers on the network. It never sees a wallet signature per request: it checks the
|
|
3
|
+
// router's running-total voucher, serves, meters, and redeems the highest voucher on chain when it chooses.
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { createServer } from 'node:http';
|
|
7
|
+
import { Connection, Keypair, PublicKey, Transaction, sendAndConfirmTransaction } from '@solana/web3.js';
|
|
8
|
+
import { decodeChannel, ins, pda, programId } from "../chain/program.js";
|
|
9
|
+
import { decodeVoucher, redeemInstructions, verifyVoucher, encodeVoucher } from "../chain/voucher.js";
|
|
10
|
+
import { home, loadKeypair, loadOrCreateKeypair, writePrivate } from "../keys.js";
|
|
11
|
+
import { canonical, manifestHash } from "../manifest.js";
|
|
12
|
+
import { costOf, estimateInputTokens, usd } from "../pricing.js";
|
|
13
|
+
import { encrypt, enclaveKeyFromSecret, newEnclaveKey, openRequest } from "../seal.js";
|
|
14
|
+
import { readSse, sseFrame, fetchWithTimeout } from "../sse.js";
|
|
15
|
+
import { nextUsageHash, ZERO_HASH } from "../usage.js";
|
|
16
|
+
const json = (res, status, body, headers = {}) => {
|
|
17
|
+
res.writeHead(status, { 'content-type': 'application/json', ...headers });
|
|
18
|
+
res.end(JSON.stringify(body, (_, v) => (typeof v === 'bigint' ? v.toString() : v)));
|
|
19
|
+
};
|
|
20
|
+
const fail = (res, status, code, message, extra = {}) => json(res, status, { error: { code, message, ...extra } });
|
|
21
|
+
async function readBody(req, limit = 4 * 1024 * 1024) {
|
|
22
|
+
const parts = [];
|
|
23
|
+
let n = 0;
|
|
24
|
+
for await (const c of req) {
|
|
25
|
+
n += c.length;
|
|
26
|
+
if (n > limit)
|
|
27
|
+
throw new Error('body too large');
|
|
28
|
+
parts.push(c);
|
|
29
|
+
}
|
|
30
|
+
return Buffer.concat(parts);
|
|
31
|
+
}
|
|
32
|
+
const expand = (p) => p.replace(/^~(?=$|\/)/, process.env.HOME ?? '~');
|
|
33
|
+
// Only standard chat fields reach the upstream. A gateway upstream (OpenRouter and the like) reads extra fields such
|
|
34
|
+
// as fallback model lists, plugins or provider routing, any of which would let a caller spend the operator's key on
|
|
35
|
+
// something the price list does not cover. `n` is dropped for the same reason: it multiplies the output.
|
|
36
|
+
const UPSTREAM_FIELDS = [
|
|
37
|
+
'messages', 'temperature', 'top_p', 'stop', 'seed', 'presence_penalty', 'frequency_penalty', 'response_format',
|
|
38
|
+
'tools', 'tool_choice', 'parallel_tool_calls', 'logit_bias', 'logprobs', 'top_logprobs', 'user',
|
|
39
|
+
];
|
|
40
|
+
export function upstreamBody(body, model, maxOut) {
|
|
41
|
+
const out = {};
|
|
42
|
+
for (const k of UPSTREAM_FIELDS)
|
|
43
|
+
if (body[k] !== undefined)
|
|
44
|
+
out[k] = body[k];
|
|
45
|
+
return { ...out, model, stream: true, stream_options: { include_usage: true }, max_tokens: maxOut };
|
|
46
|
+
}
|
|
47
|
+
function secretFromEnv(name, what) {
|
|
48
|
+
const v = process.env[name];
|
|
49
|
+
if (!v)
|
|
50
|
+
throw new Error(`${what}: environment variable ${name} is not set`);
|
|
51
|
+
return v;
|
|
52
|
+
}
|
|
53
|
+
export class ProviderNode {
|
|
54
|
+
cfg;
|
|
55
|
+
conn;
|
|
56
|
+
node;
|
|
57
|
+
authority;
|
|
58
|
+
pda;
|
|
59
|
+
mint;
|
|
60
|
+
manifest;
|
|
61
|
+
manifestBytes;
|
|
62
|
+
enclave;
|
|
63
|
+
attestation;
|
|
64
|
+
apiKey;
|
|
65
|
+
ledger = new Map();
|
|
66
|
+
server = null;
|
|
67
|
+
timer = null;
|
|
68
|
+
dir;
|
|
69
|
+
log = (l) => console.log(l);
|
|
70
|
+
constructor(cfg) {
|
|
71
|
+
this.cfg = cfg;
|
|
72
|
+
this.conn = new Connection(cfg.rpcUrl, 'confirmed');
|
|
73
|
+
this.dir = expand(cfg.dataDir ?? home('provider'));
|
|
74
|
+
this.node = cfg.nodeKeyEnv
|
|
75
|
+
? Keypair.fromSecretKey(Uint8Array.from(JSON.parse(secretFromEnv(cfg.nodeKeyEnv, 'node key'))))
|
|
76
|
+
: loadOrCreateKeypair(expand(cfg.nodeKey ?? `${this.dir}/node.json`));
|
|
77
|
+
this.apiKey = cfg.upstream.apiKey ?? (cfg.upstream.apiKeyEnv ? secretFromEnv(cfg.upstream.apiKeyEnv, 'upstream key') : undefined);
|
|
78
|
+
this.authority = cfg.authority ? new PublicKey(cfg.authority) : loadKeypair(expand(cfg.wallet ?? `${this.dir}/wallet.json`)).publicKey;
|
|
79
|
+
this.pda = pda.provider(this.authority);
|
|
80
|
+
this.mint = new PublicKey(cfg.settleMint);
|
|
81
|
+
if (cfg.tee) {
|
|
82
|
+
const keyPath = `${this.dir}/enclave.key`;
|
|
83
|
+
this.enclave = existsSync(keyPath) ? enclaveKeyFromSecret(Buffer.from(readFileSync(keyPath, 'utf8').trim(), 'hex')) : newEnclaveKey();
|
|
84
|
+
if (!existsSync(keyPath))
|
|
85
|
+
writePrivate(keyPath, this.enclave.secret.toString('hex'));
|
|
86
|
+
const kind = cfg.tee.kind ?? 1;
|
|
87
|
+
const measurement = createHash('sha384').update(`orrery-dev-image:${this.node.publicKey.toBase58()}`).digest();
|
|
88
|
+
const report = Buffer.from(JSON.stringify({ dev: true, note: 'software stand-in for a hardware quote; routers must not treat it as verified', kind, measurement: measurement.toString('hex'), enclaveKey: this.enclave.publicKey.toString('hex'), node: this.node.publicKey.toBase58() }));
|
|
89
|
+
this.attestation = { kind, measurement, report, reportHash: createHash('sha256').update(report).digest() };
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
this.enclave = null;
|
|
93
|
+
this.attestation = null;
|
|
94
|
+
}
|
|
95
|
+
this.manifest = {
|
|
96
|
+
v: 1,
|
|
97
|
+
name: cfg.name,
|
|
98
|
+
authority: this.authority.toBase58(),
|
|
99
|
+
nodeKey: this.node.publicKey.toBase58(),
|
|
100
|
+
region: cfg.region,
|
|
101
|
+
models: cfg.models.map(({ upstreamModel: _u, ...o }) => o),
|
|
102
|
+
tee: this.enclave ? { kind: this.attestation.kind, enclaveKey: this.enclave.publicKey.toString('hex') } : undefined,
|
|
103
|
+
};
|
|
104
|
+
this.manifestBytes = canonical(this.manifest);
|
|
105
|
+
this.load();
|
|
106
|
+
}
|
|
107
|
+
get manifestHash() {
|
|
108
|
+
return manifestHash(this.manifestBytes);
|
|
109
|
+
}
|
|
110
|
+
ledgerPath() {
|
|
111
|
+
return `${this.dir}/ledger.json`;
|
|
112
|
+
}
|
|
113
|
+
load() {
|
|
114
|
+
if (!existsSync(this.ledgerPath()))
|
|
115
|
+
return;
|
|
116
|
+
const raw = JSON.parse(readFileSync(this.ledgerPath(), 'utf8'));
|
|
117
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
118
|
+
this.raw.set(k, Buffer.from(v.channel, 'base64'));
|
|
119
|
+
this.ledger.set(k, {
|
|
120
|
+
channel: decodeChannel(Buffer.from(v.channel, 'base64')),
|
|
121
|
+
checkedAt: 0,
|
|
122
|
+
owed: BigInt(v.owed),
|
|
123
|
+
best: v.best ? decodeVoucher(v.best) : undefined,
|
|
124
|
+
usageHash: Buffer.from(v.usageHash, 'hex'),
|
|
125
|
+
redeemed: BigInt(v.redeemed),
|
|
126
|
+
unpaid: v.unpaid.map((r) => ({ ...r, cost: BigInt(r.cost), owedAfter: BigInt(r.owedAfter) })),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
raw = new Map();
|
|
131
|
+
save() {
|
|
132
|
+
const out = {};
|
|
133
|
+
for (const [k, e] of this.ledger) {
|
|
134
|
+
out[k] = {
|
|
135
|
+
owed: e.owed.toString(), best: e.best ? encodeVoucher(e.best) : undefined, usageHash: e.usageHash.toString('hex'), redeemed: e.redeemed.toString(),
|
|
136
|
+
channel: (this.raw.get(k) ?? Buffer.alloc(0)).toString('base64'),
|
|
137
|
+
unpaid: e.unpaid.map((r) => ({ ...r, cost: r.cost.toString(), owedAfter: r.owedAfter.toString() })),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
writePrivate(this.ledgerPath(), JSON.stringify(out));
|
|
141
|
+
}
|
|
142
|
+
async entry(address, force = false) {
|
|
143
|
+
const have = this.ledger.get(address);
|
|
144
|
+
if (have && !force && Date.now() - have.checkedAt < 30_000)
|
|
145
|
+
return have;
|
|
146
|
+
const info = await this.conn.getAccountInfo(new PublicKey(address));
|
|
147
|
+
if (!info)
|
|
148
|
+
throw Object.assign(new Error('channel not found on chain'), { code: 'unknown_channel', status: 404 });
|
|
149
|
+
if (!info.owner.equals(programId()))
|
|
150
|
+
throw Object.assign(new Error('not a channel'), { code: 'unknown_channel', status: 400 });
|
|
151
|
+
const ch = decodeChannel(info.data);
|
|
152
|
+
if (!ch.provider.equals(this.pda))
|
|
153
|
+
throw Object.assign(new Error('channel is for another provider'), { code: 'wrong_provider', status: 400 });
|
|
154
|
+
if (!ch.mint.equals(this.mint))
|
|
155
|
+
throw Object.assign(new Error('channel is in another mint'), { code: 'wrong_mint', status: 400 });
|
|
156
|
+
this.raw.set(address, Buffer.from(info.data));
|
|
157
|
+
const e = have ?? { channel: ch, checkedAt: 0, owed: 0n, usageHash: ZERO_HASH, unpaid: [], redeemed: ch.redeemed };
|
|
158
|
+
e.channel = ch;
|
|
159
|
+
e.checkedAt = Date.now();
|
|
160
|
+
if (ch.redeemed > e.redeemed)
|
|
161
|
+
e.redeemed = ch.redeemed;
|
|
162
|
+
this.ledger.set(address, e);
|
|
163
|
+
return e;
|
|
164
|
+
}
|
|
165
|
+
accept(address, e, v) {
|
|
166
|
+
if (!verifyVoucher(e.channel.sessionKey, new PublicKey(address), v))
|
|
167
|
+
throw Object.assign(new Error('voucher signature does not match the channel session key'), { code: 'bad_voucher', status: 401 });
|
|
168
|
+
if (!e.best || v.cumulative > e.best.cumulative) {
|
|
169
|
+
e.best = v;
|
|
170
|
+
e.unpaid = e.unpaid.filter((r) => r.owedAfter > v.cumulative);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async handle(req, res) {
|
|
174
|
+
const url = new URL(req.url ?? '/', 'http://x');
|
|
175
|
+
try {
|
|
176
|
+
if (req.method === 'GET' && url.pathname === '/v1/manifest') {
|
|
177
|
+
res.writeHead(200, { 'content-type': 'application/json', 'x-orrery-manifest-sha256': this.manifestHash.toString('hex') });
|
|
178
|
+
return void res.end(this.manifestBytes);
|
|
179
|
+
}
|
|
180
|
+
if (req.method === 'GET' && url.pathname === '/v1/attestation') {
|
|
181
|
+
if (!this.attestation || !this.enclave)
|
|
182
|
+
return fail(res, 404, 'no_tee', 'this node does not run in an enclave');
|
|
183
|
+
return json(res, 200, { kind: this.attestation.kind, measurement: this.attestation.measurement.toString('hex'), enclaveKey: this.enclave.publicKey.toString('hex'), report: this.attestation.report.toString('base64'), reportHash: this.attestation.reportHash.toString('hex') });
|
|
184
|
+
}
|
|
185
|
+
if (req.method === 'GET' && url.pathname === '/health') {
|
|
186
|
+
return json(res, 200, { ok: true, provider: this.pda.toBase58(), models: this.manifest.models.length, channels: this.ledger.size });
|
|
187
|
+
}
|
|
188
|
+
if (req.method === 'POST' && url.pathname === '/orrery/voucher') {
|
|
189
|
+
const b = JSON.parse((await readBody(req)).toString('utf8'));
|
|
190
|
+
const e = await this.entry(b.channel);
|
|
191
|
+
this.accept(b.channel, e, decodeVoucher(b.voucher));
|
|
192
|
+
this.save();
|
|
193
|
+
return json(res, 200, { paid: e.best?.cumulative ?? 0n, owed: e.owed });
|
|
194
|
+
}
|
|
195
|
+
if (req.method === 'POST' && url.pathname === '/v1/chat/completions')
|
|
196
|
+
return await this.complete(req, res);
|
|
197
|
+
return fail(res, 404, 'not_found', 'no such route');
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
const e = err;
|
|
201
|
+
if (!res.headersSent)
|
|
202
|
+
return fail(res, e.status ?? 500, e.code ?? 'internal', e.message);
|
|
203
|
+
res.end();
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
async complete(req, res) {
|
|
207
|
+
const address = String(req.headers['x-orrery-channel'] ?? '');
|
|
208
|
+
if (!address)
|
|
209
|
+
return fail(res, 402, 'channel_required', 'open a channel to this provider and send x-orrery-channel');
|
|
210
|
+
let body = JSON.parse((await readBody(req)).toString('utf8'));
|
|
211
|
+
let sealKey = null;
|
|
212
|
+
if (body.sealed) {
|
|
213
|
+
if (!this.enclave)
|
|
214
|
+
return fail(res, 400, 'no_tee', 'sealed request to a node without an enclave');
|
|
215
|
+
const opened = openRequest(this.enclave, body);
|
|
216
|
+
body = JSON.parse(opened.plaintext.toString('utf8'));
|
|
217
|
+
sealKey = opened.key;
|
|
218
|
+
}
|
|
219
|
+
const offer = this.cfg.models.find((m) => m.id === body.model);
|
|
220
|
+
if (!offer)
|
|
221
|
+
return fail(res, 404, 'model_not_offered', `this node does not serve ${String(body.model)}`);
|
|
222
|
+
const e = await this.entry(address);
|
|
223
|
+
const now = BigInt(Math.floor(Date.now() / 1000));
|
|
224
|
+
if (e.channel.closingAt !== 0n && now > e.channel.closingAt - 120n)
|
|
225
|
+
return fail(res, 409, 'channel_closing', 'channel is closing; open a new one');
|
|
226
|
+
const vh = req.headers['x-orrery-voucher'];
|
|
227
|
+
if (vh) {
|
|
228
|
+
const v = decodeVoucher(String(vh));
|
|
229
|
+
if (v.cumulative > e.channel.deposit)
|
|
230
|
+
await this.entry(address, true);
|
|
231
|
+
this.accept(address, e, v);
|
|
232
|
+
}
|
|
233
|
+
const paid = e.best?.cumulative ?? 0n;
|
|
234
|
+
if (paid < e.owed)
|
|
235
|
+
return fail(res, 402, 'payment_required', 'previous requests are not paid for', { owed: e.owed.toString(), paid: paid.toString(), unpaid: e.unpaid.map((r) => ({ ...r, cost: r.cost.toString(), owedAfter: r.owedAfter.toString() })) });
|
|
236
|
+
const askedOut = Number(body.max_completion_tokens ?? body.max_tokens ?? offer.maxOutput);
|
|
237
|
+
const maxOut = Math.max(1, Math.min(Number.isFinite(askedOut) ? askedOut : offer.maxOutput, offer.maxOutput));
|
|
238
|
+
const maxCost = costOf(offer.price, estimateInputTokens(body), maxOut);
|
|
239
|
+
if (e.owed + maxCost > e.channel.deposit) {
|
|
240
|
+
await this.entry(address, true);
|
|
241
|
+
if (e.owed + maxCost > e.channel.deposit)
|
|
242
|
+
return fail(res, 402, 'channel_exhausted', 'not enough deposit left for this request', { owed: e.owed.toString(), deposit: e.channel.deposit.toString(), need: maxCost.toString() });
|
|
243
|
+
}
|
|
244
|
+
const upstream = await fetchWithTimeout(`${this.cfg.upstream.baseUrl.replace(/\/$/, '')}/chat/completions`, {
|
|
245
|
+
method: 'POST',
|
|
246
|
+
timeoutMs: 30_000,
|
|
247
|
+
headers: { 'content-type': 'application/json', ...(this.apiKey ? { authorization: `Bearer ${this.apiKey}` } : {}), ...(this.cfg.upstream.headers ?? {}) },
|
|
248
|
+
body: JSON.stringify({ ...(this.cfg.upstream.body ?? {}), ...upstreamBody(body, offer.upstreamModel ?? offer.id, maxOut) }),
|
|
249
|
+
});
|
|
250
|
+
if (!upstream.ok || !upstream.body)
|
|
251
|
+
return fail(res, 502, 'upstream_error', `upstream answered ${upstream.status}`);
|
|
252
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', 'x-orrery-sealed': sealKey ? '1' : '0' });
|
|
253
|
+
const send = (data, event) => res.write(sseFrame(sealKey ? encrypt(sealKey, Buffer.from(data)).toString('base64') : data, event));
|
|
254
|
+
let usage = null;
|
|
255
|
+
let chars = 0;
|
|
256
|
+
for await (const ev of readSse(upstream.body)) {
|
|
257
|
+
if (ev.data === '[DONE]')
|
|
258
|
+
break;
|
|
259
|
+
let chunk;
|
|
260
|
+
try {
|
|
261
|
+
chunk = JSON.parse(ev.data);
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (chunk.usage)
|
|
267
|
+
usage = chunk.usage;
|
|
268
|
+
for (const c of chunk.choices ?? [])
|
|
269
|
+
chars += c.delta?.content?.length ?? 0;
|
|
270
|
+
chunk.model = offer.id;
|
|
271
|
+
send(JSON.stringify(chunk));
|
|
272
|
+
}
|
|
273
|
+
const inputTokens = usage?.prompt_tokens ?? estimateInputTokens(body);
|
|
274
|
+
const outputTokens = Math.min(usage?.completion_tokens ?? Math.ceil(chars / 4), maxOut);
|
|
275
|
+
const cost = costOf(offer.price, inputTokens, outputTokens);
|
|
276
|
+
const record = { requestId: randomUUID(), model: offer.id, inputTokens, outputTokens, cost: cost > maxCost ? maxCost : cost };
|
|
277
|
+
e.owed += record.cost;
|
|
278
|
+
e.usageHash = nextUsageHash(e.usageHash, record);
|
|
279
|
+
e.unpaid.push({ ...record, owedAfter: e.owed });
|
|
280
|
+
this.save();
|
|
281
|
+
send(JSON.stringify({ ...record, cost: record.cost.toString(), owed: e.owed.toString(), usageHash: e.usageHash.toString('hex') }), 'orrery');
|
|
282
|
+
res.write(sseFrame('[DONE]'));
|
|
283
|
+
res.end();
|
|
284
|
+
this.log(`served ${offer.id} ${inputTokens}+${outputTokens} tok $${usd(record.cost)} channel ${address.slice(0, 6)} owed $${usd(e.owed)}${sealKey ? ' sealed' : ''}`);
|
|
285
|
+
}
|
|
286
|
+
/** Redeem every channel whose best voucher is ahead of what is on chain, a few channels per transaction. */
|
|
287
|
+
async redeemDue(minUnits = BigInt(this.cfg.redeem?.minUnits ?? 1)) {
|
|
288
|
+
const due = [];
|
|
289
|
+
for (const [k, e] of this.ledger) {
|
|
290
|
+
if (!e.best || e.best.cumulative <= e.redeemed)
|
|
291
|
+
continue;
|
|
292
|
+
const pending = (e.best.cumulative < e.channel.deposit ? e.best.cumulative : e.channel.deposit) - e.redeemed;
|
|
293
|
+
const closing = e.channel.closingAt !== 0n;
|
|
294
|
+
if (pending >= minUnits || (closing && pending > 0n))
|
|
295
|
+
due.push([k, e]);
|
|
296
|
+
}
|
|
297
|
+
const done = [];
|
|
298
|
+
for (let i = 0; i < due.length; i += 3) {
|
|
299
|
+
const batch = due.slice(i, i + 3);
|
|
300
|
+
const tx = new Transaction();
|
|
301
|
+
for (const [k, e] of batch) {
|
|
302
|
+
tx.add(...redeemInstructions({ caller: this.node.publicKey, providerAuthority: this.authority, channel: new PublicKey(k), mint: this.mint, sessionKey: e.channel.sessionKey, voucher: e.best }));
|
|
303
|
+
}
|
|
304
|
+
try {
|
|
305
|
+
const sig = await sendAndConfirmTransaction(this.conn, tx, [this.node], { commitment: 'confirmed' });
|
|
306
|
+
for (const [k, e] of batch) {
|
|
307
|
+
const before = e.redeemed;
|
|
308
|
+
await this.entry(k, true).catch(() => undefined);
|
|
309
|
+
e.redeemed = e.channel.redeemed;
|
|
310
|
+
const amount = e.redeemed - before;
|
|
311
|
+
done.push({ channel: k, amount, tx: sig });
|
|
312
|
+
this.log(`redeemed $${usd(amount)} from channel ${k.slice(0, 6)} tx ${sig.slice(0, 12)}…`);
|
|
313
|
+
}
|
|
314
|
+
this.save();
|
|
315
|
+
}
|
|
316
|
+
catch (err) {
|
|
317
|
+
this.log(`redeem failed: ${err.message.split('\n')[0]}`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return done;
|
|
321
|
+
}
|
|
322
|
+
registerInstruction(wallet) {
|
|
323
|
+
return ins.registerProvider(wallet, this.node.publicKey, this.cfg.publicUrl, this.manifestHash);
|
|
324
|
+
}
|
|
325
|
+
updateInstruction(wallet) {
|
|
326
|
+
return ins.updateProvider(wallet, { nodeKey: this.node.publicKey, endpoint: this.cfg.publicUrl, manifestHash: this.manifestHash, active: true });
|
|
327
|
+
}
|
|
328
|
+
attestInstruction(validSecs = 7 * 86_400) {
|
|
329
|
+
if (!this.attestation || !this.enclave)
|
|
330
|
+
throw new Error('no enclave configured');
|
|
331
|
+
return ins.attest(this.node.publicKey, this.authority, {
|
|
332
|
+
kind: this.attestation.kind,
|
|
333
|
+
measurement: this.attestation.measurement,
|
|
334
|
+
reportHash: this.attestation.reportHash,
|
|
335
|
+
teeKey: this.enclave.publicKey,
|
|
336
|
+
expiresAt: BigInt(Math.floor(Date.now() / 1000) + validSecs),
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
listen() {
|
|
340
|
+
return new Promise((resolve) => {
|
|
341
|
+
this.server = createServer((req, res) => void this.handle(req, res));
|
|
342
|
+
this.server.listen(this.cfg.listen, this.cfg.host ?? '0.0.0.0', () => resolve(this.server));
|
|
343
|
+
const every = (this.cfg.redeem?.everySecs ?? 60) * 1000;
|
|
344
|
+
this.timer = setInterval(() => void this.redeemDue(), every);
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
close() {
|
|
348
|
+
if (this.timer)
|
|
349
|
+
clearInterval(this.timer);
|
|
350
|
+
return new Promise((r) => (this.server ? this.server.close(() => r()) : r()));
|
|
351
|
+
}
|
|
352
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// The router's side of each payment channel. The wallet signs once to open (and to top up); after that a session
|
|
2
|
+
// key on this computer signs a running total after every request, and nothing else is needed per request.
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { PublicKey, Transaction, sendAndConfirmTransaction } from '@solana/web3.js';
|
|
6
|
+
import { decodeChannel, ins, pda } from "../chain/program.js";
|
|
7
|
+
import { encodeVoucher, signVoucher } from "../chain/voucher.js";
|
|
8
|
+
import { signerOf, writePrivate } from "../keys.js";
|
|
9
|
+
import { nextUsageHash, ZERO_HASH } from "../usage.js";
|
|
10
|
+
export class Channels {
|
|
11
|
+
conn;
|
|
12
|
+
wallet;
|
|
13
|
+
session;
|
|
14
|
+
mint;
|
|
15
|
+
path;
|
|
16
|
+
opts;
|
|
17
|
+
map = new Map();
|
|
18
|
+
opening = new Map();
|
|
19
|
+
sign;
|
|
20
|
+
spend = { day: '', units: 0n };
|
|
21
|
+
constructor(conn, wallet, session, mint, path, opts) {
|
|
22
|
+
this.conn = conn;
|
|
23
|
+
this.wallet = wallet;
|
|
24
|
+
this.session = session;
|
|
25
|
+
this.mint = mint;
|
|
26
|
+
this.path = path;
|
|
27
|
+
this.opts = opts;
|
|
28
|
+
this.sign = signerOf(session);
|
|
29
|
+
this.load();
|
|
30
|
+
}
|
|
31
|
+
load() {
|
|
32
|
+
if (!existsSync(this.path))
|
|
33
|
+
return;
|
|
34
|
+
const raw = JSON.parse(readFileSync(this.path, 'utf8'));
|
|
35
|
+
for (const c of raw.channels ?? []) {
|
|
36
|
+
this.map.set(c.provider, { ...c, deposit: BigInt(c.deposit), cumulative: BigInt(c.cumulative), usageHash: Buffer.from(c.usageHash, 'hex'), checkedAt: 0 });
|
|
37
|
+
}
|
|
38
|
+
if (raw.spend)
|
|
39
|
+
this.spend = { day: raw.spend.day, units: BigInt(raw.spend.units) };
|
|
40
|
+
}
|
|
41
|
+
save() {
|
|
42
|
+
const channels = [...this.map.values()].map((c) => ({ ...c, deposit: c.deposit.toString(), cumulative: c.cumulative.toString(), usageHash: c.usageHash.toString('hex') }));
|
|
43
|
+
writePrivate(this.path, JSON.stringify({ channels, spend: { day: this.spend.day, units: this.spend.units.toString() } }, null, 1));
|
|
44
|
+
}
|
|
45
|
+
list() {
|
|
46
|
+
return [...this.map.values()];
|
|
47
|
+
}
|
|
48
|
+
get(provider) {
|
|
49
|
+
return this.map.get(provider);
|
|
50
|
+
}
|
|
51
|
+
remaining(provider) {
|
|
52
|
+
const c = this.map.get(provider);
|
|
53
|
+
return c ? c.deposit - c.cumulative : 0n;
|
|
54
|
+
}
|
|
55
|
+
todaySpent() {
|
|
56
|
+
const day = new Date().toISOString().slice(0, 10);
|
|
57
|
+
if (this.spend.day !== day)
|
|
58
|
+
this.spend = { day, units: 0n };
|
|
59
|
+
return this.spend.units;
|
|
60
|
+
}
|
|
61
|
+
overBudget(next) {
|
|
62
|
+
return this.todaySpent() + next > this.opts.dailyCap;
|
|
63
|
+
}
|
|
64
|
+
/** The open channel to `provider`, opening one with the wallet if there is none or the old one is closing. */
|
|
65
|
+
async ensure(provider, need) {
|
|
66
|
+
const have = this.map.get(provider);
|
|
67
|
+
if (have && Date.now() - have.checkedAt < 5 * 60_000 && have.deposit - have.cumulative >= need)
|
|
68
|
+
return have;
|
|
69
|
+
if (have) {
|
|
70
|
+
const info = await this.conn.getAccountInfo(new PublicKey(have.channel));
|
|
71
|
+
if (info) {
|
|
72
|
+
const ch = decodeChannel(info.data);
|
|
73
|
+
if (ch.closingAt === 0n) {
|
|
74
|
+
have.deposit = ch.deposit;
|
|
75
|
+
have.checkedAt = Date.now();
|
|
76
|
+
if (have.deposit - have.cumulative < need)
|
|
77
|
+
await this.topUp(provider, need);
|
|
78
|
+
return have;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
this.map.delete(provider);
|
|
82
|
+
}
|
|
83
|
+
const pending = this.opening.get(provider);
|
|
84
|
+
if (pending)
|
|
85
|
+
return pending;
|
|
86
|
+
const p = this.open(provider, need > this.opts.deposit ? need : this.opts.deposit).finally(() => this.opening.delete(provider));
|
|
87
|
+
this.opening.set(provider, p);
|
|
88
|
+
return p;
|
|
89
|
+
}
|
|
90
|
+
async open(provider, deposit) {
|
|
91
|
+
if (!this.wallet)
|
|
92
|
+
throw new Error('no wallet configured: the router needs one to open a channel (orrery router --wallet <keypair.json>)');
|
|
93
|
+
const id = randomBytes(8).readBigUInt64LE();
|
|
94
|
+
const providerKey = new PublicKey(provider);
|
|
95
|
+
const tx = new Transaction().add(ins.openChannel(this.wallet.publicKey, providerKey, this.mint, id, deposit, this.session.publicKey));
|
|
96
|
+
await sendAndConfirmTransaction(this.conn, tx, [this.wallet], { commitment: 'confirmed' });
|
|
97
|
+
const state = {
|
|
98
|
+
provider, channel: pda.channel(this.wallet.publicKey, providerKey, id).toBase58(), id: id.toString(), deposit, cumulative: 0n,
|
|
99
|
+
usageHash: ZERO_HASH, voucher: null, openedAt: Date.now(), checkedAt: Date.now(),
|
|
100
|
+
};
|
|
101
|
+
this.map.set(provider, state);
|
|
102
|
+
this.save();
|
|
103
|
+
return state;
|
|
104
|
+
}
|
|
105
|
+
async topUp(provider, atLeast) {
|
|
106
|
+
const c = this.map.get(provider);
|
|
107
|
+
if (!c || !this.wallet)
|
|
108
|
+
return;
|
|
109
|
+
const amount = atLeast > this.opts.deposit ? atLeast : this.opts.deposit;
|
|
110
|
+
const tx = new Transaction().add(ins.topUp(this.wallet.publicKey, new PublicKey(c.channel), this.mint, amount));
|
|
111
|
+
await sendAndConfirmTransaction(this.conn, tx, [this.wallet], { commitment: 'confirmed' });
|
|
112
|
+
c.deposit += amount;
|
|
113
|
+
this.save();
|
|
114
|
+
}
|
|
115
|
+
/** Pay for one finished request: extend the usage chain, sign the new total, and return the voucher. */
|
|
116
|
+
settle(provider, r) {
|
|
117
|
+
const c = this.map.get(provider);
|
|
118
|
+
if (!c)
|
|
119
|
+
throw new Error('no channel');
|
|
120
|
+
c.cumulative += r.cost;
|
|
121
|
+
c.usageHash = nextUsageHash(c.usageHash, r);
|
|
122
|
+
c.voucher = encodeVoucher(signVoucher(this.sign, new PublicKey(c.channel), c.cumulative, c.usageHash));
|
|
123
|
+
this.todaySpent();
|
|
124
|
+
this.spend.units += r.cost;
|
|
125
|
+
this.save();
|
|
126
|
+
if (c.deposit - c.cumulative < this.opts.topUpBelow)
|
|
127
|
+
void this.topUp(provider, this.opts.deposit).catch(() => undefined);
|
|
128
|
+
return c.voucher;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The page at http://127.0.0.1:7171/ — what the router did, read from /orrery/status. Plain HTML, no build step.
|
|
2
|
+
import { NAME } from "../brand.js";
|
|
3
|
+
export function consolePage() {
|
|
4
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
5
|
+
<title>${NAME} router</title>
|
|
6
|
+
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
7
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@400;500&family=Instrument+Sans:wght@400;500;600&display=swap">
|
|
8
|
+
<style>
|
|
9
|
+
:root{--bg:#000;--card:#0c0c0c;--well:#070707;--line:#1c1c1c;--line2:#2e2e2e;--fg:#f7f7f7;--fg2:#c6c6c6;--fg3:#9c9c9c;--green:#14f195;--purple:#b98aff;--warn:#f0a868;
|
|
10
|
+
--sans:'Instrument Sans','Helvetica Neue',Arial,sans-serif;--mono:'Geist Mono',ui-monospace,SFMono-Regular,Menlo,monospace;color-scheme:dark}
|
|
11
|
+
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 var(--sans);-webkit-font-smoothing:antialiased;padding:0 clamp(16px,4vw,48px) 64px}
|
|
12
|
+
header{display:flex;align-items:center;gap:12px;height:64px;border-bottom:1px solid var(--line);margin:0 calc(-1*clamp(16px,4vw,48px)) 32px;padding:0 clamp(16px,4vw,48px)}
|
|
13
|
+
.brand{display:inline-flex;align-items:center;gap:10px;font-weight:500;font-size:17px;letter-spacing:-.02em}
|
|
14
|
+
.grad{background-image:linear-gradient(90deg,#9945ff,#5f8ff2 50%,#14f195);-webkit-background-clip:text;background-clip:text;color:transparent}
|
|
15
|
+
.here{color:var(--fg3);font-size:14px}.live{margin-left:auto;display:inline-flex;align-items:center;gap:8px;font:400 12px var(--mono);color:var(--fg2)}
|
|
16
|
+
.live i{width:7px;height:7px;border-radius:50%;background:var(--green);box-shadow:0 0 0 3px rgba(20,241,149,.15);animation:b 2.4s ease-in-out infinite}@keyframes b{50%{opacity:.35}}
|
|
17
|
+
main{max-width:1180px;margin:0 auto;display:grid;gap:36px}
|
|
18
|
+
h1{margin:0;font-size:clamp(28px,3.2vw,40px);font-weight:500;letter-spacing:-.03em;line-height:1.08}h1 span{color:var(--fg3)}
|
|
19
|
+
.lede{margin:10px 0 0;color:var(--fg2);max-width:46em}.lede code{font:400 13px var(--mono);color:var(--fg)}
|
|
20
|
+
.stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));border:1px solid var(--line);border-radius:16px;background:var(--card);overflow:hidden}
|
|
21
|
+
.stats div{padding:18px 20px;display:flex;flex-direction:column-reverse;gap:6px}.stats div+div{border-left:1px solid var(--line)}
|
|
22
|
+
.stats b{font:500 22px var(--mono);letter-spacing:0}.cap{font:500 11px/1.4 var(--mono);letter-spacing:.16em;text-transform:uppercase;color:var(--fg3)}
|
|
23
|
+
section h2{margin:0 0 12px;font:500 11px/1.4 var(--mono);letter-spacing:.16em;text-transform:uppercase;color:var(--fg3)}
|
|
24
|
+
.wrap{overflow-x:auto;border:1px solid var(--line);border-radius:16px;background:var(--card)}
|
|
25
|
+
table{width:100%;border-collapse:collapse;font:400 13px/1.5 var(--mono);font-variant-numeric:tabular-nums}
|
|
26
|
+
th,td{text-align:left;padding:12px 16px;border-bottom:1px solid var(--line);white-space:nowrap}tbody tr:last-child td{border-bottom:0}
|
|
27
|
+
th{color:var(--fg3);font-weight:500;font-size:10.5px;text-transform:uppercase;letter-spacing:.14em;background:var(--well)}
|
|
28
|
+
td{color:var(--fg2)}td:first-child{color:var(--fg)}tbody tr:hover td{background:#101010}
|
|
29
|
+
.go{color:var(--green)}.stop{color:var(--warn)}.sig{color:var(--purple)}.empty td{color:var(--fg3);font-family:var(--sans);font-size:14px;padding:22px 16px}
|
|
30
|
+
@media (max-width:760px){.stats{grid-template-columns:repeat(2,minmax(0,1fr))}.stats div:nth-child(3){border-left:0}.stats div:nth-child(n+3){border-top:1px solid var(--line)}.live{display:none}}
|
|
31
|
+
</style></head><body>
|
|
32
|
+
<header><span class="brand"><svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true"><defs><linearGradient id="g" x1="2" y1="18" x2="22" y2="6" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#9945ff"/><stop offset=".5" stop-color="#5f8ff2"/><stop offset="1" stop-color="#14f195"/></linearGradient></defs><ellipse cx="12" cy="12" rx="10.2" ry="4.3" fill="none" stroke="url(#g)" stroke-width="1.3"/><ellipse cx="12" cy="12" rx="10.2" ry="4.3" fill="none" stroke="url(#g)" stroke-width="1.3" opacity=".6" transform="rotate(62 12 12)"/><circle cx="12" cy="12" r="2.5" fill="#f5f5f5"/><circle cx="20.6" cy="9.7" r="1.7" fill="#14f195"/></svg><span class="grad">${NAME}</span></span><span class="here">Router console</span><span class="live"><i></i>live from this computer</span></header>
|
|
33
|
+
<main>
|
|
34
|
+
<div><h1><span>Your router.</span> Every request, straight to a provider.</h1><p class="lede">Point any OpenAI-compatible tool at <code id="base">http://127.0.0.1/v1</code>. Each answer is paid from your channel to the machine that served it; nothing in between.</p></div>
|
|
35
|
+
<div class="stats" id="sum"></div>
|
|
36
|
+
<section><h2>Recent requests</h2><div class="wrap"><table><thead><tr><th>time</th><th>route</th><th>model</th><th>provider</th><th>tokens</th><th>paid</th><th>first token</th></tr></thead><tbody id="trips"></tbody></table></div></section>
|
|
37
|
+
<section><h2>Providers</h2><div class="wrap"><table><thead><tr><th>name</th><th>models</th><th>settled on chain</th><th>enclave</th><th>seen</th><th>state</th></tr></thead><tbody id="prov"></tbody></table></div></section>
|
|
38
|
+
<section><h2>Channels</h2><div class="wrap"><table><thead><tr><th>provider</th><th>channel</th><th>deposit</th><th>spent</th><th>left</th></tr></thead><tbody id="ch"></tbody></table></div></section>
|
|
39
|
+
</main><script>
|
|
40
|
+
const esc=s=>String(s??'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
|
41
|
+
async function tick(){try{const s=await (await fetch('/orrery/status')).json();
|
|
42
|
+
document.getElementById('base').textContent='http://127.0.0.1:'+location.port+'/v1';
|
|
43
|
+
document.getElementById('sum').innerHTML='<div><b>'+esc(s.route)+'</b><span class=cap>default route</span></div><div><b class=go>$'+esc(s.spentToday)+'</b><span class=cap>spent today</span></div><div><b>'+s.providers.length+'</b><span class=cap>providers</span></div><div><b>'+s.channels.length+'</b><span class=cap>open channels</span></div>';
|
|
44
|
+
document.getElementById('trips').innerHTML=s.trips.map(t=>'<tr><td>'+new Date(t.at).toLocaleTimeString()+'</td><td>'+esc(t.route)+(t.sealed?' <span class=go>sealed</span>':'')+'</td><td>'+esc(t.model)+'</td><td>'+esc(t.providerName)+(t.fellThrough?' <span class=sig>after '+t.fellThrough+' failed</span>':'')+'</td><td>'+t.inputTokens+'+'+t.outputTokens+'</td><td class=go>$'+esc(t.cost)+'</td><td>'+t.ttftMs+' ms</td></tr>').join('')||'<tr class=empty><td colspan=7>No requests yet. Point a tool at http://127.0.0.1:'+location.port+'/v1</td></tr>';
|
|
45
|
+
document.getElementById('prov').innerHTML=s.providers.map(p=>'<tr><td>'+esc(p.name||p.address.slice(0,8))+'</td><td>'+p.models+'</td><td>$'+esc(p.settled)+' · '+p.redeems+' redeems</td><td>'+(p.tee?(p.tee.verified?'<span class=go>verified</span>':'<span class=stop>'+esc(p.tee.reason)+'</span>'):'—')+'</td><td>'+p.observed.ok+' ok / '+p.observed.fail+' failed</td><td>'+(p.problem?'<span class=stop>'+esc(p.problem)+'</span>':p.active?'<span class=go>open</span>':'closed')+'</td></tr>').join('');
|
|
46
|
+
document.getElementById('ch').innerHTML=s.channels.map(c=>'<tr><td>'+esc(c.provider.slice(0,8))+'</td><td>'+esc(c.channel.slice(0,8))+'</td><td>$'+esc(c.deposit)+'</td><td>$'+esc(c.spent)+'</td><td>$'+esc(c.left)+'</td></tr>').join('')||'<tr class=empty><td colspan=5>None yet. The first request to a provider opens one.</td></tr>';
|
|
47
|
+
}catch(e){}}tick();setInterval(tick,2000);
|
|
48
|
+
</script></body></html>`;
|
|
49
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { costOf } from "../pricing.js";
|
|
2
|
+
export const ROUTES = ['auto', 'price', 'speed', 'reputation', 'privacy'];
|
|
3
|
+
const SUFFIX = { cheap: 'price', floor: 'price', fast: 'speed', trusted: 'reputation', private: 'privacy' };
|
|
4
|
+
/** `qwen3-32b:private` → model qwen3-32b on the privacy route. */
|
|
5
|
+
export function parseModel(raw, fallback) {
|
|
6
|
+
const at = raw.lastIndexOf(':');
|
|
7
|
+
if (at > 0) {
|
|
8
|
+
const s = raw.slice(at + 1).toLowerCase();
|
|
9
|
+
if (SUFFIX[s])
|
|
10
|
+
return { model: raw.slice(0, at), route: SUFFIX[s] };
|
|
11
|
+
if (ROUTES.includes(s))
|
|
12
|
+
return { model: raw.slice(0, at), route: s };
|
|
13
|
+
}
|
|
14
|
+
return { model: raw, route: fallback };
|
|
15
|
+
}
|
|
16
|
+
export class Stats {
|
|
17
|
+
m = new Map();
|
|
18
|
+
get(address) {
|
|
19
|
+
return this.m.get(address) ?? { ttftMs: null, ok: 0, fail: 0, cooldownUntil: 0 };
|
|
20
|
+
}
|
|
21
|
+
ok(address, ttftMs) {
|
|
22
|
+
const o = this.get(address);
|
|
23
|
+
o.ttftMs = o.ttftMs === null ? ttftMs : Math.round(o.ttftMs * 0.7 + ttftMs * 0.3);
|
|
24
|
+
o.ok += 1;
|
|
25
|
+
this.m.set(address, o);
|
|
26
|
+
}
|
|
27
|
+
fail(address) {
|
|
28
|
+
const o = this.get(address);
|
|
29
|
+
o.fail += 1;
|
|
30
|
+
o.cooldownUntil = Date.now() + Math.min(300_000, 15_000 * 2 ** Math.min(4, o.fail - 1));
|
|
31
|
+
this.m.set(address, o);
|
|
32
|
+
}
|
|
33
|
+
all() {
|
|
34
|
+
return this.m;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const log01 = (x, cap) => Math.min(1, Math.log10(1 + Math.max(0, x)) / Math.log10(1 + cap));
|
|
38
|
+
/** 0..1 from what the chain proves (money actually settled, how often, for how long, what is bonded) and what this
|
|
39
|
+
* router saw itself. Settled volume is weighted most because it is the only input that cost someone real USDC. */
|
|
40
|
+
export function reputation(e, o, maxBond) {
|
|
41
|
+
const a = e.account;
|
|
42
|
+
const settled = log01(Number(a.settled) / 1e6, 1_000);
|
|
43
|
+
const redeems = log01(Number(a.redeems), 1_000);
|
|
44
|
+
const days = (Date.now() / 1000 - Number(a.registeredAt)) / 86_400;
|
|
45
|
+
const age = Math.min(1, Math.max(0, days) / 90);
|
|
46
|
+
const bond = maxBond > 0n ? Math.sqrt(Number((a.bonded * 1000000n) / maxBond) / 1_000_000) : 0;
|
|
47
|
+
const seen = o.ok + o.fail;
|
|
48
|
+
const local = seen === 0 ? 0.6 : o.ok / seen;
|
|
49
|
+
return 0.35 * settled + 0.15 * redeems + 0.15 * age + 0.1 * bond + 0.25 * local;
|
|
50
|
+
}
|
|
51
|
+
export function rank(offers, route, estIn, estOut, stats, minReputation = 0) {
|
|
52
|
+
const now = Date.now();
|
|
53
|
+
const maxBond = offers.reduce((m, o) => (o.entry.account.bonded > m ? o.entry.account.bonded : m), 0n);
|
|
54
|
+
let c = offers
|
|
55
|
+
.filter((o) => stats.get(o.entry.address).cooldownUntil <= now)
|
|
56
|
+
.map(({ entry, offer }) => {
|
|
57
|
+
const o = stats.get(entry.address);
|
|
58
|
+
return {
|
|
59
|
+
entry,
|
|
60
|
+
offer,
|
|
61
|
+
expected: costOf(offer.price, estIn, Math.min(estOut, offer.maxOutput)),
|
|
62
|
+
ttftMs: o.ttftMs,
|
|
63
|
+
reputation: reputation(entry, o, maxBond),
|
|
64
|
+
sealed: route === 'privacy',
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
if (route === 'privacy')
|
|
68
|
+
c = c.filter((x) => x.entry.tee?.verified);
|
|
69
|
+
const byCost = (a, b) => (a.expected === b.expected ? b.reputation - a.reputation : a.expected < b.expected ? -1 : 1);
|
|
70
|
+
const ttft = (x) => x.ttftMs ?? 1_500;
|
|
71
|
+
switch (route) {
|
|
72
|
+
case 'price':
|
|
73
|
+
case 'privacy':
|
|
74
|
+
return c.sort(byCost);
|
|
75
|
+
case 'speed':
|
|
76
|
+
return c.sort((a, b) => ttft(a) - ttft(b) || byCost(a, b));
|
|
77
|
+
case 'reputation':
|
|
78
|
+
return c.sort((a, b) => b.reputation - a.reputation || byCost(a, b));
|
|
79
|
+
case 'auto': {
|
|
80
|
+
const trusted = c.filter((x) => x.reputation >= minReputation);
|
|
81
|
+
const pool = trusted.length ? trusted : c;
|
|
82
|
+
const minCost = pool.reduce((m, x) => (x.expected < m ? x.expected : m), pool[0]?.expected ?? 0n);
|
|
83
|
+
const minT = Math.min(...pool.map(ttft));
|
|
84
|
+
const score = (x) => 0.6 * (minCost > 0n ? Number(x.expected) / Number(minCost) : 1) + 0.25 * (ttft(x) / Math.max(1, minT)) + 0.15 * (1 - x.reputation) * 3;
|
|
85
|
+
return pool.sort((a, b) => score(a) - score(b));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|