@librechat/agents 3.2.46 → 3.2.51
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/llm/anthropic/index.cjs +4 -3
- package/dist/cjs/llm/anthropic/index.cjs.map +1 -1
- package/dist/cjs/llm/anthropic/utils/tools.cjs +2 -1
- package/dist/cjs/llm/anthropic/utils/tools.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/cachePoints.cjs +28 -0
- package/dist/cjs/llm/bedrock/cachePoints.cjs.map +1 -0
- package/dist/cjs/llm/bedrock/index.cjs +10 -1
- package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
- package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
- package/dist/esm/llm/anthropic/index.mjs +4 -3
- package/dist/esm/llm/anthropic/index.mjs.map +1 -1
- package/dist/esm/llm/anthropic/utils/tools.mjs +2 -1
- package/dist/esm/llm/anthropic/utils/tools.mjs.map +1 -1
- package/dist/esm/llm/bedrock/cachePoints.mjs +28 -0
- package/dist/esm/llm/bedrock/cachePoints.mjs.map +1 -0
- package/dist/esm/llm/bedrock/index.mjs +10 -1
- package/dist/esm/llm/bedrock/index.mjs.map +1 -1
- package/dist/esm/llm/openrouter/index.mjs.map +1 -1
- package/dist/types/llm/anthropic/utils/tools.d.ts +1 -1
- package/dist/types/llm/bedrock/cachePoints.d.ts +9 -0
- package/package.json +16 -21
- package/src/llm/anthropic/index.ts +13 -2
- package/src/llm/anthropic/inherited-content-utils.spec.ts +244 -0
- package/src/llm/anthropic/inherited-stream-events.spec.ts +752 -0
- package/src/llm/anthropic/inherited-strict.spec.ts +302 -0
- package/src/llm/anthropic/llm.spec.ts +65 -0
- package/src/llm/anthropic/utils/tools.ts +7 -1
- package/src/llm/bedrock/cachePoints.ts +86 -0
- package/src/llm/bedrock/index.ts +9 -0
- package/src/llm/bedrock/inherited-cache.spec.ts +144 -0
- package/src/llm/bedrock/inherited.spec.ts +724 -0
- package/src/llm/google/inherited-stream-events.spec.ts +350 -0
- package/src/llm/openai/inherited-deepseek.spec.ts +347 -0
- package/src/llm/openai/inherited-xai.spec.ts +416 -0
- package/src/llm/openai/llm.spec.ts +1568 -0
- package/src/llm/openrouter/index.ts +1 -3
- package/src/llm/vertexai/inherited-stream-events.spec.ts +271 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
// Inherited stream-events specs for the LibreChat Google fork
|
|
2
|
+
// (`CustomChatGoogleGenerativeAI` from `@/llm/google`), merged from two
|
|
3
|
+
// upstream-derived suites adapted to the fork's actual surface:
|
|
4
|
+
//
|
|
5
|
+
// 1. converter — Inherited from @langchain/google-genai@2.2.0
|
|
6
|
+
// src/utils/stream_events.test.ts (tests `convertGoogleGenAIStream`,
|
|
7
|
+
// the raw Gemini stream -> `ChatModelStreamEvent` converter).
|
|
8
|
+
// `convertGoogleGenAIStream` is NOT exported from the package root and the
|
|
9
|
+
// deep subpath (`dist/utils/stream_events.js`) is blocked by the package
|
|
10
|
+
// `exports` map (`ERR_PACKAGE_PATH_NOT_EXPORTED`), so it cannot be imported
|
|
11
|
+
// and unit-tested directly. Instead each converter case is routed through
|
|
12
|
+
// the fork's inherited `_streamChatModelEvents` (which calls the converter
|
|
13
|
+
// internally) and asserted on the collected `ChatModelStreamEvent[]`.
|
|
14
|
+
//
|
|
15
|
+
// 2. stream events — Inherited from @langchain/google-genai@2.2.0
|
|
16
|
+
// src/tests/chat_models_stream_events.test.ts (tests
|
|
17
|
+
// `ChatGoogleGenerativeAI.streamEvents()` typed sub-streams).
|
|
18
|
+
// The fork overrides the legacy `_streamResponseChunks` / `_generate` /
|
|
19
|
+
// `invocationParams` but NOT `_streamChatModelEvents`, so it inherits the
|
|
20
|
+
// native streamEvents protocol. The Google transport is mocked the way
|
|
21
|
+
// `src/llm/google/llm.spec.ts` accesses it: by spying on the private
|
|
22
|
+
// `client.generateContentStream`. The upstream suite used vitest custom
|
|
23
|
+
// matchers (`toHaveStreamText` / `toHaveStreamReasoning` /
|
|
24
|
+
// `toHaveStreamToolCalls` / `toHaveStreamUsage`) unavailable in jest; they
|
|
25
|
+
// are re-expressed against the public `ChatModelStream` sub-streams
|
|
26
|
+
// (`.text` / `.reasoning` / `.toolCalls` / `.usage`) that those matchers
|
|
27
|
+
// wrap.
|
|
28
|
+
|
|
29
|
+
import { HumanMessage, SystemMessage } from '@langchain/core/messages';
|
|
30
|
+
import { describe, test, expect, jest, afterEach } from '@jest/globals';
|
|
31
|
+
import { ChatModelStream } from '@langchain/core/language_models/stream';
|
|
32
|
+
import type { ChatModelStreamEvent } from '@langchain/core/language_models/event';
|
|
33
|
+
import type { GenerateContentRequest } from '@google/generative-ai';
|
|
34
|
+
import type { BaseMessage } from '@langchain/core/messages';
|
|
35
|
+
import { CustomChatGoogleGenerativeAI } from '@/llm/google';
|
|
36
|
+
|
|
37
|
+
type GeminiStreamChunk = Record<string, unknown>;
|
|
38
|
+
|
|
39
|
+
type TestGoogleGenAIClient = {
|
|
40
|
+
systemInstruction?: unknown;
|
|
41
|
+
generateContentStream: (
|
|
42
|
+
request: GenerateContentRequest,
|
|
43
|
+
options?: unknown
|
|
44
|
+
) => Promise<{ stream: AsyncIterable<GeminiStreamChunk> }>;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type StreamEventsModel = {
|
|
48
|
+
_streamChatModelEvents: (
|
|
49
|
+
messages: BaseMessage[],
|
|
50
|
+
options: Record<string, unknown>
|
|
51
|
+
) => AsyncGenerator<ChatModelStreamEvent>;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function getTestClient(
|
|
55
|
+
model: CustomChatGoogleGenerativeAI
|
|
56
|
+
): TestGoogleGenAIClient {
|
|
57
|
+
return (model as unknown as { client: TestGoogleGenAIClient }).client;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function toStream(
|
|
61
|
+
chunks: GeminiStreamChunk[]
|
|
62
|
+
): AsyncIterable<GeminiStreamChunk> {
|
|
63
|
+
return (async function* () {
|
|
64
|
+
for (const chunk of chunks) {
|
|
65
|
+
yield chunk;
|
|
66
|
+
}
|
|
67
|
+
})();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function newModel(): CustomChatGoogleGenerativeAI {
|
|
71
|
+
return new CustomChatGoogleGenerativeAI({
|
|
72
|
+
apiKey: 'fake-key',
|
|
73
|
+
model: 'gemini-2.0-flash',
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function mockGoogleGenAI(
|
|
78
|
+
stream: AsyncIterable<GeminiStreamChunk>
|
|
79
|
+
): CustomChatGoogleGenerativeAI {
|
|
80
|
+
const model = newModel();
|
|
81
|
+
jest
|
|
82
|
+
.spyOn(getTestClient(model), 'generateContentStream')
|
|
83
|
+
.mockResolvedValue({ stream });
|
|
84
|
+
return model;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function streamEvents(
|
|
88
|
+
model: CustomChatGoogleGenerativeAI,
|
|
89
|
+
messages: BaseMessage[] = [new HumanMessage('Hello')],
|
|
90
|
+
options: Record<string, unknown> = {}
|
|
91
|
+
): AsyncGenerator<ChatModelStreamEvent> {
|
|
92
|
+
return (model as unknown as StreamEventsModel)._streamChatModelEvents(
|
|
93
|
+
messages,
|
|
94
|
+
options
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function collectEvents(
|
|
99
|
+
chunks: GeminiStreamChunk[],
|
|
100
|
+
options: Record<string, unknown> = {}
|
|
101
|
+
): Promise<ChatModelStreamEvent[]> {
|
|
102
|
+
const events: ChatModelStreamEvent[] = [];
|
|
103
|
+
for await (const event of streamEvents(
|
|
104
|
+
mockGoogleGenAI(toStream(chunks)),
|
|
105
|
+
[new HumanMessage('Hello')],
|
|
106
|
+
options
|
|
107
|
+
)) {
|
|
108
|
+
events.push(event);
|
|
109
|
+
}
|
|
110
|
+
return events;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
afterEach(() => {
|
|
114
|
+
jest.restoreAllMocks();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Routed from upstream `convertGoogleGenAIStream` unit tests: the converter is
|
|
118
|
+
// not importable from the fork, so each case drives the inherited
|
|
119
|
+
// `_streamChatModelEvents` (which invokes the converter) and asserts the
|
|
120
|
+
// emitted `ChatModelStreamEvent[]`.
|
|
121
|
+
describe('convertGoogleGenAIStream (via inherited _streamChatModelEvents)', () => {
|
|
122
|
+
test('text-only streaming', async () => {
|
|
123
|
+
const events = await collectEvents([
|
|
124
|
+
{ candidates: [{ content: { parts: [{ text: 'Hello' }] } }] },
|
|
125
|
+
{ candidates: [{ content: { parts: [{ text: ' world' }] } }] },
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
expect(
|
|
129
|
+
events.find((e) => e.event === 'content-block-finish')
|
|
130
|
+
).toMatchObject({
|
|
131
|
+
content: { text: 'Hello world' },
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('maps Gemini finish reasons', async () => {
|
|
136
|
+
const lengthEvents = await collectEvents([
|
|
137
|
+
{
|
|
138
|
+
candidates: [
|
|
139
|
+
{
|
|
140
|
+
content: { parts: [{ text: 'Hello' }] },
|
|
141
|
+
finishReason: 'MAX_TOKENS',
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
},
|
|
145
|
+
]);
|
|
146
|
+
expect(
|
|
147
|
+
lengthEvents.find((e) => e.event === 'message-finish')
|
|
148
|
+
).toMatchObject({ reason: 'length' });
|
|
149
|
+
|
|
150
|
+
const filterEvents = await collectEvents([
|
|
151
|
+
{
|
|
152
|
+
candidates: [
|
|
153
|
+
{
|
|
154
|
+
content: { parts: [{ text: 'Hello' }] },
|
|
155
|
+
finishReason: 'SAFETY',
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
]);
|
|
160
|
+
expect(
|
|
161
|
+
filterEvents.find((e) => e.event === 'message-finish')
|
|
162
|
+
).toMatchObject({ reason: 'content_filter' });
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('thought parts map to reasoning', async () => {
|
|
166
|
+
const events = await collectEvents([
|
|
167
|
+
{
|
|
168
|
+
candidates: [
|
|
169
|
+
{
|
|
170
|
+
content: { parts: [{ text: 'Let me think', thought: true }] },
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
},
|
|
174
|
+
]);
|
|
175
|
+
|
|
176
|
+
expect(
|
|
177
|
+
events.find(
|
|
178
|
+
(e) =>
|
|
179
|
+
e.event === 'content-block-finish' &&
|
|
180
|
+
(
|
|
181
|
+
e as Extract<
|
|
182
|
+
ChatModelStreamEvent,
|
|
183
|
+
{ event: 'content-block-finish' }
|
|
184
|
+
>
|
|
185
|
+
).content.type === 'reasoning'
|
|
186
|
+
)
|
|
187
|
+
).toMatchObject({
|
|
188
|
+
content: { reasoning: 'Let me think' },
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
function geminiTextStream(): AsyncIterable<GeminiStreamChunk> {
|
|
194
|
+
return toStream([
|
|
195
|
+
{ candidates: [{ content: { parts: [{ text: 'Hello' }] } }] },
|
|
196
|
+
{ candidates: [{ content: { parts: [{ text: ' world' }] } }] },
|
|
197
|
+
]);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function geminiReasoningStream(): AsyncIterable<GeminiStreamChunk> {
|
|
201
|
+
return toStream([
|
|
202
|
+
{
|
|
203
|
+
candidates: [
|
|
204
|
+
{ content: { parts: [{ text: 'Let me reason...', thought: true }] } },
|
|
205
|
+
],
|
|
206
|
+
},
|
|
207
|
+
]);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function geminiToolStream(): AsyncIterable<GeminiStreamChunk> {
|
|
211
|
+
return toStream([
|
|
212
|
+
{ candidates: [{ content: { parts: [{ text: 'Let me search.' }] } }] },
|
|
213
|
+
{
|
|
214
|
+
candidates: [
|
|
215
|
+
{
|
|
216
|
+
content: {
|
|
217
|
+
parts: [
|
|
218
|
+
{
|
|
219
|
+
functionCall: {
|
|
220
|
+
name: 'web_search',
|
|
221
|
+
args: { query: 'weather' },
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
],
|
|
228
|
+
},
|
|
229
|
+
]);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function geminiUsageStream(): AsyncIterable<GeminiStreamChunk> {
|
|
233
|
+
return toStream([
|
|
234
|
+
{
|
|
235
|
+
usageMetadata: {
|
|
236
|
+
promptTokenCount: 10,
|
|
237
|
+
candidatesTokenCount: 4,
|
|
238
|
+
totalTokenCount: 14,
|
|
239
|
+
},
|
|
240
|
+
candidates: [{ content: { parts: [{ text: 'Hi' }] } }],
|
|
241
|
+
},
|
|
242
|
+
]);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Re-expressed from upstream `ChatGoogleGenerativeAI.streamEvents` describe.
|
|
246
|
+
// The vitest matchers (`toHaveStreamText` etc.) are unavailable in jest, so the
|
|
247
|
+
// cases assert the `ChatModelStream` sub-streams those matchers wrap.
|
|
248
|
+
describe('CustomChatGoogleGenerativeAI.streamEvents (sub-stream assertions)', () => {
|
|
249
|
+
test('streams text', async () => {
|
|
250
|
+
const stream = new ChatModelStream(
|
|
251
|
+
streamEvents(mockGoogleGenAI(geminiTextStream())) as never
|
|
252
|
+
);
|
|
253
|
+
expect(await stream.text).toBe('Hello world');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('streams reasoning', async () => {
|
|
257
|
+
const stream = new ChatModelStream(
|
|
258
|
+
streamEvents(mockGoogleGenAI(geminiReasoningStream())) as never
|
|
259
|
+
);
|
|
260
|
+
expect(await stream.reasoning).toBe('Let me reason...');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('streams tool calls', async () => {
|
|
264
|
+
const stream = new ChatModelStream(
|
|
265
|
+
streamEvents(mockGoogleGenAI(geminiToolStream())) as never
|
|
266
|
+
);
|
|
267
|
+
const calls = await stream.toolCalls;
|
|
268
|
+
expect(calls).toEqual([
|
|
269
|
+
expect.objectContaining({
|
|
270
|
+
name: 'web_search',
|
|
271
|
+
args: { query: 'weather' },
|
|
272
|
+
}),
|
|
273
|
+
]);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('streams usage', async () => {
|
|
277
|
+
const stream = new ChatModelStream(
|
|
278
|
+
streamEvents(
|
|
279
|
+
mockGoogleGenAI(geminiUsageStream()),
|
|
280
|
+
[new HumanMessage('Hello')],
|
|
281
|
+
{ streamUsage: true }
|
|
282
|
+
) as never
|
|
283
|
+
);
|
|
284
|
+
expect(await stream.usage).toMatchObject({
|
|
285
|
+
input_tokens: 10,
|
|
286
|
+
output_tokens: 4,
|
|
287
|
+
total_tokens: 14,
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test('passes system instructions per streamEvents request', async () => {
|
|
292
|
+
const model = newModel();
|
|
293
|
+
const generateContentStream = jest
|
|
294
|
+
.spyOn(getTestClient(model), 'generateContentStream')
|
|
295
|
+
.mockResolvedValue({ stream: geminiTextStream() });
|
|
296
|
+
|
|
297
|
+
const stream = new ChatModelStream(
|
|
298
|
+
streamEvents(model, [
|
|
299
|
+
new SystemMessage('StreamV2 system instruction'),
|
|
300
|
+
new HumanMessage('Hello'),
|
|
301
|
+
]) as never
|
|
302
|
+
);
|
|
303
|
+
expect(await stream.text).toBe('Hello world');
|
|
304
|
+
|
|
305
|
+
const [[request]] = generateContentStream.mock.calls;
|
|
306
|
+
expect(request.systemInstruction).toEqual({
|
|
307
|
+
role: 'system',
|
|
308
|
+
parts: [{ text: 'StreamV2 system instruction' }],
|
|
309
|
+
});
|
|
310
|
+
expect(request.contents).toEqual([
|
|
311
|
+
{ role: 'user', parts: [{ text: 'Hello' }] },
|
|
312
|
+
]);
|
|
313
|
+
expect(getTestClient(model).systemInstruction).toBeUndefined();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// FORK DIVERGENCE (not a bug): upstream's `_streamChatModelEvents` request
|
|
317
|
+
// builder sets `request.systemInstruction` per call and leaves
|
|
318
|
+
// `client.systemInstruction` undefined. The fork's overridden legacy
|
|
319
|
+
// `_streamResponseChunks` (used by `.stream()`) keeps the old
|
|
320
|
+
// `@google/generative-ai` convention instead: it assigns the system message
|
|
321
|
+
// to `client.systemInstruction` and omits it from the request. The system
|
|
322
|
+
// instruction is still applied correctly, just via a different mechanism, so
|
|
323
|
+
// this case asserts the fork's actual behavior rather than upstream's.
|
|
324
|
+
test('passes system instructions per stream request', async () => {
|
|
325
|
+
const model = newModel();
|
|
326
|
+
const generateContentStream = jest
|
|
327
|
+
.spyOn(getTestClient(model), 'generateContentStream')
|
|
328
|
+
.mockResolvedValue({ stream: geminiTextStream() });
|
|
329
|
+
|
|
330
|
+
const stream = await model.stream([
|
|
331
|
+
new SystemMessage('Stream system instruction'),
|
|
332
|
+
new HumanMessage('Hello'),
|
|
333
|
+
]);
|
|
334
|
+
const chunks: string[] = [];
|
|
335
|
+
for await (const chunk of stream) {
|
|
336
|
+
chunks.push(chunk.text);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const [[request]] = generateContentStream.mock.calls;
|
|
340
|
+
expect(chunks.join('')).toBe('Hello world');
|
|
341
|
+
expect(request.systemInstruction).toBeUndefined();
|
|
342
|
+
expect(request.contents).toEqual([
|
|
343
|
+
{ role: 'user', parts: [{ text: 'Hello' }] },
|
|
344
|
+
]);
|
|
345
|
+
expect(getTestClient(model).systemInstruction).toEqual({
|
|
346
|
+
role: 'system',
|
|
347
|
+
parts: [{ text: 'Stream system instruction' }],
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
});
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
// Inherited DeepSeek specs for the LibreChat `@langchain/agents` fork, ported
|
|
2
|
+
// from three upstream @langchain/deepseek@1.1.3 suites and adapted to the
|
|
3
|
+
// fork's actual surface (`ChatDeepSeek` from `@/llm/openai`):
|
|
4
|
+
//
|
|
5
|
+
// 1. reasoning — Inherited from @langchain/deepseek@1.1.3
|
|
6
|
+
// src/tests/chat_models_reasoning.test.ts.
|
|
7
|
+
// Upstream drives `model.stream("hi")` through a `configuration.fetch`
|
|
8
|
+
// SSE mock. Our fork heavily overrides the legacy streaming path
|
|
9
|
+
// (`_streamResponseChunks` -> `_streamResponseChunksWithReasoning`) to
|
|
10
|
+
// split `<think>` fallback tags into `additional_kwargs.reasoning_content`.
|
|
11
|
+
// We exercise that override directly by overriding `completionWithRetry`
|
|
12
|
+
// (the `deepseek.test.ts` harness pattern) and feeding OpenAI-shaped
|
|
13
|
+
// chunks. The fork's `<think>` parser reproduces upstream's observable
|
|
14
|
+
// content/reasoning split exactly for every deterministic case below.
|
|
15
|
+
//
|
|
16
|
+
// 2. stream events — Inherited from @langchain/deepseek@1.1.3
|
|
17
|
+
// src/tests/chat_models_stream_events.test.ts.
|
|
18
|
+
// Our `ChatDeepSeek` does NOT override `_streamChatModelEvents`; it
|
|
19
|
+
// inherits the native `ChatModelStreamEvent` protocol from upstream's
|
|
20
|
+
// ChatOpenAICompletions, a separate code path from the legacy
|
|
21
|
+
// `_streamResponseChunks` override. These are kept as a parity check.
|
|
22
|
+
// Upstream's vitest custom matchers (`toHaveStreamText` /
|
|
23
|
+
// `toHaveStreamReasoning` / `toHaveStreamToolCalls`) do not exist in jest;
|
|
24
|
+
// they are re-expressed against the public `ChatModelStream` sub-streams
|
|
25
|
+
// (`.text` / `.reasoning` / `.toolCalls`) that those matchers wrap.
|
|
26
|
+
//
|
|
27
|
+
// 3. standard unit tests — Inherited from @langchain/deepseek@1.1.3
|
|
28
|
+
// src/tests/chat_models.standard.test.ts.
|
|
29
|
+
// Upstream runs `ChatModelUnitTests` from `@langchain/standard-tests/vitest`.
|
|
30
|
+
// That package is vitest-only and is not installed in this repo, so the
|
|
31
|
+
// harness class itself is dropped. Its deterministic, fork-relevant
|
|
32
|
+
// assertions (api-key precedence, tool-calling, structured output, model
|
|
33
|
+
// field) are re-expressed inline against the fork.
|
|
34
|
+
|
|
35
|
+
import { describe, it, test, expect } from '@jest/globals';
|
|
36
|
+
import { AIMessageChunk, HumanMessage } from '@langchain/core/messages';
|
|
37
|
+
import { ChatModelStream } from '@langchain/core/language_models/stream';
|
|
38
|
+
import {
|
|
39
|
+
openAIReasoningTextChunks,
|
|
40
|
+
openAITextOnlyChunks,
|
|
41
|
+
openAIToolCallChunks,
|
|
42
|
+
} from '@langchain/core/testing';
|
|
43
|
+
import type { ChatModelStreamEvent } from '@langchain/core/language_models/event';
|
|
44
|
+
import type { OpenAIClient } from '@langchain/openai';
|
|
45
|
+
import { ChatDeepSeek } from '@/llm/openai';
|
|
46
|
+
|
|
47
|
+
type RawChunk = OpenAIClient.Chat.Completions.ChatCompletionChunk;
|
|
48
|
+
|
|
49
|
+
function createContentChunk(content: string): RawChunk {
|
|
50
|
+
return {
|
|
51
|
+
id: 'chatcmpl-123',
|
|
52
|
+
object: 'chat.completion.chunk',
|
|
53
|
+
created: 0,
|
|
54
|
+
model: 'deepseek-chat',
|
|
55
|
+
choices: [
|
|
56
|
+
{
|
|
57
|
+
index: 0,
|
|
58
|
+
delta: { content },
|
|
59
|
+
finish_reason: null,
|
|
60
|
+
logprobs: null,
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function createStopChunk(): RawChunk {
|
|
67
|
+
return {
|
|
68
|
+
id: 'chatcmpl-123',
|
|
69
|
+
object: 'chat.completion.chunk',
|
|
70
|
+
created: 0,
|
|
71
|
+
model: 'deepseek-chat',
|
|
72
|
+
choices: [
|
|
73
|
+
{
|
|
74
|
+
index: 0,
|
|
75
|
+
delta: {},
|
|
76
|
+
finish_reason: 'stop',
|
|
77
|
+
logprobs: null,
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Mirrors the `deepseek.test.ts` / smoke-test transport mock: override
|
|
85
|
+
* `completionWithRetry` so the legacy streaming path consumes fixed chunks.
|
|
86
|
+
*/
|
|
87
|
+
class MockStreamChatDeepSeek extends ChatDeepSeek {
|
|
88
|
+
constructor(private readonly chunks: RawChunk[]) {
|
|
89
|
+
super({ apiKey: 'fake-key', model: 'deepseek-chat', streaming: true });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async completionWithRetry(
|
|
93
|
+
request: OpenAIClient.Chat.ChatCompletionCreateParamsStreaming,
|
|
94
|
+
requestOptions?: OpenAIClient.RequestOptions
|
|
95
|
+
): Promise<AsyncIterable<RawChunk>>;
|
|
96
|
+
async completionWithRetry(
|
|
97
|
+
request: OpenAIClient.Chat.ChatCompletionCreateParamsNonStreaming,
|
|
98
|
+
requestOptions?: OpenAIClient.RequestOptions
|
|
99
|
+
): Promise<OpenAIClient.Chat.Completions.ChatCompletion>;
|
|
100
|
+
async completionWithRetry(): Promise<
|
|
101
|
+
AsyncIterable<RawChunk> | OpenAIClient.Chat.Completions.ChatCompletion
|
|
102
|
+
> {
|
|
103
|
+
const chunks = this.chunks;
|
|
104
|
+
return {
|
|
105
|
+
async *[Symbol.asyncIterator](): AsyncGenerator<RawChunk> {
|
|
106
|
+
for (const chunk of chunks) {
|
|
107
|
+
yield chunk;
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
type AggregatedStream = { content: string; reasoning: string };
|
|
115
|
+
|
|
116
|
+
async function streamThinkContents(
|
|
117
|
+
contents: string[]
|
|
118
|
+
): Promise<AggregatedStream> {
|
|
119
|
+
const chunks = [...contents.map(createContentChunk), createStopChunk()];
|
|
120
|
+
const model = new MockStreamChatDeepSeek(chunks);
|
|
121
|
+
|
|
122
|
+
const collected: AIMessageChunk[] = [];
|
|
123
|
+
for await (const chunk of await model.stream([new HumanMessage('hi')])) {
|
|
124
|
+
collected.push(chunk);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const content = collected
|
|
128
|
+
.map((chunk) => (typeof chunk.content === 'string' ? chunk.content : ''))
|
|
129
|
+
.join('');
|
|
130
|
+
const reasoning = collected
|
|
131
|
+
.map((chunk) => (chunk.additional_kwargs.reasoning_content as string) ?? '')
|
|
132
|
+
.join('');
|
|
133
|
+
return { content, reasoning };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function toDeepSeekChunks(
|
|
137
|
+
chunks: ReturnType<typeof openAITextOnlyChunks>
|
|
138
|
+
): RawChunk[] {
|
|
139
|
+
return chunks.map((chunk) => ({
|
|
140
|
+
...chunk,
|
|
141
|
+
id: chunk.id ?? 'chatcmpl-test',
|
|
142
|
+
object: 'chat.completion.chunk',
|
|
143
|
+
created: 0,
|
|
144
|
+
model: chunk.model ?? 'deepseek-chat',
|
|
145
|
+
service_tier: null,
|
|
146
|
+
choices: (chunk.choices ?? []).map((choice) => ({
|
|
147
|
+
...choice,
|
|
148
|
+
delta: choice.delta ?? {},
|
|
149
|
+
})) as RawChunk['choices'],
|
|
150
|
+
})) as RawChunk[];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
type StreamEventsModel = {
|
|
154
|
+
_streamChatModelEvents: (
|
|
155
|
+
messages: unknown[],
|
|
156
|
+
options: Record<string, unknown>
|
|
157
|
+
) => AsyncGenerator<ChatModelStreamEvent>;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
function streamEvents(
|
|
161
|
+
model: ChatDeepSeek
|
|
162
|
+
): AsyncGenerator<ChatModelStreamEvent> {
|
|
163
|
+
return (model as unknown as StreamEventsModel)._streamChatModelEvents([], {});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
describe('ChatDeepSeek (inherited @langchain/deepseek@1.1.3 reasoning)', () => {
|
|
167
|
+
// Dropped (inherited): `new ChatDeepSeek("deepseek-chat", {...})` shorthand —
|
|
168
|
+
// the fork constructor only accepts a single fields object
|
|
169
|
+
// (ConstructorParameters<typeof OriginalChatDeepSeek>[0]); the positional
|
|
170
|
+
// (model, fields) overload does not type-check. Model-field init is covered
|
|
171
|
+
// in the standard-unit-tests describe below.
|
|
172
|
+
|
|
173
|
+
test('separates <think> tags into reasoning_content', async () => {
|
|
174
|
+
const { content, reasoning } = await streamThinkContents([
|
|
175
|
+
'<think>',
|
|
176
|
+
'thinking process...',
|
|
177
|
+
'</think>',
|
|
178
|
+
'Hello world',
|
|
179
|
+
]);
|
|
180
|
+
|
|
181
|
+
// Parity with upstream: tags stripped, thought routed to reasoning.
|
|
182
|
+
expect(content).toBe('Hello world');
|
|
183
|
+
expect(reasoning).toBe('thinking process...');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('handles multiple think blocks and content before/after', async () => {
|
|
187
|
+
const { content, reasoning } = await streamThinkContents([
|
|
188
|
+
'Start ',
|
|
189
|
+
'<think>',
|
|
190
|
+
'Reason 1',
|
|
191
|
+
'</think>',
|
|
192
|
+
' Middle ',
|
|
193
|
+
'<think>',
|
|
194
|
+
'Reason 2',
|
|
195
|
+
'</think>',
|
|
196
|
+
' End',
|
|
197
|
+
]);
|
|
198
|
+
|
|
199
|
+
// Parity with upstream.
|
|
200
|
+
expect(content).toBe('Start Middle End');
|
|
201
|
+
expect(reasoning).toBe('Reason 1Reason 2');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('handles unclosed think tags (flush at end)', async () => {
|
|
205
|
+
const { reasoning } = await streamThinkContents([
|
|
206
|
+
'Start ',
|
|
207
|
+
'<think>',
|
|
208
|
+
'Unclosed thought',
|
|
209
|
+
]);
|
|
210
|
+
|
|
211
|
+
// Parity with upstream: trailing open block flushes as reasoning.
|
|
212
|
+
expect(reasoning).toBe('Unclosed thought');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('handles split tags across chunks', async () => {
|
|
216
|
+
const { reasoning } = await streamThinkContents([
|
|
217
|
+
'<th',
|
|
218
|
+
'ink>Thought',
|
|
219
|
+
'</th',
|
|
220
|
+
'ink>',
|
|
221
|
+
]);
|
|
222
|
+
|
|
223
|
+
// Parity with upstream: partial-tag buffering reassembles boundaries.
|
|
224
|
+
expect(reasoning).toBe('Thought');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('handles empty think blocks', async () => {
|
|
228
|
+
const { content, reasoning } = await streamThinkContents([
|
|
229
|
+
'Before ',
|
|
230
|
+
'<think>',
|
|
231
|
+
'</think>',
|
|
232
|
+
' After',
|
|
233
|
+
]);
|
|
234
|
+
|
|
235
|
+
// Parity with upstream.
|
|
236
|
+
expect(content).toBe('Before After');
|
|
237
|
+
expect(reasoning).toBe('');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('handles nested think tags (inner treated as reasoning text)', async () => {
|
|
241
|
+
const { content, reasoning } = await streamThinkContents([
|
|
242
|
+
'<think>',
|
|
243
|
+
'Outer ',
|
|
244
|
+
'<think>',
|
|
245
|
+
'Inner',
|
|
246
|
+
'</think>',
|
|
247
|
+
' Content',
|
|
248
|
+
]);
|
|
249
|
+
|
|
250
|
+
// Parity with upstream: first </think> closes the outer block; the inner
|
|
251
|
+
// <think> is kept verbatim inside the reasoning text.
|
|
252
|
+
expect(content).toBe(' Content');
|
|
253
|
+
expect(reasoning).toBe('Outer <think>Inner');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('handles malformed tags gracefully', async () => {
|
|
257
|
+
const { content } = await streamThinkContents([
|
|
258
|
+
'</think>',
|
|
259
|
+
'Text ',
|
|
260
|
+
'<think',
|
|
261
|
+
' more',
|
|
262
|
+
]);
|
|
263
|
+
|
|
264
|
+
// Parity with upstream's loose assertion: orphan/incomplete tags are
|
|
265
|
+
// surfaced as content. The fork emits the full
|
|
266
|
+
// "</think>Text <think more" verbatim.
|
|
267
|
+
expect(content).toContain('Text');
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe('ChatDeepSeek.streamEvents (inherited @langchain/deepseek@1.1.3, native path)', () => {
|
|
272
|
+
// The fork inherits `_streamChatModelEvents` from upstream's completions
|
|
273
|
+
// model (no override), so the native ChatModelStreamEvent protocol behaves
|
|
274
|
+
// identically — kept as a parity check.
|
|
275
|
+
|
|
276
|
+
test('streams text', async () => {
|
|
277
|
+
const model = new MockStreamChatDeepSeek(
|
|
278
|
+
toDeepSeekChunks(openAITextOnlyChunks())
|
|
279
|
+
);
|
|
280
|
+
const stream = new ChatModelStream(streamEvents(model));
|
|
281
|
+
expect(await stream.text).toBe('Hello world');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test('streams reasoning', async () => {
|
|
285
|
+
const model = new MockStreamChatDeepSeek(
|
|
286
|
+
toDeepSeekChunks(openAIReasoningTextChunks())
|
|
287
|
+
);
|
|
288
|
+
const stream = new ChatModelStream(streamEvents(model));
|
|
289
|
+
expect(await stream.reasoning).toBe('Let me reason...');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('streams tool calls', async () => {
|
|
293
|
+
const model = new MockStreamChatDeepSeek(
|
|
294
|
+
toDeepSeekChunks(openAIToolCallChunks())
|
|
295
|
+
);
|
|
296
|
+
const stream = new ChatModelStream(streamEvents(model));
|
|
297
|
+
const calls = await stream.toolCalls;
|
|
298
|
+
expect(calls).toEqual([
|
|
299
|
+
expect.objectContaining({
|
|
300
|
+
name: 'web_search',
|
|
301
|
+
args: { query: 'weather' },
|
|
302
|
+
}),
|
|
303
|
+
]);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
describe('ChatDeepSeek (inherited @langchain/deepseek@1.1.3 standard unit tests)', () => {
|
|
308
|
+
// Re-expressed from upstream's `ChatModelUnitTests` standard suite, which
|
|
309
|
+
// imports `@langchain/standard-tests/vitest` (vitest-only, not installed).
|
|
310
|
+
// The harness class is dropped; its deterministic assertions are inlined.
|
|
311
|
+
|
|
312
|
+
it('initializes the model field from constructor args', () => {
|
|
313
|
+
process.env.DEEPSEEK_API_KEY = 'test';
|
|
314
|
+
const model = new ChatDeepSeek({ model: 'deepseek-chat', apiKey: 'test' });
|
|
315
|
+
expect(model.model).toBe('deepseek-chat');
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('reads the api key from the apiKey field when the env var is unset', () => {
|
|
319
|
+
process.env.DEEPSEEK_API_KEY = '';
|
|
320
|
+
const model = new ChatDeepSeek({
|
|
321
|
+
apiKey: 'arg-key',
|
|
322
|
+
model: 'deepseek-chat',
|
|
323
|
+
});
|
|
324
|
+
expect(model.apiKey).toBe('arg-key');
|
|
325
|
+
process.env.DEEPSEEK_API_KEY = 'test';
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('throws when no api key is provided via field or env', () => {
|
|
329
|
+
process.env.DEEPSEEK_API_KEY = '';
|
|
330
|
+
expect(() => new ChatDeepSeek({ model: 'deepseek-chat' })).toThrow(
|
|
331
|
+
/API key not found/i
|
|
332
|
+
);
|
|
333
|
+
process.env.DEEPSEEK_API_KEY = 'test';
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('supports tool calling (bindTools) and structured output', () => {
|
|
337
|
+
process.env.DEEPSEEK_API_KEY = 'test';
|
|
338
|
+
const model = new ChatDeepSeek({ model: 'deepseek-chat', apiKey: 'test' });
|
|
339
|
+
expect(typeof model.bindTools).toBe('function');
|
|
340
|
+
expect(typeof model.withStructuredOutput).toBe('function');
|
|
341
|
+
expect(model.bindTools([])).toBeDefined();
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// Dropped (inherited): live `.invoke()` / `.stream()`-to-API cases — none of
|
|
346
|
+
// the three source suites contained network-backed cases; all upstream
|
|
347
|
+
// scenarios were deterministic mocked transport.
|