Files
promptfoo--promptfoo/test/providers/google/gemini-image.test.ts
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

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: {},
},
},
},
]);
});
});
});