@lobehub/chat 1.136.13 → 1.137.1

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 (190) hide show
  1. package/.cursor/rules/add-setting-env.mdc +175 -0
  2. package/.cursor/rules/db-migrations.mdc +25 -0
  3. package/.env.example +7 -0
  4. package/CHANGELOG.md +50 -0
  5. package/Dockerfile +3 -2
  6. package/Dockerfile.database +15 -3
  7. package/Dockerfile.pglite +3 -2
  8. package/changelog/v1.json +18 -0
  9. package/docs/development/database-schema.dbml +1 -0
  10. package/docs/self-hosting/advanced/feature-flags.mdx +25 -15
  11. package/docs/self-hosting/advanced/feature-flags.zh-CN.mdx +25 -15
  12. package/docs/self-hosting/environment-variables/basic.mdx +12 -0
  13. package/docs/self-hosting/environment-variables/basic.zh-CN.mdx +12 -0
  14. package/locales/ar/setting.json +8 -0
  15. package/locales/bg-BG/setting.json +8 -0
  16. package/locales/de-DE/setting.json +8 -0
  17. package/locales/en-US/setting.json +8 -0
  18. package/locales/es-ES/setting.json +8 -0
  19. package/locales/fa-IR/setting.json +8 -0
  20. package/locales/fr-FR/setting.json +8 -0
  21. package/locales/it-IT/setting.json +8 -0
  22. package/locales/ja-JP/setting.json +8 -0
  23. package/locales/ko-KR/setting.json +8 -0
  24. package/locales/nl-NL/setting.json +8 -0
  25. package/locales/pl-PL/setting.json +8 -0
  26. package/locales/pt-BR/setting.json +8 -0
  27. package/locales/ru-RU/setting.json +8 -0
  28. package/locales/tr-TR/setting.json +8 -0
  29. package/locales/vi-VN/setting.json +8 -0
  30. package/locales/zh-CN/setting.json +8 -0
  31. package/locales/zh-TW/setting.json +8 -0
  32. package/package.json +1 -1
  33. package/packages/agent-runtime/examples/tools-calling.ts +4 -3
  34. package/packages/agent-runtime/src/core/__tests__/runtime.test.ts +559 -29
  35. package/packages/agent-runtime/src/core/runtime.ts +171 -43
  36. package/packages/agent-runtime/src/types/instruction.ts +32 -6
  37. package/packages/agent-runtime/src/types/runtime.ts +2 -2
  38. package/packages/agent-runtime/src/types/state.ts +1 -8
  39. package/packages/agent-runtime/vitest.config.mts +14 -0
  40. package/packages/const/src/settings/image.ts +8 -0
  41. package/packages/const/src/settings/index.ts +3 -0
  42. package/packages/context-engine/src/__tests__/pipeline.test.ts +485 -0
  43. package/packages/context-engine/src/base/__tests__/BaseProcessor.test.ts +381 -0
  44. package/packages/context-engine/src/base/__tests__/BaseProvider.test.ts +392 -0
  45. package/packages/context-engine/src/processors/__tests__/MessageCleanup.test.ts +346 -0
  46. package/packages/context-engine/src/processors/__tests__/ToolCall.test.ts +552 -0
  47. package/packages/database/migrations/0038_add_image_user_settings.sql +1 -0
  48. package/packages/database/migrations/meta/0038_snapshot.json +7580 -0
  49. package/packages/database/migrations/meta/_journal.json +7 -0
  50. package/packages/database/src/core/migrations.json +6 -0
  51. package/packages/database/src/models/user.ts +3 -1
  52. package/packages/database/src/schemas/user.ts +1 -0
  53. package/packages/file-loaders/src/loaders/docx/index.test.ts +0 -1
  54. package/packages/file-loaders/src/loaders/excel/__snapshots__/index.test.ts.snap +30 -0
  55. package/packages/file-loaders/src/loaders/excel/index.test.ts +8 -0
  56. package/packages/file-loaders/src/loaders/pptx/index.test.ts +25 -0
  57. package/packages/file-loaders/src/utils/parser-utils.test.ts +155 -0
  58. package/packages/file-loaders/vitest.config.mts +8 -0
  59. package/packages/model-runtime/CLAUDE.md +5 -0
  60. package/packages/model-runtime/docs/test-coverage.md +706 -0
  61. package/packages/model-runtime/src/core/ModelRuntime.test.ts +231 -0
  62. package/packages/model-runtime/src/core/RouterRuntime/createRuntime.ts +1 -1
  63. package/packages/model-runtime/src/core/openaiCompatibleFactory/createImage.test.ts +799 -0
  64. package/packages/model-runtime/src/core/openaiCompatibleFactory/index.test.ts +188 -4
  65. package/packages/model-runtime/src/core/openaiCompatibleFactory/index.ts +41 -10
  66. package/packages/model-runtime/src/core/streams/openai/__snapshots__/responsesStream.test.ts.snap +439 -0
  67. package/packages/model-runtime/src/core/streams/openai/openai.test.ts +789 -0
  68. package/packages/model-runtime/src/core/streams/openai/responsesStream.test.ts +551 -0
  69. package/packages/model-runtime/src/core/usageConverters/utils/computeChatCost.test.ts +230 -0
  70. package/packages/model-runtime/src/core/usageConverters/utils/computeImageCost.test.ts +334 -37
  71. package/packages/model-runtime/src/providerTestUtils.ts +148 -145
  72. package/packages/model-runtime/src/providers/ai302/index.test.ts +60 -0
  73. package/packages/model-runtime/src/providers/ai302/index.ts +9 -4
  74. package/packages/model-runtime/src/providers/ai360/index.test.ts +1213 -1
  75. package/packages/model-runtime/src/providers/ai360/index.ts +9 -4
  76. package/packages/model-runtime/src/providers/aihubmix/index.test.ts +73 -0
  77. package/packages/model-runtime/src/providers/aihubmix/index.ts +6 -9
  78. package/packages/model-runtime/src/providers/akashchat/index.test.ts +433 -3
  79. package/packages/model-runtime/src/providers/akashchat/index.ts +12 -7
  80. package/packages/model-runtime/src/providers/anthropic/generateObject.test.ts +183 -29
  81. package/packages/model-runtime/src/providers/anthropic/generateObject.ts +40 -24
  82. package/packages/model-runtime/src/providers/azureai/index.test.ts +102 -0
  83. package/packages/model-runtime/src/providers/baichuan/index.test.ts +416 -26
  84. package/packages/model-runtime/src/providers/baichuan/index.ts +23 -20
  85. package/packages/model-runtime/src/providers/bedrock/index.test.ts +420 -2
  86. package/packages/model-runtime/src/providers/cerebras/index.test.ts +465 -0
  87. package/packages/model-runtime/src/providers/cerebras/index.ts +8 -3
  88. package/packages/model-runtime/src/providers/cohere/index.test.ts +1074 -1
  89. package/packages/model-runtime/src/providers/cohere/index.ts +8 -3
  90. package/packages/model-runtime/src/providers/cometapi/index.test.ts +439 -3
  91. package/packages/model-runtime/src/providers/cometapi/index.ts +8 -3
  92. package/packages/model-runtime/src/providers/deepseek/index.test.ts +116 -1
  93. package/packages/model-runtime/src/providers/deepseek/index.ts +8 -3
  94. package/packages/model-runtime/src/providers/fireworksai/index.test.ts +264 -3
  95. package/packages/model-runtime/src/providers/fireworksai/index.ts +8 -3
  96. package/packages/model-runtime/src/providers/giteeai/index.test.ts +325 -3
  97. package/packages/model-runtime/src/providers/giteeai/index.ts +23 -6
  98. package/packages/model-runtime/src/providers/github/index.test.ts +532 -3
  99. package/packages/model-runtime/src/providers/github/index.ts +8 -3
  100. package/packages/model-runtime/src/providers/groq/index.test.ts +344 -31
  101. package/packages/model-runtime/src/providers/groq/index.ts +8 -3
  102. package/packages/model-runtime/src/providers/higress/index.test.ts +142 -0
  103. package/packages/model-runtime/src/providers/higress/index.ts +8 -3
  104. package/packages/model-runtime/src/providers/huggingface/index.test.ts +612 -1
  105. package/packages/model-runtime/src/providers/huggingface/index.ts +9 -4
  106. package/packages/model-runtime/src/providers/hunyuan/index.test.ts +365 -1
  107. package/packages/model-runtime/src/providers/hunyuan/index.ts +9 -3
  108. package/packages/model-runtime/src/providers/infiniai/index.test.ts +71 -0
  109. package/packages/model-runtime/src/providers/internlm/index.test.ts +369 -2
  110. package/packages/model-runtime/src/providers/internlm/index.ts +10 -5
  111. package/packages/model-runtime/src/providers/jina/index.test.ts +164 -3
  112. package/packages/model-runtime/src/providers/jina/index.ts +8 -3
  113. package/packages/model-runtime/src/providers/lmstudio/index.test.ts +182 -3
  114. package/packages/model-runtime/src/providers/lmstudio/index.ts +8 -3
  115. package/packages/model-runtime/src/providers/mistral/index.test.ts +779 -27
  116. package/packages/model-runtime/src/providers/mistral/index.ts +8 -3
  117. package/packages/model-runtime/src/providers/modelscope/index.test.ts +232 -1
  118. package/packages/model-runtime/src/providers/modelscope/index.ts +8 -3
  119. package/packages/model-runtime/src/providers/moonshot/index.test.ts +489 -2
  120. package/packages/model-runtime/src/providers/moonshot/index.ts +8 -3
  121. package/packages/model-runtime/src/providers/nebius/index.test.ts +381 -3
  122. package/packages/model-runtime/src/providers/nebius/index.ts +8 -3
  123. package/packages/model-runtime/src/providers/newapi/index.test.ts +667 -3
  124. package/packages/model-runtime/src/providers/newapi/index.ts +6 -3
  125. package/packages/model-runtime/src/providers/nvidia/index.test.ts +168 -1
  126. package/packages/model-runtime/src/providers/nvidia/index.ts +12 -7
  127. package/packages/model-runtime/src/providers/ollama/index.test.ts +797 -1
  128. package/packages/model-runtime/src/providers/ollama/index.ts +8 -0
  129. package/packages/model-runtime/src/providers/ollamacloud/index.test.ts +411 -0
  130. package/packages/model-runtime/src/providers/ollamacloud/index.ts +8 -3
  131. package/packages/model-runtime/src/providers/openai/index.test.ts +171 -2
  132. package/packages/model-runtime/src/providers/openai/index.ts +8 -3
  133. package/packages/model-runtime/src/providers/openrouter/index.test.ts +1647 -95
  134. package/packages/model-runtime/src/providers/openrouter/index.ts +12 -7
  135. package/packages/model-runtime/src/providers/qiniu/index.test.ts +294 -1
  136. package/packages/model-runtime/src/providers/qiniu/index.ts +8 -3
  137. package/packages/model-runtime/src/providers/search1api/index.test.ts +1131 -11
  138. package/packages/model-runtime/src/providers/search1api/index.ts +10 -4
  139. package/packages/model-runtime/src/providers/sensenova/index.test.ts +1069 -1
  140. package/packages/model-runtime/src/providers/sensenova/index.ts +8 -3
  141. package/packages/model-runtime/src/providers/siliconcloud/index.test.ts +196 -0
  142. package/packages/model-runtime/src/providers/siliconcloud/index.ts +8 -3
  143. package/packages/model-runtime/src/providers/spark/index.test.ts +293 -1
  144. package/packages/model-runtime/src/providers/spark/index.ts +8 -3
  145. package/packages/model-runtime/src/providers/stepfun/index.test.ts +322 -3
  146. package/packages/model-runtime/src/providers/stepfun/index.ts +8 -3
  147. package/packages/model-runtime/src/providers/tencentcloud/index.test.ts +182 -3
  148. package/packages/model-runtime/src/providers/tencentcloud/index.ts +8 -3
  149. package/packages/model-runtime/src/providers/togetherai/index.test.ts +359 -4
  150. package/packages/model-runtime/src/providers/togetherai/index.ts +12 -5
  151. package/packages/model-runtime/src/providers/v0/index.test.ts +341 -0
  152. package/packages/model-runtime/src/providers/v0/index.ts +20 -6
  153. package/packages/model-runtime/src/providers/vercelaigateway/index.test.ts +710 -0
  154. package/packages/model-runtime/src/providers/vercelaigateway/index.ts +19 -13
  155. package/packages/model-runtime/src/providers/vllm/index.test.ts +45 -1
  156. package/packages/model-runtime/src/providers/volcengine/index.test.ts +75 -0
  157. package/packages/model-runtime/src/providers/wenxin/index.test.ts +144 -1
  158. package/packages/model-runtime/src/providers/wenxin/index.ts +8 -3
  159. package/packages/model-runtime/src/providers/xai/index.test.ts +105 -1
  160. package/packages/model-runtime/src/providers/xinference/index.test.ts +70 -1
  161. package/packages/model-runtime/src/providers/zeroone/index.test.ts +327 -3
  162. package/packages/model-runtime/src/providers/zeroone/index.ts +23 -6
  163. package/packages/model-runtime/src/providers/zhipu/index.test.ts +908 -236
  164. package/packages/model-runtime/src/providers/zhipu/index.ts +8 -3
  165. package/packages/model-runtime/src/types/structureOutput.ts +5 -1
  166. package/packages/model-runtime/vitest.config.mts +7 -1
  167. package/packages/types/src/aiChat.ts +20 -2
  168. package/packages/types/src/serverConfig.ts +7 -1
  169. package/packages/types/src/tool/index.ts +1 -0
  170. package/packages/types/src/tool/tool.ts +33 -0
  171. package/packages/types/src/user/settings/image.ts +3 -0
  172. package/packages/types/src/user/settings/index.ts +3 -0
  173. package/src/app/[variants]/(main)/settings/_layout/SettingsContent.tsx +3 -0
  174. package/src/app/[variants]/(main)/settings/hooks/useCategory.tsx +8 -3
  175. package/src/app/[variants]/(main)/settings/image/index.tsx +74 -0
  176. package/src/components/FormInput/FormSliderWithInput.tsx +40 -0
  177. package/src/components/FormInput/index.ts +1 -0
  178. package/src/envs/image.ts +27 -0
  179. package/src/features/Conversation/Messages/Assistant/index.tsx +1 -1
  180. package/src/features/Conversation/Messages/User/index.tsx +2 -2
  181. package/src/hooks/useFetchAiImageConfig.ts +12 -17
  182. package/src/locales/default/setting.ts +8 -0
  183. package/src/server/globalConfig/index.ts +5 -0
  184. package/src/server/routers/lambda/aiChat.ts +2 -0
  185. package/src/store/global/initialState.ts +1 -0
  186. package/src/store/image/slices/generationConfig/action.test.ts +17 -0
  187. package/src/store/image/slices/generationConfig/action.ts +18 -21
  188. package/src/store/image/slices/generationConfig/initialState.ts +3 -2
  189. package/src/store/user/slices/common/action.ts +1 -0
  190. package/src/store/user/slices/settings/selectors/settings.ts +3 -0
