@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.
- package/LICENSE +21 -0
- package/dist/browser/index.d.ts +1104 -0
- package/dist/browser/index.d.ts.map +1 -0
- package/dist/browser/index.js +7 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/loggers/index.cjs +1 -0
- package/dist/loggers/index.d.ts +151 -0
- package/dist/loggers/index.d.ts.map +1 -0
- package/dist/loggers/index.js +2 -0
- package/dist/loggers/index.js.map +1 -0
- package/dist/node/anthropic/index.cjs +1 -0
- package/dist/node/anthropic/index.d.ts +158 -0
- package/dist/node/anthropic/index.d.ts.map +1 -0
- package/dist/node/anthropic/index.js +1 -0
- package/dist/node/anthropic--1vgLC-e.js +5 -0
- package/dist/node/anthropic--1vgLC-e.js.map +1 -0
- package/dist/node/anthropic-BFQ6DSCP.cjs +4 -0
- package/dist/node/bytedance/index.cjs +1 -0
- package/dist/node/bytedance/index.d.ts +74 -0
- package/dist/node/bytedance/index.d.ts.map +1 -0
- package/dist/node/bytedance/index.js +1 -0
- package/dist/node/bytedance-C_0sF_pJ.js +2 -0
- package/dist/node/bytedance-C_0sF_pJ.js.map +1 -0
- package/dist/node/bytedance-DVPxqEiC.cjs +1 -0
- package/dist/node/chunk-Bmb41Sf3.cjs +1 -0
- package/dist/node/deepseek/index.cjs +1 -0
- package/dist/node/deepseek/index.d.ts +2 -0
- package/dist/node/deepseek/index.js +1 -0
- package/dist/node/deepseek-_8Ixx7rA.js +2 -0
- package/dist/node/deepseek-_8Ixx7rA.js.map +1 -0
- package/dist/node/deepseek-oA2Y6bD0.cjs +1 -0
- package/dist/node/gemini/index.cjs +1 -0
- package/dist/node/gemini/index.d.ts +173 -0
- package/dist/node/gemini/index.d.ts.map +1 -0
- package/dist/node/gemini/index.js +1 -0
- package/dist/node/gemini-Bh2U87MY.js +4 -0
- package/dist/node/gemini-Bh2U87MY.js.map +1 -0
- package/dist/node/gemini-DSaNCxZj.cjs +3 -0
- package/dist/node/gemma/index.cjs +1 -0
- package/dist/node/gemma/index.d.ts +2 -0
- package/dist/node/gemma/index.js +1 -0
- package/dist/node/gemma-Dp_AfCUR.js +2 -0
- package/dist/node/gemma-Dp_AfCUR.js.map +1 -0
- package/dist/node/gemma-G-Pf_PnX.cjs +1 -0
- package/dist/node/google/index.cjs +1 -0
- package/dist/node/google/index.d.ts +14 -0
- package/dist/node/google/index.d.ts.map +1 -0
- package/dist/node/google/index.js +2 -0
- package/dist/node/google/index.js.map +1 -0
- package/dist/node/index-B6PnlDMd.d.ts +82 -0
- package/dist/node/index-B6PnlDMd.d.ts.map +1 -0
- package/dist/node/index-B7UvPJcI.d.ts +315 -0
- package/dist/node/index-B7UvPJcI.d.ts.map +1 -0
- package/dist/node/index-BLPOTNb5.d.ts +98 -0
- package/dist/node/index-BLPOTNb5.d.ts.map +1 -0
- package/dist/node/index-BqixM_XD.d.ts +231 -0
- package/dist/node/index-BqixM_XD.d.ts.map +1 -0
- package/dist/node/index-C3beaqKO.d.ts +231 -0
- package/dist/node/index-C3beaqKO.d.ts.map +1 -0
- package/dist/node/index-Cp2XRh9G.d.ts +82 -0
- package/dist/node/index-Cp2XRh9G.d.ts.map +1 -0
- package/dist/node/index-DSv5xruI.d.ts +98 -0
- package/dist/node/index-DSv5xruI.d.ts.map +1 -0
- package/dist/node/index-w0bV1uaP.d.ts +315 -0
- package/dist/node/index-w0bV1uaP.d.ts.map +1 -0
- package/dist/node/index.cjs +1 -0
- package/dist/node/index.d.ts +8 -0
- package/dist/node/index.js +1 -0
- package/dist/node/openai/index.cjs +1 -0
- package/dist/node/openai/index.d.ts +2 -0
- package/dist/node/openai/index.js +1 -0
- package/dist/node/openai-CRQjg4xF.js +2 -0
- package/dist/node/openai-CRQjg4xF.js.map +1 -0
- package/dist/node/openai-compatible-BYfyY5lb.cjs +1 -0
- package/dist/node/openai-compatible-Dm4Sof9e.js +2 -0
- package/dist/node/openai-compatible-Dm4Sof9e.js.map +1 -0
- package/dist/node/openai-xWC6pY7r.cjs +1 -0
- package/dist/node/qwen/index.cjs +1 -0
- package/dist/node/qwen/index.d.ts +2 -0
- package/dist/node/qwen/index.js +1 -0
- package/dist/node/qwen-ChUZobTL.js +2 -0
- package/dist/node/qwen-ChUZobTL.js.map +1 -0
- package/dist/node/qwen-CjT71vSM.cjs +1 -0
- package/package.json +157 -0
- package/src/anthropic/__tests__/abort-streaming.test.ts +199 -0
- package/src/anthropic/__tests__/model-catalog-refresh.test.ts +92 -0
- package/src/anthropic/__tests__/provider-definition.test.ts +55 -0
- package/src/anthropic/__tests__/provider.test.ts +1357 -0
- package/src/anthropic/__tests__/response-parser.test.ts +326 -0
- package/src/anthropic/index.ts +22 -0
- package/src/anthropic/message-converter.ts +181 -0
- package/src/anthropic/model-catalog-refresh.ts +128 -0
- package/src/anthropic/parsers/response-parser.ts +184 -0
- package/src/anthropic/provider-definition.ts +93 -0
- package/src/anthropic/provider.ts +290 -0
- package/src/anthropic/streaming-handler.ts +204 -0
- package/src/anthropic/types/api-types.ts +158 -0
- package/src/anthropic/types.ts +79 -0
- package/src/bytedance/http-client.test.ts +288 -0
- package/src/bytedance/http-client.ts +163 -0
- package/src/bytedance/index.ts +2 -0
- package/src/bytedance/provider.spec.ts +320 -0
- package/src/bytedance/provider.ts +171 -0
- package/src/bytedance/status-mapper.test.ts +299 -0
- package/src/bytedance/status-mapper.ts +141 -0
- package/src/bytedance/types.ts +68 -0
- package/src/deepseek/defaults.ts +4 -0
- package/src/deepseek/index.ts +22 -0
- package/src/deepseek/model-catalog-refresh.test.ts +57 -0
- package/src/deepseek/model-catalog-refresh.ts +105 -0
- package/src/deepseek/model-catalog.ts +55 -0
- package/src/deepseek/provider-definition.test.ts +109 -0
- package/src/deepseek/provider-definition.ts +132 -0
- package/src/deepseek/provider.test.ts +324 -0
- package/src/deepseek/provider.ts +298 -0
- package/src/deepseek/types.ts +37 -0
- package/src/gemini/execution-helpers.ts +233 -0
- package/src/gemini/genai-transport.test.ts +208 -0
- package/src/gemini/image-operations.test.ts +448 -0
- package/src/gemini/image-operations.ts +261 -0
- package/src/gemini/index.ts +11 -0
- package/src/gemini/message-converter.test.ts +616 -0
- package/src/gemini/message-converter.ts +140 -0
- package/src/gemini/model-catalog-refresh.test.ts +107 -0
- package/src/gemini/model-catalog-refresh.ts +92 -0
- package/src/gemini/provider-definition.test.ts +70 -0
- package/src/gemini/provider-definition.ts +78 -0
- package/src/gemini/provider-extended.test.ts +898 -0
- package/src/gemini/provider.spec.ts +216 -0
- package/src/gemini/provider.ts +279 -0
- package/src/gemini/request-converter.ts +226 -0
- package/src/gemini/tool-schema-converter.ts +78 -0
- package/src/gemini/types/api-types.ts +235 -0
- package/src/gemini/types.ts +121 -0
- package/src/gemma/index.ts +5 -0
- package/src/gemma/message-factory.ts +38 -0
- package/src/gemma/provider-definition.test.ts +43 -0
- package/src/gemma/provider-definition.ts +84 -0
- package/src/gemma/provider-projection.ts +49 -0
- package/src/gemma/provider.test.ts +628 -0
- package/src/gemma/provider.ts +308 -0
- package/src/gemma/pseudo-command-envelope.ts +58 -0
- package/src/gemma/pseudo-tool-call-projector.ts +243 -0
- package/src/gemma/pseudo-tool-call-tag-parser.ts +153 -0
- package/src/gemma/pseudo-tool-call-types.ts +31 -0
- package/src/gemma/reasoning-projector.test.ts +52 -0
- package/src/gemma/reasoning-projector.ts +144 -0
- package/src/gemma/streaming-projection.ts +79 -0
- package/src/gemma/tool-call-argument-parser.ts +126 -0
- package/src/gemma/tool-call-projector.test.ts +227 -0
- package/src/gemma/tool-call-projector.ts +264 -0
- package/src/gemma/types.ts +27 -0
- package/src/google/index.ts +11 -0
- package/src/google/provider-compat.test.ts +19 -0
- package/src/google/provider-definition.ts +6 -0
- package/src/google/provider.ts +10 -0
- package/src/google/types.ts +5 -0
- package/src/index.ts +9 -0
- package/src/openai/adapter.test.ts +494 -0
- package/src/openai/adapter.ts +145 -0
- package/src/openai/chat-completions-chat.ts +189 -0
- package/src/openai/executor-integration.test.ts +206 -0
- package/src/openai/index.ts +21 -0
- package/src/openai/interfaces/payload-logger.ts +48 -0
- package/src/openai/loggers/console-payload-logger.test.ts +173 -0
- package/src/openai/loggers/console-payload-logger.ts +94 -0
- package/src/openai/loggers/console.ts +9 -0
- package/src/openai/loggers/file-payload-logger.test.ts +238 -0
- package/src/openai/loggers/file-payload-logger.ts +112 -0
- package/src/openai/loggers/file.ts +9 -0
- package/src/openai/loggers/index.ts +12 -0
- package/src/openai/loggers/sanitize-openai-log-data.test.ts +89 -0
- package/src/openai/loggers/sanitize-openai-log-data.ts +14 -0
- package/src/openai/message-converter.ts +22 -0
- package/src/openai/model-catalog-refresh.test.ts +92 -0
- package/src/openai/model-catalog-refresh.ts +115 -0
- package/src/openai/openai-request-format.ts +92 -0
- package/src/openai/parsers/response-parser.test.ts +407 -0
- package/src/openai/parsers/response-parser.ts +47 -0
- package/src/openai/provider-definition.test.ts +75 -0
- package/src/openai/provider-definition.ts +132 -0
- package/src/openai/provider.test.ts +1402 -0
- package/src/openai/provider.ts +237 -0
- package/src/openai/responses-chat.ts +258 -0
- package/src/openai/responses-converter.ts +112 -0
- package/src/openai/responses-parser.ts +285 -0
- package/src/openai/responses-stream-utils.ts +45 -0
- package/src/openai/responses-types.ts +195 -0
- package/src/openai/streaming/stream-assembler.ts +3 -0
- package/src/openai/streaming/stream-handler.test.ts +367 -0
- package/src/openai/streaming/stream-handler.ts +119 -0
- package/src/openai/types/api-types.ts +112 -0
- package/src/openai/types.ts +194 -0
- package/src/qwen/defaults.ts +26 -0
- package/src/qwen/index.ts +5 -0
- package/src/qwen/model-catalog-refresh.test.ts +91 -0
- package/src/qwen/model-catalog-refresh.ts +97 -0
- package/src/qwen/provider-capabilities.ts +34 -0
- package/src/qwen/provider-definition.test.ts +139 -0
- package/src/qwen/provider-definition.ts +173 -0
- package/src/qwen/provider-streaming-assembly.ts +40 -0
- package/src/qwen/provider.test.ts +640 -0
- package/src/qwen/provider.ts +293 -0
- package/src/qwen/responses-chat.ts +194 -0
- package/src/qwen/responses-converter.ts +104 -0
- package/src/qwen/responses-parser.ts +299 -0
- package/src/qwen/responses-stream-utils.ts +38 -0
- package/src/qwen/types.ts +228 -0
- package/src/shared/openai-compatible/endpoint-probe.test.ts +52 -0
- package/src/shared/openai-compatible/endpoint-probe.ts +43 -0
- package/src/shared/openai-compatible/index.ts +6 -0
- package/src/shared/openai-compatible/message-converter.test.ts +111 -0
- package/src/shared/openai-compatible/message-converter.ts +84 -0
- package/src/shared/openai-compatible/native-payload-observer.test.ts +43 -0
- package/src/shared/openai-compatible/native-payload-observer.ts +26 -0
- package/src/shared/openai-compatible/response-parser.test.ts +172 -0
- package/src/shared/openai-compatible/response-parser.ts +180 -0
- package/src/shared/openai-compatible/stream-assembler.test.ts +266 -0
- package/src/shared/openai-compatible/stream-assembler.ts +248 -0
- package/src/shared/openai-compatible/types.ts +59 -0
|
@@ -0,0 +1,898 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { GeminiProvider } from './provider';
|
|
3
|
+
import type {
|
|
4
|
+
IProviderNativeRawPayloadEvent,
|
|
5
|
+
TUniversalMessage,
|
|
6
|
+
IExecutor,
|
|
7
|
+
} from '@robota-sdk/agent-core';
|
|
8
|
+
|
|
9
|
+
// Shared mock for generateContent and generateContentStream
|
|
10
|
+
const generateContentMock = vi.fn();
|
|
11
|
+
const generateContentStreamMock = vi.fn();
|
|
12
|
+
|
|
13
|
+
vi.mock('@google/genai', () => {
|
|
14
|
+
class GoogleGenAI {
|
|
15
|
+
public readonly models = {
|
|
16
|
+
generateContent: generateContentMock,
|
|
17
|
+
generateContentStream: generateContentStreamMock,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
public constructor(_options: { apiKey: string }) {}
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
GoogleGenAI,
|
|
24
|
+
Type: {
|
|
25
|
+
STRING: 'STRING',
|
|
26
|
+
NUMBER: 'NUMBER',
|
|
27
|
+
INTEGER: 'INTEGER',
|
|
28
|
+
BOOLEAN: 'BOOLEAN',
|
|
29
|
+
ARRAY: 'ARRAY',
|
|
30
|
+
OBJECT: 'OBJECT',
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function makeTextResponse(text: string) {
|
|
36
|
+
return {
|
|
37
|
+
candidates: [{ content: { parts: [{ text }] } }],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function makeImageResponse(text: string, mimeType: string, data: string) {
|
|
42
|
+
return {
|
|
43
|
+
candidates: [
|
|
44
|
+
{
|
|
45
|
+
content: {
|
|
46
|
+
parts: [{ text }, { inlineData: { mimeType, data } }],
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('GeminiProvider - chat error paths', () => {
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
generateContentMock.mockReset();
|
|
56
|
+
generateContentStreamMock.mockReset();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('throws when model and provider defaultModel are not specified', async () => {
|
|
60
|
+
const provider = new GeminiProvider({ apiKey: 'test-key' });
|
|
61
|
+
await expect(
|
|
62
|
+
provider.chat(
|
|
63
|
+
[
|
|
64
|
+
{
|
|
65
|
+
id: 'msg-1',
|
|
66
|
+
state: 'complete' as const,
|
|
67
|
+
role: 'user',
|
|
68
|
+
content: 'hello',
|
|
69
|
+
timestamp: new Date(),
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
{}, // no model
|
|
73
|
+
),
|
|
74
|
+
).rejects.toThrow('Google chat failed: Model is required');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('uses provider defaultModel when options are undefined', async () => {
|
|
78
|
+
generateContentMock.mockResolvedValue(makeTextResponse('done'));
|
|
79
|
+
const provider = new GeminiProvider({ apiKey: 'test-key', defaultModel: 'gemini-pro' });
|
|
80
|
+
const response = await provider.chat([
|
|
81
|
+
{
|
|
82
|
+
id: 'msg-1',
|
|
83
|
+
state: 'complete' as const,
|
|
84
|
+
role: 'user',
|
|
85
|
+
content: 'hello',
|
|
86
|
+
timestamp: new Date(),
|
|
87
|
+
},
|
|
88
|
+
]);
|
|
89
|
+
expect(response.content).toBe('done');
|
|
90
|
+
expect(generateContentMock.mock.calls[0]?.[0].model).toBe('gemini-pro');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('emits native Gemini request and response payloads before normalization', async () => {
|
|
94
|
+
generateContentMock.mockResolvedValue(makeTextResponse('done'));
|
|
95
|
+
const provider = new GeminiProvider({ apiKey: 'test-key', defaultModel: 'gemini-pro' });
|
|
96
|
+
const events: IProviderNativeRawPayloadEvent[] = [];
|
|
97
|
+
|
|
98
|
+
await provider.chat(
|
|
99
|
+
[
|
|
100
|
+
{
|
|
101
|
+
id: 'msg-1',
|
|
102
|
+
state: 'complete' as const,
|
|
103
|
+
role: 'user',
|
|
104
|
+
content: 'hello',
|
|
105
|
+
timestamp: new Date(),
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
{ onProviderNativeRawPayload: (event) => events.push(event) },
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
expect(events).toEqual([
|
|
112
|
+
expect.objectContaining({
|
|
113
|
+
provider: 'gemini',
|
|
114
|
+
apiSurface: 'gemini-generate-content',
|
|
115
|
+
payloadKind: 'request',
|
|
116
|
+
payload: expect.objectContaining({ model: 'gemini-pro' }),
|
|
117
|
+
}),
|
|
118
|
+
expect.objectContaining({
|
|
119
|
+
provider: 'gemini',
|
|
120
|
+
apiSurface: 'gemini-generate-content',
|
|
121
|
+
payloadKind: 'response',
|
|
122
|
+
payload: expect.objectContaining({ candidates: expect.any(Array) }),
|
|
123
|
+
}),
|
|
124
|
+
]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('passes tool messages as function responses', async () => {
|
|
128
|
+
generateContentMock.mockResolvedValue(makeTextResponse('done'));
|
|
129
|
+
const provider = new GeminiProvider({ apiKey: 'test-key' });
|
|
130
|
+
await provider.chat(
|
|
131
|
+
[
|
|
132
|
+
{
|
|
133
|
+
id: 'msg-1',
|
|
134
|
+
state: 'complete' as const,
|
|
135
|
+
role: 'assistant',
|
|
136
|
+
content: null,
|
|
137
|
+
toolCalls: [
|
|
138
|
+
{
|
|
139
|
+
id: 'call_1',
|
|
140
|
+
type: 'function',
|
|
141
|
+
function: { name: 'get_weather', arguments: '{"city":"Seoul"}' },
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
timestamp: new Date(),
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
id: 'msg-2',
|
|
148
|
+
state: 'complete' as const,
|
|
149
|
+
role: 'tool',
|
|
150
|
+
content: '{"temperature":20}',
|
|
151
|
+
toolCallId: 'call_1',
|
|
152
|
+
name: 'get_weather',
|
|
153
|
+
timestamp: new Date(),
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
{ model: 'gemini-pro' },
|
|
157
|
+
);
|
|
158
|
+
expect(generateContentMock.mock.calls[0]?.[0].contents).toEqual([
|
|
159
|
+
{
|
|
160
|
+
role: 'model',
|
|
161
|
+
parts: [
|
|
162
|
+
{
|
|
163
|
+
functionCall: {
|
|
164
|
+
id: 'call_1',
|
|
165
|
+
name: 'get_weather',
|
|
166
|
+
args: { city: 'Seoul' },
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
role: 'user',
|
|
173
|
+
parts: [
|
|
174
|
+
{
|
|
175
|
+
functionResponse: {
|
|
176
|
+
id: 'call_1',
|
|
177
|
+
name: 'get_weather',
|
|
178
|
+
response: { temperature: 20 },
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
},
|
|
183
|
+
]);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('wraps API errors in "Google chat failed" message', async () => {
|
|
187
|
+
generateContentMock.mockRejectedValue(new Error('API quota exceeded'));
|
|
188
|
+
const provider = new GeminiProvider({ apiKey: 'test-key' });
|
|
189
|
+
await expect(
|
|
190
|
+
provider.chat(
|
|
191
|
+
[
|
|
192
|
+
{
|
|
193
|
+
id: 'msg-1',
|
|
194
|
+
state: 'complete' as const,
|
|
195
|
+
role: 'user',
|
|
196
|
+
content: 'hello',
|
|
197
|
+
timestamp: new Date(),
|
|
198
|
+
},
|
|
199
|
+
],
|
|
200
|
+
{
|
|
201
|
+
model: 'gemini-pro',
|
|
202
|
+
},
|
|
203
|
+
),
|
|
204
|
+
).rejects.toThrow('Google chat failed: API quota exceeded');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('wraps non-Error API failures', async () => {
|
|
208
|
+
generateContentMock.mockRejectedValue('string error');
|
|
209
|
+
const provider = new GeminiProvider({ apiKey: 'test-key' });
|
|
210
|
+
await expect(
|
|
211
|
+
provider.chat(
|
|
212
|
+
[
|
|
213
|
+
{
|
|
214
|
+
id: 'msg-1',
|
|
215
|
+
state: 'complete' as const,
|
|
216
|
+
role: 'user',
|
|
217
|
+
content: 'hello',
|
|
218
|
+
timestamp: new Date(),
|
|
219
|
+
},
|
|
220
|
+
],
|
|
221
|
+
{
|
|
222
|
+
model: 'gemini-pro',
|
|
223
|
+
},
|
|
224
|
+
),
|
|
225
|
+
).rejects.toThrow('Google chat failed: Google API request failed');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('passes tools as function declarations', async () => {
|
|
229
|
+
generateContentMock.mockResolvedValue(makeTextResponse('done'));
|
|
230
|
+
const provider = new GeminiProvider({ apiKey: 'test-key' });
|
|
231
|
+
await provider.chat(
|
|
232
|
+
[
|
|
233
|
+
{
|
|
234
|
+
id: 'msg-1',
|
|
235
|
+
state: 'complete' as const,
|
|
236
|
+
role: 'user',
|
|
237
|
+
content: 'hello',
|
|
238
|
+
timestamp: new Date(),
|
|
239
|
+
},
|
|
240
|
+
],
|
|
241
|
+
{
|
|
242
|
+
model: 'gemini-pro',
|
|
243
|
+
tools: [
|
|
244
|
+
{
|
|
245
|
+
name: 'search',
|
|
246
|
+
description: 'Search the web',
|
|
247
|
+
parameters: { type: 'object', properties: { q: { type: 'string' } } },
|
|
248
|
+
},
|
|
249
|
+
],
|
|
250
|
+
},
|
|
251
|
+
);
|
|
252
|
+
const payload = generateContentMock.mock.calls[0]?.[0];
|
|
253
|
+
expect(payload.config.tools).toBeDefined();
|
|
254
|
+
expect(payload.config.tools[0].functionDeclarations).toHaveLength(1);
|
|
255
|
+
expect(payload.config.tools[0].functionDeclarations[0].name).toBe('search');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('throws on IMAGE modality when response lacks image part', async () => {
|
|
259
|
+
generateContentMock.mockResolvedValue(makeTextResponse('no image here'));
|
|
260
|
+
const provider = new GeminiProvider({ apiKey: 'test-key' });
|
|
261
|
+
await expect(
|
|
262
|
+
provider.chat(
|
|
263
|
+
[
|
|
264
|
+
{
|
|
265
|
+
id: 'msg-1',
|
|
266
|
+
state: 'complete' as const,
|
|
267
|
+
role: 'user',
|
|
268
|
+
content: 'create image',
|
|
269
|
+
timestamp: new Date(),
|
|
270
|
+
},
|
|
271
|
+
],
|
|
272
|
+
{
|
|
273
|
+
model: 'gemini-2.5-flash-image',
|
|
274
|
+
google: { responseModalities: ['TEXT', 'IMAGE'] },
|
|
275
|
+
},
|
|
276
|
+
),
|
|
277
|
+
).rejects.toThrow('Gemini response did not include an image part');
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe('GeminiProvider - chatStream', () => {
|
|
282
|
+
beforeEach(() => {
|
|
283
|
+
generateContentMock.mockReset();
|
|
284
|
+
generateContentStreamMock.mockReset();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('yields streaming text chunks', async () => {
|
|
288
|
+
generateContentStreamMock.mockResolvedValue(
|
|
289
|
+
(async function* () {
|
|
290
|
+
yield { text: 'Hello' };
|
|
291
|
+
yield { text: ' world' };
|
|
292
|
+
})(),
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
const provider = new GeminiProvider({ apiKey: 'test-key' });
|
|
296
|
+
const chunks: TUniversalMessage[] = [];
|
|
297
|
+
for await (const chunk of provider.chatStream(
|
|
298
|
+
[
|
|
299
|
+
{
|
|
300
|
+
id: 'msg-1',
|
|
301
|
+
state: 'complete' as const,
|
|
302
|
+
role: 'user',
|
|
303
|
+
content: 'hello',
|
|
304
|
+
timestamp: new Date(),
|
|
305
|
+
},
|
|
306
|
+
],
|
|
307
|
+
{ model: 'gemini-pro' },
|
|
308
|
+
)) {
|
|
309
|
+
chunks.push(chunk);
|
|
310
|
+
}
|
|
311
|
+
expect(chunks).toHaveLength(2);
|
|
312
|
+
expect(chunks[0]?.content).toBe('Hello');
|
|
313
|
+
expect(chunks[1]?.content).toBe(' world');
|
|
314
|
+
expect(chunks[0]?.role).toBe('assistant');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('skips empty text chunks', async () => {
|
|
318
|
+
generateContentStreamMock.mockResolvedValue(
|
|
319
|
+
(async function* () {
|
|
320
|
+
yield { text: '' };
|
|
321
|
+
yield { text: 'data' };
|
|
322
|
+
})(),
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
const provider = new GeminiProvider({ apiKey: 'test-key' });
|
|
326
|
+
const chunks: TUniversalMessage[] = [];
|
|
327
|
+
for await (const chunk of provider.chatStream(
|
|
328
|
+
[
|
|
329
|
+
{
|
|
330
|
+
id: 'msg-1',
|
|
331
|
+
state: 'complete' as const,
|
|
332
|
+
role: 'user',
|
|
333
|
+
content: 'hello',
|
|
334
|
+
timestamp: new Date(),
|
|
335
|
+
},
|
|
336
|
+
],
|
|
337
|
+
{ model: 'gemini-pro' },
|
|
338
|
+
)) {
|
|
339
|
+
chunks.push(chunk);
|
|
340
|
+
}
|
|
341
|
+
expect(chunks).toHaveLength(1);
|
|
342
|
+
expect(chunks[0]?.content).toBe('data');
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('throws when IMAGE modality is requested in stream mode', async () => {
|
|
346
|
+
const provider = new GeminiProvider({ apiKey: 'test-key' });
|
|
347
|
+
const iter = provider.chatStream(
|
|
348
|
+
[
|
|
349
|
+
{
|
|
350
|
+
id: 'msg-1',
|
|
351
|
+
state: 'complete' as const,
|
|
352
|
+
role: 'user',
|
|
353
|
+
content: 'hello',
|
|
354
|
+
timestamp: new Date(),
|
|
355
|
+
},
|
|
356
|
+
],
|
|
357
|
+
{
|
|
358
|
+
model: 'gemini-2.5-flash-image',
|
|
359
|
+
google: { responseModalities: ['IMAGE'] },
|
|
360
|
+
},
|
|
361
|
+
);
|
|
362
|
+
await expect(async () => {
|
|
363
|
+
for await (const _chunk of iter) {
|
|
364
|
+
// consume
|
|
365
|
+
}
|
|
366
|
+
}).rejects.toThrow('Google stream failed:');
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('throws when model is not specified', async () => {
|
|
370
|
+
const provider = new GeminiProvider({ apiKey: 'test-key' });
|
|
371
|
+
const iter = provider.chatStream(
|
|
372
|
+
[
|
|
373
|
+
{
|
|
374
|
+
id: 'msg-1',
|
|
375
|
+
state: 'complete' as const,
|
|
376
|
+
role: 'user',
|
|
377
|
+
content: 'hello',
|
|
378
|
+
timestamp: new Date(),
|
|
379
|
+
},
|
|
380
|
+
],
|
|
381
|
+
{}, // no model
|
|
382
|
+
);
|
|
383
|
+
await expect(async () => {
|
|
384
|
+
for await (const _chunk of iter) {
|
|
385
|
+
// consume
|
|
386
|
+
}
|
|
387
|
+
}).rejects.toThrow('Google stream failed:');
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('uses provider defaultModel in stream mode', async () => {
|
|
391
|
+
generateContentStreamMock.mockResolvedValue(
|
|
392
|
+
(async function* () {
|
|
393
|
+
yield { text: 'ok' };
|
|
394
|
+
})(),
|
|
395
|
+
);
|
|
396
|
+
const provider = new GeminiProvider({ apiKey: 'test-key', defaultModel: 'gemini-pro' });
|
|
397
|
+
const chunks: TUniversalMessage[] = [];
|
|
398
|
+
for await (const chunk of provider.chatStream([
|
|
399
|
+
{
|
|
400
|
+
id: 'msg-1',
|
|
401
|
+
state: 'complete' as const,
|
|
402
|
+
role: 'user',
|
|
403
|
+
content: 'hello',
|
|
404
|
+
timestamp: new Date(),
|
|
405
|
+
},
|
|
406
|
+
])) {
|
|
407
|
+
chunks.push(chunk);
|
|
408
|
+
}
|
|
409
|
+
expect(chunks).toHaveLength(1);
|
|
410
|
+
expect(generateContentStreamMock.mock.calls[0]?.[0].model).toBe('gemini-pro');
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it('streams chat() through onTextDelta and returns assembled text', async () => {
|
|
414
|
+
generateContentStreamMock.mockResolvedValue(
|
|
415
|
+
(async function* () {
|
|
416
|
+
yield { text: 'Hello ' };
|
|
417
|
+
yield { text: 'Gemini' };
|
|
418
|
+
})(),
|
|
419
|
+
);
|
|
420
|
+
const onTextDelta = vi.fn();
|
|
421
|
+
const provider = new GeminiProvider({ apiKey: 'test-key' });
|
|
422
|
+
const response = await provider.chat(
|
|
423
|
+
[
|
|
424
|
+
{
|
|
425
|
+
id: 'msg-1',
|
|
426
|
+
state: 'complete' as const,
|
|
427
|
+
role: 'user',
|
|
428
|
+
content: 'hello',
|
|
429
|
+
timestamp: new Date(),
|
|
430
|
+
},
|
|
431
|
+
],
|
|
432
|
+
{ model: 'gemini-pro', onTextDelta },
|
|
433
|
+
);
|
|
434
|
+
expect(response.content).toBe('Hello Gemini');
|
|
435
|
+
expect(onTextDelta).toHaveBeenNthCalledWith(1, 'Hello ');
|
|
436
|
+
expect(onTextDelta).toHaveBeenNthCalledWith(2, 'Gemini');
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it('emits ordered native Gemini stream chunks', async () => {
|
|
440
|
+
generateContentStreamMock.mockResolvedValue(
|
|
441
|
+
(async function* () {
|
|
442
|
+
yield { text: 'Hello ' };
|
|
443
|
+
yield { text: 'Gemini' };
|
|
444
|
+
})(),
|
|
445
|
+
);
|
|
446
|
+
const provider = new GeminiProvider({ apiKey: 'test-key' });
|
|
447
|
+
const events: IProviderNativeRawPayloadEvent[] = [];
|
|
448
|
+
|
|
449
|
+
await provider.chat(
|
|
450
|
+
[
|
|
451
|
+
{
|
|
452
|
+
id: 'msg-1',
|
|
453
|
+
state: 'complete' as const,
|
|
454
|
+
role: 'user',
|
|
455
|
+
content: 'hello',
|
|
456
|
+
timestamp: new Date(),
|
|
457
|
+
},
|
|
458
|
+
],
|
|
459
|
+
{
|
|
460
|
+
model: 'gemini-pro',
|
|
461
|
+
onTextDelta: vi.fn(),
|
|
462
|
+
onProviderNativeRawPayload: (event) => events.push(event),
|
|
463
|
+
},
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
expect(events.map((event) => event.payloadKind)).toEqual([
|
|
467
|
+
'request',
|
|
468
|
+
'stream_event',
|
|
469
|
+
'stream_event',
|
|
470
|
+
]);
|
|
471
|
+
expect(
|
|
472
|
+
events.filter((event) => event.payloadKind === 'stream_event').map((event) => event.sequence),
|
|
473
|
+
).toEqual([0, 1]);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it('wraps stream errors', async () => {
|
|
477
|
+
generateContentStreamMock.mockRejectedValue(new Error('network timeout'));
|
|
478
|
+
const provider = new GeminiProvider({ apiKey: 'test-key' });
|
|
479
|
+
const iter = provider.chatStream(
|
|
480
|
+
[
|
|
481
|
+
{
|
|
482
|
+
id: 'msg-1',
|
|
483
|
+
state: 'complete' as const,
|
|
484
|
+
role: 'user',
|
|
485
|
+
content: 'hello',
|
|
486
|
+
timestamp: new Date(),
|
|
487
|
+
},
|
|
488
|
+
],
|
|
489
|
+
{
|
|
490
|
+
model: 'gemini-pro',
|
|
491
|
+
},
|
|
492
|
+
);
|
|
493
|
+
await expect(async () => {
|
|
494
|
+
for await (const _chunk of iter) {
|
|
495
|
+
// consume
|
|
496
|
+
}
|
|
497
|
+
}).rejects.toThrow('Google stream failed: network timeout');
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it('passes tools in streaming request', async () => {
|
|
501
|
+
generateContentStreamMock.mockResolvedValue(
|
|
502
|
+
(async function* () {
|
|
503
|
+
yield { text: 'ok' };
|
|
504
|
+
})(),
|
|
505
|
+
);
|
|
506
|
+
const provider = new GeminiProvider({ apiKey: 'test-key' });
|
|
507
|
+
const chunks: TUniversalMessage[] = [];
|
|
508
|
+
for await (const chunk of provider.chatStream(
|
|
509
|
+
[
|
|
510
|
+
{
|
|
511
|
+
id: 'msg-1',
|
|
512
|
+
state: 'complete' as const,
|
|
513
|
+
role: 'user',
|
|
514
|
+
content: 'hello',
|
|
515
|
+
timestamp: new Date(),
|
|
516
|
+
},
|
|
517
|
+
],
|
|
518
|
+
{
|
|
519
|
+
model: 'gemini-pro',
|
|
520
|
+
tools: [
|
|
521
|
+
{
|
|
522
|
+
name: 'fn',
|
|
523
|
+
description: 'A function',
|
|
524
|
+
parameters: { type: 'object', properties: {} },
|
|
525
|
+
},
|
|
526
|
+
],
|
|
527
|
+
},
|
|
528
|
+
)) {
|
|
529
|
+
chunks.push(chunk);
|
|
530
|
+
}
|
|
531
|
+
expect(chunks).toHaveLength(1);
|
|
532
|
+
const payload = generateContentStreamMock.mock.calls[0]?.[0];
|
|
533
|
+
expect(payload.config.tools).toBeDefined();
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
describe('GeminiProvider - validateConfig and dispose', () => {
|
|
538
|
+
it('returns true when client and apiKey exist', () => {
|
|
539
|
+
const provider = new GeminiProvider({ apiKey: 'test-key' });
|
|
540
|
+
expect(provider.validateConfig()).toBe(true);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it('returns false when apiKey is empty string', () => {
|
|
544
|
+
const provider = new GeminiProvider({ apiKey: '' });
|
|
545
|
+
expect(provider.validateConfig()).toBe(false);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it('supportsTools returns true', () => {
|
|
549
|
+
const provider = new GeminiProvider({ apiKey: 'test-key' });
|
|
550
|
+
expect(provider.supportsTools()).toBe(true);
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it('dispose resolves without error', async () => {
|
|
554
|
+
const provider = new GeminiProvider({ apiKey: 'test-key' });
|
|
555
|
+
await expect(provider.dispose()).resolves.toBeUndefined();
|
|
556
|
+
});
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
describe('GeminiProvider - constructor with executor', () => {
|
|
560
|
+
it('uses executor instead of direct client', async () => {
|
|
561
|
+
const mockExecutor: IExecutor = {
|
|
562
|
+
executeChat: vi.fn().mockResolvedValue({
|
|
563
|
+
role: 'assistant',
|
|
564
|
+
content: 'executor response',
|
|
565
|
+
timestamp: new Date(),
|
|
566
|
+
}),
|
|
567
|
+
supportsTools: () => false,
|
|
568
|
+
validateConfig: () => true,
|
|
569
|
+
name: 'mock-executor',
|
|
570
|
+
version: '1.0.0',
|
|
571
|
+
};
|
|
572
|
+
const provider = new GeminiProvider({
|
|
573
|
+
apiKey: 'placeholder',
|
|
574
|
+
executor: mockExecutor,
|
|
575
|
+
});
|
|
576
|
+
const result = await provider.chat(
|
|
577
|
+
[
|
|
578
|
+
{
|
|
579
|
+
id: 'msg-1',
|
|
580
|
+
state: 'complete' as const,
|
|
581
|
+
role: 'user',
|
|
582
|
+
content: 'hello',
|
|
583
|
+
timestamp: new Date(),
|
|
584
|
+
},
|
|
585
|
+
],
|
|
586
|
+
{ model: 'gemini-pro' },
|
|
587
|
+
);
|
|
588
|
+
expect(result.content).toBe('executor response');
|
|
589
|
+
expect(mockExecutor.executeChat).toHaveBeenCalledTimes(1);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
it('delegates validateConfig to executor when configured', () => {
|
|
593
|
+
const mockExecutor: IExecutor = {
|
|
594
|
+
executeChat: vi.fn(),
|
|
595
|
+
supportsTools: () => false,
|
|
596
|
+
validateConfig: () => true,
|
|
597
|
+
name: 'mock-executor',
|
|
598
|
+
version: '1.0.0',
|
|
599
|
+
};
|
|
600
|
+
const provider = new GeminiProvider({
|
|
601
|
+
apiKey: 'placeholder',
|
|
602
|
+
executor: mockExecutor,
|
|
603
|
+
});
|
|
604
|
+
expect(provider.validateConfig()).toBe(true);
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it('propagates executor chat errors', async () => {
|
|
608
|
+
const mockExecutor: IExecutor = {
|
|
609
|
+
executeChat: vi.fn().mockRejectedValue(new Error('executor failed')),
|
|
610
|
+
supportsTools: () => false,
|
|
611
|
+
validateConfig: () => true,
|
|
612
|
+
name: 'mock-executor',
|
|
613
|
+
version: '1.0.0',
|
|
614
|
+
};
|
|
615
|
+
const provider = new GeminiProvider({
|
|
616
|
+
apiKey: 'placeholder',
|
|
617
|
+
executor: mockExecutor,
|
|
618
|
+
});
|
|
619
|
+
await expect(
|
|
620
|
+
provider.chat(
|
|
621
|
+
[
|
|
622
|
+
{
|
|
623
|
+
id: 'msg-1',
|
|
624
|
+
state: 'complete' as const,
|
|
625
|
+
role: 'user',
|
|
626
|
+
content: 'hello',
|
|
627
|
+
timestamp: new Date(),
|
|
628
|
+
},
|
|
629
|
+
],
|
|
630
|
+
{
|
|
631
|
+
model: 'gemini-pro',
|
|
632
|
+
},
|
|
633
|
+
),
|
|
634
|
+
).rejects.toThrow('executor failed');
|
|
635
|
+
});
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
describe('GeminiProvider - generateImage', () => {
|
|
639
|
+
beforeEach(() => {
|
|
640
|
+
generateContentMock.mockReset();
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
it('returns error for empty prompt', async () => {
|
|
644
|
+
const provider = new GeminiProvider({ apiKey: 'test-key' });
|
|
645
|
+
const result = await provider.generateImage({ prompt: '', model: 'gemini-2.5-flash-image' });
|
|
646
|
+
expect(result.ok).toBe(false);
|
|
647
|
+
if (!result.ok) {
|
|
648
|
+
expect(result.error.code).toBe('PROVIDER_INVALID_REQUEST');
|
|
649
|
+
expect(result.error.message).toContain('non-empty prompt');
|
|
650
|
+
}
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
it('returns error for whitespace-only prompt', async () => {
|
|
654
|
+
const provider = new GeminiProvider({ apiKey: 'test-key' });
|
|
655
|
+
const result = await provider.generateImage({ prompt: ' ', model: 'gemini-2.5-flash-image' });
|
|
656
|
+
expect(result.ok).toBe(false);
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
it('returns error for empty model', async () => {
|
|
660
|
+
const provider = new GeminiProvider({ apiKey: 'test-key' });
|
|
661
|
+
const result = await provider.generateImage({ prompt: 'a cat', model: '' });
|
|
662
|
+
expect(result.ok).toBe(false);
|
|
663
|
+
if (!result.ok) {
|
|
664
|
+
expect(result.error.code).toBe('PROVIDER_INVALID_REQUEST');
|
|
665
|
+
expect(result.error.message).toContain('non-empty model');
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
it('returns image result on success', async () => {
|
|
670
|
+
generateContentMock.mockResolvedValue(makeImageResponse('generated', 'image/png', 'imgdata'));
|
|
671
|
+
const provider = new GeminiProvider({ apiKey: 'test-key' });
|
|
672
|
+
const result = await provider.generateImage({
|
|
673
|
+
prompt: 'a cat',
|
|
674
|
+
model: 'gemini-2.5-flash-image',
|
|
675
|
+
});
|
|
676
|
+
expect(result.ok).toBe(true);
|
|
677
|
+
if (result.ok) {
|
|
678
|
+
expect(result.value.outputs).toHaveLength(1);
|
|
679
|
+
expect(result.value.outputs[0]?.mimeType).toBe('image/png');
|
|
680
|
+
expect(result.value.model).toBe('gemini-2.5-flash-image');
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
it('returns upstream error when chat fails', async () => {
|
|
685
|
+
generateContentMock.mockRejectedValue(new Error('API error'));
|
|
686
|
+
const provider = new GeminiProvider({ apiKey: 'test-key' });
|
|
687
|
+
const result = await provider.generateImage({
|
|
688
|
+
prompt: 'a cat',
|
|
689
|
+
model: 'gemini-2.5-flash-image',
|
|
690
|
+
});
|
|
691
|
+
expect(result.ok).toBe(false);
|
|
692
|
+
if (!result.ok) {
|
|
693
|
+
expect(result.error.code).toBe('PROVIDER_UPSTREAM_ERROR');
|
|
694
|
+
}
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
it('returns upstream error when response has no image parts', async () => {
|
|
698
|
+
generateContentMock.mockResolvedValue(makeTextResponse('no image'));
|
|
699
|
+
const provider = new GeminiProvider({ apiKey: 'test-key' });
|
|
700
|
+
// Need to bypass the IMAGE modality check — the response must contain image
|
|
701
|
+
// but this test validates runImageRequest when mapInlineImagePartsToMediaOutputs returns empty
|
|
702
|
+
// Since chat() will throw first due to missing image, this exercises that path
|
|
703
|
+
const result = await provider.generateImage({
|
|
704
|
+
prompt: 'a cat',
|
|
705
|
+
model: 'gemini-2.5-flash-image',
|
|
706
|
+
});
|
|
707
|
+
expect(result.ok).toBe(false);
|
|
708
|
+
if (!result.ok) {
|
|
709
|
+
expect(result.error.code).toBe('PROVIDER_UPSTREAM_ERROR');
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
describe('GeminiProvider - editImage', () => {
|
|
715
|
+
beforeEach(() => {
|
|
716
|
+
generateContentMock.mockReset();
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
it('returns error for empty prompt', async () => {
|
|
720
|
+
const provider = new GeminiProvider({ apiKey: 'test-key' });
|
|
721
|
+
const result = await provider.editImage({
|
|
722
|
+
prompt: '',
|
|
723
|
+
model: 'gemini-2.5-flash-image',
|
|
724
|
+
image: { kind: 'inline', mimeType: 'image/png', data: 'abc' },
|
|
725
|
+
});
|
|
726
|
+
expect(result.ok).toBe(false);
|
|
727
|
+
if (!result.ok) {
|
|
728
|
+
expect(result.error.message).toContain('non-empty prompt');
|
|
729
|
+
}
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
it('returns error for empty model', async () => {
|
|
733
|
+
const provider = new GeminiProvider({ apiKey: 'test-key' });
|
|
734
|
+
const result = await provider.editImage({
|
|
735
|
+
prompt: 'make it blue',
|
|
736
|
+
model: '',
|
|
737
|
+
image: { kind: 'inline', mimeType: 'image/png', data: 'abc' },
|
|
738
|
+
});
|
|
739
|
+
expect(result.ok).toBe(false);
|
|
740
|
+
if (!result.ok) {
|
|
741
|
+
expect(result.error.message).toContain('non-empty model');
|
|
742
|
+
}
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
it('returns error for invalid image source', async () => {
|
|
746
|
+
const provider = new GeminiProvider({ apiKey: 'test-key' });
|
|
747
|
+
const result = await provider.editImage({
|
|
748
|
+
prompt: 'make it blue',
|
|
749
|
+
model: 'gemini-2.5-flash-image',
|
|
750
|
+
image: { kind: 'inline', mimeType: '', data: 'abc' },
|
|
751
|
+
});
|
|
752
|
+
expect(result.ok).toBe(false);
|
|
753
|
+
if (!result.ok) {
|
|
754
|
+
expect(result.error.code).toBe('PROVIDER_INVALID_REQUEST');
|
|
755
|
+
}
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
it('returns image result on success', async () => {
|
|
759
|
+
generateContentMock.mockResolvedValue(makeImageResponse('edited', 'image/png', 'newdata'));
|
|
760
|
+
const provider = new GeminiProvider({ apiKey: 'test-key' });
|
|
761
|
+
const result = await provider.editImage({
|
|
762
|
+
prompt: 'make it blue',
|
|
763
|
+
model: 'gemini-2.5-flash-image',
|
|
764
|
+
image: { kind: 'inline', mimeType: 'image/png', data: 'original' },
|
|
765
|
+
});
|
|
766
|
+
expect(result.ok).toBe(true);
|
|
767
|
+
if (result.ok) {
|
|
768
|
+
expect(result.value.outputs).toHaveLength(1);
|
|
769
|
+
}
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
it('handles URI image source with data URI', async () => {
|
|
773
|
+
generateContentMock.mockResolvedValue(makeImageResponse('edited', 'image/jpeg', 'newdata'));
|
|
774
|
+
const provider = new GeminiProvider({ apiKey: 'test-key' });
|
|
775
|
+
const result = await provider.editImage({
|
|
776
|
+
prompt: 'crop it',
|
|
777
|
+
model: 'gemini-2.5-flash-image',
|
|
778
|
+
image: { kind: 'uri', uri: 'data:image/jpeg;base64,originaldata' },
|
|
779
|
+
});
|
|
780
|
+
expect(result.ok).toBe(true);
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
it('returns error for non-data URI image source', async () => {
|
|
784
|
+
const provider = new GeminiProvider({ apiKey: 'test-key' });
|
|
785
|
+
const result = await provider.editImage({
|
|
786
|
+
prompt: 'crop it',
|
|
787
|
+
model: 'gemini-2.5-flash-image',
|
|
788
|
+
image: { kind: 'uri', uri: 'https://example.com/img.png' },
|
|
789
|
+
});
|
|
790
|
+
expect(result.ok).toBe(false);
|
|
791
|
+
if (!result.ok) {
|
|
792
|
+
expect(result.error.code).toBe('PROVIDER_INVALID_REQUEST');
|
|
793
|
+
}
|
|
794
|
+
});
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
describe('GeminiProvider - composeImage', () => {
|
|
798
|
+
beforeEach(() => {
|
|
799
|
+
generateContentMock.mockReset();
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
it('returns error for empty prompt', async () => {
|
|
803
|
+
const provider = new GeminiProvider({ apiKey: 'test-key' });
|
|
804
|
+
const result = await provider.composeImage({
|
|
805
|
+
prompt: '',
|
|
806
|
+
model: 'gemini-2.5-flash-image',
|
|
807
|
+
images: [
|
|
808
|
+
{ kind: 'inline', mimeType: 'image/png', data: 'a' },
|
|
809
|
+
{ kind: 'inline', mimeType: 'image/png', data: 'b' },
|
|
810
|
+
],
|
|
811
|
+
});
|
|
812
|
+
expect(result.ok).toBe(false);
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
it('returns error for empty model', async () => {
|
|
816
|
+
const provider = new GeminiProvider({ apiKey: 'test-key' });
|
|
817
|
+
const result = await provider.composeImage({
|
|
818
|
+
prompt: 'merge these',
|
|
819
|
+
model: '',
|
|
820
|
+
images: [
|
|
821
|
+
{ kind: 'inline', mimeType: 'image/png', data: 'a' },
|
|
822
|
+
{ kind: 'inline', mimeType: 'image/png', data: 'b' },
|
|
823
|
+
],
|
|
824
|
+
});
|
|
825
|
+
expect(result.ok).toBe(false);
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
it('returns error for fewer than 2 images', async () => {
|
|
829
|
+
const provider = new GeminiProvider({ apiKey: 'test-key' });
|
|
830
|
+
const result = await provider.composeImage({
|
|
831
|
+
prompt: 'merge these',
|
|
832
|
+
model: 'gemini-2.5-flash-image',
|
|
833
|
+
images: [{ kind: 'inline', mimeType: 'image/png', data: 'a' }],
|
|
834
|
+
});
|
|
835
|
+
expect(result.ok).toBe(false);
|
|
836
|
+
if (!result.ok) {
|
|
837
|
+
expect(result.error.message).toContain('at least two');
|
|
838
|
+
}
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
it('returns error for empty images array', async () => {
|
|
842
|
+
const provider = new GeminiProvider({ apiKey: 'test-key' });
|
|
843
|
+
const result = await provider.composeImage({
|
|
844
|
+
prompt: 'merge these',
|
|
845
|
+
model: 'gemini-2.5-flash-image',
|
|
846
|
+
images: [],
|
|
847
|
+
});
|
|
848
|
+
expect(result.ok).toBe(false);
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
it('returns error when one of the image sources is invalid', async () => {
|
|
852
|
+
const provider = new GeminiProvider({ apiKey: 'test-key' });
|
|
853
|
+
const result = await provider.composeImage({
|
|
854
|
+
prompt: 'merge these',
|
|
855
|
+
model: 'gemini-2.5-flash-image',
|
|
856
|
+
images: [
|
|
857
|
+
{ kind: 'inline', mimeType: 'image/png', data: 'valid' },
|
|
858
|
+
{ kind: 'inline', mimeType: '', data: 'invalid' },
|
|
859
|
+
],
|
|
860
|
+
});
|
|
861
|
+
expect(result.ok).toBe(false);
|
|
862
|
+
if (!result.ok) {
|
|
863
|
+
expect(result.error.code).toBe('PROVIDER_INVALID_REQUEST');
|
|
864
|
+
}
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
it('returns composed image result on success', async () => {
|
|
868
|
+
generateContentMock.mockResolvedValue(
|
|
869
|
+
makeImageResponse('composed', 'image/png', 'composed-data'),
|
|
870
|
+
);
|
|
871
|
+
const provider = new GeminiProvider({ apiKey: 'test-key' });
|
|
872
|
+
const result = await provider.composeImage({
|
|
873
|
+
prompt: 'merge these',
|
|
874
|
+
model: 'gemini-2.5-flash-image',
|
|
875
|
+
images: [
|
|
876
|
+
{ kind: 'inline', mimeType: 'image/png', data: 'img1' },
|
|
877
|
+
{ kind: 'inline', mimeType: 'image/png', data: 'img2' },
|
|
878
|
+
],
|
|
879
|
+
});
|
|
880
|
+
expect(result.ok).toBe(true);
|
|
881
|
+
if (result.ok) {
|
|
882
|
+
expect(result.value.outputs).toHaveLength(1);
|
|
883
|
+
expect(result.value.model).toBe('gemini-2.5-flash-image');
|
|
884
|
+
}
|
|
885
|
+
});
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
describe('GeminiProvider - name and version', () => {
|
|
889
|
+
it('has name "gemini"', () => {
|
|
890
|
+
const provider = new GeminiProvider({ apiKey: 'test-key' });
|
|
891
|
+
expect(provider.name).toBe('gemini');
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
it('has version "1.0.0"', () => {
|
|
895
|
+
const provider = new GeminiProvider({ apiKey: 'test-key' });
|
|
896
|
+
expect(provider.version).toBe('1.0.0');
|
|
897
|
+
});
|
|
898
|
+
});
|