mycomesh-consumer 0.4.0 → 0.5.0
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/networks/mycomesh-v11-sepolia.json +1 -0
- package/package.json +1 -1
- package/src/account.mjs +1 -1
- package/src/cli.mjs +48 -4
- package/src/console.mjs +20 -2
- package/src/consumer.mjs +58 -19
- package/src/probes.mjs +70 -0
- package/src/reputation.mjs +86 -0
- package/src/tenants.mjs +69 -0
- package/src/tlspin.mjs +36 -0
- package/src/web/console.html +87 -9
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
"stablecoin": "0xbce27efad4191277167fe76819581a97959048cf",
|
|
7
7
|
"registry": "0x0ff3210e8e8abf5f5bfa73a39e60db7416b6c8c7",
|
|
8
8
|
"relay_directory": "0xa0aaad9393f51cb5786c69f152133a174588bdab",
|
|
9
|
+
"probe_ledger": "0x30055eae6645cce8754f834a17689d20a3795ca3",
|
|
9
10
|
"deployment_block": 11809185,
|
|
10
11
|
"rpc_urls": [
|
|
11
12
|
"https://ethereum-sepolia-rpc.publicnode.com",
|
package/package.json
CHANGED
package/src/account.mjs
CHANGED
|
@@ -84,7 +84,7 @@ export async function dispute(network, ownerPrivate, record, { reasonCode, state
|
|
|
84
84
|
const accepted = [];
|
|
85
85
|
for (const relay of relays) {
|
|
86
86
|
try {
|
|
87
|
-
const reply = await httpJson(`${relay.url}/v11/evidence`, { method: "POST", body: evidence, ca: network.tls_ca, timeoutMs: 30_000 });
|
|
87
|
+
const reply = await httpJson(`${relay.url}/v11/evidence`, { method: "POST", body: evidence, ca: network.tls_ca, pin: relay.pin, timeoutMs: 30_000 });
|
|
88
88
|
if (reply.status === 200) accepted.push(relay.url);
|
|
89
89
|
} catch {}
|
|
90
90
|
}
|
package/src/cli.mjs
CHANGED
|
@@ -11,6 +11,9 @@ import { completeWithdrawal, dispute, faucet, requestWithdrawal, setup, walletSt
|
|
|
11
11
|
import { Consumer, loadNetwork, serveConsumer } from "./consumer.mjs";
|
|
12
12
|
import { createWallet, ownerAddress, ownerKey, walletAddress } from "./wallet.mjs";
|
|
13
13
|
import { loadRequest } from "./disputes.mjs";
|
|
14
|
+
import { addTenant, loadTenants, revokeTenant, setBudget, tenantKey, tenantStatus } from "./tenants.mjs";
|
|
15
|
+
import { spawn } from "node:child_process";
|
|
16
|
+
import { statSync } from "node:fs";
|
|
14
17
|
|
|
15
18
|
const DEFAULT_NETWORK = join(dirname(fileURLToPath(import.meta.url)), "../networks/mycomesh-v11-sepolia.json");
|
|
16
19
|
|
|
@@ -28,6 +31,9 @@ const USAGE = `Usage: mycomesh-consumer <command> [options]
|
|
|
28
31
|
request "prompt" [--stream]
|
|
29
32
|
send one request and print the verified answer
|
|
30
33
|
serve [--port 8110] run the local web console and OpenAI-compatible endpoint (default)
|
|
34
|
+
tenant add NAME [--budget UNITS] [--max-per-request UNITS]
|
|
35
|
+
multi-tenant accounts: a payment key and API key per tenant, capped on-chain
|
|
36
|
+
tenant list | tenant budget NAME UNITS | tenant revoke NAME
|
|
31
37
|
dispute <settlement-key|last> [--reason CODE] [--statement TEXT]
|
|
32
38
|
reveal a recorded request and response to a Provider-AI jury
|
|
33
39
|
(within 24 hours; the reporter bond is returned if fraud is confirmed)
|
|
@@ -68,7 +74,8 @@ export async function main(argv = process.argv.slice(2), { stdout = process.stdo
|
|
|
68
74
|
port: { type: "string", default: "8110" }, host: { type: "string", default: "127.0.0.1" },
|
|
69
75
|
"max-output-tokens": { type: "string", default: "4096" }, help: { type: "boolean" },
|
|
70
76
|
reason: { type: "string", default: "unrelated_response" }, faucet: { type: "boolean" }, provider: { type: "string" },
|
|
71
|
-
statement: { type: "string", default: "" }, stream: { type: "boolean" },
|
|
77
|
+
statement: { type: "string", default: "" }, stream: { type: "boolean" }, budget: { type: "string" },
|
|
78
|
+
"no-browser": { type: "boolean" },
|
|
72
79
|
},
|
|
73
80
|
});
|
|
74
81
|
const [command = "serve", ...rest] = positionals;
|
|
@@ -136,7 +143,23 @@ export async function main(argv = process.argv.slice(2), { stdout = process.stdo
|
|
|
136
143
|
stdout.write(`${JSON.stringify(result)}\n`);
|
|
137
144
|
return result.relays_accepted.length ? 0 : 2;
|
|
138
145
|
}
|
|
139
|
-
|
|
146
|
+
if (command === "tenant") {
|
|
147
|
+
const [action, name, amount] = rest;
|
|
148
|
+
if (action === "list") { stdout.write(`${JSON.stringify(await tenantStatus(network, dir), null, 2)}\n`); return 0; }
|
|
149
|
+
const owner = await ownerKey(values["owner-key-file"], dir);
|
|
150
|
+
if (action === "add") {
|
|
151
|
+
const created = await addTenant(network, dir, owner, name, {
|
|
152
|
+
maxPerRequest: BigInt(values["max-per-request"] || values["max-fee"]), budget: BigInt(values.budget || 0) });
|
|
153
|
+
stdout.write(`${JSON.stringify(created)}\nThe API key is shown once; give it to the tenant (Authorization: Bearer ...).\n`);
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
const tenant = loadTenants(dir)[name];
|
|
157
|
+
if (!tenant) throw new Error(`no tenant ${name}`);
|
|
158
|
+
if (action === "budget") { await setBudget(network, owner, tenant.key, BigInt(amount)); stdout.write(`budget of ${name} set to ${amount}\n`); return 0; }
|
|
159
|
+
if (action === "revoke") { await revokeTenant(network, dir, owner, name); stdout.write(`revoked ${name}\n`); return 0; }
|
|
160
|
+
throw new Error("tenant add | list | budget | revoke");
|
|
161
|
+
}
|
|
162
|
+
const keyPrivate = paymentKey(dir, command === "serve");
|
|
140
163
|
const consumer = new Consumer({ network, keyPrivate, maxFee: Number(values["max-fee"]), journalDir: dir });
|
|
141
164
|
if (command === "request") {
|
|
142
165
|
const { response, receipt } = await consumer.request({
|
|
@@ -151,11 +174,32 @@ export async function main(argv = process.argv.slice(2), { stdout = process.stdo
|
|
|
151
174
|
return 0;
|
|
152
175
|
}
|
|
153
176
|
if (command === "serve") {
|
|
177
|
+
// Tenants are reloaded when tenants.json changes, so `tenant add` works while serving.
|
|
178
|
+
let cache = { mtime: -1, byHash: {} };
|
|
179
|
+
const tenants = () => {
|
|
180
|
+
const path = join(dir, "tenants.json");
|
|
181
|
+
const mtime = existsSync(path) ? statSync(path).mtimeMs : 0;
|
|
182
|
+
if (mtime !== cache.mtime) {
|
|
183
|
+
const byHash = {};
|
|
184
|
+
for (const [name, tenant] of Object.entries(loadTenants(dir))) {
|
|
185
|
+
if (tenant.revoked) continue;
|
|
186
|
+
byHash[tenant.api_key_sha256] = new Consumer({ network, keyPrivate: tenantKey(dir, name), maxFee: Number(tenant.max_per_request),
|
|
187
|
+
journalDir: join(dir, "tenants", name) });
|
|
188
|
+
}
|
|
189
|
+
cache = { mtime, byHash };
|
|
190
|
+
}
|
|
191
|
+
return cache.byHash;
|
|
192
|
+
};
|
|
154
193
|
const server = await serveConsumer(consumer, { host: values.host, port: Number(values.port),
|
|
155
|
-
apiKey: process.env.MYCOMESH_CONSUMER_API_KEY, dataDir: dir });
|
|
194
|
+
apiKey: process.env.MYCOMESH_CONSUMER_API_KEY, dataDir: dir, tenants });
|
|
156
195
|
const { port } = server.address();
|
|
196
|
+
const url = `http://127.0.0.1:${port}/`;
|
|
157
197
|
stdout.write(`MycoMesh V11 Consumer on http://${values.host}:${port}/v1 (payment key ${consumer.key})\n`);
|
|
158
|
-
stdout.write(`Web console:
|
|
198
|
+
stdout.write(`Web console: ${url}\n`);
|
|
199
|
+
if (!values["no-browser"] && process.stdout.isTTY && !process.env.CI) {
|
|
200
|
+
const opener = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
201
|
+
spawn(opener[0], opener[1], { stdio: "ignore", detached: true }).on("error", () => {}).unref();
|
|
202
|
+
}
|
|
159
203
|
return new Promise(() => {});
|
|
160
204
|
}
|
|
161
205
|
throw new Error(`unknown command ${command}\n${USAGE}`);
|
package/src/console.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { completeWithdrawal, dispute, faucet, requestWithdrawal, settlementStatu
|
|
|
6
6
|
import { listRequests, loadRequest } from "./disputes.mjs";
|
|
7
7
|
import { outputText } from "./protocol.mjs";
|
|
8
8
|
import { createWallet, ownerAddress, unlockWallet, walletAddress } from "./wallet.mjs";
|
|
9
|
+
import { addTenant, loadTenants, revokeTenant, setBudget, tenantStatus } from "./tenants.mjs";
|
|
9
10
|
|
|
10
11
|
const PAGE = new URL("./web/console.html", import.meta.url);
|
|
11
12
|
|
|
@@ -42,11 +43,13 @@ export function consoleRoutes({ consumer, dataDir }) {
|
|
|
42
43
|
const relays = [];
|
|
43
44
|
for (const relay of await consumer.relays()) {
|
|
44
45
|
let health = null; let providers = [];
|
|
45
|
-
try { health = (await consumer.fetchJson(`${relay.url}/health`, { ca: network.tls_ca, timeoutMs: 5_000 })).body; } catch {}
|
|
46
|
+
try { health = (await consumer.fetchJson(`${relay.url}/health`, { ca: network.tls_ca, pin: relay.pin, timeoutMs: 5_000 })).body; } catch {}
|
|
46
47
|
try { providers = await consumer.providers(relay); } catch {}
|
|
47
|
-
relays.push({ url: relay.url, signer: relay.signer, healthy: Boolean(health?.ok), providers: providers.map((p) => ({
|
|
48
|
+
relays.push({ url: relay.url, signer: relay.signer, pinned: Boolean(relay.pin), healthy: Boolean(health?.ok), providers: providers.map((p) => ({
|
|
48
49
|
signer: p.provider_signer, models: p.models, prices: p.prices, capacity: p.capacity })) });
|
|
49
50
|
}
|
|
51
|
+
const records = await consumer.reputations().catch(() => ({}));
|
|
52
|
+
for (const relay of relays) for (const provider of relay.providers) provider.reputation = records[provider.signer] || null;
|
|
50
53
|
return { relays };
|
|
51
54
|
},
|
|
52
55
|
"GET /api/history": async () => {
|
|
@@ -80,6 +83,21 @@ export function consoleRoutes({ consumer, dataDir }) {
|
|
|
80
83
|
await requestWithdrawal(network, ownerPrivate, amount);
|
|
81
84
|
return { requested: amount.toString() };
|
|
82
85
|
},
|
|
86
|
+
"GET /api/tenants": async () => ({ tenants: await tenantStatus(network, dataDir) }),
|
|
87
|
+
"POST /api/tenants": async (body) => addTenant(network, dataDir, withWallet(body), String(body.name || ""), {
|
|
88
|
+
maxPerRequest: units(body.max_per_request ?? consumer.maxFee, "max_per_request"),
|
|
89
|
+
budget: body.budget ? units(body.budget, "budget") : 0n,
|
|
90
|
+
}),
|
|
91
|
+
"POST /api/tenants/budget": async (body) => {
|
|
92
|
+
const tenant = loadTenants(dataDir)[String(body.name)];
|
|
93
|
+
if (!tenant) throw Object.assign(new Error("no such tenant"), { status: 404 });
|
|
94
|
+
await setBudget(network, withWallet(body), tenant.key, units(body.budget, "budget"));
|
|
95
|
+
return { name: body.name, budget: String(body.budget) };
|
|
96
|
+
},
|
|
97
|
+
"POST /api/tenants/revoke": async (body) => {
|
|
98
|
+
await revokeTenant(network, dataDir, withWallet(body), String(body.name));
|
|
99
|
+
return { revoked: body.name };
|
|
100
|
+
},
|
|
83
101
|
"POST /api/dispute": async (body) => dispute(network, withWallet(body), loadRequest(dataDir, String(body.settlement_key)),
|
|
84
102
|
{ reasonCode: String(body.reason_code || "unrelated_response"), statement: String(body.statement || "") }),
|
|
85
103
|
};
|
package/src/consumer.mjs
CHANGED
|
@@ -10,9 +10,13 @@ import { openDelta, openResponse, outputText, prepareRequest, verifiedProvider }
|
|
|
10
10
|
import { recordRequest } from "./disputes.mjs";
|
|
11
11
|
import { chatStream, responseStream } from "./sse.mjs";
|
|
12
12
|
import { consoleRoutes, localRequestAllowed } from "./console.mjs";
|
|
13
|
+
import { pinnedOptions, splitPin } from "./tlspin.mjs";
|
|
14
|
+
import { compareRank, rankKey, reputation } from "./reputation.mjs";
|
|
15
|
+
import { hashApiKey } from "./tenants.mjs";
|
|
13
16
|
|
|
14
17
|
const PROVIDER_CACHE_MS = 30_000;
|
|
15
18
|
const RELAY_CACHE_MS = 300_000;
|
|
19
|
+
const REPUTATION_CACHE_MS = 600_000;
|
|
16
20
|
const MAX_BODY = 16 * 1024 * 1024;
|
|
17
21
|
|
|
18
22
|
export function loadNetwork(path) {
|
|
@@ -22,14 +26,21 @@ export function loadNetwork(path) {
|
|
|
22
26
|
return network;
|
|
23
27
|
}
|
|
24
28
|
|
|
25
|
-
|
|
29
|
+
async function tlsOptions(target, ca, pin) {
|
|
30
|
+
if (target.protocol !== "https:") return {};
|
|
31
|
+
if (pin) return pinnedOptions(target.href, pin);
|
|
32
|
+
return ca ? { ca: [...rootCertificates, ca] } : {};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function httpJson(url, { method = "GET", body, ca, pin, timeoutMs = 330_000 } = {}) {
|
|
26
36
|
const target = new URL(url);
|
|
27
37
|
const data = body === undefined ? undefined : Buffer.from(JSON.stringify(body));
|
|
28
38
|
const send = target.protocol === "https:" ? httpsRequest : httpRequest;
|
|
39
|
+
const tls = await tlsOptions(target, ca, pin);
|
|
29
40
|
return new Promise((resolvePromise, reject) => {
|
|
30
41
|
const req = send(target, {
|
|
31
42
|
method, headers: { accept: "application/json", ...(data ? { "content-type": "application/json", "content-length": data.length } : {}) },
|
|
32
|
-
...
|
|
43
|
+
...tls,
|
|
33
44
|
}, (res) => {
|
|
34
45
|
const chunks = [];
|
|
35
46
|
let size = 0;
|
|
@@ -51,15 +62,16 @@ export function httpJson(url, { method = "GET", body, ca, timeoutMs = 330_000 }
|
|
|
51
62
|
}
|
|
52
63
|
|
|
53
64
|
/** POST expecting NDJSON; each line goes to onLine. Non-streamed replies resolve as JSON. */
|
|
54
|
-
export function httpNdjson(url, { body, ca, timeoutMs = 340_000, onLine }) {
|
|
65
|
+
export async function httpNdjson(url, { body, ca, pin, timeoutMs = 340_000, onLine }) {
|
|
55
66
|
const target = new URL(url);
|
|
56
67
|
const data = Buffer.from(JSON.stringify(body));
|
|
57
68
|
const send = target.protocol === "https:" ? httpsRequest : httpRequest;
|
|
69
|
+
const tls = await tlsOptions(target, ca, pin);
|
|
58
70
|
return new Promise((resolvePromise, reject) => {
|
|
59
71
|
const req = send(target, {
|
|
60
72
|
method: "POST",
|
|
61
73
|
headers: { accept: "application/x-ndjson", "content-type": "application/json", "content-length": data.length },
|
|
62
|
-
...
|
|
74
|
+
...tls,
|
|
63
75
|
}, (res) => {
|
|
64
76
|
const streaming = res.statusCode === 200 && String(res.headers["content-type"] || "").includes("ndjson");
|
|
65
77
|
let buffered = "";
|
|
@@ -104,10 +116,9 @@ export async function directoryRelays(network) {
|
|
|
104
116
|
const base = Number(BigInt(`0x${raw.slice(0, 64)}`));
|
|
105
117
|
if (!Number(BigInt(`0x${raw.slice(64, 128)}`))) continue;
|
|
106
118
|
const entry = raw.slice(base * 2);
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
url:
|
|
110
|
-
});
|
|
119
|
+
const [url, pin] = splitPin(decodeString(entry, Number(BigInt(`0x${entry.slice(128, 192)}`))));
|
|
120
|
+
relays.push({ owner: `0x${entry.slice(24, 64)}`.toLowerCase(), signer: `0x${entry.slice(64 + 24, 128)}`.toLowerCase(),
|
|
121
|
+
url: url.replace(/\/+$/, ""), pin });
|
|
111
122
|
}
|
|
112
123
|
return relays;
|
|
113
124
|
}
|
|
@@ -127,6 +138,25 @@ export class Consumer {
|
|
|
127
138
|
this.relayCache = null;
|
|
128
139
|
}
|
|
129
140
|
|
|
141
|
+
/** On-chain reputation and verified probe verdicts for every Provider in view, cached. */
|
|
142
|
+
async reputations() {
|
|
143
|
+
if (this.reputationCache && Date.now() - this.reputationCache.at < REPUTATION_CACHE_MS) return this.reputationCache.value;
|
|
144
|
+
const descriptors = new Map();
|
|
145
|
+
const relaysByOwner = {};
|
|
146
|
+
for (const relay of await this.relays()) {
|
|
147
|
+
try {
|
|
148
|
+
const owner = relay.owner || `0x${(await rpcCall(this.network.rpc_urls, "eth_call", [{ to: this.network.settlement,
|
|
149
|
+
data: encodeCall("relaySignerOwner(address)", [["address", relay.signer]]) }, "latest"])).slice(26)}`;
|
|
150
|
+
relaysByOwner[owner.toLowerCase()] = relay;
|
|
151
|
+
} catch {}
|
|
152
|
+
try { for (const descriptor of await this.providers(relay)) descriptors.set(descriptor.provider_signer, descriptor); } catch {}
|
|
153
|
+
}
|
|
154
|
+
let value = {};
|
|
155
|
+
try { value = await reputation(this, [...descriptors.values()], relaysByOwner); } catch {}
|
|
156
|
+
this.reputationCache = { at: Date.now(), value };
|
|
157
|
+
return value;
|
|
158
|
+
}
|
|
159
|
+
|
|
130
160
|
/** Manifest Relays first, then directory Relays whose /health proves the announced signer. */
|
|
131
161
|
async relays() {
|
|
132
162
|
if (this.relayCache && Date.now() - this.relayCache.at < RELAY_CACHE_MS) return this.relayCache.relays;
|
|
@@ -136,7 +166,7 @@ export class Consumer {
|
|
|
136
166
|
for (const relay of await directoryRelays(this.network)) {
|
|
137
167
|
if (known.has(relay.signer)) continue;
|
|
138
168
|
try {
|
|
139
|
-
const { status, body } = await this.fetchJson(`${relay.url}/health`, { ca: this.network.tls_ca, timeoutMs: 5_000 });
|
|
169
|
+
const { status, body } = await this.fetchJson(`${relay.url}/health`, { ca: this.network.tls_ca, pin: relay.pin, timeoutMs: 5_000 });
|
|
140
170
|
if (status === 200 && String(body.relay_signer).toLowerCase() === relay.signer
|
|
141
171
|
&& String(body.settlement).toLowerCase() === this.deployment.settlement) {
|
|
142
172
|
relays.push(relay);
|
|
@@ -152,7 +182,7 @@ export class Consumer {
|
|
|
152
182
|
async providers(relay) {
|
|
153
183
|
const cached = this.cache.get(relay.url);
|
|
154
184
|
if (cached && Date.now() - cached.at < PROVIDER_CACHE_MS) return cached.providers;
|
|
155
|
-
const { status, body } = await this.fetchJson(`${relay.url}/providers`, { ca: this.network.tls_ca, timeoutMs: 10_000 });
|
|
185
|
+
const { status, body } = await this.fetchJson(`${relay.url}/providers`, { ca: this.network.tls_ca, pin: relay.pin, timeoutMs: 10_000 });
|
|
156
186
|
if (status !== 200) throw new Error(`Relay ${relay.url} /providers returned ${status}`);
|
|
157
187
|
const now = this.now();
|
|
158
188
|
const providers = (body.providers || []).filter((descriptor) => {
|
|
@@ -180,7 +210,9 @@ export class Consumer {
|
|
|
180
210
|
&& (!provider || descriptor.provider_signer === provider.toLowerCase()));
|
|
181
211
|
}
|
|
182
212
|
catch (error) { lastError = error; continue; }
|
|
183
|
-
|
|
213
|
+
// What the chain proves ranks first: recent fraud, verified probe failures, reputation; then price.
|
|
214
|
+
const records = await this.reputations().catch(() => ({}));
|
|
215
|
+
candidates.sort((a, b) => compareRank(rankKey(a, records[a.provider_signer]), rankKey(b, records[b.provider_signer])));
|
|
184
216
|
for (const descriptor of candidates) {
|
|
185
217
|
const prepared = prepareRequest({
|
|
186
218
|
descriptor, deployment: this.deployment, keyPrivate: this.keyPrivate, relaySigner: relay.signer,
|
|
@@ -191,7 +223,7 @@ export class Consumer {
|
|
|
191
223
|
let final = null;
|
|
192
224
|
try {
|
|
193
225
|
reply = onDelta
|
|
194
|
-
? await this.streamJson(`${relay.url}/v11/requests`, { body: prepared.payload, ca: this.network.tls_ca, onLine: (line) => {
|
|
226
|
+
? await this.streamJson(`${relay.url}/v11/requests`, { body: prepared.payload, ca: this.network.tls_ca, pin: relay.pin, onLine: (line) => {
|
|
195
227
|
if (line.type === "delta") {
|
|
196
228
|
streamed.push(openDelta(prepared, line.sealed, streamed.length, this.now()));
|
|
197
229
|
onDelta(streamed.at(-1));
|
|
@@ -199,7 +231,7 @@ export class Consumer {
|
|
|
199
231
|
final = line;
|
|
200
232
|
}
|
|
201
233
|
} })
|
|
202
|
-
: await this.fetchJson(`${relay.url}/v11/requests`, { method: "POST", body: prepared.payload, ca: this.network.tls_ca });
|
|
234
|
+
: await this.fetchJson(`${relay.url}/v11/requests`, { method: "POST", body: prepared.payload, ca: this.network.tls_ca, pin: relay.pin });
|
|
203
235
|
} catch (error) {
|
|
204
236
|
// The request may have reached the Relay: never replay it elsewhere.
|
|
205
237
|
throw Object.assign(new Error(`request outcome unknown: ${error.message}`), { code: "outcome_unknown", requestId: prepared.authorization.request_id });
|
|
@@ -242,8 +274,9 @@ async function readJson(req) {
|
|
|
242
274
|
}
|
|
243
275
|
|
|
244
276
|
/** Local OpenAI-compatible endpoint; nothing leaves this machine unsealed except pricing and routing. */
|
|
245
|
-
export function serveConsumer(consumer, { host = "127.0.0.1", port = 8110, apiKey, dataDir } = {}) {
|
|
277
|
+
export function serveConsumer(consumer, { host = "127.0.0.1", port = 8110, apiKey, dataDir, tenants = () => ({}) } = {}) {
|
|
246
278
|
const routes = dataDir ? consoleRoutes({ consumer, dataDir }) : {};
|
|
279
|
+
const bearer = (req) => /^Bearer (.+)$/.exec(String(req.headers.authorization || ""))?.[1];
|
|
247
280
|
const server = createServer(async (req, res) => {
|
|
248
281
|
let writer = null;
|
|
249
282
|
const send = (status, body, type = "application/json") => {
|
|
@@ -251,8 +284,13 @@ export function serveConsumer(consumer, { host = "127.0.0.1", port = 8110, apiKe
|
|
|
251
284
|
res.end(type === "application/json" ? JSON.stringify(body) : body);
|
|
252
285
|
};
|
|
253
286
|
try {
|
|
254
|
-
if (!localRequestAllowed(req, server.address().port)) return send(403, openaiError("only this machine may use the local node", "forbidden"));
|
|
255
287
|
const path = new URL(req.url, "http://localhost").pathname.replace(/^\/v1\/v1\//, "/v1/");
|
|
288
|
+
// A tenant's API key selects its own payment key and budget; tenants may call from anywhere.
|
|
289
|
+
const tenant = path.startsWith("/v1/") && bearer(req) ? tenants()[hashApiKey(bearer(req))] : undefined;
|
|
290
|
+
if (!tenant && !localRequestAllowed(req, server.address().port)) {
|
|
291
|
+
return send(403, openaiError("only this machine may use the local node", "forbidden"));
|
|
292
|
+
}
|
|
293
|
+
const active = tenant || consumer;
|
|
256
294
|
const route = routes[`${req.method} ${path}`];
|
|
257
295
|
if (route) {
|
|
258
296
|
if (req.method === "POST" && !String(req.headers["content-type"] || "").startsWith("application/json")) {
|
|
@@ -261,10 +299,11 @@ export function serveConsumer(consumer, { host = "127.0.0.1", port = 8110, apiKe
|
|
|
261
299
|
const result = await route(req.method === "POST" ? await readJson(req) : {});
|
|
262
300
|
return result?.type ? send(200, result.body, result.type) : send(200, result);
|
|
263
301
|
}
|
|
264
|
-
if (
|
|
302
|
+
if (!tenant && bearer(req) && bearer(req) !== apiKey) return send(401, openaiError("invalid API key", "unauthorized"));
|
|
303
|
+
if (!tenant && apiKey && bearer(req) !== apiKey) return send(401, openaiError("invalid API key", "unauthorized"));
|
|
265
304
|
if (req.method === "GET" && path === "/health") return send(200, { ok: true, protocol: 11, key: consumer.key });
|
|
266
305
|
if (req.method === "GET" && path === "/v1/models") {
|
|
267
|
-
return send(200, { object: "list", data: (await
|
|
306
|
+
return send(200, { object: "list", data: (await active.models()).map((id) => ({ id, object: "model", owned_by: "mycomesh" })) });
|
|
268
307
|
}
|
|
269
308
|
if (req.method !== "POST" || !["/v1/responses", "/v1/chat/completions", "/responses", "/chat/completions"].includes(path)) {
|
|
270
309
|
return send(404, openaiError("not found", "not_found"));
|
|
@@ -280,7 +319,7 @@ export function serveConsumer(consumer, { host = "127.0.0.1", port = 8110, apiKe
|
|
|
280
319
|
delete rest.max_tokens;
|
|
281
320
|
delete rest.max_completion_tokens;
|
|
282
321
|
if (!stream) {
|
|
283
|
-
const { response } = await
|
|
322
|
+
const { response } = await active.request({ endpoint: chat ? "chat" : "responses", model, content, maxOutputTokens, options: rest });
|
|
284
323
|
return send(200, response.output);
|
|
285
324
|
}
|
|
286
325
|
// Headers go out with the first delta, so a request that fails before dispatch still gets an HTTP error.
|
|
@@ -289,7 +328,7 @@ export function serveConsumer(consumer, { host = "127.0.0.1", port = 8110, apiKe
|
|
|
289
328
|
res.write(text);
|
|
290
329
|
};
|
|
291
330
|
writer = chat ? chatStream(write, { model }, { includeUsage: streamOptions?.include_usage === true }) : responseStream(write, { model });
|
|
292
|
-
const { response } = await
|
|
331
|
+
const { response } = await active.request({ endpoint: chat ? "chat" : "responses", model, content, maxOutputTokens,
|
|
293
332
|
options: rest, onDelta: (text) => writer.delta(text) });
|
|
294
333
|
writer.finish(response.output);
|
|
295
334
|
return res.end();
|
package/src/probes.mjs
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Re-grading Relay probe verdicts (mirrors mycomesh/relay/probes.py and mycomesh/probe_evidence.py).
|
|
2
|
+
import { sha256Hex, outputText } from "./protocol.mjs";
|
|
3
|
+
import { verifySignedReceipt } from "./eip712.mjs";
|
|
4
|
+
|
|
5
|
+
export const PROBE_EVIDENCE_SCHEMA = "mycomesh.v11.probe-evidence.v1";
|
|
6
|
+
const WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
|
7
|
+
const numbers = (text) => (text.match(/\d{1,3}(?:[,\s_]\d{3})+(?!\d)|\d+/g) || []).map((match) => match.replace(/[,\s_]/g, ""));
|
|
8
|
+
|
|
9
|
+
export function buildTask(kind, params) {
|
|
10
|
+
if (kind === "multiply") return { kind, question: `What is ${params.a} multiplied by ${params.b}?`, reference: String(BigInt(params.a) * BigInt(params.b)), numeric: true };
|
|
11
|
+
if (kind === "sum") {
|
|
12
|
+
return { kind, question: `What is the sum of ${params.values.join(", ")}?`, reference: String(params.values.reduce((a, b) => a + Number(b), 0)), numeric: true };
|
|
13
|
+
}
|
|
14
|
+
if (kind === "count") {
|
|
15
|
+
return { kind, question: `How many times does the letter "${params.target}" appear in "${params.letters}"?`,
|
|
16
|
+
reference: String(params.letters.split(params.target).length - 1), numeric: true };
|
|
17
|
+
}
|
|
18
|
+
if (kind === "reverse") return { kind, question: `Write the string "${params.word}" backwards, letter by letter.`, reference: [...params.word].reverse().join(""), numeric: false };
|
|
19
|
+
if (kind === "sort") return { kind, question: `Sort these words alphabetically: ${params.words.join(", ")}.`, reference: [...params.words].sort().join(", "), numeric: false };
|
|
20
|
+
if (kind === "weekday") {
|
|
21
|
+
const day = new Date(`${params.start}T00:00:00Z`);
|
|
22
|
+
day.setUTCDate(day.getUTCDate() + Number(params.offset));
|
|
23
|
+
return { kind, question: `What day of the week is ${params.offset} days after ${params.start}?`, reference: WEEKDAYS[(day.getUTCDay() + 6) % 7], numeric: false };
|
|
24
|
+
}
|
|
25
|
+
throw new Error(`unknown probe kind ${kind}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function grade(task, answer) {
|
|
29
|
+
const text = String(answer).trim();
|
|
30
|
+
if (!text) return "unrelated";
|
|
31
|
+
if (task.numeric) {
|
|
32
|
+
const found = numbers(text);
|
|
33
|
+
return found.length ? (found.includes(task.reference) ? "pass" : "wrong") : "unrelated";
|
|
34
|
+
}
|
|
35
|
+
const lower = text.toLowerCase();
|
|
36
|
+
if (task.kind === "reverse") return lower.includes(task.reference) ? "pass" : "wrong";
|
|
37
|
+
if (task.kind === "sort") {
|
|
38
|
+
const positions = task.reference.split(", ").map((word) => lower.indexOf(word));
|
|
39
|
+
if (positions.every((position) => position < 0)) return "unrelated";
|
|
40
|
+
return positions.every((p, i) => p >= 0 && (i === 0 || p >= positions[i - 1])) ? "pass" : "wrong";
|
|
41
|
+
}
|
|
42
|
+
const named = WEEKDAYS.filter((day) => lower.includes(day.toLowerCase()));
|
|
43
|
+
if (!named.length) return "unrelated";
|
|
44
|
+
return named.length === 1 && named[0] === task.reference ? "pass" : "wrong";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function* strings(value) {
|
|
48
|
+
if (typeof value === "string") yield value;
|
|
49
|
+
else if (Array.isArray(value)) for (const item of value) yield* strings(item);
|
|
50
|
+
else if (value && typeof value === "object") for (const item of Object.values(value)) yield* strings(item);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Returns { provider, verdict } after re-grading the Provider's own signed answer; throws otherwise. */
|
|
54
|
+
export function verifyProbeEvidence(evidence, deployment) {
|
|
55
|
+
if (evidence?.schema !== PROBE_EVIDENCE_SCHEMA) throw new Error("not probe evidence");
|
|
56
|
+
const signed = evidence.signed_receipt;
|
|
57
|
+
verifySignedReceipt(signed, deployment);
|
|
58
|
+
const request = Buffer.from(evidence.request, "base64");
|
|
59
|
+
const response = Buffer.from(evidence.response, "base64");
|
|
60
|
+
if (sha256Hex(request) !== signed.authorization.request_hash || sha256Hex(response) !== signed.receipt.response_hash) {
|
|
61
|
+
throw new Error("plaintexts do not match the Provider-signed receipt");
|
|
62
|
+
}
|
|
63
|
+
const task = buildTask(evidence.task.kind, evidence.task.params);
|
|
64
|
+
if (![...strings(JSON.parse(request.toString("utf8")))].some((text) => text.includes(task.question))) {
|
|
65
|
+
throw new Error("the request does not ask the stated question");
|
|
66
|
+
}
|
|
67
|
+
const verdict = grade(task, outputText(JSON.parse(response.toString("utf8")).output));
|
|
68
|
+
if (verdict !== evidence.verdict || verdict === "unrelated") throw new Error(`re-grading gives ${verdict}`);
|
|
69
|
+
return { provider: signed.authorization.provider_signer, verdict };
|
|
70
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Provider ranking from what the chain and the probe ledger prove, not from what anyone claims.
|
|
2
|
+
import { encodeCall, keccak, hex } from "./eip712.mjs";
|
|
3
|
+
import { rpcCall } from "./chain.mjs";
|
|
4
|
+
import { evidenceHash } from "./disputes.mjs";
|
|
5
|
+
import { verifyProbeEvidence } from "./probes.mjs";
|
|
6
|
+
|
|
7
|
+
const PROBE_RECORDED = hex(keccak(Buffer.from("ProbeRecorded(address,address,bytes32,bytes32,uint8)")));
|
|
8
|
+
const LOOKBACK_BLOCKS = 50_400n; // about 7 days of 12-second blocks
|
|
9
|
+
const LOG_CHUNK = 10_000n;
|
|
10
|
+
const FRAUD_COOLDOWN = 30 * 86_400;
|
|
11
|
+
const MAX_VERIFIED = 20; // evidence downloads per refresh
|
|
12
|
+
|
|
13
|
+
const word = (raw, index) => BigInt(`0x${raw.slice(2 + index * 64, 2 + (index + 1) * 64)}`);
|
|
14
|
+
const address = (topic) => `0x${topic.slice(26)}`.toLowerCase();
|
|
15
|
+
|
|
16
|
+
async function providerRecord(network, signer) {
|
|
17
|
+
const owner = address((await rpcCall(network.rpc_urls, "eth_call", [{ to: network.settlement,
|
|
18
|
+
data: encodeCall("providerSignerOwner(address)", [["address", signer]]) }, "latest"])).slice(0, 66));
|
|
19
|
+
const raw = await rpcCall(network.rpc_urls, "eth_call", [{ to: network.registry,
|
|
20
|
+
data: encodeCall("providerOf(address)", [["address", owner]]) }, "latest"]);
|
|
21
|
+
return { owner, counted_volume: word(raw, 10).toString(), counterparties: Number(word(raw, 8)),
|
|
22
|
+
last_fraud_at: Number(word(raw, 9)), registered_at: Number(word(raw, 5)) };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function ledgerEvents(network) {
|
|
26
|
+
if (!network.probe_ledger) return [];
|
|
27
|
+
const head = BigInt(await rpcCall(network.rpc_urls, "eth_blockNumber", []));
|
|
28
|
+
const events = [];
|
|
29
|
+
for (let from = head > LOOKBACK_BLOCKS ? head - LOOKBACK_BLOCKS : 0n; from <= head; from += LOG_CHUNK) {
|
|
30
|
+
const to = from + LOG_CHUNK - 1n < head ? from + LOG_CHUNK - 1n : head;
|
|
31
|
+
for (const log of await rpcCall(network.rpc_urls, "eth_getLogs", [{ address: network.probe_ledger, topics: [PROBE_RECORDED],
|
|
32
|
+
fromBlock: `0x${from.toString(16)}`, toBlock: `0x${to.toString(16)}` }])) {
|
|
33
|
+
events.push({ provider: address(log.topics[1]), relay: address(log.topics[2]), key: log.topics[3],
|
|
34
|
+
evidence: `0x${log.data.slice(2, 66)}`, verdict: Number(BigInt(`0x${log.data.slice(66, 130)}`)) === 1 ? "pass" : "wrong" });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return events;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Per Provider owner: on-chain reputation plus probe verdicts. Passes are counted as reported;
|
|
42
|
+
* failures count only after this machine re-grades the published evidence, so no Relay can frame anyone.
|
|
43
|
+
*/
|
|
44
|
+
export async function reputation(consumer, descriptors, relaysByOwner) {
|
|
45
|
+
const network = consumer.network;
|
|
46
|
+
const records = {};
|
|
47
|
+
for (const descriptor of descriptors) {
|
|
48
|
+
try { records[descriptor.provider_signer] = await providerRecord(network, descriptor.provider_signer); } catch {}
|
|
49
|
+
}
|
|
50
|
+
let events = [];
|
|
51
|
+
try { events = await ledgerEvents(network); } catch {}
|
|
52
|
+
const byOwner = {};
|
|
53
|
+
for (const record of Object.values(records)) byOwner[record.owner] = { ...record, probes_passed: 0, probes_failed_verified: 0 };
|
|
54
|
+
let verified = 0;
|
|
55
|
+
for (const event of events) {
|
|
56
|
+
const entry = byOwner[event.provider];
|
|
57
|
+
if (!entry) continue;
|
|
58
|
+
if (event.verdict === "pass") { entry.probes_passed += 1; continue; }
|
|
59
|
+
const relay = relaysByOwner[event.relay];
|
|
60
|
+
if (!relay || verified >= MAX_VERIFIED) continue;
|
|
61
|
+
verified += 1;
|
|
62
|
+
try {
|
|
63
|
+
const { status, body } = await consumer.fetchJson(`${relay.url}/v11/evidence/${event.evidence}`,
|
|
64
|
+
{ ca: network.tls_ca, pin: relay.pin, timeoutMs: 8_000 });
|
|
65
|
+
if (status === 200 && evidenceHash(body) === event.evidence && verifyProbeEvidence(body, consumer.deployment).verdict === "wrong") {
|
|
66
|
+
entry.probes_failed_verified += 1;
|
|
67
|
+
}
|
|
68
|
+
} catch {}
|
|
69
|
+
}
|
|
70
|
+
return Object.fromEntries(Object.entries(records).map(([signer, record]) => [signer, byOwner[record.owner]]));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Lower is better: recent confirmed fraud last, then verified probe failure rate, then reputation, then price. */
|
|
74
|
+
export function rankKey(descriptor, record, now = Math.floor(Date.now() / 1000)) {
|
|
75
|
+
const fraud = record && record.last_fraud_at && now < record.last_fraud_at + FRAUD_COOLDOWN ? 1 : 0;
|
|
76
|
+
const probes = record ? record.probes_passed + record.probes_failed_verified : 0;
|
|
77
|
+
const failure = record && probes ? Math.round((10 * record.probes_failed_verified) / probes) : 0;
|
|
78
|
+
const volume = record ? -Number(BigInt(record.counted_volume) / 1000n) : 0;
|
|
79
|
+
const price = descriptor.prices.output_per_1k * 1e6 + descriptor.prices.input_per_1k;
|
|
80
|
+
return [fraud, failure, volume, price];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function compareRank(a, b) {
|
|
84
|
+
for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return a[i] - b[i];
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
package/src/tenants.mjs
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Multi-tenant accounts: one owner deposit, one payment key per tenant with an on-chain budget.
|
|
2
|
+
// A custodial service (or any team) gives each end user an API key; the chain caps what each key can spend.
|
|
3
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
4
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { addressOf, encodeCall } from "./eip712.mjs";
|
|
7
|
+
import { rpcCall, sendTransaction } from "./chain.mjs";
|
|
8
|
+
|
|
9
|
+
const NAME = /^[a-zA-Z0-9_.-]{1,64}$/;
|
|
10
|
+
const file = (dir) => join(dir, "tenants.json");
|
|
11
|
+
export const hashApiKey = (apiKey) => createHash("sha256").update(String(apiKey)).digest("hex");
|
|
12
|
+
|
|
13
|
+
export function loadTenants(dir) {
|
|
14
|
+
return existsSync(file(dir)) ? JSON.parse(readFileSync(file(dir), "utf8")).tenants : {};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function saveTenants(dir, tenants) {
|
|
18
|
+
writeFileSync(file(dir), `${JSON.stringify({ schema: "mycomesh.v11.tenants.v1", tenants }, null, 2)}\n`, { mode: 0o600 });
|
|
19
|
+
chmodSync(file(dir), 0o600);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function tenantKey(dir, name) {
|
|
23
|
+
const value = readFileSync(join(dir, "tenants", `${name}.key`), "utf8").trim();
|
|
24
|
+
return value.startsWith("0x") ? value : `0x${value}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Create a tenant: a fresh payment key granted on-chain with a per-request cap and a total budget. */
|
|
28
|
+
export async function addTenant(network, dir, ownerPrivate, name, { maxPerRequest, budget = 0n }) {
|
|
29
|
+
if (!NAME.test(name)) throw new Error("tenant names use letters, digits, '.', '_' and '-'");
|
|
30
|
+
const tenants = loadTenants(dir);
|
|
31
|
+
if (tenants[name]) throw new Error(`tenant ${name} already exists`);
|
|
32
|
+
mkdirSync(join(dir, "tenants"), { recursive: true, mode: 0o700 });
|
|
33
|
+
const keyPrivate = `0x${randomBytes(32).toString("hex")}`;
|
|
34
|
+
writeFileSync(join(dir, "tenants", `${name}.key`), `${keyPrivate}\n`, { mode: 0o600 });
|
|
35
|
+
const key = addressOf(keyPrivate);
|
|
36
|
+
await sendTransaction(network.rpc_urls, ownerPrivate, { to: network.settlement,
|
|
37
|
+
data: encodeCall("registerKey(address,uint256,uint64)", [["address", key], ["uint", maxPerRequest], ["uint", 0]]) });
|
|
38
|
+
if (budget > 0n) await setBudget(network, ownerPrivate, key, budget);
|
|
39
|
+
const apiKey = `mcm_${randomBytes(24).toString("hex")}`;
|
|
40
|
+
tenants[name] = { key, max_per_request: String(maxPerRequest), api_key_sha256: hashApiKey(apiKey),
|
|
41
|
+
created_at: Math.floor(Date.now() / 1000), revoked: false };
|
|
42
|
+
saveTenants(dir, tenants);
|
|
43
|
+
return { name, key, api_key: apiKey };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function setBudget(network, ownerPrivate, key, budget) {
|
|
47
|
+
await sendTransaction(network.rpc_urls, ownerPrivate, { to: network.settlement,
|
|
48
|
+
data: encodeCall("setKeyBudget(address,uint128)", [["address", key], ["uint", budget]]) });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function revokeTenant(network, dir, ownerPrivate, name) {
|
|
52
|
+
const tenants = loadTenants(dir);
|
|
53
|
+
if (!tenants[name]) throw new Error(`no tenant ${name}`);
|
|
54
|
+
await sendTransaction(network.rpc_urls, ownerPrivate, { to: network.settlement,
|
|
55
|
+
data: encodeCall("revokeKey(address)", [["address", tenants[name].key]]) });
|
|
56
|
+
tenants[name].revoked = true;
|
|
57
|
+
saveTenants(dir, tenants);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Every tenant with its on-chain budget and spend. */
|
|
61
|
+
export async function tenantStatus(network, dir) {
|
|
62
|
+
const tenants = loadTenants(dir);
|
|
63
|
+
return Promise.all(Object.entries(tenants).map(async ([name, tenant]) => {
|
|
64
|
+
const raw = (await rpcCall(network.rpc_urls, "eth_call", [{ to: network.settlement,
|
|
65
|
+
data: encodeCall("keyBudgets(address)", [["address", tenant.key]]) }, "latest"])).slice(2);
|
|
66
|
+
return { name, key: tenant.key, max_per_request: tenant.max_per_request, revoked: tenant.revoked,
|
|
67
|
+
budget: BigInt(`0x${raw.slice(0, 64)}`).toString(), spent: BigInt(`0x${raw.slice(64, 128)}`).toString() };
|
|
68
|
+
}));
|
|
69
|
+
}
|
package/src/tlspin.mjs
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// TLS without certificate authorities: a Relay pins its self-signed certificate on-chain
|
|
2
|
+
// (https://IP:PORT#sha256=<hex> in the RelayDirectoryV11); mirrors mycomesh/tlspin.py.
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { connect } from "node:tls";
|
|
5
|
+
|
|
6
|
+
const PIN_PREFIX = "#sha256=";
|
|
7
|
+
const trusted = new Map(); // pin -> PEM of the certificate that matched it
|
|
8
|
+
|
|
9
|
+
export function splitPin(endpoint) {
|
|
10
|
+
const index = String(endpoint).indexOf(PIN_PREFIX);
|
|
11
|
+
return index < 0 ? [String(endpoint), null] : [endpoint.slice(0, index), endpoint.slice(index + PIN_PREFIX.length).toLowerCase()];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Fetch the Relay's certificate once, check it against the pin, and return
|
|
16
|
+
* TLS options that trust exactly that certificate (no CA, no host name).
|
|
17
|
+
*/
|
|
18
|
+
export async function pinnedOptions(url, pin) {
|
|
19
|
+
if (!trusted.has(pin)) {
|
|
20
|
+
const target = new URL(url);
|
|
21
|
+
const pem = await new Promise((resolve, reject) => {
|
|
22
|
+
const socket = connect({ host: target.hostname, port: Number(target.port || 443), rejectUnauthorized: false }, () => {
|
|
23
|
+
const der = socket.getPeerCertificate(true).raw;
|
|
24
|
+
socket.end();
|
|
25
|
+
if (!der || createHash("sha256").update(der).digest("hex") !== pin) {
|
|
26
|
+
return reject(new Error(`Relay ${target.host} certificate does not match its on-chain pin`));
|
|
27
|
+
}
|
|
28
|
+
resolve(`-----BEGIN CERTIFICATE-----\n${der.toString("base64").match(/.{1,64}/g).join("\n")}\n-----END CERTIFICATE-----\n`);
|
|
29
|
+
});
|
|
30
|
+
socket.setTimeout(10_000, () => socket.destroy(new Error("TLS handshake timed out")));
|
|
31
|
+
socket.on("error", reject);
|
|
32
|
+
});
|
|
33
|
+
trusted.set(pin, pem);
|
|
34
|
+
}
|
|
35
|
+
return { ca: [trusted.get(pin)], checkServerIdentity: () => undefined };
|
|
36
|
+
}
|
package/src/web/console.html
CHANGED
|
@@ -56,10 +56,20 @@
|
|
|
56
56
|
<button data-tab="wallet">钱包</button>
|
|
57
57
|
<button data-tab="history">记录</button>
|
|
58
58
|
<button data-tab="network">网络</button>
|
|
59
|
+
<button data-tab="tenants">租户</button>
|
|
59
60
|
</nav>
|
|
60
61
|
<main>
|
|
61
62
|
<section id="tab-chat" class="active">
|
|
63
|
+
<div class="card" id="welcome" style="display:none; margin-bottom:18px">
|
|
64
|
+
<h2 style="margin-top:0">欢迎使用 MycoMesh</h2>
|
|
65
|
+
<p class="muted">两步就能开始:在本机创建一个加密钱包,然后一键领取测试币并存入押金。之后每个请求都在本机签名、加密,Relay 看不到内容。</p>
|
|
66
|
+
<div id="welcome-create" class="row"><input type="password" id="w-pw" placeholder="设置钱包密码(至少 8 位)">
|
|
67
|
+
<input type="password" id="w-pw2" placeholder="再输入一次"><button class="primary" id="w-create">① 创建钱包</button></div>
|
|
68
|
+
<div id="welcome-fund" class="row" style="display:none"><input type="password" id="w-fund-pw" placeholder="钱包密码">
|
|
69
|
+
<button class="primary" id="w-fund">② 领取测试币并存入 20 tUSDC</button><span class="muted">约 1 分钟,需要几笔链上交易。</span></div>
|
|
70
|
+
</div>
|
|
62
71
|
<div class="row"><label class="muted">模型</label><select id="model"></select>
|
|
72
|
+
<label class="muted">模式</label><select id="speed"><option value="low">快速</option><option value="medium">均衡</option><option value="high">深度</option></select>
|
|
63
73
|
<button class="ghost" id="clear">新对话</button><span class="muted" id="chat-hint"></span></div>
|
|
64
74
|
<div id="chat"></div>
|
|
65
75
|
<textarea id="prompt" placeholder="输入问题,Enter 发送,Shift+Enter 换行。请求在本机加密,Relay 看不到内容。"></textarea>
|
|
@@ -98,8 +108,18 @@
|
|
|
98
108
|
<table><thead><tr><th>时间</th><th>问题</th><th>回答</th><th>费用</th><th>状态</th><th></th></tr></thead><tbody id="history"></tbody></table>
|
|
99
109
|
</section>
|
|
100
110
|
|
|
111
|
+
<section id="tab-tenants">
|
|
112
|
+
<p class="muted">多租户:一笔押金服务多个用户或团队。每个租户有自己的付款 key 和 API key,链上限定单次上限和总预算,花完即停,互不影响。
|
|
113
|
+
租户用 API key 调用本机的 <span class="mono">/v1</span> 接口(可以用 <span class="mono">--host 0.0.0.0</span> 对外提供)。</p>
|
|
114
|
+
<div class="row"><input id="t-name" placeholder="租户名称" size="14"><input id="t-budget" placeholder="总预算(tUSDC)" size="14" value="10">
|
|
115
|
+
<input id="t-limit" placeholder="单次上限(tUSDC)" size="14" value="1"><button class="primary" id="t-add">添加租户</button></div>
|
|
116
|
+
<p class="mono" id="t-created" style="display:none"></p>
|
|
117
|
+
<table><thead><tr><th>租户</th><th>付款 key</th><th>已用 / 预算</th><th>单次上限</th><th>状态</th><th></th></tr></thead><tbody id="tenants"></tbody></table>
|
|
118
|
+
</section>
|
|
119
|
+
|
|
101
120
|
<section id="tab-network">
|
|
102
|
-
<p class="muted">Relay 来自内置清单和链上 Relay
|
|
121
|
+
<p class="muted">Relay 来自内置清单和链上 Relay 目录,本机逐个核对签名身份;自签证书的 Relay 按链上登记的指纹核验,不依赖证书机构。
|
|
122
|
+
选择 Provider 时先看链上能证明的:近期是否被确认欺诈、经本机重新判分的探针失败率、累计干净成交,最后才看价格。价格单位:每 1000 个 token 的 tUSDC。</p>
|
|
103
123
|
<div id="network"></div>
|
|
104
124
|
</section>
|
|
105
125
|
</main>
|
|
@@ -146,7 +166,7 @@ document.querySelectorAll("nav button").forEach((button) => button.onclick = ()
|
|
|
146
166
|
history.replaceState(null, "", `#${button.dataset.tab}`);
|
|
147
167
|
document.querySelectorAll("nav button").forEach((b) => b.classList.toggle("active", b === button));
|
|
148
168
|
document.querySelectorAll("section").forEach((s) => s.classList.toggle("active", s.id === `tab-${button.dataset.tab}`));
|
|
149
|
-
({ wallet: refreshWallet, history: refreshHistory, network: refreshNetwork })[button.dataset.tab]?.();
|
|
169
|
+
({ wallet: refreshWallet, history: refreshHistory, network: refreshNetwork, tenants: refreshTenants })[button.dataset.tab]?.();
|
|
150
170
|
});
|
|
151
171
|
|
|
152
172
|
// ---------------- wallet ----------------
|
|
@@ -164,8 +184,62 @@ async function refreshWallet() {
|
|
|
164
184
|
$("withdraw-hint").textContent = pending > 0n
|
|
165
185
|
? (w.withdrawal.available_at * 1000 > Date.now() ? `已申请取回 ${fmt(w.withdrawal.amount)},${new Date(w.withdrawal.available_at * 1000).toLocaleString()} 之后可到账。`
|
|
166
186
|
: `已申请的 ${fmt(w.withdrawal.amount)} 可以到账了,点击“取回”。`) : "先申请,等待期结束后再次点击即可到账。";
|
|
167
|
-
$("chat-hint").textContent = BigInt(w.deposit) === 0n || !w.grant?.active ? "
|
|
187
|
+
$("chat-hint").textContent = BigInt(w.deposit) === 0n || !w.grant?.active ? "还没有押金。" : `押金 ${fmt(w.deposit)} tUSDC`;
|
|
188
|
+
}
|
|
189
|
+
function renderWelcome() {
|
|
190
|
+
const ready = status?.wallet && BigInt(status.wallet.deposit) > 0n && status.wallet.grant?.active;
|
|
191
|
+
$("welcome").style.display = ready ? "none" : "block";
|
|
192
|
+
$("welcome-create").style.display = status?.wallet ? "none" : "flex";
|
|
193
|
+
$("welcome-fund").style.display = status?.wallet && !ready ? "flex" : "none";
|
|
194
|
+
}
|
|
195
|
+
$("w-create").onclick = () => busy($("w-create"), async () => {
|
|
196
|
+
if ($("w-pw").value !== $("w-pw2").value) throw new Error("两次输入的密码不一致");
|
|
197
|
+
await api("/api/wallet", { password: $("w-pw").value }); $("w-fund-pw").value = $("w-pw").value;
|
|
198
|
+
await refreshWallet(); renderWelcome(); toast("钱包已创建,下一步:领取测试币并存入押金");
|
|
199
|
+
});
|
|
200
|
+
$("w-fund").onclick = () => busy($("w-fund"), async () => {
|
|
201
|
+
await api("/api/setup", { password: $("w-fund-pw").value, deposit: toUnits(20), max_per_request: toUnits(2) });
|
|
202
|
+
$("w-fund-pw").value = ""; await refreshWallet(); renderWelcome(); toast("准备好了,开始提问吧"); $("prompt").focus();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// ---------------- tenants ----------------
|
|
206
|
+
async function refreshTenants() {
|
|
207
|
+
const { tenants } = await api("/api/tenants");
|
|
208
|
+
$("tenants").innerHTML = tenants.length ? "" : `<tr><td colspan="6" class="muted">还没有租户。</td></tr>`;
|
|
209
|
+
for (const t of tenants) {
|
|
210
|
+
const row = document.createElement("tr");
|
|
211
|
+
row.innerHTML = `<td>${t.name}</td><td class="mono">${short(t.key)}</td>
|
|
212
|
+
<td>${fmt(t.spent)} / ${BigInt(t.budget) === 0n ? "不限" : fmt(t.budget)}</td><td>${fmt(t.max_per_request)}</td>
|
|
213
|
+
<td><span class="chip ${t.revoked ? "disputed" : "released"}">${t.revoked ? "已撤销" : "有效"}</span></td><td></td>`;
|
|
214
|
+
if (!t.revoked) {
|
|
215
|
+
const cell = row.lastElementChild;
|
|
216
|
+
for (const [label, action] of [["调整预算", "budget"], ["撤销", "revoke"]]) {
|
|
217
|
+
const button = document.createElement("button"); button.className = "ghost"; button.textContent = label; button.style.marginRight = "6px";
|
|
218
|
+
button.onclick = async () => {
|
|
219
|
+
const answer = await ask(`${label}:${t.name}`, action === "budget" ? `<label>新的总预算(tUSDC)</label><input id="new-budget" value="${fmt(t.budget)}">` : "");
|
|
220
|
+
if (!answer) return;
|
|
221
|
+
await busy(button, async () => {
|
|
222
|
+
await api(`/api/tenants/${action}`, { password: answer.password, name: t.name,
|
|
223
|
+
...(action === "budget" ? { budget: toUnits(answer.form.querySelector("#new-budget").value) } : {}) });
|
|
224
|
+
toast("已更新"); await refreshTenants();
|
|
225
|
+
});
|
|
226
|
+
};
|
|
227
|
+
cell.append(button);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
$("tenants").append(row);
|
|
231
|
+
}
|
|
168
232
|
}
|
|
233
|
+
$("t-add").onclick = async () => {
|
|
234
|
+
const answer = await ask(`添加租户 ${$("t-name").value}`); if (!answer) return;
|
|
235
|
+
await busy($("t-add"), async () => {
|
|
236
|
+
const created = await api("/api/tenants", { password: answer.password, name: $("t-name").value.trim(),
|
|
237
|
+
budget: toUnits($("t-budget").value), max_per_request: toUnits($("t-limit").value) });
|
|
238
|
+
$("t-created").style.display = "block";
|
|
239
|
+
$("t-created").textContent = `租户 ${created.name} 的 API key(只显示这一次):${created.api_key}`;
|
|
240
|
+
await refreshTenants();
|
|
241
|
+
});
|
|
242
|
+
};
|
|
169
243
|
$("create-wallet").onclick = () => busy($("create-wallet"), async () => {
|
|
170
244
|
if ($("new-pw").value !== $("new-pw2").value) throw new Error("两次输入的密码不一致");
|
|
171
245
|
await api("/api/wallet", { password: $("new-pw").value }); toast("钱包已创建"); await refreshWallet();
|
|
@@ -199,7 +273,7 @@ $("send").onclick = async () => {
|
|
|
199
273
|
const out = bubble("assistant", "…"); let answer = ""; $("send").disabled = true;
|
|
200
274
|
try {
|
|
201
275
|
const response = await fetch("/v1/chat/completions", { method: "POST", headers: { "content-type": "application/json" },
|
|
202
|
-
body: JSON.stringify({ model: $("model").value, stream: true, messages: conversation }) });
|
|
276
|
+
body: JSON.stringify({ model: $("model").value, stream: true, messages: conversation, reasoning_effort: $("speed").value }) });
|
|
203
277
|
if (!response.ok) { const data = await response.json().catch(() => ({})); throw new Error(data.error?.message || `HTTP ${response.status}`); }
|
|
204
278
|
const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = "";
|
|
205
279
|
for (;;) {
|
|
@@ -264,16 +338,20 @@ async function refreshNetwork() {
|
|
|
264
338
|
$("network").innerHTML = "";
|
|
265
339
|
for (const relay of relays) {
|
|
266
340
|
const card = document.createElement("div"); card.className = "card"; card.style.marginBottom = "12px";
|
|
341
|
+
const reputation = (r) => r ? `${fmt(r.counted_volume)} · ${r.counterparties} 个付款方` : "—";
|
|
342
|
+
const probes = (r) => r ? `通过 ${r.probes_passed} · 验证失败 ${r.probes_failed_verified}` : "—";
|
|
343
|
+
const fraud = (r) => r && r.last_fraud_at && Date.now() / 1000 < r.last_fraud_at + 30 * 86400 ? `<span class="chip disputed">近期欺诈</span>` : "无";
|
|
267
344
|
card.innerHTML = `<div class="row" style="margin:0"><strong>${relay.url}</strong><span class="chip ${relay.healthy ? "released" : "disputed"}">${relay.healthy ? "在线" : "离线"}</span>
|
|
268
|
-
<span class="muted mono">签名 ${short(relay.signer)}</span
|
|
269
|
-
<table style="margin-top:8px"><thead><tr><th>Provider</th><th>模型</th><th>输入</th><th>输出</th><th>最低</th></tr></thead><tbody>${relay.providers.map((p) =>
|
|
270
|
-
`<tr><td class="mono">${short(p.signer)}</td><td>${p.models.join(", ")}</td><td>${fmt(p.prices.input_per_1k)}</td><td>${fmt(p.prices.output_per_1k)}</td><td>${fmt(p.prices.minimum_fee)}</td
|
|
271
|
-
|
|
345
|
+
<span class="muted mono">签名 ${short(relay.signer)}</span>${relay.pinned ? `<span class="chip">证书指纹已上链</span>` : ""}</div>
|
|
346
|
+
<table style="margin-top:8px"><thead><tr><th>Provider</th><th>模型</th><th>输入</th><th>输出</th><th>最低</th><th>信誉(计入成交)</th><th>探针</th><th>欺诈</th></tr></thead><tbody>${relay.providers.map((p) =>
|
|
347
|
+
`<tr><td class="mono">${short(p.signer)}</td><td>${p.models.join(", ")}</td><td>${fmt(p.prices.input_per_1k)}</td><td>${fmt(p.prices.output_per_1k)}</td><td>${fmt(p.prices.minimum_fee)}</td>
|
|
348
|
+
<td>${reputation(p.reputation)}</td><td>${probes(p.reputation)}</td><td>${fraud(p.reputation)}</td></tr>`).join("")
|
|
349
|
+
|| `<tr><td colspan="8" class="muted">没有已连接的 Provider</td></tr>`}</tbody></table>`;
|
|
272
350
|
$("network").append(card);
|
|
273
351
|
}
|
|
274
352
|
}
|
|
275
353
|
|
|
276
|
-
refreshWallet().catch((error) => toast(error.message, true));
|
|
354
|
+
refreshWallet().then(renderWelcome).catch((error) => toast(error.message, true));
|
|
277
355
|
refreshNetwork().catch(() => {});
|
|
278
356
|
loadModels().catch(() => {});
|
|
279
357
|
document.querySelector(`nav button[data-tab="${location.hash.slice(1)}"]`)?.click();
|