mycomesh-consumer 0.9.13 → 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.
@@ -33,7 +33,7 @@
33
33
  "text"
34
34
  ],
35
35
  "streaming": true,
36
- "tools": false,
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": false,
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": false,
82
+ "tools": true,
83
83
  "dialects": [
84
84
  "openai-responses",
85
85
  "openai-chat",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mycomesh-consumer",
3
- "version": "0.9.13",
3
+ "version": "0.9.14",
4
4
  "description": "MycoMesh V11 Consumer: a custodied deposit, relay-blind encrypted requests to any Provider, and a local OpenAI-compatible endpoint.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/consumer.mjs CHANGED
@@ -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, fromAnthropic, fromGemini, geminiError, geminiRoute, geminiStream,
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 answer = outputText(response.output);
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(outputText(response.output), receipt.receipt, streamedAny);
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);
package/src/dialects.mjs CHANGED
@@ -1,6 +1,7 @@
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.
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. Other block kinds are refused. */
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
- throw new DialectError(`${where}: only text content is supported (got ${part?.type || Object.keys(part || {})[0] || "unknown"})`);
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 unsupportedTools = (present) => {
25
- if (present) throw new DialectError("tool use is not supported on this network yet; send text-only requests");
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 (!["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`) });
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
- // Claude Code puts its session in metadata.user_id: a conversation's turns share it.
44
- const session = typeof body.metadata?.user_id === "string" ? body.metadata.user_id : undefined;
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
- export function toAnthropic(model, answer, receipt) {
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
- content: [{ type: "text", text: answer }], stop_reason: "end_turn", stop_sequence: null,
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, fed with text as it arrives. */
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
- event("content_block_start", { index: 0, content_block: { type: "text", text: "" } });
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) { start(); if (piece) event("content_block_delta", { index: 0, delta: { type: "text_delta", text: piece } }); },
69
- finish(answer, receipt, streamedAny) {
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
- if (!streamedAny && answer) event("content_block_delta", { index: 0, delta: { type: "text_delta", text: answer } });
72
- event("content_block_stop", { index: 0 });
73
- event("message_delta", { delta: { stop_reason: "end_turn", stop_sequence: null },
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 role = item.role === "model" ? "assistant" : "user";
103
- messages.push({ role, content: text(item.parts, `${item.role || "user"} content`) });
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 geminiChunk(model, piece, receipt) {
114
- const chunk = { candidates: [{ content: { role: "model", parts: [{ text: piece }] }, index: 0 }], modelVersion: model };
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, answer, receipt) => geminiChunk(model, answer, receipt);
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
- /** streamGenerateContent with alt=sse: one JSON chunk per event; the last carries finishReason and usage. */
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(answer, receipt, streamedAny) { chunk(geminiChunk(model, streamedAny ? "" : answer, receipt)); },
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
- const bytes = Buffer.byteLength(JSON.stringify(content ?? null), "utf8");
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
- chunk({}, choice.finish_reason || "stop");
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
  }