@librechat/agents 3.1.76 → 3.1.77
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/cjs/graphs/Graph.cjs +9 -0
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/hitl/askUserQuestion.cjs +67 -0
- package/dist/cjs/hitl/askUserQuestion.cjs.map +1 -0
- package/dist/cjs/hooks/HookRegistry.cjs +54 -0
- package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
- package/dist/cjs/hooks/createToolPolicyHook.cjs +115 -0
- package/dist/cjs/hooks/createToolPolicyHook.cjs.map +1 -0
- package/dist/cjs/hooks/executeHooks.cjs +40 -1
- package/dist/cjs/hooks/executeHooks.cjs.map +1 -1
- package/dist/cjs/hooks/types.cjs +1 -0
- package/dist/cjs/hooks/types.cjs.map +1 -1
- package/dist/cjs/llm/openai/index.cjs +317 -1
- package/dist/cjs/llm/openai/index.cjs.map +1 -1
- package/dist/cjs/main.cjs +29 -0
- package/dist/cjs/main.cjs.map +1 -1
- package/dist/cjs/run.cjs +400 -42
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +551 -55
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/search/tavily-scraper.cjs.map +1 -1
- package/dist/cjs/tools/search/tavily-search.cjs.map +1 -1
- package/dist/cjs/tools/search/tool.cjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +9 -0
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/hitl/askUserQuestion.mjs +65 -0
- package/dist/esm/hitl/askUserQuestion.mjs.map +1 -0
- package/dist/esm/hooks/HookRegistry.mjs +54 -0
- package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
- package/dist/esm/hooks/createToolPolicyHook.mjs +113 -0
- package/dist/esm/hooks/createToolPolicyHook.mjs.map +1 -0
- package/dist/esm/hooks/executeHooks.mjs +40 -1
- package/dist/esm/hooks/executeHooks.mjs.map +1 -1
- package/dist/esm/hooks/types.mjs +1 -0
- package/dist/esm/hooks/types.mjs.map +1 -1
- package/dist/esm/llm/openai/index.mjs +318 -2
- package/dist/esm/llm/openai/index.mjs.map +1 -1
- package/dist/esm/main.mjs +3 -0
- package/dist/esm/main.mjs.map +1 -1
- package/dist/esm/run.mjs +400 -42
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +552 -56
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/search/tavily-scraper.mjs.map +1 -1
- package/dist/esm/tools/search/tavily-search.mjs.map +1 -1
- package/dist/esm/tools/search/tool.mjs.map +1 -1
- package/dist/types/graphs/Graph.d.ts +7 -0
- package/dist/types/hitl/askUserQuestion.d.ts +55 -0
- package/dist/types/hitl/index.d.ts +6 -0
- package/dist/types/hooks/HookRegistry.d.ts +58 -0
- package/dist/types/hooks/createToolPolicyHook.d.ts +87 -0
- package/dist/types/hooks/index.d.ts +4 -1
- package/dist/types/hooks/types.d.ts +109 -3
- package/dist/types/index.d.ts +9 -0
- package/dist/types/llm/openai/index.d.ts +17 -0
- package/dist/types/run.d.ts +117 -1
- package/dist/types/tools/ToolNode.d.ts +26 -1
- package/dist/types/types/hitl.d.ts +272 -0
- package/dist/types/types/index.d.ts +1 -0
- package/dist/types/types/run.d.ts +33 -0
- package/dist/types/types/tools.d.ts +19 -0
- package/package.json +1 -1
- package/src/graphs/Graph.ts +9 -0
- package/src/hitl/askUserQuestion.ts +72 -0
- package/src/hitl/index.ts +7 -0
- package/src/hooks/HookRegistry.ts +71 -0
- package/src/hooks/__tests__/createToolPolicyHook.test.ts +259 -0
- package/src/hooks/createToolPolicyHook.ts +184 -0
- package/src/hooks/executeHooks.ts +50 -1
- package/src/hooks/index.ts +6 -0
- package/src/hooks/types.ts +112 -0
- package/src/index.ts +19 -0
- package/src/llm/openai/deepseek.test.ts +479 -0
- package/src/llm/openai/index.ts +484 -1
- package/src/run.ts +456 -47
- package/src/tools/ToolNode.ts +701 -62
- package/src/tools/__tests__/hitl.test.ts +3593 -0
- package/src/tools/search/tavily-scraper.ts +4 -4
- package/src/tools/search/tavily-search.ts +32 -32
- package/src/tools/search/tool.ts +3 -3
- package/src/tools/search/types.ts +3 -1
- package/src/types/hitl.ts +303 -0
- package/src/types/index.ts +1 -0
- package/src/types/run.ts +33 -0
- package/src/types/tools.ts +19 -0
package/src/hooks/types.ts
CHANGED
|
@@ -15,6 +15,7 @@ export const HOOK_EVENTS = [
|
|
|
15
15
|
'PreToolUse',
|
|
16
16
|
'PostToolUse',
|
|
17
17
|
'PostToolUseFailure',
|
|
18
|
+
'PostToolBatch',
|
|
18
19
|
'PermissionDenied',
|
|
19
20
|
'SubagentStart',
|
|
20
21
|
'SubagentStop',
|
|
@@ -100,6 +101,42 @@ export interface PostToolUseFailureHookInput extends BaseHookInput {
|
|
|
100
101
|
turn?: number;
|
|
101
102
|
}
|
|
102
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Per-tool result snapshot included in a `PostToolBatch` event. Mirrors
|
|
106
|
+
* the data PostToolUse / PostToolUseFailure get individually, but the
|
|
107
|
+
* batch view lets a single hook see the whole set so it can inject one
|
|
108
|
+
* consolidated convention/audit message rather than N per-tool ones.
|
|
109
|
+
*/
|
|
110
|
+
export interface PostToolBatchEntry {
|
|
111
|
+
toolName: string;
|
|
112
|
+
toolInput: Record<string, unknown>;
|
|
113
|
+
toolUseId: string;
|
|
114
|
+
stepId?: string;
|
|
115
|
+
turn?: number;
|
|
116
|
+
/** Successful tool output, present only when `status === 'success'`. */
|
|
117
|
+
toolOutput?: unknown;
|
|
118
|
+
/** Error message, present only when `status === 'error'`. */
|
|
119
|
+
error?: string;
|
|
120
|
+
status: 'success' | 'error';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Fires once after every tool call in a single batch finishes (including
|
|
125
|
+
* any that were rejected via HITL). Lets a hook react to the batch as a
|
|
126
|
+
* whole — useful for "inject conventions once for the whole batch", batch
|
|
127
|
+
* audit logging, or coordinating cleanup that depends on knowing the full
|
|
128
|
+
* result set rather than streaming each tool's result independently.
|
|
129
|
+
*
|
|
130
|
+
* Order: fires AFTER all per-tool PostToolUse / PostToolUseFailure hooks
|
|
131
|
+
* for the same batch have completed, BEFORE the next model call. Pass an
|
|
132
|
+
* `additionalContext` to inject context for that next model turn.
|
|
133
|
+
*/
|
|
134
|
+
export interface PostToolBatchHookInput extends BaseHookInput {
|
|
135
|
+
hook_event_name: 'PostToolBatch';
|
|
136
|
+
/** All tool calls (and their outcomes) from this batch, in batch order. */
|
|
137
|
+
entries: PostToolBatchEntry[];
|
|
138
|
+
}
|
|
139
|
+
|
|
103
140
|
export interface PermissionDeniedHookInput extends BaseHookInput {
|
|
104
141
|
hook_event_name: 'PermissionDenied';
|
|
105
142
|
toolName: string;
|
|
@@ -171,6 +208,7 @@ export type HookInput =
|
|
|
171
208
|
| PreToolUseHookInput
|
|
172
209
|
| PostToolUseHookInput
|
|
173
210
|
| PostToolUseFailureHookInput
|
|
211
|
+
| PostToolBatchHookInput
|
|
174
212
|
| PermissionDeniedHookInput
|
|
175
213
|
| SubagentStartHookInput
|
|
176
214
|
| SubagentStopHookInput
|
|
@@ -186,6 +224,7 @@ export type HookInputByEvent = {
|
|
|
186
224
|
PreToolUse: PreToolUseHookInput;
|
|
187
225
|
PostToolUse: PostToolUseHookInput;
|
|
188
226
|
PostToolUseFailure: PostToolUseFailureHookInput;
|
|
227
|
+
PostToolBatch: PostToolBatchHookInput;
|
|
189
228
|
PermissionDenied: PermissionDeniedHookInput;
|
|
190
229
|
SubagentStart: SubagentStartHookInput;
|
|
191
230
|
SubagentStop: SubagentStopHookInput;
|
|
@@ -206,6 +245,56 @@ export interface BaseHookOutput {
|
|
|
206
245
|
preventContinuation?: boolean;
|
|
207
246
|
/** Reason reported alongside `preventContinuation`. */
|
|
208
247
|
stopReason?: string;
|
|
248
|
+
/**
|
|
249
|
+
* Marks this hook output as fire-and-forget for INFLUENCE only.
|
|
250
|
+
* When `true`, the SDK skips every other field on this output —
|
|
251
|
+
* `decision`, `additionalContext`, `updatedInput`,
|
|
252
|
+
* `preventContinuation`, `allowedDecisions`, `updatedOutput` are
|
|
253
|
+
* all ignored. The hook's return value cannot block, modify, or
|
|
254
|
+
* inject context, so it's safe to use for pure side effects
|
|
255
|
+
* (logging, metrics, webhooks).
|
|
256
|
+
*
|
|
257
|
+
* Important caveat: the hook's CALLBACK promise is still awaited
|
|
258
|
+
* by `executeHooks` (subject to the matcher's timeout and the
|
|
259
|
+
* default `DEFAULT_HOOK_TIMEOUT_MS`). The SDK does not
|
|
260
|
+
* speculatively detach hooks based on output shape, because the
|
|
261
|
+
* shape is only known after the promise resolves. For TRUE
|
|
262
|
+
* fire-and-forget where the agent doesn't wait at all, the hook
|
|
263
|
+
* body should detach its side effect itself and return
|
|
264
|
+
* immediately:
|
|
265
|
+
*
|
|
266
|
+
* @example
|
|
267
|
+
* ```ts
|
|
268
|
+
* async (input) => {
|
|
269
|
+
* // Detach the slow work — the SDK awaits this hook's
|
|
270
|
+
* // returned promise, which resolves immediately because we
|
|
271
|
+
* // don't `await` the side effect.
|
|
272
|
+
* void sendToLoggingService(input).catch(console.error);
|
|
273
|
+
* return { async: true };
|
|
274
|
+
* };
|
|
275
|
+
* ```
|
|
276
|
+
*
|
|
277
|
+
* @example WRONG — the agent will block on the webhook
|
|
278
|
+
* ```ts
|
|
279
|
+
* async (input) => {
|
|
280
|
+
* await sendToLoggingService(input); // ← awaited, blocks
|
|
281
|
+
* return { async: true }; // returning async:true doesn't undo the await
|
|
282
|
+
* };
|
|
283
|
+
* ```
|
|
284
|
+
*
|
|
285
|
+
* Mirrors Claude Code Agent SDK's `async` output, with the same
|
|
286
|
+
* "detach inside the hook body" pattern.
|
|
287
|
+
*/
|
|
288
|
+
async?: boolean;
|
|
289
|
+
/**
|
|
290
|
+
* Optional advisory timeout in milliseconds for the background work
|
|
291
|
+
* a host has detached inside an `async: true` hook body. The SDK
|
|
292
|
+
* does not enforce this (the hook's own AbortSignal handling does)
|
|
293
|
+
* but the field is preserved on the wire so downstream
|
|
294
|
+
* observability can surface long-running side effects. Ignored
|
|
295
|
+
* unless `async` is true.
|
|
296
|
+
*/
|
|
297
|
+
asyncTimeout?: number;
|
|
209
298
|
}
|
|
210
299
|
|
|
211
300
|
export type RunStartHookOutput = BaseHookOutput;
|
|
@@ -229,6 +318,19 @@ export interface PreToolUseHookOutput extends BaseHookOutput {
|
|
|
229
318
|
* `updatedInput` to one hook per matcher to avoid confusing precedence.
|
|
230
319
|
*/
|
|
231
320
|
updatedInput?: Record<string, unknown>;
|
|
321
|
+
/**
|
|
322
|
+
* Restricts which decisions the host UI is allowed to surface for this
|
|
323
|
+
* tool call when the hook returns `decision: 'ask'`. Pass to lock a
|
|
324
|
+
* tool down to a subset of `'approve' | 'reject' | 'edit' | 'respond'`
|
|
325
|
+
* — for example, `['approve', 'reject']` to forbid the user from
|
|
326
|
+
* editing the tool's args or substituting a custom response.
|
|
327
|
+
*
|
|
328
|
+
* The values flow into the resulting interrupt's
|
|
329
|
+
* `review_configs[i].allowed_decisions`. Omitting the field keeps the
|
|
330
|
+
* SDK default (all four decisions advertised). Last-writer-wins in
|
|
331
|
+
* registration order, same precedence rules as `updatedInput`.
|
|
332
|
+
*/
|
|
333
|
+
allowedDecisions?: ReadonlyArray<'approve' | 'reject' | 'edit' | 'respond'>;
|
|
232
334
|
}
|
|
233
335
|
|
|
234
336
|
export interface PostToolUseHookOutput extends BaseHookOutput {
|
|
@@ -243,6 +345,8 @@ export interface PostToolUseHookOutput extends BaseHookOutput {
|
|
|
243
345
|
|
|
244
346
|
export type PostToolUseFailureHookOutput = BaseHookOutput;
|
|
245
347
|
|
|
348
|
+
export type PostToolBatchHookOutput = BaseHookOutput;
|
|
349
|
+
|
|
246
350
|
export type PermissionDeniedHookOutput = BaseHookOutput;
|
|
247
351
|
|
|
248
352
|
export interface SubagentStartHookOutput extends BaseHookOutput {
|
|
@@ -270,6 +374,7 @@ export type HookOutputByEvent = {
|
|
|
270
374
|
PreToolUse: PreToolUseHookOutput;
|
|
271
375
|
PostToolUse: PostToolUseHookOutput;
|
|
272
376
|
PostToolUseFailure: PostToolUseFailureHookOutput;
|
|
377
|
+
PostToolBatch: PostToolBatchHookOutput;
|
|
273
378
|
PermissionDenied: PermissionDeniedHookOutput;
|
|
274
379
|
SubagentStart: SubagentStartHookOutput;
|
|
275
380
|
SubagentStop: SubagentStopHookOutput;
|
|
@@ -286,6 +391,7 @@ export type HookOutput =
|
|
|
286
391
|
| PreToolUseHookOutput
|
|
287
392
|
| PostToolUseHookOutput
|
|
288
393
|
| PostToolUseFailureHookOutput
|
|
394
|
+
| PostToolBatchHookOutput
|
|
289
395
|
| PermissionDeniedHookOutput
|
|
290
396
|
| SubagentStartHookOutput
|
|
291
397
|
| SubagentStopHookOutput
|
|
@@ -381,6 +487,12 @@ export interface AggregatedHookResult {
|
|
|
381
487
|
* hook per matcher to avoid subtle precedence bugs.
|
|
382
488
|
*/
|
|
383
489
|
updatedInput?: Record<string, unknown>;
|
|
490
|
+
/**
|
|
491
|
+
* Restricted decision set from a `PreToolUse` hook. Same last-writer-wins
|
|
492
|
+
* semantics as `updatedInput`. Surfaces to the interrupt payload's
|
|
493
|
+
* `review_configs[i].allowed_decisions`.
|
|
494
|
+
*/
|
|
495
|
+
allowedDecisions?: ReadonlyArray<'approve' | 'reject' | 'edit' | 'respond'>;
|
|
384
496
|
/**
|
|
385
497
|
* Replacement tool output from a `PostToolUse` hook.
|
|
386
498
|
*
|
package/src/index.ts
CHANGED
|
@@ -35,12 +35,31 @@ export * from './utils';
|
|
|
35
35
|
/* Hooks */
|
|
36
36
|
export * from './hooks';
|
|
37
37
|
|
|
38
|
+
/* HITL helpers */
|
|
39
|
+
export * from './hitl';
|
|
40
|
+
|
|
38
41
|
/* Types */
|
|
39
42
|
export type * from './types';
|
|
40
43
|
|
|
41
44
|
/* LangChain compatibility facade */
|
|
42
45
|
export * from './langchain';
|
|
43
46
|
|
|
47
|
+
/**
|
|
48
|
+
* HITL primitives re-exported from `@langchain/langgraph` so hosts that
|
|
49
|
+
* build durable checkpoint savers, dispatch `Command({ resume })`, or
|
|
50
|
+
* detect interrupts can do so against the same langgraph instance the
|
|
51
|
+
* SDK was compiled against — avoiding accidental dual-version drift.
|
|
52
|
+
*/
|
|
53
|
+
export {
|
|
54
|
+
Command,
|
|
55
|
+
INTERRUPT,
|
|
56
|
+
interrupt,
|
|
57
|
+
MemorySaver,
|
|
58
|
+
BaseCheckpointSaver,
|
|
59
|
+
isInterrupted,
|
|
60
|
+
} from '@langchain/langgraph';
|
|
61
|
+
export type { Interrupt } from '@langchain/langgraph';
|
|
62
|
+
|
|
44
63
|
/* LLM */
|
|
45
64
|
export { CustomOpenAIClient } from './llm/openai';
|
|
46
65
|
export { ChatOpenRouter } from './llm/openrouter';
|
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
|
|
2
|
+
import type { ChatGenerationChunk } from '@langchain/core/outputs';
|
|
3
|
+
import type { BaseMessage } from '@langchain/core/messages';
|
|
4
|
+
import type { OpenAIClient } from '@langchain/openai';
|
|
5
|
+
|
|
6
|
+
import { ChatDeepSeek } from './index';
|
|
7
|
+
|
|
8
|
+
type DeepSeekRequest =
|
|
9
|
+
| OpenAIClient.Chat.ChatCompletionCreateParamsStreaming
|
|
10
|
+
| OpenAIClient.Chat.ChatCompletionCreateParamsNonStreaming;
|
|
11
|
+
type OpenAIChatCompletion = OpenAIClient.Chat.Completions.ChatCompletion;
|
|
12
|
+
type OpenAIChatCompletionChunk =
|
|
13
|
+
OpenAIClient.Chat.Completions.ChatCompletionChunk;
|
|
14
|
+
type ReasoningAssistantMessageParam =
|
|
15
|
+
OpenAIClient.Chat.Completions.ChatCompletionAssistantMessageParam & {
|
|
16
|
+
reasoning_content?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
class CapturingChatDeepSeek extends ChatDeepSeek {
|
|
20
|
+
readonly requests: DeepSeekRequest[] = [];
|
|
21
|
+
|
|
22
|
+
constructor(
|
|
23
|
+
fields: ConstructorParameters<typeof ChatDeepSeek>[0],
|
|
24
|
+
private readonly streamChunks = createCompletionStreamChunks(),
|
|
25
|
+
private readonly completion = createCompletion()
|
|
26
|
+
) {
|
|
27
|
+
super(fields);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async completionWithRetry(
|
|
31
|
+
request: OpenAIClient.Chat.ChatCompletionCreateParamsStreaming,
|
|
32
|
+
requestOptions?: OpenAIClient.RequestOptions
|
|
33
|
+
): Promise<AsyncIterable<OpenAIChatCompletionChunk>>;
|
|
34
|
+
async completionWithRetry(
|
|
35
|
+
request: OpenAIClient.Chat.ChatCompletionCreateParamsNonStreaming,
|
|
36
|
+
requestOptions?: OpenAIClient.RequestOptions
|
|
37
|
+
): Promise<OpenAIChatCompletion>;
|
|
38
|
+
async completionWithRetry(
|
|
39
|
+
request: DeepSeekRequest,
|
|
40
|
+
_requestOptions?: OpenAIClient.RequestOptions
|
|
41
|
+
): Promise<AsyncIterable<OpenAIChatCompletionChunk> | OpenAIChatCompletion> {
|
|
42
|
+
this.requests.push(request);
|
|
43
|
+
|
|
44
|
+
if (request.stream === true) {
|
|
45
|
+
return createCompletionStream(this.streamChunks);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return this.completion;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
streamChunksWithSignal(
|
|
52
|
+
signal: AbortSignal
|
|
53
|
+
): AsyncGenerator<ChatGenerationChunk> {
|
|
54
|
+
return this._streamResponseChunks([new HumanMessage('hi')], {
|
|
55
|
+
signal,
|
|
56
|
+
} as this['ParsedCallOptions']);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function createToolContextMessages(): BaseMessage[] {
|
|
61
|
+
return [
|
|
62
|
+
new AIMessage({
|
|
63
|
+
content: '',
|
|
64
|
+
tool_calls: [
|
|
65
|
+
{
|
|
66
|
+
id: 'call_1',
|
|
67
|
+
name: 'web_search',
|
|
68
|
+
args: { query: 'trending news today' },
|
|
69
|
+
type: 'tool_call',
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
additional_kwargs: {
|
|
73
|
+
reasoning_content: 'Need current news from the web.',
|
|
74
|
+
},
|
|
75
|
+
}),
|
|
76
|
+
new ToolMessage({
|
|
77
|
+
content: 'Search results',
|
|
78
|
+
tool_call_id: 'call_1',
|
|
79
|
+
}),
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function createCompletionStreamChunks(): OpenAIChatCompletionChunk[] {
|
|
84
|
+
return [
|
|
85
|
+
createContentChunk('ok'),
|
|
86
|
+
{
|
|
87
|
+
id: 'chatcmpl-deepseek-test',
|
|
88
|
+
object: 'chat.completion.chunk',
|
|
89
|
+
created: 0,
|
|
90
|
+
model: 'deepseek-v4-pro',
|
|
91
|
+
choices: [
|
|
92
|
+
{
|
|
93
|
+
index: 0,
|
|
94
|
+
delta: {},
|
|
95
|
+
finish_reason: 'stop',
|
|
96
|
+
logprobs: null,
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
},
|
|
100
|
+
];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function createContentChunk(content: string): OpenAIChatCompletionChunk {
|
|
104
|
+
return {
|
|
105
|
+
id: 'chatcmpl-deepseek-test',
|
|
106
|
+
object: 'chat.completion.chunk',
|
|
107
|
+
created: 0,
|
|
108
|
+
model: 'deepseek-v4-pro',
|
|
109
|
+
choices: [
|
|
110
|
+
{
|
|
111
|
+
index: 0,
|
|
112
|
+
delta: {
|
|
113
|
+
role: 'assistant',
|
|
114
|
+
content,
|
|
115
|
+
},
|
|
116
|
+
finish_reason: null,
|
|
117
|
+
logprobs: null,
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function* createCompletionStream(
|
|
124
|
+
chunks: OpenAIChatCompletionChunk[]
|
|
125
|
+
): AsyncGenerator<OpenAIChatCompletionChunk> {
|
|
126
|
+
for (const chunk of chunks) {
|
|
127
|
+
yield chunk;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function createCompletion(
|
|
132
|
+
usage: OpenAIClient.Completions.CompletionUsage = {
|
|
133
|
+
prompt_tokens: 1,
|
|
134
|
+
completion_tokens: 1,
|
|
135
|
+
total_tokens: 2,
|
|
136
|
+
}
|
|
137
|
+
): OpenAIChatCompletion {
|
|
138
|
+
return {
|
|
139
|
+
id: 'chatcmpl-deepseek-test',
|
|
140
|
+
object: 'chat.completion',
|
|
141
|
+
created: 0,
|
|
142
|
+
model: 'deepseek-v4-pro',
|
|
143
|
+
choices: [
|
|
144
|
+
{
|
|
145
|
+
index: 0,
|
|
146
|
+
message: {
|
|
147
|
+
role: 'assistant',
|
|
148
|
+
content: 'ok',
|
|
149
|
+
refusal: null,
|
|
150
|
+
},
|
|
151
|
+
finish_reason: 'stop',
|
|
152
|
+
logprobs: null,
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
usage,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function getReasoningAssistantMessage(
|
|
160
|
+
request: DeepSeekRequest
|
|
161
|
+
): ReasoningAssistantMessageParam {
|
|
162
|
+
return request.messages[0] as ReasoningAssistantMessageParam;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function drainStream(stream: AsyncIterable<unknown>): Promise<void> {
|
|
166
|
+
for await (const chunk of stream) {
|
|
167
|
+
void chunk;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
describe('ChatDeepSeek', () => {
|
|
172
|
+
it('passes reasoning_content back on same-run streaming tool continuations', async () => {
|
|
173
|
+
const model = new CapturingChatDeepSeek({
|
|
174
|
+
apiKey: 'test-key',
|
|
175
|
+
model: 'deepseek-v4-pro',
|
|
176
|
+
streaming: true,
|
|
177
|
+
});
|
|
178
|
+
const chunks = [];
|
|
179
|
+
|
|
180
|
+
for await (const chunk of await model.stream(createToolContextMessages())) {
|
|
181
|
+
chunks.push(chunk);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
expect(chunks).toHaveLength(2);
|
|
185
|
+
expect(model.requests).toHaveLength(1);
|
|
186
|
+
expect(getReasoningAssistantMessage(model.requests[0])).toEqual(
|
|
187
|
+
expect.objectContaining({
|
|
188
|
+
role: 'assistant',
|
|
189
|
+
content: '',
|
|
190
|
+
reasoning_content: 'Need current news from the web.',
|
|
191
|
+
})
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('passes reasoning_content back on same-run non-streaming tool continuations', async () => {
|
|
196
|
+
const model = new CapturingChatDeepSeek({
|
|
197
|
+
apiKey: 'test-key',
|
|
198
|
+
model: 'deepseek-v4-pro',
|
|
199
|
+
streaming: false,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
await model.invoke(createToolContextMessages());
|
|
203
|
+
|
|
204
|
+
expect(model.requests).toHaveLength(1);
|
|
205
|
+
expect(getReasoningAssistantMessage(model.requests[0])).toEqual(
|
|
206
|
+
expect.objectContaining({
|
|
207
|
+
role: 'assistant',
|
|
208
|
+
content: '',
|
|
209
|
+
reasoning_content: 'Need current news from the web.',
|
|
210
|
+
})
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('keeps raw think fallback content out of streamed assistant content', async () => {
|
|
215
|
+
const model = new CapturingChatDeepSeek(
|
|
216
|
+
{
|
|
217
|
+
apiKey: 'test-key',
|
|
218
|
+
model: 'deepseek-v4-pro',
|
|
219
|
+
streaming: true,
|
|
220
|
+
},
|
|
221
|
+
[
|
|
222
|
+
createContentChunk('prefix <thi'),
|
|
223
|
+
createContentChunk('nk>hidden'),
|
|
224
|
+
createContentChunk('</think>visible'),
|
|
225
|
+
]
|
|
226
|
+
);
|
|
227
|
+
const chunks = [];
|
|
228
|
+
const callbackTokens: string[] = [];
|
|
229
|
+
|
|
230
|
+
const stream = await model.stream([new HumanMessage('hi')], {
|
|
231
|
+
callbacks: [
|
|
232
|
+
{
|
|
233
|
+
handleLLMNewToken(token: string): void {
|
|
234
|
+
callbackTokens.push(token);
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
],
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
for await (const chunk of stream) {
|
|
241
|
+
chunks.push(chunk);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const streamedText = chunks
|
|
245
|
+
.map((chunk) => (typeof chunk.content === 'string' ? chunk.content : ''))
|
|
246
|
+
.join('');
|
|
247
|
+
const hasHiddenReasoning = chunks.some(
|
|
248
|
+
(chunk) => chunk.additional_kwargs.reasoning_content === 'hidden'
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
expect(streamedText).toBe('prefix visible');
|
|
252
|
+
expect(callbackTokens.join('')).toBe('prefix visible');
|
|
253
|
+
expect(callbackTokens.join('')).not.toContain('hidden');
|
|
254
|
+
expect(callbackTokens.join('')).not.toContain('think');
|
|
255
|
+
expect(hasHiddenReasoning).toBe(true);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('keeps multiple raw think fallback blocks hidden from content and callbacks', async () => {
|
|
259
|
+
const model = new CapturingChatDeepSeek(
|
|
260
|
+
{
|
|
261
|
+
apiKey: 'test-key',
|
|
262
|
+
model: 'deepseek-v4-pro',
|
|
263
|
+
streaming: true,
|
|
264
|
+
},
|
|
265
|
+
[
|
|
266
|
+
createContentChunk(
|
|
267
|
+
'before<think>hidden one</think>visible<think>hidden two</think>done'
|
|
268
|
+
),
|
|
269
|
+
]
|
|
270
|
+
);
|
|
271
|
+
const chunks = [];
|
|
272
|
+
const callbackTokens: string[] = [];
|
|
273
|
+
|
|
274
|
+
const stream = await model.stream([new HumanMessage('hi')], {
|
|
275
|
+
callbacks: [
|
|
276
|
+
{
|
|
277
|
+
handleLLMNewToken(token: string): void {
|
|
278
|
+
callbackTokens.push(token);
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
],
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
for await (const chunk of stream) {
|
|
285
|
+
chunks.push(chunk);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const streamedText = chunks
|
|
289
|
+
.map((chunk) => (typeof chunk.content === 'string' ? chunk.content : ''))
|
|
290
|
+
.join('');
|
|
291
|
+
const reasoningContent = chunks
|
|
292
|
+
.map((chunk) => chunk.additional_kwargs.reasoning_content)
|
|
293
|
+
.filter((content): content is string => typeof content === 'string');
|
|
294
|
+
|
|
295
|
+
expect(streamedText).toBe('beforevisibledone');
|
|
296
|
+
expect(callbackTokens.join('')).toBe('beforevisibledone');
|
|
297
|
+
expect(reasoningContent).toEqual(['hidden one', 'hidden two']);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('keeps cross-chunk multiple raw think fallback blocks hidden from content and callbacks', async () => {
|
|
301
|
+
const model = new CapturingChatDeepSeek(
|
|
302
|
+
{
|
|
303
|
+
apiKey: 'test-key',
|
|
304
|
+
model: 'deepseek-v4-pro',
|
|
305
|
+
streaming: true,
|
|
306
|
+
},
|
|
307
|
+
[
|
|
308
|
+
createContentChunk('before<think>hidden one</thi'),
|
|
309
|
+
createContentChunk('nk>visible<thi'),
|
|
310
|
+
createContentChunk('nk>hidden two</think>done'),
|
|
311
|
+
]
|
|
312
|
+
);
|
|
313
|
+
const chunks = [];
|
|
314
|
+
const callbackTokens: string[] = [];
|
|
315
|
+
|
|
316
|
+
const stream = await model.stream([new HumanMessage('hi')], {
|
|
317
|
+
callbacks: [
|
|
318
|
+
{
|
|
319
|
+
handleLLMNewToken(token: string): void {
|
|
320
|
+
callbackTokens.push(token);
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
],
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
for await (const chunk of stream) {
|
|
327
|
+
chunks.push(chunk);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const streamedText = chunks
|
|
331
|
+
.map((chunk) => (typeof chunk.content === 'string' ? chunk.content : ''))
|
|
332
|
+
.join('');
|
|
333
|
+
const reasoningContent = chunks
|
|
334
|
+
.map((chunk) => chunk.additional_kwargs.reasoning_content)
|
|
335
|
+
.filter((content): content is string => typeof content === 'string');
|
|
336
|
+
|
|
337
|
+
expect(streamedText).toBe('beforevisibledone');
|
|
338
|
+
expect(callbackTokens.join('')).toBe('beforevisibledone');
|
|
339
|
+
expect(reasoningContent).toEqual(['hidden one', 'hidden two']);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('emits trailing unfinished raw think fallback as reasoning content', async () => {
|
|
343
|
+
const model = new CapturingChatDeepSeek(
|
|
344
|
+
{
|
|
345
|
+
apiKey: 'test-key',
|
|
346
|
+
model: 'deepseek-v4-pro',
|
|
347
|
+
streaming: true,
|
|
348
|
+
},
|
|
349
|
+
[createContentChunk('<think>truncated')]
|
|
350
|
+
);
|
|
351
|
+
const chunks = [];
|
|
352
|
+
const callbackTokens: string[] = [];
|
|
353
|
+
|
|
354
|
+
const stream = await model.stream([new HumanMessage('hi')], {
|
|
355
|
+
callbacks: [
|
|
356
|
+
{
|
|
357
|
+
handleLLMNewToken(token: string): void {
|
|
358
|
+
callbackTokens.push(token);
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
],
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
for await (const chunk of stream) {
|
|
365
|
+
chunks.push(chunk);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const streamedText = chunks
|
|
369
|
+
.map((chunk) => (typeof chunk.content === 'string' ? chunk.content : ''))
|
|
370
|
+
.join('');
|
|
371
|
+
const reasoningContent = chunks
|
|
372
|
+
.map((chunk) => chunk.additional_kwargs.reasoning_content)
|
|
373
|
+
.filter((content): content is string => typeof content === 'string');
|
|
374
|
+
|
|
375
|
+
expect(streamedText).toBe('');
|
|
376
|
+
expect(callbackTokens.join('')).toBe('');
|
|
377
|
+
expect(reasoningContent).toEqual(['truncated']);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('preserves detailed usage metadata in non-streaming responses', async () => {
|
|
381
|
+
const model = new CapturingChatDeepSeek(
|
|
382
|
+
{
|
|
383
|
+
apiKey: 'test-key',
|
|
384
|
+
model: 'deepseek-v4-pro',
|
|
385
|
+
streaming: false,
|
|
386
|
+
},
|
|
387
|
+
createCompletionStreamChunks(),
|
|
388
|
+
createCompletion({
|
|
389
|
+
prompt_tokens: 11,
|
|
390
|
+
completion_tokens: 7,
|
|
391
|
+
total_tokens: 18,
|
|
392
|
+
prompt_tokens_details: {
|
|
393
|
+
audio_tokens: 2,
|
|
394
|
+
cached_tokens: 3,
|
|
395
|
+
},
|
|
396
|
+
completion_tokens_details: {
|
|
397
|
+
audio_tokens: 4,
|
|
398
|
+
reasoning_tokens: 5,
|
|
399
|
+
},
|
|
400
|
+
})
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
const response = await model.invoke([new HumanMessage('hi')]);
|
|
404
|
+
|
|
405
|
+
expect(response.usage_metadata).toEqual({
|
|
406
|
+
input_tokens: 11,
|
|
407
|
+
output_tokens: 7,
|
|
408
|
+
total_tokens: 18,
|
|
409
|
+
input_token_details: {
|
|
410
|
+
audio: 2,
|
|
411
|
+
cache_read: 3,
|
|
412
|
+
},
|
|
413
|
+
output_token_details: {
|
|
414
|
+
audio: 4,
|
|
415
|
+
reasoning: 5,
|
|
416
|
+
},
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it('does not serialize non-streaming requests when aborted before generation', async () => {
|
|
421
|
+
const controller = new AbortController();
|
|
422
|
+
const model = new CapturingChatDeepSeek({
|
|
423
|
+
apiKey: 'test-key',
|
|
424
|
+
model: 'deepseek-v4-pro',
|
|
425
|
+
streaming: false,
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
controller.abort();
|
|
429
|
+
|
|
430
|
+
await expect(
|
|
431
|
+
model.invoke([new HumanMessage('hi')], {
|
|
432
|
+
signal: controller.signal,
|
|
433
|
+
})
|
|
434
|
+
).rejects.toThrow();
|
|
435
|
+
expect(model.requests).toHaveLength(0);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('throws AbortError when a DeepSeek stream is canceled', async () => {
|
|
439
|
+
const controller = new AbortController();
|
|
440
|
+
const model = new CapturingChatDeepSeek({
|
|
441
|
+
apiKey: 'test-key',
|
|
442
|
+
model: 'deepseek-v4-pro',
|
|
443
|
+
streaming: true,
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
controller.abort();
|
|
447
|
+
|
|
448
|
+
await expect(
|
|
449
|
+
drainStream(model.streamChunksWithSignal(controller.signal))
|
|
450
|
+
).rejects.toThrow('AbortError');
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('throws AbortError when a DeepSeek stream is canceled mid-stream', async () => {
|
|
454
|
+
const controller = new AbortController();
|
|
455
|
+
const model = new CapturingChatDeepSeek(
|
|
456
|
+
{
|
|
457
|
+
apiKey: 'test-key',
|
|
458
|
+
model: 'deepseek-v4-pro',
|
|
459
|
+
streaming: true,
|
|
460
|
+
},
|
|
461
|
+
[createContentChunk('first '), createContentChunk('second')]
|
|
462
|
+
);
|
|
463
|
+
const stream = model.streamChunksWithSignal(controller.signal);
|
|
464
|
+
const iterator = stream[Symbol.asyncIterator]();
|
|
465
|
+
|
|
466
|
+
await expect(iterator.next()).resolves.toEqual(
|
|
467
|
+
expect.objectContaining({
|
|
468
|
+
done: false,
|
|
469
|
+
value: expect.objectContaining({
|
|
470
|
+
text: 'first ',
|
|
471
|
+
}),
|
|
472
|
+
})
|
|
473
|
+
);
|
|
474
|
+
|
|
475
|
+
controller.abort();
|
|
476
|
+
|
|
477
|
+
await expect(iterator.next()).rejects.toThrow('AbortError');
|
|
478
|
+
});
|
|
479
|
+
});
|