mycomesh-consumer 0.9.12 → 0.9.14
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/networks/mycomesh-v11-sepolia.json +3 -3
- package/package.json +1 -1
- package/src/account.mjs +3 -0
- package/src/chain.mjs +3 -1
- package/src/cli.mjs +40 -13
- package/src/console.mjs +18 -2
- package/src/consumer.mjs +38 -9
- package/src/dialects.mjs +234 -35
- package/src/protocol.mjs +6 -4
- package/src/sse.mjs +5 -1
- package/src/web/console.html +57 -47
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"text"
|
|
34
34
|
],
|
|
35
35
|
"streaming": true,
|
|
36
|
-
"tools":
|
|
36
|
+
"tools": true,
|
|
37
37
|
"dialects": [
|
|
38
38
|
"openai-responses",
|
|
39
39
|
"openai-chat",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"text"
|
|
57
57
|
],
|
|
58
58
|
"streaming": true,
|
|
59
|
-
"tools":
|
|
59
|
+
"tools": true,
|
|
60
60
|
"dialects": [
|
|
61
61
|
"openai-responses",
|
|
62
62
|
"openai-chat",
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
"text"
|
|
80
80
|
],
|
|
81
81
|
"streaming": true,
|
|
82
|
-
"tools":
|
|
82
|
+
"tools": true,
|
|
83
83
|
"dialects": [
|
|
84
84
|
"openai-responses",
|
|
85
85
|
"openai-chat",
|
package/package.json
CHANGED
package/src/account.mjs
CHANGED
|
@@ -46,9 +46,12 @@ export async function setup(network, { ownerPrivate, key, deposit, limit, forceF
|
|
|
46
46
|
await faucet(network, owner);
|
|
47
47
|
}
|
|
48
48
|
const send = (to, data) => sendTransaction(network.rpc_urls, ownerPrivate, { to, data });
|
|
49
|
+
log("approving the deposit (transaction 1 of 3)...");
|
|
49
50
|
await send(network.stablecoin, encodeCall("approve(address,uint256)", [["address", network.settlement], ["uint", deposit]]));
|
|
51
|
+
log("depositing (transaction 2 of 3)...");
|
|
50
52
|
await send(network.settlement, encodeCall("deposit(uint256)", [["uint", deposit]]));
|
|
51
53
|
if (!status.grant?.active || BigInt(status.grant.max_per_request) !== limit) {
|
|
54
|
+
log("authorizing this machine's payment key (transaction 3 of 3)...");
|
|
52
55
|
await send(network.settlement, encodeCall("registerKey(address,uint256,uint64)", [["address", key], ["uint", limit], ["uint", 0]]));
|
|
53
56
|
}
|
|
54
57
|
return { owner, key, deposit: deposit.toString(), max_per_request: limit.toString() };
|
package/src/chain.mjs
CHANGED
|
@@ -10,7 +10,9 @@ export async function rpcCall(urls, method, params, { timeoutMs = 15000 } = {})
|
|
|
10
10
|
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), signal: AbortSignal.timeout(timeoutMs),
|
|
11
11
|
});
|
|
12
12
|
const body = await response.json();
|
|
13
|
-
|
|
13
|
+
// An endpoint without the history asked for (pruned logs) is not an answer: try the next one.
|
|
14
|
+
const endpointOnly = /pruned|history unavailable|missing trie node|rate limit|too many requests/i.test(JSON.stringify(body.error ?? ""));
|
|
15
|
+
if (body.error) throw Object.assign(new Error(`${method}: ${JSON.stringify(body.error).slice(0, 300)}`), { answered: !endpointOnly });
|
|
14
16
|
return body.result;
|
|
15
17
|
} catch (error) {
|
|
16
18
|
if (error.answered) throw error;
|
package/src/cli.mjs
CHANGED
|
@@ -24,15 +24,15 @@ const USAGE = `Usage: mycomesh-consumer <command> [options]
|
|
|
24
24
|
address print the payment key address
|
|
25
25
|
wallet print the owner wallet address (a V3 keystore; MetaMask can import it)
|
|
26
26
|
faucet testnet: get gas ETH and tUSDC for the owner wallet
|
|
27
|
-
setup --deposit
|
|
27
|
+
setup [--deposit 20tUSDC] [--max-per-request 1tUSDC]
|
|
28
28
|
deposit into the settlement contract and authorize the payment key
|
|
29
|
-
(on testnets, tops the wallet up from the faucet first)
|
|
29
|
+
(on testnets, tops the wallet up from the faucet first; default 20 tUSDC)
|
|
30
30
|
balance [--owner ADDR] show the custodied deposit
|
|
31
31
|
rewards [claim] MYCO earned by the owner wallet (80% of each hour's emission goes to the
|
|
32
32
|
Consumers who paid that hour's fees); \`claim\` mints it to the wallet
|
|
33
33
|
withdraw [UNITS] request a withdrawal of the deposit; run again after the delay to receive it
|
|
34
|
-
request "prompt" [--stream]
|
|
35
|
-
send one request and print the verified answer
|
|
34
|
+
request "prompt" [--stream] [--json]
|
|
35
|
+
send one request and print the verified answer (JSON when piped or with --json)
|
|
36
36
|
serve [--port 8110] run the local web console and OpenAI-compatible endpoint (default)
|
|
37
37
|
tenant add NAME [--budget UNITS] [--max-per-request UNITS]
|
|
38
38
|
multi-tenant accounts: a payment key and API key per tenant, capped on-chain
|
|
@@ -41,10 +41,23 @@ const USAGE = `Usage: mycomesh-consumer <command> [options]
|
|
|
41
41
|
reveal a recorded request and response to a Provider-AI jury
|
|
42
42
|
(within 24 hours; the reporter bond is returned if fraud is confirmed)
|
|
43
43
|
|
|
44
|
+
Amounts: a bare integer is base units (1 tUSDC = 1000000); 20tUSDC or 20.5 means tUSDC.
|
|
44
45
|
Common: --network FILE (default: the bundled MycoMesh V11 Sepolia manifest), --data-dir DIR, --model ID,
|
|
45
46
|
--max-fee UNITS, --provider SIGNER, --owner-key-file F (raw key or keystore instead of the local wallet).
|
|
46
47
|
The wallet password comes from MYCOMESH_WALLET_PASSWORD or a terminal prompt.`;
|
|
47
48
|
|
|
49
|
+
// Amounts: a bare integer is base units (1e-6 tUSDC), as before; "20tUSDC", "20 usdc" or "20.5" are tUSDC.
|
|
50
|
+
export function parseAmount(value, name) {
|
|
51
|
+
const text = String(value).trim();
|
|
52
|
+
const match = /^([0-9]+(?:\.[0-9]{1,6})?)\s*(t?usdc)?$/i.exec(text);
|
|
53
|
+
if (!match) throw new Error(`${name}: write base units (20000000) or tUSDC (20tUSDC, 20.5)`);
|
|
54
|
+
if (!match[2] && !match[1].includes(".")) return BigInt(match[1]);
|
|
55
|
+
const [whole, fraction = ""] = match[1].split(".");
|
|
56
|
+
return BigInt(whole) * 1_000_000n + BigInt(fraction.padEnd(6, "0"));
|
|
57
|
+
}
|
|
58
|
+
export const tusdc = (units) => `${(Number(units) / 1e6).toLocaleString("en-US", { maximumFractionDigits: 6 })} tUSDC`;
|
|
59
|
+
const human = () => Boolean(process.stdout.isTTY) && !process.env.MYCOMESH_JSON;
|
|
60
|
+
|
|
48
61
|
function dataDir(value) {
|
|
49
62
|
const dir = value || process.env.MYCOMESH_CONSUMER_DATA_DIR || join(homedir(), ".mycomesh", "v11");
|
|
50
63
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
@@ -77,7 +90,7 @@ export async function main(argv = process.argv.slice(2), { stdout = process.stdo
|
|
|
77
90
|
port: { type: "string", default: "8110" }, host: { type: "string", default: "127.0.0.1" },
|
|
78
91
|
"max-output-tokens": { type: "string", default: "4096" }, help: { type: "boolean" },
|
|
79
92
|
reason: { type: "string", default: "unrelated_response" }, faucet: { type: "boolean" }, provider: { type: "string" },
|
|
80
|
-
statement: { type: "string", default: "" }, stream: { type: "boolean" }, budget: { type: "string" },
|
|
93
|
+
statement: { type: "string", default: "" }, stream: { type: "boolean" }, json: { type: "boolean" }, budget: { type: "string" },
|
|
81
94
|
"no-browser": { type: "boolean" },
|
|
82
95
|
},
|
|
83
96
|
});
|
|
@@ -93,6 +106,11 @@ export async function main(argv = process.argv.slice(2), { stdout = process.stdo
|
|
|
93
106
|
if (!values["owner-key-file"]) {
|
|
94
107
|
stdout.write(`owner wallet ${walletAddress(await createWallet(dir))} (${join(dir, "owner-wallet.json")})\n`);
|
|
95
108
|
}
|
|
109
|
+
if (human()) {
|
|
110
|
+
stdout.write("\nThe first address is this machine's payment key: it signs each request, holds no money, needs no password.\n"
|
|
111
|
+
+ "The owner wallet holds your tUSDC and deposit; it is password-protected and signs deposits, withdrawals and claims.\n"
|
|
112
|
+
+ "Next: mycomesh-consumer setup (gets testnet funds and deposits 20 tUSDC; about a minute)\n");
|
|
113
|
+
}
|
|
96
114
|
return 0;
|
|
97
115
|
}
|
|
98
116
|
if (command === "wallet") {
|
|
@@ -107,14 +125,15 @@ export async function main(argv = process.argv.slice(2), { stdout = process.stdo
|
|
|
107
125
|
}
|
|
108
126
|
if (command === "setup") {
|
|
109
127
|
const keyPrivate = paymentKey(dir);
|
|
110
|
-
if (!values.deposit) throw new Error("--deposit UNITS is required");
|
|
111
128
|
if (!values["owner-key-file"]) await createWallet(dir);
|
|
112
129
|
const result = await setup(network, {
|
|
113
|
-
ownerPrivate: await ownerKey(values["owner-key-file"], dir), key: addressOf(keyPrivate),
|
|
114
|
-
|
|
130
|
+
ownerPrivate: await ownerKey(values["owner-key-file"], dir), key: addressOf(keyPrivate),
|
|
131
|
+
deposit: parseAmount(values.deposit ?? "20tUSDC", "--deposit"),
|
|
132
|
+
limit: parseAmount(values["max-per-request"] || values["max-fee"], "--max-per-request"), forceFaucet: values.faucet,
|
|
115
133
|
log: (line) => stdout.write(`${line}\n`),
|
|
116
134
|
});
|
|
117
135
|
stdout.write(`deposited ${result.deposit} and authorized ${result.key} up to ${result.max_per_request} per request\n`);
|
|
136
|
+
if (human()) stdout.write(`Ready: ${tusdc(result.deposit)} deposited. Try: mycomesh-consumer request "hello", or mycomesh-consumer serve\n`);
|
|
118
137
|
return 0;
|
|
119
138
|
}
|
|
120
139
|
if (command === "withdraw") {
|
|
@@ -128,7 +147,7 @@ export async function main(argv = process.argv.slice(2), { stdout = process.stdo
|
|
|
128
147
|
stdout.write(`withdrew ${status.withdrawal.amount}\n`);
|
|
129
148
|
return 0;
|
|
130
149
|
}
|
|
131
|
-
const amount =
|
|
150
|
+
const amount = rest[0] ? parseAmount(rest[0], "amount") : BigInt(status.deposit);
|
|
132
151
|
await requestWithdrawal(network, owner, amount);
|
|
133
152
|
stdout.write(`requested a withdrawal of ${amount}; run \`withdraw\` again after the delay\n`);
|
|
134
153
|
return 0;
|
|
@@ -136,7 +155,8 @@ export async function main(argv = process.argv.slice(2), { stdout = process.stdo
|
|
|
136
155
|
if (command === "balance") {
|
|
137
156
|
const owner = values.owner || ownerAddress(values["owner-key-file"], dir);
|
|
138
157
|
if (!owner) throw new Error("--owner, --owner-key-file or a local wallet (`init`) is required");
|
|
139
|
-
|
|
158
|
+
const balance = await availableBalance(network, owner);
|
|
159
|
+
stdout.write(human() ? `${tusdc(balance)} deposited (${balance} base units)\n` : `${balance}\n`);
|
|
140
160
|
return 0;
|
|
141
161
|
}
|
|
142
162
|
if (command === "rewards") {
|
|
@@ -164,18 +184,19 @@ export async function main(argv = process.argv.slice(2), { stdout = process.stdo
|
|
|
164
184
|
const owner = await ownerKey(values["owner-key-file"], dir);
|
|
165
185
|
if (action === "add") {
|
|
166
186
|
const created = await addTenant(network, dir, owner, name, {
|
|
167
|
-
maxPerRequest:
|
|
187
|
+
maxPerRequest: parseAmount(values["max-per-request"] || values["max-fee"], "--max-per-request"),
|
|
188
|
+
budget: parseAmount(values.budget || 0, "--budget") });
|
|
168
189
|
stdout.write(`${JSON.stringify(created)}\nThe API key is shown once; give it to the tenant (Authorization: Bearer ...).\n`);
|
|
169
190
|
return 0;
|
|
170
191
|
}
|
|
171
192
|
const tenant = loadTenants(dir)[name];
|
|
172
193
|
if (!tenant) throw new Error(`no tenant ${name}`);
|
|
173
|
-
if (action === "budget") { await setBudget(network, owner, tenant.key,
|
|
194
|
+
if (action === "budget") { await setBudget(network, owner, tenant.key, parseAmount(amount, "budget")); stdout.write(`budget of ${name} set to ${amount}\n`); return 0; }
|
|
174
195
|
if (action === "revoke") { await revokeTenant(network, dir, owner, name); stdout.write(`revoked ${name}\n`); return 0; }
|
|
175
196
|
throw new Error("tenant add | list | budget | revoke");
|
|
176
197
|
}
|
|
177
198
|
const keyPrivate = paymentKey(dir, command === "serve");
|
|
178
|
-
const consumer = new Consumer({ network, keyPrivate, maxFee: Number(values["max-fee"]), journalDir: dir });
|
|
199
|
+
const consumer = new Consumer({ network, keyPrivate, maxFee: Number(parseAmount(values["max-fee"], "--max-fee")), journalDir: dir });
|
|
179
200
|
if (command === "request") {
|
|
180
201
|
const { response, receipt } = await consumer.request({
|
|
181
202
|
endpoint: "responses", model: values.model, content: rest.join(" "), maxOutputTokens: Number(values["max-output-tokens"]),
|
|
@@ -183,6 +204,12 @@ export async function main(argv = process.argv.slice(2), { stdout = process.stdo
|
|
|
183
204
|
});
|
|
184
205
|
if (values.stream) process.stderr.write("\n");
|
|
185
206
|
const output = response.output;
|
|
207
|
+
if (human() && !values.json) {
|
|
208
|
+
if (!values.stream) stdout.write(`${output.output_text ?? JSON.stringify(output)}\n`);
|
|
209
|
+
stdout.write(`\n— ${tusdc(receipt.receipt.actual_fee)} · Provider ${receipt.authorization.provider_signer.slice(0, 10)}… · `
|
|
210
|
+
+ "verified against the Provider's signed receipt (--json for details)\n");
|
|
211
|
+
return 0;
|
|
212
|
+
}
|
|
186
213
|
stdout.write(`${JSON.stringify({ output_text: output.output_text ?? output, fee: receipt.receipt.actual_fee,
|
|
187
214
|
provider: receipt.authorization.provider_signer, request_id: receipt.authorization.request_id,
|
|
188
215
|
settlement_key: settlementKey(receipt.authorization.key, receipt.authorization.request_id) })}\n`);
|
package/src/console.mjs
CHANGED
|
@@ -11,6 +11,9 @@ import { networkPrices } from "./pricing.mjs";
|
|
|
11
11
|
import { claimRewards, rewardsSummary } from "./rewards.mjs";
|
|
12
12
|
|
|
13
13
|
const PAGE = new URL("./web/console.html", import.meta.url);
|
|
14
|
+
// Only actions that sign with the owner wallet ask for its password; after one, the wallet stays unlocked in
|
|
15
|
+
// this process for a while so the next signature is a confirmation, not another password.
|
|
16
|
+
export const UNLOCK_MS = 15 * 60_000;
|
|
14
17
|
|
|
15
18
|
/**
|
|
16
19
|
* Only this machine's own pages may call the local node: reject other Host
|
|
@@ -26,7 +29,18 @@ export function localRequestAllowed(req, port) {
|
|
|
26
29
|
export function consoleRoutes({ consumer, dataDir }) {
|
|
27
30
|
const network = consumer.network;
|
|
28
31
|
const owner = () => ownerAddress(undefined, dataDir);
|
|
29
|
-
|
|
32
|
+
let unlocked = null; // { key, until }
|
|
33
|
+
const isUnlocked = () => Boolean(unlocked && Date.now() < unlocked.until);
|
|
34
|
+
const withWallet = (body) => {
|
|
35
|
+
if (body.password) {
|
|
36
|
+
unlocked = { key: unlockWallet(dataDir, body.password), until: Date.now() + UNLOCK_MS };
|
|
37
|
+
} else if (!isUnlocked()) {
|
|
38
|
+
unlocked = null;
|
|
39
|
+
throw Object.assign(new Error("wallet is locked"), { status: 401, code: "locked" });
|
|
40
|
+
}
|
|
41
|
+
unlocked.until = Date.now() + UNLOCK_MS; // each signature keeps it open a little longer
|
|
42
|
+
return unlocked.key;
|
|
43
|
+
};
|
|
30
44
|
const units = (value, name) => {
|
|
31
45
|
if (!/^[0-9]+$/.test(String(value ?? ""))) throw Object.assign(new Error(`${name} must be a whole number of base units`), { status: 400 });
|
|
32
46
|
return BigInt(value);
|
|
@@ -37,7 +51,7 @@ export function consoleRoutes({ consumer, dataDir }) {
|
|
|
37
51
|
const address = owner();
|
|
38
52
|
return {
|
|
39
53
|
network: { id: network.network_id, chain_id: network.chain_id, settlement: network.settlement, faucet: Boolean(network.faucet_url) },
|
|
40
|
-
payment_key: consumer.key, max_fee: String(consumer.maxFee),
|
|
54
|
+
payment_key: consumer.key, max_fee: String(consumer.maxFee), unlocked_until: isUnlocked() ? unlocked.until : null,
|
|
41
55
|
wallet: address ? await walletStatus(network, address, consumer.key) : null,
|
|
42
56
|
};
|
|
43
57
|
},
|
|
@@ -71,6 +85,8 @@ export function consoleRoutes({ consumer, dataDir }) {
|
|
|
71
85
|
return { claimed: await claimRewards(network, ownerPrivate, addressOf(ownerPrivate)) };
|
|
72
86
|
},
|
|
73
87
|
"POST /api/wallet": async (body) => ({ owner: walletAddress(await createWallet(dataDir, body.password)) }),
|
|
88
|
+
"POST /api/unlock": async (body) => { withWallet({ password: body.password }); return { unlocked_until: unlocked.until }; },
|
|
89
|
+
"POST /api/lock": async () => { unlocked = null; return { locked: true }; },
|
|
74
90
|
"POST /api/faucet": async () => {
|
|
75
91
|
const address = owner();
|
|
76
92
|
if (!address) throw Object.assign(new Error("create the wallet first"), { status: 400 });
|
package/src/consumer.mjs
CHANGED
|
@@ -14,8 +14,8 @@ import { consoleRoutes, localRequestAllowed } from "./console.mjs";
|
|
|
14
14
|
import { pinnedOptions, splitPin } from "./tlspin.mjs";
|
|
15
15
|
import { compareRank, rankKey, reputation } from "./reputation.mjs";
|
|
16
16
|
import { hashApiKey } from "./tenants.mjs";
|
|
17
|
-
import { anthropicError, anthropicStream,
|
|
18
|
-
toAnthropic, toGemini } from "./dialects.mjs";
|
|
17
|
+
import { anthropicError, anthropicStream, countAnthropicTokens, countGeminiTokens, fromAnthropic, fromGemini, fromResponses,
|
|
18
|
+
geminiError, geminiRoute, geminiStream, responsesUsesTools, toAnthropic, toGemini, toResponses } from "./dialects.mjs";
|
|
19
19
|
|
|
20
20
|
// Discovery is served from cache and refreshed in the background once it is older than its fresh period; only
|
|
21
21
|
// data older than its usable period (or none yet) makes a request wait for it.
|
|
@@ -412,7 +412,7 @@ export class Consumer {
|
|
|
412
412
|
for (let index = 0; index < candidates.length; index += 1) {
|
|
413
413
|
const descriptor = candidates[index];
|
|
414
414
|
// Authorize what this request can really cost, not the Consumer's whole per-request cap.
|
|
415
|
-
const plan = planRequest(descriptor, endpoint, content, maxOutputTokens, this.maxFee);
|
|
415
|
+
const plan = planRequest(descriptor, endpoint, content, maxOutputTokens, this.maxFee, options.tools);
|
|
416
416
|
const maxFee = Math.min(Number(this.maxFee), Math.max(raisedFee || 0, plan.maxFee));
|
|
417
417
|
const prepared = prepareRequest({
|
|
418
418
|
descriptor, deployment: this.deployment, keyPrivate: this.keyPrivate, relaySigner: relay.signer,
|
|
@@ -501,14 +501,17 @@ async function readJson(req) {
|
|
|
501
501
|
async function dialectRequest(req, res, active, path, setWriter) {
|
|
502
502
|
const body = await readJson(req);
|
|
503
503
|
const gemini = geminiRoute(path);
|
|
504
|
+
// Token counting (Claude Code and the Gemini CLI ask before long requests): an estimate, no request is sent.
|
|
505
|
+
const json = (value) => { res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }); return res.end(JSON.stringify(value)); };
|
|
506
|
+
if (path === "/v1/messages/count_tokens") return json(countAnthropicTokens(body));
|
|
507
|
+
if (gemini?.count) return json(countGeminiTokens(body, gemini.model));
|
|
504
508
|
const ask = gemini ? fromGemini(body, gemini.model) : fromAnthropic(body);
|
|
505
509
|
const stream = gemini ? gemini.stream : ask.stream;
|
|
506
510
|
const request = { endpoint: "chat", model: ask.model, content: ask.messages, maxOutputTokens: ask.maxOutputTokens, options: ask.options,
|
|
507
511
|
session: ask.session || req.headers["x-claude-code-session-id"] || req.headers["session_id"] || req.headers["x-session-id"] || undefined };
|
|
508
512
|
if (!stream) {
|
|
509
513
|
const { response, receipt } = await active.request(request);
|
|
510
|
-
const
|
|
511
|
-
const out = gemini ? toGemini(ask.model, answer, receipt.receipt) : toAnthropic(ask.model, answer, receipt.receipt);
|
|
514
|
+
const out = gemini ? toGemini(ask.model, response.output, receipt.receipt) : toAnthropic(ask.model, response.output, receipt.receipt);
|
|
512
515
|
res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
|
|
513
516
|
return res.end(JSON.stringify(out));
|
|
514
517
|
}
|
|
@@ -520,7 +523,32 @@ async function dialectRequest(req, res, active, path, setWriter) {
|
|
|
520
523
|
setWriter(writer);
|
|
521
524
|
let streamedAny = false;
|
|
522
525
|
const { response, receipt } = await active.request({ ...request, onDelta: (piece) => { streamedAny ||= Boolean(piece); writer.delta(piece); } });
|
|
523
|
-
writer.finish(
|
|
526
|
+
writer.finish(response.output, receipt.receipt, streamedAny);
|
|
527
|
+
return res.end();
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Responses with tools (Codex CLI and other agents): translated to the network's chat shape and back, so the
|
|
532
|
+
* Provider sees one way of calling tools; the client gets function_call / custom_tool_call items to run.
|
|
533
|
+
*/
|
|
534
|
+
async function responsesWithTools(res, active, body, session, setWriter) {
|
|
535
|
+
const { model, stream } = body;
|
|
536
|
+
const { messages, options, custom } = fromResponses(body);
|
|
537
|
+
const maxOutputTokens = body.max_output_tokens ?? 4096;
|
|
538
|
+
const request = { endpoint: "chat", model, content: messages, maxOutputTokens, options, session };
|
|
539
|
+
if (!stream) {
|
|
540
|
+
const { response, receipt } = await active.request(request);
|
|
541
|
+
res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
|
|
542
|
+
return res.end(JSON.stringify(toResponses(model, response.output, receipt.receipt, custom)));
|
|
543
|
+
}
|
|
544
|
+
const write = (text) => {
|
|
545
|
+
if (!res.headersSent) res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
|
|
546
|
+
res.write(text);
|
|
547
|
+
};
|
|
548
|
+
const writer = responseStream(write, { model });
|
|
549
|
+
setWriter(writer);
|
|
550
|
+
const { response, receipt } = await active.request({ ...request, onDelta: (text) => writer.delta(text) });
|
|
551
|
+
writer.finish(toResponses(model, response.output, receipt.receipt, custom));
|
|
524
552
|
return res.end();
|
|
525
553
|
}
|
|
526
554
|
|
|
@@ -540,7 +568,7 @@ export function serveConsumer(consumer, { host = "127.0.0.1", port = 8110, apiKe
|
|
|
540
568
|
};
|
|
541
569
|
try {
|
|
542
570
|
const path = new URL(req.url, "http://localhost").pathname.replace(/^\/v1\/v1\//, "/v1/");
|
|
543
|
-
dialect = path === "/v1/messages" ? "anthropic" : geminiRoute(path) ? "gemini" : "openai";
|
|
571
|
+
dialect = path === "/v1/messages" || path === "/v1/messages/count_tokens" ? "anthropic" : geminiRoute(path) ? "gemini" : "openai";
|
|
544
572
|
// A tenant's API key selects its own payment key and budget; tenants may call from anywhere.
|
|
545
573
|
const tenant = /^\/v1(beta|alpha)?\//.test(path) && bearer(req) ? tenants()[hashApiKey(bearer(req))] : undefined;
|
|
546
574
|
if (!tenant && !localRequestAllowed(req, server.address().port)) {
|
|
@@ -570,6 +598,8 @@ export function serveConsumer(consumer, { host = "127.0.0.1", port = 8110, apiKe
|
|
|
570
598
|
}
|
|
571
599
|
const body = await readJson(req);
|
|
572
600
|
const chat = path.endsWith("/chat/completions");
|
|
601
|
+
const session = req.headers["session_id"] || req.headers["x-session-id"] || undefined;
|
|
602
|
+
if (!chat && responsesUsesTools(body)) return await responsesWithTools(res, active, body, body.prompt_cache_key || session, (w) => { writer = w; });
|
|
573
603
|
const { model, stream, stream_options: streamOptions, ...rest } = body;
|
|
574
604
|
const content = chat ? rest.messages : rest.input;
|
|
575
605
|
delete rest.messages;
|
|
@@ -578,7 +608,6 @@ export function serveConsumer(consumer, { host = "127.0.0.1", port = 8110, apiKe
|
|
|
578
608
|
delete rest.max_output_tokens;
|
|
579
609
|
delete rest.max_tokens;
|
|
580
610
|
delete rest.max_completion_tokens;
|
|
581
|
-
const session = req.headers["session_id"] || req.headers["x-session-id"] || undefined;
|
|
582
611
|
if (!stream) {
|
|
583
612
|
const { response } = await active.request({ endpoint: chat ? "chat" : "responses", model, content, maxOutputTokens, options: rest, session });
|
|
584
613
|
return send(200, response.output);
|
|
@@ -597,7 +626,7 @@ export function serveConsumer(consumer, { host = "127.0.0.1", port = 8110, apiKe
|
|
|
597
626
|
if (res.headersSent) { writer?.fail(error.message); return res.end(); }
|
|
598
627
|
if (error.message === "wrong wallet password") return send(401, { error: error.message });
|
|
599
628
|
if (error.status && error.status < 500 && !error.dispatched && routes[`${req.method} ${new URL(req.url, "http://x").pathname}`]) {
|
|
600
|
-
return send(error.status, { error: error.message });
|
|
629
|
+
return send(error.status, { error: error.message, ...(error.code ? { code: error.code } : {}) });
|
|
601
630
|
}
|
|
602
631
|
const status = error.code === "outcome_unknown" ? 504 : (error.status && error.status < 500 ? error.status : 502);
|
|
603
632
|
if (dialect === "anthropic") return send(status, anthropicError(error.message, status));
|
package/src/dialects.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
// Client dialects for the local endpoint: Anthropic Messages
|
|
2
|
-
// from the network's
|
|
3
|
-
//
|
|
1
|
+
// Client dialects for the local endpoint: Anthropic Messages, Gemini generateContent and OpenAI Responses with
|
|
2
|
+
// tools, translated to and from the network's one shape (OpenAI Chat Completions: tools, assistant tool_calls,
|
|
3
|
+
// role "tool" results), streaming included. The Consumer's own client runs the tools; the network only carries
|
|
4
|
+
// the calls and their results. Token counts come from the Provider-signed receipt.
|
|
4
5
|
import { randomUUID } from "node:crypto";
|
|
5
6
|
|
|
6
7
|
const id = (prefix) => `${prefix}${randomUUID().replace(/-/g, "")}`;
|
|
@@ -9,109 +10,294 @@ export class DialectError extends Error {
|
|
|
9
10
|
constructor(message, status = 400) { super(message); this.status = status; }
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
/** Text of Anthropic or Gemini content: a string or text blocks/parts
|
|
13
|
+
/** Text of Anthropic or Gemini content: a string or text blocks/parts; images and documents become a note. */
|
|
13
14
|
function text(content, where) {
|
|
14
15
|
if (typeof content === "string") return content;
|
|
16
|
+
if (content === undefined || content === null) return "";
|
|
15
17
|
if (!Array.isArray(content)) throw new DialectError(`${where}: content must be text`);
|
|
16
18
|
return content.map((part) => {
|
|
17
19
|
if (typeof part === "string") return part;
|
|
18
20
|
if (part && typeof part.text === "string" && (part.type === undefined || part.type === "text")) return part.text;
|
|
19
21
|
if (part?.type === "thinking" || part?.type === "redacted_thinking" || part?.thought) return "";
|
|
20
|
-
|
|
22
|
+
if (part?.type === "image" || part?.inlineData || part?.type === "input_image") return "[image omitted: this network carries text only]";
|
|
23
|
+
if (part?.type === "document") return part.source?.type === "text" ? String(part.source.data ?? "") : "[document omitted]";
|
|
24
|
+
if (part?.type === "input_text" || part?.type === "output_text") return String(part.text ?? "");
|
|
25
|
+
throw new DialectError(`${where}: unsupported content (${part?.type || Object.keys(part || {})[0] || "unknown"})`);
|
|
21
26
|
}).join("");
|
|
22
27
|
}
|
|
23
28
|
|
|
24
|
-
const
|
|
25
|
-
if (
|
|
29
|
+
const parseArguments = (value) => {
|
|
30
|
+
if (value && typeof value === "object") return value;
|
|
31
|
+
try { return JSON.parse(value || "{}"); } catch { return { input: String(value) }; }
|
|
26
32
|
};
|
|
27
33
|
|
|
34
|
+
/** The answer and tool calls of a network response (a chat completion). */
|
|
35
|
+
export function answerOf(output) {
|
|
36
|
+
const choice = output?.choices?.[0] || {};
|
|
37
|
+
const message = choice.message || {};
|
|
38
|
+
return { text: typeof message.content === "string" ? message.content : "", calls: Array.isArray(message.tool_calls) ? message.tool_calls : [],
|
|
39
|
+
finish: choice.finish_reason || "stop" };
|
|
40
|
+
}
|
|
41
|
+
|
|
28
42
|
// ---------------- Anthropic Messages ----------------
|
|
29
43
|
|
|
30
|
-
/** POST /v1/messages body -> { model, messages, maxOutputTokens, options, stream } */
|
|
44
|
+
/** POST /v1/messages body -> { model, messages, maxOutputTokens, options, stream, session } */
|
|
31
45
|
export function fromAnthropic(body) {
|
|
32
46
|
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
47
|
const messages = [];
|
|
35
48
|
if (body.system !== undefined) messages.push({ role: "system", content: text(body.system, "system") });
|
|
36
49
|
for (const message of body.messages) {
|
|
37
|
-
if (
|
|
38
|
-
|
|
50
|
+
if (message?.role === "system") { messages.push({ role: "system", content: text(message.content, "system message") }); continue; }
|
|
51
|
+
if (!["user", "assistant"].includes(message?.role)) throw new DialectError(`unsupported message role ${message?.role}`);
|
|
52
|
+
const blocks = typeof message.content === "string" ? [{ type: "text", text: message.content }] : message.content || [];
|
|
53
|
+
if (message.role === "assistant") {
|
|
54
|
+
const calls = blocks.filter((b) => b?.type === "tool_use").map((b) => ({ id: b.id, type: "function",
|
|
55
|
+
function: { name: b.name, arguments: JSON.stringify(b.input ?? {}) } }));
|
|
56
|
+
const words = text(blocks.filter((b) => b?.type !== "tool_use"), "assistant message");
|
|
57
|
+
messages.push({ role: "assistant", content: words || (calls.length ? null : ""), ...(calls.length ? { tool_calls: calls } : {}) });
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
// A user turn: tool results answer the previous assistant's calls; the rest is what the user said.
|
|
61
|
+
for (const block of blocks.filter((b) => b?.type === "tool_result")) {
|
|
62
|
+
const result = text(block.content ?? "", "tool_result");
|
|
63
|
+
messages.push({ role: "tool", tool_call_id: block.tool_use_id, content: block.is_error ? `Error: ${result}` : result });
|
|
64
|
+
}
|
|
65
|
+
const rest = blocks.filter((b) => b?.type !== "tool_result");
|
|
66
|
+
if (rest.length || !blocks.length) messages.push({ role: "user", content: text(rest, "user message") });
|
|
39
67
|
}
|
|
40
68
|
const options = {};
|
|
41
69
|
for (const key of ["temperature", "top_p"]) if (body[key] !== undefined) options[key] = body[key];
|
|
42
70
|
if (body.stop_sequences) options.stop = body.stop_sequences;
|
|
43
|
-
//
|
|
44
|
-
const
|
|
71
|
+
// The effort the user chose in the client (Claude Code: output_config.effort) is the model's reasoning effort.
|
|
72
|
+
const effort = body.output_config?.effort;
|
|
73
|
+
if (["minimal", "low", "medium", "high"].includes(effort)) options.reasoning_effort = effort;
|
|
74
|
+
const tools = (body.tools || []).filter((t) => t?.name && (t.type === undefined || t.type === "custom" || t.input_schema));
|
|
75
|
+
if (tools.length) {
|
|
76
|
+
options.tools = tools.map((t) => ({ type: "function", function: { name: t.name, description: t.description || "",
|
|
77
|
+
parameters: t.input_schema || { type: "object", properties: {} } } }));
|
|
78
|
+
const choice = body.tool_choice;
|
|
79
|
+
if (choice?.type === "any") options.tool_choice = "required";
|
|
80
|
+
else if (choice?.type === "tool" && choice.name) options.tool_choice = { type: "function", function: { name: choice.name } };
|
|
81
|
+
else if (choice?.type === "none") options.tool_choice = "none";
|
|
82
|
+
}
|
|
83
|
+
// Claude Code puts its session in metadata.user_id (a JSON string with session_id): a conversation's turns share it.
|
|
84
|
+
let session = typeof body.metadata?.user_id === "string" ? body.metadata.user_id : undefined;
|
|
85
|
+
try { session = JSON.parse(session).session_id || session; } catch {}
|
|
45
86
|
return { model: body.model, messages, maxOutputTokens: body.max_tokens ?? 4096, options, stream: body.stream === true, session };
|
|
46
87
|
}
|
|
47
88
|
|
|
48
|
-
|
|
89
|
+
const anthropicStop = (answer) => answer.calls.length ? "tool_use" : answer.finish === "length" ? "max_tokens" : "end_turn";
|
|
90
|
+
|
|
91
|
+
export function toAnthropic(model, output, receipt) {
|
|
92
|
+
const answer = answerOf(output);
|
|
93
|
+
const content = answer.text ? [{ type: "text", text: answer.text }] : [];
|
|
94
|
+
for (const call of answer.calls) content.push({ type: "tool_use", id: call.id, name: call.function?.name, input: parseArguments(call.function?.arguments) });
|
|
49
95
|
return {
|
|
50
|
-
id: id("msg_"), type: "message", role: "assistant", model,
|
|
51
|
-
|
|
96
|
+
id: id("msg_"), type: "message", role: "assistant", model, content: content.length ? content : [{ type: "text", text: "" }],
|
|
97
|
+
stop_reason: anthropicStop(answer), stop_sequence: null,
|
|
52
98
|
usage: { input_tokens: Number(receipt.input_tokens), output_tokens: Number(receipt.output_tokens) },
|
|
53
99
|
};
|
|
54
100
|
}
|
|
55
101
|
|
|
56
|
-
/** Anthropic's SSE event sequence
|
|
102
|
+
/** Anthropic's SSE event sequence: text as it arrives, then tool_use blocks when the verified answer is in. */
|
|
57
103
|
export function anthropicStream(write, model) {
|
|
58
104
|
const event = (type, data) => write(`event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`);
|
|
59
105
|
let started = false;
|
|
106
|
+
let textOpen = false;
|
|
107
|
+
let index = 0;
|
|
60
108
|
const start = () => {
|
|
61
109
|
if (started) return;
|
|
62
110
|
started = true;
|
|
63
111
|
event("message_start", { message: { id: id("msg_"), type: "message", role: "assistant", model, content: [],
|
|
64
112
|
stop_reason: null, stop_sequence: null, usage: { input_tokens: 0, output_tokens: 0 } } });
|
|
65
|
-
|
|
113
|
+
};
|
|
114
|
+
const openText = () => {
|
|
115
|
+
if (textOpen) return;
|
|
116
|
+
textOpen = true;
|
|
117
|
+
event("content_block_start", { index, content_block: { type: "text", text: "" } });
|
|
66
118
|
};
|
|
67
119
|
return {
|
|
68
|
-
delta(piece) {
|
|
69
|
-
|
|
120
|
+
delta(piece) {
|
|
121
|
+
start();
|
|
122
|
+
if (!piece) return;
|
|
123
|
+
openText();
|
|
124
|
+
event("content_block_delta", { index, delta: { type: "text_delta", text: piece } });
|
|
125
|
+
},
|
|
126
|
+
finish(output, receipt, streamedAny) {
|
|
70
127
|
start();
|
|
71
|
-
|
|
72
|
-
event("
|
|
73
|
-
|
|
128
|
+
const answer = answerOf(output);
|
|
129
|
+
if (!streamedAny && answer.text) { openText(); event("content_block_delta", { index, delta: { type: "text_delta", text: answer.text } }); }
|
|
130
|
+
if (!textOpen && !answer.calls.length) openText();
|
|
131
|
+
if (textOpen) { event("content_block_stop", { index }); index += 1; }
|
|
132
|
+
for (const call of answer.calls) {
|
|
133
|
+
event("content_block_start", { index, content_block: { type: "tool_use", id: call.id, name: call.function?.name, input: {} } });
|
|
134
|
+
event("content_block_delta", { index, delta: { type: "input_json_delta", partial_json: JSON.stringify(parseArguments(call.function?.arguments)) } });
|
|
135
|
+
event("content_block_stop", { index });
|
|
136
|
+
index += 1;
|
|
137
|
+
}
|
|
138
|
+
event("message_delta", { delta: { stop_reason: anthropicStop(answer), stop_sequence: null },
|
|
74
139
|
usage: { input_tokens: Number(receipt.input_tokens), output_tokens: Number(receipt.output_tokens) } });
|
|
75
140
|
event("message_stop", {});
|
|
76
141
|
},
|
|
77
|
-
fail(message) { event("error", { error: { type: "api_error", message } }); },
|
|
142
|
+
fail(message) { start(); event("error", { error: { type: "api_error", message } }); },
|
|
78
143
|
};
|
|
79
144
|
}
|
|
80
145
|
|
|
146
|
+
/** /v1/messages/count_tokens: an upper-bound estimate (UTF-8 bytes / 3, as prices are authorized). */
|
|
147
|
+
export function countAnthropicTokens(body) {
|
|
148
|
+
const ask = fromAnthropic({ max_tokens: 1, ...body });
|
|
149
|
+
const bytes = Buffer.byteLength(JSON.stringify(ask.messages), "utf8") + Buffer.byteLength(JSON.stringify(ask.options.tools || []), "utf8");
|
|
150
|
+
return { input_tokens: Math.ceil(bytes / 3) };
|
|
151
|
+
}
|
|
152
|
+
|
|
81
153
|
export const anthropicError = (message, status) => ({ type: "error", error: {
|
|
82
154
|
type: status === 401 || status === 403 ? "authentication_error" : status === 404 ? "not_found_error"
|
|
83
155
|
: status === 402 ? "billing_error" : status >= 500 ? "api_error" : "invalid_request_error", message } });
|
|
84
156
|
|
|
157
|
+
// ---------------- OpenAI Responses (with tools) ----------------
|
|
158
|
+
|
|
159
|
+
/** Whether a Responses request needs the tool translation (tools, or tool calls and results in its input). */
|
|
160
|
+
export function responsesUsesTools(body) {
|
|
161
|
+
return (Array.isArray(body.tools) && body.tools.length > 0)
|
|
162
|
+
|| (Array.isArray(body.input) && body.input.some((item) => /^(function_call|custom_tool_call)/.test(item?.type || "")));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** POST /v1/responses body with tools -> { messages, options, custom } (custom: names of freeform tools). */
|
|
166
|
+
export function fromResponses(body) {
|
|
167
|
+
const messages = [];
|
|
168
|
+
if (body.instructions) messages.push({ role: "system", content: String(body.instructions) });
|
|
169
|
+
const custom = new Set();
|
|
170
|
+
const tools = [];
|
|
171
|
+
for (const tool of body.tools || []) {
|
|
172
|
+
if (tool?.type === "function" && tool.name) {
|
|
173
|
+
tools.push({ type: "function", function: { name: tool.name, description: tool.description || "", parameters: tool.parameters || { type: "object", properties: {} } } });
|
|
174
|
+
} else if (tool?.type === "custom" && tool.name) {
|
|
175
|
+
// A freeform tool (Codex's apply_patch): one string argument carries its whole input.
|
|
176
|
+
custom.add(tool.name);
|
|
177
|
+
tools.push({ type: "function", function: { name: tool.name, description: `${tool.description || ""}\nPut the tool's complete raw input in the "input" string.`,
|
|
178
|
+
parameters: { type: "object", properties: { input: { type: "string" } }, required: ["input"] } } });
|
|
179
|
+
} // hosted tools (web search, file search, ...) cannot run on this network: they are left out
|
|
180
|
+
}
|
|
181
|
+
const input = typeof body.input === "string" ? [{ role: "user", content: body.input }] : body.input || [];
|
|
182
|
+
const assistant = () => {
|
|
183
|
+
const last = messages.at(-1);
|
|
184
|
+
if (last?.role === "assistant") return last;
|
|
185
|
+
const fresh = { role: "assistant", content: null, tool_calls: [] };
|
|
186
|
+
messages.push(fresh);
|
|
187
|
+
return fresh;
|
|
188
|
+
};
|
|
189
|
+
for (const item of input) {
|
|
190
|
+
const type = item?.type || (item?.role ? "message" : "");
|
|
191
|
+
if (type === "message") {
|
|
192
|
+
const role = item.role === "developer" ? "system" : item.role;
|
|
193
|
+
const words = text(item.content, `${role} message`);
|
|
194
|
+
if (role === "assistant") { const a = assistant(); a.content = (a.content || "") + words; }
|
|
195
|
+
else messages.push({ role, content: words });
|
|
196
|
+
} else if (type === "function_call" || type === "custom_tool_call") {
|
|
197
|
+
const a = assistant();
|
|
198
|
+
(a.tool_calls ||= []).push({ id: item.call_id, type: "function", function: { name: item.name,
|
|
199
|
+
arguments: type === "custom_tool_call" ? JSON.stringify({ input: item.input ?? "" }) : (item.arguments ?? "{}") } });
|
|
200
|
+
} else if (type === "function_call_output" || type === "custom_tool_call_output") {
|
|
201
|
+
const output = typeof item.output === "string" ? item.output : text(item.output, "tool output");
|
|
202
|
+
messages.push({ role: "tool", tool_call_id: item.call_id, content: output });
|
|
203
|
+
} // reasoning items and the like stay with the client
|
|
204
|
+
}
|
|
205
|
+
for (const message of messages) if (message.role === "assistant" && !message.tool_calls?.length) delete message.tool_calls;
|
|
206
|
+
const options = {};
|
|
207
|
+
for (const key of ["temperature", "top_p", "reasoning", "parallel_tool_calls"]) if (body[key] !== undefined) options[key] = body[key];
|
|
208
|
+
if (tools.length) options.tools = tools;
|
|
209
|
+
const choice = body.tool_choice;
|
|
210
|
+
if (choice === "none" || choice === "required" || choice === "auto") options.tool_choice = choice;
|
|
211
|
+
else if (choice?.name) options.tool_choice = { type: "function", function: { name: choice.name } };
|
|
212
|
+
return { messages, options, custom };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** A network answer (chat completion) as a Responses object, freeform tools as custom_tool_call items. */
|
|
216
|
+
export function toResponses(model, output, receipt, custom = new Set()) {
|
|
217
|
+
const answer = answerOf(output);
|
|
218
|
+
const items = [];
|
|
219
|
+
if (answer.text || !answer.calls.length) {
|
|
220
|
+
items.push({ type: "message", id: id("msg_"), role: "assistant", status: "completed",
|
|
221
|
+
content: [{ type: "output_text", text: answer.text, annotations: [] }] });
|
|
222
|
+
}
|
|
223
|
+
for (const call of answer.calls) {
|
|
224
|
+
const name = call.function?.name;
|
|
225
|
+
items.push(custom.has(name)
|
|
226
|
+
? { type: "custom_tool_call", id: id("ctc_"), call_id: call.id, name, input: String(parseArguments(call.function?.arguments).input ?? ""), status: "completed" }
|
|
227
|
+
: { type: "function_call", id: id("fc_"), call_id: call.id, name, arguments: call.function?.arguments || "{}", status: "completed" });
|
|
228
|
+
}
|
|
229
|
+
const input = Number(receipt?.input_tokens ?? output?.usage?.prompt_tokens ?? 0);
|
|
230
|
+
const out = Number(receipt?.output_tokens ?? output?.usage?.completion_tokens ?? 0);
|
|
231
|
+
return { id: id("resp_"), object: "response", created_at: Math.floor(Date.now() / 1000), model, status: "completed",
|
|
232
|
+
output: items, output_text: answer.text, usage: { input_tokens: input, output_tokens: out, total_tokens: input + out } };
|
|
233
|
+
}
|
|
234
|
+
|
|
85
235
|
// ---------------- Gemini generateContent ----------------
|
|
86
236
|
|
|
87
|
-
const GEMINI_PATH = /^\/(?:v1|v1beta|v1alpha)\/models\/([^/:]+):(generateContent|streamGenerateContent)$/;
|
|
237
|
+
const GEMINI_PATH = /^\/(?:v1|v1beta|v1alpha)\/models\/([^/:]+):(generateContent|streamGenerateContent|countTokens)$/;
|
|
88
238
|
|
|
89
239
|
/** The model and whether it streams, for a Gemini path; null for any other path. */
|
|
90
240
|
export function geminiRoute(path) {
|
|
91
241
|
const match = GEMINI_PATH.exec(path);
|
|
92
|
-
return match ? { model: decodeURIComponent(match[1]), stream: match[2] === "streamGenerateContent" } : null;
|
|
242
|
+
return match ? { model: decodeURIComponent(match[1]), stream: match[2] === "streamGenerateContent", count: match[2] === "countTokens" } : null;
|
|
93
243
|
}
|
|
94
244
|
|
|
95
245
|
export function fromGemini(body, model) {
|
|
96
246
|
if (!Array.isArray(body.contents)) throw new DialectError("contents are required");
|
|
97
|
-
unsupportedTools(body.tools?.length);
|
|
98
247
|
const messages = [];
|
|
99
248
|
const system = body.systemInstruction || body.system_instruction;
|
|
100
249
|
if (system) messages.push({ role: "system", content: text(system.parts ?? system, "systemInstruction") });
|
|
250
|
+
// Gemini answers a call by the function's name; the network by an id. Pair each response with the
|
|
251
|
+
// earliest unanswered call of that name.
|
|
252
|
+
const open = [];
|
|
253
|
+
let counter = 0;
|
|
101
254
|
for (const item of body.contents) {
|
|
102
|
-
const
|
|
103
|
-
|
|
255
|
+
const parts = item.parts || [];
|
|
256
|
+
const calls = parts.filter((p) => p.functionCall);
|
|
257
|
+
const results = parts.filter((p) => p.functionResponse);
|
|
258
|
+
const words = text(parts.filter((p) => !p.functionCall && !p.functionResponse), `${item.role || "user"} content`);
|
|
259
|
+
if (item.role === "model") {
|
|
260
|
+
const toolCalls = calls.map((p) => {
|
|
261
|
+
const call = { id: p.functionCall.id || `call_${counter++}`, type: "function",
|
|
262
|
+
function: { name: p.functionCall.name, arguments: JSON.stringify(p.functionCall.args ?? {}) } };
|
|
263
|
+
open.push(call);
|
|
264
|
+
return call;
|
|
265
|
+
});
|
|
266
|
+
messages.push({ role: "assistant", content: words || (toolCalls.length ? null : ""), ...(toolCalls.length ? { tool_calls: toolCalls } : {}) });
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
for (const part of results) {
|
|
270
|
+
const index = open.findIndex((call) => call.function.name === part.functionResponse.name);
|
|
271
|
+
const call = index >= 0 ? open.splice(index, 1)[0] : null;
|
|
272
|
+
messages.push({ role: "tool", tool_call_id: part.functionResponse.id || call?.id || `call_${counter++}`,
|
|
273
|
+
content: JSON.stringify(part.functionResponse.response ?? {}) });
|
|
274
|
+
}
|
|
275
|
+
if (words || !results.length) messages.push({ role: "user", content: words });
|
|
104
276
|
}
|
|
105
277
|
const config = body.generationConfig || body.generation_config || {};
|
|
106
278
|
const options = {};
|
|
107
279
|
if (config.temperature !== undefined) options.temperature = config.temperature;
|
|
108
280
|
if (config.topP !== undefined) options.top_p = config.topP;
|
|
109
281
|
if (config.stopSequences) options.stop = config.stopSequences;
|
|
282
|
+
const declarations = (body.tools || []).flatMap((t) => t.functionDeclarations || t.function_declarations || []);
|
|
283
|
+
if (declarations.length) {
|
|
284
|
+
options.tools = declarations.map((d) => ({ type: "function", function: { name: d.name, description: d.description || "",
|
|
285
|
+
parameters: d.parametersJsonSchema || d.parameters || { type: "object", properties: {} } } }));
|
|
286
|
+
const mode = body.toolConfig?.functionCallingConfig?.mode;
|
|
287
|
+
if (mode === "NONE") options.tool_choice = "none";
|
|
288
|
+
else if (mode === "ANY") options.tool_choice = "required";
|
|
289
|
+
}
|
|
110
290
|
return { model, messages, maxOutputTokens: config.maxOutputTokens ?? 4096, options };
|
|
111
291
|
}
|
|
112
292
|
|
|
113
|
-
function
|
|
114
|
-
const
|
|
293
|
+
function geminiParts(piece, calls = []) {
|
|
294
|
+
const parts = piece ? [{ text: piece }] : [];
|
|
295
|
+
for (const call of calls) parts.push({ functionCall: { name: call.function?.name, args: parseArguments(call.function?.arguments) } });
|
|
296
|
+
return parts.length ? parts : [{ text: "" }];
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function geminiChunk(model, parts, receipt) {
|
|
300
|
+
const chunk = { candidates: [{ content: { role: "model", parts }, index: 0 }], modelVersion: model };
|
|
115
301
|
if (receipt) {
|
|
116
302
|
chunk.candidates[0].finishReason = "STOP";
|
|
117
303
|
const input = Number(receipt.input_tokens); const output = Number(receipt.output_tokens);
|
|
@@ -120,14 +306,27 @@ function geminiChunk(model, piece, receipt) {
|
|
|
120
306
|
return chunk;
|
|
121
307
|
}
|
|
122
308
|
|
|
123
|
-
export const toGemini = (model,
|
|
309
|
+
export const toGemini = (model, output, receipt) => {
|
|
310
|
+
const answer = answerOf(output);
|
|
311
|
+
return geminiChunk(model, geminiParts(answer.text, answer.calls), receipt);
|
|
312
|
+
};
|
|
124
313
|
|
|
125
|
-
/**
|
|
314
|
+
/** countTokens: an upper-bound estimate (UTF-8 bytes / 3, as prices are authorized). */
|
|
315
|
+
export function countGeminiTokens(body, model) {
|
|
316
|
+
const ask = fromGemini({ contents: [], ...body }, model);
|
|
317
|
+
const bytes = Buffer.byteLength(JSON.stringify(ask.messages), "utf8") + Buffer.byteLength(JSON.stringify(ask.options.tools || []), "utf8");
|
|
318
|
+
return { totalTokens: Math.ceil(bytes / 3) };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** streamGenerateContent with alt=sse: one JSON chunk per event; the last carries calls, finishReason and usage. */
|
|
126
322
|
export function geminiStream(write, model) {
|
|
127
323
|
const chunk = (value) => write(`data: ${JSON.stringify(value)}\n\n`);
|
|
128
324
|
return {
|
|
129
|
-
delta(piece) { if (piece) chunk(geminiChunk(model, piece)); },
|
|
130
|
-
finish(
|
|
325
|
+
delta(piece) { if (piece) chunk(geminiChunk(model, [{ text: piece }])); },
|
|
326
|
+
finish(output, receipt, streamedAny) {
|
|
327
|
+
const answer = answerOf(output);
|
|
328
|
+
chunk(geminiChunk(model, geminiParts(streamedAny ? "" : answer.text, answer.calls), receipt));
|
|
329
|
+
},
|
|
131
330
|
fail(message) { chunk({ error: { code: 500, message, status: "INTERNAL" } }); },
|
|
132
331
|
};
|
|
133
332
|
}
|
package/src/protocol.mjs
CHANGED
|
@@ -117,8 +117,10 @@ export function openResponse(prepared, result, deployment, now = Math.floor(Date
|
|
|
117
117
|
* (mycomesh/protocol.py worst_case_tokens): content UTF-8 length / 3, plus the backend's own input, plus
|
|
118
118
|
* the full max_output_tokens.
|
|
119
119
|
*/
|
|
120
|
-
export function worstCaseTokens(endpoint, content, maxOutputTokens, inputOverheadTokens = 0) {
|
|
121
|
-
|
|
120
|
+
export function worstCaseTokens(endpoint, content, maxOutputTokens, inputOverheadTokens = 0, tools = null) {
|
|
121
|
+
// Tool definitions are input too, counted as the Provider counts them (protocol.py).
|
|
122
|
+
const bytes = Buffer.byteLength(JSON.stringify(content ?? null), "utf8")
|
|
123
|
+
+ (Array.isArray(tools) && tools.length ? Buffer.byteLength(JSON.stringify(tools), "utf8") : 0);
|
|
122
124
|
return [Math.ceil(bytes / 3) + Number(inputOverheadTokens || 0), Number(maxOutputTokens)];
|
|
123
125
|
}
|
|
124
126
|
|
|
@@ -129,12 +131,12 @@ export function worstCaseTokens(endpoint, content, maxOutputTokens, inputOverhea
|
|
|
129
131
|
* many requests share one deposit and one Provider's daily capacity at once. When even the worst case does
|
|
130
132
|
* not fit the cap, the output is trimmed to what the cap pays for, rather than having the Provider refuse.
|
|
131
133
|
*/
|
|
132
|
-
export function planRequest(descriptor, endpoint, content, maxOutputTokens, cap) {
|
|
134
|
+
export function planRequest(descriptor, endpoint, content, maxOutputTokens, cap, tools = null) {
|
|
133
135
|
const prices = descriptor.prices || {};
|
|
134
136
|
const inPrice = Number(prices.input_per_1k || 0);
|
|
135
137
|
const outPrice = Number(prices.output_per_1k || 0);
|
|
136
138
|
const floor = Math.max(Number(prices.minimum_fee || 0), 1);
|
|
137
|
-
const [input] = worstCaseTokens(endpoint, content, 0, descriptor.input_overhead_tokens);
|
|
139
|
+
const [input] = worstCaseTokens(endpoint, content, 0, descriptor.input_overhead_tokens, tools);
|
|
138
140
|
const worst = (output) => Math.max(Math.ceil((input * inPrice + output * outPrice) / 1000), floor);
|
|
139
141
|
let output = Number(maxOutputTokens);
|
|
140
142
|
if (worst(output) > Number(cap) && outPrice > 0) {
|
package/src/sse.mjs
CHANGED
|
@@ -173,7 +173,11 @@ export function chatStream(write, { model }, { includeUsage = false } = {}) {
|
|
|
173
173
|
const choice = (payload?.choices || [])[0] || {};
|
|
174
174
|
const text = typeof choice.message?.content === "string" ? choice.message.content : "";
|
|
175
175
|
if (text.startsWith(streamed) && text.length > streamed.length) chunk({ content: text.slice(streamed.length) });
|
|
176
|
-
|
|
176
|
+
// Tool calls come whole, once the answer is verified.
|
|
177
|
+
const tools = Array.isArray(choice.message?.tool_calls) ? choice.message.tool_calls : [];
|
|
178
|
+
tools.forEach((tool, index) => chunk({ tool_calls: [{ index, id: tool.id, type: tool.type || "function",
|
|
179
|
+
function: { name: tool.function?.name || "", arguments: tool.function?.arguments || "" } }] }));
|
|
180
|
+
chunk({}, choice.finish_reason || (tools.length ? "tool_calls" : "stop"));
|
|
177
181
|
if (includeUsage && payload?.usage) {
|
|
178
182
|
write(`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, choices: [], usage: payload.usage })}\n\n`);
|
|
179
183
|
}
|
package/src/web/console.html
CHANGED
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
<span class="badge" id="net">连接中…</span>
|
|
51
51
|
<span class="badge" id="relays">Relay —</span>
|
|
52
52
|
<span class="muted mono" id="paykey" style="margin-left:auto"></span>
|
|
53
|
+
<button class="ghost" id="lock" style="display:none;padding:4px 10px" title="钱包已解锁:15 分钟内签名不用再输密码">🔓 锁定钱包</button>
|
|
53
54
|
</header>
|
|
54
55
|
<nav>
|
|
55
56
|
<button data-tab="chat" class="active">对话</button>
|
|
@@ -142,7 +143,9 @@
|
|
|
142
143
|
|
|
143
144
|
<dialog id="dialog"><form method="dialog" id="dialog-form">
|
|
144
145
|
<h3 id="dialog-title" style="margin-top:0"></h3><div id="dialog-body"></div>
|
|
145
|
-
<label>钱包密码</label><input type="password" id="dialog-pw" required>
|
|
146
|
+
<div id="dialog-pw-box"><label>钱包密码</label><input type="password" id="dialog-pw" required>
|
|
147
|
+
<p class="muted" style="margin:6px 0 0">输入一次后,15 分钟内再签名不用重复输入。</p></div>
|
|
148
|
+
<p class="muted" id="dialog-unlocked" style="display:none">钱包已解锁,确认后用它签名。</p>
|
|
146
149
|
<div class="row" style="justify-content:flex-end"><button class="ghost" value="cancel" formnovalidate>取消</button><button class="primary" value="ok">确认</button></div>
|
|
147
150
|
</form></dialog>
|
|
148
151
|
<div id="toast"></div>
|
|
@@ -163,16 +166,41 @@ function toast(text, bad = false) {
|
|
|
163
166
|
async function api(path, body) {
|
|
164
167
|
const response = await fetch(path, body === undefined ? {} : { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
|
165
168
|
const data = await response.json().catch(() => ({}));
|
|
166
|
-
if (!response.ok)
|
|
169
|
+
if (!response.ok) {
|
|
170
|
+
const message = typeof data.error === "string" ? data.error : data.error?.message || `HTTP ${response.status}`;
|
|
171
|
+
throw Object.assign(new Error(message === "wrong wallet password" ? "钱包密码不对" : message), { code: data.code });
|
|
172
|
+
}
|
|
167
173
|
return data;
|
|
168
174
|
}
|
|
169
|
-
|
|
175
|
+
// Only actions signed by the owner wallet come here. While the wallet is unlocked (15 minutes after the last
|
|
176
|
+
// signature) this is a plain confirmation; otherwise it asks for the password once.
|
|
177
|
+
const unlocked = () => Boolean(status?.unlocked_until && status.unlocked_until > Date.now());
|
|
178
|
+
function ask(title, bodyHtml = "", { password = !unlocked() } = {}) {
|
|
170
179
|
return new Promise((resolve) => {
|
|
171
180
|
$("dialog-title").textContent = title; $("dialog-body").innerHTML = bodyHtml; $("dialog-pw").value = "";
|
|
172
|
-
$("dialog").
|
|
181
|
+
$("dialog-pw-box").style.display = password ? "" : "none"; $("dialog-pw").required = password;
|
|
182
|
+
$("dialog-unlocked").style.display = password ? "none" : "";
|
|
183
|
+
$("dialog").onclose = () => resolve($("dialog").returnValue === "ok"
|
|
184
|
+
? { password: password ? $("dialog-pw").value : undefined, form: $("dialog-body") } : null);
|
|
173
185
|
$("dialog").showModal();
|
|
174
186
|
});
|
|
175
187
|
}
|
|
188
|
+
/** Ask, sign, and when the unlock ran out meanwhile, ask once more with the password. */
|
|
189
|
+
async function signed(button, title, bodyHtml, call) {
|
|
190
|
+
let answer = await ask(title, bodyHtml); if (!answer) return;
|
|
191
|
+
await busy(button, async () => {
|
|
192
|
+
try { await call(answer); } catch (error) {
|
|
193
|
+
if (error.code !== "locked") throw error;
|
|
194
|
+
status.unlocked_until = null;
|
|
195
|
+
answer = await ask(title, bodyHtml, { password: true }); if (!answer) return;
|
|
196
|
+
await call(answer);
|
|
197
|
+
}
|
|
198
|
+
status = { ...status, unlocked_until: Date.now() + 15 * 60_000 }; showLock();
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
function showLock() {
|
|
202
|
+
$("lock").style.display = unlocked() ? "" : "none";
|
|
203
|
+
}
|
|
176
204
|
async function busy(button, work) {
|
|
177
205
|
button.disabled = true; const label = button.textContent; button.textContent = "处理中…";
|
|
178
206
|
try { await work(); } catch (error) { toast(error.message, true); } finally { button.disabled = false; button.textContent = label; }
|
|
@@ -190,7 +218,7 @@ let status = null;
|
|
|
190
218
|
async function refreshWallet() {
|
|
191
219
|
status = await api("/api/status");
|
|
192
220
|
$("net").textContent = `${status.network.id} · 链 ${status.network.chain_id}`; $("net").className = "badge ok";
|
|
193
|
-
$("paykey").textContent = `付款 key ${short(status.payment_key)}`;
|
|
221
|
+
$("paykey").textContent = `付款 key ${short(status.payment_key)}`; showLock();
|
|
194
222
|
$("no-wallet").style.display = status.wallet ? "none" : "block"; $("has-wallet").style.display = status.wallet ? "block" : "none";
|
|
195
223
|
$("faucet-btn").style.display = status.network.faucet ? "" : "none";
|
|
196
224
|
const w = status.wallet; if (!w) return;
|
|
@@ -241,60 +269,46 @@ async function refreshTenants() {
|
|
|
241
269
|
const cell = row.lastElementChild;
|
|
242
270
|
for (const [label, action] of [["调整预算", "budget"], ["撤销", "revoke"]]) {
|
|
243
271
|
const button = document.createElement("button"); button.className = "ghost"; button.textContent = label; button.style.marginRight = "6px";
|
|
244
|
-
button.onclick =
|
|
245
|
-
|
|
246
|
-
if (!answer) return;
|
|
247
|
-
await busy(button, async () => {
|
|
272
|
+
button.onclick = () => signed(button, `${label}:${t.name}`,
|
|
273
|
+
action === "budget" ? `<label>新的总预算(tUSDC)</label><input id="new-budget" value="${fmt(t.budget)}">` : "", async (answer) => {
|
|
248
274
|
await api(`/api/tenants/${action}`, { password: answer.password, name: t.name,
|
|
249
275
|
...(action === "budget" ? { budget: toUnits(answer.form.querySelector("#new-budget").value) } : {}) });
|
|
250
276
|
toast("已更新"); await refreshTenants();
|
|
251
277
|
});
|
|
252
|
-
};
|
|
253
278
|
cell.append(button);
|
|
254
279
|
}
|
|
255
280
|
}
|
|
256
281
|
$("tenants").append(row);
|
|
257
282
|
}
|
|
258
283
|
}
|
|
259
|
-
$("t-add").onclick = async () => {
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
await refreshTenants();
|
|
267
|
-
});
|
|
268
|
-
};
|
|
284
|
+
$("t-add").onclick = () => signed($("t-add"), `添加租户 ${$("t-name").value}`, "", async (answer) => {
|
|
285
|
+
const created = await api("/api/tenants", { password: answer.password, name: $("t-name").value.trim(),
|
|
286
|
+
budget: toUnits($("t-budget").value), max_per_request: toUnits($("t-limit").value) });
|
|
287
|
+
$("t-created").style.display = "block";
|
|
288
|
+
$("t-created").textContent = `租户 ${created.name} 的 API key(只显示这一次):${created.api_key}`;
|
|
289
|
+
await refreshTenants();
|
|
290
|
+
});
|
|
269
291
|
$("create-wallet").onclick = () => busy($("create-wallet"), async () => {
|
|
270
292
|
if ($("new-pw").value !== $("new-pw2").value) throw new Error("两次输入的密码不一致");
|
|
271
293
|
await api("/api/wallet", { password: $("new-pw").value }); toast("钱包已创建"); await refreshWallet();
|
|
272
294
|
});
|
|
295
|
+
$("lock").onclick = async () => { await api("/api/lock", {}); status.unlocked_until = null; showLock(); toast("钱包已锁定"); };
|
|
273
296
|
$("faucet-btn").onclick = () => busy($("faucet-btn"), async () => { await api("/api/faucet", {}); toast("已领取测试 ETH 和 tUSDC"); await refreshWallet(); });
|
|
274
|
-
$("deposit-btn").onclick =
|
|
275
|
-
|
|
276
|
-
if (!answer) return;
|
|
277
|
-
await busy($("deposit-btn"), async () => {
|
|
297
|
+
$("deposit-btn").onclick = () => signed($("deposit-btn"), `存入 ${$("dep-amount").value} tUSDC`,
|
|
298
|
+
`<p class="muted">单次请求上限 ${$("dep-limit").value} tUSDC。需要几笔链上交易,约 1 分钟。</p>`, async (answer) => {
|
|
278
299
|
await api("/api/setup", { password: answer.password, deposit: toUnits($("dep-amount").value), max_per_request: toUnits($("dep-limit").value) });
|
|
279
300
|
toast("押金已存入"); await refreshWallet();
|
|
280
301
|
});
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
});
|
|
289
|
-
};
|
|
290
|
-
|
|
291
|
-
const answer = await ask("取回押金"); if (!answer) return;
|
|
292
|
-
await busy($("withdraw-btn"), async () => {
|
|
293
|
-
const amount = $("wd-amount").value.trim();
|
|
294
|
-
const result = await api("/api/withdraw", { password: answer.password, ...(amount ? { amount: toUnits(amount) } : {}) });
|
|
295
|
-
toast(result.withdrawn ? `已取回 ${fmt(result.withdrawn)} tUSDC` : `已申请取回 ${fmt(result.requested)} tUSDC`); await refreshWallet();
|
|
296
|
-
});
|
|
297
|
-
};
|
|
302
|
+
$("myco-claim").onclick = () => signed($("myco-claim"), "领取 MYCO", "", async (answer) => {
|
|
303
|
+
const { claimed } = await api("/api/rewards/claim", { password: answer.password });
|
|
304
|
+
const hours = Object.values(claimed).reduce((a, b) => a + b, 0);
|
|
305
|
+
toast(hours ? `已领取 ${hours} 个小时的 MYCO` : "暂时没有可领取的 MYCO"); await refreshRewards();
|
|
306
|
+
});
|
|
307
|
+
$("withdraw-btn").onclick = () => signed($("withdraw-btn"), "取回押金", "", async (answer) => {
|
|
308
|
+
const amount = $("wd-amount").value.trim();
|
|
309
|
+
const result = await api("/api/withdraw", { password: answer.password, ...(amount ? { amount: toUnits(amount) } : {}) });
|
|
310
|
+
toast(result.withdrawn ? `已取回 ${fmt(result.withdrawn)} tUSDC` : `已申请取回 ${fmt(result.requested)} tUSDC`); await refreshWallet();
|
|
311
|
+
});
|
|
298
312
|
|
|
299
313
|
// ---------------- chat ----------------
|
|
300
314
|
let conversation = [];
|
|
@@ -349,15 +363,11 @@ async function refreshHistory() {
|
|
|
349
363
|
const action = document.createElement("td");
|
|
350
364
|
if (entry.status === "pending" && entry.release_at > now) {
|
|
351
365
|
const button = document.createElement("button"); button.className = "ghost"; button.textContent = "争议";
|
|
352
|
-
button.onclick =
|
|
353
|
-
|
|
354
|
-
<label>说明</label><textarea id="statement" placeholder="例如:回答与问题完全无关"></textarea>`);
|
|
355
|
-
if (!answer) return;
|
|
356
|
-
await busy(button, async () => {
|
|
366
|
+
button.onclick = () => signed(button, "发起争议", `<p class="muted">会公开这次的问题和回答给随机抽选的 Provider-AI 陪审团,并锁定 1 tUSDC 保证金;确认欺诈后退款并返还保证金。</p>
|
|
367
|
+
<label>说明</label><textarea id="statement" placeholder="例如:回答与问题完全无关"></textarea>`, async (answer) => {
|
|
357
368
|
const result = await api("/api/dispute", { password: answer.password, settlement_key: entry.settlement_key, statement: answer.form.querySelector("#statement").value });
|
|
358
369
|
toast(`争议已提交,${result.relays_accepted.length} 个 Relay 接收了证据`); await refreshHistory();
|
|
359
370
|
});
|
|
360
|
-
};
|
|
361
371
|
action.append(button);
|
|
362
372
|
}
|
|
363
373
|
row.append(action); $("history").append(row);
|