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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mycomesh-consumer",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "MycoMesh V11 Consumer: a custodied deposit, relay-blind encrypted requests to any Provider, and a local OpenAI-compatible endpoint.",
5
5
  "repository": {
6
6
  "type": "git",
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
- const keyPrivate = paymentKey(dir);
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: http://127.0.0.1:${port}/\n`);
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
- export function httpJson(url, { method = "GET", body, ca, timeoutMs = 330_000 } = {}) {
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
- ...(target.protocol === "https:" && ca ? { ca: [...rootCertificates, ca] } : {}),
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
- ...(target.protocol === "https:" && ca ? { ca: [...rootCertificates, ca] } : {}),
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
- relays.push({
108
- signer: `0x${entry.slice(64 + 24, 128)}`.toLowerCase(),
109
- url: decodeString(entry, Number(BigInt(`0x${entry.slice(128, 192)}`))).replace(/\/+$/, ""),
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
- candidates.sort((a, b) => (a.prices.output_per_1k - b.prices.output_per_1k) || (a.prices.input_per_1k - b.prices.input_per_1k));
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 (apiKey && req.headers.authorization !== `Bearer ${apiKey}`) return send(401, openaiError("invalid API key", "unauthorized"));
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 consumer.models()).map((id) => ({ id, object: "model", owned_by: "mycomesh" })) });
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 consumer.request({ endpoint: chat ? "chat" : "responses", model, content, maxOutputTokens, options: rest });
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 consumer.request({ endpoint: chat ? "chat" : "responses", model, content, maxOutputTokens,
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
+ }
@@ -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
+ }
@@ -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 目录,本机逐个核对签名身份。价格单位:每 1000 个 token 的 tUSDC。</p>
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 ? "还没有押金:先到“钱包”存入。" : `押金 ${fmt(w.deposit)} tUSDC`;
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></div>
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></tr>`).join("")
271
- || `<tr><td colspan="5" class="muted">没有已连接的 Provider</td></tr>`}</tbody></table>`;
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();