mycomesh-consumer 0.9.3 → 0.9.5

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/README.md CHANGED
@@ -23,6 +23,9 @@ mycomesh-consumer serve # http://127.0.0.1:8110/v1 (responses, chat/co
23
23
  - `balance` shows the deposit.
24
24
  - `request "..." [--model gpt-5.5] [--provider SIGNER]` prints the verified answer,
25
25
  the fee and the settlement key.
26
+ - `serve` also speaks the Anthropic Messages API (`/v1/messages`) and the Gemini API
27
+ (`/v1beta/models/{model}:generateContent` / `:streamGenerateContent`), streaming included, for any model
28
+ on the network. Text only for now: no tool calls yet. `GET /v1/models` returns the model catalog.
26
29
  - `serve` runs the local web console at `http://127.0.0.1:8110/` (chat, wallet, deposit and
27
30
  withdrawal, request history and disputes, network status) and an OpenAI-compatible endpoint for
28
31
  Codex and other clients; set `MYCOMESH_CONSUMER_API_KEY` to require a bearer token. Only pages from
@@ -12,6 +12,83 @@
12
12
  "emission_block": 11815544,
13
13
  "oracle": "0x5ba28de9415c7bc2d14f3f31a12fbca226e38a8f",
14
14
  "oracle_block": 11820984,
15
+ "models": {
16
+ "gpt-5.5": {
17
+ "tier": 1,
18
+ "vendor": "OpenAI",
19
+ "name": "GPT-5.5",
20
+ "reasoning_efforts": [
21
+ "low",
22
+ "medium",
23
+ "high",
24
+ "xhigh"
25
+ ],
26
+ "summary": "OpenAI frontier model; reasoning effort selectable per request (options.reasoning.effort)",
27
+ "tier_name": "OpenAI frontier",
28
+ "capabilities": {
29
+ "input": [
30
+ "text"
31
+ ],
32
+ "output": [
33
+ "text"
34
+ ],
35
+ "streaming": true,
36
+ "tools": false,
37
+ "dialects": [
38
+ "openai-responses",
39
+ "openai-chat",
40
+ "anthropic-messages",
41
+ "gemini-generate-content"
42
+ ]
43
+ }
44
+ },
45
+ "claude-sonnet-4-6": {
46
+ "tier": 2,
47
+ "vendor": "Anthropic",
48
+ "name": "Claude Sonnet 4.6",
49
+ "summary": "Anthropic's balanced model for everyday and coding work",
50
+ "tier_name": "Anthropic Claude",
51
+ "capabilities": {
52
+ "input": [
53
+ "text"
54
+ ],
55
+ "output": [
56
+ "text"
57
+ ],
58
+ "streaming": true,
59
+ "tools": false,
60
+ "dialects": [
61
+ "openai-responses",
62
+ "openai-chat",
63
+ "anthropic-messages",
64
+ "gemini-generate-content"
65
+ ]
66
+ }
67
+ },
68
+ "claude-opus-4-8": {
69
+ "tier": 2,
70
+ "vendor": "Anthropic",
71
+ "name": "Claude Opus 4.8",
72
+ "summary": "Anthropic's most capable model",
73
+ "tier_name": "Anthropic Claude",
74
+ "capabilities": {
75
+ "input": [
76
+ "text"
77
+ ],
78
+ "output": [
79
+ "text"
80
+ ],
81
+ "streaming": true,
82
+ "tools": false,
83
+ "dialects": [
84
+ "openai-responses",
85
+ "openai-chat",
86
+ "anthropic-messages",
87
+ "gemini-generate-content"
88
+ ]
89
+ }
90
+ }
91
+ },
15
92
  "tiers": {
16
93
  "1": {
17
94
  "name": "OpenAI frontier",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mycomesh-consumer",
3
- "version": "0.9.3",
3
+ "version": "0.9.5",
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/consumer.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // V11 Consumer runtime: one custodied deposit, any Provider, relay-blind requests.
2
2
  import { readFileSync } from "node:fs";
3
- import { createServer, request as httpRequest } from "node:http";
4
- import { request as httpsRequest } from "node:https";
3
+ import { Agent as HttpAgent, createServer, request as httpRequest } from "node:http";
4
+ import { Agent as HttpsAgent, request as httpsRequest } from "node:https";
5
5
  import { dirname, resolve } from "node:path";
6
6
  import { rootCertificates } from "node:tls";
7
7
  import { addressOf, encodeCall } from "./eip712.mjs";
@@ -13,6 +13,8 @@ import { consoleRoutes, localRequestAllowed } from "./console.mjs";
13
13
  import { pinnedOptions, splitPin } from "./tlspin.mjs";
14
14
  import { compareRank, rankKey, reputation } from "./reputation.mjs";
15
15
  import { hashApiKey } from "./tenants.mjs";
16
+ import { anthropicError, anthropicStream, fromAnthropic, fromGemini, geminiError, geminiRoute, geminiStream,
17
+ toAnthropic, toGemini } from "./dialects.mjs";
16
18
 
17
19
  const PROVIDER_CACHE_MS = 30_000;
18
20
  const RELAY_CACHE_MS = 300_000;
@@ -26,10 +28,19 @@ export function loadNetwork(path) {
26
28
  return network;
27
29
  }
28
30
 
31
+ /**
32
+ * A fresh connection per request, through an agent of its own: a reused keep-alive socket that the Relay's
33
+ * edge has just closed fails with ECONNRESET after the request may have been sent, which can only be
34
+ * reported as outcome unknown. (Only an agent's own createConnection is honoured, hence the pinned one here.)
35
+ */
29
36
  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] } : {};
