pi-free 2.2.6 → 2.2.8

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.
@@ -1,251 +1,251 @@
1
- /**
2
- * Streaming parser for HTML-style thinking tags in LLM responses.
3
- *
4
- * Some providers (Qoder, DeepSeek via certain gateways) emit reasoning in
5
- * HTML-style tags like <thinking>, <think>, <reasoning>, <thought> within
6
- * the text stream, rather than via a structured reasoning_content field.
7
- *
8
- * This parser handles streaming chunks safely — it never emits partial tags
9
- * by tracking trailing tag prefixes and deferring output until the boundary
10
- * is clear.
11
- */
12
-
13
- import type {
14
- AssistantMessage,
15
- AssistantMessageEventStream,
16
- TextContent,
17
- ThinkingContent,
18
- } from "@earendil-works/pi-ai";
19
-
20
- const THINKING_TAG_VARIANTS: Array<{ open: string; close: string }> = [
21
- { open: "<thinking>", close: "</thinking>" },
22
- { open: "<think>", close: "</think>" },
23
- { open: "<reasoning>", close: "</reasoning>" },
24
- { open: "<thought>", close: "</thought>" },
25
- ];
26
-
27
- function getTrailingPossibleTagPrefixLength(text: string, tag: string): number {
28
- const maxPrefixLength = Math.min(text.length, tag.length - 1);
29
- for (let len = maxPrefixLength; len > 0; len--) {
30
- if (text.endsWith(tag.slice(0, len))) return len;
31
- }
32
- return 0;
33
- }
34
-
35
- function getMaxTrailingPossibleTagPrefixLength(
36
- text: string,
37
- tags: string[],
38
- ): number {
39
- let maxLength = 0;
40
- for (const tag of tags) {
41
- maxLength = Math.max(
42
- maxLength,
43
- getTrailingPossibleTagPrefixLength(text, tag),
44
- );
45
- }
46
- return maxLength;
47
- }
48
-
49
- /**
50
- * Streaming parser that extracts <thinking>/<think>/<reasoning>/<thought> tags
51
- * from a text stream and emits them as thinking_start/thinking_delta/thinking_end
52
- * events on the Pi event stream.
53
- *
54
- * Usage:
55
- * ```ts
56
- * const parser = new ThinkingTagParser(output, stream);
57
- * for (const chunk of textChunks) {
58
- * parser.processChunk(chunk);
59
- * }
60
- * parser.finalize();
61
- * ```
62
- */
63
- export class ThinkingTagParser {
64
- private textBuffer = "";
65
- private inThinking = false;
66
- private thinkingBlockIndex: number | null = null;
67
- private textBlockIndex: number | null = null;
68
- private activeEndTag = "";
69
-
70
- constructor(
71
- private readonly output: AssistantMessage,
72
- private readonly stream: AssistantMessageEventStream,
73
- ) {
74
- // Set initial active end tag to the first variant's close
75
- this.activeEndTag = THINKING_TAG_VARIANTS[0]!.close;
76
- }
77
-
78
- processChunk(chunk: string): void {
79
- this.textBuffer += chunk;
80
- while (this.textBuffer.length > 0) {
81
- const prevLength = this.textBuffer.length;
82
- if (!this.inThinking) {
83
- this.processBeforeThinking();
84
- if (this.textBuffer.length === 0) break;
85
- }
86
- if (this.inThinking) {
87
- this.processInsideThinking();
88
- if (this.textBuffer.length === 0) break;
89
- }
90
- if (this.textBuffer.length >= prevLength) break;
91
- }
92
- }
93
-
94
- finalize(): void {
95
- if (this.textBuffer.length === 0) return;
96
- if (this.inThinking && this.thinkingBlockIndex !== null) {
97
- const block = this.output.content[
98
- this.thinkingBlockIndex
99
- ] as ThinkingContent;
100
- block.thinking += this.textBuffer;
101
- this.stream.push({
102
- type: "thinking_delta",
103
- contentIndex: this.thinkingBlockIndex,
104
- delta: this.textBuffer,
105
- partial: this.output,
106
- });
107
- this.stream.push({
108
- type: "thinking_end",
109
- contentIndex: this.thinkingBlockIndex,
110
- content: block.thinking,
111
- partial: this.output,
112
- });
113
- } else {
114
- this.emitText(this.textBuffer);
115
- }
116
- this.textBuffer = "";
117
- }
118
-
119
- /** Get the index of the final text block (after thinking, or null if none) */
120
- getTextBlockIndex(): number | null {
121
- return this.textBlockIndex;
122
- }
123
-
124
- private processBeforeThinking(): void {
125
- let bestPos = -1;
126
- let bestVariant: (typeof THINKING_TAG_VARIANTS)[number] | null = null;
127
- for (const variant of THINKING_TAG_VARIANTS) {
128
- const pos = this.textBuffer.indexOf(variant.open);
129
- if (pos !== -1 && (bestPos === -1 || pos < bestPos)) {
130
- bestPos = pos;
131
- bestVariant = variant;
132
- }
133
- }
134
-
135
- if (bestPos !== -1 && bestVariant) {
136
- if (bestPos > 0) this.emitText(this.textBuffer.slice(0, bestPos));
137
- this.textBuffer = this.textBuffer.slice(
138
- bestPos + bestVariant.open.length,
139
- );
140
- this.activeEndTag = bestVariant.close;
141
- this.inThinking = true;
142
- return;
143
- }
144
-
145
- // No thinking tag found yet, but the buffer might end with a partial tag
146
- const trailingPrefixLength = getMaxTrailingPossibleTagPrefixLength(
147
- this.textBuffer,
148
- THINKING_TAG_VARIANTS.map((variant) => variant.open),
149
- );
150
- const safeLen = this.textBuffer.length - trailingPrefixLength;
151
- if (safeLen > 0) {
152
- this.emitText(this.textBuffer.slice(0, safeLen));
153
- this.textBuffer = this.textBuffer.slice(safeLen);
154
- }
155
- }
156
-
157
- private processInsideThinking(): void {
158
- const endPos = this.textBuffer.indexOf(this.activeEndTag);
159
- if (endPos !== -1) {
160
- if (endPos > 0) this.emitThinking(this.textBuffer.slice(0, endPos));
161
- if (this.thinkingBlockIndex !== null) {
162
- const block = this.output.content[
163
- this.thinkingBlockIndex
164
- ] as ThinkingContent;
165
- this.stream.push({
166
- type: "thinking_end",
167
- contentIndex: this.thinkingBlockIndex,
168
- content: block.thinking,
169
- partial: this.output,
170
- });
171
- }
172
- this.textBuffer = this.textBuffer.slice(
173
- endPos + this.activeEndTag.length,
174
- );
175
- this.inThinking = false;
176
- this.thinkingBlockIndex = null;
177
- this.textBlockIndex = null;
178
- if (this.textBuffer.startsWith("\n\n"))
179
- this.textBuffer = this.textBuffer.slice(2);
180
- return;
181
- }
182
-
183
- // Buffer might end with a partial close tag
184
- const trailingPrefixLength = getTrailingPossibleTagPrefixLength(
185
- this.textBuffer,
186
- this.activeEndTag,
187
- );
188
- const safeLen = this.textBuffer.length - trailingPrefixLength;
189
- if (safeLen > 0) {
190
- this.emitThinking(this.textBuffer.slice(0, safeLen));
191
- this.textBuffer = this.textBuffer.slice(safeLen);
192
- }
193
- }
194
-
195
- private emitText(text: string): void {
196
- if (!text) return;
197
- if (this.textBlockIndex === null) {
198
- this.textBlockIndex = this.output.content.length;
199
- this.output.content.push({ type: "text", text: "" } as TextContent);
200
- this.stream.push({
201
- type: "text_start",
202
- contentIndex: this.textBlockIndex,
203
- partial: this.output,
204
- });
205
- }
206
- const block = this.output.content[this.textBlockIndex] as TextContent;
207
- block.text += text;
208
- this.stream.push({
209
- type: "text_delta",
210
- contentIndex: this.textBlockIndex,
211
- delta: text,
212
- partial: this.output,
213
- });
214
- }
215
-
216
- private emitThinking(thinking: string): void {
217
- if (thinking.length === 0) return;
218
- if (this.thinkingBlockIndex === null) {
219
- if (this.textBlockIndex === null) {
220
- this.thinkingBlockIndex = this.output.content.length;
221
- this.output.content.push({
222
- type: "thinking",
223
- thinking: "",
224
- } as ThinkingContent);
225
- } else {
226
- // Insert thinking block before the existing text block
227
- this.thinkingBlockIndex = this.textBlockIndex;
228
- this.output.content.splice(this.thinkingBlockIndex, 0, {
229
- type: "thinking",
230
- thinking: "",
231
- } as ThinkingContent);
232
- this.textBlockIndex = this.textBlockIndex + 1;
233
- }
234
- this.stream.push({
235
- type: "thinking_start",
236
- contentIndex: this.thinkingBlockIndex,
237
- partial: this.output,
238
- });
239
- }
240
- const block = this.output.content[
241
- this.thinkingBlockIndex
242
- ] as ThinkingContent;
243
- block.thinking += thinking;
244
- this.stream.push({
245
- type: "thinking_delta",
246
- contentIndex: this.thinkingBlockIndex,
247
- delta: thinking,
248
- partial: this.output,
249
- });
250
- }
251
- }
1
+ /**
2
+ * Streaming parser for HTML-style thinking tags in LLM responses.
3
+ *
4
+ * Some providers (Qoder, DeepSeek via certain gateways) emit reasoning in
5
+ * HTML-style tags like <thinking>, <think>, <reasoning>, <thought> within
6
+ * the text stream, rather than via a structured reasoning_content field.
7
+ *
8
+ * This parser handles streaming chunks safely — it never emits partial tags
9
+ * by tracking trailing tag prefixes and deferring output until the boundary
10
+ * is clear.
11
+ */
12
+
13
+ import type {
14
+ AssistantMessage,
15
+ AssistantMessageEventStream,
16
+ TextContent,
17
+ ThinkingContent,
18
+ } from "@earendil-works/pi-ai/compat";
19
+
20
+ const THINKING_TAG_VARIANTS: Array<{ open: string; close: string }> = [
21
+ { open: "<thinking>", close: "</thinking>" },
22
+ { open: "<think>", close: "</think>" },
23
+ { open: "<reasoning>", close: "</reasoning>" },
24
+ { open: "<thought>", close: "</thought>" },
25
+ ];
26
+
27
+ function getTrailingPossibleTagPrefixLength(text: string, tag: string): number {
28
+ const maxPrefixLength = Math.min(text.length, tag.length - 1);
29
+ for (let len = maxPrefixLength; len > 0; len--) {
30
+ if (text.endsWith(tag.slice(0, len))) return len;
31
+ }
32
+ return 0;
33
+ }
34
+
35
+ function getMaxTrailingPossibleTagPrefixLength(
36
+ text: string,
37
+ tags: string[],
38
+ ): number {
39
+ let maxLength = 0;
40
+ for (const tag of tags) {
41
+ maxLength = Math.max(
42
+ maxLength,
43
+ getTrailingPossibleTagPrefixLength(text, tag),
44
+ );
45
+ }
46
+ return maxLength;
47
+ }
48
+
49
+ /**
50
+ * Streaming parser that extracts <thinking>/<think>/<reasoning>/<thought> tags
51
+ * from a text stream and emits them as thinking_start/thinking_delta/thinking_end
52
+ * events on the Pi event stream.
53
+ *
54
+ * Usage:
55
+ * ```ts
56
+ * const parser = new ThinkingTagParser(output, stream);
57
+ * for (const chunk of textChunks) {
58
+ * parser.processChunk(chunk);
59
+ * }
60
+ * parser.finalize();
61
+ * ```
62
+ */
63
+ export class ThinkingTagParser {
64
+ private textBuffer = "";
65
+ private inThinking = false;
66
+ private thinkingBlockIndex: number | null = null;
67
+ private textBlockIndex: number | null = null;
68
+ private activeEndTag = "";
69
+
70
+ constructor(
71
+ private readonly output: AssistantMessage,
72
+ private readonly stream: AssistantMessageEventStream,
73
+ ) {
74
+ // Set initial active end tag to the first variant's close
75
+ this.activeEndTag = THINKING_TAG_VARIANTS[0]!.close;
76
+ }
77
+
78
+ processChunk(chunk: string): void {
79
+ this.textBuffer += chunk;
80
+ while (this.textBuffer.length > 0) {
81
+ const prevLength = this.textBuffer.length;
82
+ if (!this.inThinking) {
83
+ this.processBeforeThinking();
84
+ if (this.textBuffer.length === 0) break;
85
+ }
86
+ if (this.inThinking) {
87
+ this.processInsideThinking();
88
+ if (this.textBuffer.length === 0) break;
89
+ }
90
+ if (this.textBuffer.length >= prevLength) break;
91
+ }
92
+ }
93
+
94
+ finalize(): void {
95
+ if (this.textBuffer.length === 0) return;
96
+ if (this.inThinking && this.thinkingBlockIndex !== null) {
97
+ const block = this.output.content[
98
+ this.thinkingBlockIndex
99
+ ] as ThinkingContent;
100
+ block.thinking += this.textBuffer;
101
+ this.stream.push({
102
+ type: "thinking_delta",
103
+ contentIndex: this.thinkingBlockIndex,
104
+ delta: this.textBuffer,
105
+ partial: this.output,
106
+ });
107
+ this.stream.push({
108
+ type: "thinking_end",
109
+ contentIndex: this.thinkingBlockIndex,
110
+ content: block.thinking,
111
+ partial: this.output,
112
+ });
113
+ } else {
114
+ this.emitText(this.textBuffer);
115
+ }
116
+ this.textBuffer = "";
117
+ }
118
+
119
+ /** Get the index of the final text block (after thinking, or null if none) */
120
+ getTextBlockIndex(): number | null {
121
+ return this.textBlockIndex;
122
+ }
123
+
124
+ private processBeforeThinking(): void {
125
+ let bestPos = -1;
126
+ let bestVariant: (typeof THINKING_TAG_VARIANTS)[number] | null = null;
127
+ for (const variant of THINKING_TAG_VARIANTS) {
128
+ const pos = this.textBuffer.indexOf(variant.open);
129
+ if (pos !== -1 && (bestPos === -1 || pos < bestPos)) {
130
+ bestPos = pos;
131
+ bestVariant = variant;
132
+ }
133
+ }
134
+
135
+ if (bestPos !== -1 && bestVariant) {
136
+ if (bestPos > 0) this.emitText(this.textBuffer.slice(0, bestPos));
137
+ this.textBuffer = this.textBuffer.slice(
138
+ bestPos + bestVariant.open.length,
139
+ );
140
+ this.activeEndTag = bestVariant.close;
141
+ this.inThinking = true;
142
+ return;
143
+ }
144
+
145
+ // No thinking tag found yet, but the buffer might end with a partial tag
146
+ const trailingPrefixLength = getMaxTrailingPossibleTagPrefixLength(
147
+ this.textBuffer,
148
+ THINKING_TAG_VARIANTS.map((variant) => variant.open),
149
+ );
150
+ const safeLen = this.textBuffer.length - trailingPrefixLength;
151
+ if (safeLen > 0) {
152
+ this.emitText(this.textBuffer.slice(0, safeLen));
153
+ this.textBuffer = this.textBuffer.slice(safeLen);
154
+ }
155
+ }
156
+
157
+ private processInsideThinking(): void {
158
+ const endPos = this.textBuffer.indexOf(this.activeEndTag);
159
+ if (endPos !== -1) {
160
+ if (endPos > 0) this.emitThinking(this.textBuffer.slice(0, endPos));
161
+ if (this.thinkingBlockIndex !== null) {
162
+ const block = this.output.content[
163
+ this.thinkingBlockIndex
164
+ ] as ThinkingContent;
165
+ this.stream.push({
166
+ type: "thinking_end",
167
+ contentIndex: this.thinkingBlockIndex,
168
+ content: block.thinking,
169
+ partial: this.output,
170
+ });
171
+ }
172
+ this.textBuffer = this.textBuffer.slice(
173
+ endPos + this.activeEndTag.length,
174
+ );
175
+ this.inThinking = false;
176
+ this.thinkingBlockIndex = null;
177
+ this.textBlockIndex = null;
178
+ if (this.textBuffer.startsWith("\n\n"))
179
+ this.textBuffer = this.textBuffer.slice(2);
180
+ return;
181
+ }
182
+
183
+ // Buffer might end with a partial close tag
184
+ const trailingPrefixLength = getTrailingPossibleTagPrefixLength(
185
+ this.textBuffer,
186
+ this.activeEndTag,
187
+ );
188
+ const safeLen = this.textBuffer.length - trailingPrefixLength;
189
+ if (safeLen > 0) {
190
+ this.emitThinking(this.textBuffer.slice(0, safeLen));
191
+ this.textBuffer = this.textBuffer.slice(safeLen);
192
+ }
193
+ }
194
+
195
+ private emitText(text: string): void {
196
+ if (!text) return;
197
+ if (this.textBlockIndex === null) {
198
+ this.textBlockIndex = this.output.content.length;
199
+ this.output.content.push({ type: "text", text: "" } as TextContent);
200
+ this.stream.push({
201
+ type: "text_start",
202
+ contentIndex: this.textBlockIndex,
203
+ partial: this.output,
204
+ });
205
+ }
206
+ const block = this.output.content[this.textBlockIndex] as TextContent;
207
+ block.text += text;
208
+ this.stream.push({
209
+ type: "text_delta",
210
+ contentIndex: this.textBlockIndex,
211
+ delta: text,
212
+ partial: this.output,
213
+ });
214
+ }
215
+
216
+ private emitThinking(thinking: string): void {
217
+ if (thinking.length === 0) return;
218
+ if (this.thinkingBlockIndex === null) {
219
+ if (this.textBlockIndex === null) {
220
+ this.thinkingBlockIndex = this.output.content.length;
221
+ this.output.content.push({
222
+ type: "thinking",
223
+ thinking: "",
224
+ } as ThinkingContent);
225
+ } else {
226
+ // Insert thinking block before the existing text block
227
+ this.thinkingBlockIndex = this.textBlockIndex;
228
+ this.output.content.splice(this.thinkingBlockIndex, 0, {
229
+ type: "thinking",
230
+ thinking: "",
231
+ } as ThinkingContent);
232
+ this.textBlockIndex = this.textBlockIndex + 1;
233
+ }
234
+ this.stream.push({
235
+ type: "thinking_start",
236
+ contentIndex: this.thinkingBlockIndex,
237
+ partial: this.output,
238
+ });
239
+ }
240
+ const block = this.output.content[
241
+ this.thinkingBlockIndex
242
+ ] as ThinkingContent;
243
+ block.thinking += thinking;
244
+ this.stream.push({
245
+ type: "thinking_delta",
246
+ contentIndex: this.thinkingBlockIndex,
247
+ delta: thinking,
248
+ partial: this.output,
249
+ });
250
+ }
251
+ }