@lobehub/chat 1.116.3 → 1.117.0

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 (124) hide show
  1. package/.github/PULL_REQUEST_TEMPLATE.md +1 -0
  2. package/.github/workflows/release.yml +2 -0
  3. package/.i18nrc.js +1 -1
  4. package/CHANGELOG.md +117 -0
  5. package/changelog/v1.json +21 -0
  6. package/locales/ar/components.json +12 -0
  7. package/locales/ar/models.json +3 -0
  8. package/locales/bg-BG/components.json +12 -0
  9. package/locales/bg-BG/models.json +3 -0
  10. package/locales/de-DE/components.json +12 -0
  11. package/locales/de-DE/models.json +3 -0
  12. package/locales/en-US/components.json +12 -0
  13. package/locales/en-US/models.json +3 -0
  14. package/locales/es-ES/components.json +12 -0
  15. package/locales/es-ES/models.json +3 -0
  16. package/locales/fa-IR/components.json +12 -0
  17. package/locales/fa-IR/models.json +3 -0
  18. package/locales/fr-FR/components.json +12 -0
  19. package/locales/fr-FR/models.json +3 -0
  20. package/locales/it-IT/components.json +12 -0
  21. package/locales/it-IT/models.json +3 -0
  22. package/locales/ja-JP/components.json +12 -0
  23. package/locales/ja-JP/models.json +3 -0
  24. package/locales/ko-KR/components.json +12 -0
  25. package/locales/ko-KR/models.json +3 -0
  26. package/locales/nl-NL/components.json +12 -0
  27. package/locales/nl-NL/models.json +3 -0
  28. package/locales/pl-PL/components.json +12 -0
  29. package/locales/pl-PL/models.json +3 -0
  30. package/locales/pt-BR/components.json +12 -0
  31. package/locales/pt-BR/models.json +3 -0
  32. package/locales/ru-RU/components.json +12 -0
  33. package/locales/ru-RU/models.json +3 -0
  34. package/locales/tr-TR/components.json +12 -0
  35. package/locales/tr-TR/models.json +3 -0
  36. package/locales/vi-VN/components.json +12 -0
  37. package/locales/vi-VN/models.json +3 -0
  38. package/locales/zh-CN/components.json +12 -0
  39. package/locales/zh-CN/models.json +3 -0
  40. package/locales/zh-TW/components.json +12 -0
  41. package/locales/zh-TW/models.json +3 -0
  42. package/package.json +5 -5
  43. package/packages/const/src/image.ts +9 -0
  44. package/packages/const/src/index.ts +2 -1
  45. package/packages/const/src/meta.ts +3 -2
  46. package/packages/const/src/settings/agent.ts +9 -4
  47. package/packages/const/src/settings/systemAgent.ts +0 -3
  48. package/packages/database/vitest.config.mts +1 -0
  49. package/packages/database/vitest.config.server.mts +1 -0
  50. package/packages/file-loaders/package.json +1 -1
  51. package/packages/file-loaders/vitest.config.mts +3 -7
  52. package/packages/model-runtime/src/RouterRuntime/createRuntime.ts +11 -9
  53. package/packages/model-runtime/src/google/createImage.test.ts +657 -0
  54. package/packages/model-runtime/src/google/createImage.ts +152 -0
  55. package/packages/model-runtime/src/google/index.test.ts +0 -328
  56. package/packages/model-runtime/src/google/index.ts +3 -40
  57. package/packages/model-runtime/src/utils/modelParse.ts +2 -1
  58. package/packages/model-runtime/src/utils/openaiCompatibleFactory/createImage.ts +239 -0
  59. package/packages/model-runtime/src/utils/openaiCompatibleFactory/index.test.ts +22 -22
  60. package/packages/model-runtime/src/utils/openaiCompatibleFactory/index.ts +9 -116
  61. package/packages/model-runtime/src/utils/postProcessModelList.ts +55 -0
  62. package/packages/model-runtime/src/utils/streams/google-ai.test.ts +7 -7
  63. package/packages/model-runtime/src/utils/streams/google-ai.ts +15 -2
  64. package/packages/model-runtime/src/utils/streams/openai/openai.test.ts +41 -0
  65. package/packages/model-runtime/src/utils/streams/openai/openai.ts +38 -2
  66. package/packages/model-runtime/src/utils/streams/protocol.test.ts +32 -0
  67. package/packages/model-runtime/src/utils/streams/protocol.ts +7 -3
  68. package/packages/model-runtime/src/utils/usageConverter.test.ts +58 -0
  69. package/packages/model-runtime/src/utils/usageConverter.ts +5 -1
  70. package/packages/model-runtime/vitest.config.mts +3 -0
  71. package/packages/prompts/package.json +0 -1
  72. package/packages/prompts/src/chains/__tests__/abstractChunk.test.ts +52 -0
  73. package/packages/prompts/src/chains/__tests__/answerWithContext.test.ts +100 -0
  74. package/packages/prompts/src/chains/__tests__/rewriteQuery.test.ts +88 -0
  75. package/packages/prompts/src/chains/__tests__/summaryGenerationTitle.test.ts +107 -0
  76. package/packages/prompts/src/chains/abstractChunk.ts +0 -2
  77. package/packages/prompts/src/chains/rewriteQuery.ts +3 -1
  78. package/packages/prompts/src/index.test.ts +41 -0
  79. package/packages/prompts/src/prompts/systemRole/index.test.ts +136 -0
  80. package/packages/prompts/vitest.config.mts +3 -0
  81. package/packages/types/src/index.ts +2 -0
  82. package/packages/utils/package.json +5 -1
  83. package/packages/utils/src/client/index.ts +2 -0
  84. package/packages/utils/src/server/index.ts +5 -0
  85. package/packages/utils/vitest.config.mts +4 -0
  86. package/src/app/(backend)/middleware/auth/index.test.ts +2 -2
  87. package/src/app/(backend)/middleware/auth/index.ts +1 -1
  88. package/src/app/(backend)/oidc/consent/route.ts +1 -2
  89. package/src/app/(backend)/webapi/chat/[provider]/route.test.ts +2 -2
  90. package/src/app/(backend)/webapi/plugin/gateway/route.ts +1 -1
  91. package/src/app/[variants]/(main)/files/[id]/page.tsx +1 -1
  92. package/src/app/[variants]/(main)/settings/sync/page.tsx +1 -1
  93. package/src/app/[variants]/(main)/settings/system-agent/index.tsx +2 -1
  94. package/src/components/HtmlPreview/HtmlPreviewAction.tsx +32 -0
  95. package/src/components/HtmlPreview/PreviewDrawer.tsx +133 -0
  96. package/src/components/HtmlPreview/index.ts +2 -0
  97. package/src/config/aiModels/google.ts +42 -22
  98. package/src/config/aiModels/openrouter.ts +33 -0
  99. package/src/config/aiModels/vertexai.ts +4 -4
  100. package/src/features/Conversation/Extras/Usage/UsageDetail/index.tsx +6 -0
  101. package/src/features/Conversation/Extras/Usage/UsageDetail/tokens.test.ts +38 -0
  102. package/src/features/Conversation/Extras/Usage/UsageDetail/tokens.ts +13 -1
  103. package/src/features/Conversation/components/ChatItem/ShareMessageModal/ShareText/index.tsx +1 -1
  104. package/src/features/Conversation/components/ChatItem/index.tsx +23 -0
  105. package/src/features/ShareModal/ShareJSON/index.tsx +2 -2
  106. package/src/features/ShareModal/ShareText/index.tsx +1 -1
  107. package/src/libs/oidc-provider/adapter.ts +1 -1
  108. package/src/libs/trpc/edge/middleware/jwtPayload.test.ts +1 -1
  109. package/src/libs/trpc/edge/middleware/jwtPayload.ts +1 -2
  110. package/src/libs/trpc/lambda/middleware/keyVaults.ts +1 -2
  111. package/src/locales/default/chat.ts +1 -0
  112. package/src/locales/default/components.ts +12 -0
  113. package/src/middleware.ts +3 -3
  114. package/src/server/routers/tools/search.test.ts +1 -1
  115. package/src/services/config.ts +2 -4
  116. package/src/utils/client/switchLang.ts +1 -1
  117. package/{packages/utils/src → src/utils}/server/pageProps.ts +2 -1
  118. package/tsconfig.json +1 -1
  119. package/vitest.config.mts +1 -0
  120. package/packages/model-runtime/src/UniformRuntime/index.ts +0 -117
  121. /package/{packages/const/src → src/const}/locale.ts +0 -0
  122. /package/{packages/utils/src → src/utils}/locale.test.ts +0 -0
  123. /package/{packages/utils/src → src/utils}/locale.ts +0 -0
  124. /package/{packages/utils/src → src/utils}/server/routeVariants.ts +0 -0
