@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 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
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#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
@@ -1,4 +1,13 @@
1
1
  [
2
+ {
3
+ "children": {
4
+ "improvements": [
5
+ "Add reasoning tokens and token usage statistics for Google Gemini."
6
+ ]
7
+ },
8
+ "date": "2025-05-09",
9
+ "version": "1.84.27"
10
+ },
2
11
  {
3
12
  "children": {
4
13
  "improvements": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobehub/chat",
3
- "version": "1.84.26",
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.40.1",
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
- onStart: onStartMock,
38
- onText: onTextMock,
39
- onToolsCalling: onToolCallMock,
40
- onCompletion: onCompletionMock,
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
- data: functionCalls.map(
27
- (value, index): StreamToolCallChunkData => ({
28
- function: {
29
- arguments: JSON.stringify(value.args),
30
- name: value.name,
31
- },
32
- id: generateToolCallId(index, value.name),
33
- index: index,
34
- type: 'function',
35
- }),
36
- ),
37
- id: context.id,
38
- type: 'tool_calls',
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 (chunk.candidates) {
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
- { data: candidate.finishReason, id: context?.id, type: 'stop' },
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?: ChatStreamCallbacks,
143
+ { callbacks, inputStartAt }: GoogleAIStreamOptions = {},
123
144
  ) => {
124
145
  const streamStack: StreamContext = { id: 'chat_' + nanoid() };
125
146
 
126
147
  return rawStream
127
- .pipeThrough(createSSEProtocolTransformer(transformGoogleGenerativeAIStream, streamStack))
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
- { streamStack, inputStartAt }: { inputStartAt?: number; streamStack?: StreamContext } = {},
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') outputStartAt = Date.now();
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 outputTokens = chunk.data?.totalOutputTokens || chunk.data?.outputTextTokens;
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
- onStart: onStartMock,
107
- onText: onTextMock,
108
- onToolsCalling: onToolCallMock,
109
- onCompletion: onCompletionMock,
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
- onStart: onStartMock,
208
- onText: onTextMock,
209
- onToolsCalling: onToolCallMock,
210
- onCompletion: onCompletionMock,
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 { ChatStreamCallbacks } from '../../types';
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
- stack: StreamContext,
17
- ): StreamProtocolChunk => {
18
+ context: StreamContext,
19
+ ): StreamProtocolChunk | StreamProtocolChunk[] => {
18
20
  // maybe need another structure to add support for multiple choices
19
- const candidates = chunk.candidates;
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: stack?.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
- data: [
37
- {
38
- function: {
39
- arguments: JSON.stringify(functionCall.args),
40
- name: functionCall.name,
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
- id: generateToolCallId(0, functionCall.name),
43
- index: 0,
44
- type: 'function',
45
- },
46
- ],
47
- id: stack?.id,
48
- type: 'tool_calls',
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: stack?.id,
98
+ id: context?.id,
55
99
  type: 'text',
56
100
  };
57
101
  }
58
102
 
59
103
  return {
60
104
  data: '',
61
- id: stack?.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?: ChatStreamCallbacks,
112
+ { callbacks, inputStartAt }: GoogleAIStreamOptions = {},
69
113
  ) => {
70
114
  const streamStack: StreamContext = { id: 'chat_' + nanoid() };
71
115
 
72
116
  return rawStream
73
- .pipeThrough(createSSEProtocolTransformer(transformVertexAIStream, streamStack))
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
+ });