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.
- package/networks/mycomesh-v11-sepolia.json +9 -6
- package/package.json +1 -1
- package/src/consumer.mjs +63 -8
- package/src/dialects.mjs +289 -35
- package/src/protocol.mjs +6 -4
- package/src/sse.mjs +5 -1
|
@@ -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":
|
|
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":
|
|
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":
|
|
85
|
+
"tools": true,
|
|
83
86
|
"dialects": [
|
|
84
87
|
"openai-responses",
|
|
85
88
|
"openai-chat",
|
package/package.json
CHANGED
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.
|
|
@@ -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
|
|
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(
|
|
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
|
|
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,349 @@ export class DialectError extends Error {
|
|
|
9
10
|
constructor(message, status = 400) { super(message); this.status = status; }
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
/**
|
|
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
|
-
|
|
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
|
|
25
|
-
if (
|
|
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 (
|
|
38
|
-
|
|
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
|
-
//
|
|
44
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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) {
|
|
69
|
-
finish(answer, receipt, streamedAny) {
|
|
171
|
+
delta(piece) {
|
|
70
172
|
start();
|
|
71
|
-
if (!
|
|
72
|
-
|
|
73
|
-
event("
|
|
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
|
|
103
|
-
|
|
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
|
|
114
|
-
const
|
|
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,
|
|
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
|
-
/**
|
|
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(
|
|
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
|
-
|
|
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
|
}
|