0d3cb498a3
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
1475 lines
42 KiB
TypeScript
1475 lines
42 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { fetchWithCache } from '../../../src/cache';
|
|
import { GeminiImageProvider } from '../../../src/providers/google/gemini-image';
|
|
import * as googleUtil from '../../../src/providers/google/util';
|
|
import { mockProcessEnv } from '../../util/utils';
|
|
|
|
vi.mock('../../../src/cache', () => ({
|
|
fetchWithCache: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../../src/providers/google/util', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('../../../src/providers/google/util')>();
|
|
return {
|
|
...actual,
|
|
getGoogleClient: vi.fn(),
|
|
loadCredentials: vi.fn(),
|
|
resolveProjectId: vi.fn(),
|
|
geminiFormatAndSystemInstructions: vi.fn().mockImplementation((prompt) => ({
|
|
contents: [{ parts: [{ text: prompt }], role: 'user' }],
|
|
systemInstruction: undefined,
|
|
})),
|
|
createAuthCacheDiscriminator: vi.fn().mockReturnValue(''),
|
|
normalizeTools: actual.normalizeTools,
|
|
};
|
|
});
|
|
|
|
describe('GeminiImageProvider', () => {
|
|
const mockFetchWithCache = vi.mocked(fetchWithCache);
|
|
const mockGetGoogleClient = vi.mocked(googleUtil.getGoogleClient);
|
|
const mockLoadCredentials = vi.mocked(googleUtil.loadCredentials);
|
|
const mockResolveProjectId = vi.mocked(googleUtil.resolveProjectId);
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockProcessEnv({ GOOGLE_API_KEY: 'test-api-key' });
|
|
mockProcessEnv({ GOOGLE_GENERATIVE_AI_API_KEY: undefined });
|
|
mockProcessEnv({ GEMINI_API_KEY: undefined });
|
|
mockProcessEnv({ GOOGLE_PROJECT_ID: undefined });
|
|
mockProcessEnv({ GOOGLE_CLOUD_PROJECT: undefined });
|
|
|
|
mockLoadCredentials.mockImplementation((creds) => {
|
|
if (typeof creds === 'object') {
|
|
return JSON.stringify(creds);
|
|
}
|
|
return creds;
|
|
});
|
|
mockResolveProjectId.mockResolvedValue('test-project');
|
|
});
|
|
|
|
afterEach(() => {
|
|
mockProcessEnv({ GOOGLE_API_KEY: undefined });
|
|
mockProcessEnv({ GOOGLE_PROJECT_ID: undefined });
|
|
mockProcessEnv({ GOOGLE_CLOUD_PROJECT: undefined });
|
|
mockProcessEnv({ GOOGLE_GENERATIVE_AI_API_KEY: undefined });
|
|
mockProcessEnv({ GEMINI_API_KEY: undefined });
|
|
});
|
|
|
|
it('should construct with model name', () => {
|
|
const provider = new GeminiImageProvider('gemini-3-pro-image-preview');
|
|
expect(provider.id()).toBe('google:gemini-3-pro-image-preview');
|
|
expect(provider.toString()).toBe(
|
|
'[Google Gemini Image Generation Provider gemini-3-pro-image-preview]',
|
|
);
|
|
});
|
|
|
|
it('should use Google AI Studio when API key is available', async () => {
|
|
const provider = new GeminiImageProvider('gemini-3-pro-image-preview');
|
|
|
|
mockFetchWithCache.mockResolvedValueOnce({
|
|
status: 200,
|
|
data: {
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [
|
|
{ text: 'Here is your image:' },
|
|
{
|
|
inlineData: {
|
|
mimeType: 'image/png',
|
|
data: 'base64imagedata',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
finishReason: 'STOP',
|
|
},
|
|
],
|
|
usageMetadata: {
|
|
promptTokenCount: 10,
|
|
candidatesTokenCount: 1290,
|
|
totalTokenCount: 1300,
|
|
},
|
|
},
|
|
cached: false,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const result = await provider.callApi('Generate a picture of a cat');
|
|
|
|
expect(mockFetchWithCache).toHaveBeenCalledWith(
|
|
expect.stringContaining(
|
|
'generativelanguage.googleapis.com/v1beta/models/gemini-3-pro-image-preview:generateContent',
|
|
),
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: expect.objectContaining({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: expect.stringContaining('"responseModalities":["TEXT","IMAGE"]'),
|
|
}),
|
|
expect.any(Number),
|
|
'json',
|
|
false,
|
|
);
|
|
|
|
// Text + image: text as output, images in structured field
|
|
expect(result.output).toBe('Here is your image:');
|
|
expect(result.images).toEqual([
|
|
{ data: 'data:image/png;base64,base64imagedata', mimeType: 'image/png' },
|
|
]);
|
|
});
|
|
|
|
it('should return error when both project ID and API key are missing', async () => {
|
|
mockProcessEnv({ GOOGLE_PROJECT_ID: undefined });
|
|
mockProcessEnv({ GOOGLE_API_KEY: undefined });
|
|
mockProcessEnv({ GOOGLE_GENERATIVE_AI_API_KEY: undefined });
|
|
mockProcessEnv({ GEMINI_API_KEY: undefined });
|
|
const provider = new GeminiImageProvider('gemini-3-pro-image-preview');
|
|
|
|
const result = await provider.callApi('Test prompt');
|
|
|
|
expect(result.error).toContain('Gemini image models require either:');
|
|
expect(result.error).toContain('Google AI Studio');
|
|
expect(result.error).toContain('Vertex AI');
|
|
});
|
|
|
|
it('should return error for empty prompt', async () => {
|
|
const provider = new GeminiImageProvider('gemini-3-pro-image-preview');
|
|
const result = await provider.callApi('');
|
|
|
|
expect(result.error).toBe('Prompt is required for image generation');
|
|
});
|
|
|
|
describe('Vertex AI', () => {
|
|
beforeEach(() => {
|
|
mockProcessEnv({ GOOGLE_PROJECT_ID: 'test-project' });
|
|
});
|
|
|
|
it('should use OAuth authentication for Vertex AI', async () => {
|
|
const provider = new GeminiImageProvider('gemini-3-pro-image-preview', {
|
|
config: {
|
|
projectId: 'test-project',
|
|
},
|
|
});
|
|
|
|
const mockClient = {
|
|
request: vi.fn().mockResolvedValue({
|
|
data: {
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [
|
|
{
|
|
inlineData: {
|
|
mimeType: 'image/png',
|
|
data: 'base64data',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
finishReason: 'STOP',
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
};
|
|
|
|
mockGetGoogleClient.mockResolvedValue({
|
|
client: mockClient as any,
|
|
projectId: 'test-project',
|
|
});
|
|
|
|
const result = await provider.callApi('Test prompt');
|
|
|
|
expect(mockGetGoogleClient).toHaveBeenCalled();
|
|
|
|
expect(mockClient.request).toHaveBeenCalledWith({
|
|
url: expect.stringContaining('aiplatform.googleapis.com'),
|
|
method: 'POST',
|
|
headers: expect.objectContaining({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
data: expect.objectContaining({
|
|
generationConfig: expect.objectContaining({
|
|
responseModalities: ['TEXT', 'IMAGE'],
|
|
}),
|
|
}),
|
|
timeout: 300000,
|
|
});
|
|
|
|
expect(result.output).toBe('data:image/png;base64,base64data');
|
|
});
|
|
|
|
it('should handle OAuth errors', async () => {
|
|
const provider = new GeminiImageProvider('gemini-3-pro-image-preview', {
|
|
config: {
|
|
projectId: 'test-project',
|
|
},
|
|
});
|
|
|
|
mockGetGoogleClient.mockRejectedValue(new Error('Google auth library not found'));
|
|
|
|
const result = await provider.callApi('Test prompt');
|
|
|
|
expect(result.error).toContain('Failed to call Vertex AI');
|
|
expect(result.error).toContain('Google auth library not found');
|
|
});
|
|
|
|
it('should use global endpoint with v1 for gemini-3-pro-image-preview', async () => {
|
|
const provider = new GeminiImageProvider('gemini-3-pro-image-preview', {
|
|
config: {
|
|
projectId: 'test-project',
|
|
},
|
|
});
|
|
|
|
const mockClient = {
|
|
request: vi.fn().mockResolvedValue({
|
|
data: {
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ inlineData: { mimeType: 'image/png', data: 'base64data' } }],
|
|
},
|
|
finishReason: 'STOP',
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
};
|
|
|
|
mockGetGoogleClient.mockResolvedValue({
|
|
client: mockClient as any,
|
|
projectId: 'test-project',
|
|
});
|
|
|
|
await provider.callApi('Test prompt');
|
|
|
|
expect(mockClient.request).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
url: expect.stringContaining('https://aiplatform.googleapis.com/v1/'),
|
|
}),
|
|
);
|
|
// Global endpoint uses location=global
|
|
expect(mockClient.request).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
url: expect.stringContaining('/locations/global/'),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should use global endpoint with v1 for gemini-3.1-flash-image-preview', async () => {
|
|
const provider = new GeminiImageProvider('gemini-3.1-flash-image-preview', {
|
|
config: {
|
|
projectId: 'test-project',
|
|
region: 'us-central1',
|
|
},
|
|
});
|
|
|
|
const mockClient = {
|
|
request: vi.fn().mockResolvedValue({
|
|
data: {
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ inlineData: { mimeType: 'image/png', data: 'base64data' } }],
|
|
},
|
|
finishReason: 'STOP',
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
};
|
|
|
|
mockGetGoogleClient.mockResolvedValue({
|
|
client: mockClient as any,
|
|
projectId: 'test-project',
|
|
});
|
|
|
|
await provider.callApi('Test prompt');
|
|
|
|
// Gemini 3.1 should use global endpoint, same as Gemini 3.0
|
|
expect(mockClient.request).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
url: expect.stringContaining('https://aiplatform.googleapis.com/v1/'),
|
|
}),
|
|
);
|
|
expect(mockClient.request).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
url: expect.stringContaining('/locations/global/'),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should use global endpoint with v1 for gemini-3.1-flash-lite-image', async () => {
|
|
const provider = new GeminiImageProvider('gemini-3.1-flash-lite-image', {
|
|
config: {
|
|
projectId: 'test-project',
|
|
region: 'us-central1',
|
|
},
|
|
});
|
|
|
|
const mockClient = {
|
|
request: vi.fn().mockResolvedValue({
|
|
data: {
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ inlineData: { mimeType: 'image/png', data: 'base64data' } }],
|
|
},
|
|
finishReason: 'STOP',
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
};
|
|
|
|
mockGetGoogleClient.mockResolvedValue({
|
|
client: mockClient as any,
|
|
projectId: 'test-project',
|
|
});
|
|
|
|
await provider.callApi('Test prompt');
|
|
|
|
// Nano Banana 2 Lite is a Gemini 3.x model and uses the global endpoint
|
|
expect(mockClient.request).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
url: expect.stringContaining('https://aiplatform.googleapis.com/v1/'),
|
|
}),
|
|
);
|
|
expect(mockClient.request).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
url: expect.stringContaining(
|
|
'/publishers/google/models/gemini-3.1-flash-lite-image:generateContent',
|
|
),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Image configuration', () => {
|
|
it('should pass imageAspectRatio and imageSize config for Gemini 3 models', async () => {
|
|
const provider = new GeminiImageProvider('gemini-3-pro-image-preview', {
|
|
config: {
|
|
imageAspectRatio: '16:9',
|
|
imageSize: '2K',
|
|
},
|
|
});
|
|
|
|
mockFetchWithCache.mockResolvedValueOnce({
|
|
status: 200,
|
|
data: {
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [
|
|
{
|
|
inlineData: {
|
|
mimeType: 'image/png',
|
|
data: 'base64data',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
finishReason: 'STOP',
|
|
},
|
|
],
|
|
},
|
|
cached: false,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
await provider.callApi('Test prompt');
|
|
|
|
expect(mockFetchWithCache).toHaveBeenCalledWith(
|
|
expect.any(String),
|
|
expect.objectContaining({
|
|
body: expect.stringContaining('"imageConfig"'),
|
|
}),
|
|
expect.any(Number),
|
|
'json',
|
|
false,
|
|
);
|
|
|
|
const callArgs = mockFetchWithCache.mock.calls[0];
|
|
const body = JSON.parse(callArgs[1]!.body as string);
|
|
expect(body.generationConfig.imageConfig).toEqual({
|
|
aspectRatio: '16:9',
|
|
imageSize: '2K',
|
|
});
|
|
});
|
|
|
|
it('should merge top-level options with a pass-through generationConfig.imageConfig', async () => {
|
|
const provider = new GeminiImageProvider('gemini-3.1-flash-image', {
|
|
config: {
|
|
imageAspectRatio: '16:9',
|
|
generationConfig: { imageConfig: { imageSize: '2K' } },
|
|
},
|
|
});
|
|
|
|
mockFetchWithCache.mockResolvedValueOnce({
|
|
status: 200,
|
|
data: {
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ inlineData: { mimeType: 'image/png', data: 'base64data' } }],
|
|
},
|
|
finishReason: 'STOP',
|
|
},
|
|
],
|
|
},
|
|
cached: false,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const result = await provider.callApi('Test prompt');
|
|
|
|
const callArgs = mockFetchWithCache.mock.calls[0];
|
|
const body = JSON.parse(callArgs[1]!.body as string);
|
|
// The nested imageSize must survive the top-level aspectRatio override
|
|
// and be billed at its tier.
|
|
expect(body.generationConfig.imageConfig).toEqual({
|
|
aspectRatio: '16:9',
|
|
imageSize: '2K',
|
|
});
|
|
expect(result.cost).toBe(0.101);
|
|
});
|
|
|
|
it('should pass imageSize config for gemini-3.1-flash-image-preview', async () => {
|
|
const provider = new GeminiImageProvider('gemini-3.1-flash-image-preview', {
|
|
config: {
|
|
imageAspectRatio: '1:1',
|
|
imageSize: '512px',
|
|
},
|
|
});
|
|
|
|
mockFetchWithCache.mockResolvedValueOnce({
|
|
status: 200,
|
|
data: {
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [
|
|
{
|
|
inlineData: {
|
|
mimeType: 'image/png',
|
|
data: 'base64data',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
finishReason: 'STOP',
|
|
},
|
|
],
|
|
},
|
|
cached: false,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
await provider.callApi('Test prompt');
|
|
|
|
const callArgs = mockFetchWithCache.mock.calls[0];
|
|
const body = JSON.parse(callArgs[1]!.body as string);
|
|
expect(body.generationConfig.imageConfig).toEqual({
|
|
aspectRatio: '1:1',
|
|
imageSize: '512px',
|
|
});
|
|
});
|
|
|
|
it('should pass imageSize config for gemini-3.1-flash-lite-image', async () => {
|
|
const provider = new GeminiImageProvider('gemini-3.1-flash-lite-image', {
|
|
config: {
|
|
imageAspectRatio: '16:9',
|
|
imageSize: '1K',
|
|
},
|
|
});
|
|
|
|
mockFetchWithCache.mockResolvedValueOnce({
|
|
status: 200,
|
|
data: {
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [
|
|
{
|
|
inlineData: {
|
|
mimeType: 'image/png',
|
|
data: 'base64data',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
finishReason: 'STOP',
|
|
},
|
|
],
|
|
},
|
|
cached: false,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
await provider.callApi('Test prompt');
|
|
|
|
const callArgs = mockFetchWithCache.mock.calls[0];
|
|
const body = JSON.parse(callArgs[1]!.body as string);
|
|
expect(body.generationConfig.imageConfig).toEqual({
|
|
aspectRatio: '16:9',
|
|
imageSize: '1K',
|
|
});
|
|
});
|
|
|
|
it('should NOT include imageSize for non-Gemini 3 models', async () => {
|
|
const provider = new GeminiImageProvider('gemini-2.5-flash-image', {
|
|
config: {
|
|
imageAspectRatio: '16:9',
|
|
imageSize: '1K', // This should be ignored for Gemini 2.5 models
|
|
},
|
|
});
|
|
|
|
mockFetchWithCache.mockResolvedValueOnce({
|
|
status: 200,
|
|
data: {
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [
|
|
{
|
|
inlineData: {
|
|
mimeType: 'image/png',
|
|
data: 'base64data',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
finishReason: 'STOP',
|
|
},
|
|
],
|
|
},
|
|
cached: false,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
await provider.callApi('Test prompt');
|
|
|
|
const callArgs = mockFetchWithCache.mock.calls[0];
|
|
const body = JSON.parse(callArgs[1]!.body as string);
|
|
// Should only have aspectRatio, not imageSize
|
|
expect(body.generationConfig.imageConfig).toEqual({
|
|
aspectRatio: '16:9',
|
|
});
|
|
expect(body.generationConfig.imageConfig.imageSize).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('Response handling', () => {
|
|
it('should handle blocked response', async () => {
|
|
const provider = new GeminiImageProvider('gemini-3-pro-image-preview');
|
|
|
|
mockFetchWithCache.mockResolvedValueOnce({
|
|
status: 200,
|
|
data: {
|
|
candidates: [
|
|
{
|
|
content: { parts: [] },
|
|
finishReason: 'SAFETY',
|
|
},
|
|
],
|
|
},
|
|
cached: false,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const result = await provider.callApi('Test prompt');
|
|
|
|
expect(result.error).toContain('Response was blocked with finish reason: SAFETY');
|
|
});
|
|
|
|
it('should handle prompt blocked response', async () => {
|
|
const provider = new GeminiImageProvider('gemini-3-pro-image-preview');
|
|
|
|
mockFetchWithCache.mockResolvedValueOnce({
|
|
status: 200,
|
|
data: {
|
|
candidates: [],
|
|
promptFeedback: {
|
|
blockReason: 'PROHIBITED_CONTENT',
|
|
},
|
|
},
|
|
cached: false,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const result = await provider.callApi('Test prompt');
|
|
|
|
expect(result.error).toContain('Response blocked: PROHIBITED_CONTENT');
|
|
});
|
|
|
|
it('should handle API error response', async () => {
|
|
const provider = new GeminiImageProvider('gemini-3-pro-image-preview');
|
|
|
|
mockFetchWithCache.mockResolvedValueOnce({
|
|
status: 400,
|
|
statusText: 'Bad Request',
|
|
cached: false,
|
|
data: {
|
|
error: {
|
|
message: 'Invalid request',
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await provider.callApi('Test prompt');
|
|
|
|
expect(result.error).toBe('Invalid request');
|
|
});
|
|
|
|
it('should return token usage', async () => {
|
|
const provider = new GeminiImageProvider('gemini-3-pro-image-preview');
|
|
|
|
mockFetchWithCache.mockResolvedValueOnce({
|
|
status: 200,
|
|
data: {
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [
|
|
{
|
|
inlineData: {
|
|
mimeType: 'image/png',
|
|
data: 'base64data',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
finishReason: 'STOP',
|
|
},
|
|
],
|
|
usageMetadata: {
|
|
promptTokenCount: 10,
|
|
candidatesTokenCount: 1290,
|
|
totalTokenCount: 1300,
|
|
},
|
|
},
|
|
cached: false,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const result = await provider.callApi('Test prompt');
|
|
|
|
expect(result.tokenUsage).toEqual({
|
|
prompt: 10,
|
|
completion: 1290,
|
|
total: 1300,
|
|
numRequests: 1,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Multi-image responses', () => {
|
|
it('should return all images in structured field for image-only response', async () => {
|
|
const provider = new GeminiImageProvider('gemini-3-pro-image-preview');
|
|
|
|
mockFetchWithCache.mockResolvedValueOnce({
|
|
status: 200,
|
|
data: {
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [
|
|
{ inlineData: { mimeType: 'image/png', data: 'img1data' } },
|
|
{ inlineData: { mimeType: 'image/jpeg', data: 'img2data' } },
|
|
],
|
|
},
|
|
finishReason: 'STOP',
|
|
},
|
|
],
|
|
},
|
|
cached: false,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const result = await provider.callApi('Generate two images');
|
|
|
|
// First image used as output for blob externalization
|
|
expect(result.output).toBe('data:image/png;base64,img1data');
|
|
// All images in structured field
|
|
expect(result.images).toEqual([
|
|
{ data: 'data:image/png;base64,img1data', mimeType: 'image/png' },
|
|
{ data: 'data:image/jpeg;base64,img2data', mimeType: 'image/jpeg' },
|
|
]);
|
|
});
|
|
|
|
it('should return text + multiple images with structured field', async () => {
|
|
const provider = new GeminiImageProvider('gemini-3-pro-image-preview');
|
|
|
|
mockFetchWithCache.mockResolvedValueOnce({
|
|
status: 200,
|
|
data: {
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [
|
|
{ text: 'Here are your images:' },
|
|
{ inlineData: { mimeType: 'image/png', data: 'img1data' } },
|
|
{ inlineData: { mimeType: 'image/png', data: 'img2data' } },
|
|
],
|
|
},
|
|
finishReason: 'STOP',
|
|
},
|
|
],
|
|
},
|
|
cached: false,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const result = await provider.callApi('Generate two images with description');
|
|
|
|
// Text as output
|
|
expect(result.output).toBe('Here are your images:');
|
|
// All images in structured field
|
|
expect(result.images).toEqual([
|
|
{ data: 'data:image/png;base64,img1data', mimeType: 'image/png' },
|
|
{ data: 'data:image/png;base64,img2data', mimeType: 'image/png' },
|
|
]);
|
|
});
|
|
|
|
it('should not set images field for text-only response', async () => {
|
|
const provider = new GeminiImageProvider('gemini-3-pro-image-preview');
|
|
|
|
mockFetchWithCache.mockResolvedValueOnce({
|
|
status: 200,
|
|
data: {
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ text: 'Just text, no images.' }],
|
|
},
|
|
finishReason: 'STOP',
|
|
},
|
|
],
|
|
},
|
|
cached: false,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const result = await provider.callApi('Tell me something');
|
|
|
|
expect(result.output).toBe('Just text, no images.');
|
|
expect(result.images).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('Cost calculation', () => {
|
|
it('should return correct cost for gemini-3-pro-image-preview', async () => {
|
|
const provider = new GeminiImageProvider('gemini-3-pro-image-preview');
|
|
|
|
mockFetchWithCache.mockResolvedValueOnce({
|
|
status: 200,
|
|
data: {
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [
|
|
{
|
|
inlineData: {
|
|
mimeType: 'image/png',
|
|
data: 'base64data',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
finishReason: 'STOP',
|
|
},
|
|
],
|
|
},
|
|
cached: false,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const result = await provider.callApi('Test prompt');
|
|
|
|
expect(result.cost).toBe(0.134);
|
|
});
|
|
|
|
it('should return correct cost for gemini-2.5-flash-image', async () => {
|
|
const provider = new GeminiImageProvider('gemini-2.5-flash-image');
|
|
|
|
mockFetchWithCache.mockResolvedValueOnce({
|
|
status: 200,
|
|
data: {
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [
|
|
{
|
|
inlineData: {
|
|
mimeType: 'image/png',
|
|
data: 'base64data',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
finishReason: 'STOP',
|
|
},
|
|
],
|
|
},
|
|
cached: false,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const result = await provider.callApi('Test prompt');
|
|
|
|
expect(result.cost).toBe(0.039);
|
|
});
|
|
|
|
it('should return correct cost for gemini-3.1-flash-image-preview', async () => {
|
|
const provider = new GeminiImageProvider('gemini-3.1-flash-image-preview');
|
|
|
|
mockFetchWithCache.mockResolvedValueOnce({
|
|
status: 200,
|
|
data: {
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [
|
|
{
|
|
inlineData: {
|
|
mimeType: 'image/png',
|
|
data: 'base64data',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
finishReason: 'STOP',
|
|
},
|
|
],
|
|
},
|
|
cached: false,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const result = await provider.callApi('Test prompt');
|
|
|
|
expect(result.cost).toBe(0.067);
|
|
});
|
|
|
|
it('should return correct cost for gemini-3.1-flash-lite-image (Nano Banana 2 Lite)', async () => {
|
|
const provider = new GeminiImageProvider('gemini-3.1-flash-lite-image');
|
|
|
|
mockFetchWithCache.mockResolvedValueOnce({
|
|
status: 200,
|
|
data: {
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [
|
|
{
|
|
inlineData: {
|
|
mimeType: 'image/png',
|
|
data: 'base64data',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
finishReason: 'STOP',
|
|
},
|
|
],
|
|
},
|
|
cached: false,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const result = await provider.callApi('Test prompt');
|
|
|
|
expect(result.cost).toBe(0.0336);
|
|
});
|
|
|
|
it('should return correct cost for gemini-3.1-flash-image (GA Nano Banana 2)', async () => {
|
|
const provider = new GeminiImageProvider('gemini-3.1-flash-image');
|
|
|
|
mockFetchWithCache.mockResolvedValueOnce({
|
|
status: 200,
|
|
data: {
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ inlineData: { mimeType: 'image/png', data: 'base64data' } }],
|
|
},
|
|
finishReason: 'STOP',
|
|
},
|
|
],
|
|
},
|
|
cached: false,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const result = await provider.callApi('Test prompt');
|
|
|
|
expect(result.cost).toBe(0.067);
|
|
});
|
|
|
|
it('should return correct cost for gemini-3-pro-image (GA Nano Banana Pro)', async () => {
|
|
const provider = new GeminiImageProvider('gemini-3-pro-image');
|
|
|
|
mockFetchWithCache.mockResolvedValueOnce({
|
|
status: 200,
|
|
data: {
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ inlineData: { mimeType: 'image/png', data: 'base64data' } }],
|
|
},
|
|
finishReason: 'STOP',
|
|
},
|
|
],
|
|
},
|
|
cached: false,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const result = await provider.callApi('Test prompt');
|
|
|
|
expect(result.cost).toBe(0.134);
|
|
});
|
|
|
|
it.each([
|
|
{ model: 'gemini-3.1-flash-image', imageSize: '512px', expected: 0.045 },
|
|
{ model: 'gemini-3.1-flash-image', imageSize: '1K', expected: 0.067 },
|
|
{ model: 'gemini-3.1-flash-image', imageSize: '2K', expected: 0.101 },
|
|
{ model: 'gemini-3.1-flash-image', imageSize: '4K', expected: 0.151 },
|
|
{ model: 'gemini-3-pro-image', imageSize: '2K', expected: 0.134 },
|
|
{ model: 'gemini-3-pro-image', imageSize: '4K', expected: 0.24 },
|
|
] as const)('should scale per-image cost by imageSize ($model @ $imageSize)', async ({
|
|
model,
|
|
imageSize,
|
|
expected,
|
|
}) => {
|
|
const provider = new GeminiImageProvider(model, { config: { imageSize } });
|
|
|
|
mockFetchWithCache.mockResolvedValueOnce({
|
|
status: 200,
|
|
data: {
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ inlineData: { mimeType: 'image/png', data: 'base64data' } }],
|
|
},
|
|
finishReason: 'STOP',
|
|
},
|
|
],
|
|
},
|
|
cached: false,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const result = await provider.callApi('Test prompt');
|
|
|
|
expect(result.cost).toBe(expected);
|
|
});
|
|
|
|
it('should default resolution-tiered cost to the 1K price when imageSize is unset', async () => {
|
|
const provider = new GeminiImageProvider('gemini-3.1-flash-image');
|
|
|
|
mockFetchWithCache.mockResolvedValueOnce({
|
|
status: 200,
|
|
data: {
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ inlineData: { mimeType: 'image/png', data: 'base64data' } }],
|
|
},
|
|
finishReason: 'STOP',
|
|
},
|
|
],
|
|
},
|
|
cached: false,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const result = await provider.callApi('Test prompt');
|
|
|
|
expect(result.cost).toBe(0.067);
|
|
});
|
|
|
|
it('should not report cost for cached responses', async () => {
|
|
const provider = new GeminiImageProvider('gemini-3.1-flash-image');
|
|
|
|
mockFetchWithCache.mockResolvedValueOnce({
|
|
status: 200,
|
|
data: {
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ inlineData: { mimeType: 'image/png', data: 'base64data' } }],
|
|
},
|
|
finishReason: 'STOP',
|
|
},
|
|
],
|
|
usageMetadata: { totalTokenCount: 1200 },
|
|
},
|
|
cached: true,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const result = await provider.callApi('Test prompt');
|
|
|
|
expect(result.cached).toBe(true);
|
|
expect(result.cost).toBeUndefined();
|
|
});
|
|
|
|
it('should bill prompt and text/thinking tokens on top of the per-image price', async () => {
|
|
const provider = new GeminiImageProvider('gemini-3-pro-image');
|
|
|
|
mockFetchWithCache.mockResolvedValueOnce({
|
|
status: 200,
|
|
data: {
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [
|
|
{ text: 'Here is your image.' },
|
|
{ inlineData: { mimeType: 'image/png', data: 'base64data' } },
|
|
],
|
|
},
|
|
finishReason: 'STOP',
|
|
},
|
|
],
|
|
usageMetadata: {
|
|
promptTokenCount: 1000,
|
|
candidatesTokenCount: 1130,
|
|
totalTokenCount: 2630,
|
|
thoughtsTokenCount: 500,
|
|
candidatesTokensDetails: [
|
|
{ modality: 'IMAGE', tokenCount: 1120 },
|
|
{ modality: 'TEXT', tokenCount: 10 },
|
|
],
|
|
},
|
|
},
|
|
cached: false,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const result = await provider.callApi('Test prompt');
|
|
|
|
// $0.134 per image + 1000 input tokens * $2/1M + (10 text + 500 thinking) * $12/1M
|
|
expect(result.cost).toBeCloseTo(0.134 + 0.002 + 0.00612, 10);
|
|
});
|
|
|
|
it('should price by generationConfig.imageConfig.imageSize when the top-level option is unset', async () => {
|
|
const provider = new GeminiImageProvider('gemini-3.1-flash-image', {
|
|
config: { generationConfig: { imageConfig: { imageSize: '4K' } } },
|
|
});
|
|
|
|
mockFetchWithCache.mockResolvedValueOnce({
|
|
status: 200,
|
|
data: {
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ inlineData: { mimeType: 'image/png', data: 'base64data' } }],
|
|
},
|
|
finishReason: 'STOP',
|
|
},
|
|
],
|
|
},
|
|
cached: false,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const result = await provider.callApi('Test prompt');
|
|
|
|
expect(result.cost).toBe(0.151);
|
|
});
|
|
|
|
it('should not bill thought images as final images', async () => {
|
|
const provider = new GeminiImageProvider('gemini-3.1-flash-image');
|
|
|
|
mockFetchWithCache.mockResolvedValueOnce({
|
|
status: 200,
|
|
data: {
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [
|
|
{ thought: true, inlineData: { mimeType: 'image/png', data: 'draftimage' } },
|
|
{ inlineData: { mimeType: 'image/png', data: 'finalimage' } },
|
|
],
|
|
},
|
|
finishReason: 'STOP',
|
|
},
|
|
],
|
|
},
|
|
cached: false,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const result = await provider.callApi('Test prompt');
|
|
|
|
expect(result.images).toHaveLength(1);
|
|
expect(result.images![0].data).toBe('data:image/png;base64,finalimage');
|
|
expect(result.cost).toBe(0.067);
|
|
});
|
|
});
|
|
|
|
describe('Image size validation', () => {
|
|
it('should reject unsupported image sizes for gemini-3.1-flash-lite-image', async () => {
|
|
const provider = new GeminiImageProvider('gemini-3.1-flash-lite-image', {
|
|
config: { imageSize: '4K' },
|
|
});
|
|
|
|
const result = await provider.callApi('Test prompt');
|
|
|
|
expect(result.error).toContain('only supports imageSize 1K');
|
|
expect(mockFetchWithCache).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should reject unsupported nested imageConfig sizes for gemini-3.1-flash-lite-image', async () => {
|
|
const provider = new GeminiImageProvider('gemini-3.1-flash-lite-image', {
|
|
config: { generationConfig: { imageConfig: { imageSize: '2K' } } },
|
|
});
|
|
|
|
const result = await provider.callApi('Test prompt');
|
|
|
|
expect(result.error).toContain('only supports imageSize 1K');
|
|
expect(mockFetchWithCache).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should accept 1K for gemini-3.1-flash-lite-image', async () => {
|
|
const provider = new GeminiImageProvider('gemini-3.1-flash-lite-image', {
|
|
config: { imageSize: '1K' },
|
|
});
|
|
|
|
mockFetchWithCache.mockResolvedValueOnce({
|
|
status: 200,
|
|
data: {
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ inlineData: { mimeType: 'image/png', data: 'base64data' } }],
|
|
},
|
|
finishReason: 'STOP',
|
|
},
|
|
],
|
|
},
|
|
cached: false,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const result = await provider.callApi('Test prompt');
|
|
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.cost).toBe(0.0336);
|
|
});
|
|
});
|
|
|
|
describe('API key handling', () => {
|
|
it('should support GEMINI_API_KEY environment variable', async () => {
|
|
mockProcessEnv({ GOOGLE_API_KEY: undefined });
|
|
mockProcessEnv({ GEMINI_API_KEY: 'gemini-key' });
|
|
|
|
const provider = new GeminiImageProvider('gemini-3-pro-image-preview');
|
|
|
|
mockFetchWithCache.mockResolvedValueOnce({
|
|
status: 200,
|
|
data: {
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [
|
|
{
|
|
inlineData: {
|
|
mimeType: 'image/png',
|
|
data: 'base64data',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
finishReason: 'STOP',
|
|
},
|
|
],
|
|
},
|
|
cached: false,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const result = await provider.callApi('Test prompt');
|
|
|
|
expect(mockFetchWithCache).toHaveBeenCalledWith(
|
|
expect.not.stringContaining('key='), // API key should NOT be in URL
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
'x-goog-api-key': 'gemini-key',
|
|
}),
|
|
}),
|
|
expect.any(Number),
|
|
'json',
|
|
false,
|
|
);
|
|
expect(result.output).toContain('data:image/png;base64,');
|
|
});
|
|
|
|
it('should use apiKey from config', async () => {
|
|
mockProcessEnv({ GOOGLE_API_KEY: undefined });
|
|
|
|
const provider = new GeminiImageProvider('gemini-3-pro-image-preview', {
|
|
config: {
|
|
apiKey: 'config-api-key',
|
|
},
|
|
});
|
|
|
|
mockFetchWithCache.mockResolvedValueOnce({
|
|
status: 200,
|
|
data: {
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [
|
|
{
|
|
inlineData: {
|
|
mimeType: 'image/png',
|
|
data: 'base64data',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
finishReason: 'STOP',
|
|
},
|
|
],
|
|
},
|
|
cached: false,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
await provider.callApi('Test prompt');
|
|
|
|
expect(mockFetchWithCache).toHaveBeenCalledWith(
|
|
expect.not.stringContaining('key='), // API key should NOT be in URL
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
'x-goog-api-key': 'config-api-key',
|
|
}),
|
|
}),
|
|
expect.any(Number),
|
|
'json',
|
|
false,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('API version selection', () => {
|
|
it('should use v1beta for gemini-3 models', async () => {
|
|
const provider = new GeminiImageProvider('gemini-3-pro-image-preview');
|
|
|
|
mockFetchWithCache.mockResolvedValueOnce({
|
|
status: 200,
|
|
data: {
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ inlineData: { mimeType: 'image/png', data: 'base64data' } }],
|
|
},
|
|
finishReason: 'STOP',
|
|
},
|
|
],
|
|
},
|
|
cached: false,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
await provider.callApi('Test prompt');
|
|
|
|
expect(mockFetchWithCache).toHaveBeenCalledWith(
|
|
expect.stringContaining('/v1beta/'),
|
|
expect.any(Object),
|
|
expect.any(Number),
|
|
'json',
|
|
false,
|
|
);
|
|
});
|
|
|
|
it('should use v1beta for non-gemini-3 models', async () => {
|
|
const provider = new GeminiImageProvider('gemini-2.5-flash-image');
|
|
|
|
mockFetchWithCache.mockResolvedValueOnce({
|
|
status: 200,
|
|
data: {
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ inlineData: { mimeType: 'image/png', data: 'base64data' } }],
|
|
},
|
|
finishReason: 'STOP',
|
|
},
|
|
],
|
|
},
|
|
cached: false,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
await provider.callApi('Test prompt');
|
|
|
|
expect(mockFetchWithCache).toHaveBeenCalledWith(
|
|
expect.stringContaining('/v1beta/'),
|
|
expect.any(Object),
|
|
expect.any(Number),
|
|
'json',
|
|
false,
|
|
);
|
|
});
|
|
|
|
it('should use v1beta for gemini-3.1 image models', async () => {
|
|
const provider = new GeminiImageProvider('gemini-3.1-flash-image-preview');
|
|
|
|
mockFetchWithCache.mockResolvedValueOnce({
|
|
status: 200,
|
|
data: {
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ inlineData: { mimeType: 'image/png', data: 'base64data' } }],
|
|
},
|
|
finishReason: 'STOP',
|
|
},
|
|
],
|
|
},
|
|
cached: false,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
await provider.callApi('Test prompt');
|
|
|
|
expect(mockFetchWithCache).toHaveBeenCalledWith(
|
|
expect.stringContaining('/v1beta/'),
|
|
expect.any(Object),
|
|
expect.any(Number),
|
|
'json',
|
|
false,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Grounding tools', () => {
|
|
it('should include googleSearch tool in request body', async () => {
|
|
const provider = new GeminiImageProvider('gemini-3.1-flash-image-preview', {
|
|
config: {
|
|
tools: [
|
|
{
|
|
googleSearch: {},
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
mockFetchWithCache.mockResolvedValueOnce({
|
|
status: 200,
|
|
data: {
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ inlineData: { mimeType: 'image/png', data: 'base64data' } }],
|
|
},
|
|
finishReason: 'STOP',
|
|
},
|
|
],
|
|
},
|
|
cached: false,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
await provider.callApi('Generate a grounded image');
|
|
|
|
const callArgs = mockFetchWithCache.mock.calls[0];
|
|
const body = JSON.parse(callArgs[1]!.body as string);
|
|
expect(body.tools).toEqual([{ googleSearch: {} }]);
|
|
});
|
|
|
|
it('should preserve googleSearch when function calling is disabled', async () => {
|
|
const provider = new GeminiImageProvider('gemini-3.1-flash-image-preview', {
|
|
config: {
|
|
tools: [
|
|
{
|
|
googleSearch: {},
|
|
},
|
|
],
|
|
toolConfig: {
|
|
functionCallingConfig: {
|
|
mode: 'NONE',
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
mockFetchWithCache.mockResolvedValueOnce({
|
|
status: 200,
|
|
data: {
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ inlineData: { mimeType: 'image/png', data: 'base64data' } }],
|
|
},
|
|
finishReason: 'STOP',
|
|
},
|
|
],
|
|
},
|
|
cached: false,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
await provider.callApi('Generate a grounded image');
|
|
|
|
const callArgs = mockFetchWithCache.mock.calls[0];
|
|
const body = JSON.parse(callArgs[1]!.body as string);
|
|
expect(body.toolConfig).toEqual({ functionCallingConfig: { mode: 'NONE' } });
|
|
expect(body.tools).toEqual([{ googleSearch: {} }]);
|
|
});
|
|
|
|
it('should normalize google_search tool format to googleSearch', async () => {
|
|
const provider = new GeminiImageProvider('gemini-3.1-flash-image-preview', {
|
|
config: {
|
|
tools: [
|
|
{
|
|
google_search: {
|
|
searchTypes: {
|
|
webSearch: {},
|
|
imageSearch: {},
|
|
},
|
|
},
|
|
} as any,
|
|
],
|
|
},
|
|
});
|
|
|
|
mockFetchWithCache.mockResolvedValueOnce({
|
|
status: 200,
|
|
data: {
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ inlineData: { mimeType: 'image/png', data: 'base64data' } }],
|
|
},
|
|
finishReason: 'STOP',
|
|
},
|
|
],
|
|
},
|
|
cached: false,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
await provider.callApi('Generate a grounded image');
|
|
|
|
const callArgs = mockFetchWithCache.mock.calls[0];
|
|
const body = JSON.parse(callArgs[1]!.body as string);
|
|
expect(body.tools).toMatchObject([
|
|
{
|
|
googleSearch: {
|
|
searchTypes: {
|
|
webSearch: {},
|
|
imageSearch: {},
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
});
|