mycomesh-consumer 0.9.2 → 0.9.4

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,10 +23,16 @@ 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
29
32
  this machine can call it: other websites and DNS-rebinding hosts are refused.
33
+ - `tenant add NAME --budget UNITS` gives a tenant its own payment key, capped on-chain, and an API key
34
+ usable from any host (`tenant list | budget | revoke`).
35
+ - `rewards [claim]` shows and claims the MYCO earned by paying fees.
30
36
  - `withdraw [UNITS]` requests a withdrawal; run it again after the delay to receive the funds.
31
37
  - `dispute <settlement-key|last> --statement "..."`
32
38
  reveals a recorded request and response to a randomly drawn Provider-AI jury
@@ -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.2",
3
+ "version": "0.9.4",
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
@@ -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;
@@ -198,6 +200,11 @@ export class Consumer {
198
200
  return providers;
199
201
  }
200
202
 
203
+ /** The network's model catalog (manifest "models"): vendor, tier, capabilities. */
204
+ catalog() {
205
+ return this.network.models || {};
206
+ }
207
+
201
208
  async models() {
202
209
  const names = new Set();
203
210
  for (const relay of await this.relays()) {
@@ -279,20 +286,50 @@ async function readJson(req) {
279
286
  return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
280
287
  }
281
288
 
282
- /** Local OpenAI-compatible endpoint; nothing leaves this machine unsealed except pricing and routing. */
289
+ /** Anthropic Messages and Gemini generateContent: translated to a chat request, answered in the client's dialect. */
290
+ async function dialectRequest(req, res, active, path, setWriter) {
291
+ const body = await readJson(req);
292
+ const gemini = geminiRoute(path);
293
+ const ask = gemini ? fromGemini(body, gemini.model) : fromAnthropic(body);
294
+ const stream = gemini ? gemini.stream : ask.stream;
295
+ const request = { endpoint: "chat", model: ask.model, content: ask.messages, maxOutputTokens: ask.maxOutputTokens, options: ask.options };
296
+ if (!stream) {
297
+ const { response, receipt } = await active.request(request);
298
+ const answer = outputText(response.output);
299
+ const out = gemini ? toGemini(ask.model, answer, receipt.receipt) : toAnthropic(ask.model, answer, receipt.receipt);
300
+ res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
301
+ return res.end(JSON.stringify(out));
302
+ }
303
+ const write = (chunk) => {
304
+ if (!res.headersSent) res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
305
+ res.write(chunk);
306
+ };
307
+ const writer = gemini ? geminiStream(write, ask.model) : anthropicStream(write, ask.model);
308
+ setWriter(writer);
309
+ let streamedAny = false;
310
+ const { response, receipt } = await active.request({ ...request, onDelta: (piece) => { streamedAny ||= Boolean(piece); writer.delta(piece); } });
311
+ writer.finish(outputText(response.output), receipt.receipt, streamedAny);
312
+ return res.end();
313
+ }
314
+
315
+ /** Local OpenAI-compatible endpoint (plus the Anthropic and Gemini dialects); nothing leaves this machine unsealed except pricing and routing. */
283
316
  export function serveConsumer(consumer, { host = "127.0.0.1", port = 8110, apiKey, dataDir, tenants = () => ({}) } = {}) {
284
317
  const routes = dataDir ? consoleRoutes({ consumer, dataDir }) : {};
285
- const bearer = (req) => /^Bearer (.+)$/.exec(String(req.headers.authorization || ""))?.[1];
318
+ // Every dialect's way of presenting a key: OpenAI Bearer, Anthropic x-api-key, Gemini x-goog-api-key or ?key=.
319
+ const bearer = (req) => /^Bearer (.+)$/.exec(String(req.headers.authorization || ""))?.[1]
320
+ || req.headers["x-api-key"] || req.headers["x-goog-api-key"] || new URL(req.url, "http://localhost").searchParams.get("key") || undefined;
286
321
  const server = createServer(async (req, res) => {
287
322
  let writer = null;
323
+ let dialect = "openai";
288
324
  const send = (status, body, type = "application/json") => {
289
325
  res.writeHead(status, { "content-type": type, "cache-control": "no-store" });
290
326
  res.end(type === "application/json" ? JSON.stringify(body) : body);
291
327
  };
292
328
  try {
293
329
  const path = new URL(req.url, "http://localhost").pathname.replace(/^\/v1\/v1\//, "/v1/");
330
+ dialect = path === "/v1/messages" ? "anthropic" : geminiRoute(path) ? "gemini" : "openai";
294
331
  // 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;
332
+ const tenant = /^\/v1(beta|alpha)?\//.test(path) && bearer(req) ? tenants()[hashApiKey(bearer(req))] : undefined;
296
333
  if (!tenant && !localRequestAllowed(req, server.address().port)) {
297
334
  return send(403, openaiError("only this machine may use the local node", "forbidden"));
298
335
  }
@@ -305,12 +342,16 @@ export function serveConsumer(consumer, { host = "127.0.0.1", port = 8110, apiKe
305
342
  const result = await route(req.method === "POST" ? await readJson(req) : {});
306
343
  return result?.type ? send(200, result.body, result.type) : send(200, result);
307
344
  }
308
- if (!tenant && bearer(req) && bearer(req) !== apiKey) return send(401, openaiError("invalid API key", "unauthorized"));
345
+ // Without a configured key the local node trusts this machine (Host and Origin are checked above), so
346
+ // clients that insist on sending some key (Claude Code, the Gemini CLI) still work.
309
347
  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
348
  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" })) });
349
+ const catalog = active.catalog?.() || {};
350
+ return send(200, { object: "list", data: (await active.models()).map((model) => ({
351
+ id: model, object: "model", owned_by: "mycomesh", ...(catalog[model] ? { mycomesh: catalog[model] } : {}) })) });
313
352
  }
353
+ if (req.method === "POST" && dialect !== "openai") return await dialectRequest(req, res, active, path, (w) => { writer = w; });
354
+ if (req.method === "GET" && path === "/health") return send(200, { ok: true, protocol: 11, key: consumer.key });
314
355
  if (req.method !== "POST" || !["/v1/responses", "/v1/chat/completions", "/responses", "/chat/completions"].includes(path)) {
315
356
  return send(404, openaiError("not found", "not_found"));
316
357
  }
@@ -345,6 +386,8 @@ export function serveConsumer(consumer, { host = "127.0.0.1", port = 8110, apiKe
345
386
  return send(error.status, { error: error.message });
346
387
  }
347
388
  const status = error.code === "outcome_unknown" ? 504 : (error.status && error.status < 500 ? error.status : 502);
389
+ if (dialect === "anthropic") return send(status, anthropicError(error.message, status));
390
+ if (dialect === "gemini") return send(status, geminiError(error.message, status));
348
391
  return send(status, openaiError(error.message, error.code || "request_failed"));
349
392
  }
350
393
  });
@@ -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 = "";