@lobehub/chat 1.84.26 → 1.84.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/changelog/v1.json +9 -0
- package/package.json +2 -2
- package/src/config/aiModels/vertexai.ts +6 -6
- package/src/libs/agent-runtime/google/index.ts +2 -1
- package/src/libs/agent-runtime/utils/streams/google-ai.test.ts +101 -6
- package/src/libs/agent-runtime/utils/streams/google-ai.ts +62 -38
- package/src/libs/agent-runtime/utils/streams/protocol.ts +24 -4
- package/src/libs/agent-runtime/utils/streams/vertex-ai.test.ts +109 -8
- package/src/libs/agent-runtime/utils/streams/vertex-ai.ts +68 -23
- package/src/server/routers/lambda/__tests__/file.test.ts +213 -0
package/CHANGELOG.md
CHANGED
@@ -2,6 +2,31 @@
|
|
2
2
|
|
3
3
|
# Changelog
|
4
4
|
|
5
|
+
### [Version 1.84.27](https://github.com/lobehub/lobe-chat/compare/v1.84.26...v1.84.27)
|
6
|
+
|
7
|
+
<sup>Released on **2025-05-09**</sup>
|
8
|
+
|
9
|
+
#### 💄 Styles
|
10
|
+
|
11
|
+
- **misc**: Add reasoning tokens and token usage statistics for Google Gemini.
|
12
|
+
|
13
|
+
<br/>
|
14
|
+
|
15
|
+
<details>
|
16
|
+
<summary><kbd>Improvements and Fixes</kbd></summary>
|
17
|
+
|
18
|
+
#### Styles
|
19
|
+
|
20
|
+
- **misc**: Add reasoning tokens and token usage statistics for Google Gemini, closes [#7501](https://github.com/lobehub/lobe-chat/issues/7501) ([b466b42](https://github.com/lobehub/lobe-chat/commit/b466b42))
|
21
|
+
|
22
|
+
</details>
|
23
|
+
|
24
|
+
<div align="right">
|
25
|
+
|
26
|
+
[](#readme-top)
|
27
|
+
|
28
|
+
</div>
|
29
|
+
|
5
30
|
### [Version 1.84.26](https://github.com/lobehub/lobe-chat/compare/v1.84.25...v1.84.26)
|
6
31
|
|
7
32
|
<sup>Released on **2025-05-08**</sup>
|
package/changelog/v1.json
CHANGED
package/package.json
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
{
|
2
2
|
"name": "@lobehub/chat",
|
3
|
-
"version": "1.84.
|
3
|
+
"version": "1.84.27",
|
4
4
|
"description": "Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
|
5
5
|
"keywords": [
|
6
6
|
"framework",
|
@@ -121,7 +121,7 @@
|
|
121
121
|
"dependencies": {
|
122
122
|
"@ant-design/icons": "^5.6.1",
|
123
123
|
"@ant-design/pro-components": "^2.8.7",
|
124
|
-
"@anthropic-ai/sdk": "^0.
|
124
|
+
"@anthropic-ai/sdk": "^0.41.0",
|
125
125
|
"@auth/core": "^0.38.0",
|
126
126
|
"@aws-sdk/client-bedrock-runtime": "^3.779.0",
|
127
127
|
"@aws-sdk/client-s3": "^3.779.0",
|
@@ -98,9 +98,9 @@ const vertexaiChatModels: AIChatModelCard[] = [
|
|
98
98
|
type: 'chat',
|
99
99
|
},
|
100
100
|
{
|
101
|
-
abilities: {
|
102
|
-
functionCall: true,
|
103
|
-
vision: true
|
101
|
+
abilities: {
|
102
|
+
functionCall: true,
|
103
|
+
vision: true
|
104
104
|
},
|
105
105
|
contextWindowTokens: 1_000_000 + 8192,
|
106
106
|
description: 'Gemini 1.5 Flash 002 是一款高效的多模态模型,支持广泛应用的扩展。',
|
@@ -115,9 +115,9 @@ const vertexaiChatModels: AIChatModelCard[] = [
|
|
115
115
|
type: 'chat',
|
116
116
|
},
|
117
117
|
{
|
118
|
-
abilities: {
|
119
|
-
functionCall: true,
|
120
|
-
vision: true
|
118
|
+
abilities: {
|
119
|
+
functionCall: true,
|
120
|
+
vision: true
|
121
121
|
},
|
122
122
|
contextWindowTokens: 2_000_000 + 8192,
|
123
123
|
description:
|
@@ -106,6 +106,7 @@ export class LobeGoogleAI implements LobeRuntimeAI {
|
|
106
106
|
|
107
107
|
const contents = await this.buildGoogleMessages(payload.messages);
|
108
108
|
|
109
|
+
const inputStartAt = Date.now();
|
109
110
|
const geminiStreamResult = await this.client
|
110
111
|
.getGenerativeModel(
|
111
112
|
{
|
@@ -161,7 +162,7 @@ export class LobeGoogleAI implements LobeRuntimeAI {
|
|
161
162
|
|
162
163
|
// Convert the response into a friendly text-stream
|
163
164
|
const Stream = this.isVertexAi ? VertexAIStream : GoogleGenerativeAIStream;
|
164
|
-
const stream = Stream(prod, options?.callback);
|
165
|
+
const stream = Stream(prod, { callbacks: options?.callback, inputStartAt });
|
165
166
|
|
166
167
|
// Respond with the stream
|
167
168
|
return StreamingResponse(stream, { headers: options?.headers });
|
@@ -34,10 +34,12 @@ describe('GoogleGenerativeAIStream', () => {
|
|
34
34
|
const onCompletionMock = vi.fn();
|
35
35
|
|
36
36
|
const protocolStream = GoogleGenerativeAIStream(mockGoogleStream, {
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
37
|
+
callbacks: {
|
38
|
+
onStart: onStartMock,
|
39
|
+
onText: onTextMock,
|
40
|
+
onToolsCalling: onToolCallMock,
|
41
|
+
onCompletion: onCompletionMock,
|
42
|
+
},
|
41
43
|
});
|
42
44
|
|
43
45
|
const decoder = new TextDecoder();
|
@@ -187,7 +189,7 @@ describe('GoogleGenerativeAIStream', () => {
|
|
187
189
|
// usage
|
188
190
|
'id: chat_1\n',
|
189
191
|
'event: usage\n',
|
190
|
-
`data: {"inputImageTokens":258,"inputTextTokens":8,"totalInputTokens":266,"totalTokens":266}\n\n`,
|
192
|
+
`data: {"inputImageTokens":258,"inputTextTokens":8,"outputTextTokens":0,"totalInputTokens":266,"totalOutputTokens":0,"totalTokens":266}\n\n`,
|
191
193
|
]);
|
192
194
|
});
|
193
195
|
|
@@ -276,7 +278,100 @@ describe('GoogleGenerativeAIStream', () => {
|
|
276
278
|
// usage
|
277
279
|
'id: chat_1',
|
278
280
|
'event: usage',
|
279
|
-
`data: {"inputTextTokens":19,"totalInputTokens":19,"totalOutputTokens":11,"totalTokens":30}\n`,
|
281
|
+
`data: {"inputTextTokens":19,"outputTextTokens":11,"totalInputTokens":19,"totalOutputTokens":11,"totalTokens":30}\n`,
|
282
|
+
].map((i) => i + '\n'),
|
283
|
+
);
|
284
|
+
});
|
285
|
+
|
286
|
+
it('should handle stop with content and thought', async () => {
|
287
|
+
vi.spyOn(uuidModule, 'nanoid').mockReturnValueOnce('1');
|
288
|
+
|
289
|
+
const data = [
|
290
|
+
{
|
291
|
+
candidates: [
|
292
|
+
{
|
293
|
+
content: { parts: [{ text: '234' }], role: 'model' },
|
294
|
+
safetyRatings: [
|
295
|
+
{ category: 'HARM_CATEGORY_HATE_SPEECH', probability: 'NEGLIGIBLE' },
|
296
|
+
{ category: 'HARM_CATEGORY_DANGEROUS_CONTENT', probability: 'NEGLIGIBLE' },
|
297
|
+
{ category: 'HARM_CATEGORY_HARASSMENT', probability: 'NEGLIGIBLE' },
|
298
|
+
{ category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', probability: 'NEGLIGIBLE' },
|
299
|
+
],
|
300
|
+
},
|
301
|
+
],
|
302
|
+
text: () => '234',
|
303
|
+
usageMetadata: {
|
304
|
+
promptTokenCount: 19,
|
305
|
+
candidatesTokenCount: 3,
|
306
|
+
totalTokenCount: 122,
|
307
|
+
promptTokensDetails: [{ modality: 'TEXT', tokenCount: 19 }],
|
308
|
+
thoughtsTokenCount: 100,
|
309
|
+
},
|
310
|
+
modelVersion: 'gemini-2.0-flash-exp-image-generation',
|
311
|
+
},
|
312
|
+
{
|
313
|
+
text: () => '567890\n',
|
314
|
+
candidates: [
|
315
|
+
{
|
316
|
+
content: { parts: [{ text: '567890\n' }], role: 'model' },
|
317
|
+
finishReason: 'STOP',
|
318
|
+
safetyRatings: [
|
319
|
+
{ category: 'HARM_CATEGORY_HATE_SPEECH', probability: 'NEGLIGIBLE' },
|
320
|
+
{ category: 'HARM_CATEGORY_DANGEROUS_CONTENT', probability: 'NEGLIGIBLE' },
|
321
|
+
{ category: 'HARM_CATEGORY_HARASSMENT', probability: 'NEGLIGIBLE' },
|
322
|
+
{ category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', probability: 'NEGLIGIBLE' },
|
323
|
+
],
|
324
|
+
},
|
325
|
+
],
|
326
|
+
usageMetadata: {
|
327
|
+
promptTokenCount: 19,
|
328
|
+
candidatesTokenCount: 11,
|
329
|
+
totalTokenCount: 131,
|
330
|
+
promptTokensDetails: [{ modality: 'TEXT', tokenCount: 19 }],
|
331
|
+
candidatesTokensDetails: [{ modality: 'TEXT', tokenCount: 11 }],
|
332
|
+
thoughtsTokenCount: 100,
|
333
|
+
},
|
334
|
+
modelVersion: 'gemini-2.0-flash-exp-image-generation',
|
335
|
+
},
|
336
|
+
];
|
337
|
+
|
338
|
+
const mockGoogleStream = new ReadableStream({
|
339
|
+
start(controller) {
|
340
|
+
data.forEach((item) => {
|
341
|
+
controller.enqueue(item);
|
342
|
+
});
|
343
|
+
|
344
|
+
controller.close();
|
345
|
+
},
|
346
|
+
});
|
347
|
+
|
348
|
+
const protocolStream = GoogleGenerativeAIStream(mockGoogleStream);
|
349
|
+
|
350
|
+
const decoder = new TextDecoder();
|
351
|
+
const chunks = [];
|
352
|
+
|
353
|
+
// @ts-ignore
|
354
|
+
for await (const chunk of protocolStream) {
|
355
|
+
chunks.push(decoder.decode(chunk, { stream: true }));
|
356
|
+
}
|
357
|
+
|
358
|
+
expect(chunks).toEqual(
|
359
|
+
[
|
360
|
+
'id: chat_1',
|
361
|
+
'event: text',
|
362
|
+
'data: "234"\n',
|
363
|
+
|
364
|
+
'id: chat_1',
|
365
|
+
'event: text',
|
366
|
+
`data: "567890\\n"\n`,
|
367
|
+
// stop
|
368
|
+
'id: chat_1',
|
369
|
+
'event: stop',
|
370
|
+
`data: "STOP"\n`,
|
371
|
+
// usage
|
372
|
+
'id: chat_1',
|
373
|
+
'event: usage',
|
374
|
+
`data: {"inputTextTokens":19,"outputReasoningTokens":100,"outputTextTokens":11,"totalInputTokens":19,"totalOutputTokens":111,"totalTokens":131}\n`,
|
280
375
|
].map((i) => i + '\n'),
|
281
376
|
);
|
282
377
|
});
|
@@ -11,6 +11,7 @@ import {
|
|
11
11
|
StreamToolCallChunkData,
|
12
12
|
createCallbacksTransformer,
|
13
13
|
createSSEProtocolTransformer,
|
14
|
+
createTokenSpeedCalculator,
|
14
15
|
generateToolCallId,
|
15
16
|
} from './protocol';
|
16
17
|
|
@@ -19,31 +20,62 @@ const transformGoogleGenerativeAIStream = (
|
|
19
20
|
context: StreamContext,
|
20
21
|
): StreamProtocolChunk | StreamProtocolChunk[] => {
|
21
22
|
// maybe need another structure to add support for multiple choices
|
23
|
+
const candidate = chunk.candidates?.[0];
|
24
|
+
const usage = chunk.usageMetadata;
|
25
|
+
const usageChunks: StreamProtocolChunk[] = [];
|
26
|
+
if (candidate?.finishReason && usage) {
|
27
|
+
const outputReasoningTokens = (usage as any).thoughtsTokenCount || undefined;
|
28
|
+
const totalOutputTokens = (usage.candidatesTokenCount ?? 0) + (outputReasoningTokens ?? 0);
|
29
|
+
|
30
|
+
usageChunks.push(
|
31
|
+
{ data: candidate.finishReason, id: context?.id, type: 'stop' },
|
32
|
+
{
|
33
|
+
data: {
|
34
|
+
// TODO: Google SDK 0.24.0 don't have promptTokensDetails types
|
35
|
+
inputImageTokens: (usage as any).promptTokensDetails?.find(
|
36
|
+
(i: any) => i.modality === 'IMAGE',
|
37
|
+
)?.tokenCount,
|
38
|
+
inputTextTokens: (usage as any).promptTokensDetails?.find(
|
39
|
+
(i: any) => i.modality === 'TEXT',
|
40
|
+
)?.tokenCount,
|
41
|
+
outputReasoningTokens,
|
42
|
+
outputTextTokens: totalOutputTokens - (outputReasoningTokens ?? 0),
|
43
|
+
totalInputTokens: usage.promptTokenCount,
|
44
|
+
totalOutputTokens,
|
45
|
+
totalTokens: usage.totalTokenCount,
|
46
|
+
} as ModelTokensUsage,
|
47
|
+
id: context?.id,
|
48
|
+
type: 'usage',
|
49
|
+
},
|
50
|
+
);
|
51
|
+
}
|
52
|
+
|
22
53
|
const functionCalls = chunk.functionCalls?.();
|
23
54
|
|
24
55
|
if (functionCalls) {
|
25
|
-
return
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
56
|
+
return [
|
57
|
+
{
|
58
|
+
data: functionCalls.map(
|
59
|
+
(value, index): StreamToolCallChunkData => ({
|
60
|
+
function: {
|
61
|
+
arguments: JSON.stringify(value.args),
|
62
|
+
name: value.name,
|
63
|
+
},
|
64
|
+
id: generateToolCallId(index, value.name),
|
65
|
+
index: index,
|
66
|
+
type: 'function',
|
67
|
+
}),
|
68
|
+
),
|
69
|
+
id: context.id,
|
70
|
+
type: 'tool_calls',
|
71
|
+
},
|
72
|
+
...usageChunks,
|
73
|
+
];
|
40
74
|
}
|
41
75
|
|
42
76
|
const text = chunk.text?.();
|
43
77
|
|
44
|
-
if (
|
45
|
-
const candidate = chunk.candidates[0];
|
46
|
-
|
78
|
+
if (candidate) {
|
47
79
|
// return the grounding
|
48
80
|
if (candidate.groundingMetadata) {
|
49
81
|
const { webSearchQueries, groundingChunks } = candidate.groundingMetadata;
|
@@ -64,31 +96,15 @@ const transformGoogleGenerativeAIStream = (
|
|
64
96
|
id: context.id,
|
65
97
|
type: 'grounding',
|
66
98
|
},
|
99
|
+
...usageChunks,
|
67
100
|
];
|
68
101
|
}
|
69
102
|
|
70
103
|
if (candidate.finishReason) {
|
71
104
|
if (chunk.usageMetadata) {
|
72
|
-
const usage = chunk.usageMetadata;
|
73
105
|
return [
|
74
106
|
!!text ? { data: text, id: context?.id, type: 'text' } : undefined,
|
75
|
-
|
76
|
-
{
|
77
|
-
data: {
|
78
|
-
// TODO: Google SDK 0.24.0 don't have promptTokensDetails types
|
79
|
-
inputImageTokens: (usage as any).promptTokensDetails?.find(
|
80
|
-
(i: any) => i.modality === 'IMAGE',
|
81
|
-
)?.tokenCount,
|
82
|
-
inputTextTokens: (usage as any).promptTokensDetails?.find(
|
83
|
-
(i: any) => i.modality === 'TEXT',
|
84
|
-
)?.tokenCount,
|
85
|
-
totalInputTokens: usage.promptTokenCount,
|
86
|
-
totalOutputTokens: usage.candidatesTokenCount,
|
87
|
-
totalTokens: usage.totalTokenCount,
|
88
|
-
} as ModelTokensUsage,
|
89
|
-
id: context?.id,
|
90
|
-
type: 'usage',
|
91
|
-
},
|
107
|
+
...usageChunks,
|
92
108
|
].filter(Boolean) as StreamProtocolChunk[];
|
93
109
|
}
|
94
110
|
return { data: candidate.finishReason, id: context?.id, type: 'stop' };
|
@@ -117,13 +133,21 @@ const transformGoogleGenerativeAIStream = (
|
|
117
133
|
};
|
118
134
|
};
|
119
135
|
|
136
|
+
export interface GoogleAIStreamOptions {
|
137
|
+
callbacks?: ChatStreamCallbacks;
|
138
|
+
inputStartAt?: number;
|
139
|
+
}
|
140
|
+
|
120
141
|
export const GoogleGenerativeAIStream = (
|
121
142
|
rawStream: ReadableStream<EnhancedGenerateContentResponse>,
|
122
|
-
callbacks
|
143
|
+
{ callbacks, inputStartAt }: GoogleAIStreamOptions = {},
|
123
144
|
) => {
|
124
145
|
const streamStack: StreamContext = { id: 'chat_' + nanoid() };
|
125
146
|
|
126
147
|
return rawStream
|
127
|
-
.pipeThrough(
|
148
|
+
.pipeThrough(
|
149
|
+
createTokenSpeedCalculator(transformGoogleGenerativeAIStream, { inputStartAt, streamStack }),
|
150
|
+
)
|
151
|
+
.pipeThrough(createSSEProtocolTransformer((c) => c, streamStack))
|
128
152
|
.pipeThrough(createCallbacksTransformer(callbacks));
|
129
153
|
};
|
@@ -298,17 +298,37 @@ export const TOKEN_SPEED_CHUNK_ID = 'output_speed';
|
|
298
298
|
*/
|
299
299
|
export const createTokenSpeedCalculator = (
|
300
300
|
transformer: (chunk: any, stack: StreamContext) => StreamProtocolChunk | StreamProtocolChunk[],
|
301
|
-
{
|
301
|
+
{ inputStartAt, streamStack }: { inputStartAt?: number; streamStack?: StreamContext } = {},
|
302
302
|
) => {
|
303
303
|
let outputStartAt: number | undefined;
|
304
|
+
let outputThinking: boolean | undefined;
|
304
305
|
|
305
306
|
const process = (chunk: StreamProtocolChunk) => {
|
306
307
|
let result = [chunk];
|
307
|
-
// if the chunk is the first text chunk, set as output start
|
308
|
-
if (!outputStartAt && chunk.type === 'text'
|
308
|
+
// if the chunk is the first text or reasoning chunk, set as output start
|
309
|
+
if (!outputStartAt && (chunk.type === 'text' || chunk.type === 'reasoning')) {
|
310
|
+
outputStartAt = Date.now();
|
311
|
+
}
|
312
|
+
|
313
|
+
/**
|
314
|
+
* 部分 provider 在正式输出 reasoning 前,可能会先输出 content 为空字符串的 chunk,
|
315
|
+
* 其中 reasoning 可能为 null,会导致判断是否输出思考内容错误,所以过滤掉 null 或者空字符串。
|
316
|
+
* 也可能是某些特殊 token,所以不修改 outputStartAt 的逻辑。
|
317
|
+
*/
|
318
|
+
if (
|
319
|
+
outputThinking === undefined &&
|
320
|
+
(chunk.type === 'text' || chunk.type === 'reasoning') &&
|
321
|
+
typeof chunk.data === 'string' &&
|
322
|
+
chunk.data.length > 0
|
323
|
+
) {
|
324
|
+
outputThinking = chunk.type === 'reasoning';
|
325
|
+
}
|
309
326
|
// if the chunk is the stop chunk, set as output finish
|
310
327
|
if (inputStartAt && outputStartAt && chunk.type === 'usage') {
|
311
|
-
const
|
328
|
+
const totalOutputTokens = chunk.data?.totalOutputTokens || chunk.data?.outputTextTokens;
|
329
|
+
const reasoningTokens = chunk.data?.outputReasoningTokens || 0;
|
330
|
+
const outputTokens =
|
331
|
+
(outputThinking ?? false) ? totalOutputTokens : totalOutputTokens - reasoningTokens;
|
312
332
|
result.push({
|
313
333
|
data: {
|
314
334
|
tps: (outputTokens / (Date.now() - outputStartAt)) * 1000,
|
@@ -103,10 +103,12 @@ describe('VertexAIStream', () => {
|
|
103
103
|
const onCompletionMock = vi.fn();
|
104
104
|
|
105
105
|
const protocolStream = VertexAIStream(mockGoogleStream, {
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
106
|
+
callbacks: {
|
107
|
+
onStart: onStartMock,
|
108
|
+
onText: onTextMock,
|
109
|
+
onToolsCalling: onToolCallMock,
|
110
|
+
onCompletion: onCompletionMock,
|
111
|
+
},
|
110
112
|
});
|
111
113
|
|
112
114
|
const decoder = new TextDecoder();
|
@@ -136,6 +138,7 @@ describe('VertexAIStream', () => {
|
|
136
138
|
|
137
139
|
it('tool_calls', async () => {
|
138
140
|
vi.spyOn(uuidModule, 'nanoid').mockReturnValueOnce('1');
|
141
|
+
|
139
142
|
const rawChunks = [
|
140
143
|
{
|
141
144
|
candidates: [
|
@@ -204,10 +207,12 @@ describe('VertexAIStream', () => {
|
|
204
207
|
const onCompletionMock = vi.fn();
|
205
208
|
|
206
209
|
const protocolStream = VertexAIStream(mockGoogleStream, {
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
210
|
+
callbacks: {
|
211
|
+
onStart: onStartMock,
|
212
|
+
onText: onTextMock,
|
213
|
+
onToolsCalling: onToolCallMock,
|
214
|
+
onCompletion: onCompletionMock,
|
215
|
+
},
|
211
216
|
});
|
212
217
|
|
213
218
|
const decoder = new TextDecoder();
|
@@ -223,10 +228,106 @@ describe('VertexAIStream', () => {
|
|
223
228
|
'id: chat_1\n',
|
224
229
|
'event: tool_calls\n',
|
225
230
|
`data: [{"function":{"arguments":"{\\"city\\":\\"杭州\\"}","name":"realtime-weather____fetchCurrentWeather"},"id":"realtime-weather____fetchCurrentWeather_0","index":0,"type":"function"}]\n\n`,
|
231
|
+
'id: chat_1\n',
|
232
|
+
'event: stop\n',
|
233
|
+
'data: "STOP"\n\n',
|
234
|
+
'id: chat_1\n',
|
235
|
+
'event: usage\n',
|
236
|
+
'data: {"outputTextTokens":9,"totalInputTokens":95,"totalOutputTokens":9,"totalTokens":104}\n\n',
|
226
237
|
]);
|
227
238
|
|
228
239
|
expect(onStartMock).toHaveBeenCalledTimes(1);
|
229
240
|
expect(onToolCallMock).toHaveBeenCalledTimes(1);
|
230
241
|
expect(onCompletionMock).toHaveBeenCalledTimes(1);
|
231
242
|
});
|
243
|
+
|
244
|
+
it('should handle stop with content', async () => {
|
245
|
+
vi.spyOn(uuidModule, 'nanoid').mockReturnValueOnce('1');
|
246
|
+
|
247
|
+
const data = [
|
248
|
+
{
|
249
|
+
candidates: [
|
250
|
+
{
|
251
|
+
content: { parts: [{ text: '234' }], role: 'model' },
|
252
|
+
safetyRatings: [
|
253
|
+
{ category: 'HARM_CATEGORY_HATE_SPEECH', probability: 'NEGLIGIBLE' },
|
254
|
+
{ category: 'HARM_CATEGORY_DANGEROUS_CONTENT', probability: 'NEGLIGIBLE' },
|
255
|
+
{ category: 'HARM_CATEGORY_HARASSMENT', probability: 'NEGLIGIBLE' },
|
256
|
+
{ category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', probability: 'NEGLIGIBLE' },
|
257
|
+
],
|
258
|
+
},
|
259
|
+
],
|
260
|
+
text: () => '234',
|
261
|
+
usageMetadata: {
|
262
|
+
promptTokenCount: 20,
|
263
|
+
totalTokenCount: 20,
|
264
|
+
promptTokensDetails: [{ modality: 'TEXT', tokenCount: 20 }],
|
265
|
+
},
|
266
|
+
modelVersion: 'gemini-2.0-flash-exp-image-generation',
|
267
|
+
},
|
268
|
+
{
|
269
|
+
text: () => '567890\n',
|
270
|
+
candidates: [
|
271
|
+
{
|
272
|
+
content: { parts: [{ text: '567890\n' }], role: 'model' },
|
273
|
+
finishReason: 'STOP',
|
274
|
+
safetyRatings: [
|
275
|
+
{ category: 'HARM_CATEGORY_HATE_SPEECH', probability: 'NEGLIGIBLE' },
|
276
|
+
{ category: 'HARM_CATEGORY_DANGEROUS_CONTENT', probability: 'NEGLIGIBLE' },
|
277
|
+
{ category: 'HARM_CATEGORY_HARASSMENT', probability: 'NEGLIGIBLE' },
|
278
|
+
{ category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', probability: 'NEGLIGIBLE' },
|
279
|
+
],
|
280
|
+
},
|
281
|
+
],
|
282
|
+
usageMetadata: {
|
283
|
+
promptTokenCount: 19,
|
284
|
+
candidatesTokenCount: 11,
|
285
|
+
totalTokenCount: 30,
|
286
|
+
promptTokensDetails: [{ modality: 'TEXT', tokenCount: 19 }],
|
287
|
+
candidatesTokensDetails: [{ modality: 'TEXT', tokenCount: 11 }],
|
288
|
+
},
|
289
|
+
modelVersion: 'gemini-2.0-flash-exp-image-generation',
|
290
|
+
},
|
291
|
+
];
|
292
|
+
|
293
|
+
const mockGoogleStream = new ReadableStream({
|
294
|
+
start(controller) {
|
295
|
+
data.forEach((item) => {
|
296
|
+
controller.enqueue(item);
|
297
|
+
});
|
298
|
+
|
299
|
+
controller.close();
|
300
|
+
},
|
301
|
+
});
|
302
|
+
|
303
|
+
const protocolStream = VertexAIStream(mockGoogleStream);
|
304
|
+
|
305
|
+
const decoder = new TextDecoder();
|
306
|
+
const chunks = [];
|
307
|
+
|
308
|
+
// @ts-ignore
|
309
|
+
for await (const chunk of protocolStream) {
|
310
|
+
chunks.push(decoder.decode(chunk, { stream: true }));
|
311
|
+
}
|
312
|
+
|
313
|
+
expect(chunks).toEqual(
|
314
|
+
[
|
315
|
+
'id: chat_1',
|
316
|
+
'event: text',
|
317
|
+
'data: "234"\n',
|
318
|
+
|
319
|
+
'id: chat_1',
|
320
|
+
'event: text',
|
321
|
+
`data: "567890\\n"\n`,
|
322
|
+
// stop
|
323
|
+
'id: chat_1',
|
324
|
+
'event: stop',
|
325
|
+
`data: "STOP"\n`,
|
326
|
+
// usage
|
327
|
+
'id: chat_1',
|
328
|
+
'event: usage',
|
329
|
+
`data: {"inputTextTokens":19,"outputTextTokens":11,"totalInputTokens":19,"totalOutputTokens":11,"totalTokens":30}\n`,
|
330
|
+
].map((i) => i + '\n'),
|
331
|
+
);
|
332
|
+
});
|
232
333
|
});
|
@@ -1,27 +1,58 @@
|
|
1
1
|
import { EnhancedGenerateContentResponse, GenerateContentResponse } from '@google/generative-ai';
|
2
2
|
|
3
|
+
import { ModelTokensUsage } from '@/types/message';
|
3
4
|
import { nanoid } from '@/utils/uuid';
|
4
5
|
|
5
|
-
import {
|
6
|
+
import { type GoogleAIStreamOptions } from './google-ai';
|
6
7
|
import {
|
7
8
|
StreamContext,
|
8
9
|
StreamProtocolChunk,
|
9
10
|
createCallbacksTransformer,
|
10
11
|
createSSEProtocolTransformer,
|
12
|
+
createTokenSpeedCalculator,
|
11
13
|
generateToolCallId,
|
12
14
|
} from './protocol';
|
13
15
|
|
14
16
|
const transformVertexAIStream = (
|
15
17
|
chunk: GenerateContentResponse,
|
16
|
-
|
17
|
-
): StreamProtocolChunk => {
|
18
|
+
context: StreamContext,
|
19
|
+
): StreamProtocolChunk | StreamProtocolChunk[] => {
|
18
20
|
// maybe need another structure to add support for multiple choices
|
19
|
-
const
|
21
|
+
const candidate = chunk.candidates?.[0];
|
22
|
+
const usage = chunk.usageMetadata;
|
23
|
+
const usageChunks: StreamProtocolChunk[] = [];
|
24
|
+
if (candidate?.finishReason && usage) {
|
25
|
+
const outputReasoningTokens = (usage as any).thoughtsTokenCount || undefined;
|
26
|
+
const totalOutputTokens = (usage.candidatesTokenCount ?? 0) + (outputReasoningTokens ?? 0);
|
27
|
+
|
28
|
+
usageChunks.push(
|
29
|
+
{ data: candidate.finishReason, id: context?.id, type: 'stop' },
|
30
|
+
{
|
31
|
+
data: {
|
32
|
+
// TODO: Google SDK 0.24.0 don't have promptTokensDetails types
|
33
|
+
inputImageTokens: (usage as any).promptTokensDetails?.find(
|
34
|
+
(i: any) => i.modality === 'IMAGE',
|
35
|
+
)?.tokenCount,
|
36
|
+
inputTextTokens: (usage as any).promptTokensDetails?.find(
|
37
|
+
(i: any) => i.modality === 'TEXT',
|
38
|
+
)?.tokenCount,
|
39
|
+
outputReasoningTokens,
|
40
|
+
outputTextTokens: totalOutputTokens - (outputReasoningTokens ?? 0),
|
41
|
+
totalInputTokens: usage.promptTokenCount,
|
42
|
+
totalOutputTokens,
|
43
|
+
totalTokens: usage.totalTokenCount,
|
44
|
+
} as ModelTokensUsage,
|
45
|
+
id: context?.id,
|
46
|
+
type: 'usage',
|
47
|
+
},
|
48
|
+
);
|
49
|
+
}
|
20
50
|
|
51
|
+
const candidates = chunk.candidates;
|
21
52
|
if (!candidates)
|
22
53
|
return {
|
23
54
|
data: '',
|
24
|
-
id:
|
55
|
+
id: context?.id,
|
25
56
|
type: 'text',
|
26
57
|
};
|
27
58
|
|
@@ -32,44 +63,58 @@ const transformVertexAIStream = (
|
|
32
63
|
if (part.functionCall) {
|
33
64
|
const functionCall = part.functionCall;
|
34
65
|
|
35
|
-
return
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
66
|
+
return [
|
67
|
+
{
|
68
|
+
data: [
|
69
|
+
{
|
70
|
+
function: {
|
71
|
+
arguments: JSON.stringify(functionCall.args),
|
72
|
+
name: functionCall.name,
|
73
|
+
},
|
74
|
+
id: generateToolCallId(0, functionCall.name),
|
75
|
+
index: 0,
|
76
|
+
type: 'function',
|
41
77
|
},
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
78
|
+
],
|
79
|
+
id: context?.id,
|
80
|
+
type: 'tool_calls',
|
81
|
+
},
|
82
|
+
...usageChunks,
|
83
|
+
];
|
84
|
+
}
|
85
|
+
|
86
|
+
if (item.finishReason) {
|
87
|
+
if (chunk.usageMetadata) {
|
88
|
+
return [
|
89
|
+
!!part.text ? { data: part.text, id: context?.id, type: 'text' } : undefined,
|
90
|
+
...usageChunks,
|
91
|
+
].filter(Boolean) as StreamProtocolChunk[];
|
92
|
+
}
|
93
|
+
return { data: item.finishReason, id: context?.id, type: 'stop' };
|
50
94
|
}
|
51
95
|
|
52
96
|
return {
|
53
97
|
data: part.text,
|
54
|
-
id:
|
98
|
+
id: context?.id,
|
55
99
|
type: 'text',
|
56
100
|
};
|
57
101
|
}
|
58
102
|
|
59
103
|
return {
|
60
104
|
data: '',
|
61
|
-
id:
|
105
|
+
id: context?.id,
|
62
106
|
type: 'stop',
|
63
107
|
};
|
64
108
|
};
|
65
109
|
|
66
110
|
export const VertexAIStream = (
|
67
111
|
rawStream: ReadableStream<EnhancedGenerateContentResponse>,
|
68
|
-
callbacks
|
112
|
+
{ callbacks, inputStartAt }: GoogleAIStreamOptions = {},
|
69
113
|
) => {
|
70
114
|
const streamStack: StreamContext = { id: 'chat_' + nanoid() };
|
71
115
|
|
72
116
|
return rawStream
|
73
|
-
.pipeThrough(
|
117
|
+
.pipeThrough(createTokenSpeedCalculator(transformVertexAIStream, { inputStartAt, streamStack }))
|
118
|
+
.pipeThrough(createSSEProtocolTransformer((c) => c, streamStack))
|
74
119
|
.pipeThrough(createCallbacksTransformer(callbacks));
|
75
120
|
};
|
@@ -0,0 +1,213 @@
|
|
1
|
+
import { TRPCError } from '@trpc/server';
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
3
|
+
|
4
|
+
import { fileRouter } from '@/server/routers/lambda/file';
|
5
|
+
import { AsyncTaskStatus } from '@/types/asyncTask';
|
6
|
+
|
7
|
+
// Patch: Use actual router context middleware to inject the correct models/services
|
8
|
+
function createCallerWithCtx(partialCtx: any = {}) {
|
9
|
+
// All mocks are spies
|
10
|
+
const fileModel = {
|
11
|
+
checkHash: vi.fn().mockResolvedValue({ isExist: true }),
|
12
|
+
create: vi.fn().mockResolvedValue({ id: 'test-id' }),
|
13
|
+
findById: vi.fn().mockResolvedValue(undefined),
|
14
|
+
query: vi.fn().mockResolvedValue([]),
|
15
|
+
delete: vi.fn().mockResolvedValue(undefined),
|
16
|
+
deleteMany: vi.fn().mockResolvedValue([]),
|
17
|
+
clear: vi.fn().mockResolvedValue({} as any),
|
18
|
+
};
|
19
|
+
|
20
|
+
const fileService = {
|
21
|
+
getFullFileUrl: vi.fn().mockResolvedValue('full-url'),
|
22
|
+
deleteFile: vi.fn().mockResolvedValue(undefined),
|
23
|
+
deleteFiles: vi.fn().mockResolvedValue(undefined),
|
24
|
+
};
|
25
|
+
|
26
|
+
const chunkModel = {
|
27
|
+
countByFileIds: vi.fn().mockResolvedValue([{ id: 'test-id', count: 5 }]),
|
28
|
+
countByFileId: vi.fn().mockResolvedValue(5),
|
29
|
+
};
|
30
|
+
|
31
|
+
const asyncTaskModel = {
|
32
|
+
findByIds: vi.fn().mockResolvedValue([
|
33
|
+
{
|
34
|
+
id: 'test-task-id',
|
35
|
+
status: AsyncTaskStatus.Success,
|
36
|
+
},
|
37
|
+
]),
|
38
|
+
findById: vi.fn(),
|
39
|
+
delete: vi.fn(),
|
40
|
+
};
|
41
|
+
|
42
|
+
const ctx = {
|
43
|
+
serverDB: {} as any,
|
44
|
+
userId: 'test-user',
|
45
|
+
asyncTaskModel,
|
46
|
+
chunkModel,
|
47
|
+
fileModel,
|
48
|
+
fileService,
|
49
|
+
...partialCtx,
|
50
|
+
};
|
51
|
+
|
52
|
+
return { ctx, caller: fileRouter.createCaller(ctx) };
|
53
|
+
}
|
54
|
+
|
55
|
+
vi.mock('@/config/db', () => ({
|
56
|
+
serverDBEnv: {
|
57
|
+
REMOVE_GLOBAL_FILE: false,
|
58
|
+
},
|
59
|
+
}));
|
60
|
+
|
61
|
+
vi.mock('@/database/models/asyncTask', () => ({
|
62
|
+
AsyncTaskModel: vi.fn(() => ({
|
63
|
+
findById: vi.fn(),
|
64
|
+
findByIds: vi.fn(),
|
65
|
+
delete: vi.fn(),
|
66
|
+
})),
|
67
|
+
}));
|
68
|
+
|
69
|
+
vi.mock('@/database/models/chunk', () => ({
|
70
|
+
ChunkModel: vi.fn(() => ({
|
71
|
+
countByFileId: vi.fn(),
|
72
|
+
countByFileIds: vi.fn(),
|
73
|
+
})),
|
74
|
+
}));
|
75
|
+
|
76
|
+
vi.mock('@/database/models/file', () => ({
|
77
|
+
FileModel: vi.fn(() => ({
|
78
|
+
checkHash: vi.fn(),
|
79
|
+
create: vi.fn(),
|
80
|
+
delete: vi.fn(),
|
81
|
+
deleteMany: vi.fn(),
|
82
|
+
findById: vi.fn(),
|
83
|
+
query: vi.fn(),
|
84
|
+
clear: vi.fn(),
|
85
|
+
})),
|
86
|
+
}));
|
87
|
+
|
88
|
+
vi.mock('@/server/services/file', () => ({
|
89
|
+
FileService: vi.fn(() => ({
|
90
|
+
getFullFileUrl: vi.fn(),
|
91
|
+
deleteFile: vi.fn(),
|
92
|
+
deleteFiles: vi.fn(),
|
93
|
+
})),
|
94
|
+
}));
|
95
|
+
|
96
|
+
describe('fileRouter', () => {
|
97
|
+
let ctx: any;
|
98
|
+
let caller: any;
|
99
|
+
let mockFile: any;
|
100
|
+
|
101
|
+
beforeEach(() => {
|
102
|
+
vi.clearAllMocks();
|
103
|
+
|
104
|
+
mockFile = {
|
105
|
+
id: 'test-id',
|
106
|
+
name: 'test.txt',
|
107
|
+
url: 'test-url',
|
108
|
+
createdAt: new Date(),
|
109
|
+
updatedAt: new Date(),
|
110
|
+
accessedAt: new Date(),
|
111
|
+
userId: 'test-user',
|
112
|
+
size: 100,
|
113
|
+
fileType: 'text',
|
114
|
+
metadata: {},
|
115
|
+
fileHash: null,
|
116
|
+
clientId: null,
|
117
|
+
chunkTaskId: null,
|
118
|
+
embeddingTaskId: null,
|
119
|
+
};
|
120
|
+
|
121
|
+
// Use actual context with default mocks
|
122
|
+
({ ctx, caller } = createCallerWithCtx());
|
123
|
+
});
|
124
|
+
|
125
|
+
describe('checkFileHash', () => {
|
126
|
+
it('should handle when fileModel.checkHash returns undefined', async () => {
|
127
|
+
ctx.fileModel.checkHash.mockResolvedValue(undefined);
|
128
|
+
await expect(caller.checkFileHash({ hash: 'test-hash' })).resolves.toBeUndefined();
|
129
|
+
});
|
130
|
+
});
|
131
|
+
|
132
|
+
describe('createFile', () => {
|
133
|
+
it('should throw if fileModel.checkHash returns undefined', async () => {
|
134
|
+
ctx.fileModel.checkHash.mockResolvedValue(undefined);
|
135
|
+
await expect(
|
136
|
+
caller.createFile({
|
137
|
+
hash: 'test-hash',
|
138
|
+
fileType: 'text',
|
139
|
+
name: 'test.txt',
|
140
|
+
size: 100,
|
141
|
+
url: 'test-url',
|
142
|
+
metadata: {},
|
143
|
+
}),
|
144
|
+
).rejects.toThrow();
|
145
|
+
});
|
146
|
+
});
|
147
|
+
|
148
|
+
describe('findById', () => {
|
149
|
+
it('should throw error when file not found', async () => {
|
150
|
+
ctx.fileModel.findById.mockResolvedValue(null);
|
151
|
+
|
152
|
+
await expect(caller.findById({ id: 'invalid-id' })).rejects.toThrow(TRPCError);
|
153
|
+
});
|
154
|
+
});
|
155
|
+
|
156
|
+
describe('getFileItemById', () => {
|
157
|
+
it('should throw error when file not found', async () => {
|
158
|
+
ctx.fileModel.findById.mockResolvedValue(null);
|
159
|
+
|
160
|
+
await expect(caller.getFileItemById({ id: 'invalid-id' })).rejects.toThrow(TRPCError);
|
161
|
+
});
|
162
|
+
});
|
163
|
+
|
164
|
+
describe('getFiles', () => {
|
165
|
+
it('should handle fileModel.query returning undefined', async () => {
|
166
|
+
ctx.fileModel.query.mockResolvedValue(undefined);
|
167
|
+
|
168
|
+
await expect(caller.getFiles({})).rejects.toThrow();
|
169
|
+
});
|
170
|
+
});
|
171
|
+
|
172
|
+
describe('removeFile', () => {
|
173
|
+
it('should do nothing when file not found', async () => {
|
174
|
+
ctx.fileModel.delete.mockResolvedValue(null);
|
175
|
+
|
176
|
+
await caller.removeFile({ id: 'invalid-id' });
|
177
|
+
|
178
|
+
expect(ctx.fileService.deleteFile).not.toHaveBeenCalled();
|
179
|
+
});
|
180
|
+
});
|
181
|
+
|
182
|
+
describe('removeFiles', () => {
|
183
|
+
it('should do nothing when no files found', async () => {
|
184
|
+
ctx.fileModel.deleteMany.mockResolvedValue([]);
|
185
|
+
|
186
|
+
await caller.removeFiles({ ids: ['invalid-1', 'invalid-2'] });
|
187
|
+
|
188
|
+
expect(ctx.fileService.deleteFiles).not.toHaveBeenCalled();
|
189
|
+
});
|
190
|
+
});
|
191
|
+
|
192
|
+
describe('removeFileAsyncTask', () => {
|
193
|
+
it('should do nothing when file not found', async () => {
|
194
|
+
ctx.fileModel.findById.mockResolvedValue(null);
|
195
|
+
|
196
|
+
await caller.removeFileAsyncTask({ id: 'test-id', type: 'chunk' });
|
197
|
+
|
198
|
+
expect(ctx.asyncTaskModel.delete).not.toHaveBeenCalled();
|
199
|
+
});
|
200
|
+
|
201
|
+
it('should do nothing when task id is missing', async () => {
|
202
|
+
ctx.fileModel.findById.mockResolvedValue(mockFile);
|
203
|
+
|
204
|
+
await caller.removeFileAsyncTask({ id: 'test-id', type: 'embedding' });
|
205
|
+
|
206
|
+
expect(ctx.asyncTaskModel.delete).not.toHaveBeenCalled();
|
207
|
+
|
208
|
+
await caller.removeFileAsyncTask({ id: 'test-id', type: 'chunk' });
|
209
|
+
|
210
|
+
expect(ctx.asyncTaskModel.delete).not.toHaveBeenCalled();
|
211
|
+
});
|
212
|
+
});
|
213
|
+
});
|