@8-/gemini-web-api 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +81 -28
- package/index.js +62 -876
- package/package.json +5 -3
- package/src/constant.js +89 -0
- package/src/cookieRead.js +65 -0
- package/src/modelDiscover.js +208 -0
- package/src/payloadBuild.js +115 -0
- package/src/router.js +302 -0
- package/src/serverStart.js +96 -0
- package/src/sessionState.js +70 -0
- package/src/sseResponse.js +166 -0
- package/src/streamExtract.js +75 -0
- package/src/toolHandle.js +332 -0
- package/test/modelDiscover.test.js +105 -0
- package/test/toolHandle.test.js +243 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { TOOL_FENCE_CLOSE, TOOL_FENCE_OPEN } from "./constant.js";
|
|
2
|
+
|
|
3
|
+
export const jsonFormat = (val) => {
|
|
4
|
+
try {
|
|
5
|
+
const obj = typeof val === "string" ? JSON.parse(val) : val,
|
|
6
|
+
json_str = JSON.stringify(obj, null, 2);
|
|
7
|
+
if (process.stdout?.isTTY) {
|
|
8
|
+
return json_str.replaceAll(
|
|
9
|
+
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g,
|
|
10
|
+
(match) => {
|
|
11
|
+
if (match.startsWith('"')) {
|
|
12
|
+
if (match.endsWith(":")) return "\x1b[36m" + match + "\x1b[0m";
|
|
13
|
+
return "\x1b[32m" + match + "\x1b[0m";
|
|
14
|
+
}
|
|
15
|
+
if (match === "true" || match === "false") return "\x1b[33m" + match + "\x1b[0m";
|
|
16
|
+
if (match === "null") return "\x1b[35m" + match + "\x1b[0m";
|
|
17
|
+
return "\x1b[34m" + match + "\x1b[0m";
|
|
18
|
+
},
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
return json_str;
|
|
22
|
+
} catch {
|
|
23
|
+
return String(val ?? "");
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
jsonParseSafe = (str) => {
|
|
28
|
+
if (!str) return null;
|
|
29
|
+
const trimmed = str.trim().replaceAll("\\_", "_");
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(trimmed);
|
|
32
|
+
} catch {
|
|
33
|
+
try {
|
|
34
|
+
const repaired = trimmed
|
|
35
|
+
.replaceAll(/\\([^"\\/bfnrtu])/g, "$1")
|
|
36
|
+
.replaceAll(/,\s*([\]}])/g, "$1")
|
|
37
|
+
.replaceAll(/([{,]\s*)([a-zA-Z0-9_]+)\s*:/g, '$1"$2":');
|
|
38
|
+
return JSON.parse(repaired);
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
toolObjectNormalize = (data) => {
|
|
46
|
+
const result_li = [];
|
|
47
|
+
if (!data) return result_li;
|
|
48
|
+
|
|
49
|
+
const itemPush = (item) => {
|
|
50
|
+
if (!item || typeof item !== "object") return;
|
|
51
|
+
const name = item.name ?? item.function?.name ?? "",
|
|
52
|
+
raw_args = item.arguments ?? item.args ?? item.input ?? item.function?.arguments ?? {},
|
|
53
|
+
args_str = typeof raw_args === "string" ? raw_args : JSON.stringify(raw_args);
|
|
54
|
+
if (name) {
|
|
55
|
+
result_li.push({
|
|
56
|
+
id: "call_" + crypto.randomUUID().replaceAll("-", "").slice(0, 24),
|
|
57
|
+
type: "function",
|
|
58
|
+
function: {
|
|
59
|
+
name,
|
|
60
|
+
arguments: args_str,
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
if (Array.isArray(data)) {
|
|
67
|
+
data.forEach(itemPush);
|
|
68
|
+
} else if (Array.isArray(data.tool_calls)) {
|
|
69
|
+
data.tool_calls.forEach(itemPush);
|
|
70
|
+
} else if (data.name || data.function?.name) {
|
|
71
|
+
itemPush(data);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return result_li;
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
toolPromptBuild = (tool_li, tool_choice) => {
|
|
78
|
+
if (!tool_li || tool_li.length === 0) return "";
|
|
79
|
+
let forced_name = "";
|
|
80
|
+
if (typeof tool_choice === "string") {
|
|
81
|
+
if (tool_choice === "none") return "";
|
|
82
|
+
} else if (tool_choice?.function?.name) {
|
|
83
|
+
forced_name = tool_choice.function.name;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const def_li = tool_li
|
|
87
|
+
.map((tool) => {
|
|
88
|
+
const fn = tool.function ?? tool;
|
|
89
|
+
return {
|
|
90
|
+
name: fn.name ?? "",
|
|
91
|
+
description: fn.description ?? "",
|
|
92
|
+
parameters: fn.parameters ?? { type: "object", properties: {} },
|
|
93
|
+
};
|
|
94
|
+
})
|
|
95
|
+
.filter((def_item) => !forced_name || def_item.name === forced_name);
|
|
96
|
+
|
|
97
|
+
if (def_li.length === 0) return "";
|
|
98
|
+
|
|
99
|
+
const rule = forced_name
|
|
100
|
+
? "You MUST call the tool \"" +
|
|
101
|
+
forced_name +
|
|
102
|
+
"\". Reply with the tool_call block and nothing else. Do not answer yourself."
|
|
103
|
+
: tool_choice === "required"
|
|
104
|
+
? "You MUST call at least one of the tools above. Reply with the tool_call block and nothing else. Do not answer yourself."
|
|
105
|
+
: "Only use tool_call blocks when a tool execution is needed.",
|
|
106
|
+
defs_json = JSON.stringify(def_li, null, 2);
|
|
107
|
+
return (
|
|
108
|
+
"# TOOLS\n\n" +
|
|
109
|
+
"You have access to the following tools. To call a tool, respond with:\n" +
|
|
110
|
+
"```tool_call\n" +
|
|
111
|
+
"{\"name\": \"tool_name\", \"arguments\": {...}}\n" +
|
|
112
|
+
"```\n\n" +
|
|
113
|
+
rule +
|
|
114
|
+
"\n\n" +
|
|
115
|
+
"Available tools:\n" +
|
|
116
|
+
defs_json
|
|
117
|
+
);
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
toolTailAnchorBuild = () =>
|
|
121
|
+
"[System instruction — highest priority]: To run a tool, you MUST output a ```tool_call``` block. " +
|
|
122
|
+
"When the request needs a tool, your reply is ONE ```tool_call``` block and nothing else. " +
|
|
123
|
+
"Do not output conversational filler or explanation before or after the tool_call block. Act, do not explain.",
|
|
124
|
+
|
|
125
|
+
toolResultFormat = (name, raw) => {
|
|
126
|
+
const label = name ? "Tool result for " + name : "Tool result",
|
|
127
|
+
trimmed = (raw ?? "").trim();
|
|
128
|
+
if (!trimmed) {
|
|
129
|
+
return "[" + label + "]: ✅ 完成,无输出(正常,动作已生效,请勿重复执行)。";
|
|
130
|
+
}
|
|
131
|
+
const match = trimmed.match(/exit(?:ed with|\s*code)?\s*(?:code\s*)?(\d+)/i);
|
|
132
|
+
if (match) {
|
|
133
|
+
const code = match[1],
|
|
134
|
+
out_idx = trimmed.lastIndexOf("Output:");
|
|
135
|
+
let out = trimmed;
|
|
136
|
+
if (out_idx >= 0) {
|
|
137
|
+
out = trimmed.slice(out_idx + 7).trim();
|
|
138
|
+
}
|
|
139
|
+
if (code === "0") {
|
|
140
|
+
if (!out) {
|
|
141
|
+
return "[" + label + "]: ✅ 命令成功(exit 0),无文本输出(正常,动作已生效,请勿重复执行)。";
|
|
142
|
+
}
|
|
143
|
+
return "[" + label + "]: ✅ 命令成功(exit 0)。输出:\n" + out;
|
|
144
|
+
}
|
|
145
|
+
return "[" + label + "]: ❌ 命令失败(exit " + code + ")。输出:\n" + out;
|
|
146
|
+
}
|
|
147
|
+
return "[" + label + "]: " + trimmed;
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
toolBracketExtract = (text, start_ch, close_ch, start_idx) => {
|
|
151
|
+
let depth = 0,
|
|
152
|
+
in_str = false,
|
|
153
|
+
escape = false;
|
|
154
|
+
for (let i = start_idx; i < text.length; ++i) {
|
|
155
|
+
const c = text[i];
|
|
156
|
+
if (escape) {
|
|
157
|
+
escape = false;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (c === "\\") {
|
|
161
|
+
escape = true;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (c === '"') {
|
|
165
|
+
in_str = !in_str;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (!in_str) {
|
|
169
|
+
if (c === start_ch) {
|
|
170
|
+
++depth;
|
|
171
|
+
} else if (c === close_ch) {
|
|
172
|
+
--depth;
|
|
173
|
+
if (depth === 0) {
|
|
174
|
+
return text.slice(start_idx, i + 1);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
toolCallExtract = (text) => {
|
|
183
|
+
if (!text) return ["", []];
|
|
184
|
+
const tool_call_li = [],
|
|
185
|
+
fence_re = /```(?:tool_calls?|json)?\s*([\s\S]*?)```/gi;
|
|
186
|
+
let cleaned = text.replaceAll("\\_", "_"),
|
|
187
|
+
match;
|
|
188
|
+
|
|
189
|
+
// Strategy 1: Fenced code blocks ```tool_call, ```tool_calls, ```json, or bare ```
|
|
190
|
+
while ((match = fence_re.exec(cleaned)) !== null) {
|
|
191
|
+
const body = match[1].trim(),
|
|
192
|
+
data = jsonParseSafe(body);
|
|
193
|
+
if (data) {
|
|
194
|
+
const parsed_li = toolObjectNormalize(data);
|
|
195
|
+
if (parsed_li.length > 0) {
|
|
196
|
+
tool_call_li.push(...parsed_li);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Strategy 2: Array matching on "tool_calls"
|
|
202
|
+
if (tool_call_li.length === 0) {
|
|
203
|
+
let key_idx = cleaned.indexOf('"tool_calls"');
|
|
204
|
+
while (key_idx !== -1) {
|
|
205
|
+
const arr_start = cleaned.indexOf("[", key_idx);
|
|
206
|
+
if (arr_start !== -1) {
|
|
207
|
+
const arr_blob = toolBracketExtract(cleaned, "[", "]", arr_start);
|
|
208
|
+
if (arr_blob) {
|
|
209
|
+
const arr = jsonParseSafe(arr_blob);
|
|
210
|
+
if (Array.isArray(arr)) {
|
|
211
|
+
const parsed_li = toolObjectNormalize({ tool_calls: arr });
|
|
212
|
+
if (parsed_li.length > 0) {
|
|
213
|
+
tool_call_li.push(...parsed_li);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
key_idx = cleaned.indexOf('"tool_calls"', arr_start + arr_blob.length);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
key_idx = cleaned.indexOf('"tool_calls"', key_idx + 12);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Strategy 3: Missing opening backticks or unclosed fence (e.g. tool_call\n{...}``` or tool_call\n{...})
|
|
225
|
+
if (tool_call_li.length === 0) {
|
|
226
|
+
const unfenced_re = /(?:^|\n)\s*(?:tool_calls?|json)?\s*(\{[\s\S]*?\})\s*(?:```|$)/gi;
|
|
227
|
+
while ((match = unfenced_re.exec(cleaned)) !== null) {
|
|
228
|
+
const data = jsonParseSafe(match[1]);
|
|
229
|
+
if (data) {
|
|
230
|
+
const parsed_li = toolObjectNormalize(data);
|
|
231
|
+
if (parsed_li.length > 0) {
|
|
232
|
+
tool_call_li.push(...parsed_li);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Strategy 4: Bracket matching on any JSON object containing "name" and ("arguments" | "args" | "input")
|
|
239
|
+
if (tool_call_li.length === 0) {
|
|
240
|
+
let name_idx = cleaned.indexOf('"name"');
|
|
241
|
+
while (name_idx !== -1) {
|
|
242
|
+
const brace_start = cleaned.lastIndexOf("{", name_idx);
|
|
243
|
+
if (brace_start !== -1) {
|
|
244
|
+
const obj_blob = toolBracketExtract(cleaned, "{", "}", brace_start);
|
|
245
|
+
if (obj_blob) {
|
|
246
|
+
const data = jsonParseSafe(obj_blob);
|
|
247
|
+
if (data && (data.arguments || data.args || data.input || data.function)) {
|
|
248
|
+
const parsed_li = toolObjectNormalize(data);
|
|
249
|
+
if (parsed_li.length > 0) {
|
|
250
|
+
tool_call_li.push(...parsed_li);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
name_idx = cleaned.indexOf('"name"', brace_start + obj_blob.length);
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
name_idx = cleaned.indexOf('"name"', name_idx + 6);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
let clean_text = cleaned;
|
|
262
|
+
if (tool_call_li.length > 0) {
|
|
263
|
+
clean_text = clean_text
|
|
264
|
+
.replaceAll(/```(?:tool_calls?|json)?\s*[\s\S]*?```/gi, "")
|
|
265
|
+
.replaceAll(/(?:^|\n)\s*(?:tool_calls?|json)?\s*\{[\s\S]*?\}\s*(?:```|$)/gi, "")
|
|
266
|
+
.replaceAll(/\{\s*"tool_calls"\s*:[\s\S]*?\]\s*\}/g, "")
|
|
267
|
+
.replaceAll(/^\s*tool_calls?\s*/gi, "")
|
|
268
|
+
.trim();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return [clean_text, tool_call_li];
|
|
272
|
+
},
|
|
273
|
+
|
|
274
|
+
toolFenceGateCreate = (onEmit) => {
|
|
275
|
+
const state = {
|
|
276
|
+
buf: "",
|
|
277
|
+
sent: "",
|
|
278
|
+
in_fence: false,
|
|
279
|
+
},
|
|
280
|
+
partialPrefixLen = (s, marker) => {
|
|
281
|
+
const max = Math.min(s.length, marker.length - 1);
|
|
282
|
+
for (let k = max; k > 0; --k) {
|
|
283
|
+
if (marker.startsWith(s.slice(s.length - k))) {
|
|
284
|
+
return k;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return 0;
|
|
288
|
+
},
|
|
289
|
+
send = (s) => {
|
|
290
|
+
if (!s) return;
|
|
291
|
+
state.sent += s;
|
|
292
|
+
onEmit(s);
|
|
293
|
+
},
|
|
294
|
+
push = (delta) => {
|
|
295
|
+
state.buf += delta;
|
|
296
|
+
while (state.buf.length > 0) {
|
|
297
|
+
if (state.in_fence) {
|
|
298
|
+
const close_idx = state.buf.indexOf(TOOL_FENCE_CLOSE);
|
|
299
|
+
if (close_idx < 0) {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
state.buf = state.buf.slice(close_idx + TOOL_FENCE_CLOSE.length);
|
|
303
|
+
state.in_fence = false;
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const open_idx = state.buf.indexOf(TOOL_FENCE_OPEN);
|
|
308
|
+
if (open_idx >= 0) {
|
|
309
|
+
send(state.buf.slice(0, open_idx));
|
|
310
|
+
state.buf = state.buf.slice(open_idx + TOOL_FENCE_OPEN.length);
|
|
311
|
+
state.in_fence = true;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const keep = partialPrefixLen(state.buf, TOOL_FENCE_OPEN),
|
|
316
|
+
yield_len = state.buf.length - keep;
|
|
317
|
+
if (yield_len > 0) {
|
|
318
|
+
send(state.buf.slice(0, yield_len));
|
|
319
|
+
state.buf = state.buf.slice(yield_len);
|
|
320
|
+
}
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
flush = () => {
|
|
325
|
+
if (!state.in_fence && state.buf.length > 0) {
|
|
326
|
+
send(state.buf);
|
|
327
|
+
state.buf = "";
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
return { push, flush, state };
|
|
332
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import {
|
|
4
|
+
modelListFormat,
|
|
5
|
+
modelMap,
|
|
6
|
+
stringPadEnd,
|
|
7
|
+
stringVisualWidth,
|
|
8
|
+
versionCompare,
|
|
9
|
+
} from "../src/modelDiscover.js";
|
|
10
|
+
import { DEFAULT_MODEL_LI } from "../src/constant.js";
|
|
11
|
+
|
|
12
|
+
test("stringVisualWidth correctly measures ASCII and East Asian characters", () => {
|
|
13
|
+
assert.equal(stringVisualWidth("abc 123"), 7);
|
|
14
|
+
assert.equal(stringVisualWidth("你好"), 4);
|
|
15
|
+
assert.equal(stringVisualWidth("Gemini 模型"), 11);
|
|
16
|
+
assert.equal(stringVisualWidth(""), 0);
|
|
17
|
+
assert.equal(stringVisualWidth(null), 0);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("stringPadEnd pads string to target visual width", () => {
|
|
21
|
+
const padded_ascii = stringPadEnd("abc", 6),
|
|
22
|
+
padded_cjk = stringPadEnd("你好", 6);
|
|
23
|
+
assert.equal(padded_ascii, "abc ");
|
|
24
|
+
assert.equal(stringVisualWidth(padded_ascii), 6);
|
|
25
|
+
assert.equal(padded_cjk, "你好 ");
|
|
26
|
+
assert.equal(stringVisualWidth(padded_cjk), 6);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("versionCompare correctly orders semantic versions", () => {
|
|
30
|
+
assert.ok(versionCompare("2.5", "1.5") > 0);
|
|
31
|
+
assert.ok(versionCompare("1.5", "2.5") < 0);
|
|
32
|
+
assert.equal(versionCompare("2.0", "2.0"), 0);
|
|
33
|
+
assert.ok(versionCompare("3.8 Flash", "3.1 Pro") > 0);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("modelListFormat formats models cleanly with colors and without tables or tag symbols", () => {
|
|
37
|
+
const custom_model_li = [
|
|
38
|
+
{
|
|
39
|
+
id: "gemini-3.8-flash",
|
|
40
|
+
disp: "3.8 Flash",
|
|
41
|
+
alias_li: [
|
|
42
|
+
"56fdd199312815e2",
|
|
43
|
+
"gemini-3.8-flash",
|
|
44
|
+
"gemini-flash",
|
|
45
|
+
"flash",
|
|
46
|
+
"3.8-flash",
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: "gemini-3.1-pro",
|
|
51
|
+
disp: "3.1 Pro",
|
|
52
|
+
alias_li: ["797f3d0293f288ad", "gemini-3.1-pro", "gemini-pro", "pro"],
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
default_model = custom_model_li[0],
|
|
56
|
+
output = modelListFormat(custom_model_li, default_model);
|
|
57
|
+
|
|
58
|
+
assert.ok(output.includes("模型列表"));
|
|
59
|
+
assert.ok(output.includes("gemini-3.8-flash 当前模型"));
|
|
60
|
+
assert.ok(output.includes("gemini-3.1-pro"));
|
|
61
|
+
assert.ok(!output.includes("gemini-flash"));
|
|
62
|
+
assert.ok(!output.includes("56fdd199312815e2"));
|
|
63
|
+
assert.ok(!output.includes("┌"));
|
|
64
|
+
assert.ok(!output.includes("│"));
|
|
65
|
+
assert.ok(!output.includes("★"));
|
|
66
|
+
assert.ok(!output.includes("(默认)"));
|
|
67
|
+
assert.ok(!output.includes("支持在请求中传"));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("modelListFormat sorts default model first even when not first in array", () => {
|
|
71
|
+
const custom_model_li = [
|
|
72
|
+
{
|
|
73
|
+
id: "gemini-3.5-flash-lite",
|
|
74
|
+
disp: "3.5 Flash-Lite",
|
|
75
|
+
alias_li: ["flash-lite"],
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: "gemini-3.8-flash",
|
|
79
|
+
disp: "3.8 Flash",
|
|
80
|
+
alias_li: ["flash"],
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
default_model = custom_model_li[1],
|
|
84
|
+
output = modelListFormat(custom_model_li, default_model),
|
|
85
|
+
flash_idx = output.indexOf("gemini-3.8-flash"),
|
|
86
|
+
lite_idx = output.indexOf("gemini-3.5-flash-lite");
|
|
87
|
+
|
|
88
|
+
assert.ok(flash_idx < lite_idx);
|
|
89
|
+
assert.ok(output.includes("gemini-3.8-flash 当前模型"));
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("modelListFormat falls back to DEFAULT_MODEL_LI when empty", () => {
|
|
93
|
+
const output = modelListFormat([], null);
|
|
94
|
+
assert.ok(output.includes(DEFAULT_MODEL_LI[0].id));
|
|
95
|
+
assert.ok(output.includes(DEFAULT_MODEL_LI[1].id));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("modelMap resolves alias or ID correctly", () => {
|
|
99
|
+
const mapped_pro = modelMap("pro"),
|
|
100
|
+
mapped_flash = modelMap("gemini-flash"),
|
|
101
|
+
mapped_unknown = modelMap("unknown-model-foo");
|
|
102
|
+
assert.ok(mapped_pro.id.includes("pro"));
|
|
103
|
+
assert.ok(mapped_flash.id.includes("flash"));
|
|
104
|
+
assert.ok(Boolean(mapped_unknown));
|
|
105
|
+
});
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import {
|
|
4
|
+
jsonFormat,
|
|
5
|
+
jsonParseSafe,
|
|
6
|
+
toolCallExtract,
|
|
7
|
+
toolFenceGateCreate,
|
|
8
|
+
toolPromptBuild,
|
|
9
|
+
toolResultFormat,
|
|
10
|
+
} from "../src/toolHandle.js";
|
|
11
|
+
import { conversationFormat } from "../src/payloadBuild.js";
|
|
12
|
+
|
|
13
|
+
test("toolPromptBuild generates prompt with function definitions", () => {
|
|
14
|
+
const tool_li = [
|
|
15
|
+
{
|
|
16
|
+
type: "function",
|
|
17
|
+
function: {
|
|
18
|
+
name: "get_weather",
|
|
19
|
+
description: "Get current weather",
|
|
20
|
+
parameters: {
|
|
21
|
+
type: "object",
|
|
22
|
+
properties: {
|
|
23
|
+
city: { type: "string" },
|
|
24
|
+
},
|
|
25
|
+
required: ["city"],
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
prompt = toolPromptBuild(tool_li);
|
|
31
|
+
|
|
32
|
+
assert.ok(prompt.includes("# TOOLS"));
|
|
33
|
+
assert.ok(prompt.includes("get_weather"));
|
|
34
|
+
assert.ok(prompt.includes("```tool_call"));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("toolPromptBuild respects tool_choice constraints", () => {
|
|
38
|
+
const tool_li = [
|
|
39
|
+
{
|
|
40
|
+
type: "function",
|
|
41
|
+
function: { name: "get_weather", description: "Get weather" },
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
type: "function",
|
|
45
|
+
function: { name: "search", description: "Search web" },
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
assert.equal(toolPromptBuild(tool_li, "none"), "");
|
|
50
|
+
|
|
51
|
+
const required_prompt = toolPromptBuild(tool_li, "required");
|
|
52
|
+
assert.ok(required_prompt.includes("You MUST call at least one of the tools above"));
|
|
53
|
+
|
|
54
|
+
const forced_prompt = toolPromptBuild(tool_li, {
|
|
55
|
+
type: "function",
|
|
56
|
+
function: { name: "search" },
|
|
57
|
+
});
|
|
58
|
+
assert.ok(forced_prompt.includes('You MUST call the tool "search"'));
|
|
59
|
+
assert.ok(!forced_prompt.includes("get_weather"));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("toolResultFormat handles exit codes and empty results cleanly", () => {
|
|
63
|
+
const empty_res = toolResultFormat("exec", ""),
|
|
64
|
+
exit_zero_empty = toolResultFormat("exec", "Process exited with code 0\nOutput:\n"),
|
|
65
|
+
exit_zero_output = toolResultFormat("exec", "Process exited with code 0\nOutput:\nhello world"),
|
|
66
|
+
exit_err = toolResultFormat("exec", "Process exited with code 127\nOutput:\ncommand not found");
|
|
67
|
+
|
|
68
|
+
assert.ok(empty_res.includes("✅ 完成,无输出"));
|
|
69
|
+
assert.ok(exit_zero_empty.includes("✅ 命令成功(exit 0),无文本输出"));
|
|
70
|
+
assert.ok(exit_zero_output.includes("✅ 命令成功(exit 0)。输出:\nhello world"));
|
|
71
|
+
assert.ok(exit_err.includes("❌ 命令失败(exit 127)"));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("toolCallExtract parses fenced tool calls accurately", () => {
|
|
75
|
+
const text =
|
|
76
|
+
"Here is the data:\n" +
|
|
77
|
+
"```tool_call\n" +
|
|
78
|
+
'{"name": "get_weather", "arguments": {"city": "Tokyo"}}\n' +
|
|
79
|
+
"```\n" +
|
|
80
|
+
"Have a nice day!",
|
|
81
|
+
[clean_text, call_li] = toolCallExtract(text);
|
|
82
|
+
|
|
83
|
+
assert.equal(call_li.length, 1);
|
|
84
|
+
assert.equal(call_li[0].function.name, "get_weather");
|
|
85
|
+
assert.equal(call_li[0].function.arguments, '{"city":"Tokyo"}');
|
|
86
|
+
assert.ok(!clean_text.includes("```tool_call"));
|
|
87
|
+
assert.ok(clean_text.includes("Here is the data:"));
|
|
88
|
+
assert.ok(clean_text.includes("Have a nice day!"));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("toolFenceGate prevents tool fence from leaking during streaming", () => {
|
|
92
|
+
const emitted_li = [],
|
|
93
|
+
gate = toolFenceGateCreate((chunk) => {
|
|
94
|
+
emitted_li.push(chunk);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
gate.push("Hello! ");
|
|
98
|
+
gate.push("I will check the weather. ");
|
|
99
|
+
gate.push("```");
|
|
100
|
+
gate.push("tool_call\n");
|
|
101
|
+
gate.push('{"name": "get_weather", "arguments": {"city": "Paris"}}\n');
|
|
102
|
+
gate.push("```");
|
|
103
|
+
gate.push(" Hope that helps!");
|
|
104
|
+
gate.flush();
|
|
105
|
+
|
|
106
|
+
const joined = emitted_li.join("");
|
|
107
|
+
assert.ok(!joined.includes("```tool_call"));
|
|
108
|
+
assert.ok(!joined.includes("get_weather"));
|
|
109
|
+
assert.ok(joined.includes("Hello! I will check the weather. "));
|
|
110
|
+
assert.ok(joined.includes(" Hope that helps!"));
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("conversationFormat formats multi-turn tool history", () => {
|
|
114
|
+
const msg_li = [
|
|
115
|
+
{ role: "user", content: "What is the weather in Paris?" },
|
|
116
|
+
{
|
|
117
|
+
role: "assistant",
|
|
118
|
+
content: "Let me check.",
|
|
119
|
+
tool_calls: [
|
|
120
|
+
{
|
|
121
|
+
id: "call_1",
|
|
122
|
+
type: "function",
|
|
123
|
+
function: { name: "get_weather", arguments: '{"city":"Paris"}' },
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
{ role: "tool", name: "get_weather", content: '{"temp": "22C"}' },
|
|
128
|
+
],
|
|
129
|
+
tool_li = [
|
|
130
|
+
{
|
|
131
|
+
type: "function",
|
|
132
|
+
function: { name: "get_weather", description: "Get weather" },
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
conversation = conversationFormat(msg_li, tool_li);
|
|
136
|
+
|
|
137
|
+
assert.ok(conversation.includes("[System instruction]:"));
|
|
138
|
+
assert.ok(conversation.includes("Human: What is the weather in Paris?"));
|
|
139
|
+
assert.ok(conversation.includes("Assistant: Let me check."));
|
|
140
|
+
assert.ok(conversation.includes('```tool_call\n{"name":"get_weather","arguments":{"city":"Paris"}}\n```'));
|
|
141
|
+
assert.ok(conversation.includes("[Tool result for get_weather]:"));
|
|
142
|
+
assert.ok(conversation.includes("[System instruction — highest priority]:"));
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("toolCallExtract parses ZCode actual failed output (missing opening backticks)", () => {
|
|
146
|
+
const text =
|
|
147
|
+
'tool_call\n{"name": "Agent", "arguments": {"description": "Search bftree in garnet", "prompt": "Search ./garnet code.", "subagent_type": "Explore"}}\n```',
|
|
148
|
+
[clean_text, call_li] = toolCallExtract(text);
|
|
149
|
+
|
|
150
|
+
assert.equal(call_li.length, 1);
|
|
151
|
+
assert.equal(call_li[0].function.name, "Agent");
|
|
152
|
+
const args = JSON.parse(call_li[0].function.arguments);
|
|
153
|
+
assert.equal(args.description, "Search bftree in garnet");
|
|
154
|
+
assert.equal(args.subagent_type, "Explore");
|
|
155
|
+
assert.equal(clean_text, "");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("toolCallExtract handles markdown escaped underscores and json tool_calls array", () => {
|
|
159
|
+
const escaped_text =
|
|
160
|
+
'```tool_call\n{"name": "Agent", "arguments": {"subagent\\_type": "Explore"}}\n```',
|
|
161
|
+
[, escaped_call_li] = toolCallExtract(escaped_text);
|
|
162
|
+
|
|
163
|
+
assert.equal(escaped_call_li.length, 1);
|
|
164
|
+
const escaped_args = JSON.parse(escaped_call_li[0].function.arguments);
|
|
165
|
+
assert.equal(escaped_args.subagent_type, "Explore");
|
|
166
|
+
|
|
167
|
+
const json_array_text =
|
|
168
|
+
'```json\n{"tool_calls": [{"name": "read", "arguments": {"path": "a.txt"}}]}\n```',
|
|
169
|
+
[, arr_call_li] = toolCallExtract(json_array_text);
|
|
170
|
+
|
|
171
|
+
assert.equal(arr_call_li.length, 1);
|
|
172
|
+
assert.equal(arr_call_li[0].function.name, "read");
|
|
173
|
+
assert.equal(JSON.parse(arr_call_li[0].function.arguments).path, "a.txt");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("toolCallExtract parses bare JSON tool call", () => {
|
|
177
|
+
const bare_text = '{"name": "Agent", "arguments": {"task": "inspect"}}',
|
|
178
|
+
[, call_li] = toolCallExtract(bare_text);
|
|
179
|
+
|
|
180
|
+
assert.equal(call_li.length, 1);
|
|
181
|
+
assert.equal(call_li[0].function.name, "Agent");
|
|
182
|
+
assert.equal(JSON.parse(call_li[0].function.arguments).task, "inspect");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("conversationFormat supports Vercel AI-SDK / ZCode message format (toolCalls, input, toolName)", () => {
|
|
186
|
+
const msg_li = [
|
|
187
|
+
{
|
|
188
|
+
role: "assistant",
|
|
189
|
+
content: "",
|
|
190
|
+
toolCalls: [
|
|
191
|
+
{
|
|
192
|
+
id: "tool_123",
|
|
193
|
+
name: "Bash",
|
|
194
|
+
input: { command: "ls -la" },
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
role: "tool",
|
|
200
|
+
toolName: "Bash",
|
|
201
|
+
toolCallId: "tool_123",
|
|
202
|
+
content: "Exit code 0\nOutput:\nfile1.txt",
|
|
203
|
+
},
|
|
204
|
+
],
|
|
205
|
+
conversation = conversationFormat(msg_li);
|
|
206
|
+
|
|
207
|
+
assert.ok(conversation.includes('```tool_call\n{"name":"Bash","arguments":{"command":"ls -la"}}\n```'));
|
|
208
|
+
assert.ok(conversation.includes("[Tool result for Bash]:"));
|
|
209
|
+
assert.ok(conversation.includes("file1.txt"));
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("toolCallExtract parses restarted / duplicate fence output", () => {
|
|
213
|
+
const bad_text =
|
|
214
|
+
'```tool_call\n{"name```tool_call\n{"name": "get_weather", "arguments": {"city": "Tokyo"}}\n```',
|
|
215
|
+
[clean_text, call_li] = toolCallExtract(bad_text);
|
|
216
|
+
|
|
217
|
+
assert.equal(call_li.length, 1);
|
|
218
|
+
assert.equal(call_li[0].function.name, "get_weather");
|
|
219
|
+
assert.equal(JSON.parse(call_li[0].function.arguments).city, "Tokyo");
|
|
220
|
+
assert.equal(clean_text, "");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("toolCallExtract parses multiple bare JSON tool calls", () => {
|
|
224
|
+
const multi_bare_text =
|
|
225
|
+
'Calling: {"name": "func_a", "arguments": {"x": 1}}, and {"name": "func_b", "arguments": {"y": 2}}',
|
|
226
|
+
[, call_li] = toolCallExtract(multi_bare_text);
|
|
227
|
+
|
|
228
|
+
assert.equal(call_li.length, 2);
|
|
229
|
+
assert.equal(call_li[0].function.name, "func_a");
|
|
230
|
+
assert.equal(call_li[1].function.name, "func_b");
|
|
231
|
+
assert.equal(JSON.parse(call_li[0].function.arguments).x, 1);
|
|
232
|
+
assert.equal(JSON.parse(call_li[1].function.arguments).y, 2);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("jsonFormat formats objects and strings cleanly", () => {
|
|
236
|
+
const formatted = jsonFormat({ a: 1, b: "test" });
|
|
237
|
+
assert.ok(formatted.includes('"a"') && formatted.includes("1"));
|
|
238
|
+
assert.ok(formatted.includes('"b"') && formatted.includes("test"));
|
|
239
|
+
|
|
240
|
+
const raw_str = jsonFormat("simple text");
|
|
241
|
+
assert.equal(raw_str, "simple text");
|
|
242
|
+
});
|
|
243
|
+
|