mycomesh-consumer 0.9.13 → 0.9.15

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.
@@ -27,13 +27,14 @@
27
27
  "tier_name": "OpenAI frontier",
28
28
  "capabilities": {
29
29
  "input": [
30
- "text"
30
+ "text",
31
+ "image"
31
32
  ],
32
33
  "output": [
33
34
  "text"
34
35
  ],
35
36
  "streaming": true,
36
- "tools": false,
37
+ "tools": true,
37
38
  "dialects": [
38
39
  "openai-responses",
39
40
  "openai-chat",
@@ -50,13 +51,14 @@
50
51
  "tier_name": "Anthropic Claude",
51
52
  "capabilities": {
52
53
  "input": [
53
- "text"
54
+ "text",
55
+ "image"
54
56
  ],
55
57
  "output": [
56
58
  "text"
57
59
  ],
58
60
  "streaming": true,
59
- "tools": false,
61
+ "tools": true,
60
62
  "dialects": [
61
63
  "openai-responses",
62
64
  "openai-chat",
@@ -73,13 +75,14 @@
73
75
  "tier_name": "Anthropic Claude",
74
76
  "capabilities": {
75
77
  "input": [
76
- "text"
78
+ "text",
79
+ "image"
77
80
  ],
78
81
  "output": [
79
82
  "text"
80
83
  ],
81
84
  "streaming": true,
82
- "tools": false,
85
+ "tools": true,
83
86
  "dialects": [
84
87
  "openai-responses",
85
88
  "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.15",
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.
@@ -43,6 +43,20 @@ class Cached {
43
43
  }
44
44
  }
45
45
 
46
+ const MAX_REQUEST_BYTES = 7_500_000; // the sealed request must fit the network's 8 MB plaintext limit
47
+
48
+ /** What a request asks a backend to read besides text: "image", "file". */
49
+ export function inputKinds(content) {
50
+ const kinds = new Set();
51
+ for (const message of Array.isArray(content) ? content : []) {
52
+ for (const part of Array.isArray(message?.content) ? message.content : []) {
53
+ if (part?.type === "image_url" || part?.type === "input_image") kinds.add("image");
54
+ if (part?.type === "file" || part?.type === "input_file") kinds.add("file");
55
+ }
56
+ }
57
+ return [...kinds].sort();
58
+ }
59
+
46
60
  // Session affinity: a conversation goes back to the Provider that answered its last turn, whose upstream
47
61
  // account holds the conversation's prompt cache (measured: a new Codex thread with the same 10k-token prefix
48
62
  // read 9,728 tokens from cache on the same account, none on another).
@@ -397,6 +411,12 @@ export class Consumer {
397
411
  async attempt({ endpoint, model, content, maxOutputTokens = 4096, options = {}, provider, session, onDelta }) {
398
412
  let lastError;
399
413
  let raisedFee = 0;
414
+ const size = Buffer.byteLength(JSON.stringify({ content, tools: options.tools ?? null }), "utf8");
415
+ if (size > MAX_REQUEST_BYTES) {
416
+ throw Object.assign(new Error(`this request is ${(size / 1e6).toFixed(1)} MB; the network carries up to ${MAX_REQUEST_BYTES / 1e6} MB (smaller or fewer images)`), { status: 413 });
417
+ }
418
+ const needs = inputKinds(content); // images and files go only to Providers whose backend reads them
419
+ let unreadable = false;
400
420
  const conversation = sessionKey({ endpoint, model, content, options, session });
401
421
  const preferred = this.stickyProvider(conversation);
402
422
  for (const relay of await this.relays()) {
@@ -404,6 +424,9 @@ export class Consumer {
404
424
  try {
405
425
  candidates = (await this.providers(relay)).filter((descriptor) => (descriptor.models || []).includes(model)
406
426
  && (!provider || descriptor.provider_signer === provider.toLowerCase()));
427
+ const able = candidates.filter((descriptor) => needs.every((kind) => (descriptor.inputs || ["text"]).includes(kind)));
428
+ if (able.length < candidates.length && !able.length) unreadable = true;
429
+ candidates = able;
407
430
  }
408
431
  catch (error) { lastError = error; continue; }
409
432
  const records = await this.reputations({ waitMs: REPUTATION_WAIT_MS }).catch(() => ({}));
@@ -412,7 +435,7 @@ export class Consumer {
412
435
  for (let index = 0; index < candidates.length; index += 1) {
413
436
  const descriptor = candidates[index];
414
437
  // 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);
438
+ const plan = planRequest(descriptor, endpoint, content, maxOutputTokens, this.maxFee, options.tools);
416
439
  const maxFee = Math.min(Number(this.maxFee), Math.max(raisedFee || 0, plan.maxFee));
417
440
  const prepared = prepareRequest({
418
441
  descriptor, deployment: this.deployment, keyPrivate: this.keyPrivate, relaySigner: relay.signer,
@@ -478,6 +501,9 @@ export class Consumer {
478
501
  if (reply.body?.dispatched !== false || ![502, 503].includes(reply.status)) throw lastError;
479
502
  }
480
503
  }
504
+ if (!lastError && unreadable) {
505
+ throw Object.assign(new Error(`no Provider online for ${model} reads ${needs.join(" and ")} input (Codex Providers read text and images, not files)`), { status: 400 });
506
+ }
481
507
  throw lastError || new Error(`no Provider serves model ${model}`);
482
508
  }
483
509
  }
@@ -501,14 +527,17 @@ async function readJson(req) {
501
527
  async function dialectRequest(req, res, active, path, setWriter) {
502
528
  const body = await readJson(req);
503
529
  const gemini = geminiRoute(path);
530
+ // Token counting (Claude Code and the Gemini CLI ask before long requests): an estimate, no request is sent.
531
+ const json = (value) => { res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }); return res.end(JSON.stringify(value)); };
532
+ if (path === "/v1/messages/count_tokens") return json(countAnthropicTokens(body));
533
+ if (gemini?.count) return json(countGeminiTokens(body, gemini.model));
504
534
  const ask = gemini ? fromGemini(body, gemini.model) : fromAnthropic(body);
505
535
  const stream = gemini ? gemini.stream : ask.stream;
506
536
  const request = { endpoint: "chat", model: ask.model, content: ask.messages, maxOutputTokens: ask.maxOutputTokens, options: ask.options,
507
537
  session: ask.session || req.headers["x-claude-code-session-id"] || req.headers["session_id"] || req.headers["x-session-id"] || undefined };
508
538
  if (!stream) {
509
539
  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);
540
+ const out = gemini ? toGemini(ask.model, response.output, receipt.receipt) : toAnthropic(ask.model, response.output, receipt.receipt);
512
541
  res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
513
542
  return res.end(JSON.stringify(out));
514
543
  }
@@ -520,7 +549,32 @@ async function dialectRequest(req, res, active, path, setWriter) {
520
549
  setWriter(writer);
521
550
  let streamedAny = false;
522
551
  const { response, receipt } = await active.request({ ...request, onDelta: (piece) => { streamedAny ||= Boolean(piece); writer.delta(piece); } });
523
- writer.finish(outputText(response.output), receipt.receipt, streamedAny);
552
+ writer.finish(response.output, receipt.receipt, streamedAny);
553
+ return res.end();
554
+ }
555
+
556
+ /**
557
+ * Responses with tools (Codex CLI and other agents): translated to the network's chat shape and back, so the
558
+ * Provider sees one way of calling tools; the client gets function_call / custom_tool_call items to run.
559
+ */
560
+ async function responsesWithTools(res, active, body, session, setWriter) {
561
+ const { model, stream } = body;
562
+ const { messages, options, custom } = fromResponses(body);
563
+ const maxOutputTokens = body.max_output_tokens ?? 4096;
564
+ const request = { endpoint: "chat", model, content: messages, maxOutputTokens, options, session };
565
+ if (!stream) {
566
+ const { response, receipt } = await active.request(request);
567
+ res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
568
+ return res.end(JSON.stringify(toResponses(model, response.output, receipt.receipt, custom)));
569
+ }
570
+ const write = (text) => {
571
+ if (!res.headersSent) res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
572
+ res.write(text);
573
+ };
574
+ const writer = responseStream(write, { model });
575
+ setWriter(writer);
576
+ const { response, receipt } = await active.request({ ...request, onDelta: (text) => writer.delta(text) });
577
+ writer.finish(toResponses(model, response.output, receipt.receipt, custom));
524
578
  return res.end();
525
579
  }
526
580
 
@@ -540,7 +594,7 @@ export function serveConsumer(consumer, { host = "127.0.0.1", port = 8110, apiKe
540
594
  };
541
595
  try {
542
596
  const path = new URL(req.url, "http://localhost").pathname.replace(/^\/v1\/v1\//, "/v1/");
543
- dialect = path === "/v1/messages" ? "anthropic" : geminiRoute(path) ? "gemini" : "openai";
597
+ dialect = path === "/v1/messages" || path === "/v1/messages/count_tokens" ? "anthropic" : geminiRoute(path) ? "gemini" : "openai";
544
598
  // A tenant's API key selects its own payment key and budget; tenants may call from anywhere.
545
599
  const tenant = /^\/v1(beta|alpha)?\//.test(path) && bearer(req) ? tenants()[hashApiKey(bearer(req))] : undefined;
546
600
  if (!tenant && !localRequestAllowed(req, server.address().port)) {
@@ -570,6 +624,8 @@ export function serveConsumer(consumer, { host = "127.0.0.1", port = 8110, apiKe
570
624
  }
571
625
  const body = await readJson(req);
572
626
  const chat = path.endsWith("/chat/completions");
627
+ const session = req.headers["session_id"] || req.headers["x-session-id"] || undefined;
628
+ if (!chat && responsesUsesTools(body)) return await responsesWithTools(res, active, body, body.prompt_cache_key || session, (w) => { writer = w; });
573
629
  const { model, stream, stream_options: streamOptions, ...rest } = body;
574
630
  const content = chat ? rest.messages : rest.input;
575
631
  delete rest.messages;
@@ -578,7 +634,6 @@ export function serveConsumer(consumer, { host = "127.0.0.1", port = 8110, apiKe
578
634
  delete rest.max_output_tokens;
579
635
  delete rest.max_tokens;
580
636
  delete rest.max_completion_tokens;
581
- const session = req.headers["session_id"] || req.headers["x-session-id"] || undefined;
582
637
  if (!stream) {
583
638
  const { response } = await active.request({ endpoint: chat ? "chat" : "responses", model, content, maxOutputTokens, options: rest, session });
584
639
  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,349 @@ 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
+ /**
14
+ * Content in the network's parts (OpenAI Chat): a string when it is all text, else text, image_url (a data: or
15
+ * https URL) and file (a data: URL and its filename) parts. Anthropic blocks, Responses inputs and Gemini parts
16
+ * all land here.
17
+ */
18
+ export function parts(content, where) {
19
+ if (typeof content === "string" || content === undefined || content === null) return content ?? "";
20
+ if (!Array.isArray(content)) throw new DialectError(`${where}: content must be text`);
21
+ const out = [];
22
+ for (const part of content) {
23
+ const media = mediaPart(part);
24
+ if (media) out.push(media);
25
+ else { const words = text([part], where); if (words) out.push({ type: "text", text: words }); }
26
+ }
27
+ return out.every((p) => p.type === "text") ? out.map((p) => p.text).join("") : out;
28
+ }
29
+
30
+ const dataUrl = (mime, data) => `data:${mime || "application/octet-stream"};base64,${data}`;
31
+
32
+ /** An image or document block of any dialect as a network part; null for text and the rest. */
33
+ function mediaPart(part) {
34
+ if (!part || typeof part !== "object") return null;
35
+ if (part.type === "image" && part.source) { // Anthropic
36
+ const url = part.source.type === "base64" ? dataUrl(part.source.media_type, part.source.data) : part.source.url;
37
+ return url ? { type: "image_url", image_url: { url } } : null;
38
+ }
39
+ if (part.type === "document" && part.source?.type === "base64") {
40
+ return { type: "file", file: { filename: part.title || "document.pdf", file_data: dataUrl(part.source.media_type, part.source.data) } };
41
+ }
42
+ if (part.type === "input_image" && (part.image_url || part.url)) return { type: "image_url", image_url: { url: part.image_url || part.url } }; // Responses
43
+ if (part.type === "input_file" && part.file_data) {
44
+ return { type: "file", file: { filename: part.filename || "file", file_data: part.file_data.startsWith("data:") ? part.file_data : dataUrl("application/pdf", part.file_data) } };
45
+ }
46
+ if (part.type === "image_url" && part.image_url) return { type: "image_url", image_url: { url: part.image_url.url || part.image_url } }; // Chat
47
+ if (part.type === "file" && part.file) return { type: "file", file: part.file };
48
+ if (part.inlineData?.data) { // Gemini
49
+ const url = dataUrl(part.inlineData.mimeType, part.inlineData.data);
50
+ return part.inlineData.mimeType?.startsWith("image/") ? { type: "image_url", image_url: { url } }
51
+ : { type: "file", file: { filename: "file", file_data: url } };
52
+ }
53
+ return null;
54
+ }
55
+
56
+ /** The text of content (images and documents become a note): for places that can hold only text. */
13
57
  function text(content, where) {
14
58
  if (typeof content === "string") return content;
59
+ if (content === undefined || content === null) return "";
15
60
  if (!Array.isArray(content)) throw new DialectError(`${where}: content must be text`);
16
61
  return content.map((part) => {
17
62
  if (typeof part === "string") return part;
18
63
  if (part && typeof part.text === "string" && (part.type === undefined || part.type === "text")) return part.text;
19
64
  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"})`);
65
+ if (part?.type === "image" || part?.inlineData || part?.type === "input_image" || part?.type === "image_url") return "[image]";
66
+ if (part?.type === "document") return part.source?.type === "text" ? String(part.source.data ?? "") : "[document]";
67
+ if (part?.type === "input_file" || part?.type === "file") return "[file]";
68
+ if (part?.type === "input_text" || part?.type === "output_text") return String(part.text ?? "");
69
+ throw new DialectError(`${where}: unsupported content (${part?.type || Object.keys(part || {})[0] || "unknown"})`);
21
70
  }).join("");
22
71
  }
23
72
 
24
- const unsupportedTools = (present) => {
25
- if (present) throw new DialectError("tool use is not supported on this network yet; send text-only requests");
73
+ const parseArguments = (value) => {
74
+ if (value && typeof value === "object") return value;
75
+ try { return JSON.parse(value || "{}"); } catch { return { input: String(value) }; }
26
76
  };
27
77
 
78
+ /** The answer and tool calls of a network response (a chat completion). */
79
+ export function answerOf(output) {
80
+ const choice = output?.choices?.[0] || {};
81
+ const message = choice.message || {};
82
+ return { text: typeof message.content === "string" ? message.content : "", calls: Array.isArray(message.tool_calls) ? message.tool_calls : [],
83
+ finish: choice.finish_reason || "stop" };
84
+ }
85
+
28
86
  // ---------------- Anthropic Messages ----------------
29
87
 
30
- /** POST /v1/messages body -> { model, messages, maxOutputTokens, options, stream } */
88
+ /** POST /v1/messages body -> { model, messages, maxOutputTokens, options, stream, session } */
31
89
  export function fromAnthropic(body) {
32
90
  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
91
  const messages = [];
35
92
  if (body.system !== undefined) messages.push({ role: "system", content: text(body.system, "system") });
36
93
  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`) });
94
+ if (message?.role === "system") { messages.push({ role: "system", content: text(message.content, "system message") }); continue; }
95
+ if (!["user", "assistant"].includes(message?.role)) throw new DialectError(`unsupported message role ${message?.role}`);
96
+ const blocks = typeof message.content === "string" ? [{ type: "text", text: message.content }] : message.content || [];
97
+ if (message.role === "assistant") {
98
+ const calls = blocks.filter((b) => b?.type === "tool_use").map((b) => ({ id: b.id, type: "function",
99
+ function: { name: b.name, arguments: JSON.stringify(b.input ?? {}) } }));
100
+ const words = text(blocks.filter((b) => b?.type !== "tool_use"), "assistant message");
101
+ messages.push({ role: "assistant", content: words || (calls.length ? null : ""), ...(calls.length ? { tool_calls: calls } : {}) });
102
+ continue;
103
+ }
104
+ // A user turn: tool results answer the previous assistant's calls; the rest is what the user said. A tool
105
+ // result can only be text, so images it returned (a screenshot Read) follow in a user message.
106
+ const shown = [];
107
+ for (const block of blocks.filter((b) => b?.type === "tool_result")) {
108
+ const content = Array.isArray(block.content) ? block.content : [{ type: "text", text: String(block.content ?? "") }];
109
+ const media = content.map(mediaPart).filter(Boolean);
110
+ const result = text(content.filter((b) => !mediaPart(b)), "tool_result") + (media.length ? `\n[${media.length} attachment(s) follow]` : "");
111
+ messages.push({ role: "tool", tool_call_id: block.tool_use_id, content: block.is_error ? `Error: ${result}` : result });
112
+ if (media.length) shown.push({ type: "text", text: `Attachments returned by tool call ${block.tool_use_id}:` }, ...media);
113
+ }
114
+ const rest = blocks.filter((b) => b?.type !== "tool_result");
115
+ const said = rest.length ? parts(rest, "user message") : "";
116
+ if (shown.length) messages.push({ role: "user", content: [...shown, ...(typeof said === "string" ? (said ? [{ type: "text", text: said }] : []) : said)] });
117
+ else if (rest.length || !blocks.length) messages.push({ role: "user", content: said });
39
118
  }
40
119
  const options = {};
41
120
  for (const key of ["temperature", "top_p"]) if (body[key] !== undefined) options[key] = body[key];
42
121
  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;
122
+ // The effort the user chose in the client (Claude Code: output_config.effort) is the model's reasoning effort.
123
+ const effort = body.output_config?.effort;
124
+ if (["minimal", "low", "medium", "high"].includes(effort)) options.reasoning_effort = effort;
125
+ const tools = (body.tools || []).filter((t) => t?.name && (t.type === undefined || t.type === "custom" || t.input_schema));
126
+ if (tools.length) {
127
+ options.tools = tools.map((t) => ({ type: "function", function: { name: t.name, description: t.description || "",
128
+ parameters: t.input_schema || { type: "object", properties: {} } } }));
129
+ const choice = body.tool_choice;
130
+ if (choice?.type === "any") options.tool_choice = "required";
131
+ else if (choice?.type === "tool" && choice.name) options.tool_choice = { type: "function", function: { name: choice.name } };
132
+ else if (choice?.type === "none") options.tool_choice = "none";
133
+ }
134
+ // Claude Code puts its session in metadata.user_id (a JSON string with session_id): a conversation's turns share it.
135
+ let session = typeof body.metadata?.user_id === "string" ? body.metadata.user_id : undefined;
136
+ try { session = JSON.parse(session).session_id || session; } catch {}
45
137
  return { model: body.model, messages, maxOutputTokens: body.max_tokens ?? 4096, options, stream: body.stream === true, session };
46
138
  }
47
139
 
48
- export function toAnthropic(model, answer, receipt) {
140
+ const anthropicStop = (answer) => answer.calls.length ? "tool_use" : answer.finish === "length" ? "max_tokens" : "end_turn";
141
+
142
+ export function toAnthropic(model, output, receipt) {
143
+ const answer = answerOf(output);
144
+ const content = answer.text ? [{ type: "text", text: answer.text }] : [];
145
+ for (const call of answer.calls) content.push({ type: "tool_use", id: call.id, name: call.function?.name, input: parseArguments(call.function?.arguments) });
49
146
  return {
50
- id: id("msg_"), type: "message", role: "assistant", model,
51
- content: [{ type: "text", text: answer }], stop_reason: "end_turn", stop_sequence: null,
147
+ id: id("msg_"), type: "message", role: "assistant", model, content: content.length ? content : [{ type: "text", text: "" }],
148
+ stop_reason: anthropicStop(answer), stop_sequence: null,
52
149
  usage: { input_tokens: Number(receipt.input_tokens), output_tokens: Number(receipt.output_tokens) },
53
150
  };
54
151
  }
55
152
 
56
- /** Anthropic's SSE event sequence, fed with text as it arrives. */
153
+ /** Anthropic's SSE event sequence: text as it arrives, then tool_use blocks when the verified answer is in. */
57
154
  export function anthropicStream(write, model) {
58
155
  const event = (type, data) => write(`event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`);
59
156
  let started = false;
157
+ let textOpen = false;
158
+ let index = 0;
60
159
  const start = () => {
61
160
  if (started) return;
62
161
  started = true;
63
162
  event("message_start", { message: { id: id("msg_"), type: "message", role: "assistant", model, content: [],
64
163
  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: "" } });
164
+ };
165
+ const openText = () => {
166
+ if (textOpen) return;
167
+ textOpen = true;
168
+ event("content_block_start", { index, content_block: { type: "text", text: "" } });
66
169
  };
67
170
  return {
68
- delta(piece) { start(); if (piece) event("content_block_delta", { index: 0, delta: { type: "text_delta", text: piece } }); },
69
- finish(answer, receipt, streamedAny) {
171
+ delta(piece) {
70
172
  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 },
173
+ if (!piece) return;
174
+ openText();
175
+ event("content_block_delta", { index, delta: { type: "text_delta", text: piece } });
176
+ },
177
+ finish(output, receipt, streamedAny) {
178
+ start();
179
+ const answer = answerOf(output);
180
+ if (!streamedAny && answer.text) { openText(); event("content_block_delta", { index, delta: { type: "text_delta", text: answer.text } }); }
181
+ if (!textOpen && !answer.calls.length) openText();
182
+ if (textOpen) { event("content_block_stop", { index }); index += 1; }
183
+ for (const call of answer.calls) {
184
+ event("content_block_start", { index, content_block: { type: "tool_use", id: call.id, name: call.function?.name, input: {} } });
185
+ event("content_block_delta", { index, delta: { type: "input_json_delta", partial_json: JSON.stringify(parseArguments(call.function?.arguments)) } });
186
+ event("content_block_stop", { index });
187
+ index += 1;
188
+ }
189
+ event("message_delta", { delta: { stop_reason: anthropicStop(answer), stop_sequence: null },
74
190
  usage: { input_tokens: Number(receipt.input_tokens), output_tokens: Number(receipt.output_tokens) } });
75
191
  event("message_stop", {});
76
192
  },
77
- fail(message) { event("error", { error: { type: "api_error", message } }); },
193
+ fail(message) { start(); event("error", { error: { type: "api_error", message } }); },
78
194
  };
79
195
  }
80
196
 
197
+ /** /v1/messages/count_tokens: an upper-bound estimate (UTF-8 bytes / 3, as prices are authorized). */
198
+ export function countAnthropicTokens(body) {
199
+ const ask = fromAnthropic({ max_tokens: 1, ...body });
200
+ const bytes = Buffer.byteLength(JSON.stringify(ask.messages), "utf8") + Buffer.byteLength(JSON.stringify(ask.options.tools || []), "utf8");
201
+ return { input_tokens: Math.ceil(bytes / 3) };
202
+ }
203
+
81
204
  export const anthropicError = (message, status) => ({ type: "error", error: {
82
205
  type: status === 401 || status === 403 ? "authentication_error" : status === 404 ? "not_found_error"
83
206
  : status === 402 ? "billing_error" : status >= 500 ? "api_error" : "invalid_request_error", message } });
84
207
 
208
+ // ---------------- OpenAI Responses (with tools) ----------------
209
+
210
+ /** Whether a Responses request needs the tool translation (tools, or tool calls and results in its input). */
211
+ export function responsesUsesTools(body) {
212
+ return (Array.isArray(body.tools) && body.tools.length > 0)
213
+ || (Array.isArray(body.input) && body.input.some((item) => /^(function_call|custom_tool_call)/.test(item?.type || "")));
214
+ }
215
+
216
+ /** POST /v1/responses body with tools -> { messages, options, custom } (custom: names of freeform tools). */
217
+ export function fromResponses(body) {
218
+ const messages = [];
219
+ if (body.instructions) messages.push({ role: "system", content: String(body.instructions) });
220
+ const custom = new Set();
221
+ const tools = [];
222
+ for (const tool of body.tools || []) {
223
+ if (tool?.type === "function" && tool.name) {
224
+ tools.push({ type: "function", function: { name: tool.name, description: tool.description || "", parameters: tool.parameters || { type: "object", properties: {} } } });
225
+ } else if (tool?.type === "custom" && tool.name) {
226
+ // A freeform tool (Codex's apply_patch): one string argument carries its whole input.
227
+ custom.add(tool.name);
228
+ tools.push({ type: "function", function: { name: tool.name, description: `${tool.description || ""}\nPut the tool's complete raw input in the "input" string.`,
229
+ parameters: { type: "object", properties: { input: { type: "string" } }, required: ["input"] } } });
230
+ } // hosted tools (web search, file search, ...) cannot run on this network: they are left out
231
+ }
232
+ const input = typeof body.input === "string" ? [{ role: "user", content: body.input }] : body.input || [];
233
+ const assistant = () => {
234
+ const last = messages.at(-1);
235
+ if (last?.role === "assistant") return last;
236
+ const fresh = { role: "assistant", content: null, tool_calls: [] };
237
+ messages.push(fresh);
238
+ return fresh;
239
+ };
240
+ for (const item of input) {
241
+ const type = item?.type || (item?.role ? "message" : "");
242
+ if (type === "message") {
243
+ const role = item.role === "developer" ? "system" : item.role;
244
+ if (role === "assistant") { const a = assistant(); a.content = (a.content || "") + text(item.content, "assistant message"); }
245
+ else messages.push({ role, content: role === "user" ? parts(item.content, "user message") : text(item.content, `${role} message`) });
246
+ } else if (type === "function_call" || type === "custom_tool_call") {
247
+ const a = assistant();
248
+ (a.tool_calls ||= []).push({ id: item.call_id, type: "function", function: { name: item.name,
249
+ arguments: type === "custom_tool_call" ? JSON.stringify({ input: item.input ?? "" }) : (item.arguments ?? "{}") } });
250
+ } else if (type === "function_call_output" || type === "custom_tool_call_output") {
251
+ const media = Array.isArray(item.output) ? item.output.map(mediaPart).filter(Boolean) : [];
252
+ const output = typeof item.output === "string" ? item.output : text(item.output.filter((b) => !mediaPart(b)), "tool output");
253
+ messages.push({ role: "tool", tool_call_id: item.call_id, content: output });
254
+ if (media.length) messages.push({ role: "user", content: [{ type: "text", text: `Attachments returned by tool call ${item.call_id}:` }, ...media] });
255
+ } // reasoning items and the like stay with the client
256
+ }
257
+ for (const message of messages) if (message.role === "assistant" && !message.tool_calls?.length) delete message.tool_calls;
258
+ const options = {};
259
+ for (const key of ["temperature", "top_p", "reasoning", "parallel_tool_calls"]) if (body[key] !== undefined) options[key] = body[key];
260
+ if (tools.length) options.tools = tools;
261
+ const choice = body.tool_choice;
262
+ if (choice === "none" || choice === "required" || choice === "auto") options.tool_choice = choice;
263
+ else if (choice?.name) options.tool_choice = { type: "function", function: { name: choice.name } };
264
+ return { messages, options, custom };
265
+ }
266
+
267
+ /** A network answer (chat completion) as a Responses object, freeform tools as custom_tool_call items. */
268
+ export function toResponses(model, output, receipt, custom = new Set()) {
269
+ const answer = answerOf(output);
270
+ const items = [];
271
+ if (answer.text || !answer.calls.length) {
272
+ items.push({ type: "message", id: id("msg_"), role: "assistant", status: "completed",
273
+ content: [{ type: "output_text", text: answer.text, annotations: [] }] });
274
+ }
275
+ for (const call of answer.calls) {
276
+ const name = call.function?.name;
277
+ items.push(custom.has(name)
278
+ ? { type: "custom_tool_call", id: id("ctc_"), call_id: call.id, name, input: String(parseArguments(call.function?.arguments).input ?? ""), status: "completed" }
279
+ : { type: "function_call", id: id("fc_"), call_id: call.id, name, arguments: call.function?.arguments || "{}", status: "completed" });
280
+ }
281
+ const input = Number(receipt?.input_tokens ?? output?.usage?.prompt_tokens ?? 0);
282
+ const out = Number(receipt?.output_tokens ?? output?.usage?.completion_tokens ?? 0);
283
+ return { id: id("resp_"), object: "response", created_at: Math.floor(Date.now() / 1000), model, status: "completed",
284
+ output: items, output_text: answer.text, usage: { input_tokens: input, output_tokens: out, total_tokens: input + out } };
285
+ }
286
+
85
287
  // ---------------- Gemini generateContent ----------------
86
288
 
87
- const GEMINI_PATH = /^\/(?:v1|v1beta|v1alpha)\/models\/([^/:]+):(generateContent|streamGenerateContent)$/;
289
+ const GEMINI_PATH = /^\/(?:v1|v1beta|v1alpha)\/models\/([^/:]+):(generateContent|streamGenerateContent|countTokens)$/;
88
290
 
89
291
  /** The model and whether it streams, for a Gemini path; null for any other path. */
90
292
  export function geminiRoute(path) {
91
293
  const match = GEMINI_PATH.exec(path);
92
- return match ? { model: decodeURIComponent(match[1]), stream: match[2] === "streamGenerateContent" } : null;
294
+ return match ? { model: decodeURIComponent(match[1]), stream: match[2] === "streamGenerateContent", count: match[2] === "countTokens" } : null;
93
295
  }
94
296
 
297
+ const partsOf = (value, where) => parts(value, where);
298
+
95
299
  export function fromGemini(body, model) {
96
300
  if (!Array.isArray(body.contents)) throw new DialectError("contents are required");
97
- unsupportedTools(body.tools?.length);
98
301
  const messages = [];
99
302
  const system = body.systemInstruction || body.system_instruction;
100
303
  if (system) messages.push({ role: "system", content: text(system.parts ?? system, "systemInstruction") });
304
+ // Gemini answers a call by the function's name; the network by an id. Pair each response with the
305
+ // earliest unanswered call of that name.
306
+ const open = [];
307
+ let counter = 0;
101
308
  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`) });
309
+ const parts = item.parts || [];
310
+ const calls = parts.filter((p) => p.functionCall);
311
+ const results = parts.filter((p) => p.functionResponse);
312
+ const said = parts.filter((p) => !p.functionCall && !p.functionResponse);
313
+ const words = item.role === "model" ? text(said, "model content") : partsOf(said, "user content");
314
+ if (item.role === "model") {
315
+ const toolCalls = calls.map((p) => {
316
+ const call = { id: p.functionCall.id || `call_${counter++}`, type: "function",
317
+ function: { name: p.functionCall.name, arguments: JSON.stringify(p.functionCall.args ?? {}) } };
318
+ open.push(call);
319
+ return call;
320
+ });
321
+ messages.push({ role: "assistant", content: words || (toolCalls.length ? null : ""), ...(toolCalls.length ? { tool_calls: toolCalls } : {}) });
322
+ continue;
323
+ }
324
+ for (const part of results) { // results first: they answer the calls the model just made
325
+ const index = open.findIndex((call) => call.function.name === part.functionResponse.name);
326
+ const call = index >= 0 ? open.splice(index, 1)[0] : null;
327
+ messages.push({ role: "tool", tool_call_id: part.functionResponse.id || call?.id || `call_${counter++}`,
328
+ content: JSON.stringify(part.functionResponse.response ?? {}) });
329
+ }
330
+ if ((typeof words === "string" ? words : words.length) || !results.length) messages.push({ role: "user", content: words });
104
331
  }
105
332
  const config = body.generationConfig || body.generation_config || {};
106
333
  const options = {};
107
334
  if (config.temperature !== undefined) options.temperature = config.temperature;
108
335
  if (config.topP !== undefined) options.top_p = config.topP;
109
336
  if (config.stopSequences) options.stop = config.stopSequences;
337
+ const declarations = (body.tools || []).flatMap((t) => t.functionDeclarations || t.function_declarations || []);
338
+ if (declarations.length) {
339
+ options.tools = declarations.map((d) => ({ type: "function", function: { name: d.name, description: d.description || "",
340
+ parameters: d.parametersJsonSchema || d.parameters || { type: "object", properties: {} } } }));
341
+ const mode = body.toolConfig?.functionCallingConfig?.mode;
342
+ if (mode === "NONE") options.tool_choice = "none";
343
+ else if (mode === "ANY") options.tool_choice = "required";
344
+ }
110
345
  return { model, messages, maxOutputTokens: config.maxOutputTokens ?? 4096, options };
111
346
  }
112
347
 
113
- function geminiChunk(model, piece, receipt) {
114
- const chunk = { candidates: [{ content: { role: "model", parts: [{ text: piece }] }, index: 0 }], modelVersion: model };
348
+ function geminiParts(piece, calls = []) {
349
+ const parts = piece ? [{ text: piece }] : [];
350
+ for (const call of calls) parts.push({ functionCall: { name: call.function?.name, args: parseArguments(call.function?.arguments) } });
351
+ return parts.length ? parts : [{ text: "" }];
352
+ }
353
+
354
+ function geminiChunk(model, parts, receipt) {
355
+ const chunk = { candidates: [{ content: { role: "model", parts }, index: 0 }], modelVersion: model };
115
356
  if (receipt) {
116
357
  chunk.candidates[0].finishReason = "STOP";
117
358
  const input = Number(receipt.input_tokens); const output = Number(receipt.output_tokens);
@@ -120,14 +361,27 @@ function geminiChunk(model, piece, receipt) {
120
361
  return chunk;
121
362
  }
122
363
 
123
- export const toGemini = (model, answer, receipt) => geminiChunk(model, answer, receipt);
364
+ export const toGemini = (model, output, receipt) => {
365
+ const answer = answerOf(output);
366
+ return geminiChunk(model, geminiParts(answer.text, answer.calls), receipt);
367
+ };
124
368
 
125
- /** streamGenerateContent with alt=sse: one JSON chunk per event; the last carries finishReason and usage. */
369
+ /** countTokens: an upper-bound estimate (UTF-8 bytes / 3, as prices are authorized). */
370
+ export function countGeminiTokens(body, model) {
371
+ const ask = fromGemini({ contents: [], ...body }, model);
372
+ const bytes = Buffer.byteLength(JSON.stringify(ask.messages), "utf8") + Buffer.byteLength(JSON.stringify(ask.options.tools || []), "utf8");
373
+ return { totalTokens: Math.ceil(bytes / 3) };
374
+ }
375
+
376
+ /** streamGenerateContent with alt=sse: one JSON chunk per event; the last carries calls, finishReason and usage. */
126
377
  export function geminiStream(write, model) {
127
378
  const chunk = (value) => write(`data: ${JSON.stringify(value)}\n\n`);
128
379
  return {
129
- delta(piece) { if (piece) chunk(geminiChunk(model, piece)); },
130
- finish(answer, receipt, streamedAny) { chunk(geminiChunk(model, streamedAny ? "" : answer, receipt)); },
380
+ delta(piece) { if (piece) chunk(geminiChunk(model, [{ text: piece }])); },
381
+ finish(output, receipt, streamedAny) {
382
+ const answer = answerOf(output);
383
+ chunk(geminiChunk(model, geminiParts(streamedAny ? "" : answer.text, answer.calls), receipt));
384
+ },
131
385
  fail(message) { chunk({ error: { code: 500, message, status: "INTERNAL" } }); },
132
386
  };
133
387
  }
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
  }