@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.
- package/bin/mipham.ts +48 -7
- package/package.json +2 -2
- package/src/agent/agent-context.ts +8 -1
- package/src/agent/effectiveness-tracker.ts +16 -2
- package/src/agent/sub-agent.ts +25 -17
- package/src/core/autocomplete.ts +30 -2
- package/src/core/context.ts +5 -1
- package/src/core/dream-engine.ts +17 -2
- package/src/core/error-signature-db.ts +7 -2
- package/src/core/memory/memory-manager.ts +14 -6
- package/src/core/permission-classifier.ts +17 -5
- package/src/core/rule-engine.ts +27 -2
- package/src/core/self-critique.ts +15 -3
- package/src/daemon/launch.ts +69 -2
- package/src/i18n-core/locales/en-US.json +81 -141
- package/src/i18n-core/locales/zh-CN.json +81 -141
- package/src/providers/anthropic.ts +147 -133
- package/src/providers/fetch-utils.ts +12 -29
- package/src/providers/openai-compat.ts +121 -106
- package/src/shared/arg-validation.ts +11 -1
- package/src/shared/package-info.ts +36 -1
- package/src/shared/update.ts +290 -146
- package/src/ui/commands.ts +19 -5
- package/src/ui/graft-status.tsx +35 -6
- package/src/ui/input.tsx +7 -2
|
@@ -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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
219
|
+
case 'content_block_delta': {
|
|
220
|
+
const delta = event.delta
|
|
221
|
+
if (!delta) continue
|
|
211
222
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
223
|
+
if (delta.type === 'text_delta' && delta.text) {
|
|
224
|
+
yield { type: 'text', content: delta.text }
|
|
225
|
+
}
|
|
215
226
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
227
|
+
if (delta.type === 'thinking_delta' && delta.text) {
|
|
228
|
+
yield { type: 'thinking', thinking: delta.text }
|
|
229
|
+
}
|
|
219
230
|
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
260
|
+
toolUse: {
|
|
261
|
+
type: 'tool_use',
|
|
262
|
+
id: currentToolId,
|
|
263
|
+
name: currentToolName,
|
|
264
|
+
input: parsedInput,
|
|
265
|
+
},
|
|
266
|
+
}
|
|
255
267
|
}
|
|
256
|
-
}
|
|
257
268
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
269
|
+
// Reset accumulator
|
|
270
|
+
currentToolName = ''
|
|
271
|
+
currentToolId = ''
|
|
272
|
+
accumulatedToolInput = ''
|
|
273
|
+
}
|
|
274
|
+
break
|
|
262
275
|
}
|
|
263
|
-
break
|
|
264
|
-
}
|
|
265
276
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
300
|
+
case 'message_stop': {
|
|
301
|
+
sawTerminalEvent = true
|
|
302
|
+
yield truncated ? { type: 'stop', truncated: true } : { type: 'stop' }
|
|
303
|
+
return
|
|
304
|
+
}
|
|
294
305
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
81
|
-
)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
144
|
+
if (!choice) continue
|
|
133
145
|
|
|
134
|
-
|
|
146
|
+
const delta = choice.delta
|
|
135
147
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
161
|
+
pendingToolCalls.set(idx, pending)
|
|
162
|
+
}
|
|
150
163
|
}
|
|
151
|
-
}
|
|
152
164
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
165
|
+
if (delta?.content) {
|
|
166
|
+
yield { type: 'text', content: delta.content }
|
|
167
|
+
}
|
|
156
168
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
169
|
+
if (delta?.reasoning_content) {
|
|
170
|
+
reasoningContent += delta.reasoning_content
|
|
171
|
+
}
|
|
160
172
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
190
|
+
if (choice.finish_reason === 'stop') {
|
|
191
|
+
yield { type: 'stop', reasoning_content: reasoningContent }
|
|
192
|
+
}
|
|
181
193
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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[]> {
|