mycomesh-consumer 0.9.3 → 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 +3 -0
- package/networks/mycomesh-v11-sepolia.json +77 -0
- package/package.json +1 -1
- package/src/consumer.mjs +49 -6
- 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
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|
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 = "";
|