37
+ if (target.protocol !== "https:") return { agent: new HttpAgent({ keepAlive: false }) };
38
+ const agent = new HttpsAgent({ keepAlive: false });
39
+ if (pin) {
40
+ agent.createConnection = pinnedOptions(target.href, pin).createConnection;
41
+ return { agent };
42
+ }
43
+ return ca ? { agent, ca: [...rootCertificates, ca] } : { agent };
33
44
  }
34
45
 
35
46
  export async function httpJson(url, { method = "GET", body, ca, pin, timeoutMs = 330_000 } = {}) {
@@ -54,7 +65,10 @@ export async function httpJson(url, { method = "GET", body, ca, pin, timeoutMs =
54
65
  catch (error) { reject(error); }
55
66
  });
56
67
  });
57
- req.setTimeout(timeoutMs, () => req.destroy(new Error("request timed out")));
68
+ // A wall-clock deadline, not a socket idle timeout: keep-alive sockets are reused, and an idle timeout
69
+ // left on one by an earlier short request (a 10 s /providers call) would later cut a long answer.
70
+ const timer = setTimeout(() => req.destroy(new Error(`request to ${target.host}${target.pathname} timed out after ${timeoutMs / 1000} s`)), timeoutMs);
71
+ req.on("close", () => clearTimeout(timer));
58
72
  req.on("error", reject);
59
73
  if (data) req.write(data);
60
74
  req.end();
@@ -94,7 +108,10 @@ export async function httpNdjson(url, { body, ca, pin, timeoutMs = 340_000, onLi
94
108
  try { resolvePromise({ status: res.statusCode, body: JSON.parse(chunks.join("") || "{}") }); } catch (error) { reject(error); }
95
109
  });
96
110
  });
97
- req.setTimeout(timeoutMs, () => req.destroy(new Error("request timed out")));
111
+ // A wall-clock deadline, not a socket idle timeout: keep-alive sockets are reused, and an idle timeout
112
+ // left on one by an earlier short request (a 10 s /providers call) would later cut a long answer.
113
+ const timer = setTimeout(() => req.destroy(new Error(`request to ${target.host}${target.pathname} timed out after ${timeoutMs / 1000} s`)), timeoutMs);
114
+ req.on("close", () => clearTimeout(timer));
98
115
  req.on("error", reject);
99
116
  req.end(data);
100
117
  });
