@lobehub/chat 1.99.2 → 1.99.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/.cursor/rules/project-introduce.mdc +1 -56
  2. package/.cursor/rules/testing-guide/db-model-test.mdc +453 -0
  3. package/.cursor/rules/testing-guide/electron-ipc-test.mdc +80 -0
  4. package/.cursor/rules/testing-guide/testing-guide.mdc +401 -0
  5. package/CHANGELOG.md +50 -0
  6. package/changelog/v1.json +18 -0
  7. package/docs/usage/providers/ai21.mdx +1 -1
  8. package/docs/usage/providers/ai21.zh-CN.mdx +1 -1
  9. package/docs/usage/providers/ai360.mdx +1 -1
  10. package/docs/usage/providers/ai360.zh-CN.mdx +1 -1
  11. package/docs/usage/providers/anthropic.mdx +1 -1
  12. package/docs/usage/providers/anthropic.zh-CN.mdx +1 -1
  13. package/docs/usage/providers/azure.mdx +1 -1
  14. package/docs/usage/providers/azure.zh-CN.mdx +1 -1
  15. package/docs/usage/providers/baichuan.mdx +1 -1
  16. package/docs/usage/providers/baichuan.zh-CN.mdx +1 -1
  17. package/docs/usage/providers/bedrock.mdx +1 -1
  18. package/docs/usage/providers/bedrock.zh-CN.mdx +1 -1
  19. package/docs/usage/providers/cloudflare.mdx +1 -1
  20. package/docs/usage/providers/cloudflare.zh-CN.mdx +1 -1
  21. package/docs/usage/providers/deepseek.mdx +1 -1
  22. package/docs/usage/providers/deepseek.zh-CN.mdx +1 -1
  23. package/docs/usage/providers/fal.mdx +69 -0
  24. package/docs/usage/providers/fal.zh-CN.mdx +68 -0
  25. package/docs/usage/providers/fireworksai.mdx +1 -1
  26. package/docs/usage/providers/fireworksai.zh-CN.mdx +1 -1
  27. package/docs/usage/providers/giteeai.mdx +1 -1
  28. package/docs/usage/providers/giteeai.zh-CN.mdx +1 -1
  29. package/docs/usage/providers/github.mdx +1 -1
  30. package/docs/usage/providers/github.zh-CN.mdx +1 -1
  31. package/docs/usage/providers/google.mdx +1 -1
  32. package/docs/usage/providers/google.zh-CN.mdx +1 -1
  33. package/docs/usage/providers/groq.mdx +1 -1
  34. package/docs/usage/providers/groq.zh-CN.mdx +1 -1
  35. package/docs/usage/providers/hunyuan.mdx +1 -1
  36. package/docs/usage/providers/hunyuan.zh-CN.mdx +1 -1
  37. package/docs/usage/providers/internlm.mdx +1 -1
  38. package/docs/usage/providers/internlm.zh-CN.mdx +1 -1
  39. package/docs/usage/providers/jina.mdx +1 -1
  40. package/docs/usage/providers/jina.zh-CN.mdx +1 -1
  41. package/docs/usage/providers/minimax.mdx +1 -1
  42. package/docs/usage/providers/minimax.zh-CN.mdx +1 -1
  43. package/docs/usage/providers/mistral.mdx +1 -1
  44. package/docs/usage/providers/mistral.zh-CN.mdx +1 -1
  45. package/docs/usage/providers/moonshot.mdx +1 -1
  46. package/docs/usage/providers/moonshot.zh-CN.mdx +1 -1
  47. package/docs/usage/providers/novita.mdx +1 -1
  48. package/docs/usage/providers/novita.zh-CN.mdx +1 -1
  49. package/docs/usage/providers/ollama.mdx +1 -1
  50. package/docs/usage/providers/ollama.zh-CN.mdx +1 -1
  51. package/docs/usage/providers/openai.mdx +4 -4
  52. package/docs/usage/providers/openai.zh-CN.mdx +4 -4
  53. package/docs/usage/providers/openrouter.mdx +1 -1
  54. package/docs/usage/providers/openrouter.zh-CN.mdx +1 -1
  55. package/docs/usage/providers/perplexity.mdx +1 -1
  56. package/docs/usage/providers/perplexity.zh-CN.mdx +1 -1
  57. package/docs/usage/providers/ppio.mdx +1 -1
  58. package/docs/usage/providers/ppio.zh-CN.mdx +1 -1
  59. package/docs/usage/providers/qiniu.mdx +1 -1
  60. package/docs/usage/providers/qiniu.zh-CN.mdx +1 -1
  61. package/docs/usage/providers/qwen.mdx +1 -1
  62. package/docs/usage/providers/qwen.zh-CN.mdx +1 -1
  63. package/docs/usage/providers/sambanova.mdx +1 -1
  64. package/docs/usage/providers/sambanova.zh-CN.mdx +1 -1
  65. package/docs/usage/providers/sensenova.mdx +1 -1
  66. package/docs/usage/providers/sensenova.zh-CN.mdx +1 -1
  67. package/docs/usage/providers/siliconcloud.mdx +1 -1
  68. package/docs/usage/providers/siliconcloud.zh-CN.mdx +1 -1
  69. package/docs/usage/providers/spark.mdx +1 -1
  70. package/docs/usage/providers/spark.zh-CN.mdx +1 -1
  71. package/docs/usage/providers/stepfun.mdx +1 -1
  72. package/docs/usage/providers/stepfun.zh-CN.mdx +1 -1
  73. package/docs/usage/providers/taichu.mdx +1 -1
  74. package/docs/usage/providers/taichu.zh-CN.mdx +1 -1
  75. package/docs/usage/providers/togetherai.mdx +1 -1
  76. package/docs/usage/providers/togetherai.zh-CN.mdx +1 -1
  77. package/docs/usage/providers/upstage.mdx +1 -1
  78. package/docs/usage/providers/upstage.zh-CN.mdx +1 -1
  79. package/docs/usage/providers/vllm.mdx +1 -1
  80. package/docs/usage/providers/vllm.zh-CN.mdx +1 -1
  81. package/docs/usage/providers/wenxin.mdx +1 -1
  82. package/docs/usage/providers/wenxin.zh-CN.mdx +1 -1
  83. package/docs/usage/providers/xai.mdx +1 -1
  84. package/docs/usage/providers/xai.zh-CN.mdx +1 -1
  85. package/docs/usage/providers/zeroone.mdx +1 -1
  86. package/docs/usage/providers/zeroone.zh-CN.mdx +1 -1
  87. package/docs/usage/providers/zhipu.mdx +1 -1
  88. package/docs/usage/providers/zhipu.zh-CN.mdx +1 -1
  89. package/package.json +2 -2
  90. package/src/config/aiModels/openai.ts +24 -9
  91. package/src/libs/model-runtime/BaseAI.ts +1 -0
  92. package/src/libs/model-runtime/ModelRuntime.ts +0 -1
  93. package/src/libs/model-runtime/hunyuan/index.ts +4 -6
  94. package/src/libs/model-runtime/novita/__snapshots__/index.test.ts.snap +18 -0
  95. package/src/libs/model-runtime/openai/__snapshots__/index.test.ts.snap +28 -0
  96. package/src/libs/model-runtime/openai/index.test.ts +1 -338
  97. package/src/libs/model-runtime/openai/index.ts +0 -127
  98. package/src/libs/model-runtime/openrouter/__snapshots__/index.test.ts.snap +3 -0
  99. package/src/libs/model-runtime/ppio/__snapshots__/index.test.ts.snap +2 -0
  100. package/src/libs/model-runtime/utils/modelParse.ts +1 -0
  101. package/src/libs/model-runtime/utils/openaiCompatibleFactory/index.test.ts +364 -12
  102. package/src/libs/model-runtime/utils/openaiCompatibleFactory/index.ts +145 -43
  103. package/src/libs/model-runtime/utils/openaiHelpers.test.ts +151 -0
  104. package/src/libs/model-runtime/utils/openaiHelpers.ts +26 -1
  105. package/src/libs/model-runtime/xai/index.ts +1 -4
  106. package/src/store/aiInfra/slices/aiModel/action.ts +1 -1
  107. package/src/store/aiInfra/slices/aiProvider/action.ts +5 -2
  108. package/src/types/aiModel.ts +1 -0
  109. package/src/types/llm.ts +3 -1
  110. package/.cursor/rules/testing-guide.mdc +0 -881
