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 +3 -0
- package/networks/mycomesh-v11-sepolia.json +77 -0
- package/package.json +1 -1
- package/src/consumer.mjs +111 -20
- package/src/dialects.mjs +135 -0
- package/src/pricing.mjs +1 -0
- package/src/web/console.html +17 -1
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
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
210
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
248
|
-
reply =
|
|
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}`),
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|
package/src/dialects.mjs
ADDED
|
@@ -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 },
|
package/src/web/console.html
CHANGED
|
@@ -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 = "";
|