@robota-sdk/agent-provider 3.0.0-beta.64

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 (220) hide show
  1. package/LICENSE +21 -0
  2. package/dist/browser/index.d.ts +1104 -0
  3. package/dist/browser/index.d.ts.map +1 -0
  4. package/dist/browser/index.js +7 -0
  5. package/dist/browser/index.js.map +1 -0
  6. package/dist/loggers/index.cjs +1 -0
  7. package/dist/loggers/index.d.ts +151 -0
  8. package/dist/loggers/index.d.ts.map +1 -0
  9. package/dist/loggers/index.js +2 -0
  10. package/dist/loggers/index.js.map +1 -0
  11. package/dist/node/anthropic/index.cjs +1 -0
  12. package/dist/node/anthropic/index.d.ts +158 -0
  13. package/dist/node/anthropic/index.d.ts.map +1 -0
  14. package/dist/node/anthropic/index.js +1 -0
  15. package/dist/node/anthropic--1vgLC-e.js +5 -0
  16. package/dist/node/anthropic--1vgLC-e.js.map +1 -0
  17. package/dist/node/anthropic-BFQ6DSCP.cjs +4 -0
  18. package/dist/node/bytedance/index.cjs +1 -0
  19. package/dist/node/bytedance/index.d.ts +74 -0
  20. package/dist/node/bytedance/index.d.ts.map +1 -0
  21. package/dist/node/bytedance/index.js +1 -0
  22. package/dist/node/bytedance-C_0sF_pJ.js +2 -0
  23. package/dist/node/bytedance-C_0sF_pJ.js.map +1 -0
  24. package/dist/node/bytedance-DVPxqEiC.cjs +1 -0
  25. package/dist/node/chunk-Bmb41Sf3.cjs +1 -0
  26. package/dist/node/deepseek/index.cjs +1 -0
  27. package/dist/node/deepseek/index.d.ts +2 -0
  28. package/dist/node/deepseek/index.js +1 -0
  29. package/dist/node/deepseek-_8Ixx7rA.js +2 -0
  30. package/dist/node/deepseek-_8Ixx7rA.js.map +1 -0
  31. package/dist/node/deepseek-oA2Y6bD0.cjs +1 -0
  32. package/dist/node/gemini/index.cjs +1 -0
  33. package/dist/node/gemini/index.d.ts +173 -0
  34. package/dist/node/gemini/index.d.ts.map +1 -0
  35. package/dist/node/gemini/index.js +1 -0
  36. package/dist/node/gemini-Bh2U87MY.js +4 -0
  37. package/dist/node/gemini-Bh2U87MY.js.map +1 -0
  38. package/dist/node/gemini-DSaNCxZj.cjs +3 -0
  39. package/dist/node/gemma/index.cjs +1 -0
  40. package/dist/node/gemma/index.d.ts +2 -0
  41. package/dist/node/gemma/index.js +1 -0
  42. package/dist/node/gemma-Dp_AfCUR.js +2 -0
  43. package/dist/node/gemma-Dp_AfCUR.js.map +1 -0
  44. package/dist/node/gemma-G-Pf_PnX.cjs +1 -0
  45. package/dist/node/google/index.cjs +1 -0
  46. package/dist/node/google/index.d.ts +14 -0
  47. package/dist/node/google/index.d.ts.map +1 -0
  48. package/dist/node/google/index.js +2 -0
  49. package/dist/node/google/index.js.map +1 -0
  50. package/dist/node/index-B6PnlDMd.d.ts +82 -0
  51. package/dist/node/index-B6PnlDMd.d.ts.map +1 -0
  52. package/dist/node/index-B7UvPJcI.d.ts +315 -0
  53. package/dist/node/index-B7UvPJcI.d.ts.map +1 -0
  54. package/dist/node/index-BLPOTNb5.d.ts +98 -0
  55. package/dist/node/index-BLPOTNb5.d.ts.map +1 -0
  56. package/dist/node/index-BqixM_XD.d.ts +231 -0
  57. package/dist/node/index-BqixM_XD.d.ts.map +1 -0
  58. package/dist/node/index-C3beaqKO.d.ts +231 -0
  59. package/dist/node/index-C3beaqKO.d.ts.map +1 -0
  60. package/dist/node/index-Cp2XRh9G.d.ts +82 -0
  61. package/dist/node/index-Cp2XRh9G.d.ts.map +1 -0
  62. package/dist/node/index-DSv5xruI.d.ts +98 -0
  63. package/dist/node/index-DSv5xruI.d.ts.map +1 -0
  64. package/dist/node/index-w0bV1uaP.d.ts +315 -0
  65. package/dist/node/index-w0bV1uaP.d.ts.map +1 -0
  66. package/dist/node/index.cjs +1 -0
  67. package/dist/node/index.d.ts +8 -0
  68. package/dist/node/index.js +1 -0
  69. package/dist/node/openai/index.cjs +1 -0
  70. package/dist/node/openai/index.d.ts +2 -0
  71. package/dist/node/openai/index.js +1 -0
  72. package/dist/node/openai-CRQjg4xF.js +2 -0
  73. package/dist/node/openai-CRQjg4xF.js.map +1 -0
  74. package/dist/node/openai-compatible-BYfyY5lb.cjs +1 -0
  75. package/dist/node/openai-compatible-Dm4Sof9e.js +2 -0
  76. package/dist/node/openai-compatible-Dm4Sof9e.js.map +1 -0
  77. package/dist/node/openai-xWC6pY7r.cjs +1 -0
  78. package/dist/node/qwen/index.cjs +1 -0
  79. package/dist/node/qwen/index.d.ts +2 -0
  80. package/dist/node/qwen/index.js +1 -0
  81. package/dist/node/qwen-ChUZobTL.js +2 -0
  82. package/dist/node/qwen-ChUZobTL.js.map +1 -0
  83. package/dist/node/qwen-CjT71vSM.cjs +1 -0
  84. package/package.json +157 -0
  85. package/src/anthropic/__tests__/abort-streaming.test.ts +199 -0
  86. package/src/anthropic/__tests__/model-catalog-refresh.test.ts +92 -0
  87. package/src/anthropic/__tests__/provider-definition.test.ts +55 -0
  88. package/src/anthropic/__tests__/provider.test.ts +1357 -0
  89. package/src/anthropic/__tests__/response-parser.test.ts +326 -0
  90. package/src/anthropic/index.ts +22 -0
  91. package/src/anthropic/message-converter.ts +181 -0
  92. package/src/anthropic/model-catalog-refresh.ts +128 -0
  93. package/src/anthropic/parsers/response-parser.ts +184 -0
  94. package/src/anthropic/provider-definition.ts +93 -0
  95. package/src/anthropic/provider.ts +290 -0
  96. package/src/anthropic/streaming-handler.ts +204 -0
  97. package/src/anthropic/types/api-types.ts +158 -0
  98. package/src/anthropic/types.ts +79 -0
  99. package/src/bytedance/http-client.test.ts +288 -0
  100. package/src/bytedance/http-client.ts +163 -0
  101. package/src/bytedance/index.ts +2 -0
  102. package/src/bytedance/provider.spec.ts +320 -0
  103. package/src/bytedance/provider.ts +171 -0
  104. package/src/bytedance/status-mapper.test.ts +299 -0
  105. package/src/bytedance/status-mapper.ts +141 -0
  106. package/src/bytedance/types.ts +68 -0
  107. package/src/deepseek/defaults.ts +4 -0
  108. package/src/deepseek/index.ts +22 -0
  109. package/src/deepseek/model-catalog-refresh.test.ts +57 -0
  110. package/src/deepseek/model-catalog-refresh.ts +105 -0
  111. package/src/deepseek/model-catalog.ts +55 -0
  112. package/src/deepseek/provider-definition.test.ts +109 -0
  113. package/src/deepseek/provider-definition.ts +132 -0
  114. package/src/deepseek/provider.test.ts +324 -0
  115. package/src/deepseek/provider.ts +298 -0
  116. package/src/deepseek/types.ts +37 -0
  117. package/src/gemini/execution-helpers.ts +233 -0
  118. package/src/gemini/genai-transport.test.ts +208 -0
  119. package/src/gemini/image-operations.test.ts +448 -0
  120. package/src/gemini/image-operations.ts +261 -0
  121. package/src/gemini/index.ts +11 -0
  122. package/src/gemini/message-converter.test.ts +616 -0
  123. package/src/gemini/message-converter.ts +140 -0
  124. package/src/gemini/model-catalog-refresh.test.ts +107 -0
  125. package/src/gemini/model-catalog-refresh.ts +92 -0
  126. package/src/gemini/provider-definition.test.ts +70 -0
  127. package/src/gemini/provider-definition.ts +78 -0
  128. package/src/gemini/provider-extended.test.ts +898 -0
  129. package/src/gemini/provider.spec.ts +216 -0
  130. package/src/gemini/provider.ts +279 -0
  131. package/src/gemini/request-converter.ts +226 -0
  132. package/src/gemini/tool-schema-converter.ts +78 -0
  133. package/src/gemini/types/api-types.ts +235 -0
  134. package/src/gemini/types.ts +121 -0
  135. package/src/gemma/index.ts +5 -0
  136. package/src/gemma/message-factory.ts +38 -0
  137. package/src/gemma/provider-definition.test.ts +43 -0
  138. package/src/gemma/provider-definition.ts +84 -0
  139. package/src/gemma/provider-projection.ts +49 -0
  140. package/src/gemma/provider.test.ts +628 -0
  141. package/src/gemma/provider.ts +308 -0
  142. package/src/gemma/pseudo-command-envelope.ts +58 -0
  143. package/src/gemma/pseudo-tool-call-projector.ts +243 -0
  144. package/src/gemma/pseudo-tool-call-tag-parser.ts +153 -0
  145. package/src/gemma/pseudo-tool-call-types.ts +31 -0
  146. package/src/gemma/reasoning-projector.test.ts +52 -0
  147. package/src/gemma/reasoning-projector.ts +144 -0
  148. package/src/gemma/streaming-projection.ts +79 -0
  149. package/src/gemma/tool-call-argument-parser.ts +126 -0
  150. package/src/gemma/tool-call-projector.test.ts +227 -0
  151. package/src/gemma/tool-call-projector.ts +264 -0
  152. package/src/gemma/types.ts +27 -0
  153. package/src/google/index.ts +11 -0
  154. package/src/google/provider-compat.test.ts +19 -0
  155. package/src/google/provider-definition.ts +6 -0
  156. package/src/google/provider.ts +10 -0
  157. package/src/google/types.ts +5 -0
  158. package/src/index.ts +9 -0
  159. package/src/openai/adapter.test.ts +494 -0
  160. package/src/openai/adapter.ts +145 -0
  161. package/src/openai/chat-completions-chat.ts +189 -0
  162. package/src/openai/executor-integration.test.ts +206 -0
  163. package/src/openai/index.ts +21 -0
  164. package/src/openai/interfaces/payload-logger.ts +48 -0
  165. package/src/openai/loggers/console-payload-logger.test.ts +173 -0
  166. package/src/openai/loggers/console-payload-logger.ts +94 -0
  167. package/src/openai/loggers/console.ts +9 -0
  168. package/src/openai/loggers/file-payload-logger.test.ts +238 -0
  169. package/src/openai/loggers/file-payload-logger.ts +112 -0
  170. package/src/openai/loggers/file.ts +9 -0
  171. package/src/openai/loggers/index.ts +12 -0
  172. package/src/openai/loggers/sanitize-openai-log-data.test.ts +89 -0
  173. package/src/openai/loggers/sanitize-openai-log-data.ts +14 -0
  174. package/src/openai/message-converter.ts +22 -0
  175. package/src/openai/model-catalog-refresh.test.ts +92 -0
  176. package/src/openai/model-catalog-refresh.ts +115 -0
  177. package/src/openai/openai-request-format.ts +92 -0
  178. package/src/openai/parsers/response-parser.test.ts +407 -0
  179. package/src/openai/parsers/response-parser.ts +47 -0
  180. package/src/openai/provider-definition.test.ts +75 -0
  181. package/src/openai/provider-definition.ts +132 -0
  182. package/src/openai/provider.test.ts +1402 -0
  183. package/src/openai/provider.ts +237 -0
  184. package/src/openai/responses-chat.ts +258 -0
  185. package/src/openai/responses-converter.ts +112 -0
  186. package/src/openai/responses-parser.ts +285 -0
  187. package/src/openai/responses-stream-utils.ts +45 -0
  188. package/src/openai/responses-types.ts +195 -0
  189. package/src/openai/streaming/stream-assembler.ts +3 -0
  190. package/src/openai/streaming/stream-handler.test.ts +367 -0
  191. package/src/openai/streaming/stream-handler.ts +119 -0
  192. package/src/openai/types/api-types.ts +112 -0
  193. package/src/openai/types.ts +194 -0
  194. package/src/qwen/defaults.ts +26 -0
  195. package/src/qwen/index.ts +5 -0
  196. package/src/qwen/model-catalog-refresh.test.ts +91 -0
  197. package/src/qwen/model-catalog-refresh.ts +97 -0
  198. package/src/qwen/provider-capabilities.ts +34 -0
  199. package/src/qwen/provider-definition.test.ts +139 -0
  200. package/src/qwen/provider-definition.ts +173 -0
  201. package/src/qwen/provider-streaming-assembly.ts +40 -0
  202. package/src/qwen/provider.test.ts +640 -0
  203. package/src/qwen/provider.ts +293 -0
  204. package/src/qwen/responses-chat.ts +194 -0
  205. package/src/qwen/responses-converter.ts +104 -0
  206. package/src/qwen/responses-parser.ts +299 -0
  207. package/src/qwen/responses-stream-utils.ts +38 -0
  208. package/src/qwen/types.ts +228 -0
  209. package/src/shared/openai-compatible/endpoint-probe.test.ts +52 -0
  210. package/src/shared/openai-compatible/endpoint-probe.ts +43 -0
  211. package/src/shared/openai-compatible/index.ts +6 -0
  212. package/src/shared/openai-compatible/message-converter.test.ts +111 -0
  213. package/src/shared/openai-compatible/message-converter.ts +84 -0
  214. package/src/shared/openai-compatible/native-payload-observer.test.ts +43 -0
  215. package/src/shared/openai-compatible/native-payload-observer.ts +26 -0
  216. package/src/shared/openai-compatible/response-parser.test.ts +172 -0
  217. package/src/shared/openai-compatible/response-parser.ts +180 -0
  218. package/src/shared/openai-compatible/stream-assembler.test.ts +266 -0
  219. package/src/shared/openai-compatible/stream-assembler.ts +248 -0
  220. package/src/shared/openai-compatible/types.ts +59 -0