@@ -10,6 +10,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
10
10
  "id": "whisper-1",
11
11
  "maxOutput": undefined,
12
12
  "reasoning": false,
13
+ "type": "stt",
13
14
  "vision": false,
14
15
  },
15
16
  {
@@ -20,6 +21,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
20
21
  "id": "davinci-002",
21
22
  "maxOutput": undefined,
22
23
  "reasoning": false,
24
+ "type": "chat",
23
25
  "vision": false,
24
26
  },
25
27
  {
@@ -30,6 +32,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
30
32
  "id": "gpt-3.5-turbo",
31
33
  "maxOutput": undefined,
32
34
  "reasoning": false,
35
+ "type": "chat",
33
36
  "vision": false,
34
37
  },
35
38
  {
@@ -40,6 +43,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
40
43
  "id": "dall-e-2",
41
44
  "maxOutput": undefined,
42
45
  "reasoning": false,
46
+ "type": "image",
43
47
  "vision": false,
44
48
  },
45
49
  {
@@ -50,6 +54,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
50
54
  "id": "gpt-3.5-turbo-16k",
51
55
  "maxOutput": undefined,
52
56
  "reasoning": false,
57
+ "type": "chat",
53
58
  "vision": false,
54
59
  },
55
60
  {
@@ -60,6 +65,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
60
65
  "id": "tts-1-hd-1106",
61
66
  "maxOutput": undefined,
62
67
  "reasoning": false,
68
+ "type": "chat",
63
69
  "vision": false,
64
70
  },
