@miphamai/cli 0.85.5 → 0.85.6

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.
@@ -132,6 +132,9 @@ export class AnthropicProvider implements ProviderInstance {
132
132
  'anthropic-beta': 'prompt-caching-2024-07-31',
133
133
  },
134
134
  body: JSON.stringify(body),
135
+ // Same as `openai-compat`: without this the caller's signal never reaches
136
+ // the transport, and every per-call cancellation budget is decorative.
137
+ signal: req.signal,
135
138
  })
136
139
 
137
140
  if (!response.ok) {
@@ -153,162 +156,173 @@ export class AnthropicProvider implements ProviderInstance {
153
156
  // passes aren't mistaken for a stalled connection.
154
157
  const STREAM_READ_TIMEOUT_MS = streamIdleTimeoutMs(req.effort)
155
158
 
156
- while (true) {
157
- let readResult: Awaited<ReturnType<typeof reader.read>>
158
- let idleTimer: ReturnType<typeof setTimeout> | undefined
159
- try {
160
- readResult = await Promise.race([
161
- reader.read(),
162
- new Promise<never>((_, reject) => {
163
- idleTimer = setTimeout(
164
- () =>
165
- reject(
166
- new Error(
167
- `Stream read timeout — no data for ${Math.round(STREAM_READ_TIMEOUT_MS / 1000)}s`,
168
- ),
169
- ),
170
- STREAM_READ_TIMEOUT_MS,
171
- )
172
- }),
173
- ])
174
- } catch (err) {
175
- yield { type: 'error', error: `Stream stalled: ${String(err)}` }
176
- return
177
- } finally {
178
- if (idleTimer) clearTimeout(idleTimer)
179
- }
180
- const { done, value } = readResult
181
- if (done) break
182
-
183
- buffer += decoder.decode(value, { stream: true })
184
- const lines = buffer.split('\n')
185
- buffer = lines.pop() || ''
186
-
187
- for (const line of lines) {
188
- const trimmed = line.trim()
189
- if (!trimmed || !trimmed.startsWith('data: ')) continue
190
- const data = trimmed.slice(6)
191
-
159
+ // The read loop and the trailing stop share one reader, and that reader owns
160
+ // the connection. `engine.ts` breaks out of this generator on the ordinary
161
+ // `stop` chunk, and a sub-agent throws mid-stream on abort — both call
162
+ // `.return()`, which unwinds through here. Without this, a turn that ends
163
+ // normally (or is abandoned) leaves the body unread and uncancelled, so the
164
+ // socket can't be reused. `cancel()` on an already-errored stream rejects, and
165
+ // on a closed one is a no-op — the catch covers the first.
166
+ try {
167
+ while (true) {
168
+ let readResult: Awaited<ReturnType<typeof reader.read>>
169
+ let idleTimer: ReturnType<typeof setTimeout> | undefined
192
170
  try {
193
- const event = JSON.parse(data) as AnthropicSSEEvent
194
-
195
- switch (event.type) {
196
- case 'content_block_start': {
197
- const cb = event.content_block
198
- if (!cb) continue
199
-
200
- if (cb.type === 'tool_use') {
201
- currentToolName = cb.name || ''
202
- currentToolId = cb.id || ''
203
- accumulatedToolInput = ''
171
+ readResult = await Promise.race([
172
+ reader.read(),
173
+ new Promise<never>((_, reject) => {
174
+ idleTimer = setTimeout(
175
+ () =>
176
+ reject(
177
+ new Error(
178
+ `Stream read timeout — no data for ${Math.round(STREAM_READ_TIMEOUT_MS / 1000)}s`,
179
+ ),
180
+ ),
181
+ STREAM_READ_TIMEOUT_MS,
182
+ )
183
+ }),
184
+ ])
185
+ } catch (err) {
186
+ yield { type: 'error', error: `Stream stalled: ${String(err)}` }
187
+ return
188
+ } finally {
189
+ if (idleTimer) clearTimeout(idleTimer)
190
+ }
191
+ const { done, value } = readResult
192
+ if (done) break
193
+
194
+ buffer += decoder.decode(value, { stream: true })
195
+ const lines = buffer.split('\n')
196
+ buffer = lines.pop() || ''
197
+
198
+ for (const line of lines) {
199
+ const trimmed = line.trim()
200
+ if (!trimmed || !trimmed.startsWith('data: ')) continue
201
+ const data = trimmed.slice(6)
202
+
203
+ try {
204
+ const event = JSON.parse(data) as AnthropicSSEEvent
205
+
206
+ switch (event.type) {
207
+ case 'content_block_start': {
208
+ const cb = event.content_block
209
+ if (!cb) continue
210
+
211
+ if (cb.type === 'tool_use') {
212
+ currentToolName = cb.name || ''
213
+ currentToolId = cb.id || ''
214
+ accumulatedToolInput = ''
215
+ }
216
+ break
204
217
  }
205
- break
206
- }
207
218
 
208
- case 'content_block_delta': {
209
- const delta = event.delta
210
- if (!delta) continue
219
+ case 'content_block_delta': {
220
+ const delta = event.delta
221
+ if (!delta) continue
211
222
 
212
- if (delta.type === 'text_delta' && delta.text) {
213
- yield { type: 'text', content: delta.text }
214
- }
223
+ if (delta.type === 'text_delta' && delta.text) {
224
+ yield { type: 'text', content: delta.text }
225
+ }
215
226
 
216
- if (delta.type === 'thinking_delta' && delta.text) {
217
- yield { type: 'thinking', thinking: delta.text }
218
- }
227
+ if (delta.type === 'thinking_delta' && delta.text) {
228
+ yield { type: 'thinking', thinking: delta.text }
229
+ }
219
230
 
220
- if (delta.type === 'input_json_delta' && delta.partial_json) {
221
- accumulatedToolInput += delta.partial_json
231
+ if (delta.type === 'input_json_delta' && delta.partial_json) {
232
+ accumulatedToolInput += delta.partial_json
233
+ }
234
+ break
222
235
  }
223
- break
224
- }
225
236
 
226
- case 'content_block_stop': {
227
- // 此刻还无从得知本轮是否被截断 —— `stop_reason` 要到后面的
228
- // `message_delta` 才到(见下方同名分支)。所以被截断的 `tool_use`
229
- // 在这里已经发出去了;openai-compat 那条路上「截断即丢弃未完成的
230
- // tool_call」的处置,这里结构上做不到(它的 finish_reason 与
231
- // tool_calls 落在同一个响应体里)。**这是有意的不对称,不是漏做**:
232
- // 要在这里丢弃,就得把 `tool_use` 缓冲到 `message_stop` 再发 ——
233
- // 那是一次行为变更,不属本次范围。
234
- if (currentToolId && currentToolName && accumulatedToolInput) {
235
- // A replayed block carries the id it was first sent with, so the
236
- // id is what tells a second call apart from the same call twice.
237
- if (!emittedToolIds.has(currentToolId)) {
238
- emittedToolIds.add(currentToolId)
239
-
240
- let parsedInput: Record<string, unknown> = {}
241
- try {
242
- parsedInput = JSON.parse(accumulatedToolInput)
243
- } catch {
244
- parsedInput = { _raw: accumulatedToolInput }
245
- }
246
-
247
- yield {
248
- type: 'tool_use',
249
- toolUse: {
237
+ case 'content_block_stop': {
238
+ // 此刻还无从得知本轮是否被截断 —— `stop_reason` 要到后面的
239
+ // `message_delta` 才到(见下方同名分支)。所以被截断的 `tool_use`
240
+ // 在这里已经发出去了;openai-compat 那条路上「截断即丢弃未完成的
241
+ // tool_call」的处置,这里结构上做不到(它的 finish_reason 与
242
+ // tool_calls 落在同一个响应体里)。**这是有意的不对称,不是漏做**:
243
+ // 要在这里丢弃,就得把 `tool_use` 缓冲到 `message_stop` 再发 ——
244
+ // 那是一次行为变更,不属本次范围。
245
+ if (currentToolId && currentToolName && accumulatedToolInput) {
246
+ // A replayed block carries the id it was first sent with, so the
247
+ // id is what tells a second call apart from the same call twice.
248
+ if (!emittedToolIds.has(currentToolId)) {
249
+ emittedToolIds.add(currentToolId)
250
+
251
+ let parsedInput: Record<string, unknown> = {}
252
+ try {
253
+ parsedInput = JSON.parse(accumulatedToolInput)
254
+ } catch {
255
+ parsedInput = { _raw: accumulatedToolInput }
256
+ }
257
+
258
+ yield {
250
259
  type: 'tool_use',
251
- id: currentToolId,
252
- name: currentToolName,
253
- input: parsedInput,
254
- },
260
+ toolUse: {
261
+ type: 'tool_use',
262
+ id: currentToolId,
263
+ name: currentToolName,
264
+ input: parsedInput,
265
+ },
266
+ }
255
267
  }
256
- }
257
268
 
258
- // Reset accumulator
259
- currentToolName = ''
260
- currentToolId = ''
261
- accumulatedToolInput = ''
269
+ // Reset accumulator
270
+ currentToolName = ''
271
+ currentToolId = ''
272
+ accumulatedToolInput = ''
273
+ }
274
+ break
262
275
  }
263
- break
264
- }
265
276
 
266
- case 'message_delta': {
267
- // Capture token usage for accurate cost tracking
268
- if (event.usage) {
269
- yield {
270
- type: 'usage',
271
- inputTokens: event.usage.input_tokens,
272
- outputTokens: event.usage.output_tokens,
277
+ case 'message_delta': {
278
+ // Capture token usage for accurate cost tracking
279
+ if (event.usage) {
280
+ yield {
281
+ type: 'usage',
282
+ inputTokens: event.usage.input_tokens,
283
+ outputTokens: event.usage.output_tokens,
284
+ }
273
285
  }
286
+ // Contains stop_reason; also handles late input_json_delta
287
+ if (event.delta?.type === 'input_json_delta' && event.delta.partial_json) {
288
+ accumulatedToolInput += event.delta.partial_json
289
+ }
290
+ // `max_tokens` means the turn hit the output ceiling. Without this the
291
+ // truncation is indistinguishable from `end_turn`: both arrive here and
292
+ // the terminal stop below looks the same either way.
293
+ const stopReason = event.delta?.stop_reason
294
+ if (stopReason === 'max_tokens') {
295
+ truncated = true
296
+ }
297
+ break
274
298
  }
275
- // Contains stop_reason; also handles late input_json_delta
276
- if (event.delta?.type === 'input_json_delta' && event.delta.partial_json) {
277
- accumulatedToolInput += event.delta.partial_json
278
- }
279
- // `max_tokens` means the turn hit the output ceiling. Without this the
280
- // truncation is indistinguishable from `end_turn`: both arrive here and
281
- // the terminal stop below looks the same either way.
282
- const stopReason = event.delta?.stop_reason
283
- if (stopReason === 'max_tokens') {
284
- truncated = true
285
- }
286
- break
287
- }
288
299
 
289
- case 'message_stop': {
290
- sawTerminalEvent = true
291
- yield truncated ? { type: 'stop', truncated: true } : { type: 'stop' }
292
- return
293
- }
300
+ case 'message_stop': {
301
+ sawTerminalEvent = true
302
+ yield truncated ? { type: 'stop', truncated: true } : { type: 'stop' }
303
+ return
304
+ }
294
305
 
295
- case 'error': {
296
- yield { type: 'error', error: event.error?.message || 'Unknown Anthropic error' }
297
- return
306
+ case 'error': {
307
+ yield { type: 'error', error: event.error?.message || 'Unknown Anthropic error' }
308
+ return
309
+ }
298
310
  }
311
+ } catch {
312
+ // Skip unparseable SSE events
299
313
  }
300
- } catch {
301
- // Skip unparseable SSE events
302
314
  }
303
315
  }
304
- }
305
316
 
306
- // The stream ran out without `message_stop`. Whatever stopped it, the turn is
307
- // incomplete — and this is the only place that knows, because a cleanly
308
- // closed connection and a finished response are otherwise the same stream.
309
- if (!sawTerminalEvent) truncated = true
317
+ // The stream ran out without `message_stop`. Whatever stopped it, the turn is
318
+ // incomplete — and this is the only place that knows, because a cleanly
319
+ // closed connection and a finished response are otherwise the same stream.
320
+ if (!sawTerminalEvent) truncated = true
310
321
 
311
- yield truncated ? { type: 'stop', truncated: true } : { type: 'stop' }
322
+ yield truncated ? { type: 'stop', truncated: true } : { type: 'stop' }
323
+ } finally {
324
+ await reader.cancel().catch(() => {})
325
+ }
312
326
  }
313
327
 
314
328
  async listModels(): Promise<ModelInfo[]> {
@@ -96,7 +96,13 @@ export async function fetchWithRetry(
96
96
  timedOut = true
97
97
  controller.abort()
98
98
  }, timeout)
99
- const signal = init.signal ? anySignal([init.signal, controller.signal]) : controller.signal
99
+ // `AbortSignal.any` (node ≥22 / bun ≥1.2, both in `engines`) instead of a
100
+ // hand-rolled combiner: it keeps a *weak* reference to the source signals, so
101
+ // the combination stays live for the reader without pinning the caller's
102
+ // signal — which is what the hand-rolled version had to trade away.
103
+ const signal = init.signal
104
+ ? AbortSignal.any([init.signal, controller.signal])
105
+ : controller.signal
100
106
 
101
107
  try {
102
108
  const response = await fetch(url, { ...init, signal })
@@ -123,14 +129,11 @@ export async function fetchWithRetry(
123
129
  await sleep(baseDelay * Math.pow(2, attempt))
124
130
  } finally {
125
131
  clearTimeout(timer)
126
- // Clean up combined signal if we created one
127
- if (init.signal) {
128
- try {
129
- controller.abort()
130
- } catch {
131
- /* best effort */
132
- }
133
- }
132
+ // Nothing to release: the combination is natively managed. Do NOT abort
133
+ // anything here — `fetch` holds that signal and the caller reads the body
134
+ // *after* we return, so aborting it at this point errored every response at
135
+ // the headers (measured on Node: the next read throws AbortError; Bun
136
+ // happens to tolerate it, which is why a Bun-only run never showed this).
134
137
  }
135
138
  }
136
139
 
@@ -163,23 +166,3 @@ export function streamIdleTimeoutMs(effort?: string): number {
163
166
  const multiplier = effort ? (EFFORT_TIMEOUT_MULTIPLIER[effort] ?? 1) : 1
164
167
  return STREAM_IDLE_TIMEOUT_BASE_MS * multiplier
165
168
  }
166
-
167
- /**
168
- * Combine multiple AbortSignals into one — any signal aborting
169
- * triggers the combined signal.
170
- */
171
- function anySignal(signals: AbortSignal[]): AbortSignal {
172
- const controller = new AbortController()
173
- const onAbort = () => {
174
- controller.abort()
175
- for (const s of signals) s.removeEventListener('abort', onAbort)
176
- }
177
- for (const s of signals) {
178
- if (s.aborted) {
179
- controller.abort()
180
- return controller.signal
181
- }
182
- s.addEventListener('abort', onAbort)
183
- }
184
- return controller.signal
185
- }
@@ -38,6 +38,10 @@ export class OpenAICompatProvider implements ProviderInstance {
38
38
  Authorization: `Bearer ${apiKey}`,
39
39
  },
40
40
  body: JSON.stringify(body),
41
+ // The caller's cancellation (e.g. `self-critique`'s 2s budget) has to reach
42
+ // the transport, or it is a no-op: `fetch-utils` only combines a caller
43
+ // signal when this field exists.
44
+ signal: req.signal,
41
45
  })
42
46
 
43
47
  if (!response.ok) {
@@ -63,136 +67,147 @@ export class OpenAICompatProvider implements ProviderInstance {
63
67
  // (DeepSeek V4 / reasoning models) aren't mistaken for a stalled connection.
64
68
  const STREAM_READ_TIMEOUT_MS = streamIdleTimeoutMs(req.effort)
65
69
 
66
- while (true) {
67
- let readResult: Awaited<ReturnType<typeof reader.read>>
68
- let idleTimer: ReturnType<typeof setTimeout> | undefined
69
- try {
70
- readResult = await Promise.race([
71
- reader.read(),
72
- new Promise<never>((_, reject) => {
73
- idleTimer = setTimeout(
74
- () =>
75
- reject(
76
- new Error(
77
- `Stream read timeout — no data for ${Math.round(STREAM_READ_TIMEOUT_MS / 1000)}s`,
70
+ // The read loop and the fallback stop share one reader, and that reader owns
71
+ // the connection. `engine.ts` breaks out of this generator on the ordinary
72
+ // `stop` chunk, and a sub-agent throws mid-stream on abort — both call
73
+ // `.return()`, which unwinds through here. Without this, a turn that ends
74
+ // normally (or is abandoned) leaves the body unread and uncancelled, so the
75
+ // socket can't be reused. `cancel()` on an already-errored stream rejects, and
76
+ // on a closed one is a no-op — the catch covers the first.
77
+ try {
78
+ while (true) {
79
+ let readResult: Awaited<ReturnType<typeof reader.read>>
80
+ let idleTimer: ReturnType<typeof setTimeout> | undefined
81
+ try {
82
+ readResult = await Promise.race([
83
+ reader.read(),
84
+ new Promise<never>((_, reject) => {
85
+ idleTimer = setTimeout(
86
+ () =>
87
+ reject(
88
+ new Error(
89
+ `Stream read timeout — no data for ${Math.round(STREAM_READ_TIMEOUT_MS / 1000)}s`,
90
+ ),
78
91
  ),
79
- ),
80
- STREAM_READ_TIMEOUT_MS,
81
- )
82
- }),
83
- ])
84
- } catch (err) {
85
- yield { type: 'error', error: `Stream stalled: ${String(err)}` }
86
- return
87
- } finally {
88
- if (idleTimer) clearTimeout(idleTimer)
89
- }
90
- const { done, value } = readResult
91
- if (done) break
92
-
93
- buffer += decoder.decode(value, { stream: true })
94
- const lines = buffer.split('\n')
95
- buffer = lines.pop() || ''
96
-
97
- for (const line of lines) {
98
- const trimmed = line.trim()
99
- if (!trimmed || !trimmed.startsWith('data: ')) continue
100
- const data = trimmed.slice(6)
101
- if (data === '[DONE]') {
102
- // Emit any pending tool calls before stopping
103
- for (const [, tc] of pendingToolCalls) {
104
- if (!tc.name) continue // drop malformed tool call (missing name)
105
- yield {
106
- type: 'tool_use',
107
- toolUse: {
92
+ STREAM_READ_TIMEOUT_MS,
93
+ )
94
+ }),
95
+ ])
96
+ } catch (err) {
97
+ yield { type: 'error', error: `Stream stalled: ${String(err)}` }
98
+ return
99
+ } finally {
100
+ if (idleTimer) clearTimeout(idleTimer)
101
+ }
102
+ const { done, value } = readResult
103
+ if (done) break
104
+
105
+ buffer += decoder.decode(value, { stream: true })
106
+ const lines = buffer.split('\n')
107
+ buffer = lines.pop() || ''
108
+
109
+ for (const line of lines) {
110
+ const trimmed = line.trim()
111
+ if (!trimmed || !trimmed.startsWith('data: ')) continue
112
+ const data = trimmed.slice(6)
113
+ if (data === '[DONE]') {
114
+ // Emit any pending tool calls before stopping
115
+ for (const [, tc] of pendingToolCalls) {
116
+ if (!tc.name) continue // drop malformed tool call (missing name)
117
+ yield {
108
118
  type: 'tool_use',
109
- id: tc.id || `call_${Date.now()}`,
110
- name: tc.name,
111
- input: this.safeParseJson(tc.arguments),
112
- },
119
+ toolUse: {
120
+ type: 'tool_use',
121
+ id: tc.id || `call_${Date.now()}`,
122
+ name: tc.name,
123
+ input: this.safeParseJson(tc.arguments),
124
+ },
125
+ }
113
126
  }
127
+ yield { type: 'stop', reasoning_content: reasoningContent }
128
+ return
114
129
  }
115
- yield { type: 'stop', reasoning_content: reasoningContent }
116
- return
117
- }
118
130
 
119
- try {
120
- const parsed = JSON.parse(data)
121
- const choice = parsed.choices?.[0]
122
-
123
- // Capture token usage when available (final chunk with stream_options.include_usage)
124
- if (parsed.usage) {
125
- yield {
126
- type: 'usage',
127
- inputTokens: parsed.usage.prompt_tokens,
128
- outputTokens: parsed.usage.completion_tokens,
131
+ try {
132
+ const parsed = JSON.parse(data)
133
+ const choice = parsed.choices?.[0]
134
+
135
+ // Capture token usage when available (final chunk with stream_options.include_usage)
136
+ if (parsed.usage) {
137
+ yield {
138
+ type: 'usage',
139
+ inputTokens: parsed.usage.prompt_tokens,
140
+ outputTokens: parsed.usage.completion_tokens,
141
+ }
129
142
  }
130
- }
131
143
 
132
- if (!choice) continue
144
+ if (!choice) continue
133
145
 
134
- const delta = choice.delta
146
+ const delta = choice.delta
135
147
 
136
- if (delta?.tool_calls) {
137
- for (const tc of delta.tool_calls) {
138
- const idx = tc.index ?? 0
139
- const pending = pendingToolCalls.get(idx) || {
140
- id: '',
141
- name: '',
142
- arguments: '',
143
- }
148
+ if (delta?.tool_calls) {
149
+ for (const tc of delta.tool_calls) {
150
+ const idx = tc.index ?? 0
151
+ const pending = pendingToolCalls.get(idx) || {
152
+ id: '',
153
+ name: '',
154
+ arguments: '',
155
+ }
144
156
 
145
- if (tc.id) pending.id = tc.id
146
- if (tc.function?.name) pending.name = tc.function.name
147
- if (tc.function?.arguments) pending.arguments += tc.function.arguments
157
+ if (tc.id) pending.id = tc.id
158
+ if (tc.function?.name) pending.name = tc.function.name
159
+ if (tc.function?.arguments) pending.arguments += tc.function.arguments
148
160
 
149
- pendingToolCalls.set(idx, pending)
161
+ pendingToolCalls.set(idx, pending)
162
+ }
150
163
  }
151
- }
152
164
 
153
- if (delta?.content) {
154
- yield { type: 'text', content: delta.content }
155
- }
165
+ if (delta?.content) {
166
+ yield { type: 'text', content: delta.content }
167
+ }
156
168
 
157
- if (delta?.reasoning_content) {
158
- reasoningContent += delta.reasoning_content
159
- }
169
+ if (delta?.reasoning_content) {
170
+ reasoningContent += delta.reasoning_content
171
+ }
160
172
 
161
- if (choice.finish_reason === 'tool_calls') {
162
- // Emit fully accumulated tool calls
163
- for (const [, tc] of pendingToolCalls) {
164
- if (!tc.name) continue // drop malformed tool call (missing name)
165
- yield {
166
- type: 'tool_use',
167
- toolUse: {
173
+ if (choice.finish_reason === 'tool_calls') {
174
+ // Emit fully accumulated tool calls
175
+ for (const [, tc] of pendingToolCalls) {
176
+ if (!tc.name) continue // drop malformed tool call (missing name)
177
+ yield {
168
178
  type: 'tool_use',
169
- id: tc.id || `call_${Date.now()}`,
170
- name: tc.name,
171
- input: this.safeParseJson(tc.arguments),
172
- },
179
+ toolUse: {
180
+ type: 'tool_use',
181
+ id: tc.id || `call_${Date.now()}`,
182
+ name: tc.name,
183
+ input: this.safeParseJson(tc.arguments),
184
+ },
185
+ }
173
186
  }
187
+ pendingToolCalls.clear()
174
188
  }
175
- pendingToolCalls.clear()
176
- }
177
189
 
178
- if (choice.finish_reason === 'stop') {
179
- yield { type: 'stop', reasoning_content: reasoningContent }
180
- }
190
+ if (choice.finish_reason === 'stop') {
191
+ yield { type: 'stop', reasoning_content: reasoningContent }
192
+ }
181
193
 
182
- if (choice.finish_reason === 'length') {
183
- // Truncated: the accumulated tool calls were cut off mid-arguments, so
184
- // their JSON is incomplete. Drop them rather than dispatching a broken
185
- // call, and clear the map so the `[DONE]` handler can't emit them either.
186
- pendingToolCalls.clear()
187
- yield { type: 'stop', reasoning_content: reasoningContent, truncated: true }
194
+ if (choice.finish_reason === 'length') {
195
+ // Truncated: the accumulated tool calls were cut off mid-arguments, so
196
+ // their JSON is incomplete. Drop them rather than dispatching a broken
197
+ // call, and clear the map so the `[DONE]` handler can't emit them either.
198
+ pendingToolCalls.clear()
199
+ yield { type: 'stop', reasoning_content: reasoningContent, truncated: true }
200
+ }
201
+ } catch {
202
+ // skip unparseable chunks
188
203
  }
189
- } catch {
190
- // skip unparseable chunks
191
204
  }
192
205
  }
193
- }
194
206
 
195
- yield { type: 'stop', reasoning_content: reasoningContent }
207
+ yield { type: 'stop', reasoning_content: reasoningContent }
208
+ } finally {
209
+ await reader.cancel().catch(() => {})
210
+ }
196
211
  }
197
212
 
198
213
  async listModels(): Promise<ModelInfo[]> {