@lobehub/chat 1.128.3 → 1.128.5

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,56 @@
2
2
 
3
3
  # Changelog
4
4
 
5
+ ### [Version 1.128.5](https://github.com/lobehub/lobe-chat/compare/v1.128.4...v1.128.5)
6
+
7
+ <sup>Released on **2025-09-13**</sup>
8
+
9
+ #### 🐛 Bug Fixes
10
+
11
+ - **misc**: Google stream error unable to abort request.
12
+
13
+ <br/>
14
+
15
+ <details>
16
+ <summary><kbd>Improvements and Fixes</kbd></summary>
17
+
18
+ #### What's fixed
19
+
20
+ - **misc**: Google stream error unable to abort request, closes [#9180](https://github.com/lobehub/lobe-chat/issues/9180) ([78eaead](https://github.com/lobehub/lobe-chat/commit/78eaead))
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
+
30
+ ### [Version 1.128.4](https://github.com/lobehub/lobe-chat/compare/v1.128.3...v1.128.4)
31
+
32
+ <sup>Released on **2025-09-13**</sup>
33
+
34
+ #### 💄 Styles
35
+
36
+ - **misc**: Fix discover plugin link.
37
+
38
+ <br/>
39
+
40
+ <details>
41
+ <summary><kbd>Improvements and Fixes</kbd></summary>
42
+
43
+ #### Styles
44
+
45
+ - **misc**: Fix discover plugin link, closes [#9240](https://github.com/lobehub/lobe-chat/issues/9240) ([cfb2246](https://github.com/lobehub/lobe-chat/commit/cfb2246))
46
+
47
+ </details>
48
+
49
+ <div align="right">
50
+
51
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
52
+
53
+ </div>
54
+
5
55
  ### [Version 1.128.3](https://github.com/lobehub/lobe-chat/compare/v1.128.2...v1.128.3)
6
56
 
7
57
  <sup>Released on **2025-09-13**</sup>
package/changelog/v1.json CHANGED
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "children": {
4
+ "fixes": [
5
+ "Google stream error unable to abort request."
6
+ ]
7
+ },
8
+ "date": "2025-09-13",
9
+ "version": "1.128.5"
10
+ },
11
+ {
12
+ "children": {
13
+ "improvements": [
14
+ "Fix discover plugin link."
15
+ ]
16
+ },
17
+ "date": "2025-09-13",
18
+ "version": "1.128.4"
19
+ },
2
20
  {
3
21
  "children": {
4
22
  "fixes": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobehub/chat",
3
- "version": "1.128.3",
3
+ "version": "1.128.5",
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",
@@ -137,7 +137,7 @@
137
137
  "@electric-sql/pglite": "0.2.17",
138
138
  "@emotion/react": "^11.14.0",
139
139
  "@fal-ai/client": "^1.6.2",
140
- "@formkit/auto-animate": "^0.8.4",
140
+ "@formkit/auto-animate": "^0.9.0",
141
141
  "@google/genai": "^1.19.0",
142
142
  "@huggingface/inference": "^2.8.1",
143
143
  "@icons-pack/react-simple-icons": "9.6.0",
@@ -86,7 +86,7 @@ const xaiChatModels: AIChatModelCard[] = [
86
86
  description:
87
87
  '旗舰级模型,擅长数据提取、编程和文本摘要等企业级应用,拥有金融、医疗、法律和科学等领域的深厚知识。',
88
88
  displayName: 'Grok 3 (Fast mode)',
89
- id: 'grok-3-fast',
89
+ id: 'grok-3-fast', // legacy
90
90
  pricing: {
91
91
  units: [
92
92
  { name: 'textInput_cacheRead', rate: 1.25, strategy: 'fixed', unit: 'millionTokens' },
@@ -136,7 +136,7 @@ const xaiChatModels: AIChatModelCard[] = [
136
136
  description:
137
137
  '轻量级模型,回话前会先思考。运行快速、智能,适用于不需要深层领域知识的逻辑任务,并能获取原始的思维轨迹。',
138
138
  displayName: 'Grok 3 Mini (Fast mode)',
139
- id: 'grok-3-mini-fast',
139
+ id: 'grok-3-mini-fast', // legacy
140
140
  pricing: {
141
141
  units: [
142
142
  { name: 'textInput_cacheRead', rate: 0.15, strategy: 'fixed', unit: 'millionTokens' },
@@ -181,7 +181,7 @@ const xaiChatModels: AIChatModelCard[] = [
181
181
  contextWindowTokens: 32_768,
182
182
  description: '该模型在准确性、指令遵循和多语言能力方面有所改进。',
183
183
  displayName: 'Grok 2 Vision 1212',
184
- id: 'grok-2-vision-1212',
184
+ id: 'grok-2-vision-1212', // legacy
185
185
  pricing: {
186
186
  units: [
187
187
  { name: 'textInput', rate: 2, strategy: 'fixed', unit: 'millionTokens' },
@@ -58,6 +58,14 @@ afterEach(() => {
58
58
  });
59
59
 
60
60
  describe('LobeOpenAICompatibleFactory', () => {
61
+ // Polyfill File for Node environment used in image tests
62
+ if (typeof File === 'undefined') {
63
+ // @ts-ignore
64
+ global.File = class MockFile {
65
+ constructor(public parts: any[], public name: string, public opts?: any) {}
66
+ };
67
+ }
68
+
61
69
  describe('init', () => {
62
70
  it('should correctly initialize with an API key', async () => {
63
71
  const instance = new LobeMockProvider({ apiKey: 'test_api_key' });
@@ -148,10 +156,22 @@ describe('LobeOpenAICompatibleFactory', () => {
148
156
 
149
157
  const decoder = new TextDecoder();
150
158
  const reader = result.body!.getReader();
151
- expect(decoder.decode((await reader.read()).value)).toEqual('id: a\n');
152
- expect(decoder.decode((await reader.read()).value)).toEqual('event: text\n');
153
- expect(decoder.decode((await reader.read()).value)).toEqual('data: "hello"\n\n');
154
- expect((await reader.read()).done).toBe(true);
159
+
160
+ // Collect all chunks
161
+ const chunks = [];
162
+ while (true) {
163
+ const { value, done } = await reader.read();
164
+ if (done) break;
165
+ chunks.push(decoder.decode(value));
166
+ }
167
+ // Assert that all expected chunk patterns are present
168
+ expect(chunks).toEqual(
169
+ expect.arrayContaining([
170
+ 'id: a\n',
171
+ 'event: text\n',
172
+ 'data: "hello"\n\n',
173
+ ]),
174
+ );
155
175
  });
156
176
 
157
177
  // https://github.com/lobehub/lobe-chat/issues/2752
@@ -2,7 +2,7 @@ import { GenerateContentResponse } from '@google/genai';
2
2
  import { describe, expect, it, vi } from 'vitest';
3
3
 
4
4
  import * as uuidModule from '../../utils/uuid';
5
- import { GoogleGenerativeAIStream } from './google-ai';
5
+ import { GoogleGenerativeAIStream, LOBE_ERROR_KEY } from './google-ai';
6
6
 
7
7
  describe('GoogleGenerativeAIStream', () => {
8
8
  it('should transform Google Generative AI stream to protocol stream', async () => {
@@ -21,7 +21,21 @@ describe('GoogleGenerativeAIStream', () => {
21
21
  controller.enqueue(
22
22
  mockGenerateContentResponse('', [{ name: 'testFunction', args: { arg1: 'value1' } }]),
23
23
  );
24
- controller.enqueue(mockGenerateContentResponse(' world!'));
24
+
25
+ // final chunk should include finishReason and usageMetadata to mark terminal event
26
+ controller.enqueue({
27
+ text: ' world!',
28
+ candidates: [
29
+ { content: { role: 'model' }, finishReason: 'STOP', index: 0 },
30
+ ],
31
+ usageMetadata: {
32
+ promptTokenCount: 1,
33
+ totalTokenCount: 1,
34
+ promptTokensDetails: [{ modality: 'TEXT', tokenCount: 1 }],
35
+ },
36
+ modelVersion: 'gemini-test',
37
+ } as unknown as GenerateContentResponse);
38
+
25
39
  controller.close();
26
40
  },
27
41
  });
@@ -63,6 +77,14 @@ describe('GoogleGenerativeAIStream', () => {
63
77
  'id: chat_1\n',
64
78
  'event: text\n',
65
79
  `data: " world!"\n\n`,
80
+ // stop
81
+ 'id: chat_1\n',
82
+ 'event: stop\n',
83
+ `data: "STOP"\n\n`,
84
+ // usage
85
+ 'id: chat_1\n',
86
+ 'event: usage\n',
87
+ `data: {"inputTextTokens":1,"outputImageTokens":0,"outputTextTokens":0,"totalInputTokens":1,"totalOutputTokens":0,"totalTokens":1}\n\n`,
66
88
  ]);
67
89
 
68
90
  expect(onStartMock).toHaveBeenCalledTimes(1);
@@ -73,8 +95,23 @@ describe('GoogleGenerativeAIStream', () => {
73
95
  });
74
96
 
75
97
  it('should handle empty stream', async () => {
98
+ vi.spyOn(uuidModule, 'nanoid').mockReturnValueOnce('E5M9dFKw');
76
99
  const mockGoogleStream = new ReadableStream({
77
100
  start(controller) {
101
+ controller.enqueue({
102
+ candidates: [{ content: { role: 'model' }, finishReason: 'STOP', index: 0 }],
103
+ usageMetadata: {
104
+ promptTokenCount: 0,
105
+ cachedContentTokenCount: 0,
106
+ totalTokenCount: 0,
107
+ promptTokensDetails: [
108
+ { modality: 'TEXT', tokenCount: 0 },
109
+ { modality: 'IMAGE', tokenCount: 0 },
110
+ ],
111
+ },
112
+ modelVersion: 'gemini-test',
113
+ } as unknown as GenerateContentResponse);
114
+
78
115
  controller.close();
79
116
  },
80
117
  });
@@ -89,7 +126,14 @@ describe('GoogleGenerativeAIStream', () => {
89
126
  chunks.push(decoder.decode(chunk, { stream: true }));
90
127
  }
91
128
 
92
- expect(chunks).toEqual([]);
129
+ expect(chunks).toEqual([
130
+ 'id: chat_E5M9dFKw\n',
131
+ 'event: stop\n',
132
+ `data: "STOP"\n\n`,
133
+ 'id: chat_E5M9dFKw\n',
134
+ 'event: usage\n',
135
+ `data: {"inputCachedTokens":0,"inputImageTokens":0,"inputTextTokens":0,"outputImageTokens":0,"outputTextTokens":0,"totalInputTokens":0,"totalOutputTokens":0,"totalTokens":0}\n\n`,
136
+ ]);
93
137
  });
94
138
 
95
139
  it('should handle image', async () => {
@@ -102,13 +146,17 @@ describe('GoogleGenerativeAIStream', () => {
102
146
  parts: [{ inlineData: { mimeType: 'image/png', data: 'iVBORw0KGgoAA' } }],
103
147
  role: 'model',
104
148
  },
149
+ finishReason: 'STOP',
105
150
  index: 0,
106
151
  },
107
152
  ],
108
153
  usageMetadata: {
109
154
  promptTokenCount: 6,
110
155
  totalTokenCount: 6,
111
- promptTokensDetails: [{ modality: 'TEXT', tokenCount: 6 }],
156
+ promptTokensDetails: [
157
+ { modality: 'TEXT', tokenCount: 6 },
158
+ { modality: 'IMAGE', tokenCount: 0 },
159
+ ],
112
160
  },
113
161
  modelVersion: 'gemini-2.0-flash-exp',
114
162
  };
@@ -136,6 +184,14 @@ describe('GoogleGenerativeAIStream', () => {
136
184
  'id: chat_1\n',
137
185
  'event: base64_image\n',
138
186
  `data: "data:image/png;base64,iVBORw0KGgoAA"\n\n`,
187
+ // stop
188
+ 'id: chat_1\n',
189
+ 'event: stop\n',
190
+ `data: "STOP"\n\n`,
191
+ // usage
192
+ 'id: chat_1\n',
193
+ 'event: usage\n',
194
+ `data: {"inputImageTokens":0,"inputTextTokens":6,"outputImageTokens":0,"outputTextTokens":0,"totalInputTokens":6,"totalOutputTokens":0,"totalTokens":6}\n\n`,
139
195
  ]);
140
196
  });
141
197
 
@@ -855,4 +911,33 @@ describe('GoogleGenerativeAIStream', () => {
855
911
  `data: {"body":{"context":{"promptFeedback":{"blockReason":"PROHIBITED_CONTENT"}},"message":"您的请求可能包含违禁内容。请调整您的请求,确保内容符合使用规范。","provider":"google"},"type":"ProviderBizError"}\n\n`,
856
912
  ]);
857
913
  });
914
+
915
+ it('should pass through injected lobe error marker', async () => {
916
+ vi.spyOn(uuidModule, 'nanoid').mockReturnValueOnce('1');
917
+
918
+ const errorPayload = { message: 'internal error', code: 123 };
919
+
920
+ const mockGoogleStream = new ReadableStream({
921
+ start(controller) {
922
+ controller.enqueue({ [LOBE_ERROR_KEY]: errorPayload });
923
+ controller.close();
924
+ },
925
+ });
926
+
927
+ const protocolStream = GoogleGenerativeAIStream(mockGoogleStream);
928
+
929
+ const decoder = new TextDecoder();
930
+ const chunks = [];
931
+
932
+ // @ts-ignore
933
+ for await (const chunk of protocolStream) {
934
+ chunks.push(decoder.decode(chunk, { stream: true }));
935
+ }
936
+
937
+ expect(chunks).toEqual([
938
+ 'id: chat_1\n',
939
+ 'event: error\n',
940
+ `data: ${JSON.stringify(errorPayload)}\n\n`,
941
+ ]);
942
+ });
858
943
  });
@@ -16,6 +16,8 @@ import {
16
16
  generateToolCallId,
17
17
  } from './protocol';
18
18
 
19
+ export const LOBE_ERROR_KEY = '__lobe_error';
20
+
19
21
  const getBlockReasonMessage = (blockReason: string): string => {
20
22
  const blockReasonMessages = errorLocale.response.GoogleAIBlockReason;
21
23
 
@@ -29,6 +31,14 @@ const transformGoogleGenerativeAIStream = (
29
31
  chunk: GenerateContentResponse,
30
32
  context: StreamContext,
31
33
  ): StreamProtocolChunk | StreamProtocolChunk[] => {
34
+ // Handle injected internal error marker to pass through detailed error info
35
+ if ((chunk as any)?.[LOBE_ERROR_KEY]) {
36
+ return {
37
+ data: (chunk as any)[LOBE_ERROR_KEY],
38
+ id: context?.id || 'error',
39
+ type: 'error',
40
+ };
41
+ }
32
42
  // Handle promptFeedback with blockReason (e.g., PROHIBITED_CONTENT)
33
43
  if ('promptFeedback' in chunk && (chunk as any).promptFeedback?.blockReason) {
34
44
  const blockReason = (chunk as any).promptFeedback.blockReason;
@@ -216,6 +226,8 @@ export const GoogleGenerativeAIStream = (
216
226
  .pipeThrough(
217
227
  createTokenSpeedCalculator(transformGoogleGenerativeAIStream, { inputStartAt, streamStack }),
218
228
  )
219
- .pipeThrough(createSSEProtocolTransformer((c) => c, streamStack))
229
+ .pipeThrough(
230
+ createSSEProtocolTransformer((c) => c, streamStack, { requireTerminalEvent: true }),
231
+ )
220
232
  .pipeThrough(createCallbacksTransformer(callbacks));
221
233
  };
@@ -396,7 +396,7 @@ describe('OpenAIStream', () => {
396
396
  expect(chunks).toEqual([
397
397
  'id: first_chunk_error\n',
398
398
  'event: error\n',
399
- `data: {"body":{"errorType":"ProviderBizError","message":"Test error"},"type":"ProviderBizError"}\n\n`,
399
+ `data: {"body":{"errorType":"ProviderBizError","message":"Test error"},"message":"Test error","type":"ProviderBizError"}\n\n`,
400
400
  ]);
401
401
  });
402
402
 
@@ -427,7 +427,7 @@ describe('OpenAIStream', () => {
427
427
  expect(chunks).toEqual([
428
428
  'id: first_chunk_error\n',
429
429
  'event: error\n',
430
- `data: {"body":{"message":"Custom error","errorType":"PermissionDenied","provider":"grok"},"type":"PermissionDenied"}\n\n`,
430
+ `data: {"body":{"message":"Custom error","errorType":"PermissionDenied","provider":"grok"},"message":"Custom error","type":"PermissionDenied"}\n\n`,
431
431
  ]);
432
432
  });
433
433
 
@@ -2481,4 +2481,4 @@ describe('OpenAIStream', () => {
2481
2481
  `data: "${base64_2}"\n\n`,
2482
2482
  ]);
2483
2483
  });
2484
- });
2484
+ });
@@ -57,8 +57,9 @@ const transformOpenAIStream = (
57
57
 
58
58
  const errorData = {
59
59
  body: chunk,
60
- type: 'errorType' in chunk ? chunk.errorType : AgentRuntimeErrorType.ProviderBizError,
61
- } as ChatMessageError;
60
+ message: 'message' in chunk ? typeof chunk.message === 'string' ? chunk.message : JSON.stringify(chunk) : JSON.stringify(chunk),
61
+ type: 'errorType' in chunk ? chunk.errorType as typeof AgentRuntimeErrorType.ProviderBizError : AgentRuntimeErrorType.ProviderBizError,
62
+ } satisfies ChatMessageError;
62
63
  return { data: errorData, id: 'first_chunk_error', type: 'error' };
63
64
  }
64
65
 
@@ -45,8 +45,9 @@ const transformOpenAIStream = (
45
45
 
46
46
  const errorData = {
47
47
  body: chunk,
48
- type: 'errorType' in chunk ? chunk.errorType : AgentRuntimeErrorType.ProviderBizError,
49
- } as ChatMessageError;
48
+ message: 'message' in chunk ? typeof chunk.message === 'string' ? chunk.message : JSON.stringify(chunk) : JSON.stringify(chunk),
49
+ type: 'errorType' in chunk ? chunk.errorType as typeof AgentRuntimeErrorType.ProviderBizError : AgentRuntimeErrorType.ProviderBizError,
50
+ } satisfies ChatMessageError;
50
51
  return { data: errorData, id: 'first_chunk_error', type: 'error' };
51
52
  }
52
53
 
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
 
3
- import { createSSEDataExtractor, createTokenSpeedCalculator } from './protocol';
3
+ import { createSSEDataExtractor, createTokenSpeedCalculator, createSSEProtocolTransformer } from './protocol';
4
4
 
5
5
  describe('createSSEDataExtractor', () => {
6
6
  // Helper function to convert string to Uint8Array
@@ -233,3 +233,82 @@ describe('createTokenSpeedCalculator', async () => {
233
233
  expect(speedChunk.data.ttft).not.toBeNaN();
234
234
  });
235
235
  });
236
+
237
+ describe('createSSEProtocolTransformer', () => {
238
+ const processChunk = async (transformer: TransformStream, chunk: any) => {
239
+ const results: any[] = [];
240
+ const readable = new ReadableStream({
241
+ start(controller) {
242
+ controller.enqueue(chunk);
243
+ controller.close();
244
+ },
245
+ });
246
+
247
+ const writable = new WritableStream({
248
+ write(chunk) {
249
+ results.push(chunk);
250
+ },
251
+ });
252
+
253
+ await readable.pipeThrough(transformer).pipeTo(writable);
254
+
255
+ return results;
256
+ };
257
+
258
+ it('should convert chunk into SSE formatted lines without enforcing terminal (default)', async () => {
259
+ const transformerFn = (chunk: any) => ({ type: 'text', id: chunk.id, data: chunk.data });
260
+ const transformer = createSSEProtocolTransformer(transformerFn as any);
261
+
262
+ const input = { id: '1', data: 'hello' };
263
+ const results = await processChunk(transformer, input);
264
+
265
+ // Should only output the text event, no injected error on flush (default not enforced)
266
+ expect(results).toEqual([
267
+ `id: 1\n`,
268
+ `event: text\n`,
269
+ `data: ${JSON.stringify('hello')}\n\n`,
270
+ ]);
271
+ });
272
+
273
+ it('should not emit flush error if a terminal event was received (enforced)', async () => {
274
+ const transformerFn = (chunk: any) => ({ type: 'stop', id: chunk.id, data: chunk.data });
275
+ const transformer = createSSEProtocolTransformer(transformerFn as any, { id: 'stream_ok' }, { requireTerminalEvent: true });
276
+
277
+ const input = { id: 'ok', data: 'bye' };
278
+ const results = await processChunk(transformer, input);
279
+
280
+ // Only the stop event lines should be present (no extra error event from flush)
281
+ expect(results).toEqual([
282
+ `id: ok\n`,
283
+ `event: stop\n`,
284
+ `data: ${JSON.stringify('bye')}\n\n`,
285
+ ]);
286
+ });
287
+
288
+ it('should emit an error event on flush when no terminal event received (enforced)', async () => {
289
+ const transformerFn = (chunk: any) => ({ type: 'text', id: chunk.id, data: chunk.data });
290
+ const streamStack = { id: 'stream_missing_term' } as any;
291
+ const transformer = createSSEProtocolTransformer(transformerFn as any, streamStack, { requireTerminalEvent: true });
292
+
293
+ const input = { id: '1', data: 'partial' };
294
+ const results = await processChunk(transformer, input);
295
+
296
+ // original 3 lines + 3 lines from flush error
297
+ expect(results).toHaveLength(6);
298
+
299
+ // last three lines should be the injected error event
300
+ const lastThree = results.slice(-3);
301
+ const expectedData = {
302
+ body: { name: 'Stream parsing error', reason: 'unexpected_end' },
303
+ message: 'Stream ended unexpectedly',
304
+ name: 'Stream parsing error',
305
+ type: 'StreamChunkError',
306
+ };
307
+
308
+ expect(lastThree).toEqual([
309
+ `id: ${streamStack.id}\n`,
310
+ `event: error\n`,
311
+ `data: ${JSON.stringify(expectedData)}\n\n`,
312
+ ]);
313
+ });
314
+ });
@@ -166,8 +166,27 @@ export const convertIterableToStream = <T>(stream: AsyncIterable<T>) => {
166
166
  export const createSSEProtocolTransformer = (
167
167
  transformer: (chunk: any, stack: StreamContext) => StreamProtocolChunk | StreamProtocolChunk[],
168
168
  streamStack?: StreamContext,
169
- ) =>
170
- new TransformStream({
169
+ options?: { requireTerminalEvent?: boolean },
170
+ ) => {
171
+ let hasTerminalEvent = false;
172
+ const requireTerminalEvent = Boolean(options?.requireTerminalEvent);
173
+
174
+ return new TransformStream({
175
+ flush(controller) {
176
+ // If the upstream closes without sending a terminal event, emit a final error event
177
+ if (requireTerminalEvent && !hasTerminalEvent) {
178
+ const id = streamStack?.id || 'stream_end';
179
+ const data = {
180
+ body: { name: 'Stream parsing error', reason: 'unexpected_end' },
181
+ message: 'Stream ended unexpectedly',
182
+ name: 'Stream parsing error',
183
+ type: 'StreamChunkError',
184
+ };
185
+ controller.enqueue(`id: ${id}\n`);
186
+ controller.enqueue(`event: error\n`);
187
+ controller.enqueue(`data: ${JSON.stringify(data)}\n\n`);
188
+ }
189
+ },
171
190
  transform: (chunk, controller) => {
172
191
  const result = transformer(chunk, streamStack || { id: '' });
173
192
 
@@ -177,9 +196,13 @@ export const createSSEProtocolTransformer = (
177
196
  controller.enqueue(`id: ${id}\n`);
178
197
  controller.enqueue(`event: ${type}\n`);
179
198
  controller.enqueue(`data: ${JSON.stringify(data)}\n\n`);
199
+
200
+ // mark terminal when receiving any of these events
201
+ if (type === 'stop' || type === 'usage' || type === 'error') hasTerminalEvent = true;
180
202
  });
181
203
  },
182
204
  });
205
+ };
183
206
 
184
207
  export function createCallbacksTransformer(cb: ChatStreamCallbacks | undefined) {
185
208
  const textEncoder = new TextEncoder();
@@ -8,6 +8,8 @@ import { ChatStreamPayload } from '@/types/openai/chat';
8
8
 
9
9
  import * as debugStreamModule from '../../utils/debugStream';
10
10
  import * as imageToBase64Module from '../../utils/imageToBase64';
11
+ import { LOBE_ERROR_KEY } from '../../core/streams/google-ai';
12
+ import { AgentRuntimeErrorType } from '../../types/error';
11
13
  import { LobeGoogleAI } from './index';
12
14
 
13
15
  const provider = 'google';
@@ -825,5 +827,158 @@ describe('LobeGoogleAI', () => {
825
827
  });
826
828
  });
827
829
  });
830
+
831
+ describe('createEnhancedStream', () => {
832
+ it('should handle stream cancellation with data gracefully', async () => {
833
+ const mockStream = (async function* () {
834
+ yield { text: 'Hello' };
835
+ yield { text: ' world' };
836
+ })();
837
+
838
+ const abortController = new AbortController();
839
+ const enhancedStream = instance['createEnhancedStream'](mockStream, abortController.signal);
840
+
841
+ const reader = enhancedStream.getReader();
842
+ const chunks: any[] = [];
843
+
844
+ // Read first value then cancel to trigger error chunk
845
+ chunks.push((await reader.read()).value);
846
+ abortController.abort();
847
+
848
+ // Read all remaining chunks
849
+ let result;
850
+ while (!(result = await reader.read()).done) {
851
+ chunks.push(result.value);
852
+ }
853
+
854
+ // Batch-assert the entire chunks array
855
+ expect(chunks).toEqual([
856
+ { text: 'Hello' },
857
+ {
858
+ [LOBE_ERROR_KEY]: {
859
+ body: { name: 'Stream cancelled', provider, reason: 'aborted' },
860
+ message: 'Stream cancelled',
861
+ name: 'Stream cancelled',
862
+ type: AgentRuntimeErrorType.StreamChunkError,
863
+ },
864
+ },
865
+ ]);
866
+ });
867
+
868
+ it('should handle stream cancellation without data', async () => {
869
+ const mockStream = (async function* () {
870
+ // Empty stream
871
+ })();
872
+
873
+ const abortController = new AbortController();
874
+ const enhancedStream = instance['createEnhancedStream'](mockStream, abortController.signal);
875
+
876
+ const reader = enhancedStream.getReader();
877
+
878
+ // Cancel immediately
879
+ abortController.abort();
880
+
881
+ // Should be closed without any chunks
882
+ const chunk = await reader.read();
883
+ expect(chunk.done).toBe(true);
884
+ });
885
+
886
+ it('should handle AbortError with data', async () => {
887
+ const mockStream = (async function* () {
888
+ yield { text: 'Hello' };
889
+ throw new Error('aborted');
890
+ })();
891
+
892
+ const abortController = new AbortController();
893
+ const enhancedStream = instance['createEnhancedStream'](mockStream, abortController.signal);
894
+
895
+ const reader = enhancedStream.getReader();
896
+ const chunks: any[] = [];
897
+
898
+ // Read first value then collect remaining chunks (error included)
899
+ chunks.push((await reader.read()).value);
900
+ let result;
901
+ while (!(result = await reader.read()).done) {
902
+ chunks.push(result.value);
903
+ }
904
+
905
+ // Assert both data and error chunk together
906
+ expect(chunks).toEqual([
907
+ { text: 'Hello' },
908
+ {
909
+ [LOBE_ERROR_KEY]: {
910
+ body: { name: 'Stream cancelled', provider, reason: 'aborted' },
911
+ message: 'Stream cancelled',
912
+ name: 'Stream cancelled',
913
+ type: AgentRuntimeErrorType.StreamChunkError,
914
+ },
915
+ },
916
+ ]);
917
+ });
918
+
919
+ it('should handle AbortError without data', async () => {
920
+ const mockStream = (async function* () {
921
+ throw new Error('aborted');
922
+ })();
923
+
924
+ const abortController = new AbortController();
925
+ const enhancedStream = instance['createEnhancedStream'](mockStream, abortController.signal);
926
+
927
+ const reader = enhancedStream.getReader();
928
+ const chunks: any[] = [];
929
+
930
+ // Read error chunk
931
+ const chunk1 = await reader.read();
932
+ chunks.push(chunk1.value);
933
+
934
+ // Stream should be closed
935
+ const chunk2 = await reader.read();
936
+ expect(chunk2.done).toBe(true);
937
+
938
+ expect(chunks[0][LOBE_ERROR_KEY]).toEqual({
939
+ body: {
940
+ message: 'aborted',
941
+ name: 'AbortError',
942
+ provider,
943
+ stack: expect.any(String),
944
+ },
945
+ message: 'aborted',
946
+ name: 'AbortError',
947
+ type: AgentRuntimeErrorType.StreamChunkError,
948
+ });
949
+ });
950
+
951
+ it('should handle other stream parsing errors', async () => {
952
+ const mockStream = (async function* () {
953
+ yield { text: 'Hello' };
954
+ throw new Error('Network error');
955
+ })();
956
+
957
+ const abortController = new AbortController();
958
+ const enhancedStream = instance['createEnhancedStream'](mockStream, abortController.signal);
959
+
960
+ const reader = enhancedStream.getReader();
961
+ const chunks: any[] = [];
962
+
963
+ // Read first value then collect remaining chunks (parsing error)
964
+ chunks.push((await reader.read()).value);
965
+ let result;
966
+ while (!(result = await reader.read()).done) {
967
+ chunks.push(result.value);
968
+ }
969
+
970
+ expect(chunks).toEqual([
971
+ { text: 'Hello' },
972
+ {
973
+ [LOBE_ERROR_KEY]: {
974
+ body: { message: 'Network error', provider },
975
+ message: 'Network error',
976
+ name: 'Stream parsing error',
977
+ type: AgentRuntimeErrorType.ProviderBizError,
978
+ },
979
+ },
980
+ ]);
981
+ });
982
+ });
828
983
  });
829
984
  });
@@ -10,6 +10,7 @@ import {
10
10
  ThinkingConfig,
11
11
  } from '@google/genai';
12
12
 
13
+ import { LOBE_ERROR_KEY } from '../../core/streams/google-ai';
13
14
  import { LobeRuntimeAI } from '../../core/BaseAI';
14
15
  import { GoogleGenerativeAIStream, VertexAIStream } from '../../core/streams';
15
16
  import {
@@ -29,6 +30,9 @@ import { StreamingResponse } from '../../utils/response';
29
30
  import { safeParseJSON } from '../../utils/safeParseJSON';
30
31
  import { parseDataUri } from '../../utils/uriParser';
31
32
  import { createGoogleImage } from './createImage';
33
+ import debug from 'debug';
34
+
35
+ const log = debug('model-runtime:google');
32
36
 
33
37
  const modelsOffSafetySettings = new Set(['gemini-2.0-flash-exp']);
34
38
 
@@ -244,7 +248,7 @@ export class LobeGoogleAI implements LobeRuntimeAI {
244
248
 
245
249
  // 移除之前的静默处理,统一抛出错误
246
250
  if (isAbortError(err)) {
247
- console.log('Request was cancelled');
251
+ log('Request was cancelled');
248
252
  throw AgentRuntimeError.chat({
249
253
  error: { message: 'Request was cancelled' },
250
254
  errorType: AgentRuntimeErrorType.ProviderBizError,
@@ -252,7 +256,7 @@ export class LobeGoogleAI implements LobeRuntimeAI {
252
256
  });
253
257
  }
254
258
 
255
- console.log(err);
259
+ log('Error: %O', err);
256
260
  const { errorType, error } = parseGoogleErrorMessage(err.message);
257
261
 
258
262
  throw AgentRuntimeError.chat({ error, errorType, provider: this.provider });
@@ -268,6 +272,8 @@ export class LobeGoogleAI implements LobeRuntimeAI {
268
272
  }
269
273
 
270
274
  private createEnhancedStream(originalStream: any, signal: AbortSignal): ReadableStream {
275
+ // capture provider for error payloads inside the stream closure
276
+ const provider = this.provider;
271
277
  return new ReadableStream({
272
278
  async start(controller) {
273
279
  let hasData = false;
@@ -277,12 +283,23 @@ export class LobeGoogleAI implements LobeRuntimeAI {
277
283
  if (signal.aborted) {
278
284
  // 如果有数据已经输出,优雅地关闭流而不是抛出错误
279
285
  if (hasData) {
280
- console.log('Stream cancelled gracefully, preserving existing output');
286
+ log('Stream cancelled gracefully, preserving existing output');
287
+ // 显式注入取消错误,避免走 SSE 兜底 unexpected_end
288
+ controller.enqueue({
289
+ [LOBE_ERROR_KEY]: {
290
+ body: { name: 'Stream cancelled', provider, reason: 'aborted' },
291
+ message: 'Stream cancelled',
292
+ name: 'Stream cancelled',
293
+ type: AgentRuntimeErrorType.StreamChunkError,
294
+ },
295
+ });
281
296
  controller.close();
282
297
  return;
283
298
  } else {
284
- // 如果还没有数据输出,则抛出取消错误
285
- throw new Error('Stream cancelled');
299
+ // 如果还没有数据输出,直接关闭流,由下游 SSE 在 flush 阶段补发错误事件
300
+ log('Stream cancelled before any output');
301
+ controller.close();
302
+ return;
286
303
  }
287
304
  }
288
305
 
@@ -296,18 +313,55 @@ export class LobeGoogleAI implements LobeRuntimeAI {
296
313
  if (isAbortError(err) || signal.aborted) {
297
314
  // 如果有数据已经输出,优雅地关闭流
298
315
  if (hasData) {
299
- console.log('Stream reading cancelled gracefully, preserving existing output');
316
+ log('Stream reading cancelled gracefully, preserving existing output');
317
+ // 显式注入取消错误,避免走 SSE 兜底 unexpected_end
318
+ controller.enqueue({
319
+ [LOBE_ERROR_KEY]: {
320
+ body: { name: 'Stream cancelled', provider, reason: 'aborted' },
321
+ message: 'Stream cancelled',
322
+ name: 'Stream cancelled',
323
+ type: AgentRuntimeErrorType.StreamChunkError,
324
+ },
325
+ });
300
326
  controller.close();
301
327
  return;
302
328
  } else {
303
- console.log('Stream reading cancelled before any output');
304
- controller.error(new Error('Stream cancelled'));
329
+ log('Stream reading cancelled before any output');
330
+ // 注入一个带详细错误信息的错误标记,交由下游 google-ai transformer 输出 error 事件
331
+ controller.enqueue({
332
+ [LOBE_ERROR_KEY]: {
333
+ body: {
334
+ message: err.message,
335
+ name: 'AbortError',
336
+ provider,
337
+ stack: err.stack,
338
+ },
339
+ message: err.message || 'Request was cancelled',
340
+ name: 'AbortError',
341
+ type: AgentRuntimeErrorType.StreamChunkError,
342
+ },
343
+ });
344
+ controller.close();
305
345
  return;
306
346
  }
307
347
  } else {
308
348
  // 处理其他流解析错误
309
- console.error('Stream parsing error:', err);
310
- controller.error(err);
349
+ log('Stream parsing error: %O', err);
350
+ // 尝试解析 Google 错误并提取 code/message/status
351
+ const { error: parsedError, errorType } = parseGoogleErrorMessage(
352
+ err?.message || String(err),
353
+ );
354
+
355
+ // 注入一个带详细错误信息的错误标记,交由下游 google-ai transformer 输出 error 事件
356
+ controller.enqueue({
357
+ [LOBE_ERROR_KEY]: {
358
+ body: { ...parsedError, provider },
359
+ message: parsedError?.message || err.message || 'Stream parsing error',
360
+ name: 'Stream parsing error',
361
+ type: errorType ?? AgentRuntimeErrorType.StreamChunkError,
362
+ },
363
+ });
364
+ controller.close();
311
365
  return;
312
366
  }
313
367
  }
@@ -348,7 +402,7 @@ export class LobeGoogleAI implements LobeRuntimeAI {
348
402
 
349
403
  return processModelList(processedModels, MODEL_LIST_CONFIGS.google);
350
404
  } catch (error) {
351
- console.error('Failed to fetch Google models:', error);
405
+ log('Failed to fetch Google models: %O', error);
352
406
  throw error;
353
407
  }
354
408
  }
@@ -34,7 +34,7 @@ const Client = memo<{ mobile?: boolean }>(() => {
34
34
  </Title>
35
35
  <AssistantList data={assistantList.items} rows={4} />
36
36
  <div />
37
- <Title more={t('home.more')} moreLink={'/discover/plugin'}>
37
+ <Title more={t('home.more')} moreLink={'/discover/mcp'}>
38
38
  {t('home.featuredTools')}
39
39
  </Title>
40
40
  <McpList data={mcpList.items} rows={4} />
@@ -247,7 +247,7 @@ const Qwen: ModelProviderCard = {
247
247
  releasedAt: '2025-02-05',
248
248
  },
249
249
  ],
250
- checkModel: 'qwen-flash-latest',
250
+ checkModel: 'qwen-flash',
251
251
  description:
252
252
  '通义千问是阿里云自主研发的超大规模语言模型,具有强大的自然语言理解和生成能力。它可以回答各种问题、创作文字内容、表达观点看法、撰写代码等,在多个领域发挥作用。',
253
253
  disableBrowserRequest: true,
@@ -419,7 +419,7 @@ const SiliconCloud: ModelProviderCard = {
419
419
  vision: true,
420
420
  },
421
421
  ],
422
- checkModel: 'Pro/Qwen/Qwen2-1.5B-Instruct',
422
+ checkModel: 'Pro/Qwen/Qwen2-7B-Instruct',
423
423
  description: 'SiliconCloud,基于优秀开源基础模型的高性价比 GenAI 云服务',
424
424
  id: 'siliconcloud',
425
425
  modelList: { showModelFetcher: true },