65
71
  {
@@ -70,6 +76,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
70
76
  "id": "tts-1-hd",
71
77
  "maxOutput": undefined,
72
78
  "reasoning": false,
79
+ "type": "tts",
73
80
  "vision": false,
74
81
  },
75
82
  {
@@ -80,6 +87,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
80
87
  "id": "gpt-3.5-turbo-16k-0613",
81
88
  "maxOutput": undefined,
82
89
  "reasoning": false,
90
+ "type": "chat",
83
91
  "vision": false,
84
92
  },
85
93
  {
@@ -90,6 +98,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
90
98
  "id": "text-embedding-3-large",
91
99
  "maxOutput": undefined,
92
100
  "reasoning": false,
101
+ "type": "embedding",
93
102
  "vision": false,
94
103
  },
95
104
  {
@@ -100,6 +109,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
100
109
  "id": "gpt-4-1106-vision-preview",
101
110
  "maxOutput": undefined,
102
111
  "reasoning": false,
112
+ "type": "chat",
103
113
  "vision": false,
104
114
  },
105
115
  {
@@ -110,6 +120,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
110
120
  "id": "gpt-3.5-turbo-instruct-0914",
111
121
  "maxOutput": undefined,
112
122
  "reasoning": false,
123
+ "type": "chat",
113
124
  "vision": false,
114
125
  },
115
126
  {
@@ -120,6 +131,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
120
131
  "id": "gpt-4-0125-preview",
121
132
  "maxOutput": undefined,
122
133
  "reasoning": false,
134
+ "type": "chat",
123
135
  "vision": false,
124
136
  },
125
137
  {
@@ -130,6 +142,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
130
142
  "id": "gpt-4-turbo-preview",
131
143
  "maxOutput": undefined,
132
144
  "reasoning": false,
145
+ "type": "chat",
133
146
  "vision": false,
134
147
  },
135
148
  {
@@ -140,6 +153,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
140
153
  "id": "gpt-3.5-turbo-instruct",
141
154
  "maxOutput": undefined,
142
155
  "reasoning": false,
156
+ "type": "chat",
143
157
  "vision": false,
144
158
  },
145
159
  {
@@ -150,6 +164,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
150
164
  "id": "gpt-3.5-turbo-0301",
151
165
  "maxOutput": undefined,
152
166
  "reasoning": false,
167
+ "type": "chat",
153
168
  "vision": false,
154
169
  },
155
170
  {
@@ -160,6 +175,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
160
175
  "id": "gpt-3.5-turbo-0613",
161
176
  "maxOutput": undefined,
162
177
  "reasoning": false,
178
+ "type": "chat",
163
179
  "vision": false,
164
180
  },
165
181
  {
@@ -170,6 +186,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
170
186
  "id": "tts-1",
171
187
  "maxOutput": undefined,
172
188
  "reasoning": false,
189
+ "type": "tts",
173
190
  "vision": false,
174
191
  },
