@8-/gemini-web-api 1.0.0 → 1.0.1
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 +77 -24
- package/index.js +62 -876
- package/package.json +4 -2
- package/src/constant.js +89 -0
- package/src/cookieRead.js +65 -0
- package/src/modelDiscover.js +278 -0
- package/src/payloadBuild.js +115 -0
- package/src/router.js +302 -0
- package/src/serverStart.js +120 -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,75 @@
|
|
|
1
|
+
import { ENABLE_THINKING } from "./constant.js";
|
|
2
|
+
|
|
3
|
+
export const streamChunkExtract = (buf) => {
|
|
4
|
+
const extracted_li = [],
|
|
5
|
+
line_li = buf.split("\n"),
|
|
6
|
+
next_buf = line_li.pop() ?? "";
|
|
7
|
+
|
|
8
|
+
line_li.forEach((raw_line) => {
|
|
9
|
+
const line = raw_line.trim();
|
|
10
|
+
if (!line || !line.includes('"wrb.fr"')) return;
|
|
11
|
+
try {
|
|
12
|
+
const item_li = JSON.parse(line);
|
|
13
|
+
if (Array.isArray(item_li)) {
|
|
14
|
+
item_li.forEach((item) => {
|
|
15
|
+
if (Array.isArray(item) && item[2]) {
|
|
16
|
+
const inner = JSON.parse(item[2]),
|
|
17
|
+
cand_li = inner[4];
|
|
18
|
+
if (Array.isArray(cand_li) && cand_li.length > 0) {
|
|
19
|
+
const cand = cand_li[0],
|
|
20
|
+
thoughts = cand[37]?.[0]?.[0] ?? "",
|
|
21
|
+
raw_text = cand[1]?.[0] ?? "",
|
|
22
|
+
card_text = raw_text.startsWith("http://googleusercontent.com/card_content/")
|
|
23
|
+
? (cand[22]?.[0] ?? raw_text)
|
|
24
|
+
: raw_text,
|
|
25
|
+
text = card_text.replaceAll(
|
|
26
|
+
/https?:\/\/googleusercontent\.com\/(?:\w+\/)+\d+\n*/g,
|
|
27
|
+
"",
|
|
28
|
+
);
|
|
29
|
+
extracted_li.push({ thoughts, text });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
// ignore non-json or malformed lines
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
return [extracted_li, next_buf];
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
fullResponseCollect = async (body) => {
|
|
43
|
+
const reader = body.getReader(),
|
|
44
|
+
decoder = new TextDecoder();
|
|
45
|
+
let buf = "",
|
|
46
|
+
final_text = "",
|
|
47
|
+
final_thought = "";
|
|
48
|
+
|
|
49
|
+
const textUpdate = ({ thoughts, text }) => {
|
|
50
|
+
if (text) final_text = text;
|
|
51
|
+
if (thoughts) final_thought = thoughts;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
while (true) {
|
|
55
|
+
const read_res = await reader.read();
|
|
56
|
+
if (read_res.done) {
|
|
57
|
+
if (buf.trim()) {
|
|
58
|
+
const [remaining_li] = streamChunkExtract(buf + "\n");
|
|
59
|
+
remaining_li.forEach(textUpdate);
|
|
60
|
+
}
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
buf += decoder.decode(read_res.value, { stream: true });
|
|
64
|
+
|
|
65
|
+
const [extracted_li, next_buf] = streamChunkExtract(buf);
|
|
66
|
+
buf = next_buf;
|
|
67
|
+
extracted_li.forEach(textUpdate);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let content = final_text;
|
|
71
|
+
if (ENABLE_THINKING && final_thought) {
|
|
72
|
+
content = "<think>\n" + final_thought + "\n</think>\n\n" + final_text;
|
|
73
|
+
}
|
|
74
|
+
return [content, final_text, final_thought];
|
|
75
|
+
};
|
|
@@ -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 table with headers and models", () => {
|
|
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("支持的模型列表 (Supported Models):"));
|
|
59
|
+
assert.ok(output.includes("模型 ID (Model ID)"));
|
|
60
|
+
assert.ok(output.includes("显示名称 (Name)"));
|
|
61
|
+
assert.ok(output.includes("别名 (Aliases)"));
|
|
62
|
+
assert.ok(output.includes("gemini-3.8-flash"));
|
|
63
|
+
assert.ok(output.includes("★"));
|
|
64
|
+
assert.ok(output.includes("(默认)"));
|
|
65
|
+
assert.ok(output.includes("gemini-3.1-pro"));
|
|
66
|
+
assert.ok(output.includes("gemini-flash, flash, 3.8-flash"));
|
|
67
|
+
assert.ok(!output.includes("56fdd199312815e2"));
|
|
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
|
+
});
|