@@ -0,0 +1,657 @@
1
+ // @vitest-environment edge-runtime
2
+ import { GoogleGenAI } from '@google/genai';
3
+ import * as imageToBase64Module from '@lobechat/utils';
4
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
5
+
6
+ import { CreateImagePayload } from '@/libs/model-runtime/types/image';
7
+
8
+ import { createGoogleImage } from './createImage';
9
+
10
+ const provider = 'google';
11
+ const bizErrorType = 'ProviderBizError';
12
+ const invalidErrorType = 'InvalidProviderAPIKey';
13
+
14
+ // Mock the console.error to avoid polluting test output
15
+ vi.spyOn(console, 'error').mockImplementation(() => {});
16
+
17
+ let mockClient: GoogleGenAI;
18
+
19
+ beforeEach(() => {
20
+ mockClient = {
21
+ models: {
22
+ generateImages: vi.fn(),
23
+ generateContent: vi.fn(),
24
+ },
25
+ } as any;
26
+ });
27
+
28
+ describe('createGoogleImage', () => {
29
+ describe('Traditional Imagen Models', () => {
30
+ it('should create image successfully with basic parameters', async () => {
31
+ // Arrange - Use real base64 image data (5x5 red pixel PNG)
32
+ const realBase64ImageData =
33
+ 'iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==';
34
+ const mockImageResponse = {
35
+ generatedImages: [
36
+ {
37
+ image: {
38
+ imageBytes: realBase64ImageData,
39
+ },
40
+ },
41
+ ],
42
+ };
43
+ vi.spyOn(mockClient.models, 'generateImages').mockResolvedValue(mockImageResponse as any);
44
+
45
+ const payload: CreateImagePayload = {
46
+ model: 'imagen-4.0-generate-preview-06-06',
47
+ params: {
48
+ prompt: 'A beautiful landscape with mountains and trees',
49
+ aspectRatio: '1:1',
50
+ },
51
+ };
52
+
53
+ // Act
54
+ const result = await createGoogleImage(mockClient, provider, payload);
55
+
56
+ // Assert
57
+ expect(mockClient.models.generateImages).toHaveBeenCalledWith({
58
+ model: 'imagen-4.0-generate-preview-06-06',
59
+ prompt: 'A beautiful landscape with mountains and trees',
60
+ config: {
61
+ aspectRatio: '1:1',
62
+ numberOfImages: 1,
63
+ },
64
+ });
65
+ expect(result).toEqual({
66
+ imageUrl: `data:image/png;base64,${realBase64ImageData}`,
67
+ });
68
+ });
69
+
70
+ it('should support different aspect ratios like 16:9 for widescreen images', async () => {
71
+ // Arrange - Use real base64 data
72
+ const realBase64Data =
73
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
74
+ const mockImageResponse = {
75
+ generatedImages: [
76
+ {
77
+ image: {
78
+ imageBytes: realBase64Data,
79
+ },
80
+ },
81
+ ],
82
+ };
83
+ vi.spyOn(mockClient.models, 'generateImages').mockResolvedValue(mockImageResponse as any);
84
+
85
+ const payload: CreateImagePayload = {
86
+ model: 'imagen-4.0-ultra-generate-preview-06-06',
87
+ params: {
88
+ prompt: 'Cinematic landscape shot with dramatic lighting',
89
+ aspectRatio: '16:9',
90
+ },
91
+ };
92
+
93
+ // Act
94
+ await createGoogleImage(mockClient, provider, payload);
95
+
96
+ // Assert
97
+ expect(mockClient.models.generateImages).toHaveBeenCalledWith({
98
+ model: 'imagen-4.0-ultra-generate-preview-06-06',
99
+ prompt: 'Cinematic landscape shot with dramatic lighting',
100
+ config: {
101
+ aspectRatio: '16:9',
102
+ numberOfImages: 1,
103
+ },
104
+ });
105
+ });
106
+
107
+ it('should work with only prompt when aspect ratio is not specified', async () => {
108
+ // Arrange
109
+ const realBase64Data =
110
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
111
+ const mockImageResponse = {
112
+ generatedImages: [
113
+ {
114
+ image: {
115
+ imageBytes: realBase64Data,
116
+ },
117
+ },
118
+ ],
119
+ };
120
+ vi.spyOn(mockClient.models, 'generateImages').mockResolvedValue(mockImageResponse as any);
121
+
122
+ const payload: CreateImagePayload = {
123
+ model: 'imagen-4.0-generate-preview-06-06',
124
+ params: {
125
+ prompt: 'A cute cat sitting in a garden',
126
+ },
127
+ };
128
+
129
+ // Act
130
+ await createGoogleImage(mockClient, provider, payload);
131
+
132
+ // Assert
133
+ expect(mockClient.models.generateImages).toHaveBeenCalledWith({
134
+ model: 'imagen-4.0-generate-preview-06-06',
135
+ prompt: 'A cute cat sitting in a garden',
136
+ config: {
137
+ aspectRatio: undefined,
138
+ numberOfImages: 1,
139
+ },
140
+ });
141
+ });
142
+
143
+ describe('Error handling', () => {
144
+ it('should throw InvalidProviderAPIKey error when API key is invalid', async () => {
145
+ // Arrange - Use real Google AI error format
146
+ const message = `[GoogleGenerativeAI Error]: Error fetching from https://generativelanguage.googleapis.com/v1/models/imagen-4.0:generateImages: [400 Bad Request] API key not valid. Please pass a valid API key. [{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"API_KEY_INVALID","domain":"googleapis.com","metadata":{"service":"generativelanguage.googleapis.com"}}]`;
147
+ const apiError = new Error(message);
148
+ vi.spyOn(mockClient.models, 'generateImages').mockRejectedValue(apiError);
149
+
150
+ const payload: CreateImagePayload = {
151
+ model: 'imagen-4.0-generate-preview-06-06',
152
+ params: {
153
+ prompt: 'A realistic landscape photo',
154
+ },
155
+ };
156
+
157
+ // Act & Assert - Test error type rather than specific text
158
+ await expect(createGoogleImage(mockClient, provider, payload)).rejects.toEqual(
159
+ expect.objectContaining({
160
+ errorType: invalidErrorType,
161
+ provider,
162
+ }),
163
+ );
164
+ });
165
+
166
+ it('should throw ProviderBizError for network and API errors', async () => {
167
+ // Arrange
168
+ const apiError = new Error('Network connection failed');
169
+ vi.spyOn(mockClient.models, 'generateImages').mockRejectedValue(apiError);
170
+
171
+ const payload: CreateImagePayload = {
172
+ model: 'imagen-4.0-generate-preview-06-06',
173
+ params: {
174
+ prompt: 'A digital art portrait',
175
+ },
176
+ };
177
+
178
+ // Act & Assert - Test error type and basic structure
179
+ await expect(createGoogleImage(mockClient, provider, payload)).rejects.toEqual(
180
+ expect.objectContaining({
181
+ errorType: bizErrorType,
182
+ provider,
183
+ error: expect.objectContaining({
184
+ message: expect.any(String),
185
+ }),
186
+ }),
187
+ );
188
+ });
189
+
190
+ it('should throw error when API response is malformed - missing generatedImages', async () => {
191
+ // Arrange
192
+ const mockImageResponse = {};
193
+ vi.spyOn(mockClient.models, 'generateImages').mockResolvedValue(mockImageResponse as any);
194
+
195
+ const payload: CreateImagePayload = {
196
+ model: 'imagen-4.0-generate-preview-06-06',
197
+ params: {
198
+ prompt: 'Abstract geometric patterns',
199
+ },
200
+ };
201
+
202
+ // Act & Assert - Test error behavior rather than specific text
203
+ await expect(createGoogleImage(mockClient, provider, payload)).rejects.toEqual(
204
+ expect.objectContaining({
205
+ errorType: bizErrorType,
206
+ provider,
207
+ }),
208
+ );
209
+ });
210
+
211
+ it('should throw error when API response contains empty image array', async () => {
212
+ // Arrange
213
+ const mockImageResponse = {
214
+ generatedImages: [],
215
+ };
216
+ vi.spyOn(mockClient.models, 'generateImages').mockResolvedValue(mockImageResponse as any);
217
+
218
+ const payload: CreateImagePayload = {
219
+ model: 'imagen-4.0-generate-preview-06-06',
220
+ params: {
221
+ prompt: 'Minimalist design poster',
222
+ },
223
+ };
224
+
225
+ // Act & Assert
226
+ await expect(createGoogleImage(mockClient, provider, payload)).rejects.toEqual(
227
+ expect.objectContaining({
228
+ errorType: bizErrorType,
229
+ provider,
230
+ }),
231
+ );
232
+ });
233
+
234
+ it('should throw error when generated image lacks required data', async () => {
235
+ // Arrange
236
+ const mockImageResponse = {
237
+ generatedImages: [
238
+ {
239
+ image: {}, // Missing imageBytes
240
+ },
241
+ ],
242
+ };
243
+ vi.spyOn(mockClient.models, 'generateImages').mockResolvedValue(mockImageResponse as any);
244
+
245
+ const payload: CreateImagePayload = {
246
+ model: 'imagen-4.0-generate-preview-06-06',
247
+ params: {
248
+ prompt: 'Watercolor painting style',
249
+ },
250
+ };
251
+
252
+ // Act & Assert
253
+ await expect(createGoogleImage(mockClient, provider, payload)).rejects.toEqual(
254
+ expect.objectContaining({
255
+ errorType: bizErrorType,
256
+ provider,
257
+ }),
258
+ );
259
+ });
260
+ });
261
+
262
+ describe('Edge cases', () => {
263
+ it('should return first image when API returns multiple generated images', async () => {
264
+ // Arrange - Use two different real base64 image data
265
+ const firstImageData =
266
+ 'iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==';
267
+ const secondImageData =
268
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
269
+ const mockImageResponse = {
270
+ generatedImages: [
271
+ {
272
+ image: {
273
+ imageBytes: firstImageData,
274
+ },
275
+ },
276
+ {
277
+ image: {
278
+ imageBytes: secondImageData,
279
+ },
280
+ },
281
+ ],
282
+ };
283
+ vi.spyOn(mockClient.models, 'generateImages').mockResolvedValue(mockImageResponse as any);
284
+
285
+ const payload: CreateImagePayload = {
286
+ model: 'imagen-4.0-generate-preview-06-06',
287
+ params: {
288
+ prompt: 'Generate multiple variations of a sunset',
289
+ },
290
+ };
291
+
292
+ // Act
293
+ const result = await createGoogleImage(mockClient, provider, payload);
294
+
295
+ // Assert - Should return the first image
296
+ expect(result).toEqual({
297
+ imageUrl: `data:image/png;base64,${firstImageData}`,
298
+ });
299
+ });
300
+
301
+ it('should work with custom future Imagen model versions', async () => {
302
+ // Arrange
303
+ const realBase64Data =
304
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
305
+ const mockImageResponse = {
306
+ generatedImages: [
307
+ {
308
+ image: {
309
+ imageBytes: realBase64Data,
310
+ },
311
+ },
312
+ ],
313
+ };
314
+ vi.spyOn(mockClient.models, 'generateImages').mockResolvedValue(mockImageResponse as any);
315
+
316
+ const payload: CreateImagePayload = {
317
+ model: 'imagen-5.0-future-model',
318
+ params: {
319
+ prompt: 'Photorealistic portrait with soft lighting',
320
+ aspectRatio: '4:3',
321
+ },
322
+ };
323
+
324
+ // Act
325
+ await createGoogleImage(mockClient, provider, payload);
326
+
327
+ // Assert
328
+ expect(mockClient.models.generateImages).toHaveBeenCalledWith({
329
+ model: 'imagen-5.0-future-model',
330
+ prompt: 'Photorealistic portrait with soft lighting',
331
+ config: {
332
+ aspectRatio: '4:3',
333
+ numberOfImages: 1,
334
+ },
335
+ });
336
+ });
337
+ });
338
+ });
339
+
340
+ describe('Gemini 2.5 Flash Image Models (:image)', () => {
341
+ it('should create image successfully using generateContent for :image model', async () => {
342
+ // Arrange
343
+ const realBase64ImageData =
344
+ 'iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==';
345
+ const mockContentResponse = {
346
+ candidates: [
347
+ {
348
+ content: {
349
+ parts: [
350
+ {
351
+ inlineData: {
352
+ data: realBase64ImageData,
353
+ mimeType: 'image/png',
354
+ },
355
+ },
356
+ ],
357
+ },
358
+ },
359
+ ],
360
+ };
361
+ vi.spyOn(mockClient.models, 'generateContent').mockResolvedValue(mockContentResponse as any);
362
+
363
+ const payload: CreateImagePayload = {
364
+ model: 'gemini-2.5-flash-image-preview:image',
365
+ params: {
366
+ prompt: 'Create a beautiful sunset landscape',
367
+ },
368
+ };
369
+
370
+ // Act
371
+ const result = await createGoogleImage(mockClient, provider, payload);
372
+
373
+ // Assert
374
+ expect(mockClient.models.generateContent).toHaveBeenCalledWith({
375
+ contents: [
376
+ {
377
+ role: 'user',
378
+ parts: [{ text: 'Create a beautiful sunset landscape' }],
379
+ },
380
+ ],
381
+ model: 'gemini-2.5-flash-image-preview',
382
+ config: {
383
+ responseModalities: ['Image'],
384
+ },
385
+ });
386
+ expect(result).toEqual({
387
+ imageUrl: `data:image/png;base64,${realBase64ImageData}`,
388
+ });
389
+ });
390
+
391
+ it('should support image editing with base64 imageUrl', async () => {
392
+ // Arrange
393
+ const inputImageBase64 =
394
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
395
+ const outputImageBase64 =
396
+ 'iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==';
397
+
398
+ const mockContentResponse = {
399
+ candidates: [
400
+ {
401
+ content: {
402
+ parts: [
403
+ {
404
+ inlineData: {
405
+ data: outputImageBase64,
406
+ mimeType: 'image/png',
407
+ },
408
+ },
409
+ ],
410
+ },
411
+ },
412
+ ],
413
+ };
414
+ vi.spyOn(mockClient.models, 'generateContent').mockResolvedValue(mockContentResponse as any);
415
+
416
+ const payload: CreateImagePayload = {
417
+ model: 'gemini-2.5-flash-image-preview:image',
418
+ params: {
419
+ prompt: 'Add a red rose to this image',
420
+ imageUrl: `data:image/png;base64,${inputImageBase64}`,
421
+ },
422
+ };
423
+
424
+ // Act
425
+ const result = await createGoogleImage(mockClient, provider, payload);
426
+
427
+ // Assert
428
+ expect(mockClient.models.generateContent).toHaveBeenCalledWith({
429
+ contents: [
430
+ {
431
+ role: 'user',
432
+ parts: [
433
+ { text: 'Add a red rose to this image' },
434
+ {
435
+ inlineData: {
436
+ data: inputImageBase64,
437
+ mimeType: 'image/png',
438
+ },
439
+ },
440
+ ],
441
+ },
442
+ ],
443
+ model: 'gemini-2.5-flash-image-preview',
444
+ config: {
445
+ responseModalities: ['Image'],
446
+ },
447
+ });
448
+ expect(result).toEqual({
449
+ imageUrl: `data:image/png;base64,${outputImageBase64}`,
450
+ });
451
+ });
452
+
453
+ it('should support image editing with URL imageUrl', async () => {
454
+ // Arrange
455
+ const inputImageBase64 =
456
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
457
+ const outputImageBase64 =
458
+ 'iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==';
459
+
460
+ // Mock imageUrlToBase64 utility
461
+ vi.spyOn(imageToBase64Module, 'imageUrlToBase64').mockResolvedValue({
462
+ base64: inputImageBase64,
463
+ mimeType: 'image/jpeg',
464
+ });
465
+
466
+ const mockContentResponse = {
467
+ candidates: [
468
+ {
469
+ content: {
470
+ parts: [
471
+ {
472
+ inlineData: {
473
+ data: outputImageBase64,
474
+ mimeType: 'image/png',
475
+ },
476
+ },
477
+ ],
478
+ },
479
+ },
480
+ ],
481
+ };
482
+ vi.spyOn(mockClient.models, 'generateContent').mockResolvedValue(mockContentResponse as any);
483
+
484
+ const payload: CreateImagePayload = {
485
+ model: 'gemini-2.5-flash-image-preview:image',
486
+ params: {
487
+ prompt: 'Change the background to blue sky',
488
+ imageUrl: 'https://example.com/image.jpg',
489
+ },
490
+ };
491
+
492
+ // Act
493
+ const result = await createGoogleImage(mockClient, provider, payload);
494
+
495
+ // Assert
496
+ expect(imageToBase64Module.imageUrlToBase64).toHaveBeenCalledWith(
497
+ 'https://example.com/image.jpg',
498
+ );
499
+ expect(mockClient.models.generateContent).toHaveBeenCalledWith({
500
+ contents: [
501
+ {
502
+ role: 'user',
503
+ parts: [
504
+ { text: 'Change the background to blue sky' },
505
+ {
506
+ inlineData: {
507
+ data: inputImageBase64,
508
+ mimeType: 'image/jpeg',
509
+ },
510
+ },
511
+ ],
512
+ },
513
+ ],
514
+ model: 'gemini-2.5-flash-image-preview',
515
+ config: {
516
+ responseModalities: ['Image'],
517
+ },
518
+ });
519
+ expect(result).toEqual({
520
+ imageUrl: `data:image/png;base64,${outputImageBase64}`,
521
+ });
522
+ });
523
+
524
+ it('should handle null imageUrl as text-only generation', async () => {
525
+ // Arrange
526
+ const outputImageBase64 =
527
+ 'iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==';
528
+
529
+ const mockContentResponse = {
530
+ candidates: [
531
+ {
532
+ content: {
533
+ parts: [
534
+ {
535
+ inlineData: {
536
+ data: outputImageBase64,
537
+ mimeType: 'image/png',
538
+ },
539
+ },
540
+ ],
541
+ },
542
+ },
543
+ ],
544
+ };
545
+ vi.spyOn(mockClient.models, 'generateContent').mockResolvedValue(mockContentResponse as any);
546
+
547
+ const payload: CreateImagePayload = {
548
+ model: 'gemini-2.5-flash-image-preview:image',
549
+ params: {
550
+ prompt: 'Generate a colorful abstract pattern',
551
+ imageUrl: null,
552
+ },
553
+ };
554
+
555
+ // Act
556
+ const result = await createGoogleImage(mockClient, provider, payload);
557
+
558
+ // Assert
559
+ expect(mockClient.models.generateContent).toHaveBeenCalledWith({
560
+ contents: [
561
+ {
562
+ role: 'user',
563
+ parts: [{ text: 'Generate a colorful abstract pattern' }],
564
+ },
565
+ ],
566
+ model: 'gemini-2.5-flash-image-preview',
567
+ config: {
568
+ responseModalities: ['Image'],
569
+ },
570
+ });
571
+ expect(result).toEqual({
572
+ imageUrl: `data:image/png;base64,${outputImageBase64}`,
573
+ });
574
+ });
575
+
576
+ describe('Error handling for :image models', () => {
577
+ it('should throw error when no image generated in response', async () => {
578
+ // Arrange
579
+ const mockContentResponse = {
580
+ candidates: [
581
+ {
582
+ content: {
583
+ parts: [
584
+ {
585
+ text: 'I cannot generate an image.',
586
+ },
587
+ ],
588
+ },
589
+ },
590
+ ],
591
+ };
592
+ vi.spyOn(mockClient.models, 'generateContent').mockResolvedValue(
593
+ mockContentResponse as any,
594
+ );
595
+
596
+ const payload: CreateImagePayload = {
597
+ model: 'gemini-2.5-flash-image-preview:image',
598
+ params: {
599
+ prompt: 'Create inappropriate content',
600
+ },
601
+ };
602
+
603
+ // Act & Assert
604
+ await expect(createGoogleImage(mockClient, provider, payload)).rejects.toEqual(
605
+ expect.objectContaining({
606
+ errorType: bizErrorType,
607
+ provider,
608
+ }),
609
+ );
610
+ });
611
+
612
+ it('should throw error when response is malformed', async () => {
613
+ // Arrange
614
+ const mockContentResponse = {
615
+ candidates: [],
616
+ };
617
+ vi.spyOn(mockClient.models, 'generateContent').mockResolvedValue(
618
+ mockContentResponse as any,
619
+ );
620
+
621
+ const payload: CreateImagePayload = {
622
+ model: 'gemini-2.5-flash-image-preview:image',
623
+ params: {
624
+ prompt: 'Generate an image',
625
+ },
626
+ };
627
+
628
+ // Act & Assert
629
+ await expect(createGoogleImage(mockClient, provider, payload)).rejects.toEqual(
630
+ expect.objectContaining({
631
+ errorType: bizErrorType,
632
+ provider,
633
+ }),
634
+ );
635
+ });
636
+
637
+ it('should throw error for unsupported image URL format', async () => {
638
+ // Arrange
639
+ const payload: CreateImagePayload = {
640
+ model: 'gemini-2.5-flash-image-preview:image',
641
+ params: {
642
+ prompt: 'Edit this image',
643
+ imageUrl: 'ftp://example.com/image.jpg',
644
+ },
645
+ };
646
+
647
+ // Act & Assert
648
+ await expect(createGoogleImage(mockClient, provider, payload)).rejects.toEqual(
649
+ expect.objectContaining({
650
+ errorType: bizErrorType,
651
+ provider,
652
+ }),
653
+ );
654
+ });
655
+ });
656
+ });
657
+ });