175
192
  {
@@ -180,6 +197,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
180
197
  "id": "dall-e-3",
181
198
  "maxOutput": undefined,
182
199
  "reasoning": false,
200
+ "type": "image",
183
201
  "vision": false,
184
202
  },
185
203
  {
@@ -190,6 +208,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
190
208
  "id": "gpt-3.5-turbo-1106",
191
209
  "maxOutput": undefined,
192
210
  "reasoning": false,
211
+ "type": "chat",
193
212
  "vision": false,
194
213
  },
195
214
  {
@@ -200,6 +219,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
200
219
  "id": "gpt-4-1106-preview",
201
220
  "maxOutput": undefined,
202
221
  "reasoning": false,
222
+ "type": "chat",
203
223
  "vision": false,
204
224
  },
205
225
  {
@@ -210,6 +230,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
210
230
  "id": "babbage-002",
211
231
  "maxOutput": undefined,
212
232
  "reasoning": false,
233
+ "type": "chat",
213
234
  "vision": false,
214
235
  },
215
236
  {
@@ -220,6 +241,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
220
241
  "id": "tts-1-1106",
221
242
  "maxOutput": undefined,
222
243
  "reasoning": false,
244
+ "type": "chat",
223
245
  "vision": false,
224
246
  },
225
247
  {
@@ -230,6 +252,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
230
252
  "id": "gpt-4-vision-preview",
231
253
  "maxOutput": undefined,
232
254
  "reasoning": false,
255
+ "type": "chat",
233
256
  "vision": true,
234
257
  },
235
258
  {
@@ -240,6 +263,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
240
263
  "id": "text-embedding-3-small",
241
264
  "maxOutput": undefined,
242
265
  "reasoning": false,
266
+ "type": "embedding",
243
267
  "vision": false,
244
268
  },
245
269
  {
@@ -250,6 +274,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
250
274
  "id": "gpt-4",
251
275
  "maxOutput": 4096,
252
276
  "reasoning": false,
277
+ "type": "chat",
253
278
  "vision": true,
254
279
  },
255
280
  {
@@ -260,6 +285,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
260
285
  "id": "text-embedding-ada-002",
261
286
  "maxOutput": undefined,
262
287
  "reasoning": false,
288
+ "type": "chat",
263
289
  "vision": false,
264
290
  },
265
291
  {
@@ -270,6 +296,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
270
296
  "id": "gpt-3.5-turbo-0125",
271
297
  "maxOutput": undefined,
272
298
  "reasoning": false,
299
+ "type": "chat",
273
300
  "vision": false,
274
301
  },
275
302
  {
@@ -280,6 +307,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
280
307
  "id": "gpt-4-0613",
281
308
  "maxOutput": undefined,
282
309
  "reasoning": false,
310
+ "type": "chat",
283
311
  "vision": false,
284
312
  },
285
313
  ]
