@alexeiled/pi-model-router 0.5.0 → 0.5.2
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/CHANGELOG.md +23 -1
- package/README.md +22 -4
- package/extensions/classifier.ts +93 -0
- package/extensions/commands.ts +56 -31
- package/extensions/config.ts +65 -46
- package/extensions/constants.ts +0 -46
- package/extensions/context.ts +58 -0
- package/extensions/index.ts +105 -134
- package/extensions/provider.ts +160 -155
- package/extensions/routing.ts +17 -162
- package/extensions/state.ts +90 -27
- package/extensions/types.ts +73 -45
- package/extensions/ui.ts +14 -28
- package/package.json +6 -5
package/extensions/provider.ts
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
1
2
|
import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
|
|
2
3
|
import {
|
|
3
4
|
type Api,
|
|
4
5
|
type AssistantMessage,
|
|
6
|
+
type AssistantMessageEvent,
|
|
5
7
|
type AssistantMessageEventStream,
|
|
6
8
|
type Context,
|
|
7
9
|
createAssistantMessageEventStream,
|
|
8
10
|
type Model,
|
|
9
|
-
normalizeContext,
|
|
10
11
|
type SimpleStreamOptions,
|
|
11
|
-
type TranscriptContext,
|
|
12
12
|
} from '@earendil-works/pi-ai';
|
|
13
|
-
import { streamSimple } from '@earendil-works/pi-ai/compat';
|
|
14
13
|
import type {
|
|
15
14
|
ExtensionAPI,
|
|
16
15
|
ExtensionContext,
|
|
17
16
|
} from '@earendil-works/pi-coding-agent';
|
|
17
|
+
import { runClassifier } from './classifier';
|
|
18
18
|
import {
|
|
19
19
|
clampThinkingLevel,
|
|
20
20
|
collectProfileThinkingLevels,
|
|
@@ -25,13 +25,14 @@ import {
|
|
|
25
25
|
resolveContextWindow,
|
|
26
26
|
resolveMaxTokens,
|
|
27
27
|
} from './config';
|
|
28
|
+
import { DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_TOKENS } from './constants';
|
|
29
|
+
import { extractTextFromContent, hasImageAttachment } from './context';
|
|
28
30
|
import {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
} from './constants';
|
|
31
|
+
buildRoutingDecision,
|
|
32
|
+
decideRouting,
|
|
33
|
+
phaseForTier,
|
|
34
|
+
resolveAvailableTier,
|
|
35
|
+
} from './routing';
|
|
35
36
|
import type {
|
|
36
37
|
RouterConfig,
|
|
37
38
|
RouterPinByProfile,
|
|
@@ -44,17 +45,6 @@ const REGISTRY_WAIT_TIMEOUT_MS = 5000;
|
|
|
44
45
|
const REGISTRY_WAIT_INITIAL_DELAY_MS = 50;
|
|
45
46
|
const REGISTRY_WAIT_MAX_DELAY_MS = 500;
|
|
46
47
|
|
|
47
|
-
type ProviderAwareRegistry = ExtensionContext['modelRegistry'] & {
|
|
48
|
-
getRegisteredProviderConfig?: (provider: string) => {
|
|
49
|
-
api?: Api;
|
|
50
|
-
streamSimple?: (
|
|
51
|
-
model: Model<Api>,
|
|
52
|
-
context: TranscriptContext,
|
|
53
|
-
options?: SimpleStreamOptions,
|
|
54
|
-
) => AssistantMessageEventStream;
|
|
55
|
-
};
|
|
56
|
-
};
|
|
57
|
-
|
|
58
48
|
/**
|
|
59
49
|
* Wait for the model registry to become available with exponential backoff.
|
|
60
50
|
* This handles the race condition where subagents (e.g. from pi-dynamic-workflows)
|
|
@@ -67,28 +57,25 @@ export const waitForRegistry = async (
|
|
|
67
57
|
| undefined;
|
|
68
58
|
},
|
|
69
59
|
timeoutMs: number = REGISTRY_WAIT_TIMEOUT_MS,
|
|
60
|
+
signal?: AbortSignal,
|
|
70
61
|
): Promise<ExtensionContext['modelRegistry'] | undefined> => {
|
|
62
|
+
signal?.throwIfAborted();
|
|
71
63
|
if (state.currentModelRegistry) return state.currentModelRegistry;
|
|
72
64
|
|
|
73
65
|
const start = Date.now();
|
|
74
|
-
let
|
|
66
|
+
let interval = REGISTRY_WAIT_INITIAL_DELAY_MS;
|
|
75
67
|
while (Date.now() - start < timeoutMs) {
|
|
76
|
-
await
|
|
68
|
+
await delay(
|
|
69
|
+
Math.min(interval, timeoutMs - (Date.now() - start)),
|
|
70
|
+
undefined,
|
|
71
|
+
{ signal },
|
|
72
|
+
);
|
|
77
73
|
if (state.currentModelRegistry) return state.currentModelRegistry;
|
|
78
|
-
|
|
74
|
+
interval = Math.min(interval * 2, REGISTRY_WAIT_MAX_DELAY_MS);
|
|
79
75
|
}
|
|
80
76
|
return undefined;
|
|
81
77
|
};
|
|
82
78
|
|
|
83
|
-
import {
|
|
84
|
-
buildRoutingDecision,
|
|
85
|
-
decideRouting,
|
|
86
|
-
extractTextFromContent,
|
|
87
|
-
hasImageAttachment,
|
|
88
|
-
phaseForTier,
|
|
89
|
-
runClassifier,
|
|
90
|
-
} from './routing';
|
|
91
|
-
|
|
92
79
|
export const createErrorMessage = (
|
|
93
80
|
model: Model<Api>,
|
|
94
81
|
message: string,
|
|
@@ -120,7 +107,7 @@ const estimateTokens = (text: string): number => Math.ceil(text.length / 3);
|
|
|
120
107
|
|
|
121
108
|
/**
|
|
122
109
|
* Truncate context to fit within a target token limit by removing oldest messages.
|
|
123
|
-
*
|
|
110
|
+
* Preserves the system prompt and the complete latest user/tool turn.
|
|
124
111
|
*/
|
|
125
112
|
const truncateContext = (context: Context, limit: number): Context => {
|
|
126
113
|
const messages = [...context.messages];
|
|
@@ -139,24 +126,32 @@ const truncateContext = (context: Context, limit: number): Context => {
|
|
|
139
126
|
|
|
140
127
|
if (totalTokens <= limit) return context;
|
|
141
128
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
let
|
|
148
|
-
|
|
149
|
-
let
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
129
|
+
// Drop only complete turns. Splitting an assistant/tool-result pair corrupts transcripts.
|
|
130
|
+
// This text estimate cannot guarantee a fit for images/tools or one oversized active turn.
|
|
131
|
+
const systemMessages = messages.filter(
|
|
132
|
+
(message) => message.role === 'system',
|
|
133
|
+
);
|
|
134
|
+
let remaining = totalTokens;
|
|
135
|
+
let nextRemovableIndex = 0;
|
|
136
|
+
for (let i = 0; i < messages.length && remaining > limit; i += 1) {
|
|
137
|
+
if (messages[i]?.role !== 'user') continue;
|
|
138
|
+
while (nextRemovableIndex < i) {
|
|
139
|
+
const candidate = messages[nextRemovableIndex];
|
|
140
|
+
if (candidate?.role !== 'system') {
|
|
141
|
+
remaining -= messageTokens[nextRemovableIndex] ?? 0;
|
|
142
|
+
}
|
|
143
|
+
nextRemovableIndex += 1;
|
|
144
|
+
}
|
|
156
145
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
146
|
+
return {
|
|
147
|
+
...context,
|
|
148
|
+
messages: [
|
|
149
|
+
...systemMessages,
|
|
150
|
+
...messages
|
|
151
|
+
.slice(nextRemovableIndex)
|
|
152
|
+
.filter((message) => message.role !== 'system'),
|
|
153
|
+
],
|
|
154
|
+
};
|
|
160
155
|
};
|
|
161
156
|
|
|
162
157
|
const supportsReasoning = (
|
|
@@ -213,24 +208,29 @@ export const registerRouterProvider = (
|
|
|
213
208
|
const profileList = profileNames(state.currentConfig);
|
|
214
209
|
|
|
215
210
|
// Map profiles to their capacities
|
|
216
|
-
const modelDefinitions = profileList.
|
|
211
|
+
const modelDefinitions = profileList.flatMap((name) => {
|
|
217
212
|
const profile = state.currentConfig.profiles[name];
|
|
213
|
+
if (!profile) return [];
|
|
218
214
|
|
|
219
215
|
// Report the MAX context window and max output tokens across all tiers.
|
|
220
216
|
// The honesty check + truncateContext handles the case where the
|
|
221
217
|
// actually routed model is smaller.
|
|
222
|
-
let maxContextWindow =
|
|
223
|
-
let
|
|
218
|
+
let maxContextWindow = 0;
|
|
219
|
+
let maxOutputTokens = 0;
|
|
224
220
|
for (const tier of ROUTER_TIERS) {
|
|
225
221
|
if (!profile[tier]) continue;
|
|
226
|
-
const
|
|
222
|
+
const contextWindow = resolveContextWindow(
|
|
227
223
|
tier,
|
|
228
224
|
profile,
|
|
229
225
|
state.currentModelRegistry,
|
|
230
226
|
);
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
227
|
+
const maxTokens = resolveMaxTokens(
|
|
228
|
+
tier,
|
|
229
|
+
profile,
|
|
230
|
+
state.currentModelRegistry,
|
|
231
|
+
);
|
|
232
|
+
if (contextWindow > maxContextWindow) maxContextWindow = contextWindow;
|
|
233
|
+
if (maxTokens > maxOutputTokens) maxOutputTokens = maxTokens;
|
|
234
234
|
}
|
|
235
235
|
|
|
236
236
|
const hasReasoning = supportsReasoning(profile, state.currentModelRegistry);
|
|
@@ -245,21 +245,21 @@ export const registerRouterProvider = (
|
|
|
245
245
|
if (Object.keys(map).length > 0) thinkingLevelMap = map;
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
-
return
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
248
|
+
return [
|
|
249
|
+
{
|
|
250
|
+
id: name,
|
|
251
|
+
name: `Router ${name}`,
|
|
252
|
+
reasoning: hasReasoning,
|
|
253
|
+
...(thinkingLevelMap ? { thinkingLevelMap } : {}),
|
|
254
|
+
input: ['text', 'image'] satisfies ('text' | 'image')[],
|
|
255
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
256
|
+
contextWindow: maxContextWindow || DEFAULT_CONTEXT_WINDOW,
|
|
257
|
+
maxTokens: maxOutputTokens || DEFAULT_MAX_TOKENS,
|
|
258
|
+
},
|
|
259
|
+
];
|
|
258
260
|
});
|
|
259
261
|
|
|
260
|
-
const modelsKey = modelDefinitions
|
|
261
|
-
.map((m) => `${m.id}:${m.contextWindow}:${m.maxTokens}:${m.reasoning}`)
|
|
262
|
-
.join(',');
|
|
262
|
+
const modelsKey = JSON.stringify(modelDefinitions);
|
|
263
263
|
if (state.lastRegisteredModels === modelsKey) return;
|
|
264
264
|
|
|
265
265
|
pi.registerProvider('router', {
|
|
@@ -274,7 +274,8 @@ export const registerRouterProvider = (
|
|
|
274
274
|
): AssistantMessageEventStream {
|
|
275
275
|
const stream = createAssistantMessageEventStream();
|
|
276
276
|
|
|
277
|
-
(async () => {
|
|
277
|
+
void (async () => {
|
|
278
|
+
let partialMessage: AssistantMessage | undefined;
|
|
278
279
|
try {
|
|
279
280
|
// Wait for the router to be fully initialized (session_start sets currentModelRegistry).
|
|
280
281
|
// This handles the race where subagents (e.g. from pi-dynamic-workflows) invoke
|
|
@@ -282,6 +283,7 @@ export const registerRouterProvider = (
|
|
|
282
283
|
const registry = await waitForRegistry(
|
|
283
284
|
state,
|
|
284
285
|
state.registryTimeoutMs,
|
|
286
|
+
options?.signal,
|
|
285
287
|
);
|
|
286
288
|
if (!registry) {
|
|
287
289
|
throw new Error(
|
|
@@ -325,15 +327,20 @@ export const registerRouterProvider = (
|
|
|
325
327
|
state.currentConfig.classifierModel.model,
|
|
326
328
|
registry,
|
|
327
329
|
context,
|
|
328
|
-
state.lastDecision?.
|
|
330
|
+
state.lastDecision?.profile === model.id
|
|
331
|
+
? state.lastDecision.phase
|
|
332
|
+
: undefined,
|
|
329
333
|
state.currentConfig.classifierModel.thinking,
|
|
334
|
+
options?.signal,
|
|
330
335
|
);
|
|
336
|
+
options?.signal?.throwIfAborted();
|
|
331
337
|
if (classifierResult) {
|
|
338
|
+
const tier = resolveAvailableTier(profile, classifierResult.tier);
|
|
332
339
|
decision = buildRoutingDecision(
|
|
333
340
|
model.id,
|
|
334
341
|
profile,
|
|
335
|
-
|
|
336
|
-
phaseForTier(
|
|
342
|
+
tier,
|
|
343
|
+
phaseForTier(tier),
|
|
337
344
|
`Classifier: ${classifierResult.reasoning}`,
|
|
338
345
|
state.thinkingByProfile[model.id],
|
|
339
346
|
true,
|
|
@@ -446,14 +453,16 @@ export const registerRouterProvider = (
|
|
|
446
453
|
if (imageAttached) {
|
|
447
454
|
modelsToTry = modelsToTry.filter(checkModelSupportsImage);
|
|
448
455
|
if (modelsToTry.length === 0) {
|
|
449
|
-
|
|
456
|
+
throw new Error(
|
|
457
|
+
'No configured model supports image attachments.',
|
|
458
|
+
);
|
|
450
459
|
}
|
|
451
460
|
}
|
|
452
461
|
let lastError: unknown;
|
|
453
462
|
let success = false;
|
|
454
463
|
|
|
455
|
-
for (
|
|
456
|
-
|
|
464
|
+
for (const [i, modelRef] of modelsToTry.entries()) {
|
|
465
|
+
options?.signal?.throwIfAborted();
|
|
457
466
|
const { provider: targetProvider, modelId: targetModelId } =
|
|
458
467
|
parseCanonicalModelRef(modelRef);
|
|
459
468
|
|
|
@@ -467,31 +476,14 @@ export const registerRouterProvider = (
|
|
|
467
476
|
continue;
|
|
468
477
|
}
|
|
469
478
|
|
|
470
|
-
|
|
471
|
-
if (!auth.ok || !hasUsableRequestAuth(auth)) {
|
|
472
|
-
lastError = new Error(
|
|
473
|
-
auth.ok
|
|
474
|
-
? `No API key or authentication headers for routed model: ${targetProvider}/${targetModelId}`
|
|
475
|
-
: `Auth failed for routed model: ${targetProvider}/${targetModelId}: ${auth.error}`,
|
|
476
|
-
);
|
|
477
|
-
continue;
|
|
478
|
-
}
|
|
479
|
-
const apiKey = auth.apiKey;
|
|
480
|
-
const headers = auth.headers;
|
|
481
|
-
const requestModel = await resolveDelegatedModel(
|
|
482
|
-
registry as unknown as RegistryWithProviderAuth,
|
|
483
|
-
targetModel,
|
|
484
|
-
);
|
|
485
|
-
|
|
479
|
+
let contentReceived = false;
|
|
486
480
|
try {
|
|
487
481
|
// HONESTY CHECK & AUTO-TRUNCATION
|
|
488
482
|
// If the picked model has a smaller context than what we reported, truncate now.
|
|
489
483
|
let effectiveContext = context;
|
|
490
|
-
const targetLimit =
|
|
491
|
-
|
|
492
|
-
profile,
|
|
493
|
-
registry,
|
|
494
|
-
);
|
|
484
|
+
const targetLimit =
|
|
485
|
+
targetModel.contextWindow ??
|
|
486
|
+
resolveContextWindow(decision.tier, profile, registry);
|
|
495
487
|
if (
|
|
496
488
|
model.contextWindow !== undefined &&
|
|
497
489
|
targetLimit < model.contextWindow
|
|
@@ -509,15 +501,15 @@ export const registerRouterProvider = (
|
|
|
509
501
|
const tierConfig = profile[decision.tier];
|
|
510
502
|
if (tierConfig?.resolvedThinkingLevels) {
|
|
511
503
|
requestedReasoning = clampThinkingLevel(
|
|
512
|
-
requestedReasoning
|
|
504
|
+
requestedReasoning,
|
|
513
505
|
tierConfig.resolvedThinkingLevels,
|
|
514
|
-
)
|
|
506
|
+
);
|
|
515
507
|
}
|
|
516
508
|
}
|
|
517
509
|
|
|
518
|
-
const delegatedReasoning =
|
|
510
|
+
const delegatedReasoning: SimpleStreamOptions['reasoning'] =
|
|
519
511
|
targetModel.reasoning && requestedReasoning !== 'off'
|
|
520
|
-
?
|
|
512
|
+
? requestedReasoning
|
|
521
513
|
: undefined;
|
|
522
514
|
|
|
523
515
|
try {
|
|
@@ -534,42 +526,42 @@ export const registerRouterProvider = (
|
|
|
534
526
|
// Stale extension context — skip non-critical UI updates.
|
|
535
527
|
}
|
|
536
528
|
|
|
537
|
-
//
|
|
538
|
-
const {
|
|
539
|
-
|
|
529
|
+
// Router credentials must not override the concrete provider's request auth.
|
|
530
|
+
const {
|
|
531
|
+
reasoning: _piReasoning,
|
|
532
|
+
apiKey: _routerKey,
|
|
533
|
+
headers: _routerHeaders,
|
|
534
|
+
env: _routerEnv,
|
|
535
|
+
...delegationOptions
|
|
536
|
+
} = options ?? {};
|
|
540
537
|
|
|
541
|
-
const delegatedOptions = {
|
|
538
|
+
const delegatedOptions: SimpleStreamOptions = {
|
|
542
539
|
...delegationOptions,
|
|
543
|
-
apiKey,
|
|
544
|
-
headers,
|
|
545
540
|
...(delegatedReasoning
|
|
546
541
|
? { reasoning: delegatedReasoning }
|
|
547
542
|
: {}),
|
|
548
543
|
};
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
);
|
|
565
|
-
|
|
566
|
-
let contentReceived = false;
|
|
544
|
+
// Pi owns request-time auth, custom/native providers, URLs and transcript normalization.
|
|
545
|
+
const delegatedStream = registry.streamSimple(
|
|
546
|
+
targetModel,
|
|
547
|
+
effectiveContext,
|
|
548
|
+
delegatedOptions,
|
|
549
|
+
);
|
|
550
|
+
let terminalReceived = false;
|
|
551
|
+
const pendingEvents: AssistantMessageEvent[] = [];
|
|
552
|
+
const recordTarget = () => {
|
|
553
|
+
decision.targetProvider = targetProvider;
|
|
554
|
+
decision.targetModelId = targetModelId;
|
|
555
|
+
decision.targetLabel = modelRef;
|
|
556
|
+
decision.thinking = delegatedReasoning ?? 'off';
|
|
557
|
+
decision.isFallback = i > 0;
|
|
558
|
+
};
|
|
567
559
|
for await (const event of delegatedStream) {
|
|
568
|
-
if (
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
560
|
+
if (
|
|
561
|
+
event.type === 'error' &&
|
|
562
|
+
event.reason !== 'aborted' &&
|
|
563
|
+
!contentReceived
|
|
564
|
+
) {
|
|
573
565
|
const errorMessage =
|
|
574
566
|
'error' in event &&
|
|
575
567
|
event.error &&
|
|
@@ -587,13 +579,37 @@ export const registerRouterProvider = (
|
|
|
587
579
|
event.type === 'thinking_delta' ||
|
|
588
580
|
event.type === 'toolcall_delta' ||
|
|
589
581
|
event.type === 'toolcall_end';
|
|
590
|
-
if (isContent)
|
|
591
|
-
|
|
582
|
+
if (isContent) {
|
|
583
|
+
contentReceived = true;
|
|
584
|
+
recordTarget();
|
|
585
|
+
}
|
|
586
|
+
if (event.type === 'done' || event.type === 'error') {
|
|
587
|
+
terminalReceived = true;
|
|
588
|
+
recordTarget();
|
|
589
|
+
const cost = (
|
|
590
|
+
event.type === 'done' ? event.message : event.error
|
|
591
|
+
).usage.cost.total;
|
|
592
|
+
if (Number.isFinite(cost) && cost > 0)
|
|
593
|
+
state.accumulatedCost += cost;
|
|
594
|
+
}
|
|
595
|
+
if (contentReceived || terminalReceived) {
|
|
596
|
+
for (const pending of pendingEvents.splice(0))
|
|
597
|
+
stream.push(pending);
|
|
598
|
+
stream.push(event);
|
|
599
|
+
if ('partial' in event) partialMessage = event.partial;
|
|
600
|
+
} else {
|
|
601
|
+
pendingEvents.push(event);
|
|
602
|
+
}
|
|
603
|
+
if (terminalReceived) break;
|
|
592
604
|
}
|
|
605
|
+
if (!terminalReceived)
|
|
606
|
+
throw new Error(
|
|
607
|
+
'Provider stream ended without a terminal event.',
|
|
608
|
+
);
|
|
593
609
|
success = true;
|
|
594
|
-
if (i > 0) decision.isFallback = true;
|
|
595
610
|
break;
|
|
596
611
|
} catch (err) {
|
|
612
|
+
if (contentReceived || options?.signal?.aborted) throw err;
|
|
597
613
|
lastError = err;
|
|
598
614
|
}
|
|
599
615
|
}
|
|
@@ -610,28 +626,17 @@ export const registerRouterProvider = (
|
|
|
610
626
|
|
|
611
627
|
stream.end();
|
|
612
628
|
} catch (error) {
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
stream.push({
|
|
621
|
-
type: 'done',
|
|
622
|
-
reason: 'stop',
|
|
623
|
-
message: createErrorMessage(model, ''),
|
|
624
|
-
});
|
|
625
|
-
} else {
|
|
626
|
-
stream.push({
|
|
627
|
-
type: 'error',
|
|
628
|
-
reason: 'error',
|
|
629
|
-
error: createErrorMessage(
|
|
630
|
-
model,
|
|
629
|
+
const reason = options?.signal?.aborted ? 'aborted' : 'error';
|
|
630
|
+
stream.push({
|
|
631
|
+
type: 'error',
|
|
632
|
+
reason,
|
|
633
|
+
error: {
|
|
634
|
+
...(partialMessage ?? createErrorMessage(model, '')),
|
|
635
|
+
errorMessage:
|
|
631
636
|
error instanceof Error ? error.message : String(error),
|
|
632
|
-
|
|
633
|
-
}
|
|
634
|
-
}
|
|
637
|
+
stopReason: reason,
|
|
638
|
+
},
|
|
639
|
+
});
|
|
635
640
|
stream.end();
|
|
636
641
|
} finally {
|
|
637
642
|
try {
|