praxis-agent 0.23.1 → 0.24.1
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/dist/application/session-service.d.ts +3 -0
- package/dist/application/session-service.js +125 -47
- package/dist/cli/interactive.d.ts +1 -0
- package/dist/cli/interactive.js +54 -25
- package/dist/cli/protocol.d.ts +1 -0
- package/dist/cli/protocol.js +11 -1
- package/dist/cli-runtime.js +1 -0
- package/dist/core/runtime.d.ts +12 -1
- package/dist/core/runtime.js +25 -0
- package/dist/providers/anthropic-compatible.js +85 -11
- package/dist/providers/fallback-provider.js +9 -0
- package/dist/providers/openai-compatible.js +92 -8
- package/dist/providers/provider-errors.d.ts +3 -0
- package/dist/providers/provider-errors.js +9 -0
- package/package.json +1 -1
|
@@ -161,6 +161,7 @@ export interface RewindPoint {
|
|
|
161
161
|
fileRestoreAvailable: boolean;
|
|
162
162
|
}
|
|
163
163
|
export declare function workflowTokenTarget(prompt: string): number | null;
|
|
164
|
+
export type HookSessionEndReason = 'clear' | 'resume' | 'other';
|
|
164
165
|
export declare class ClaudeSessionService {
|
|
165
166
|
private readonly options;
|
|
166
167
|
private readonly schema;
|
|
@@ -182,6 +183,7 @@ export declare class ClaudeSessionService {
|
|
|
182
183
|
private activeCostSessionId;
|
|
183
184
|
private closeCostSavePromise;
|
|
184
185
|
private readonly sessionMemoryControllers;
|
|
186
|
+
private readonly hookLifecycle;
|
|
185
187
|
private runtimeCwd;
|
|
186
188
|
constructor(options: ClaudeSessionServiceOptions);
|
|
187
189
|
nextScheduledPrompt(signal?: AbortSignal): Promise<ScheduledPrompt | null>;
|
|
@@ -198,6 +200,7 @@ export declare class ClaudeSessionService {
|
|
|
198
200
|
stopTask(sessionId: string, taskId: string): Promise<void>;
|
|
199
201
|
private applyPermissionUpdates;
|
|
200
202
|
close(): Promise<void>;
|
|
203
|
+
transitionHookSession(sessionId: string, reason: Exclude<HookSessionEndReason, 'other'>): Promise<void>;
|
|
201
204
|
createHostedToolRegistry(sessionId: string): ToolRegistry;
|
|
202
205
|
run(prompt: string, signal?: AbortSignal, sessionId?: string, name?: string, images?: readonly ModelImage[], documents?: readonly ModelDocument[]): Promise<SessionRunResult>;
|
|
203
206
|
runShell(command: string, signal?: AbortSignal, sessionId?: string, name?: string): Promise<SessionRunResult>;
|
|
@@ -319,6 +319,116 @@ function validSessionName(value) {
|
|
|
319
319
|
? name.toLocaleLowerCase()
|
|
320
320
|
: null;
|
|
321
321
|
}
|
|
322
|
+
const SESSION_END_HOOK_TIMEOUT_MS = 15_000;
|
|
323
|
+
class HookLifecycle {
|
|
324
|
+
hooks;
|
|
325
|
+
eventSink;
|
|
326
|
+
sessions = new Map();
|
|
327
|
+
pendingSource;
|
|
328
|
+
constructor(hooks, eventSink) {
|
|
329
|
+
this.hooks = hooks;
|
|
330
|
+
this.eventSink = eventSink;
|
|
331
|
+
}
|
|
332
|
+
async start(sessionId, input, fallbackSource, signal) {
|
|
333
|
+
let state = this.sessions.get(sessionId);
|
|
334
|
+
if (state?.started)
|
|
335
|
+
return undefined;
|
|
336
|
+
if (state?.starting)
|
|
337
|
+
return state.starting;
|
|
338
|
+
state = {
|
|
339
|
+
input,
|
|
340
|
+
started: false,
|
|
341
|
+
starting: undefined,
|
|
342
|
+
ending: undefined,
|
|
343
|
+
};
|
|
344
|
+
this.sessions.set(sessionId, state);
|
|
345
|
+
const source = this.pendingSource ?? fallbackSource;
|
|
346
|
+
this.pendingSource = undefined;
|
|
347
|
+
state.starting = this.hooks?.run({ ...input, hook_event_name: 'SessionStart', source }, source, signal);
|
|
348
|
+
try {
|
|
349
|
+
const outcome = await state.starting;
|
|
350
|
+
state.started = true;
|
|
351
|
+
return outcome;
|
|
352
|
+
}
|
|
353
|
+
catch (error) {
|
|
354
|
+
this.sessions.delete(sessionId);
|
|
355
|
+
throw error;
|
|
356
|
+
}
|
|
357
|
+
finally {
|
|
358
|
+
state.starting = undefined;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
async refresh(sessionId, input, signal) {
|
|
362
|
+
const state = this.sessions.get(sessionId);
|
|
363
|
+
if (state)
|
|
364
|
+
state.input = input;
|
|
365
|
+
return this.hooks?.run({ ...input, hook_event_name: 'SessionStart', source: 'compact' }, 'compact', signal);
|
|
366
|
+
}
|
|
367
|
+
async transition(sessionId, reason) {
|
|
368
|
+
await this.end(sessionId, reason);
|
|
369
|
+
this.pendingSource = reason;
|
|
370
|
+
}
|
|
371
|
+
async end(sessionId, reason) {
|
|
372
|
+
const state = this.sessions.get(sessionId);
|
|
373
|
+
if (!state)
|
|
374
|
+
return;
|
|
375
|
+
if (state.starting)
|
|
376
|
+
await state.starting;
|
|
377
|
+
if (!state.started)
|
|
378
|
+
return;
|
|
379
|
+
if (state.ending)
|
|
380
|
+
return state.ending;
|
|
381
|
+
state.ending = this.runEnd(state.input, reason).finally(() => {
|
|
382
|
+
this.sessions.delete(sessionId);
|
|
383
|
+
});
|
|
384
|
+
await state.ending;
|
|
385
|
+
}
|
|
386
|
+
async close() {
|
|
387
|
+
await Promise.all([...this.sessions.keys()].map((sessionId) => this.end(sessionId, 'other')));
|
|
388
|
+
}
|
|
389
|
+
async runEnd(input, reason) {
|
|
390
|
+
if (!this.hooks)
|
|
391
|
+
return;
|
|
392
|
+
const controller = new AbortController();
|
|
393
|
+
let timer;
|
|
394
|
+
try {
|
|
395
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
396
|
+
timer = setTimeout(() => {
|
|
397
|
+
controller.abort();
|
|
398
|
+
reject(new Error(`timed out after ${SESSION_END_HOOK_TIMEOUT_MS}ms`));
|
|
399
|
+
}, SESSION_END_HOOK_TIMEOUT_MS);
|
|
400
|
+
});
|
|
401
|
+
const outcome = await Promise.race([
|
|
402
|
+
this.hooks.run({ ...input, hook_event_name: 'SessionEnd', reason }, reason, controller.signal),
|
|
403
|
+
timeout,
|
|
404
|
+
]);
|
|
405
|
+
for (const execution of outcome.executions) {
|
|
406
|
+
if (execution.exitCode === 0)
|
|
407
|
+
continue;
|
|
408
|
+
const detail = execution.stderr.trim() ||
|
|
409
|
+
execution.stdout.trim() ||
|
|
410
|
+
`exit code ${execution.exitCode}`;
|
|
411
|
+
this.warn(detail);
|
|
412
|
+
}
|
|
413
|
+
if (outcome.blockedReason && outcome.executions.at(-1)?.exitCode === 0) {
|
|
414
|
+
this.warn(outcome.blockedReason);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
catch (error) {
|
|
418
|
+
this.warn(error instanceof Error ? error.message : String(error));
|
|
419
|
+
}
|
|
420
|
+
finally {
|
|
421
|
+
if (timer !== undefined)
|
|
422
|
+
clearTimeout(timer);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
warn(detail) {
|
|
426
|
+
this.eventSink?.({
|
|
427
|
+
type: 'warning',
|
|
428
|
+
message: `SessionEnd hook failed: ${detail}`,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
}
|
|
322
432
|
export class ClaudeSessionService {
|
|
323
433
|
options;
|
|
324
434
|
schema;
|
|
@@ -340,9 +450,11 @@ export class ClaudeSessionService {
|
|
|
340
450
|
activeCostSessionId;
|
|
341
451
|
closeCostSavePromise;
|
|
342
452
|
sessionMemoryControllers = new Map();
|
|
453
|
+
hookLifecycle;
|
|
343
454
|
runtimeCwd;
|
|
344
455
|
constructor(options) {
|
|
345
456
|
this.options = options;
|
|
457
|
+
this.hookLifecycle = new HookLifecycle(options.hooks, options.eventSink);
|
|
346
458
|
this.runtimeCwd = options.workspace?.cwd() ?? options.cwd;
|
|
347
459
|
this.schema = selectClaudeSchemaAdapter(options.claudeVersion);
|
|
348
460
|
this.scheduledPrompts =
|
|
@@ -481,6 +593,7 @@ export class ClaudeSessionService {
|
|
|
481
593
|
}
|
|
482
594
|
}
|
|
483
595
|
async close() {
|
|
596
|
+
await this.hookLifecycle.close();
|
|
484
597
|
this.scheduledPrompts?.close();
|
|
485
598
|
await Promise.all([...this.hostedSubagents].map((executor) => executor.close()));
|
|
486
599
|
await Promise.resolve();
|
|
@@ -494,6 +607,9 @@ export class ClaudeSessionService {
|
|
|
494
607
|
this.mcpClosePromise ??= this.options.mcp?.close?.() ?? Promise.resolve();
|
|
495
608
|
await this.mcpClosePromise;
|
|
496
609
|
}
|
|
610
|
+
async transitionHookSession(sessionId, reason) {
|
|
611
|
+
await this.hookLifecycle.transition(sessionId, reason);
|
|
612
|
+
}
|
|
497
613
|
createHostedToolRegistry(sessionId) {
|
|
498
614
|
const baseTools = this.options.tools;
|
|
499
615
|
if (!baseTools)
|
|
@@ -2243,17 +2359,11 @@ export class ClaudeSessionService {
|
|
|
2243
2359
|
}
|
|
2244
2360
|
},
|
|
2245
2361
|
};
|
|
2362
|
+
let turnCompleted = false;
|
|
2246
2363
|
try {
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
...hookSession,
|
|
2250
|
-
hook_event_name: 'SessionStart',
|
|
2251
|
-
source: requireExisting ? 'resume' : 'startup',
|
|
2252
|
-
}, requireExisting ? 'resume' : 'startup', signal);
|
|
2364
|
+
const outcome = await this.hookLifecycle.start(sessionId, hookSession, requireExisting ? 'resume' : 'startup', signal);
|
|
2365
|
+
if (outcome) {
|
|
2253
2366
|
await recordHookOutcome(outcome, pendingRecoveryToolCallIds.size > 0);
|
|
2254
|
-
if (outcome.blockedReason) {
|
|
2255
|
-
throw new Error(`SessionStart hook error: ${outcome.blockedReason}`);
|
|
2256
|
-
}
|
|
2257
2367
|
}
|
|
2258
2368
|
const approveRecovery = this.options.approveRecovery;
|
|
2259
2369
|
const recoveryRequest = {
|
|
@@ -2620,15 +2730,9 @@ export class ClaudeSessionService {
|
|
|
2620
2730
|
// runtime-only context so the next request retains current
|
|
2621
2731
|
// instructions, plan state, session memory, and hook context.
|
|
2622
2732
|
if (this.options.hooks) {
|
|
2623
|
-
const outcome = await this.
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
source: 'compact',
|
|
2627
|
-
}, 'compact', signal);
|
|
2628
|
-
await recordHookOutcome(outcome);
|
|
2629
|
-
if (outcome.blockedReason) {
|
|
2630
|
-
throw new Error(`SessionStart hook error: ${outcome.blockedReason}`);
|
|
2631
|
-
}
|
|
2733
|
+
const outcome = await this.hookLifecycle.refresh(sessionId, hookSession, signal);
|
|
2734
|
+
if (outcome)
|
|
2735
|
+
await recordHookOutcome(outcome);
|
|
2632
2736
|
}
|
|
2633
2737
|
await refreshRuntimeContext();
|
|
2634
2738
|
this.options.eventSink?.({
|
|
@@ -3057,6 +3161,7 @@ export class ClaudeSessionService {
|
|
|
3057
3161
|
});
|
|
3058
3162
|
}
|
|
3059
3163
|
}
|
|
3164
|
+
turnCompleted = true;
|
|
3060
3165
|
return {
|
|
3061
3166
|
sessionId,
|
|
3062
3167
|
text: structuredCapture && structuredCapture.calls === 1
|
|
@@ -3074,35 +3179,8 @@ export class ClaudeSessionService {
|
|
|
3074
3179
|
};
|
|
3075
3180
|
}
|
|
3076
3181
|
finally {
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
...hookSession,
|
|
3080
|
-
hook_event_name: 'SessionEnd',
|
|
3081
|
-
reason: 'other',
|
|
3082
|
-
}, 'other');
|
|
3083
|
-
const failedExecutions = outcome?.executions.filter((execution) => execution.exitCode !== 0) ?? [];
|
|
3084
|
-
for (const execution of failedExecutions) {
|
|
3085
|
-
const detail = execution.stderr.trim() ||
|
|
3086
|
-
execution.stdout.trim() ||
|
|
3087
|
-
`exit code ${execution.exitCode}`;
|
|
3088
|
-
this.options.eventSink?.({
|
|
3089
|
-
type: 'warning',
|
|
3090
|
-
message: `SessionEnd hook failed: ${detail}`,
|
|
3091
|
-
});
|
|
3092
|
-
}
|
|
3093
|
-
if (outcome?.blockedReason &&
|
|
3094
|
-
outcome.executions.at(-1)?.exitCode === 0) {
|
|
3095
|
-
this.options.eventSink?.({
|
|
3096
|
-
type: 'warning',
|
|
3097
|
-
message: `SessionEnd hook failed: ${outcome.blockedReason}`,
|
|
3098
|
-
});
|
|
3099
|
-
}
|
|
3100
|
-
}
|
|
3101
|
-
catch (error) {
|
|
3102
|
-
this.options.eventSink?.({
|
|
3103
|
-
type: 'warning',
|
|
3104
|
-
message: `SessionEnd hook failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
3105
|
-
});
|
|
3182
|
+
if (!turnCompleted) {
|
|
3183
|
+
await this.hookLifecycle.end(sessionId, 'other');
|
|
3106
3184
|
}
|
|
3107
3185
|
}
|
|
3108
3186
|
});
|
package/dist/cli/interactive.js
CHANGED
|
@@ -1294,6 +1294,8 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
|
|
|
1294
1294
|
text: `API retry ${event.attempt}/${event.maxRetries} · ${event.error}`,
|
|
1295
1295
|
});
|
|
1296
1296
|
break;
|
|
1297
|
+
case 'terminal':
|
|
1298
|
+
break;
|
|
1297
1299
|
case 'elicitation-complete':
|
|
1298
1300
|
append({
|
|
1299
1301
|
kind: 'notice',
|
|
@@ -3433,9 +3435,16 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
|
|
|
3433
3435
|
if (selectingSession) {
|
|
3434
3436
|
if (key.escape || value === '\u001B') {
|
|
3435
3437
|
if (pickerIncludesNewSessionRef.current && allowNewSession) {
|
|
3436
|
-
setSessionId(null);
|
|
3437
|
-
setPendingFork(false);
|
|
3438
3438
|
setSelectingSession(false);
|
|
3439
|
+
const previousSessionId = sessionIdRef.current;
|
|
3440
|
+
void (async () => {
|
|
3441
|
+
if (previousSessionId) {
|
|
3442
|
+
const commands = await service();
|
|
3443
|
+
await commands.transitionHookSession?.(previousSessionId, 'clear');
|
|
3444
|
+
}
|
|
3445
|
+
openSession(null);
|
|
3446
|
+
setPendingFork(false);
|
|
3447
|
+
})().catch(warn);
|
|
3439
3448
|
}
|
|
3440
3449
|
else if (allowNewSession) {
|
|
3441
3450
|
setSelectingSession(false);
|
|
@@ -3460,10 +3469,18 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
|
|
|
3460
3469
|
const selected = filterSessionChoices(currentPickerChoices, sessionSearchRef.current)[selectedIndexRef.current];
|
|
3461
3470
|
if (selected === undefined)
|
|
3462
3471
|
return;
|
|
3463
|
-
openSession(selected?.sessionId ?? null);
|
|
3464
|
-
if (!selected)
|
|
3465
|
-
setPendingFork(false);
|
|
3466
3472
|
setSelectingSession(false);
|
|
3473
|
+
const nextSessionId = selected?.sessionId ?? null;
|
|
3474
|
+
const previousSessionId = sessionIdRef.current;
|
|
3475
|
+
void (async () => {
|
|
3476
|
+
if (previousSessionId && previousSessionId !== nextSessionId) {
|
|
3477
|
+
const commands = await service();
|
|
3478
|
+
await commands.transitionHookSession?.(previousSessionId, nextSessionId === null ? 'clear' : 'resume');
|
|
3479
|
+
}
|
|
3480
|
+
openSession(nextSessionId);
|
|
3481
|
+
if (!selected)
|
|
3482
|
+
setPendingFork(false);
|
|
3483
|
+
})().catch(warn);
|
|
3467
3484
|
}
|
|
3468
3485
|
else if (key.backspace || key.delete) {
|
|
3469
3486
|
sessionSearchRef.current = sessionSearchRef.current.slice(0, -1);
|
|
@@ -5218,28 +5235,40 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
|
|
|
5218
5235
|
updateMenu({ kind: 'help', tabIndex: 0, selectedIndex: 0 });
|
|
5219
5236
|
}
|
|
5220
5237
|
else if (prompt === '/new') {
|
|
5221
|
-
|
|
5222
|
-
|
|
5223
|
-
|
|
5224
|
-
|
|
5225
|
-
|
|
5238
|
+
const previousSessionId = sessionIdRef.current;
|
|
5239
|
+
void (async () => {
|
|
5240
|
+
if (previousSessionId) {
|
|
5241
|
+
const commands = await service();
|
|
5242
|
+
await commands.transitionHookSession?.(previousSessionId, 'clear');
|
|
5243
|
+
}
|
|
5244
|
+
statusLineSessionId.current = randomUUID();
|
|
5245
|
+
openSession(null);
|
|
5246
|
+
setPendingFork(false);
|
|
5247
|
+
append({ kind: 'notice', text: 'Started a new session.' });
|
|
5248
|
+
})().catch(warn);
|
|
5226
5249
|
}
|
|
5227
5250
|
else if (prompt === '/clear') {
|
|
5228
|
-
|
|
5229
|
-
|
|
5230
|
-
|
|
5231
|
-
|
|
5232
|
-
|
|
5233
|
-
|
|
5234
|
-
|
|
5235
|
-
|
|
5236
|
-
|
|
5237
|
-
|
|
5238
|
-
|
|
5239
|
-
|
|
5240
|
-
|
|
5241
|
-
|
|
5242
|
-
|
|
5251
|
+
const previousSessionId = sessionIdRef.current;
|
|
5252
|
+
void (async () => {
|
|
5253
|
+
if (previousSessionId) {
|
|
5254
|
+
const commands = await service();
|
|
5255
|
+
await commands.transitionHookSession?.(previousSessionId, 'clear');
|
|
5256
|
+
}
|
|
5257
|
+
statusLineSessionId.current = randomUUID();
|
|
5258
|
+
openSession(null);
|
|
5259
|
+
setPendingFork(false);
|
|
5260
|
+
setHistory([]);
|
|
5261
|
+
setUsage(undefined);
|
|
5262
|
+
setCostUsd(undefined);
|
|
5263
|
+
streamingFrameRef.current?.resetText();
|
|
5264
|
+
streamingFrameRef.current?.resetThinking();
|
|
5265
|
+
streamingFrameRef.current?.flush();
|
|
5266
|
+
setThinkingExpanded(false);
|
|
5267
|
+
setStatus('ready');
|
|
5268
|
+
inputHistoryRef.current = [];
|
|
5269
|
+
inputHistoryIndexRef.current = null;
|
|
5270
|
+
inputHistoryDraftRef.current = '';
|
|
5271
|
+
})().catch(warn);
|
|
5243
5272
|
}
|
|
5244
5273
|
else if (prompt === '/model') {
|
|
5245
5274
|
updateMenu({ kind: 'model', selectedIndex: 0 });
|
package/dist/cli/protocol.d.ts
CHANGED
package/dist/cli/protocol.js
CHANGED
|
@@ -1666,6 +1666,7 @@ export class StreamJsonOutput {
|
|
|
1666
1666
|
turnThinking = [];
|
|
1667
1667
|
turnCalls = [];
|
|
1668
1668
|
turnUsage = emptyUsage();
|
|
1669
|
+
turnTerminalReason;
|
|
1669
1670
|
turnActive = false;
|
|
1670
1671
|
assistantFlushed = true;
|
|
1671
1672
|
contentStarted = false;
|
|
@@ -1884,6 +1885,11 @@ export class StreamJsonOutput {
|
|
|
1884
1885
|
this.turnUsage = event.usage;
|
|
1885
1886
|
return;
|
|
1886
1887
|
}
|
|
1888
|
+
if (event.type === 'terminal') {
|
|
1889
|
+
this.ensureTurn();
|
|
1890
|
+
this.turnTerminalReason = event.reason;
|
|
1891
|
+
return;
|
|
1892
|
+
}
|
|
1887
1893
|
if (event.type === 'permission-decision') {
|
|
1888
1894
|
this.flushAssistant();
|
|
1889
1895
|
return;
|
|
@@ -2111,6 +2117,7 @@ export class StreamJsonOutput {
|
|
|
2111
2117
|
this.turnThinking = [];
|
|
2112
2118
|
this.turnCalls = [];
|
|
2113
2119
|
this.turnUsage = emptyUsage();
|
|
2120
|
+
this.turnTerminalReason = undefined;
|
|
2114
2121
|
this.turnActive = true;
|
|
2115
2122
|
this.assistantFlushed = false;
|
|
2116
2123
|
this.contentStarted = false;
|
|
@@ -2224,7 +2231,10 @@ export class StreamJsonOutput {
|
|
|
2224
2231
|
event: {
|
|
2225
2232
|
type: 'message_delta',
|
|
2226
2233
|
delta: {
|
|
2227
|
-
stop_reason: this.
|
|
2234
|
+
stop_reason: this.turnTerminalReason === 'prompt_too_long'
|
|
2235
|
+
? 'model_context_window_exceeded'
|
|
2236
|
+
: (this.turnTerminalReason ??
|
|
2237
|
+
(this.turnCalls.length > 0 ? 'tool_use' : 'end_turn')),
|
|
2228
2238
|
stop_sequence: null,
|
|
2229
2239
|
},
|
|
2230
2240
|
usage: { output_tokens: this.turnUsage.outputTokens },
|
package/dist/cli-runtime.js
CHANGED
|
@@ -1577,6 +1577,7 @@ const createDefaultService = async ({ eventSink, requireProvider, hooksOnly = fa
|
|
|
1577
1577
|
rename: (sessionId, name) => service.rename(sessionId, name),
|
|
1578
1578
|
sessionNameSuggestion: (sessionId, signal) => service.sessionNameSuggestion(sessionId, signal),
|
|
1579
1579
|
nextScheduledPrompt: (signal) => service.nextScheduledPrompt(signal),
|
|
1580
|
+
transitionHookSession: (sessionId, reason) => service.transitionHookSession(sessionId, reason),
|
|
1580
1581
|
close: async () => {
|
|
1581
1582
|
let failure;
|
|
1582
1583
|
try {
|
package/dist/core/runtime.d.ts
CHANGED
|
@@ -78,6 +78,7 @@ export interface ModelWebSearch {
|
|
|
78
78
|
blockedDomains?: readonly string[];
|
|
79
79
|
maxUses: number;
|
|
80
80
|
}
|
|
81
|
+
export type ModelTerminalReason = 'end_turn' | 'tool_use' | 'max_tokens' | 'prompt_too_long';
|
|
81
82
|
export type ModelStreamEvent = {
|
|
82
83
|
type: 'text-delta';
|
|
83
84
|
delta: string;
|
|
@@ -102,6 +103,9 @@ export type ModelStreamEvent = {
|
|
|
102
103
|
} | {
|
|
103
104
|
type: 'usage';
|
|
104
105
|
usage: ModelUsage;
|
|
106
|
+
} | {
|
|
107
|
+
type: 'terminal';
|
|
108
|
+
reason: ModelTerminalReason;
|
|
105
109
|
} | {
|
|
106
110
|
type: 'api-retry';
|
|
107
111
|
attempt: number;
|
|
@@ -135,6 +139,8 @@ export interface ModelProviderCapabilities {
|
|
|
135
139
|
};
|
|
136
140
|
contextWindowTokens?: number;
|
|
137
141
|
maxOutputTokens?: number;
|
|
142
|
+
/** The provider emits exactly one terminal event as its final stream event. */
|
|
143
|
+
terminalReasons?: boolean;
|
|
138
144
|
}
|
|
139
145
|
export interface ModelProvider {
|
|
140
146
|
readonly capabilities: ModelProviderCapabilities;
|
|
@@ -170,6 +176,9 @@ export type RuntimeEvent = {
|
|
|
170
176
|
} | {
|
|
171
177
|
type: 'usage';
|
|
172
178
|
usage: ModelUsage;
|
|
179
|
+
} | {
|
|
180
|
+
type: 'terminal';
|
|
181
|
+
reason: ModelTerminalReason;
|
|
173
182
|
} | {
|
|
174
183
|
type: 'tool-call';
|
|
175
184
|
call: ModelToolCall;
|
|
@@ -480,8 +489,10 @@ export declare class ModelProviderError extends Error {
|
|
|
480
489
|
readonly retryable: boolean;
|
|
481
490
|
readonly status?: number;
|
|
482
491
|
readonly retryDelayMs?: number;
|
|
492
|
+
readonly kind?: ProviderErrorKind;
|
|
483
493
|
constructor(message: string, options: {
|
|
484
494
|
retryable: boolean;
|
|
495
|
+
kind?: ProviderErrorKind;
|
|
485
496
|
status?: number;
|
|
486
497
|
retryDelayMs?: number;
|
|
487
498
|
cause?: unknown;
|
|
@@ -492,7 +503,7 @@ export declare class AgentRunCancelledError extends Error {
|
|
|
492
503
|
constructor();
|
|
493
504
|
}
|
|
494
505
|
export type RuntimeEventSink = (event: RuntimeEvent) => void;
|
|
495
|
-
export type ProviderErrorKind = 'authentication_failed' | 'billing_error' | 'rate_limit' | 'invalid_request' | 'server_error' | 'unknown' | 'max_output_tokens';
|
|
506
|
+
export type ProviderErrorKind = 'authentication_failed' | 'billing_error' | 'rate_limit' | 'invalid_request' | 'server_error' | 'timeout' | 'overloaded' | 'api_error' | 'prompt_too_long' | 'transport_error' | 'cancelled' | 'unknown' | 'max_output_tokens';
|
|
496
507
|
export declare function modelProviderErrorKind(error: ModelProviderError): ProviderErrorKind;
|
|
497
508
|
export declare class AgentRuntime {
|
|
498
509
|
private readonly provider;
|
package/dist/core/runtime.js
CHANGED
|
@@ -20,9 +20,12 @@ export class ModelProviderError extends Error {
|
|
|
20
20
|
retryable;
|
|
21
21
|
status;
|
|
22
22
|
retryDelayMs;
|
|
23
|
+
kind;
|
|
23
24
|
constructor(message, options) {
|
|
24
25
|
super(message, options.cause === undefined ? undefined : { cause: options.cause });
|
|
25
26
|
this.retryable = options.retryable;
|
|
27
|
+
if (options.kind !== undefined)
|
|
28
|
+
this.kind = options.kind;
|
|
26
29
|
if (options.status !== undefined)
|
|
27
30
|
this.status = options.status;
|
|
28
31
|
if (options.retryDelayMs !== undefined)
|
|
@@ -37,6 +40,8 @@ export class AgentRunCancelledError extends Error {
|
|
|
37
40
|
}
|
|
38
41
|
const emptyUsage = () => ({ inputTokens: 0, outputTokens: 0 });
|
|
39
42
|
export function modelProviderErrorKind(error) {
|
|
43
|
+
if (error.kind !== undefined)
|
|
44
|
+
return error.kind;
|
|
40
45
|
if (error.status === 401 || error.status === 403)
|
|
41
46
|
return 'authentication_failed';
|
|
42
47
|
if (error.status === 402)
|
|
@@ -341,6 +346,7 @@ export class AgentRuntime {
|
|
|
341
346
|
let turnUsage = emptyUsage();
|
|
342
347
|
let streaming = false;
|
|
343
348
|
const toolCalls = [];
|
|
349
|
+
let terminalReason;
|
|
344
350
|
const apiStartedAt = request.collectMetrics ? performance.now() : 0;
|
|
345
351
|
let turnApiDurationMs = 0;
|
|
346
352
|
let turnApiDurationWithoutRetriesMs;
|
|
@@ -349,6 +355,9 @@ export class AgentRuntime {
|
|
|
349
355
|
for await (const event of this.provider.complete(providerRequest)) {
|
|
350
356
|
if (request.signal?.aborted)
|
|
351
357
|
return this.cancel();
|
|
358
|
+
if (terminalReason !== undefined) {
|
|
359
|
+
throw new ModelProviderError(`Provider emitted ${event.type} after terminal reason ${terminalReason}`, { retryable: false });
|
|
360
|
+
}
|
|
352
361
|
if (event.type === 'api-retry') {
|
|
353
362
|
this.emit(event);
|
|
354
363
|
continue;
|
|
@@ -414,6 +423,10 @@ export class AgentRuntime {
|
|
|
414
423
|
toolCalls.push(event.call);
|
|
415
424
|
this.emit(event);
|
|
416
425
|
}
|
|
426
|
+
else if (event.type === 'terminal') {
|
|
427
|
+
terminalReason = event.reason;
|
|
428
|
+
this.emit(event);
|
|
429
|
+
}
|
|
417
430
|
else {
|
|
418
431
|
turnUsage = event.usage;
|
|
419
432
|
this.emit(event);
|
|
@@ -427,6 +440,18 @@ export class AgentRuntime {
|
|
|
427
440
|
unrecordedDurationApiMs = addApiDurationMetric(turnApiDurationMs, unrecordedDurationApiMs, 'durationApiMs');
|
|
428
441
|
}
|
|
429
442
|
}
|
|
443
|
+
if (this.provider.capabilities.terminalReasons === true &&
|
|
444
|
+
terminalReason === undefined) {
|
|
445
|
+
throw new ModelProviderError('Provider stream ended without a terminal reason', { retryable: true });
|
|
446
|
+
}
|
|
447
|
+
if (terminalReason === 'tool_use' && toolCalls.length === 0) {
|
|
448
|
+
throw new ModelProviderError('Provider reported tool_use without a completed tool call', { retryable: false });
|
|
449
|
+
}
|
|
450
|
+
if (terminalReason !== undefined &&
|
|
451
|
+
terminalReason !== 'tool_use' &&
|
|
452
|
+
toolCalls.length > 0) {
|
|
453
|
+
throw new ModelProviderError(`Provider reported ${terminalReason} with completed tool calls`, { retryable: false });
|
|
454
|
+
}
|
|
430
455
|
if (request.collectMetrics) {
|
|
431
456
|
const turnApiDurationWithoutRetriesMsResolved = turnApiDurationWithoutRetriesMs ?? turnApiDurationMs;
|
|
432
457
|
durationApiWithoutRetriesMs = addApiDurationMetric(turnApiDurationWithoutRetriesMsResolved, durationApiWithoutRetriesMs, 'durationApiWithoutRetriesMs');
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ModelProviderError, } from '../core/runtime.js';
|
|
2
|
+
import { transportFailureKind } from './provider-errors.js';
|
|
2
3
|
function isRecord(value) {
|
|
3
4
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
4
5
|
}
|
|
@@ -61,6 +62,52 @@ function readErrorMessage(value, status) {
|
|
|
61
62
|
}
|
|
62
63
|
return `Provider request failed with HTTP ${status}`;
|
|
63
64
|
}
|
|
65
|
+
function isPromptTooLongError(type, message) {
|
|
66
|
+
return (['prompt_too_long', 'context_length_exceeded'].includes(type) ||
|
|
67
|
+
/prompt\s+(?:is\s+)?too long|context.{0,80}(?:exceed|too long)|maximum context length/iu.test(message));
|
|
68
|
+
}
|
|
69
|
+
function anthropicErrorKind(value, status) {
|
|
70
|
+
const error = isRecord(value) && isRecord(value.error) ? value.error : value;
|
|
71
|
+
const type = isRecord(error) && typeof error.type === 'string' ? error.type : '';
|
|
72
|
+
const message = isRecord(error) && typeof error.message === 'string' ? error.message : '';
|
|
73
|
+
if (isPromptTooLongError(type, message))
|
|
74
|
+
return 'prompt_too_long';
|
|
75
|
+
if (type === 'authentication_error' || status === 401 || status === 403)
|
|
76
|
+
return 'authentication_failed';
|
|
77
|
+
if (type === 'billing_error' || status === 402)
|
|
78
|
+
return 'billing_error';
|
|
79
|
+
if (type === 'rate_limit_error' || status === 429)
|
|
80
|
+
return 'rate_limit';
|
|
81
|
+
if (type === 'overloaded_error' || status === 529)
|
|
82
|
+
return 'overloaded';
|
|
83
|
+
if (type === 'api_error')
|
|
84
|
+
return 'api_error';
|
|
85
|
+
if (status === 408)
|
|
86
|
+
return 'timeout';
|
|
87
|
+
if (type === 'invalid_request_error')
|
|
88
|
+
return 'invalid_request';
|
|
89
|
+
if (status !== undefined && status >= 400 && status < 500)
|
|
90
|
+
return 'invalid_request';
|
|
91
|
+
if (status !== undefined && status >= 500)
|
|
92
|
+
return 'server_error';
|
|
93
|
+
return 'unknown';
|
|
94
|
+
}
|
|
95
|
+
function anthropicStopReason(value) {
|
|
96
|
+
if (value === 'end_turn' || value === 'stop_sequence' || value === 'refusal')
|
|
97
|
+
return 'end_turn';
|
|
98
|
+
if (value === 'tool_use')
|
|
99
|
+
return 'tool_use';
|
|
100
|
+
if (value === 'max_tokens')
|
|
101
|
+
return 'max_tokens';
|
|
102
|
+
if (value === 'model_context_window_exceeded')
|
|
103
|
+
return 'prompt_too_long';
|
|
104
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
105
|
+
throw new ModelProviderError('Provider message delta is missing stop reason', {
|
|
106
|
+
retryable: false,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
throw new ModelProviderError(`Provider returned unsupported stop reason ${value}`, { retryable: false });
|
|
110
|
+
}
|
|
64
111
|
function completedToolCall(state, index) {
|
|
65
112
|
const pending = state.tools.get(index);
|
|
66
113
|
if (!pending)
|
|
@@ -105,12 +152,14 @@ function parseSseEvent(data, state, maxToolArgumentsBytes, maxToolCallsPerRespon
|
|
|
105
152
|
const message = typeof error.message === 'string'
|
|
106
153
|
? error.message
|
|
107
154
|
: 'Provider stream returned an error';
|
|
155
|
+
const kind = anthropicErrorKind(error);
|
|
108
156
|
const retryable = [
|
|
109
157
|
'api_error',
|
|
110
|
-
'
|
|
111
|
-
'
|
|
112
|
-
|
|
113
|
-
|
|
158
|
+
'overloaded',
|
|
159
|
+
'rate_limit',
|
|
160
|
+
'timeout',
|
|
161
|
+
].includes(kind);
|
|
162
|
+
throw new ModelProviderError(message, { kind, retryable });
|
|
114
163
|
}
|
|
115
164
|
if (value.type === 'message_start') {
|
|
116
165
|
if (state.messageStarted || !isRecord(value.message)) {
|
|
@@ -344,6 +393,17 @@ function parseSseEvent(data, state, maxToolArgumentsBytes, maxToolCallsPerRespon
|
|
|
344
393
|
throw new ModelProviderError('Provider returned an invalid message delta', { retryable: false });
|
|
345
394
|
}
|
|
346
395
|
state.messageDeltaSeen = true;
|
|
396
|
+
if (value.delta !== undefined && !isRecord(value.delta)) {
|
|
397
|
+
throw new ModelProviderError('Provider returned an invalid message delta', {
|
|
398
|
+
retryable: false,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
if (isRecord(value.delta) && value.delta.stop_reason !== undefined) {
|
|
402
|
+
if (state.terminalReason !== undefined) {
|
|
403
|
+
throw new ModelProviderError('Provider returned multiple terminal stop reasons', { retryable: false });
|
|
404
|
+
}
|
|
405
|
+
state.terminalReason = anthropicStopReason(value.delta.stop_reason);
|
|
406
|
+
}
|
|
347
407
|
if (typeof value.usage.output_tokens === 'number') {
|
|
348
408
|
state.outputTokens = value.usage.output_tokens;
|
|
349
409
|
state.usageSeen = true;
|
|
@@ -360,6 +420,11 @@ function parseSseEvent(data, state, maxToolArgumentsBytes, maxToolCallsPerRespon
|
|
|
360
420
|
if (!state.messageDeltaSeen) {
|
|
361
421
|
throw new ModelProviderError('Provider stopped before the terminal message delta', { retryable: false });
|
|
362
422
|
}
|
|
423
|
+
if (state.terminalReason === undefined) {
|
|
424
|
+
throw new ModelProviderError('Provider message delta is missing stop reason', {
|
|
425
|
+
retryable: false,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
363
428
|
state.terminal = true;
|
|
364
429
|
const events = [];
|
|
365
430
|
if (state.usageSeen) {
|
|
@@ -381,6 +446,7 @@ function parseSseEvent(data, state, maxToolArgumentsBytes, maxToolCallsPerRespon
|
|
|
381
446
|
});
|
|
382
447
|
state.usageSeen = false;
|
|
383
448
|
}
|
|
449
|
+
events.push({ type: 'terminal', reason: state.terminalReason });
|
|
384
450
|
return events;
|
|
385
451
|
}
|
|
386
452
|
return [];
|
|
@@ -532,6 +598,7 @@ export class AnthropicCompatibleProvider {
|
|
|
532
598
|
? {}
|
|
533
599
|
: { contextWindowTokens: options.contextWindowTokens }),
|
|
534
600
|
maxOutputTokens: this.maxOutputTokens,
|
|
601
|
+
terminalReasons: true,
|
|
535
602
|
};
|
|
536
603
|
this.thinking = validateThinking(options.thinking);
|
|
537
604
|
this.anthropicVersion = options.anthropicVersion ?? '2023-06-01';
|
|
@@ -618,10 +685,14 @@ export class AnthropicCompatibleProvider {
|
|
|
618
685
|
response = await this.fetchImplementation(this.endpoint, requestInit);
|
|
619
686
|
}
|
|
620
687
|
catch (error) {
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
688
|
+
const kind = transportFailureKind(error, request.signal);
|
|
689
|
+
throw new ModelProviderError(kind === 'cancelled'
|
|
690
|
+
? 'Provider request cancelled'
|
|
691
|
+
: kind === 'timeout'
|
|
692
|
+
? 'Provider request timed out'
|
|
693
|
+
: 'Provider transport failed', {
|
|
694
|
+
kind,
|
|
695
|
+
retryable: kind === 'timeout' || kind === 'transport_error',
|
|
625
696
|
cause: error,
|
|
626
697
|
});
|
|
627
698
|
}
|
|
@@ -669,12 +740,14 @@ export class AnthropicCompatibleProvider {
|
|
|
669
740
|
payload = null;
|
|
670
741
|
}
|
|
671
742
|
throw new ModelProviderError(readErrorMessage(payload, response.status), {
|
|
743
|
+
kind: anthropicErrorKind(payload, response.status),
|
|
672
744
|
retryable: isRetryableStatus(response.status),
|
|
673
745
|
status: response.status,
|
|
674
746
|
});
|
|
675
747
|
}
|
|
676
748
|
if (!response.body) {
|
|
677
749
|
throw new ModelProviderError('Provider response has no body', {
|
|
750
|
+
kind: 'transport_error',
|
|
678
751
|
retryable: true,
|
|
679
752
|
});
|
|
680
753
|
}
|
|
@@ -734,11 +807,12 @@ export class AnthropicCompatibleProvider {
|
|
|
734
807
|
}
|
|
735
808
|
}
|
|
736
809
|
catch (error) {
|
|
737
|
-
if (error instanceof ModelProviderError
|
|
810
|
+
if (error instanceof ModelProviderError)
|
|
738
811
|
throw error;
|
|
739
|
-
|
|
812
|
+
const kind = transportFailureKind(error, request.signal);
|
|
740
813
|
throw new ModelProviderError('Provider stream failed', {
|
|
741
|
-
|
|
814
|
+
kind,
|
|
815
|
+
retryable: kind === 'timeout' || kind === 'transport_error',
|
|
742
816
|
cause: error,
|
|
743
817
|
});
|
|
744
818
|
}
|
|
@@ -35,7 +35,11 @@ export class FallbackModelProvider {
|
|
|
35
35
|
// cannot duplicate text/tool events when retried.
|
|
36
36
|
const events = [];
|
|
37
37
|
let attemptDurationMs;
|
|
38
|
+
let terminalSeen = false;
|
|
38
39
|
for await (const event of provider.complete(request)) {
|
|
40
|
+
if (terminalSeen) {
|
|
41
|
+
throw new ModelProviderError(`Provider emitted ${event.type} after its terminal event`, { retryable: false });
|
|
42
|
+
}
|
|
39
43
|
if (event.type === 'api-attempt-duration') {
|
|
40
44
|
// Consume the underlying attempt timing rather than replaying it
|
|
41
45
|
// so nested wrappers report a single retry-free duration.
|
|
@@ -51,8 +55,13 @@ export class FallbackModelProvider {
|
|
|
51
55
|
attemptDurationMs = durationMs;
|
|
52
56
|
continue;
|
|
53
57
|
}
|
|
58
|
+
if (event.type === 'terminal')
|
|
59
|
+
terminalSeen = true;
|
|
54
60
|
events.push(event);
|
|
55
61
|
}
|
|
62
|
+
if (provider.capabilities.terminalReasons === true && !terminalSeen) {
|
|
63
|
+
throw new ModelProviderError('Provider stream ended without a terminal reason', { retryable: true });
|
|
64
|
+
}
|
|
56
65
|
yield {
|
|
57
66
|
type: 'api-attempt-duration',
|
|
58
67
|
durationMs: attemptDurationMs ??
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ModelProviderError, } from '../core/runtime.js';
|
|
2
|
+
import { transportFailureKind } from './provider-errors.js';
|
|
2
3
|
function isRecord(value) {
|
|
3
4
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
4
5
|
}
|
|
@@ -13,6 +14,48 @@ function readErrorMessage(value, status) {
|
|
|
13
14
|
}
|
|
14
15
|
return `Provider request failed with HTTP ${status}`;
|
|
15
16
|
}
|
|
17
|
+
function openAiFinishReason(value) {
|
|
18
|
+
if (value === 'stop' || value === 'content_filter')
|
|
19
|
+
return 'end_turn';
|
|
20
|
+
if (value === 'tool_calls' || value === 'function_call')
|
|
21
|
+
return 'tool_use';
|
|
22
|
+
if (value === 'length')
|
|
23
|
+
return 'max_tokens';
|
|
24
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
25
|
+
throw new ModelProviderError('Provider stream is missing finish reason', {
|
|
26
|
+
retryable: false,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
throw new ModelProviderError(`Provider returned unsupported finish reason ${value}`, { retryable: false });
|
|
30
|
+
}
|
|
31
|
+
function openAiErrorKind(value, status) {
|
|
32
|
+
const error = isRecord(value) && isRecord(value.error) ? value.error : value;
|
|
33
|
+
const type = isRecord(error) && typeof error.type === 'string' ? error.type : '';
|
|
34
|
+
const code = isRecord(error) && typeof error.code === 'string' ? error.code : '';
|
|
35
|
+
const message = isRecord(error) && typeof error.message === 'string' ? error.message : '';
|
|
36
|
+
if (['context_length_exceeded', 'prompt_too_long'].includes(type) ||
|
|
37
|
+
['context_length_exceeded', 'prompt_too_long'].includes(code) ||
|
|
38
|
+
/prompt\s+(?:is\s+)?too long|context.{0,80}(?:exceed|too long)|maximum context length/iu.test(message)) {
|
|
39
|
+
return 'prompt_too_long';
|
|
40
|
+
}
|
|
41
|
+
if (status === 401 || status === 403)
|
|
42
|
+
return 'authentication_failed';
|
|
43
|
+
if (status === 402)
|
|
44
|
+
return 'billing_error';
|
|
45
|
+
if (status === 408)
|
|
46
|
+
return 'timeout';
|
|
47
|
+
if (type === 'rate_limit_error' || status === 429)
|
|
48
|
+
return 'rate_limit';
|
|
49
|
+
if (type === 'overloaded_error' || status === 529)
|
|
50
|
+
return 'overloaded';
|
|
51
|
+
if (type === 'api_error' || type === 'server_error')
|
|
52
|
+
return 'api_error';
|
|
53
|
+
if (status !== undefined && status >= 400 && status < 500)
|
|
54
|
+
return 'invalid_request';
|
|
55
|
+
if (status !== undefined && status >= 500)
|
|
56
|
+
return 'server_error';
|
|
57
|
+
return 'unknown';
|
|
58
|
+
}
|
|
16
59
|
function completedToolCallEvents(pending) {
|
|
17
60
|
const events = [...pending.calls.entries()]
|
|
18
61
|
.sort(([left], [right]) => left - right)
|
|
@@ -42,9 +85,18 @@ function completedToolCallEvents(pending) {
|
|
|
42
85
|
}
|
|
43
86
|
function parseSseEvent(data, pendingTools, maxToolArgumentsBytes, maxToolCallsPerResponse, maxToolMetadataBytes) {
|
|
44
87
|
if (data === '[DONE]') {
|
|
88
|
+
if (pendingTools.terminalReason === undefined) {
|
|
89
|
+
throw new ModelProviderError('Provider stream is missing finish reason', {
|
|
90
|
+
retryable: false,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
45
93
|
pendingTools.done = true;
|
|
46
94
|
pendingTools.terminal = true;
|
|
47
|
-
|
|
95
|
+
pendingTools.terminalEmitted = true;
|
|
96
|
+
return [
|
|
97
|
+
...completedToolCallEvents(pendingTools),
|
|
98
|
+
{ type: 'terminal', reason: pendingTools.terminalReason },
|
|
99
|
+
];
|
|
48
100
|
}
|
|
49
101
|
let value;
|
|
50
102
|
try {
|
|
@@ -58,6 +110,16 @@ function parseSseEvent(data, pendingTools, maxToolArgumentsBytes, maxToolCallsPe
|
|
|
58
110
|
}
|
|
59
111
|
if (!isRecord(value))
|
|
60
112
|
return [];
|
|
113
|
+
if (isRecord(value.error)) {
|
|
114
|
+
const kind = openAiErrorKind(value);
|
|
115
|
+
const message = typeof value.error.message === 'string'
|
|
116
|
+
? value.error.message
|
|
117
|
+
: 'Provider stream returned an error';
|
|
118
|
+
throw new ModelProviderError(message, {
|
|
119
|
+
kind,
|
|
120
|
+
retryable: ['api_error', 'overloaded', 'rate_limit', 'timeout'].includes(kind),
|
|
121
|
+
});
|
|
122
|
+
}
|
|
61
123
|
const events = [];
|
|
62
124
|
const choices = value.choices;
|
|
63
125
|
if (Array.isArray(choices)) {
|
|
@@ -106,7 +168,11 @@ function parseSseEvent(data, pendingTools, maxToolArgumentsBytes, maxToolCallsPe
|
|
|
106
168
|
if (isRecord(first) &&
|
|
107
169
|
first.finish_reason !== null &&
|
|
108
170
|
first.finish_reason !== undefined) {
|
|
171
|
+
if (pendingTools.terminalReason !== undefined) {
|
|
172
|
+
throw new ModelProviderError('Provider returned multiple finish reasons', { retryable: false });
|
|
173
|
+
}
|
|
109
174
|
pendingTools.terminal = true;
|
|
175
|
+
pendingTools.terminalReason = openAiFinishReason(first.finish_reason);
|
|
110
176
|
if (pendingTools.calls.size > 0) {
|
|
111
177
|
events.push(...completedToolCallEvents(pendingTools));
|
|
112
178
|
}
|
|
@@ -219,6 +285,7 @@ export class OpenAICompatibleProvider {
|
|
|
219
285
|
tools: true,
|
|
220
286
|
images: true,
|
|
221
287
|
thinking: { modes: ['disabled'], maxTokens: false },
|
|
288
|
+
terminalReasons: true,
|
|
222
289
|
...(options.contextWindowTokens === undefined
|
|
223
290
|
? {}
|
|
224
291
|
: { contextWindowTokens: options.contextWindowTokens }),
|
|
@@ -279,10 +346,14 @@ export class OpenAICompatibleProvider {
|
|
|
279
346
|
response = await this.fetchImplementation(this.endpoint, requestInit);
|
|
280
347
|
}
|
|
281
348
|
catch (error) {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
349
|
+
const kind = transportFailureKind(error, request.signal);
|
|
350
|
+
throw new ModelProviderError(kind === 'cancelled'
|
|
351
|
+
? 'Provider request cancelled'
|
|
352
|
+
: kind === 'timeout'
|
|
353
|
+
? 'Provider request timed out'
|
|
354
|
+
: 'Provider transport failed', {
|
|
355
|
+
kind,
|
|
356
|
+
retryable: kind === 'timeout' || kind === 'transport_error',
|
|
286
357
|
cause: error,
|
|
287
358
|
});
|
|
288
359
|
}
|
|
@@ -330,12 +401,14 @@ export class OpenAICompatibleProvider {
|
|
|
330
401
|
payload = null;
|
|
331
402
|
}
|
|
332
403
|
throw new ModelProviderError(readErrorMessage(payload, response.status), {
|
|
404
|
+
kind: openAiErrorKind(payload, response.status),
|
|
333
405
|
retryable: isRetryableStatus(response.status),
|
|
334
406
|
status: response.status,
|
|
335
407
|
});
|
|
336
408
|
}
|
|
337
409
|
if (!response.body) {
|
|
338
410
|
throw new ModelProviderError('Provider response has no body', {
|
|
411
|
+
kind: 'transport_error',
|
|
339
412
|
retryable: true,
|
|
340
413
|
});
|
|
341
414
|
}
|
|
@@ -347,6 +420,7 @@ export class OpenAICompatibleProvider {
|
|
|
347
420
|
metadataBytes: 0,
|
|
348
421
|
done: false,
|
|
349
422
|
terminal: false,
|
|
423
|
+
terminalEmitted: false,
|
|
350
424
|
};
|
|
351
425
|
let streamEnded = false;
|
|
352
426
|
try {
|
|
@@ -383,13 +457,23 @@ export class OpenAICompatibleProvider {
|
|
|
383
457
|
if (!pendingTools.terminal) {
|
|
384
458
|
throw new ModelProviderError('Provider stream ended before a terminal event', { retryable: true });
|
|
385
459
|
}
|
|
460
|
+
if (!pendingTools.terminalEmitted) {
|
|
461
|
+
if (pendingTools.terminalReason === undefined) {
|
|
462
|
+
throw new ModelProviderError('Provider stream is missing finish reason', {
|
|
463
|
+
retryable: false,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
pendingTools.terminalEmitted = true;
|
|
467
|
+
yield { type: 'terminal', reason: pendingTools.terminalReason };
|
|
468
|
+
}
|
|
386
469
|
}
|
|
387
470
|
catch (error) {
|
|
388
|
-
if (error instanceof ModelProviderError
|
|
471
|
+
if (error instanceof ModelProviderError)
|
|
389
472
|
throw error;
|
|
390
|
-
|
|
473
|
+
const kind = transportFailureKind(error, request.signal);
|
|
391
474
|
throw new ModelProviderError('Provider stream failed', {
|
|
392
|
-
|
|
475
|
+
kind,
|
|
476
|
+
retryable: kind === 'timeout' || kind === 'transport_error',
|
|
393
477
|
cause: error,
|
|
394
478
|
});
|
|
395
479
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function transportFailureKind(error, signal) {
|
|
2
|
+
const reason = signal?.reason;
|
|
3
|
+
if ((error instanceof Error && error.name === 'TimeoutError') ||
|
|
4
|
+
(reason instanceof Error && reason.name === 'TimeoutError')) {
|
|
5
|
+
return 'timeout';
|
|
6
|
+
}
|
|
7
|
+
return signal?.aborted ? 'cancelled' : 'transport_error';
|
|
8
|
+
}
|
|
9
|
+
//# sourceMappingURL=provider-errors.js.map
|