@@ -4,7 +4,6 @@ import { Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
4
 
5
5
  // 引入模块以便于对函数进行spy
6
6
  import { ChatStreamCallbacks } from '@/libs/model-runtime';
7
- import * as openai from '@/libs/model-runtime/openai';
8
7
 
9
8
  import * as debugStreamModule from '../utils/debugStream';
10
9
  import officalOpenAIModels from './fixtures/openai-models.json';
@@ -13,22 +12,13 @@ import { LobeOpenAI } from './index';
13
12
  // Mock the console.error to avoid polluting test output
14
13
  vi.spyOn(console, 'error').mockImplementation(() => {});
15
14
 
16
- // Mock fetch for most tests, but will be restored for convertImageUrlToFile tests
15
+ // Mock fetch for most tests, but will be restored for real network tests
17
16
  const mockFetch = vi.fn();
18
17
  global.fetch = mockFetch;
19
18
 
20
- const convertImageUrlToFileSpy = vi.spyOn(openai, 'convertImageUrlToFile');
21
-
22
19
  describe('LobeOpenAI', () => {
23
20
  let instance: InstanceType<typeof LobeOpenAI>;
24
21
 
25
- // Create mock params for createImage tests - only gpt-image-1 supported params
26
- const mockParams = {
27
- prompt: 'test prompt',
28
- imageUrls: [] as string[],
29
- size: '1024x1024' as const,
30
- };
31
-
32
22
  beforeEach(() => {
33
23
  instance = new LobeOpenAI({ apiKey: 'test' });
34
24
 
@@ -40,27 +30,6 @@ describe('LobeOpenAI', () => {
40
30
 
41
31
  // Mock responses.create for responses API tests
42
32
  vi.spyOn(instance['client'].responses, 'create').mockResolvedValue(new ReadableStream() as any);
43
-
44
- // Mock convertImageUrlToFile to return a mock File object
45
- convertImageUrlToFileSpy.mockResolvedValue({
46
- name: 'image.png',
47
- type: 'image/png',
48
- size: 1024,
49
- } as any);
50
-
51
- // Mock fetch response for most tests
52
- mockFetch.mockResolvedValue({
53
- ok: true,
54
- arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
55
- headers: {
56
- get: (header: string) => {
57
- if (header === 'content-type') {
58
- return 'image/png';
59
- }
60
- return null;
61
- },
62
- },
63
- });
64
33
  });
65
34
 
66
35
  afterEach(() => {
@@ -282,312 +251,6 @@ describe('LobeOpenAI', () => {
282
251
  });
283
252
  });
284
253
 
285
- describe('createImage', () => {
286
- it('should generate an image with gpt-image-1', async () => {
287
- // Arrange
288
- const mockResponse = { data: [{ b64_json: 'test-base64-string' }] };
289
- const generateSpy = vi
290
- .spyOn(instance['client'].images, 'generate')
291
- .mockResolvedValue(mockResponse as any);
292
-
293
- // Act
294
- const result = await instance.createImage({
295
- model: 'gpt-image-1',
296
- params: {
297
- ...mockParams,
298
- prompt: 'A cute cat',
299
- size: '1024x1024',
300
- },
301
- });
302
-
303
- // Assert
304
- expect(generateSpy).toHaveBeenCalledWith(
305
- expect.objectContaining({
306
- model: 'gpt-image-1',
307
- prompt: 'A cute cat',
308
- n: 1,
309
- size: '1024x1024',
310
- }),
311
- );
312
- expect(result.imageUrl).toBe('data:image/png;base64,test-base64-string');
313
- });
314
-
315
- it('should edit an image from a URL', async () => {
316
- // Arrange
317
- const mockResponse = { data: [{ b64_json: 'edited-base64-string' }] };
318
- const editSpy = vi
319
- .spyOn(instance['client'].images, 'edit')
320
- .mockResolvedValue(mockResponse as any);
321
-
322
- // Temporarily restore the spy to use real implementation
323
- convertImageUrlToFileSpy.mockRestore();
324
-
325
- const imageUrl = 'https://lobehub.com/_next/static/media/logo.98482105.png';
326
-
327
- // Act
328
- const result = await instance.createImage({
329
- model: 'gpt-image-1',
330
- params: {
331
- ...mockParams,
332
- prompt: 'A cat in a hat',
333
- imageUrls: [imageUrl],
334
- },
335
- });
336
-
337
- // Assert
338
- expect(editSpy).toHaveBeenCalled();
339
- const callArg = editSpy.mock.calls[0][0];
340
- expect(callArg.model).toBe('gpt-image-1');
341
- expect(callArg.prompt).toBe('A cat in a hat');
342
- expect(result.imageUrl).toBe('data:image/png;base64,edited-base64-string');
343
-
344
- // Restore the spy for other tests
345
- convertImageUrlToFileSpy.mockResolvedValue({
346
- name: 'image.png',
347
- type: 'image/png',
348
- size: 1024,
349
- } as any);
350
- });
351
-
352
- it('should handle `size` set to `auto`', async () => {
353
- // Arrange
354
- const mockResponse = { data: [{ b64_json: 'test-base64-string' }] };
355
- const generateSpy = vi
356
- .spyOn(instance['client'].images, 'generate')
357
- .mockResolvedValue(mockResponse as any);
358
-
359
- // Act
360
- await instance.createImage({
361
- model: 'gpt-image-1',
362
- params: {
363
- ...mockParams,
364
- prompt: 'A cute cat',
365
- size: 'auto',
366
- },
367
- });
368
-
369
- // Assert
370
- expect(generateSpy).toHaveBeenCalledWith(
371
- expect.objectContaining({
372
- model: 'gpt-image-1',
373
- prompt: 'A cute cat',
374
- n: 1,
375
- }),
376
- );
377
- // Should not include size when it's 'auto'
378
- expect(generateSpy.mock.calls[0][0]).not.toHaveProperty('size');
379
- });
380
-
381
- it('should throw an error if convertImageUrlToFile fails', async () => {
382
- // Arrange
383
- const imageUrl = 'https://example.com/test-image.png';
384
-
385
- // Mock fetch to fail for the image URL, which will cause convertImageUrlToFile to fail
386
- vi.mocked(global.fetch).mockRejectedValueOnce(new Error('Network error'));
387
-
388
- // Mock the OpenAI API methods to ensure they don't get called
389
- const generateSpy = vi.spyOn(instance['client'].images, 'generate');
390
- const editSpy = vi.spyOn(instance['client'].images, 'edit');
391
-
392
- // Act & Assert - Note: imageUrls must be non-empty array to trigger isImageEdit = true
393
- await expect(
394
- instance.createImage({
395
- model: 'gpt-image-1',
396
- params: {
397
- prompt: 'A cat in a hat',
398
- imageUrls: [imageUrl], // This is the key - non-empty array
399
- },
400
- }),
401
- ).rejects.toThrow('Failed to convert image URLs to File objects: Error: Network error');
402
-
403
- // Verify that OpenAI API methods were not called since conversion failed
404
- expect(generateSpy).not.toHaveBeenCalled();
405
- expect(editSpy).not.toHaveBeenCalled();
406
- });
407
-
408
- it('should throw an error when image response is missing data array', async () => {
409
- // Arrange
410
- const mockInvalidResponse = {}; // missing data property
411
- vi.spyOn(instance['client'].images, 'generate').mockResolvedValue(mockInvalidResponse as any);
412
-
413
- // Act & Assert
414
- await expect(
415
- instance.createImage({
416
- model: 'gpt-image-1',
417
- params: { ...mockParams, prompt: 'A cute cat' },
418
- }),
419
- ).rejects.toThrow('Invalid image response: missing or empty data array');
420
- });
421
-
422
- it('should throw an error when image response data array is empty', async () => {
423
- // Arrange
424
- const mockInvalidResponse = { data: [] }; // empty data array
425
- vi.spyOn(instance['client'].images, 'generate').mockResolvedValue(mockInvalidResponse as any);
426
-
427
- // Act & Assert
428
- await expect(
429
- instance.createImage({
430
- model: 'gpt-image-1',
431
- params: { ...mockParams, prompt: 'A cute cat' },
432
- }),
433
- ).rejects.toThrow('Invalid image response: missing or empty data array');
434
- });
435
-
436
- it('should throw an error when first data item is null', async () => {
437
- // Arrange
438
- const mockInvalidResponse = { data: [null] }; // first item is null
439
- vi.spyOn(instance['client'].images, 'generate').mockResolvedValue(mockInvalidResponse as any);
440
-
441
- // Act & Assert
442
- await expect(
443
- instance.createImage({
444
- model: 'gpt-image-1',
445
- params: { ...mockParams, prompt: 'A cute cat' },
446
- }),
447
- ).rejects.toThrow('Invalid image response: first data item is null or undefined');
448
- });
449
-
450
- it('should throw an error when first data item is undefined', async () => {
451
- // Arrange
452
- const mockInvalidResponse = { data: [undefined] }; // first item is undefined
453
- vi.spyOn(instance['client'].images, 'generate').mockResolvedValue(mockInvalidResponse as any);
454
-
455
- // Act & Assert
456
- await expect(
457
- instance.createImage({
458
- model: 'gpt-image-1',
459
- params: { ...mockParams, prompt: 'A cute cat' },
460
- }),
461
- ).rejects.toThrow('Invalid image response: first data item is null or undefined');
462
- });
463
-
464
- it('should re-throw OpenAI API errors during image generation', async () => {
465
- // Arrange
466
- const apiError = new OpenAI.APIError(
467
- 400,
468
- { error: { message: 'Bad Request' } },
469
- 'Error message',
470
- {},
471
- );
472
- vi.spyOn(instance['client'].images, 'generate').mockRejectedValue(apiError);
473
-
474
- // Act & Assert
475
- await expect(
476
- instance.createImage({
477
- model: 'gpt-image-1',
478
- params: { ...mockParams, prompt: 'A cute cat' },
479
- }),
480
- ).rejects.toThrow(apiError);
481
- });
482
-
483
- it('should throw an error for invalid image response', async () => {
484
- // Arrange
485
- const mockInvalidResponse = { data: [{ url: 'some_url' }] }; // missing b64_json
486
- vi.spyOn(instance['client'].images, 'generate').mockResolvedValue(mockInvalidResponse as any);
487
-
488
- // Act & Assert
489
- await expect(
490
- instance.createImage({
491
- model: 'gpt-image-1',
492
- params: { ...mockParams, prompt: 'A cute cat' },
493
- }),
494
- ).rejects.toThrow('Invalid image response: missing b64_json field');
495
- });
496
- });
497
-
498
- describe('convertImageUrlToFile', () => {
499
- beforeEach(() => {
500
- // Reset the spy to use the real implementation for these tests
501
- convertImageUrlToFileSpy.mockRestore();
502
- });
503
-
504
- afterEach(() => {
505
- // Restore the spy for other tests
506
- convertImageUrlToFileSpy.mockResolvedValue({
507
- name: 'image.png',
508
- type: 'image/png',
509
- size: 1024,
510
- } as any);
511
- });
512
-
513
- it('should convert the real lobehub logo URL to a FileLike object', async () => {
514
- const imageUrl = 'https://lobehub.com/_next/static/media/logo.98482105.png';
515
- const file = await openai.convertImageUrlToFile(imageUrl);
516
-
517
- expect(file).toBeDefined();
518
- expect((file as any).name).toBe('image.png');
519
- expect((file as any).type).toMatch(/^image\//);
520
- expect((file as any).size).toBeGreaterThan(0);
521
- });
522
-
523
- it('should convert a base64 data URL to a FileLike object', async () => {
524
- const dataUrl =
525
- 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAFAABAAAAAAAAAAAAAAAAAAAAA//EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AN//Z';
526
- const file = await openai.convertImageUrlToFile(dataUrl);
527
-
528
- expect(file).toBeDefined();
529
- expect((file as any).name).toBe('image.jpeg');
530
- expect((file as any).type).toBe('image/jpeg');
531
- });
532
-
533
- it('should handle different image mime types from data URL', async () => {
534
- const webpDataUrl = 'data:image/webp;base64,UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA==';
535
- const file = await openai.convertImageUrlToFile(webpDataUrl);
536
-
537
- expect(file).toBeDefined();
538
- expect((file as any).name).toBe('image.webp');
539
- expect((file as any).type).toBe('image/webp');
540
- });
541
- });
542
-
543
- // Separate describe block for mocked fetch scenarios
544
- describe('convertImageUrlToFile - mocked scenarios', () => {
545
- beforeEach(() => {
546
- // Reset the spy to use the real implementation
547
- convertImageUrlToFileSpy.mockRestore();
548
- });
549
-
550
- afterEach(() => {
551
- // Restore the spy for other tests
552
- convertImageUrlToFileSpy.mockResolvedValue({
553
- name: 'image.png',
554
- type: 'image/png',
555
- size: 1024,
556
- } as any);
557
- });
558
-
559
- it('should throw an error if fetching an image from a URL fails', async () => {
560
- // Use vi.mocked for type-safe mocking
561
- vi.mocked(global.fetch).mockResolvedValueOnce({
562
- ok: false,
563
- statusText: 'Not Found',
564
- } as any);
565
-
566
- const imageUrl = 'https://example.com/invalid-image.png';
567
-
568
- await expect(openai.convertImageUrlToFile(imageUrl)).rejects.toThrow(
569
- 'Failed to fetch image from https://example.com/invalid-image.png: Not Found',
570
- );
571
- });
572
-
573
- it('should use a default mime type of image/png if content-type header is not available', async () => {
574
- // Use vi.mocked for type-safe mocking
575
- vi.mocked(global.fetch).mockResolvedValueOnce({
576
- ok: true,
577
- arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
578
- headers: {
579
- get: () => null,
580
- },
581
- } as any);
582
-
583
- const imageUrl = 'https://example.com/image-no-content-type';
584
- const file = await openai.convertImageUrlToFile(imageUrl);
585
-
586
- expect(file).toBeDefined();
587
- expect((file as any).type).toBe('image/png');
588
- });
589
- });
590
-
591
254
  describe('responses.handlePayload', () => {
592
255
  it('should add web_search_preview tool when enabledSearch is true', async () => {
593
256
  const payload = {