@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.
Files changed (85) hide show
  1. package/dist/cjs/graphs/Graph.cjs +9 -0
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/hitl/askUserQuestion.cjs +67 -0
  4. package/dist/cjs/hitl/askUserQuestion.cjs.map +1 -0
  5. package/dist/cjs/hooks/HookRegistry.cjs +54 -0
  6. package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
  7. package/dist/cjs/hooks/createToolPolicyHook.cjs +115 -0
  8. package/dist/cjs/hooks/createToolPolicyHook.cjs.map +1 -0
  9. package/dist/cjs/hooks/executeHooks.cjs +40 -1
  10. package/dist/cjs/hooks/executeHooks.cjs.map +1 -1
  11. package/dist/cjs/hooks/types.cjs +1 -0
  12. package/dist/cjs/hooks/types.cjs.map +1 -1
  13. package/dist/cjs/llm/openai/index.cjs +317 -1
  14. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  15. package/dist/cjs/main.cjs +29 -0
  16. package/dist/cjs/main.cjs.map +1 -1
  17. package/dist/cjs/run.cjs +400 -42
  18. package/dist/cjs/run.cjs.map +1 -1
  19. package/dist/cjs/tools/ToolNode.cjs +551 -55
  20. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  21. package/dist/cjs/tools/search/tavily-scraper.cjs.map +1 -1
  22. package/dist/cjs/tools/search/tavily-search.cjs.map +1 -1
  23. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  24. package/dist/esm/graphs/Graph.mjs +9 -0
  25. package/dist/esm/graphs/Graph.mjs.map +1 -1
  26. package/dist/esm/hitl/askUserQuestion.mjs +65 -0
  27. package/dist/esm/hitl/askUserQuestion.mjs.map +1 -0
  28. package/dist/esm/hooks/HookRegistry.mjs +54 -0
  29. package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
  30. package/dist/esm/hooks/createToolPolicyHook.mjs +113 -0
  31. package/dist/esm/hooks/createToolPolicyHook.mjs.map +1 -0
  32. package/dist/esm/hooks/executeHooks.mjs +40 -1
  33. package/dist/esm/hooks/executeHooks.mjs.map +1 -1
  34. package/dist/esm/hooks/types.mjs +1 -0
  35. package/dist/esm/hooks/types.mjs.map +1 -1
  36. package/dist/esm/llm/openai/index.mjs +318 -2
  37. package/dist/esm/llm/openai/index.mjs.map +1 -1
  38. package/dist/esm/main.mjs +3 -0
  39. package/dist/esm/main.mjs.map +1 -1
  40. package/dist/esm/run.mjs +400 -42
  41. package/dist/esm/run.mjs.map +1 -1
  42. package/dist/esm/tools/ToolNode.mjs +552 -56
  43. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  44. package/dist/esm/tools/search/tavily-scraper.mjs.map +1 -1
  45. package/dist/esm/tools/search/tavily-search.mjs.map +1 -1
  46. package/dist/esm/tools/search/tool.mjs.map +1 -1
  47. package/dist/types/graphs/Graph.d.ts +7 -0
  48. package/dist/types/hitl/askUserQuestion.d.ts +55 -0
  49. package/dist/types/hitl/index.d.ts +6 -0
  50. package/dist/types/hooks/HookRegistry.d.ts +58 -0
  51. package/dist/types/hooks/createToolPolicyHook.d.ts +87 -0
  52. package/dist/types/hooks/index.d.ts +4 -1
  53. package/dist/types/hooks/types.d.ts +109 -3
  54. package/dist/types/index.d.ts +9 -0
  55. package/dist/types/llm/openai/index.d.ts +17 -0
  56. package/dist/types/run.d.ts +117 -1
  57. package/dist/types/tools/ToolNode.d.ts +26 -1
  58. package/dist/types/types/hitl.d.ts +272 -0
  59. package/dist/types/types/index.d.ts +1 -0
  60. package/dist/types/types/run.d.ts +33 -0
  61. package/dist/types/types/tools.d.ts +19 -0
  62. package/package.json +1 -1
  63. package/src/graphs/Graph.ts +9 -0
  64. package/src/hitl/askUserQuestion.ts +72 -0
  65. package/src/hitl/index.ts +7 -0
  66. package/src/hooks/HookRegistry.ts +71 -0
  67. package/src/hooks/__tests__/createToolPolicyHook.test.ts +259 -0
  68. package/src/hooks/createToolPolicyHook.ts +184 -0
  69. package/src/hooks/executeHooks.ts +50 -1
  70. package/src/hooks/index.ts +6 -0
  71. package/src/hooks/types.ts +112 -0
  72. package/src/index.ts +19 -0
  73. package/src/llm/openai/deepseek.test.ts +479 -0
  74. package/src/llm/openai/index.ts +484 -1
  75. package/src/run.ts +456 -47
  76. package/src/tools/ToolNode.ts +701 -62
  77. package/src/tools/__tests__/hitl.test.ts +3593 -0
  78. package/src/tools/search/tavily-scraper.ts +4 -4
  79. package/src/tools/search/tavily-search.ts +32 -32
  80. package/src/tools/search/tool.ts +3 -3
  81. package/src/tools/search/types.ts +3 -1
  82. package/src/types/hitl.ts +303 -0
  83. package/src/types/index.ts +1 -0
  84. package/src/types/run.ts +33 -0
  85. package/src/types/tools.ts +19 -0
@@ -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
+ });