pi-free 2.0.15 → 2.1.0

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/README.md +64 -79
  3. package/banner.svg +21 -36
  4. package/config.ts +123 -9
  5. package/constants.ts +3 -9
  6. package/index.ts +14 -15
  7. package/lib/built-in-toggle.ts +29 -16
  8. package/lib/json-persistence.ts +90 -22
  9. package/lib/logger.ts +21 -12
  10. package/lib/model-detection.ts +2 -12
  11. package/lib/model-enhancer.ts +11 -2
  12. package/lib/model-metadata.ts +387 -0
  13. package/lib/open-browser.ts +74 -24
  14. package/lib/paths.ts +90 -0
  15. package/lib/probe-cache.ts +19 -19
  16. package/lib/provider-cache.ts +74 -28
  17. package/lib/provider-compat.ts +53 -37
  18. package/lib/provider-probe.ts +188 -0
  19. package/lib/registry.ts +1 -5
  20. package/lib/session-start-metrics.ts +46 -0
  21. package/lib/telemetry.ts +115 -86
  22. package/lib/types.ts +22 -2
  23. package/lib/util.ts +80 -21
  24. package/package.json +7 -2
  25. package/provider-failover/benchmark-lookup.ts +17 -5
  26. package/provider-helper.ts +11 -2
  27. package/providers/cline/cline-models.ts +7 -1
  28. package/providers/cline/cline-xml-bridge.ts +974 -0
  29. package/providers/cline/cline.ts +67 -176
  30. package/providers/crofai/crofai.ts +6 -1
  31. package/providers/deepinfra/deepinfra.ts +69 -2
  32. package/providers/dynamic-built-in/index.ts +237 -2
  33. package/providers/kilo/kilo-models.ts +3 -1
  34. package/providers/kilo/kilo.ts +268 -41
  35. package/providers/model-fetcher.ts +18 -55
  36. package/providers/novita/novita.ts +69 -2
  37. package/providers/ollama/ollama.ts +48 -24
  38. package/providers/opencode-session.ts +67 -2
  39. package/providers/routeway/routeway.ts +25 -17
  40. package/providers/sambanova/sambanova.ts +67 -1
  41. package/providers/together/together.ts +69 -2
  42. package/providers/tokenrouter/tokenrouter.ts +378 -0
  43. package/providers/zenmux/zenmux.ts +6 -1
  44. package/scripts/check-extensions.mjs +32 -16
  45. package/providers/nvidia/nvidia.ts +0 -510