@@ -0,0 +1,208 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import type { TUniversalMessage } from '@robota-sdk/agent-core';
3
+
4
+ const generateContentMock = vi.fn();
5
+ const generateContentStreamMock = vi.fn();
6
+ const genAiConstructorMock = vi.fn();
7
+
8
+ vi.mock('@google/genai', () => {
9
+ class GoogleGenAI {
10
+ public readonly models = {
11
+ generateContent: generateContentMock,
12
+ generateContentStream: generateContentStreamMock,
13
+ };
14
+
15
+ public constructor(options: { apiKey: string }) {
16
+ genAiConstructorMock(options);
17
+ }
18
+ }
19
+
20
+ return {
21
+ GoogleGenAI,
22
+ Type: {
23
+ STRING: 'STRING',
24
+ NUMBER: 'NUMBER',
25
+ INTEGER: 'INTEGER',
26
+ BOOLEAN: 'BOOLEAN',
27
+ ARRAY: 'ARRAY',
28
+ OBJECT: 'OBJECT',
29
+ },
30
+ };
31
+ });
32
+
33
+ function createUserMessage(content: string): TUniversalMessage {
34
+ return {
35
+ id: 'msg-1',
36
+ state: 'complete' as const,
37
+ role: 'user',
38
+ content,
39
+ timestamp: new Date(),
40
+ };
41
+ }
42
+
43
+ function createSystemMessage(content: string): TUniversalMessage {
44
+ return {
45
+ id: 'sys-1',
46
+ state: 'complete' as const,
47
+ role: 'system',
48
+ content,
49
+ timestamp: new Date(),
50
+ };
51
+ }
52
+
53
+ describe('GeminiProvider @google/genai transport', () => {
54
+ beforeEach(() => {
55
+ vi.resetModules();
56
+ generateContentMock.mockReset();
57
+ generateContentStreamMock.mockReset();
58
+ genAiConstructorMock.mockClear();
59
+ });
60
+
61
+ it('uses GoogleGenAI models.generateContent with config payload shape', async () => {
62
+ generateContentMock.mockResolvedValue({
63
+ candidates: [{ content: { parts: [{ text: 'done' }] } }],
64
+ });
65
+ const { GeminiProvider } = await import('./provider');
66
+
67
+ const provider = new GeminiProvider({ apiKey: 'test-key' });
68
+ const response = await provider.chat([createUserMessage('hello')], {
69
+ model: 'gemini-3-flash-preview',
70
+ temperature: 0.4,
71
+ maxTokens: 128,
72
+ tools: [
73
+ {
74
+ name: 'search',
75
+ description: 'Search the web',
76
+ parameters: { type: 'object', properties: { q: { type: 'string' } } },
77
+ },
78
+ ],
79
+ });
80
+
81
+ expect(response.content).toBe('done');
82
+ expect(genAiConstructorMock).toHaveBeenCalledWith({ apiKey: 'test-key' });
83
+ expect(generateContentMock).toHaveBeenCalledWith({
84
+ model: 'gemini-3-flash-preview',
85
+ contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
86
+ config: {
87
+ temperature: 0.4,
88
+ maxOutputTokens: 128,
89
+ responseModalities: ['TEXT'],
90
+ tools: [
91
+ {
92
+ functionDeclarations: [
93
+ {
94
+ name: 'search',
95
+ description: 'Search the web',
96
+ parameters: { type: 'OBJECT', properties: { q: { type: 'STRING' } } },
97
+ },
98
+ ],
99
+ },
100
+ ],
101
+ },
102
+ });
103
+ });
104
+
105
+ it('uses provider defaultModel when chat options omit model', async () => {
106
+ generateContentMock.mockResolvedValue({
107
+ candidates: [{ content: { parts: [{ text: 'done' }] } }],
108
+ });
109
+ const { GeminiProvider } = await import('./provider');
110
+
111
+ const provider = new GeminiProvider({
112
+ apiKey: 'test-key',
113
+ defaultModel: 'gemini-3-flash-preview',
114
+ });
115
+ await provider.chat([createUserMessage('hello')]);
116
+
117
+ expect(generateContentMock).toHaveBeenCalledWith({
118
+ model: 'gemini-3-flash-preview',
119
+ contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
120
+ config: {
121
+ responseModalities: ['TEXT'],
122
+ },
123
+ });
124
+ });
125
+
126
+ it('sends system messages through config.systemInstruction', async () => {
127
+ generateContentMock.mockResolvedValue({
128
+ candidates: [{ content: { parts: [{ text: 'done' }] } }],
129
+ });
130
+ const { GeminiProvider } = await import('./provider');
131
+
132
+ const provider = new GeminiProvider({ apiKey: 'test-key' });
133
+ await provider.chat([createSystemMessage('You are concise.'), createUserMessage('hello')], {
134
+ model: 'gemini-3-flash-preview',
135
+ });
136
+
137
+ expect(generateContentMock).toHaveBeenCalledWith({
138
+ model: 'gemini-3-flash-preview',
139
+ contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
140
+ config: {
141
+ responseModalities: ['TEXT'],
142
+ systemInstruction: 'You are concise.',
143
+ },
144
+ });
145
+ });
146
+
147
+ it('passes structured output, thinking, and safety config to Gemini', async () => {
148
+ generateContentMock.mockResolvedValue({
149
+ candidates: [{ content: { parts: [{ text: '{"answer":"done"}' }] } }],
150
+ });
151
+ const { GeminiProvider } = await import('./provider');
152
+
153
+ const provider = new GeminiProvider({
154
+ apiKey: 'test-key',
155
+ responseJsonSchema: {
156
+ type: 'object',
157
+ properties: { answer: { type: 'string' } },
158
+ required: ['answer'],
159
+ },
160
+ thinkingConfig: { thinkingLevel: 'LOW' },
161
+ safetySettings: [{ category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_ONLY_HIGH' }],
162
+ });
163
+ await provider.chat([createUserMessage('hello')], { model: 'gemini-3-flash-preview' });
164
+
165
+ expect(generateContentMock).toHaveBeenCalledWith({
166
+ model: 'gemini-3-flash-preview',
167
+ contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
168
+ config: {
169
+ responseModalities: ['TEXT'],
170
+ responseMimeType: 'application/json',
171
+ responseJsonSchema: {
172
+ type: 'object',
173
+ properties: { answer: { type: 'string' } },
174
+ required: ['answer'],
175
+ },
176
+ thinkingConfig: { thinkingLevel: 'LOW' },
177
+ safetySettings: [{ category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_ONLY_HIGH' }],
178
+ },
179
+ });
180
+ });
181
+
182
+ it('uses GoogleGenAI models.generateContentStream as an async iterable', async () => {
183
+ generateContentStreamMock.mockResolvedValue(
184
+ (async function* () {
185
+ yield { text: 'Hello' };
186
+ yield { text: ' world' };
187
+ })(),
188
+ );
189
+ const { GeminiProvider } = await import('./provider');
190
+
191
+ const provider = new GeminiProvider({ apiKey: 'test-key' });
192
+ const chunks: TUniversalMessage[] = [];
193
+ for await (const chunk of provider.chatStream([createUserMessage('hello')], {
194
+ model: 'gemini-3-flash-preview',
195
+ })) {
196
+ chunks.push(chunk);
197
+ }
198
+
199
+ expect(chunks.map((chunk) => chunk.content)).toEqual(['Hello', ' world']);
200
+ expect(generateContentStreamMock).toHaveBeenCalledWith({
201
+ model: 'gemini-3-flash-preview',
202
+ contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
203
+ config: {
204
+ responseModalities: ['TEXT'],
205
+ },
206
+ });
207
+ });
208
+ });
@@ -0,0 +1,448 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { TUniversalMessage, TUniversalMessagePart } from '@robota-sdk/agent-core';
3
+ import {
4
+ hasImagePart,
5
+ mapInlineImagePartsToMediaOutputs,
6
+ parseDataUri,
7
+ mapImageInputSourceToPart,
8
+ buildResponseModalities,
9
+ isImageCapableModel,
10
+ buildGenerationConfig,
11
+ } from './image-operations';
12
+
13
+ describe('hasImagePart', () => {
14
+ it('returns false for undefined parts', () => {
15
+ expect(hasImagePart(undefined)).toBe(false);
16
+ });
17
+
18
+ it('returns false for empty parts array', () => {
19
+ expect(hasImagePart([])).toBe(false);
20
+ });
21
+
22
+ it('returns false when only text parts exist', () => {
23
+ const parts: TUniversalMessagePart[] = [{ type: 'text', text: 'hello' }];
24
+ expect(hasImagePart(parts)).toBe(false);
25
+ });
26
+
27
+ it('returns true when image_inline part exists', () => {
28
+ const parts: TUniversalMessagePart[] = [
29
+ { type: 'image_inline', mimeType: 'image/png', data: 'abc' },
30
+ ];
31
+ expect(hasImagePart(parts)).toBe(true);
32
+ });
33
+
34
+ it('returns true when image_uri part exists', () => {
35
+ const parts: TUniversalMessagePart[] = [
36
+ { type: 'image_uri', uri: 'https://example.com/img.png', mimeType: 'image/png' },
37
+ ];
38
+ expect(hasImagePart(parts)).toBe(true);
39
+ });
40
+
41
+ it('returns true when mixed parts include an image', () => {
42
+ const parts: TUniversalMessagePart[] = [
43
+ { type: 'text', text: 'describe this' },
44
+ { type: 'image_inline', mimeType: 'image/jpeg', data: 'data' },
45
+ ];
46
+ expect(hasImagePart(parts)).toBe(true);
47
+ });
48
+ });
49
+
50
+ describe('mapInlineImagePartsToMediaOutputs', () => {
51
+ it('returns empty array for undefined parts', () => {
52
+ expect(mapInlineImagePartsToMediaOutputs(undefined)).toEqual([]);
53
+ });
54
+
55
+ it('returns empty array when no image parts exist', () => {
56
+ const parts: TUniversalMessagePart[] = [{ type: 'text', text: 'hello' }];
57
+ expect(mapInlineImagePartsToMediaOutputs(parts)).toEqual([]);
58
+ });
59
+
60
+ it('converts inline image parts to media output references', () => {
61
+ const parts: TUniversalMessagePart[] = [
62
+ { type: 'image_inline', mimeType: 'image/png', data: 'abc123' },
63
+ ];
64
+ const result = mapInlineImagePartsToMediaOutputs(parts);
65
+ expect(result).toHaveLength(1);
66
+ expect(result[0]).toEqual({
67
+ kind: 'uri',
68
+ uri: 'data:image/png;base64,abc123',
69
+ mimeType: 'image/png',
70
+ });
71
+ });
72
+
73
+ it('skips non-inline-image parts', () => {
74
+ const parts: TUniversalMessagePart[] = [
75
+ { type: 'text', text: 'hello' },
76
+ { type: 'image_inline', mimeType: 'image/png', data: 'data1' },
77
+ { type: 'image_uri', uri: 'https://example.com/img.png', mimeType: 'image/png' },
78
+ { type: 'image_inline', mimeType: 'image/jpeg', data: 'data2' },
79
+ ];
80
+ const result = mapInlineImagePartsToMediaOutputs(parts);
81
+ expect(result).toHaveLength(2);
82
+ expect(result[0]?.mimeType).toBe('image/png');
83
+ expect(result[1]?.mimeType).toBe('image/jpeg');
84
+ });
85
+ });
86
+
87
+ describe('parseDataUri', () => {
88
+ it('parses a valid data URI', () => {
89
+ const result = parseDataUri('data:image/png;base64,abc123');
90
+ expect(result).toEqual({ mimeType: 'image/png', data: 'abc123' });
91
+ });
92
+
93
+ it('returns undefined for URI without comma', () => {
94
+ expect(parseDataUri('data:image/png;base64')).toBeUndefined();
95
+ });
96
+
97
+ it('returns undefined for URI without base64 encoding marker', () => {
98
+ expect(parseDataUri('data:image/png,abc123')).toBeUndefined();
99
+ });
100
+
101
+ it('returns undefined for empty MIME type', () => {
102
+ expect(parseDataUri('data:;base64,abc123')).toBeUndefined();
103
+ });
104
+
105
+ it('returns undefined for empty payload', () => {
106
+ expect(parseDataUri('data:image/png;base64,')).toBeUndefined();
107
+ });
108
+
109
+ it('returns undefined for whitespace-only payload', () => {
110
+ expect(parseDataUri('data:image/png;base64, ')).toBeUndefined();
111
+ });
112
+
113
+ it('parses data URI with complex MIME type', () => {
114
+ const result = parseDataUri('data:application/octet-stream;base64,AQID');
115
+ expect(result).toEqual({ mimeType: 'application/octet-stream', data: 'AQID' });
116
+ });
117
+ });
118
+
119
+ describe('mapImageInputSourceToPart', () => {
120
+ it('maps inline source with valid data', () => {
121
+ const result = mapImageInputSourceToPart({
122
+ kind: 'inline',
123
+ mimeType: 'image/png',
124
+ data: 'base64data',
125
+ });
126
+ expect(result).toEqual({
127
+ ok: true,
128
+ value: { type: 'image_inline', mimeType: 'image/png', data: 'base64data' },
129
+ });
130
+ });
131
+
132
+ it('rejects inline source with empty mimeType', () => {
133
+ const result = mapImageInputSourceToPart({
134
+ kind: 'inline',
135
+ mimeType: '',
136
+ data: 'base64data',
137
+ });
138
+ expect(result.ok).toBe(false);
139
+ if (!result.ok) {
140
+ expect(result.error.code).toBe('PROVIDER_INVALID_REQUEST');
141
+ expect(result.error.message).toContain('non-empty mimeType and data');
142
+ }
143
+ });
144
+
145
+ it('rejects inline source with empty data', () => {
146
+ const result = mapImageInputSourceToPart({
147
+ kind: 'inline',
148
+ mimeType: 'image/png',
149
+ data: '',
150
+ });
151
+ expect(result.ok).toBe(false);
152
+ if (!result.ok) {
153
+ expect(result.error.code).toBe('PROVIDER_INVALID_REQUEST');
154
+ }
155
+ });
156
+
157
+ it('rejects inline source with whitespace-only mimeType', () => {
158
+ const result = mapImageInputSourceToPart({
159
+ kind: 'inline',
160
+ mimeType: ' ',
161
+ data: 'base64data',
162
+ });
163
+ expect(result.ok).toBe(false);
164
+ });
165
+
166
+ it('maps URI source with valid data URI', () => {
167
+ const result = mapImageInputSourceToPart({
168
+ kind: 'uri',
169
+ uri: 'data:image/jpeg;base64,imgdata',
170
+ });
171
+ expect(result).toEqual({
172
+ ok: true,
173
+ value: { type: 'image_inline', mimeType: 'image/jpeg', data: 'imgdata' },
174
+ });
175
+ });
176
+
177
+ it('rejects non-data URI sources', () => {
178
+ const result = mapImageInputSourceToPart({
179
+ kind: 'uri',
180
+ uri: 'https://example.com/img.png',
181
+ });
182
+ expect(result.ok).toBe(false);
183
+ if (!result.ok) {
184
+ expect(result.error.code).toBe('PROVIDER_INVALID_REQUEST');
185
+ expect(result.error.message).toContain('only inline or data URI');
186
+ }
187
+ });
188
+
189
+ it('rejects invalid data URI format', () => {
190
+ const result = mapImageInputSourceToPart({
191
+ kind: 'uri',
192
+ uri: 'data:image/png,not-base64',
193
+ });
194
+ expect(result.ok).toBe(false);
195
+ if (!result.ok) {
196
+ expect(result.error.code).toBe('PROVIDER_INVALID_REQUEST');
197
+ expect(result.error.message).toContain('base64 payload');
198
+ }
199
+ });
200
+
201
+ it('rejects data URI with empty payload', () => {
202
+ const result = mapImageInputSourceToPart({
203
+ kind: 'uri',
204
+ uri: 'data:image/png;base64,',
205
+ });
206
+ expect(result.ok).toBe(false);
207
+ });
208
+ });
209
+
210
+ describe('buildResponseModalities', () => {
211
+ const textMessage: TUniversalMessage = {
212
+ id: 'msg-1',
213
+ state: 'complete' as const,
214
+ role: 'user',
215
+ content: 'hello',
216
+ timestamp: new Date(),
217
+ };
218
+ const imageMessage: TUniversalMessage = {
219
+ id: 'msg-2',
220
+ state: 'complete' as const,
221
+ role: 'user',
222
+ content: '',
223
+ parts: [{ type: 'image_inline', mimeType: 'image/png', data: 'data' }],
224
+ timestamp: new Date(),
225
+ };
226
+
227
+ it('returns option modalities when explicitly provided', () => {
228
+ const result = buildResponseModalities([textMessage], undefined, ['IMAGE']);
229
+ expect(result).toEqual(['IMAGE']);
230
+ });
231
+
232
+ it('returns ["TEXT", "IMAGE"] when input has image parts and no explicit option', () => {
233
+ const result = buildResponseModalities([imageMessage], undefined, undefined);
234
+ expect(result).toEqual(['TEXT', 'IMAGE']);
235
+ });
236
+
237
+ it('returns default modalities when no image input and no option', () => {
238
+ const result = buildResponseModalities([textMessage], ['TEXT', 'IMAGE'], undefined);
239
+ expect(result).toEqual(['TEXT', 'IMAGE']);
240
+ });
241
+
242
+ it('returns ["TEXT"] when no image input, no defaults, and no option', () => {
243
+ const result = buildResponseModalities([textMessage], undefined, undefined);
244
+ expect(result).toEqual(['TEXT']);
245
+ });
246
+
247
+ it('option modalities take precedence over image input detection', () => {
248
+ const result = buildResponseModalities([imageMessage], undefined, ['TEXT']);
249
+ expect(result).toEqual(['TEXT']);
250
+ });
251
+
252
+ it('option modalities take precedence over default modalities', () => {
253
+ const result = buildResponseModalities([textMessage], ['IMAGE'], ['TEXT']);
254
+ expect(result).toEqual(['TEXT']);
255
+ });
256
+
257
+ it('returns ["TEXT"] for empty option modalities array (falls through)', () => {
258
+ const result = buildResponseModalities([textMessage], undefined, []);
259
+ expect(result).toEqual(['TEXT']);
260
+ });
261
+
262
+ it('returns default modalities even with empty option modalities', () => {
263
+ const result = buildResponseModalities([textMessage], ['IMAGE'], []);
264
+ expect(result).toEqual(['IMAGE']);
265
+ });
266
+ });
267
+
268
+ describe('isImageCapableModel', () => {
269
+ it('returns true when model is in configured allowlist', () => {
270
+ expect(isImageCapableModel('my-custom-model', ['my-custom-model'])).toBe(true);
271
+ });
272
+
273
+ it('returns false when model is not in configured allowlist', () => {
274
+ expect(isImageCapableModel('gemini-pro', ['my-custom-model'])).toBe(false);
275
+ });
276
+
277
+ it('allows any model when no allowlist provided', () => {
278
+ expect(isImageCapableModel('gemini-2.5-flash-image', undefined)).toBe(true);
279
+ expect(isImageCapableModel('gemini-pro', undefined)).toBe(true);
280
+ });
281
+
282
+ it('allows any model when allowlist is empty', () => {
283
+ expect(isImageCapableModel('some-image-model', [])).toBe(true);
284
+ expect(isImageCapableModel('gemini-pro', [])).toBe(true);
285
+ });
286
+ });
287
+
288
+ describe('buildGenerationConfig', () => {
289
+ const textMessage: TUniversalMessage = {
290
+ id: 'msg-1',
291
+ state: 'complete' as const,
292
+ role: 'user',
293
+ content: 'hello',
294
+ timestamp: new Date(),
295
+ };
296
+ const providerOptions = {
297
+ apiKey: 'test-key',
298
+ };
299
+
300
+ it('returns TEXT modality for basic text request', () => {
301
+ const result = buildGenerationConfig([textMessage], providerOptions, {
302
+ model: 'gemini-pro',
303
+ });
304
+ expect(result.responseModalities).toEqual(['TEXT']);
305
+ });
306
+
307
+ it('includes temperature and maxOutputTokens from options', () => {
308
+ const result = buildGenerationConfig([textMessage], providerOptions, {
309
+ model: 'gemini-pro',
310
+ temperature: 0.5,
311
+ maxTokens: 1000,
312
+ });
313
+ expect(result.temperature).toBe(0.5);
314
+ expect(result.maxOutputTokens).toBe(1000);
315
+ });
316
+
317
+ it('allows IMAGE modality with any model when no allowlist configured', () => {
318
+ const result = buildGenerationConfig([textMessage], providerOptions, {
319
+ model: 'gemini-pro',
320
+ google: { responseModalities: ['IMAGE'] },
321
+ });
322
+ expect(result.responseModalities).toEqual(['IMAGE']);
323
+ });
324
+
325
+ it('does not throw when IMAGE modality with an image-capable model', () => {
326
+ const result = buildGenerationConfig([textMessage], providerOptions, {
327
+ model: 'gemini-2.5-flash-image',
328
+ google: { responseModalities: ['TEXT', 'IMAGE'] },
329
+ });
330
+ expect(result.responseModalities).toEqual(['TEXT', 'IMAGE']);
331
+ });
332
+
333
+ it('uses custom imageCapableModels allowlist', () => {
334
+ const result = buildGenerationConfig(
335
+ [textMessage],
336
+ { ...providerOptions, imageCapableModels: ['custom-model'] },
337
+ {
338
+ model: 'custom-model',
339
+ google: { responseModalities: ['IMAGE'] },
340
+ },
341
+ );
342
+ expect(result.responseModalities).toEqual(['IMAGE']);
343
+ });
344
+
345
+ it('throws for non-allowlisted model when allowlist is provided', () => {
346
+ expect(() =>
347
+ buildGenerationConfig(
348
+ [textMessage],
349
+ { ...providerOptions, imageCapableModels: ['custom-model'] },
350
+ {
351
+ model: 'other-model',
352
+ google: { responseModalities: ['IMAGE'] },
353
+ },
354
+ ),
355
+ ).toThrow('Selected model "other-model" is not configured as image-capable');
356
+ });
357
+
358
+ it('handles undefined options gracefully', () => {
359
+ const result = buildGenerationConfig([textMessage], providerOptions);
360
+ expect(result.responseModalities).toEqual(['TEXT']);
361
+ expect(result.temperature).toBeUndefined();
362
+ expect(result.maxOutputTokens).toBeUndefined();
363
+ });
364
+
365
+ it('auto-detects IMAGE modality from image input', () => {
366
+ const imageMessage: TUniversalMessage = {
367
+ id: 'msg-1',
368
+ state: 'complete' as const,
369
+ role: 'user',
370
+ content: '',
371
+ parts: [{ type: 'image_inline', mimeType: 'image/png', data: 'data' }],
372
+ timestamp: new Date(),
373
+ };
374
+ const result = buildGenerationConfig([imageMessage], providerOptions, {
375
+ model: 'gemini-2.5-flash-image',
376
+ });
377
+ expect(result.responseModalities).toEqual(['TEXT', 'IMAGE']);
378
+ });
379
+
380
+ it('respects default modalities', () => {
381
+ const result = buildGenerationConfig(
382
+ [textMessage],
383
+ {
384
+ ...providerOptions,
385
+ defaultResponseModalities: ['TEXT', 'IMAGE'],
386
+ imageCapableModels: ['gemini-pro'],
387
+ },
388
+ {
389
+ model: 'gemini-pro',
390
+ },
391
+ );
392
+ expect(result.responseModalities).toEqual(['TEXT', 'IMAGE']);
393
+ });
394
+
395
+ it('passes provider structured output settings to Gemini config', () => {
396
+ const result = buildGenerationConfig(
397
+ [textMessage],
398
+ {
399
+ ...providerOptions,
400
+ responseJsonSchema: {
401
+ type: 'object',
402
+ properties: { answer: { type: 'string' } },
403
+ required: ['answer'],
404
+ },
405
+ },
406
+ { model: 'gemini-pro' },
407
+ );
408
+ expect(result.responseMimeType).toBe('application/json');
409
+ expect(result.responseJsonSchema).toEqual({
410
+ type: 'object',
411
+ properties: { answer: { type: 'string' } },
412
+ required: ['answer'],
413
+ });
414
+ });
415
+
416
+ it('rejects mutually exclusive structured output schema options', () => {
417
+ expect(() =>
418
+ buildGenerationConfig(
419
+ [textMessage],
420
+ {
421
+ ...providerOptions,
422
+ responseSchema: { type: 'OBJECT' },
423
+ responseJsonSchema: { type: 'object' },
424
+ },
425
+ { model: 'gemini-pro' },
426
+ ),
427
+ ).toThrow('mutually exclusive');
428
+ });
429
+
430
+ it('uses per-request safety settings over provider defaults', () => {
431
+ const result = buildGenerationConfig(
432
+ [textMessage],
433
+ {
434
+ ...providerOptions,
435
+ safetySettings: [{ category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_NONE' }],
436
+ },
437
+ {
438
+ model: 'gemini-pro',
439
+ google: {
440
+ safetySettings: [{ category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_ONLY_HIGH' }],
441
+ },
442
+ },
443
+ );
444
+ expect(result.safetySettings).toEqual([
445
+ { category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_ONLY_HIGH' },
446
+ ]);
447
+ });
448
+ });