@@ -0,0 +1,799 @@
1
+ // @vitest-environment node
2
+ import OpenAI from 'openai';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ import { CreateImagePayload } from '../../types/image';
6
+ import * as imageToBase64Module from '../../utils/imageToBase64';
7
+ import * as uriParserModule from '../../utils/uriParser';
8
+ import { createOpenAICompatibleImage } from './createImage';
9
+
10
+ // Mock the console to avoid polluting test output
11
+ vi.spyOn(console, 'error').mockImplementation(() => {});
12
+
13
+ // Polyfill File for Node environment
14
+ if (typeof File === 'undefined') {
15
+ // @ts-ignore
16
+ global.File = class MockFile {
17
+ constructor(
18
+ public parts: any[],
19
+ public name: string,
20
+ public opts?: any,
21
+ ) {}
22
+ };
23
+ }
24
+
25
+ describe('createOpenAICompatibleImage', () => {
26
+ let mockClient: OpenAI;
27
+
28
+ beforeEach(() => {
29
+ // Create a mock OpenAI client
30
+ mockClient = {
31
+ images: {
32
+ generate: vi.fn(),
33
+ edit: vi.fn(),
34
+ },
35
+ chat: {
36
+ completions: {
37
+ create: vi.fn(),
38
+ },
39
+ },
40
+ } as any;
41
+
42
+ vi.clearAllMocks();
43
+ });
44
+
45
+ describe('chat model mode (model with :image suffix)', () => {
46
+ describe('processImageUrlForChat function', () => {
47
+ it('should process base64 data URI correctly', async () => {
48
+ const mockImageUrl =
49
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
50
+
51
+ vi.spyOn(uriParserModule, 'parseDataUri').mockReturnValue({
52
+ type: 'base64',
53
+ base64:
54
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
55
+ mimeType: 'image/png',
56
+ });
57
+
58
+ const mockChatResponse = {
59
+ choices: [
60
+ {
61
+ message: {
62
+ images: [
63
+ {
64
+ image_url: {
65
+ url: 'data:image/png;base64,generatedImageData',
66
+ },
67
+ },
68
+ ],
69
+ },
70
+ },
71
+ ],
72
+ };
73
+
74
+ vi.mocked(mockClient.chat.completions.create).mockResolvedValue(mockChatResponse as any);
75
+
76
+ const payload: CreateImagePayload = {
77
+ model: 'gemini-2.0-flash-exp:image',
78
+ params: {
79
+ prompt: 'Edit this image',
80
+ imageUrl: mockImageUrl,
81
+ },
82
+ };
83
+
84
+ const result = await createOpenAICompatibleImage(mockClient, payload, 'openrouter');
85
+
86
+ expect(result.imageUrl).toBe('data:image/png;base64,generatedImageData');
87
+ expect(mockClient.chat.completions.create).toHaveBeenCalled();
88
+ });
89
+
90
+ it('should process base64 data URI without mimeType', async () => {
91
+ const mockImageUrl = 'data:;base64,someBase64Data';
92
+
93
+ vi.spyOn(uriParserModule, 'parseDataUri').mockReturnValue({
94
+ type: 'base64',
95
+ base64: 'someBase64Data',
96
+ mimeType: null,
97
+ });
98
+
99
+ const mockChatResponse = {
100
+ choices: [
101
+ {
102
+ message: {
103
+ images: [
104
+ {
105
+ image_url: {
106
+ url: 'data:image/png;base64,result',
107
+ },
108
+ },
109
+ ],
110
+ },
111
+ },
112
+ ],
113
+ };
114
+
115
+ vi.mocked(mockClient.chat.completions.create).mockResolvedValue(mockChatResponse as any);
116
+
117
+ const payload: CreateImagePayload = {
118
+ model: 'test-model:image',
119
+ params: {
120
+ prompt: 'Process this',
121
+ imageUrl: mockImageUrl,
122
+ },
123
+ };
124
+
125
+ const result = await createOpenAICompatibleImage(mockClient, payload, 'test-provider');
126
+
127
+ expect(result.imageUrl).toBe('data:image/png;base64,result');
128
+ });
129
+
130
+ it('should throw error when base64 data is missing in data URI', async () => {
131
+ const mockImageUrl = 'data:image/png;base64,';
132
+
133
+ vi.spyOn(uriParserModule, 'parseDataUri').mockReturnValue({
134
+ type: 'base64',
135
+ base64: null,
136
+ mimeType: 'image/png',
137
+ });
138
+
139
+ const payload: CreateImagePayload = {
140
+ model: 'test-model:image',
141
+ params: {
142
+ prompt: 'Process this',
143
+ imageUrl: mockImageUrl,
144
+ },
145
+ };
146
+
147
+ await expect(
148
+ createOpenAICompatibleImage(mockClient, payload, 'test-provider'),
149
+ ).rejects.toThrow(
150
+ "Failed to process image URL: TypeError: Image URL doesn't contain base64 data",
151
+ );
152
+ });
153
+
154
+ it('should process URL type by converting to base64', async () => {
155
+ const mockHttpImageUrl = 'https://example.com/image.jpg';
156
+
157
+ vi.spyOn(uriParserModule, 'parseDataUri').mockReturnValue({
158
+ type: 'url',
159
+ base64: null,
160
+ mimeType: null,
161
+ });
162
+
163
+ vi.spyOn(imageToBase64Module, 'imageUrlToBase64').mockResolvedValue({
164
+ base64: 'convertedBase64Data',
165
+ mimeType: 'image/jpeg',
166
+ });
167
+
168
+ const mockChatResponse = {
169
+ choices: [
170
+ {
171
+ message: {
172
+ images: [
173
+ {
174
+ image_url: {
175
+ url: 'data:image/png;base64,output',
176
+ },
177
+ },
178
+ ],
179
+ },
180
+ },
181
+ ],
182
+ };
183
+
184
+ vi.mocked(mockClient.chat.completions.create).mockResolvedValue(mockChatResponse as any);
185
+
186
+ const payload: CreateImagePayload = {
187
+ model: 'vision-model:image',
188
+ params: {
189
+ prompt: 'Convert and process',
190
+ imageUrl: mockHttpImageUrl,
191
+ },
192
+ };
193
+
194
+ const result = await createOpenAICompatibleImage(mockClient, payload, 'test-provider');
195
+
196
+ expect(imageToBase64Module.imageUrlToBase64).toHaveBeenCalledWith(mockHttpImageUrl);
197
+ expect(result.imageUrl).toBe('data:image/png;base64,output');
198
+ });
199
+
200
+ it('should throw error for unsupported image URL type', async () => {
201
+ const mockInvalidUrl = 'file:///local/path/image.png';
202
+
203
+ vi.spyOn(uriParserModule, 'parseDataUri').mockReturnValue({
204
+ type: null,
205
+ base64: null,
206
+ mimeType: null,
207
+ });
208
+
209
+ const payload: CreateImagePayload = {
210
+ model: 'test-model:image',
211
+ params: {
212
+ prompt: 'Process this',
213
+ imageUrl: mockInvalidUrl,
214
+ },
215
+ };
216
+
217
+ await expect(
218
+ createOpenAICompatibleImage(mockClient, payload, 'test-provider'),
219
+ ).rejects.toThrow(
220
+ `Failed to process image URL: TypeError: Currently we don't support image url: ${mockInvalidUrl}`,
221
+ );
222
+ });
223
+ });
224
+
225
+ describe('generateByChatModel function', () => {
226
+ it('should generate image without imageUrl parameter', async () => {
227
+ const mockChatResponse = {
228
+ choices: [
229
+ {
230
+ message: {
231
+ images: [
232
+ {
233
+ image_url: {
234
+ url: 'data:image/png;base64,generatedWithoutInputImage',
235
+ },
236
+ },
237
+ ],
238
+ },
239
+ },
240
+ ],
241
+ };
242
+
243
+ vi.mocked(mockClient.chat.completions.create).mockResolvedValue(mockChatResponse as any);
244
+
245
+ const payload: CreateImagePayload = {
246
+ model: 'gemini-2.0-flash:image',
247
+ params: {
248
+ prompt: 'Generate a cat image',
249
+ },
250
+ };
251
+
252
+ const result = await createOpenAICompatibleImage(mockClient, payload, 'openrouter');
253
+
254
+ expect(result.imageUrl).toBe('data:image/png;base64,generatedWithoutInputImage');
255
+ expect(mockClient.chat.completions.create).toHaveBeenCalledWith({
256
+ messages: [
257
+ {
258
+ content: [
259
+ {
260
+ text: 'Generate a cat image',
261
+ type: 'text',
262
+ },
263
+ ],
264
+ role: 'user',
265
+ },
266
+ ],
267
+ model: 'gemini-2.0-flash',
268
+ stream: false,
269
+ });
270
+ });
271
+
272
+ it('should handle null imageUrl parameter', async () => {
273
+ const mockChatResponse = {
274
+ choices: [
275
+ {
276
+ message: {
277
+ images: [
278
+ {
279
+ image_url: {
280
+ url: 'data:image/png;base64,generatedImage',
281
+ },
282
+ },
283
+ ],
284
+ },
285
+ },
286
+ ],
287
+ };
288
+
289
+ vi.mocked(mockClient.chat.completions.create).mockResolvedValue(mockChatResponse as any);
290
+
291
+ const payload: CreateImagePayload = {
292
+ model: 'test-model:image',
293
+ params: {
294
+ prompt: 'Generate image',
295
+ imageUrl: null as any,
296
+ },
297
+ };
298
+
299
+ const result = await createOpenAICompatibleImage(mockClient, payload, 'test-provider');
300
+
301
+ expect(result.imageUrl).toBe('data:image/png;base64,generatedImage');
302
+ // Should not include image in content array
303
+ const callArgs = vi.mocked(mockClient.chat.completions.create).mock.calls[0][0] as any;
304
+ expect(callArgs.messages[0].content).toHaveLength(1);
305
+ expect(callArgs.messages[0].content[0].type).toBe('text');
306
+ });
307
+
308
+ it('should throw error when no message in response', async () => {
309
+ const mockChatResponse = {
310
+ choices: [
311
+ {
312
+ // message is missing
313
+ },
314
+ ],
315
+ };
316
+
317
+ vi.mocked(mockClient.chat.completions.create).mockResolvedValue(mockChatResponse as any);
318
+
319
+ const payload: CreateImagePayload = {
320
+ model: 'test-model:image',
321
+ params: {
322
+ prompt: 'Generate image',
323
+ },
324
+ };
325
+
326
+ await expect(
327
+ createOpenAICompatibleImage(mockClient, payload, 'test-provider'),
328
+ ).rejects.toThrow('No message in chat completion response');
329
+ });
330
+
331
+ it('should throw error when images array is missing', async () => {
332
+ const mockChatResponse = {
333
+ choices: [
334
+ {
335
+ message: {
336
+ content: 'Some text response',
337
+ },
338
+ },
339
+ ],
340
+ };
341
+
342
+ vi.mocked(mockClient.chat.completions.create).mockResolvedValue(mockChatResponse as any);
343
+
344
+ const payload: CreateImagePayload = {
345
+ model: 'test-model:image',
346
+ params: {
347
+ prompt: 'Generate image',
348
+ },
349
+ };
350
+
351
+ await expect(
352
+ createOpenAICompatibleImage(mockClient, payload, 'test-provider'),
353
+ ).rejects.toThrow('No image generated in chat completion response');
354
+ });
355
+
356
+ it('should throw error when images array is empty', async () => {
357
+ const mockChatResponse = {
358
+ choices: [
359
+ {
360
+ message: {
361
+ images: [],
362
+ },
363
+ },
364
+ ],
365
+ };
366
+
367
+ vi.mocked(mockClient.chat.completions.create).mockResolvedValue(mockChatResponse as any);
368
+
369
+ const payload: CreateImagePayload = {
370
+ model: 'test-model:image',
371
+ params: {
372
+ prompt: 'Generate image',
373
+ },
374
+ };
375
+
376
+ await expect(
377
+ createOpenAICompatibleImage(mockClient, payload, 'test-provider'),
378
+ ).rejects.toThrow('No image generated in chat completion response');
379
+ });
380
+
381
+ it('should throw error when image_url is missing in images array', async () => {
382
+ const mockChatResponse = {
383
+ choices: [
384
+ {
385
+ message: {
386
+ images: [
387
+ {
388
+ // image_url is missing
389
+ someOtherField: 'value',
390
+ },
391
+ ],
392
+ },
393
+ },
394
+ ],
395
+ };
396
+
397
+ vi.mocked(mockClient.chat.completions.create).mockResolvedValue(mockChatResponse as any);
398
+
399
+ const payload: CreateImagePayload = {
400
+ model: 'test-model:image',
401
+ params: {
402
+ prompt: 'Generate image',
403
+ },
404
+ };
405
+
406
+ await expect(
407
+ createOpenAICompatibleImage(mockClient, payload, 'test-provider'),
408
+ ).rejects.toThrow('No image generated in chat completion response');
409
+ });
410
+
411
+ it('should throw error when url is missing in image_url object', async () => {
412
+ const mockChatResponse = {
413
+ choices: [
414
+ {
415
+ message: {
416
+ images: [
417
+ {
418
+ image_url: {
419
+ // url is missing
420
+ detail: 'high',
421
+ },
422
+ },
423
+ ],
424
+ },
425
+ },
426
+ ],
427
+ };
428
+
429
+ vi.mocked(mockClient.chat.completions.create).mockResolvedValue(mockChatResponse as any);
430
+
431
+ const payload: CreateImagePayload = {
432
+ model: 'test-model:image',
433
+ params: {
434
+ prompt: 'Generate image',
435
+ },
436
+ };
437
+
438
+ await expect(
439
+ createOpenAICompatibleImage(mockClient, payload, 'test-provider'),
440
+ ).rejects.toThrow('No image generated in chat completion response');
441
+ });
442
+
443
+ it('should successfully process image with valid imageUrl', async () => {
444
+ const mockImageUrl = 'data:image/jpeg;base64,validBase64Data';
445
+
446
+ vi.spyOn(uriParserModule, 'parseDataUri').mockReturnValue({
447
+ type: 'base64',
448
+ base64: 'validBase64Data',
449
+ mimeType: 'image/jpeg',
450
+ });
451
+
452
+ const mockChatResponse = {
453
+ choices: [
454
+ {
455
+ message: {
456
+ images: [
457
+ {
458
+ image_url: {
459
+ url: 'data:image/png;base64,processedResult',
460
+ },
461
+ },
462
+ ],
463
+ },
464
+ },
465
+ ],
466
+ };
467
+
468
+ vi.mocked(mockClient.chat.completions.create).mockResolvedValue(mockChatResponse as any);
469
+
470
+ const payload: CreateImagePayload = {
471
+ model: 'vision-model:image',
472
+ params: {
473
+ prompt: 'Edit this image by adding a sunset',
474
+ imageUrl: mockImageUrl,
475
+ },
476
+ };
477
+
478
+ const result = await createOpenAICompatibleImage(mockClient, payload, 'openrouter');
479
+
480
+ expect(result.imageUrl).toBe('data:image/png;base64,processedResult');
481
+
482
+ const callArgs = vi.mocked(mockClient.chat.completions.create).mock.calls[0][0] as any;
483
+ expect(callArgs.messages[0].content).toHaveLength(2);
484
+ expect(callArgs.messages[0].content[0]).toEqual({
485
+ text: 'Edit this image by adding a sunset',
486
+ type: 'text',
487
+ });
488
+ expect(callArgs.messages[0].content[1]).toEqual({
489
+ image_url: {
490
+ url: mockImageUrl,
491
+ },
492
+ type: 'image_url',
493
+ });
494
+ });
495
+ });
496
+ });
497
+
498
+ describe('routing logic', () => {
499
+ it('should route to chat model when model ends with :image', async () => {
500
+ const mockChatResponse = {
501
+ choices: [
502
+ {
503
+ message: {
504
+ images: [
505
+ {
506
+ image_url: {
507
+ url: 'data:image/png;base64,chatModelResult',
508
+ },
509
+ },
510
+ ],
511
+ },
512
+ },
513
+ ],
514
+ };
515
+
516
+ vi.mocked(mockClient.chat.completions.create).mockResolvedValue(mockChatResponse as any);
517
+
518
+ const payload: CreateImagePayload = {
519
+ model: 'some-model:image',
520
+ params: {
521
+ prompt: 'Test routing',
522
+ },
523
+ };
524
+
525
+ const result = await createOpenAICompatibleImage(mockClient, payload, 'test-provider');
526
+
527
+ expect(result.imageUrl).toBe('data:image/png;base64,chatModelResult');
528
+ expect(mockClient.chat.completions.create).toHaveBeenCalled();
529
+ expect(mockClient.images.generate).not.toHaveBeenCalled();
530
+ expect(mockClient.images.edit).not.toHaveBeenCalled();
531
+ });
532
+
533
+ it('should route to image mode when model does not end with :image', async () => {
534
+ const mockImageResponse = {
535
+ data: [
536
+ {
537
+ b64_json: 'imageModelBase64Result',
538
+ },
539
+ ],
540
+ };
541
+
542
+ vi.mocked(mockClient.images.generate).mockResolvedValue(mockImageResponse as any);
543
+
544
+ const payload: CreateImagePayload = {
545
+ model: 'dall-e-3',
546
+ params: {
547
+ prompt: 'Test traditional image generation',
548
+ },
549
+ };
550
+
551
+ const result = await createOpenAICompatibleImage(mockClient, payload, 'openai');
552
+
553
+ expect(result.imageUrl).toBe('data:image/png;base64,imageModelBase64Result');
554
+ expect(mockClient.images.generate).toHaveBeenCalled();
555
+ expect(mockClient.chat.completions.create).not.toHaveBeenCalled();
556
+ });
557
+ });
558
+
559
+ describe('image mode - parameter mapping', () => {
560
+ it('should map single imageUrl string parameter to image array', async () => {
561
+ const mockImageResponse = {
562
+ data: [
563
+ {
564
+ b64_json: 'editedImageResult',
565
+ },
566
+ ],
567
+ };
568
+
569
+ // Mock fetch for image download
570
+ const mockArrayBuffer = new Uint8Array([0xff, 0xd8, 0xff, 0xe0]).buffer;
571
+ global.fetch = vi.fn().mockResolvedValue({
572
+ ok: true,
573
+ arrayBuffer: async () => mockArrayBuffer,
574
+ headers: {
575
+ get: (name: string) => (name === 'content-type' ? 'image/jpeg' : null),
576
+ },
577
+ } as any);
578
+
579
+ vi.mocked(mockClient.images.edit).mockResolvedValue(mockImageResponse as any);
580
+
581
+ const payload: CreateImagePayload = {
582
+ model: 'dall-e-2',
583
+ params: {
584
+ prompt: 'Edit image',
585
+ imageUrl: 'https://example.com/single-image.jpg',
586
+ },
587
+ };
588
+
589
+ const result = await createOpenAICompatibleImage(mockClient, payload, 'openai');
590
+
591
+ expect(result.imageUrl).toBe('data:image/png;base64,editedImageResult');
592
+ expect(mockClient.images.edit).toHaveBeenCalled();
593
+ });
594
+
595
+ it('should handle imageUrl with empty string by not converting to array', async () => {
596
+ const mockImageResponse = {
597
+ data: [
598
+ {
599
+ b64_json: 'generatedImage',
600
+ },
601
+ ],
602
+ };
603
+
604
+ vi.mocked(mockClient.images.generate).mockResolvedValue(mockImageResponse as any);
605
+
606
+ const payload: CreateImagePayload = {
607
+ model: 'dall-e-3',
608
+ params: {
609
+ prompt: 'Generate image',
610
+ imageUrl: '',
611
+ },
612
+ };
613
+
614
+ const result = await createOpenAICompatibleImage(mockClient, payload, 'openai');
615
+
616
+ expect(result.imageUrl).toBe('data:image/png;base64,generatedImage');
617
+ expect(mockClient.images.generate).toHaveBeenCalled();
618
+ expect(mockClient.images.edit).not.toHaveBeenCalled();
619
+ });
620
+
621
+ it('should handle imageUrl with whitespace-only string', async () => {
622
+ const mockImageResponse = {
623
+ data: [
624
+ {
625
+ b64_json: 'generatedImage',
626
+ },
627
+ ],
628
+ };
629
+
630
+ vi.mocked(mockClient.images.generate).mockResolvedValue(mockImageResponse as any);
631
+
632
+ const payload: CreateImagePayload = {
633
+ model: 'dall-e-3',
634
+ params: {
635
+ prompt: 'Generate image',
636
+ imageUrl: ' ',
637
+ },
638
+ };
639
+
640
+ const result = await createOpenAICompatibleImage(mockClient, payload, 'openai');
641
+
642
+ expect(result.imageUrl).toBe('data:image/png;base64,generatedImage');
643
+ expect(mockClient.images.generate).toHaveBeenCalled();
644
+ expect(mockClient.images.edit).not.toHaveBeenCalled();
645
+ });
646
+ });
647
+
648
+ describe('image mode - response format handling', () => {
649
+ it('should handle URL format response instead of base64', async () => {
650
+ const mockImageUrl = 'https://oaidalleapiprodscus.blob.core.windows.net/generated/image.png';
651
+ const mockImageResponse = {
652
+ data: [
653
+ {
654
+ url: mockImageUrl,
655
+ },
656
+ ],
657
+ };
658
+
659
+ vi.mocked(mockClient.images.generate).mockResolvedValue(mockImageResponse as any);
660
+
661
+ const payload: CreateImagePayload = {
662
+ model: 'dall-e-3',
663
+ params: {
664
+ prompt: 'Generate image with URL response',
665
+ },
666
+ };
667
+
668
+ const result = await createOpenAICompatibleImage(mockClient, payload, 'openai');
669
+
670
+ expect(result.imageUrl).toBe(mockImageUrl);
671
+ expect(mockClient.images.generate).toHaveBeenCalled();
672
+ });
673
+
674
+ it('should throw error when imageData has neither url nor b64_json', async () => {
675
+ const mockImageResponse = {
676
+ data: [
677
+ {
678
+ // Missing both url and b64_json
679
+ revised_prompt: 'some prompt',
680
+ },
681
+ ],
682
+ };
683
+
684
+ vi.mocked(mockClient.images.generate).mockResolvedValue(mockImageResponse as any);
685
+
686
+ const payload: CreateImagePayload = {
687
+ model: 'dall-e-3',
688
+ params: {
689
+ prompt: 'Test',
690
+ },
691
+ };
692
+
693
+ await expect(createOpenAICompatibleImage(mockClient, payload, 'openai')).rejects.toThrow(
694
+ 'Invalid image response: missing both b64_json and url fields',
695
+ );
696
+ });
697
+
698
+ it('should throw error when response data is not an array', async () => {
699
+ const mockImageResponse = {
700
+ data: 'not an array',
701
+ };
702
+
703
+ vi.mocked(mockClient.images.generate).mockResolvedValue(mockImageResponse as any);
704
+
705
+ const payload: CreateImagePayload = {
706
+ model: 'dall-e-3',
707
+ params: {
708
+ prompt: 'Test',
709
+ },
710
+ };
711
+
712
+ await expect(createOpenAICompatibleImage(mockClient, payload, 'openai')).rejects.toThrow(
713
+ 'Invalid image response: missing or empty data array',
714
+ );
715
+ });
716
+
717
+ it('should throw error when imageData is undefined in array', async () => {
718
+ const mockImageResponse = {
719
+ data: [undefined],
720
+ };
721
+
722
+ vi.mocked(mockClient.images.generate).mockResolvedValue(mockImageResponse as any);
723
+
724
+ const payload: CreateImagePayload = {
725
+ model: 'dall-e-3',
726
+ params: {
727
+ prompt: 'Test',
728
+ },
729
+ };
730
+
731
+ await expect(createOpenAICompatibleImage(mockClient, payload, 'openai')).rejects.toThrow(
732
+ 'Invalid image response: first data item is null or undefined',
733
+ );
734
+ });
735
+ });
736
+
737
+ describe('image mode - usage tracking', () => {
738
+ it('should include modelUsage when usage is present in response', async () => {
739
+ const mockImageResponse = {
740
+ data: [
741
+ {
742
+ b64_json: 'imageWithUsage',
743
+ },
744
+ ],
745
+ usage: {
746
+ total_tokens: 1000,
747
+ input_tokens: 100,
748
+ output_tokens: 900,
749
+ input_tokens_details: {
750
+ text_tokens: 50,
751
+ image_tokens: 50,
752
+ },
753
+ },
754
+ };
755
+
756
+ vi.mocked(mockClient.images.generate).mockResolvedValue(mockImageResponse as any);
757
+
758
+ const payload: CreateImagePayload = {
759
+ model: 'dall-e-3',
760
+ params: {
761
+ prompt: 'Generate image with usage tracking',
762
+ },
763
+ };
764
+
765
+ const result = await createOpenAICompatibleImage(mockClient, payload, 'openai');
766
+
767
+ expect(result.imageUrl).toBe('data:image/png;base64,imageWithUsage');
768
+ expect(result.modelUsage).toBeDefined();
769
+ expect(result.modelUsage?.inputImageTokens).toBe(50);
770
+ expect(result.modelUsage?.inputTextTokens).toBe(50);
771
+ expect(result.modelUsage?.outputImageTokens).toBe(900);
772
+ });
773
+
774
+ it('should not include modelUsage when usage is missing in response', async () => {
775
+ const mockImageResponse = {
776
+ data: [
777
+ {
778
+ b64_json: 'imageWithoutUsage',
779
+ },
780
+ ],
781
+ // No usage field
782
+ };
783
+
784
+ vi.mocked(mockClient.images.generate).mockResolvedValue(mockImageResponse as any);
785
+
786
+ const payload: CreateImagePayload = {
787
+ model: 'dall-e-3',
788
+ params: {
789
+ prompt: 'Generate image without usage tracking',
790
+ },
791
+ };
792
+
793
+ const result = await createOpenAICompatibleImage(mockClient, payload, 'openai');
794
+
795
+ expect(result.imageUrl).toBe('data:image/png;base64,imageWithoutUsage');
796
+ expect(result.modelUsage).toBeUndefined();
797
+ });
798
+ });
799
+ });