@@ -0,0 +1,974 @@
1
+ import type {
2
+ AssistantMessage,
3
+ Context,
4
+ Message,
5
+ Model,
6
+ SimpleStreamOptions,
7
+ Tool,
8
+ ToolCall,
9
+ ToolResultMessage,
10
+ Usage,
11
+ } from "@earendil-works/pi-ai";
12
+ import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
13
+ import { BASE_URL_CLINE, PROVIDER_CLINE } from "../../constants.ts";
14
+
15
+ const DEFAULT_USAGE: Usage = {
16
+ input: 0,
17
+ output: 0,
18
+ cacheRead: 0,
19
+ cacheWrite: 0,
20
+ totalTokens: 0,
21
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
22
+ };
23
+
24
+ type ClineXmlChatMessage = {
25
+ role: "assistant" | "system" | "user";
26
+ content: string | Array<{ type: "text"; text: string }>;
27
+ };
28
+
29
+ type ClineXmlChunk = {
30
+ error?: { message?: string; code?: string };
31
+ usage?: {
32
+ prompt_tokens?: number;
33
+ completion_tokens?: number;
34
+ total_tokens?: number;
35
+ };
36
+ choices?: Array<{
37
+ delta?: { content?: string | null; reasoning?: string | null };
38
+ finish_reason?: string | null;
39
+ error?: { message?: string; code?: string };
40
+ }>;
41
+ };
42
+
43
+ function normalizeApiModelId(modelId: string): string {
44
+ return modelId.startsWith(`${PROVIDER_CLINE}/`)
45
+ ? modelId.slice(`${PROVIDER_CLINE}/`.length)
46
+ : modelId;
47
+ }
48
+
49
+ function xmlEscape(value: unknown): string {
50
+ return String(value)
51
+ .replaceAll("&", "&amp;")
52
+ .replaceAll("<", "&lt;")
53
+ .replaceAll(">", "&gt;");
54
+ }
55
+
56
+ function decodeXmlEntities(value: string): string {
57
+ return value
58
+ .replaceAll("&lt;", "<")
59
+ .replaceAll("&gt;", ">")
60
+ .replaceAll("&quot;", '"')
61
+ .replaceAll("&#39;", "'")
62
+ .replaceAll("&amp;", "&");
63
+ }
64
+
65
+ function contentToText(content: unknown): string {
66
+ if (typeof content === "string") return content;
67
+ if (!Array.isArray(content)) return "";
68
+ return content
69
+ .map((part) => {
70
+ if (part?.type === "text" && typeof part.text === "string") {
71
+ return part.text;
72
+ }
73
+ if (part?.type === "image")
74
+ return `[image:${part.mimeType ?? "unknown"}]`;
75
+ return "";
76
+ })
77
+ .filter(Boolean)
78
+ .join("\n");
79
+ }
80
+
81
+ function toolResultToText(message: ToolResultMessage): string {
82
+ return message.content.map((part) => contentToText([part])).join("\n");
83
+ }
84
+
85
+ type ToolBridge = {
86
+ remoteName: string;
87
+ runtimeName: string;
88
+ description?: string;
89
+ parameters: string[];
90
+ toRuntimeArgs(args: Record<string, unknown>): Record<string, unknown>;
91
+ fromRuntimeArgs(args: Record<string, unknown>): Record<string, unknown>;
92
+ };
93
+
94
+ const CORE_CLINE_TOOL_NAMES = [
95
+ "read_file",
96
+ "write_to_file",
97
+ "replace_in_file",
98
+ "execute_command",
99
+ "list_files",
100
+ "search_files",
101
+ "list_code_definition_names",
102
+ ] as const;
103
+
104
+ function stringArg(args: Record<string, unknown>, key: string): string {
105
+ const value = args[key];
106
+ return typeof value === "string" ? value : value == null ? "" : String(value);
107
+ }
108
+
109
+ function booleanArg(args: Record<string, unknown>, key: string): boolean {
110
+ return String(args[key]).toLowerCase() === "true";
111
+ }
112
+
113
+ function shellQuote(value: string): string {
114
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
115
+ }
116
+
117
+ function buildListFilesCommand(args: Record<string, unknown>): string {
118
+ const path = shellQuote(stringArg(args, "path") || ".");
119
+ return booleanArg(args, "recursive")
120
+ ? `find ${path} | sort`
121
+ : `find ${path} -mindepth 1 -maxdepth 1 | sort`;
122
+ }
123
+
124
+ function buildSearchFilesCommand(args: Record<string, unknown>): string {
125
+ const path = shellQuote(stringArg(args, "path") || ".");
126
+ const regex = shellQuote(stringArg(args, "regex"));
127
+ const filePattern = stringArg(args, "file_pattern");
128
+ return [
129
+ "rg",
130
+ "-n",
131
+ "--no-heading",
132
+ "--color",
133
+ "never",
134
+ filePattern ? `-g ${shellQuote(filePattern)}` : "",
135
+ `-e ${regex}`,
136
+ path,
137
+ ]
138
+ .filter(Boolean)
139
+ .join(" ");
140
+ }
141
+
142
+ function buildSearchReplaceDiff(
143
+ edits: Array<{ oldText: string; newText: string }>,
144
+ ): string {
145
+ return edits
146
+ .map((edit) =>
147
+ [
148
+ "------- SEARCH",
149
+ edit.oldText,
150
+ "=======",
151
+ edit.newText,
152
+ "+++++++ REPLACE",
153
+ ].join("\n"),
154
+ )
155
+ .join("\n");
156
+ }
157
+
158
+ function parseSearchReplaceBlocks(
159
+ diff: string,
160
+ ): Array<{ oldText: string; newText: string }> {
161
+ const edits: Array<{ oldText: string; newText: string }> = [];
162
+ const normalized = diff.replaceAll("\r\n", "\n");
163
+ let cursor = 0;
164
+
165
+ while (cursor < normalized.length) {
166
+ const searchMarker = "------- SEARCH\n";
167
+ const replaceMarker = "\n=======\n";
168
+ const endMarker = "\n+++++++ REPLACE";
169
+ const searchStart = normalized.indexOf(searchMarker, cursor);
170
+ if (searchStart === -1) break;
171
+ const oldTextStart = searchStart + searchMarker.length;
172
+ const replaceStart = normalized.indexOf(replaceMarker, oldTextStart);
173
+ if (replaceStart === -1) break;
174
+ const newTextStart = replaceStart + replaceMarker.length;
175
+ const endStart = normalized.indexOf(endMarker, newTextStart);
176
+ if (endStart === -1) break;
177
+ edits.push({
178
+ oldText: normalized.slice(oldTextStart, replaceStart),
179
+ newText: normalized.slice(newTextStart, endStart),
180
+ });
181
+ cursor = endStart + endMarker.length;
182
+ }
183
+
184
+ return edits;
185
+ }
186
+
187
+ function buildListCodeDefinitionNamesCommand(
188
+ args: Record<string, unknown>,
189
+ ): string {
190
+ const path = shellQuote(stringArg(args, "path") || ".");
191
+ const globArgs = [
192
+ "-g '*.ts'",
193
+ "-g '*.tsx'",
194
+ "-g '*.js'",
195
+ "-g '*.jsx'",
196
+ "-g '*.mjs'",
197
+ "-g '*.cjs'",
198
+ "-g '*.py'",
199
+ "-g '*.go'",
200
+ "-g '*.rs'",
201
+ "-g '*.java'",
202
+ "-g '*.kt'",
203
+ "-g '*.swift'",
204
+ ].join(" ");
205
+ const regex =
206
+ "^(export\\s+)?(async\\s+function|function|class|interface|type|enum)\\s+[A-Za-z_][A-Za-z0-9_]*|^(export\\s+)?const\\s+[A-Za-z_][A-Za-z0-9_]*\\s*=\\s*(async\\s*)?\\(";
207
+ return [
208
+ "rg",
209
+ "-n",
210
+ "--no-heading",
211
+ "--color",
212
+ "never",
213
+ globArgs,
214
+ `-e ${shellQuote(regex)}`,
215
+ path,
216
+ ].join(" ");
217
+ }
218
+
219
+ function readFileBridge(tool?: Tool): ToolBridge {
220
+ return {
221
+ remoteName: "read_file",
222
+ runtimeName: tool?.name === "read_file" ? "read_file" : "read",
223
+ description: tool?.description ?? "Read a file from disk",
224
+ parameters: ["path"],
225
+ toRuntimeArgs: (args) => ({ path: stringArg(args, "path") }),
226
+ fromRuntimeArgs: (args) => ({ path: args.path }),
227
+ };
228
+ }
229
+
230
+ function writeToFileBridge(tool?: Tool): ToolBridge {
231
+ return {
232
+ remoteName: "write_to_file",
233
+ runtimeName: tool?.name === "write_to_file" ? "write_to_file" : "write",
234
+ description: tool?.description ?? "Write content to a file",
235
+ parameters: ["path", "content"],
236
+ toRuntimeArgs: (args) => ({
237
+ path: stringArg(args, "path"),
238
+ content: stringArg(args, "content"),
239
+ }),
240
+ fromRuntimeArgs: (args) => ({ path: args.path, content: args.content }),
241
+ };
242
+ }
243
+
244
+ function replaceInFileBridge(tool?: Tool): ToolBridge {
245
+ return {
246
+ remoteName: "replace_in_file",
247
+ runtimeName: tool?.name === "replace_in_file" ? "replace_in_file" : "edit",
248
+ description:
249
+ tool?.description ?? "Edit a file using Cline SEARCH/REPLACE blocks",
250
+ parameters: ["path", "diff"],
251
+ toRuntimeArgs: (args) => ({
252
+ path: stringArg(args, "path"),
253
+ edits: parseSearchReplaceBlocks(stringArg(args, "diff")),
254
+ }),
255
+ fromRuntimeArgs: (args) => {
256
+ const edits = Array.isArray(args.edits)
257
+ ? args.edits
258
+ .map((edit) => ({
259
+ oldText: stringArg(edit as Record<string, unknown>, "oldText"),
260
+ newText: stringArg(edit as Record<string, unknown>, "newText"),
261
+ }))
262
+ .filter((edit) => edit.oldText || edit.newText)
263
+ : [
264
+ {
265
+ oldText: stringArg(args, "oldText"),
266
+ newText: stringArg(args, "newText"),
267
+ },
268
+ ].filter((edit) => edit.oldText || edit.newText);
269
+ return {
270
+ path: args.path,
271
+ diff: buildSearchReplaceDiff(edits),
272
+ };
273
+ },
274
+ };
275
+ }
276
+
277
+ function executeCommandBridge(tool?: Tool): ToolBridge {
278
+ return {
279
+ remoteName: "execute_command",
280
+ runtimeName: tool?.name === "execute_command" ? "execute_command" : "bash",
281
+ description: tool?.description ?? "Execute a shell command",
282
+ parameters: ["command", "timeout"],
283
+ toRuntimeArgs: (args) => ({
284
+ command: stringArg(args, "command"),
285
+ ...(args.timeout !== undefined ? { timeout: Number(args.timeout) } : {}),
286
+ }),
287
+ fromRuntimeArgs: (args) => ({
288
+ command: args.command,
289
+ ...(args.timeout !== undefined ? { timeout: args.timeout } : {}),
290
+ }),
291
+ };
292
+ }
293
+
294
+ function listFilesBridge(): ToolBridge {
295
+ return {
296
+ remoteName: "list_files",
297
+ runtimeName: "bash",
298
+ description: "List files in a directory",
299
+ parameters: ["path", "recursive"],
300
+ toRuntimeArgs: (args) => ({ command: buildListFilesCommand(args) }),
301
+ fromRuntimeArgs: (args) => ({ command: args.command }),
302
+ };
303
+ }
304
+
305
+ function searchFilesBridge(): ToolBridge {
306
+ return {
307
+ remoteName: "search_files",
308
+ runtimeName: "bash",
309
+ description: "Search files by regex",
310
+ parameters: ["path", "regex", "file_pattern"],
311
+ toRuntimeArgs: (args) => ({ command: buildSearchFilesCommand(args) }),
312
+ fromRuntimeArgs: (args) => ({ command: args.command }),
313
+ };
314
+ }
315
+
316
+ function listCodeDefinitionNamesBridge(): ToolBridge {
317
+ return {
318
+ remoteName: "list_code_definition_names",
319
+ runtimeName: "bash",
320
+ description: "List code definition names in source files",
321
+ parameters: ["path"],
322
+ toRuntimeArgs: (args) => ({
323
+ command: buildListCodeDefinitionNamesCommand(args),
324
+ }),
325
+ fromRuntimeArgs: (args) => ({ command: args.command }),
326
+ };
327
+ }
328
+
329
+ function getToolBridge(tool: Tool): ToolBridge {
330
+ if (tool.name === "read" || tool.name === "read_file") {
331
+ return readFileBridge(tool);
332
+ }
333
+ if (tool.name === "write" || tool.name === "write_to_file") {
334
+ return writeToFileBridge(tool);
335
+ }
336
+ if (tool.name === "edit" || tool.name === "replace_in_file") {
337
+ return replaceInFileBridge(tool);
338
+ }
339
+ if (tool.name === "bash" || tool.name === "execute_command") {
340
+ return executeCommandBridge(tool);
341
+ }
342
+ const parameters = schemaProperties(tool);
343
+ return {
344
+ remoteName: tool.name,
345
+ runtimeName: tool.name,
346
+ description: tool.description,
347
+ parameters,
348
+ toRuntimeArgs: (args) => args,
349
+ fromRuntimeArgs: (args) => args,
350
+ };
351
+ }
352
+
353
+ function getToolBridges(tools: Tool[] | undefined): ToolBridge[] {
354
+ const bridges: ToolBridge[] = [];
355
+ for (const tool of tools ?? []) {
356
+ bridges.push(getToolBridge(tool));
357
+ if (tool.name === "bash" || tool.name === "execute_command") {
358
+ bridges.push(
359
+ listFilesBridge(),
360
+ searchFilesBridge(),
361
+ listCodeDefinitionNamesBridge(),
362
+ );
363
+ }
364
+ }
365
+ return bridges;
366
+ }
367
+
368
+ function getParseToolBridges(tools: Tool[] | undefined): ToolBridge[] {
369
+ const bridges = getToolBridges(tools);
370
+ const remoteNames = new Set(bridges.map((bridge) => bridge.remoteName));
371
+ const toolsByName = new Map((tools ?? []).map((tool) => [tool.name, tool]));
372
+
373
+ for (const remoteName of CORE_CLINE_TOOL_NAMES) {
374
+ if (remoteNames.has(remoteName)) continue;
375
+ if (remoteName === "read_file") {
376
+ bridges.push(readFileBridge(toolsByName.get("read_file")));
377
+ }
378
+ if (remoteName === "write_to_file") {
379
+ bridges.push(writeToFileBridge(toolsByName.get("write_to_file")));
380
+ }
381
+ if (remoteName === "replace_in_file") {
382
+ bridges.push(replaceInFileBridge(toolsByName.get("replace_in_file")));
383
+ }
384
+ if (remoteName === "execute_command") {
385
+ bridges.push(executeCommandBridge(toolsByName.get("execute_command")));
386
+ }
387
+ if (remoteName === "list_files") {
388
+ bridges.push(listFilesBridge());
389
+ }
390
+ if (remoteName === "search_files") {
391
+ bridges.push(searchFilesBridge());
392
+ }
393
+ if (remoteName === "list_code_definition_names") {
394
+ bridges.push(listCodeDefinitionNamesBridge());
395
+ }
396
+ }
397
+
398
+ return bridges;
399
+ }
400
+
401
+ function serializeXmlToolCall(
402
+ name: string,
403
+ args: Record<string, unknown>,
404
+ ): string {
405
+ const parts = [`<${name}>`];
406
+ for (const [key, value] of Object.entries(args)) {
407
+ const text = typeof value === "string" ? value : JSON.stringify(value);
408
+ parts.push(`<${key}>${xmlEscape(text)}</${key}>`);
409
+ }
410
+ parts.push(`</${name}>`);
411
+ return parts.join("\n");
412
+ }
413
+
414
+ function assistantMessageToText(
415
+ message: Extract<Message, { role: "assistant" }>,
416
+ tools: Tool[] | undefined,
417
+ ): string {
418
+ const bridgeByRuntimeName = new Map<string, ToolBridge>();
419
+ for (const bridge of getToolBridges(tools)) {
420
+ if (!bridgeByRuntimeName.has(bridge.runtimeName)) {
421
+ bridgeByRuntimeName.set(bridge.runtimeName, bridge);
422
+ }
423
+ }
424
+ return message.content
425
+ .map((part) => {
426
+ if (part.type === "text") return part.text;
427
+ if (part.type === "thinking") {
428
+ return `<thinking>\n${xmlEscape(part.thinking)}\n</thinking>`;
429
+ }
430
+ if (part.type === "toolCall") {
431
+ const bridge = bridgeByRuntimeName.get(part.name);
432
+ return serializeXmlToolCall(
433
+ bridge?.remoteName ?? part.name,
434
+ bridge?.fromRuntimeArgs(part.arguments) ?? part.arguments,
435
+ );
436
+ }
437
+ return "";
438
+ })
439
+ .filter(Boolean)
440
+ .join("\n\n");
441
+ }
442
+
443
+ function schemaProperties(tool: Tool): string[] {
444
+ const parameters = tool.parameters as unknown as {
445
+ properties?: Record<string, unknown>;
446
+ };
447
+ return Object.keys(parameters.properties ?? {});
448
+ }
449
+
450
+ function buildToolInstructions(tools: Tool[] | undefined): string {
451
+ const bridges = getToolBridges(tools);
452
+ if (bridges.length === 0) return "";
453
+
454
+ const sections = bridges.map((bridge) => {
455
+ const params =
456
+ bridge.remoteName === "replace_in_file"
457
+ ? [
458
+ " <path>path/to/file</path>",
459
+ " <diff>",
460
+ "------- SEARCH",
461
+ "exact text to replace",
462
+ "=======",
463
+ "new text",
464
+ "+++++++ REPLACE",
465
+ " </diff>",
466
+ ].join("\n")
467
+ : bridge.parameters.length
468
+ ? bridge.parameters
469
+ .map((name) => ` <${name}>value</${name}>`)
470
+ .join("\n")
471
+ : " <arguments>{}</arguments>";
472
+ return [
473
+ `Tool: ${bridge.remoteName}`,
474
+ `Description: ${bridge.description ?? bridge.runtimeName}`,
475
+ "XML usage:",
476
+ `<${bridge.remoteName}>`,
477
+ params,
478
+ `</${bridge.remoteName}>`,
479
+ ].join("\n");
480
+ });
481
+
482
+ return [
483
+ "You have access to tools. Use XML tool calls instead of OpenAI function calling.",
484
+ "When you need a tool, output exactly one XML tool call using one of the tool names below.",
485
+ "Do not wrap XML tool calls in markdown fences. Do not invent tool names.",
486
+ "Available tools:",
487
+ sections.join("\n\n"),
488
+ ].join("\n\n");
489
+ }
490
+
491
+ function buildClineXmlMessages(context: Context): ClineXmlChatMessage[] {
492
+ const messages: ClineXmlChatMessage[] = [];
493
+ const systemParts = [
494
+ context.systemPrompt,
495
+ buildToolInstructions(context.tools),
496
+ ]
497
+ .filter(Boolean)
498
+ .join("\n\n");
499
+ if (systemParts) messages.push({ role: "system", content: systemParts });
500
+
501
+ let firstUser = true;
502
+ for (const message of context.messages) {
503
+ if (message.role === "user") {
504
+ const text = contentToText(message.content).trim();
505
+ if (!text) continue;
506
+ messages.push({
507
+ role: "user",
508
+ content: firstUser ? `<task>\n${text}\n</task>` : text,
509
+ });
510
+ firstUser = false;
511
+ continue;
512
+ }
513
+
514
+ if (message.role === "assistant") {
515
+ const text = assistantMessageToText(message, context.tools).trim();
516
+ if (text) messages.push({ role: "assistant", content: text });
517
+ continue;
518
+ }
519
+
520
+ if (message.role === "toolResult") {
521
+ const text = toolResultToText(message).trim();
522
+ const bridge = getToolBridges(context.tools).find(
523
+ (candidate) => candidate.runtimeName === message.toolName,
524
+ );
525
+ messages.push({
526
+ role: "user",
527
+ content: `Tool result for ${bridge?.remoteName ?? message.toolName}:\n${text || "(no output)"}`,
528
+ });
529
+ }
530
+ }
531
+
532
+ return messages;
533
+ }
534
+
535
+ function findNextToolStart(
536
+ text: string,
537
+ toolNames: Set<string>,
538
+ from: number,
539
+ ): { index: number; name: string; openTag: string } | null {
540
+ let best: { index: number; name: string; openTag: string } | null = null;
541
+ for (const name of toolNames) {
542
+ const openTag = `<${name}>`;
543
+ const index = text.indexOf(openTag, from);
544
+ if (index === -1) continue;
545
+ if (!best || index < best.index) best = { index, name, openTag };
546
+ }
547
+ return best;
548
+ }
549
+
550
+ function isFenceOnlyText(text: string): boolean {
551
+ const trimmed = text.trim().toLowerCase();
552
+ return trimmed === "```" || trimmed === "```xml";
553
+ }
554
+
555
+ function pushTextFragment(textParts: string[], fragment: string): void {
556
+ const trimmed = fragment.trim();
557
+ if (!trimmed || isFenceOnlyText(trimmed)) return;
558
+ textParts.push(trimmed);
559
+ }
560
+
561
+ function extractThinkingXml(text: string): {
562
+ text: string;
563
+ thinking: string[];
564
+ } {
565
+ const thinking: string[] = [];
566
+ const parts: string[] = [];
567
+ const openTags = ["<thinking>", "<think>"];
568
+ const closeTag = "</thinking>";
569
+ let cursor = 0;
570
+
571
+ function findNextOpenTag(from: number): { index: number; tag: string } | null {
572
+ let best: { index: number; tag: string } | null = null;
573
+ for (const tag of openTags) {
574
+ const index = text.indexOf(tag, from);
575
+ if (index === -1) continue;
576
+ if (!best || index < best.index) best = { index, tag };
577
+ }
578
+ return best;
579
+ }
580
+
581
+ while (cursor < text.length) {
582
+ const nextOpen = findNextOpenTag(cursor);
583
+ const openStart = nextOpen?.index ?? -1;
584
+ const closeStart = text.indexOf(closeTag, cursor);
585
+
586
+ if (closeStart !== -1 && (openStart === -1 || closeStart < openStart)) {
587
+ const danglingThinking = decodeXmlEntities(
588
+ text.slice(cursor, closeStart).trim(),
589
+ );
590
+ if (danglingThinking) thinking.push(danglingThinking);
591
+ cursor = closeStart + closeTag.length;
592
+ continue;
593
+ }
594
+
595
+ if (openStart === -1 || !nextOpen) break;
596
+ parts.push(text.slice(cursor, openStart));
597
+ const valueStart = openStart + nextOpen.tag.length;
598
+ const valueEnd = text.indexOf(closeTag, valueStart);
599
+ if (valueEnd === -1) {
600
+ const value = decodeXmlEntities(text.slice(valueStart).trim());
601
+ if (value) thinking.push(value);
602
+ cursor = text.length;
603
+ break;
604
+ }
605
+
606
+ const value = decodeXmlEntities(text.slice(valueStart, valueEnd).trim());
607
+ if (value) thinking.push(value);
608
+ cursor = valueEnd + closeTag.length;
609
+ }
610
+
611
+ if (cursor === 0) {
612
+ return { text, thinking };
613
+ }
614
+ parts.push(text.slice(cursor));
615
+ return { text: parts.join(""), thinking };
616
+ }
617
+
618
+ function extractTagContent(text: string, tag: string): string | undefined {
619
+ const open = `<${tag}>`;
620
+ const close = `</${tag}>`;
621
+ const start = text.indexOf(open);
622
+ if (start === -1) return undefined;
623
+ const valueStart = start + open.length;
624
+ const end =
625
+ tag === "content"
626
+ ? text.lastIndexOf(close)
627
+ : text.indexOf(close, valueStart);
628
+ if (end === -1 || end < valueStart) return undefined;
629
+ return decodeXmlEntities(text.slice(valueStart, end).trim());
630
+ }
631
+
632
+ function parseToolArguments(block: string): Record<string, unknown> {
633
+ const explicitArgs = extractTagContent(block, "arguments");
634
+ if (explicitArgs) {
635
+ try {
636
+ const parsed = JSON.parse(explicitArgs);
637
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
638
+ return parsed as Record<string, unknown>;
639
+ }
640
+ } catch {
641
+ return { arguments: explicitArgs };
642
+ }
643
+ }
644
+
645
+ const args: Record<string, unknown> = {};
646
+ let cursor = 0;
647
+ while (cursor < block.length) {
648
+ const openStart = block.indexOf("<", cursor);
649
+ if (openStart === -1) break;
650
+ const openEnd = block.indexOf(">", openStart + 1);
651
+ if (openEnd === -1) break;
652
+ const tag = block.slice(openStart + 1, openEnd).trim();
653
+ if (!tag || tag.startsWith("/") || tag.includes(" ")) {
654
+ cursor = openEnd + 1;
655
+ continue;
656
+ }
657
+ const close = `</${tag}>`;
658
+ const closeStart =
659
+ tag === "content" || tag === "diff"
660
+ ? block.lastIndexOf(close)
661
+ : block.indexOf(close, openEnd + 1);
662
+ if (closeStart === -1 || closeStart < openEnd) break;
663
+ const raw = decodeXmlEntities(block.slice(openEnd + 1, closeStart).trim());
664
+ try {
665
+ args[tag] = JSON.parse(raw);
666
+ } catch {
667
+ args[tag] = raw;
668
+ }
669
+ cursor = closeStart + close.length;
670
+ }
671
+ return args;
672
+ }
673
+
674
+ function parseXmlToolCalls(
675
+ rawText: string,
676
+ tools: Tool[] | undefined,
677
+ ): {
678
+ text: string;
679
+ toolCalls: Array<{ name: string; arguments: Record<string, unknown> }>;
680
+ } {
681
+ const bridgeByRemoteName = new Map(
682
+ getParseToolBridges(tools).map((bridge) => [bridge.remoteName, bridge]),
683
+ );
684
+ const toolNames = new Set(bridgeByRemoteName.keys());
685
+ const textWithoutThinking = extractThinkingXml(rawText).text;
686
+ if (toolNames.size === 0) {
687
+ return { text: textWithoutThinking.trim(), toolCalls: [] };
688
+ }
689
+
690
+ const sourceText = findNextToolStart(textWithoutThinking, toolNames, 0)
691
+ ? textWithoutThinking
692
+ : decodeXmlEntities(textWithoutThinking);
693
+ const textParts: string[] = [];
694
+ const toolCalls: Array<{ name: string; arguments: Record<string, unknown> }> =
695
+ [];
696
+ let cursor = 0;
697
+
698
+ while (cursor < sourceText.length) {
699
+ const next = findNextToolStart(sourceText, toolNames, cursor);
700
+ if (!next) break;
701
+ const closeTag = `</${next.name}>`;
702
+ const closeStart = sourceText.indexOf(
703
+ closeTag,
704
+ next.index + next.openTag.length,
705
+ );
706
+ pushTextFragment(textParts, sourceText.slice(cursor, next.index));
707
+ const blockEnd = closeStart === -1 ? sourceText.length : closeStart;
708
+ const block = sourceText.slice(next.index + next.openTag.length, blockEnd);
709
+ const bridge = bridgeByRemoteName.get(next.name);
710
+ const remoteArgs = parseToolArguments(block);
711
+ toolCalls.push({
712
+ name: bridge?.runtimeName ?? next.name,
713
+ arguments: bridge?.toRuntimeArgs(remoteArgs) ?? remoteArgs,
714
+ });
715
+ cursor =
716
+ closeStart === -1 ? sourceText.length : closeStart + closeTag.length;
717
+ }
718
+
719
+ pushTextFragment(textParts, sourceText.slice(cursor));
720
+ return { text: textParts.join("\n\n").trim(), toolCalls };
721
+ }
722
+
723
+ function usageFromChunkUsage(usage: ClineXmlChunk["usage"] | undefined): Usage {
724
+ const input = usage?.prompt_tokens ?? 0;
725
+ const output = usage?.completion_tokens ?? 0;
726
+ const totalTokens = usage?.total_tokens ?? input + output;
727
+ return {
728
+ ...DEFAULT_USAGE,
729
+ input,
730
+ output,
731
+ totalTokens,
732
+ };
733
+ }
734
+
735
+ async function* parseSse(response: Response): AsyncGenerator<ClineXmlChunk> {
736
+ const reader = response.body?.getReader();
737
+ if (!reader) return;
738
+ const decoder = new TextDecoder();
739
+ let buffer = "";
740
+
741
+ while (true) {
742
+ const { done, value } = await reader.read();
743
+ if (done) break;
744
+ buffer += decoder.decode(value, { stream: true });
745
+ const lines = buffer.split(/\r?\n/);
746
+ buffer = lines.pop() ?? "";
747
+ for (const line of lines) {
748
+ const trimmed = line.trim();
749
+ if (!trimmed.startsWith("data:")) continue;
750
+ const data = trimmed.slice("data:".length).trim();
751
+ if (!data || data === "[DONE]") continue;
752
+ yield JSON.parse(data) as ClineXmlChunk;
753
+ }
754
+ }
755
+ }
756
+
757
+ function createAssistant(model: Model<string>): AssistantMessage {
758
+ return {
759
+ role: "assistant",
760
+ content: [],
761
+ api: model.api,
762
+ provider: model.provider,
763
+ model: model.id,
764
+ usage: DEFAULT_USAGE,
765
+ stopReason: "stop",
766
+ timestamp: Date.now(),
767
+ };
768
+ }
769
+
770
+ function pushText(
771
+ message: AssistantMessage,
772
+ text: string,
773
+ stream: ReturnType<typeof createAssistantMessageEventStream>,
774
+ ): void {
775
+ if (!text) return;
776
+ const index = message.content.length;
777
+ message.content.push({ type: "text", text: "" });
778
+ stream.push({ type: "text_start", contentIndex: index, partial: message });
779
+ (message.content[index] as { type: "text"; text: string }).text = text;
780
+ stream.push({
781
+ type: "text_delta",
782
+ contentIndex: index,
783
+ delta: text,
784
+ partial: message,
785
+ });
786
+ stream.push({
787
+ type: "text_end",
788
+ contentIndex: index,
789
+ content: text,
790
+ partial: message,
791
+ });
792
+ }
793
+
794
+ function pushThinking(
795
+ message: AssistantMessage,
796
+ thinking: string,
797
+ stream: ReturnType<typeof createAssistantMessageEventStream>,
798
+ ): void {
799
+ if (!thinking) return;
800
+ const index = message.content.length;
801
+ message.content.push({ type: "thinking", thinking: "" });
802
+ stream.push({
803
+ type: "thinking_start",
804
+ contentIndex: index,
805
+ partial: message,
806
+ });
807
+ (message.content[index] as { type: "thinking"; thinking: string }).thinking =
808
+ thinking;
809
+ stream.push({
810
+ type: "thinking_delta",
811
+ contentIndex: index,
812
+ delta: thinking,
813
+ partial: message,
814
+ });
815
+ stream.push({
816
+ type: "thinking_end",
817
+ contentIndex: index,
818
+ content: thinking,
819
+ partial: message,
820
+ });
821
+ }
822
+
823
+ function pushToolCall(
824
+ message: AssistantMessage,
825
+ toolCall: { name: string; arguments: Record<string, unknown> },
826
+ stream: ReturnType<typeof createAssistantMessageEventStream>,
827
+ ): void {
828
+ const index = message.content.length;
829
+ const id = `cline_xml_${Date.now()}_${index}`;
830
+ const block: ToolCall = {
831
+ type: "toolCall",
832
+ id,
833
+ name: toolCall.name,
834
+ arguments: {},
835
+ };
836
+ message.content.push(block);
837
+ stream.push({
838
+ type: "toolcall_start",
839
+ contentIndex: index,
840
+ partial: message,
841
+ });
842
+ const delta = JSON.stringify(toolCall.arguments);
843
+ stream.push({
844
+ type: "toolcall_delta",
845
+ contentIndex: index,
846
+ delta,
847
+ partial: message,
848
+ });
849
+ block.arguments = toolCall.arguments;
850
+ stream.push({
851
+ type: "toolcall_end",
852
+ contentIndex: index,
853
+ toolCall: block,
854
+ partial: message,
855
+ });
856
+ }
857
+
858
+ export function streamClineXml(
859
+ model: Model<string>,
860
+ context: Context,
861
+ options: SimpleStreamOptions | undefined,
862
+ headers: Record<string, string>,
863
+ ) {
864
+ const stream = createAssistantMessageEventStream();
865
+
866
+ void (async () => {
867
+ const assistant = createAssistant(model);
868
+ stream.push({ type: "start", partial: assistant });
869
+ try {
870
+ if (!options?.apiKey) {
871
+ throw new Error("No Cline access token found. Run /login cline first.");
872
+ }
873
+
874
+ const response = await fetch(`${BASE_URL_CLINE}/chat/completions`, {
875
+ method: "POST",
876
+ headers: {
877
+ ...headers,
878
+ Authorization: `Bearer ${options.apiKey}`,
879
+ "Content-Type": "application/json",
880
+ },
881
+ body: JSON.stringify({
882
+ model: normalizeApiModelId(model.id),
883
+ temperature: 0,
884
+ messages: buildClineXmlMessages(context),
885
+ stream: true,
886
+ stream_options: { include_usage: true },
887
+ include_reasoning: true,
888
+ }),
889
+ signal: options.signal,
890
+ });
891
+ await options.onResponse?.(
892
+ {
893
+ status: response.status,
894
+ headers: Object.fromEntries(response.headers.entries()),
895
+ },
896
+ model,
897
+ );
898
+
899
+ if (!response.ok) {
900
+ throw new Error(
901
+ `Cline API error ${response.status}: ${await response.text()}`,
902
+ );
903
+ }
904
+
905
+ let rawText = "";
906
+ let thinking = "";
907
+ let finishReason: string | null | undefined;
908
+ let usage: ClineXmlChunk["usage"] | undefined;
909
+
910
+ for await (const chunk of parseSse(response)) {
911
+ if (chunk.error) {
912
+ throw new Error(
913
+ `${chunk.error.code ?? "cline_error"}: ${chunk.error.message ?? "Unknown Cline error"}`,
914
+ );
915
+ }
916
+ if (chunk.usage) usage = chunk.usage;
917
+ const choice = chunk.choices?.[0];
918
+ if (!choice) continue;
919
+ if (choice.error) {
920
+ throw new Error(
921
+ `${choice.error.code ?? "cline_error"}: ${choice.error.message ?? "Unknown Cline error"}`,
922
+ );
923
+ }
924
+ if (choice.finish_reason) finishReason = choice.finish_reason;
925
+ rawText += choice.delta?.content ?? "";
926
+ thinking += choice.delta?.reasoning ?? "";
927
+ }
928
+
929
+ assistant.usage = usageFromChunkUsage(usage);
930
+ const extractedThinking = extractThinkingXml(rawText);
931
+ pushThinking(
932
+ assistant,
933
+ [thinking.trim(), ...extractedThinking.thinking]
934
+ .filter(Boolean)
935
+ .join("\n\n"),
936
+ stream,
937
+ );
938
+ const parsed = parseXmlToolCalls(extractedThinking.text, context.tools);
939
+ pushText(assistant, parsed.text, stream);
940
+ for (const toolCall of parsed.toolCalls) {
941
+ pushToolCall(assistant, toolCall, stream);
942
+ }
943
+
944
+ assistant.stopReason =
945
+ parsed.toolCalls.length > 0
946
+ ? "toolUse"
947
+ : finishReason === "length"
948
+ ? "length"
949
+ : "stop";
950
+ stream.push({
951
+ type: "done",
952
+ reason: assistant.stopReason as "stop" | "length" | "toolUse",
953
+ message: assistant,
954
+ });
955
+ } catch (error) {
956
+ assistant.stopReason = options?.signal?.aborted ? "aborted" : "error";
957
+ assistant.errorMessage =
958
+ error instanceof Error ? error.message : String(error);
959
+ stream.push({
960
+ type: "error",
961
+ reason: assistant.stopReason,
962
+ error: assistant,
963
+ });
964
+ }
965
+ })();
966
+
967
+ return stream;
968
+ }
969
+
970
+ export const __test__ = {
971
+ buildClineXmlMessages,
972
+ parseXmlToolCalls,
973
+ serializeXmlToolCall,
974
+ };