@@ -127,6 +144,7 @@ export class Consumer {
127
144
  constructor({ network, keyPrivate, maxFee, journalDir, fetchJson = httpJson, streamJson = httpNdjson, now = () => Math.floor(Date.now() / 1000) }) {
128
145
  this.streamJson = streamJson;
129
146
  this.journalDir = journalDir;
147
+ this.inFlight = new Map(); // Provider signer -> requests this Consumer has with it now
130
148
  this.network = network;
131
149
  this.keyPrivate = keyPrivate;
132
150
  this.key = addressOf(keyPrivate);
@@ -198,6 +216,11 @@ export class Consumer {
198
216
  return providers;
199
217
  }
200
218
 
219
+ /** The network's model catalog (manifest "models"): vendor, tier, capabilities. */
220
+ catalog() {
221
+ return this.network.models || {};
222
+ }
223
+
201
224
  async models() {
202
225
  const names = new Set();
203
226
  for (const relay of await this.relays()) {
@@ -206,8 +229,24 @@ export class Consumer {
206
229
  return [...names].sort();
207
230
  }
208
231
 
209
- /** Try Relays in order; fail over only while the request provably was not dispatched. */
210
- async request({ endpoint, model, content, maxOutputTokens = 4096, options = {}, provider, onDelta }) {
232
+ /**
233
+ * Try Relays and Providers in order; fail over only while the request provably was not dispatched. When
234
+ * every Provider is busy, wait and try again (a short queue) rather than failing a burst of requests.
235
+ */
236
+ async request(args) {
237
+ const deadline = Date.now() + (this.queueMs ?? 120_000);
238
+ for (let attempt = 0; ; attempt += 1) {
239
+ try {
240
+ return await this.attempt(args);
241
+ } catch (error) {
242
+ const busy = error.dispatched === false && [502, 503].includes(error.status);
243
+ if (!busy || Date.now() > deadline) throw error;
244
+ await new Promise((resolve) => setTimeout(resolve, Math.min(4_000, 500 * 2 ** attempt) * (0.5 + Math.random())));
245
+ }
246
+ }
247
+ }
248
+
249
+ async attempt({ endpoint, model, content, maxOutputTokens = 4096, options = {}, provider, onDelta }) {
211
250
  let lastError;
212
251
  for (const relay of await this.relays()) {
213
252
  let candidates;
@@ -216,9 +255,18 @@ export class Consumer {
216
255
  && (!provider || descriptor.provider_signer === provider.toLowerCase()));
217
256
  }
218
257
  catch (error) { lastError = error; continue; }
219
- // What the chain proves ranks first: recent fraud, verified probe failures, reputation; then price.
258
+ // What the chain proves ranks first (recent fraud, a suspected downgrade, verified probe failures); then
259
+ // Providers this Consumer is not already keeping busy; then reputation in coarse bands, shuffled within a
260
+ // band so that many Consumers spread over the network instead of all queueing at its single best Provider.
220
261
  const records = await this.reputations().catch(() => ({}));
221
- candidates.sort((a, b) => compareRank(rankKey(a, records[a.provider_signer]), rankKey(b, records[b.provider_signer])));
262
+ const key = (descriptor) => {
263
+ const [fraud, downgrade, failure, volume] = rankKey(descriptor, records[descriptor.provider_signer]);
264
+ const busy = (this.inFlight.get(descriptor.provider_signer) || 0) >= Math.max(1, Number(descriptor.capacity) || 1) ? 1 : 0;
265
+ return [fraud, downgrade, busy, failure, -Math.floor(Math.log10(1 - volume)), Math.random()];
266
+ };
267
+ const keyed = candidates.map((descriptor) => [key(descriptor), descriptor]);
268
+ keyed.sort((a, b) => compareRank(a[0], b[0]));
269
+ candidates = keyed.map(([, descriptor]) => descriptor);
222
270
  for (const descriptor of candidates) {
223
271
  const prepared = prepareRequest({
224
272
  descriptor, deployment: this.deployment, keyPrivate: this.keyPrivate, relaySigner: relay.signer,
@@ -227,6 +275,8 @@ export class Consumer {
227
275
  let reply;
228
276
  const streamed = [];
229
277
  let final = null;
278
+ const signer = descriptor.provider_signer;
279
+ this.inFlight.set(signer, (this.inFlight.get(signer) || 0) + 1);
230
280
  try {
231
281
  reply = onDelta
232
282
  ? await this.streamJson(`${relay.url}/v11/requests`, { body: prepared.payload, ca: this.network.tls_ca, pin: relay.pin, onLine: (line) => {
@@ -241,11 +291,15 @@ export class Consumer {
241
291
  } catch (error) {
242
292
  // The request may have reached the Relay: never replay it elsewhere.
243
293
  throw Object.assign(new Error(`request outcome unknown: ${error.message}`), { code: "outcome_unknown", requestId: prepared.authorization.request_id });
294
+ } finally {
295
+ this.inFlight.set(signer, (this.inFlight.get(signer) || 1) - 1);
244
296
  }
245
297
  if (reply.streamed) {
246
298
  if (!final) throw Object.assign(new Error("stream ended without a result"), { code: "outcome_unknown" });
247
- if (final.type === "error") throw Object.assign(new Error(final.error), { status: final.status, dispatched: final.dispatched });
248
- reply = { status: 200, body: final };
299
+ // An error before dispatch (a busy Provider, say) is safe to fail over, exactly as without streaming.
300
+ reply = final.type === "error"
301
+ ? { status: final.status || 502, body: { error: final.error, dispatched: final.dispatched } }
302
+ : { status: 200, body: final };
249
303
  }
250
304
  if (reply.status === 200) {
251
305
  const opened = openResponse(prepared, reply.body, this.deployment, this.now());
@@ -256,7 +310,8 @@ export class Consumer {
256
310
  if (this.journalDir) recordRequest(this.journalDir, prepared, opened, relay.url);
257
311
  return opened;
258
312
  }
259
- lastError = Object.assign(new Error(reply.body?.error || `Relay returned ${reply.status}`), { status: reply.status });
313
+ lastError = Object.assign(new Error(reply.body?.error || `Relay returned ${reply.status}`),
314
+ { status: reply.status, dispatched: reply.body?.dispatched });
260
315
  if (reply.body?.dispatched !== false || ![502, 503].includes(reply.status)) throw lastError;
261
316
  }
262
317
  }
@@ -279,20 +334,50 @@ async function readJson(req) {
279
334
  return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
280
335
  }
281
336
 
282
- /** Local OpenAI-compatible endpoint; nothing leaves this machine unsealed except pricing and routing. */
337
+ /** Anthropic Messages and Gemini generateContent: translated to a chat request, answered in the client's dialect. */
338
+ async function dialectRequest(req, res, active, path, setWriter) {
339
+ const body = await readJson(req);
340
+ const gemini = geminiRoute(path);
341
+ const ask = gemini ? fromGemini(body, gemini.model) : fromAnthropic(body);
342
+ const stream = gemini ? gemini.stream : ask.stream;
343
+ const request = { endpoint: "chat", model: ask.model, content: ask.messages, maxOutputTokens: ask.maxOutputTokens, options: ask.options };
344
+ if (!stream) {
345
+ const { response, receipt } = await active.request(request);
346
+ const answer = outputText(response.output);
347
+ const out = gemini ? toGemini(ask.model, answer, receipt.receipt) : toAnthropic(ask.model, answer, receipt.receipt);
348
+ res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
349
+ return res.end(JSON.stringify(out));
350
+ }
351
+ const write = (chunk) => {
352
+ if (!res.headersSent) res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
353
+ res.write(chunk);
354
+ };
355
+ const writer = gemini ? geminiStream(write, ask.model) : anthropicStream(write, ask.model);
356
+ setWriter(writer);
357
+ let streamedAny = false;
358
+ const { response, receipt } = await active.request({ ...request, onDelta: (piece) => { streamedAny ||= Boolean(piece); writer.delta(piece); } });
359
+ writer.finish(outputText(response.output), receipt.receipt, streamedAny);
360
+ return res.end();
361
+ }
362
+
363
+ /** Local OpenAI-compatible endpoint (plus the Anthropic and Gemini dialects); nothing leaves this machine unsealed except pricing and routing. */
283
364
  export function serveConsumer(consumer, { host = "127.0.0.1", port = 8110, apiKey, dataDir, tenants = () => ({}) } = {}) {
284
365
  const routes = dataDir ? consoleRoutes({ consumer, dataDir }) : {};
285
- const bearer = (req) => /^Bearer (.+)$/.exec(String(req.headers.authorization || ""))?.[1];
366
+ // Every dialect's way of presenting a key: OpenAI Bearer, Anthropic x-api-key, Gemini x-goog-api-key or ?key=.
367
+ const bearer = (req) => /^Bearer (.+)$/.exec(String(req.headers.authorization || ""))?.[1]
368
+ || req.headers["x-api-key"] || req.headers["x-goog-api-key"] || new URL(req.url, "http://localhost").searchParams.get("key") || undefined;
286
369
  const server = createServer(async (req, res) => {
287
370
  let writer = null;
371
+ let dialect = "openai";
288
372
  const send = (status, body, type = "application/json") => {
289
373
  res.writeHead(status, { "content-type": type, "cache-control": "no-store" });
290
374
  res.end(type === "application/json" ? JSON.stringify(body) : body);
291
375
  };
292
376
  try {
293
377
  const path = new URL(req.url, "http://localhost").pathname.replace(/^\/v1\/v1\//, "/v1/");
378
+ dialect = path === "/v1/messages" ? "anthropic" : geminiRoute(path) ? "gemini" : "openai";
294
379
  // A tenant's API key selects its own payment key and budget; tenants may call from anywhere.
295
- const tenant = path.startsWith("/v1/") && bearer(req) ? tenants()[hashApiKey(bearer(req))] : undefined;
380
+ const tenant = /^\/v1(beta|alpha)?\//.test(path) && bearer(req) ? tenants()[hashApiKey(bearer(req))] : undefined;
296
381
  if (!tenant && !localRequestAllowed(req, server.address().port)) {
297
382
  return send(403, openaiError("only this machine may use the local node", "forbidden"));
298
383
  }
@@ -305,12 +390,16 @@ export function serveConsumer(consumer, { host = "127.0.0.1", port = 8110, apiKe
305
390
  const result = await route(req.method === "POST" ? await readJson(req) : {});
306
391
  return result?.type ? send(200, result.body, result.type) : send(200, result);
307
392
  }
308
- if (!tenant && bearer(req) && bearer(req) !== apiKey) return send(401, openaiError("invalid API key", "unauthorized"));
393
+ // Without a configured key the local node trusts this machine (Host and Origin are checked above), so
394
+ // clients that insist on sending some key (Claude Code, the Gemini CLI) still work.
309
395
  if (!tenant && apiKey && bearer(req) !== apiKey) return send(401, openaiError("invalid API key", "unauthorized"));
310
- if (req.method === "GET" && path === "/health") return send(200, { ok: true, protocol: 11, key: consumer.key });
311
396
  if (req.method === "GET" && path === "/v1/models") {
312
- return send(200, { object: "list", data: (await active.models()).map((id) => ({ id, object: "model", owned_by: "mycomesh" })) });
397
+ const catalog = active.catalog?.() || {};
398
+ return send(200, { object: "list", data: (await active.models()).map((model) => ({
399
+ id: model, object: "model", owned_by: "mycomesh", ...(catalog[model] ? { mycomesh: catalog[model] } : {}) })) });
313
400
  }
401
+ if (req.method === "POST" && dialect !== "openai") return await dialectRequest(req, res, active, path, (w) => { writer = w; });
402
+ if (req.method === "GET" && path === "/health") return send(200, { ok: true, protocol: 11, key: consumer.key });
314
403
  if (req.method !== "POST" || !["/v1/responses", "/v1/chat/completions", "/responses", "/chat/completions"].includes(path)) {
315
404
  return send(404, openaiError("not found", "not_found"));
316
405
  }
@@ -345,6 +434,8 @@ export function serveConsumer(consumer, { host = "127.0.0.1", port = 8110, apiKe
345
434
  return send(error.status, { error: error.message });
346
435
  }
347
436
  const status = error.code === "outcome_unknown" ? 504 : (error.status && error.status < 500 ? error.status : 502);
437
+ if (dialect === "anthropic") return send(status, anthropicError(error.message, status));
438
+ if (dialect === "gemini") return send(status, geminiError(error.message, status));
348
439
  return send(status, openaiError(error.message, error.code || "request_failed"));
349
440
  }
350
441
  });
@@ -0,0 +1,135 @@
1
+ // Client dialects for the local endpoint: Anthropic Messages and Gemini generateContent, translated to and
2
+ // from the network's chat requests (OpenAI shape), streaming included. Token counts come from the
3
+ // Provider-signed receipt, so every client sees what it was charged for.
4
+ import { randomUUID } from "node:crypto";
5
+
6
+ const id = (prefix) => `${prefix}${randomUUID().replace(/-/g, "")}`;
7
+
8
+ export class DialectError extends Error {
9
+ constructor(message, status = 400) { super(message); this.status = status; }
10
+ }
11
+
12
+ /** Text of Anthropic or Gemini content: a string or text blocks/parts. Other block kinds are refused. */
13
+ function text(content, where) {
14
+ if (typeof content === "string") return content;
15
+ if (!Array.isArray(content)) throw new DialectError(`${where}: content must be text`);
16
+ return content.map((part) => {
17
+ if (typeof part === "string") return part;
18
+ if (part && typeof part.text === "string" && (part.type === undefined || part.type === "text")) return part.text;
19
+ if (part?.type === "thinking" || part?.type === "redacted_thinking" || part?.thought) return "";
20
+ throw new DialectError(`${where}: only text content is supported (got ${part?.type || Object.keys(part || {})[0] || "unknown"})`);
21
+ }).join("");
22
+ }
23
+
24
+ const unsupportedTools = (present) => {
25
+ if (present) throw new DialectError("tool use is not supported on this network yet; send text-only requests");
26
+ };
27
+
28
+ // ---------------- Anthropic Messages ----------------
29
+
30
+ /** POST /v1/messages body -> { model, messages, maxOutputTokens, options, stream } */
31
+ export function fromAnthropic(body) {
32
+ if (typeof body.model !== "string" || !Array.isArray(body.messages)) throw new DialectError("model and messages are required");
33
+ unsupportedTools(body.tools?.length || body.tool_choice);
34
+ const messages = [];
35
+ if (body.system !== undefined) messages.push({ role: "system", content: text(body.system, "system") });
36
+ for (const message of body.messages) {
37
+ if (!["user", "assistant"].includes(message?.role)) throw new DialectError("messages alternate user and assistant");
38
+ messages.push({ role: message.role, content: text(message.content, `${message.role} message`) });
39
+ }
40
+ const options = {};
41
+ for (const key of ["temperature", "top_p"]) if (body[key] !== undefined) options[key] = body[key];
42
+ if (body.stop_sequences) options.stop = body.stop_sequences;
43
+ return { model: body.model, messages, maxOutputTokens: body.max_tokens ?? 4096, options, stream: body.stream === true };
44
+ }
45
+
46
+ export function toAnthropic(model, answer, receipt) {
47
+ return {
48
+ id: id("msg_"), type: "message", role: "assistant", model,
49
+ content: [{ type: "text", text: answer }], stop_reason: "end_turn", stop_sequence: null,
50
+ usage: { input_tokens: Number(receipt.input_tokens), output_tokens: Number(receipt.output_tokens) },
51
+ };
52
+ }
53
+
54
+ /** Anthropic's SSE event sequence, fed with text as it arrives. */
55
+ export function anthropicStream(write, model) {
56
+ const event = (type, data) => write(`event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`);
57
+ let started = false;
58
+ const start = () => {
59
+ if (started) return;
60
+ started = true;
61
+ event("message_start", { message: { id: id("msg_"), type: "message", role: "assistant", model, content: [],
62
+ stop_reason: null, stop_sequence: null, usage: { input_tokens: 0, output_tokens: 0 } } });
63
+ event("content_block_start", { index: 0, content_block: { type: "text", text: "" } });
64
+ };
65
+ return {
66
+ delta(piece) { start(); if (piece) event("content_block_delta", { index: 0, delta: { type: "text_delta", text: piece } }); },
67
+ finish(answer, receipt, streamedAny) {
68
+ start();
69
+ if (!streamedAny && answer) event("content_block_delta", { index: 0, delta: { type: "text_delta", text: answer } });
70
+ event("content_block_stop", { index: 0 });
71
+ event("message_delta", { delta: { stop_reason: "end_turn", stop_sequence: null },
72
+ usage: { input_tokens: Number(receipt.input_tokens), output_tokens: Number(receipt.output_tokens) } });
73
+ event("message_stop", {});
74
+ },
75
+ fail(message) { event("error", { error: { type: "api_error", message } }); },
76
+ };
77
+ }
78
+
79
+ export const anthropicError = (message, status) => ({ type: "error", error: {
80
+ type: status === 401 || status === 403 ? "authentication_error" : status === 404 ? "not_found_error"
81
+ : status === 402 ? "billing_error" : status >= 500 ? "api_error" : "invalid_request_error", message } });
82
+
83
+ // ---------------- Gemini generateContent ----------------
84
+
85
+ const GEMINI_PATH = /^\/(?:v1|v1beta|v1alpha)\/models\/([^/:]+):(generateContent|streamGenerateContent)$/;
86
+
87
+ /** The model and whether it streams, for a Gemini path; null for any other path. */
88
+ export function geminiRoute(path) {
89
+ const match = GEMINI_PATH.exec(path);
90
+ return match ? { model: decodeURIComponent(match[1]), stream: match[2] === "streamGenerateContent" } : null;
91
+ }
92
+
93
+ export function fromGemini(body, model) {
94
+ if (!Array.isArray(body.contents)) throw new DialectError("contents are required");
95
+ unsupportedTools(body.tools?.length);
96
+ const messages = [];
97
+ const system = body.systemInstruction || body.system_instruction;
98
+ if (system) messages.push({ role: "system", content: text(system.parts ?? system, "systemInstruction") });
99
+ for (const item of body.contents) {
100
+ const role = item.role === "model" ? "assistant" : "user";
101
+ messages.push({ role, content: text(item.parts, `${item.role || "user"} content`) });
102
+ }
103
+ const config = body.generationConfig || body.generation_config || {};
104
+ const options = {};
105
+ if (config.temperature !== undefined) options.temperature = config.temperature;
106
+ if (config.topP !== undefined) options.top_p = config.topP;
107
+ if (config.stopSequences) options.stop = config.stopSequences;
108
+ return { model, messages, maxOutputTokens: config.maxOutputTokens ?? 4096, options };
109
+ }
110
+
111
+ function geminiChunk(model, piece, receipt) {
112
+ const chunk = { candidates: [{ content: { role: "model", parts: [{ text: piece }] }, index: 0 }], modelVersion: model };
113
+ if (receipt) {
114
+ chunk.candidates[0].finishReason = "STOP";
115
+ const input = Number(receipt.input_tokens); const output = Number(receipt.output_tokens);
116
+ chunk.usageMetadata = { promptTokenCount: input, candidatesTokenCount: output, totalTokenCount: input + output };
117
+ }
118
+ return chunk;
119
+ }
120
+
121
+ export const toGemini = (model, answer, receipt) => geminiChunk(model, answer, receipt);
122
+
123
+ /** streamGenerateContent with alt=sse: one JSON chunk per event; the last carries finishReason and usage. */
124
+ export function geminiStream(write, model) {
125
+ const chunk = (value) => write(`data: ${JSON.stringify(value)}\n\n`);
126
+ return {
127
+ delta(piece) { if (piece) chunk(geminiChunk(model, piece)); },
128
+ finish(answer, receipt, streamedAny) { chunk(geminiChunk(model, streamedAny ? "" : answer, receipt)); },
129
+ fail(message) { chunk({ error: { code: 500, message, status: "INTERNAL" } }); },
130
+ };
131
+ }
132
+
133
+ export const geminiError = (message, status) => ({ error: { code: status, message,
134
+ status: status === 400 ? "INVALID_ARGUMENT" : status === 401 || status === 403 ? "PERMISSION_DENIED"
135
+ : status === 404 ? "NOT_FOUND" : status === 402 ? "RESOURCE_EXHAUSTED" : "INTERNAL" } });
package/src/pricing.mjs CHANGED
@@ -26,6 +26,7 @@ export async function networkPrices(network) {
26
26
  const scale = (value) => ((value * multiplier + UNIT - 1n) / UNIT).toString();
27
27
  tiers.push({
28
28
  tier: Number(tier), name: meta.name, models: meta.models, target_bps: Number(targetBps),
29
+ catalog: Object.fromEntries((meta.models || []).map((model) => [model, network.models?.[model] || null])),
29
30
  multiplier: Number(multiplier), multiplier_yesterday: Number(previous),
30
31
  prices: { input_per_1k: scale(baseIn), output_per_1k: scale(baseOut), minimum_fee: scale(minFee) },
31
32
  yesterday: { demand: demand.toString(), supply: supply.toString(), utilization_bps: supply ? Number((demand * 10_000n) / supply) : 0 },
@@ -132,6 +132,10 @@
132
132
  <p class="muted">全网统一价,像比特币难度一样每天自动调整:前一天的利用率(成交 ÷ 在线产能)高于目标就涨,低于就降,单日最多 ±10%。</p>
133
133
  <table style="margin-bottom:22px"><thead><tr><th>档位</th><th>模型</th><th>输入 / 1k</th><th>输出 / 1k</th><th>最低</th><th>价格系数</th><th>昨日利用率 / 目标</th></tr></thead>
134
134
  <tbody id="pricing"></tbody></table>
135
+ <h2>模型目录</h2>
136
+ <p class="muted">网络上有哪些模型、属于哪个档位、能做什么。价格见上表(同档位同价)。本机接口同时支持 OpenAI、Anthropic(<code>/v1/messages</code>)和 Gemini(<code>generateContent</code>)三种格式。</p>
137
+ <table style="margin-bottom:22px"><thead><tr><th>模型</th><th>厂商</th><th>档位</th><th>能力</th><th>说明</th><th>在线 Provider</th></tr></thead>
138
+ <tbody id="catalog"></tbody></table>
135
139
  <div id="network"></div>
136
140
  </section>
137
141
  </main>
@@ -370,10 +374,22 @@ async function refreshPricing() {
370
374
  <td>${fmt(t.prices.minimum_fee)}</td><td>${(t.multiplier / 1e6).toFixed(3)}× ${arrow}</td>
371
375
  <td>${(t.yesterday.utilization_bps / 100).toFixed(1)}% / ${(t.target_bps / 100).toFixed(0)}%</td></tr>`;
372
376
  }).join("") || `<tr><td colspan="7" class="muted">这个网络还没有启用网络定价。</td></tr>`;
377
+ const models = tiers.flatMap((t) => t.models.map((model) => ({ model, tier: t, meta: t.catalog?.[model] })));
378
+ const online = window.onlineModels || {};
379
+ const caps = (m) => !m ? "—" : [m.capabilities?.streaming ? "流式" : null, m.reasoning_efforts ? `推理强度 ${m.reasoning_efforts.join("/")}` : null,
380
+ m.capabilities?.tools ? "工具调用" : "暂不支持工具调用", `输入 ${(m.capabilities?.input || ["text"]).join("/")}`].filter(Boolean).join(" · ");
381
+ $("catalog").innerHTML = models.map(({ model, tier, meta }) => `<tr><td class="mono">${model}</td><td>${meta?.vendor || "—"}</td>
382
+ <td>${tier.name}</td><td>${caps(meta)}</td><td>${meta?.summary || ""}</td><td>${online[model] ?? "…"}</td></tr>`).join("")
383
+ || `<tr><td colspan="6" class="muted">清单里没有模型目录。</td></tr>`;
373
384
  }
374
385
  async function refreshNetwork() {
375
- refreshPricing().catch(() => {});
376
386
  const { relays } = await api("/api/network");
387
+ // Online Providers per model, for the catalog.
388
+ window.onlineModels = {};
389
+ for (const relay of relays) for (const p of relay.providers) for (const m of p.models) {
390
+ window.onlineModels[m] = (window.onlineModels[m] || 0) + (relay.healthy ? 1 : 0);
391
+ }
392
+ refreshPricing().catch(() => {});
377
393
  $("relays").textContent = `Relay ${relays.filter((r) => r.healthy).length}/${relays.length}`;
378
394
  $("relays").className = relays.some((r) => r.healthy) ? "badge ok" : "badge";
379
395
  $("network").innerHTML = "";