Files
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

1408 lines
46 KiB
TypeScript

import * as fs from 'fs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as cache from '../../../src/cache';
import cliState from '../../../src/cliState';
import { GoogleProvider } from '../../../src/providers/google/provider';
import * as util from '../../../src/providers/google/util';
import * as fetchUtil from '../../../src/util/fetch/index';
import { getNunjucksEngineForFilePath } from '../../../src/util/file';
import * as templates from '../../../src/util/templates';
import { mockProcessEnv } from '../../util/utils';
vi.mock('../../../src/cache', async (importOriginal) => {
return {
...(await importOriginal()),
fetchWithCache: vi.fn(),
};
});
vi.mock('../../../src/util/fetch/index', async (importOriginal) => {
return {
...(await importOriginal()),
fetchWithProxy: vi.fn(),
};
});
vi.mock('../../../src/providers/google/util', async () => ({
...(await vi.importActual('../../../src/providers/google/util')),
maybeCoerceToGeminiFormat: vi.fn(),
getGoogleClient: vi.fn().mockResolvedValue({
client: {
request: vi.fn().mockResolvedValue({
data: {
candidates: [{ content: { parts: [{ text: 'test response' }] } }],
usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 20, totalTokenCount: 30 },
},
}),
},
credentials: {},
}),
loadCredentials: vi.fn().mockReturnValue({}),
createAuthCacheDiscriminator: vi.fn().mockReturnValue(''),
}));
vi.mock('../../../src/util/templates', async (importOriginal) => {
return {
...(await importOriginal()),
getNunjucksEngine: vi.fn(() => ({
renderString: vi.fn((str) => str),
})),
};
});
// Hoisted mocks for file loading functions
const mockMaybeLoadToolsFromExternalFile = vi.hoisted(() => vi.fn((input) => input));
const mockMaybeLoadFromExternalFile = vi.hoisted(() => vi.fn((input) => input));
vi.mock('../../../src/util/file', async (importOriginal) => {
return {
...(await importOriginal()),
getNunjucksEngineForFilePath: vi.fn(),
maybeLoadToolsFromExternalFile: mockMaybeLoadToolsFromExternalFile,
maybeLoadFromExternalFile: mockMaybeLoadFromExternalFile,
};
});
// Also mock the barrel file since the provider imports from util/index
vi.mock('../../../src/util/index', async (importOriginal) => {
return {
...(await importOriginal()),
maybeLoadToolsFromExternalFile: mockMaybeLoadToolsFromExternalFile,
};
});
vi.mock('glob', async (importOriginal) => {
return {
...(await importOriginal()),
globSync: vi.fn().mockReturnValue([]),
};
});
// Mock envars to control API key availability in tests
vi.mock('../../../src/envars', async (importOriginal) => {
return {
...(await importOriginal()),
getEnvString: vi.fn().mockReturnValue(undefined),
};
});
vi.mock('fs', async (importOriginal) => {
return {
...(await importOriginal()),
existsSync: vi.fn(),
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
statSync: vi.fn(),
};
});
describe('GoogleProvider', () => {
beforeEach(() => {
cliState.config = undefined;
vi.clearAllMocks();
// Reset hoisted mocks to default pass-through behavior
mockMaybeLoadToolsFromExternalFile.mockReset().mockImplementation((input) => input);
mockMaybeLoadFromExternalFile.mockReset().mockImplementation((input) => input);
vi.mocked(templates.getNunjucksEngine).mockImplementation(function () {
return {
renderString: vi.fn((str) => str),
} as any;
});
vi.mocked(fs.existsSync).mockReset();
vi.mocked(fs.readFileSync).mockReset();
vi.mocked(fs.writeFileSync).mockReset();
vi.mocked(fs.statSync).mockReset();
vi.mocked(getNunjucksEngineForFilePath).mockImplementation(function () {
return {
renderString: vi.fn((str) => str),
} as any;
});
});
describe('constructor and mode determination', () => {
it('should default to AI Studio mode (vertexai: false)', () => {
const provider = new GoogleProvider('gemini-pro', {
config: { apiKey: 'test-key' },
});
expect(provider.id()).toBe('google:gemini-pro');
expect((provider as any).isVertexMode).toBe(false);
});
it('should use Vertex AI mode when vertexai: true', () => {
const provider = new GoogleProvider('gemini-pro', {
config: { vertexai: true, projectId: 'my-project' },
});
expect(provider.id()).toBe('vertex:gemini-pro');
expect((provider as any).isVertexMode).toBe(true);
});
it('should detect Vertex mode from projectId presence', () => {
const provider = new GoogleProvider('gemini-pro', {
config: { projectId: 'my-project' },
});
expect(provider.id()).toBe('vertex:gemini-pro');
expect((provider as any).isVertexMode).toBe(true);
});
it('should detect Vertex mode from credentials presence', () => {
const provider = new GoogleProvider('gemini-pro', {
config: {
credentials: JSON.stringify({ client_email: 'test@test.iam.gserviceaccount.com' }),
},
});
expect(provider.id()).toBe('vertex:gemini-pro');
expect((provider as any).isVertexMode).toBe(true);
});
it('should respect explicit vertexai: false even with projectId', () => {
const provider = new GoogleProvider('gemini-pro', {
config: {
vertexai: false,
projectId: 'my-project',
apiKey: 'test-key',
},
});
expect(provider.id()).toBe('google:gemini-pro');
expect((provider as any).isVertexMode).toBe(false);
});
});
describe('AI Studio mode', () => {
let provider: GoogleProvider;
beforeEach(() => {
provider = new GoogleProvider('gemini-pro', {
config: {
apiKey: 'test-key',
temperature: 0.7,
maxOutputTokens: 100,
},
});
});
it('should resolve API endpoint correctly', () => {
const endpoint = provider.getApiEndpoint('generateContent');
expect(endpoint).toContain('/v1beta/models/gemini-pro:generateContent');
expect(endpoint).toContain('generativelanguage.googleapis.com');
});
it('should use v1alpha for thinking models', () => {
const thinkingProvider = new GoogleProvider('gemini-2.0-flash-thinking-exp', {
config: { apiKey: 'test-key' },
});
const endpoint = thinkingProvider.getApiEndpoint('generateContent');
expect(endpoint).toContain('/v1alpha/');
});
it('should use v1beta for gemini-3 models', () => {
// Regression: dash-named gemini-3-* models resolve to v1beta, the same
// as dotted gemini-3.x IDs. v1beta is Google's primary Gemini 3 endpoint.
const gemini3Provider = new GoogleProvider('gemini-3-pro', {
config: { apiKey: 'test-key' },
});
const endpoint = gemini3Provider.getApiEndpoint('generateContent');
expect(endpoint).toContain('/v1beta/');
});
it('should use v1beta for gemini-3.1 models', () => {
const gemini31Provider = new GoogleProvider('gemini-3.1-pro-preview', {
config: { apiKey: 'test-key' },
});
const endpoint = gemini31Provider.getApiEndpoint('generateContent');
expect(endpoint).toContain('/v1beta/');
});
it('should allow explicit apiVersion override in AI Studio mode', () => {
// Test that config.apiVersion takes precedence over auto-detection
const overrideProvider = new GoogleProvider('gemini-pro', {
config: {
apiKey: 'test-key',
apiVersion: 'v1', // Override default v1beta
},
});
const endpoint = overrideProvider.getApiEndpoint('generateContent');
expect(endpoint).toContain('/v1/models/gemini-pro:generateContent');
expect(endpoint).not.toContain('v1beta');
});
it('should use custom apiHost in endpoint', () => {
const customProvider = new GoogleProvider('gemini-pro', {
config: {
apiKey: 'test-key',
apiHost: 'custom.host.com',
},
});
const endpoint = customProvider.getApiEndpoint('generateContent');
expect(endpoint).toContain('https://custom.host.com');
});
it('should pass API key in x-goog-api-key header', async () => {
vi.mocked(cache.fetchWithCache).mockResolvedValueOnce({
data: {
candidates: [{ content: { parts: [{ text: 'test response' }] } }],
usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 20, totalTokenCount: 30 },
},
cached: false,
status: 200,
statusText: 'OK',
});
await provider.callApi('test prompt');
const calledUrl = vi.mocked(cache.fetchWithCache).mock.calls[0][0] as string;
expect(calledUrl).not.toContain('?key=');
expect(calledUrl).not.toContain('&key=');
const calledOptions = vi.mocked(cache.fetchWithCache).mock.calls[0][1] as any;
expect(calledOptions.headers['x-goog-api-key']).toBe('test-key');
});
it('should throw error when API key is missing in AI Studio mode', async () => {
// Delete all possible API key env vars
mockProcessEnv({ GEMINI_API_KEY: undefined });
mockProcessEnv({ GOOGLE_API_KEY: undefined });
mockProcessEnv({ PALM_API_KEY: undefined });
mockProcessEnv({ VERTEX_API_KEY: undefined });
// Also delete project-related env vars that would trigger Vertex mode detection
mockProcessEnv({ GOOGLE_PROJECT_ID: undefined });
mockProcessEnv({ VERTEX_PROJECT_ID: undefined });
mockProcessEnv({ GOOGLE_CLOUD_PROJECT: undefined });
mockProcessEnv({ GOOGLE_GENAI_USE_VERTEXAI: undefined });
// Explicitly set vertexai: false to ensure AI Studio mode regardless of env vars
const noKeyProvider = new GoogleProvider('gemini-pro', {
config: { vertexai: false },
});
await expect(noKeyProvider.callApi('test prompt')).rejects.toThrow(
'Google API key is not set. Set the GOOGLE_API_KEY or GEMINI_API_KEY environment variable or add `apiKey` to the provider config.',
);
});
it('should call API and return response', async () => {
vi.mocked(cache.fetchWithCache).mockResolvedValueOnce({
data: {
candidates: [{ content: { parts: [{ text: 'Hello, world!' }] } }],
usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5, totalTokenCount: 15 },
},
cached: false,
status: 200,
statusText: 'OK',
});
const result = await provider.callApi('test prompt');
expect(result.error).toBeUndefined();
expect(result.output).toBe('Hello, world!');
expect(result.tokenUsage).toEqual({
prompt: 10,
completion: 5,
total: 15,
numRequests: 1,
});
});
it('should handle safety blocked response', async () => {
vi.mocked(cache.fetchWithCache).mockResolvedValueOnce({
data: {
promptFeedback: {
blockReason: 'SAFETY',
safetyRatings: [{ category: 'HARM_CATEGORY_HARASSMENT', probability: 'HIGH' }],
},
usageMetadata: { promptTokenCount: 10, totalTokenCount: 10 },
},
cached: false,
status: 200,
statusText: 'OK',
});
const result = await provider.callApi('test prompt');
expect(result.guardrails?.flagged).toBe(true);
expect(result.guardrails?.flaggedInput).toBe(true);
});
});
describe('Vertex AI mode', () => {
describe('OAuth mode', () => {
let provider: GoogleProvider;
beforeEach(() => {
provider = new GoogleProvider('gemini-pro', {
config: {
vertexai: true,
projectId: 'my-project',
region: 'us-central1',
},
});
});
it('should resolve API endpoint correctly', () => {
const endpoint = provider.getApiEndpoint('generateContent');
expect(endpoint).toContain('us-central1-aiplatform.googleapis.com');
expect(endpoint).toContain('/publishers/google/models/gemini-pro:generateContent');
});
it('should use global region endpoint when region is global', () => {
const globalProvider = new GoogleProvider('gemini-pro', {
config: {
vertexai: true,
projectId: 'my-project',
region: 'global',
},
});
expect(globalProvider.getApiHost()).toBe('aiplatform.googleapis.com');
});
it('should call API using Google client for OAuth mode', async () => {
const getProjectIdSpy = vi
.spyOn(provider as any, 'getProjectId')
.mockResolvedValue('my-project');
try {
await provider.callApi('test prompt');
} finally {
getProjectIdSpy.mockRestore();
}
expect(vi.mocked(util.getGoogleClient)).toHaveBeenCalled();
});
});
describe('Express mode', () => {
let provider: GoogleProvider;
beforeEach(() => {
provider = new GoogleProvider('gemini-pro', {
config: {
vertexai: true,
apiKey: 'vertex-api-key',
// expressMode not needed - automatic when API key is present
},
});
});
it('should use express mode automatically when API key is present', () => {
// Express mode is invisible to users - just provide an API key and it works
expect((provider as any).isExpressMode()).toBe(true);
});
it('should not use express mode when no API key is available', () => {
const noApiKeyProvider = new GoogleProvider('gemini-pro', {
config: {
vertexai: true,
projectId: 'my-project',
// No API key - will use OAuth/ADC
},
});
expect((noApiKeyProvider as any).isExpressMode()).toBe(false);
});
it('should not use express mode when expressMode: false (opt-out)', () => {
// Users can explicitly opt-out if they need OAuth features
const noExpressProvider = new GoogleProvider('gemini-pro', {
config: {
vertexai: true,
apiKey: 'vertex-api-key',
expressMode: false,
},
});
expect((noExpressProvider as any).isExpressMode()).toBe(false);
});
it('should pass API key in header for express mode', async () => {
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({
candidates: [{ content: { parts: [{ text: 'test response' }] } }],
usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 20, totalTokenCount: 30 },
}),
};
vi.mocked(fetchUtil.fetchWithProxy).mockResolvedValueOnce(mockResponse as any);
await provider.callApi('test prompt');
const calledUrl = vi.mocked(fetchUtil.fetchWithProxy).mock.calls[0][0] as string;
expect(calledUrl).not.toContain('?key=');
expect(calledUrl).not.toContain('&key=');
const calledOptions = vi.mocked(fetchUtil.fetchWithProxy).mock.calls[0][1] as any;
expect(calledOptions.headers['x-goog-api-key']).toBe('vertex-api-key');
});
it('should use aiplatform.googleapis.com endpoint for express mode', async () => {
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({
candidates: [{ content: { parts: [{ text: 'test response' }] } }],
usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 20, totalTokenCount: 30 },
}),
};
vi.mocked(fetchUtil.fetchWithProxy).mockResolvedValueOnce(mockResponse as any);
await provider.callApi('test prompt');
const calledUrl = vi.mocked(fetchUtil.fetchWithProxy).mock.calls[0][0] as string;
expect(calledUrl).toContain('aiplatform.googleapis.com');
expect(calledUrl).toContain('/publishers/google/models/gemini-pro:generateContent');
});
});
});
describe('response parsing', () => {
let provider: GoogleProvider;
beforeEach(() => {
provider = new GoogleProvider('gemini-pro', {
config: { apiKey: 'test-key' },
});
});
it('should handle cached response', async () => {
vi.mocked(cache.fetchWithCache).mockResolvedValueOnce({
data: {
candidates: [{ content: { parts: [{ text: 'cached response' }] } }],
usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5, totalTokenCount: 15 },
},
cached: true,
status: 200,
statusText: 'OK',
});
const result = await provider.callApi('test prompt');
expect(result.cached).toBe(true);
expect(result.tokenUsage).toEqual({
cached: 15,
total: 15,
numRequests: 0,
});
});
it('should extract grounding metadata from response', async () => {
vi.mocked(cache.fetchWithCache).mockResolvedValueOnce({
data: {
candidates: [
{
content: { parts: [{ text: 'response with grounding' }] },
groundingMetadata: { searchQueries: ['test query'] },
webSearchQueries: ['test search'],
},
],
usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5, totalTokenCount: 15 },
},
cached: false,
status: 200,
statusText: 'OK',
});
const result = await provider.callApi('test prompt');
expect(result.metadata?.groundingMetadata).toEqual({ searchQueries: ['test query'] });
expect(result.metadata?.webSearchQueries).toEqual(['test search']);
});
it('should handle Model Armor block reason', async () => {
vi.mocked(cache.fetchWithCache).mockResolvedValueOnce({
data: {
promptFeedback: {
blockReason: 'MODEL_ARMOR',
blockReasonMessage: 'Content blocked by Model Armor',
},
usageMetadata: { promptTokenCount: 10, totalTokenCount: 10 },
},
cached: false,
status: 200,
statusText: 'OK',
});
const result = await provider.callApi('test prompt');
expect(result.guardrails?.flagged).toBe(true);
expect(result.metadata?.modelArmor?.blockReason).toBe('MODEL_ARMOR');
});
it('should handle MAX_TOKENS finish reason as success', async () => {
vi.mocked(cache.fetchWithCache).mockResolvedValueOnce({
data: {
candidates: [
{
content: { parts: [{ text: 'truncated response' }] },
finishReason: 'MAX_TOKENS',
},
],
usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 100, totalTokenCount: 110 },
},
cached: false,
status: 200,
statusText: 'OK',
});
const result = await provider.callApi('test prompt');
expect(result.error).toBeUndefined();
expect(result.output).toBe('truncated response');
});
it('should ignore metadata-only chunks in streaming responses', async () => {
vi.mocked(cache.fetchWithCache).mockResolvedValueOnce({
data: [
{
candidates: [{ content: { parts: [{ text: 'streamed response' }] } }],
},
{
usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5, totalTokenCount: 15 },
},
],
cached: false,
status: 200,
statusText: 'OK',
});
const result = await provider.callApi('test prompt');
expect(result.error).toBeUndefined();
expect(result.output).toBe('streamed response');
expect(result.tokenUsage).toEqual({
prompt: 10,
completion: 5,
total: 15,
numRequests: 1,
});
});
it('should preserve prompt safety ratings from separate streaming chunks', async () => {
vi.mocked(cache.fetchWithCache).mockResolvedValueOnce({
data: [
{
promptFeedback: {
safetyRatings: [{ category: 'HARM_CATEGORY_HARASSMENT', probability: 'HIGH' }],
},
},
{
candidates: [
{
content: { parts: [{ text: 'streamed response' }] },
safetyRatings: [
{ category: 'HARM_CATEGORY_HARASSMENT', probability: 'NEGLIGIBLE' },
],
},
],
},
{
usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5, totalTokenCount: 15 },
},
],
cached: false,
status: 200,
statusText: 'OK',
});
const result = await provider.callApi('test prompt');
expect(result.error).toBeUndefined();
expect(result.output).toBe('streamed response');
expect(result.guardrails).toEqual({
flaggedInput: true,
flaggedOutput: false,
flagged: true,
});
});
it('should accumulate incremental chunks in streaming responses', async () => {
vi.mocked(cache.fetchWithCache).mockResolvedValueOnce({
data: [
{
candidates: [{ content: { parts: [{ text: 'Hello ' }] } }],
},
{
candidates: [{ content: { parts: [{ text: 'world' }] } }],
},
{
usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 2, totalTokenCount: 12 },
},
],
cached: false,
status: 200,
statusText: 'OK',
});
const result = await provider.callApi('test prompt');
expect(result.error).toBeUndefined();
expect(result.output).toBe('Hello world');
});
it('should not mutate raw multipart chunks when accumulating streaming responses', async () => {
const firstPart = { functionCall: { name: 'look_up', args: { query: 'weather' } } };
const firstChunk = {
candidates: [{ content: { parts: [firstPart] } }],
};
const responseData = [
firstChunk,
{
candidates: [{ content: { parts: [{ text: 'done' }] } }],
},
{
usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 2, totalTokenCount: 12 },
},
];
vi.mocked(cache.fetchWithCache).mockResolvedValueOnce({
data: responseData,
cached: false,
status: 200,
statusText: 'OK',
});
const result = await provider.callApi('test prompt');
expect(result.error).toBeUndefined();
expect(result.output).toEqual([firstPart, { text: 'done' }]);
expect(firstChunk.candidates[0].content.parts).toEqual([firstPart]);
expect(result.raw).toBe(responseData);
});
it('should reject streaming responses that never provide output', async () => {
vi.mocked(cache.fetchWithCache).mockResolvedValueOnce({
data: [
{
usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 0, totalTokenCount: 10 },
},
],
cached: false,
status: 200,
statusText: 'OK',
});
const result = await provider.callApi('test prompt');
expect(result.error).toContain('No output found in response');
});
it('should return an error for safety finish reasons outside scorable evaluations', async () => {
const responseData = {
candidates: [{ content: { parts: [{ text: '' }] }, finishReason: 'SAFETY' }],
usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 0, totalTokenCount: 10 },
};
vi.mocked(cache.fetchWithCache).mockResolvedValueOnce({
data: responseData,
cached: true,
status: 200,
statusText: 'OK',
});
const result = await provider.callApi('test prompt');
expect(result).toEqual(
expect.objectContaining({
error: 'Content was blocked due to safety settings with finish reason: SAFETY.',
guardrails: expect.objectContaining({
flagged: true,
flaggedOutput: true,
}),
cached: true,
raw: responseData,
}),
);
});
it('should return safety finish reasons as output during redteam evaluations', async () => {
cliState.config = { redteam: {} } as any;
vi.mocked(cache.fetchWithCache).mockResolvedValueOnce({
data: {
candidates: [{ content: { parts: [{ text: '' }] }, finishReason: 'SAFETY' }],
usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 0, totalTokenCount: 10 },
},
cached: false,
status: 200,
statusText: 'OK',
});
const result = await provider.callApi('test prompt');
expect(result.error).toBeUndefined();
expect(result.output).toBe(
'Content was blocked due to safety settings with finish reason: SAFETY.',
);
expect(result.guardrails?.flagged).toBe(true);
expect(result.cached).toBe(false);
expect(result.raw).toEqual({
candidates: [{ content: { parts: [{ text: '' }] }, finishReason: 'SAFETY' }],
usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 0, totalTokenCount: 10 },
});
});
it('should expose safety finish reasons to guardrails assertions', async () => {
vi.mocked(cache.fetchWithCache).mockResolvedValueOnce({
data: {
candidates: [{ content: { parts: [{ text: '' }] }, finishReason: 'SAFETY' }],
usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 0, totalTokenCount: 10 },
},
cached: false,
status: 200,
statusText: 'OK',
});
const result = await provider.callApi('test prompt', {
prompt: { raw: 'test prompt', label: 'test prompt' },
vars: {},
test: { assert: [{ type: 'not-guardrails' }] },
} as any);
expect(result.error).toBeUndefined();
expect(result.output).toBe(
'Content was blocked due to safety settings with finish reason: SAFETY.',
);
expect(result.guardrails?.flagged).toBe(true);
});
it('should expose safety finish reasons for exported redteam test metadata', async () => {
vi.mocked(cache.fetchWithCache).mockResolvedValueOnce({
data: {
candidates: [{ content: { parts: [{ text: '' }] }, finishReason: 'SAFETY' }],
usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 0, totalTokenCount: 10 },
},
cached: false,
status: 200,
statusText: 'OK',
});
const result = await provider.callApi('test prompt', {
prompt: { raw: 'test prompt', label: 'test prompt' },
vars: {},
test: {
metadata: { pluginId: 'ascii-smuggling', goal: 'exfiltrate data' },
},
} as any);
expect(result.error).toBeUndefined();
expect(result.output).toBe(
'Content was blocked due to safety settings with finish reason: SAFETY.',
);
expect(result.guardrails?.flagged).toBe(true);
});
it('should expose safety finish reasons for nested redteam assertions', async () => {
vi.mocked(cache.fetchWithCache).mockResolvedValueOnce({
data: {
candidates: [{ content: { parts: [{ text: '' }] }, finishReason: 'SAFETY' }],
usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 0, totalTokenCount: 10 },
},
cached: false,
status: 200,
statusText: 'OK',
});
const result = await provider.callApi('test prompt', {
prompt: { raw: 'test prompt', label: 'test prompt' },
vars: {},
test: {
assert: [
{
type: 'assert-set',
assert: [{ type: 'promptfoo:redteam:ascii-smuggling' }],
},
],
},
} as any);
expect(result.error).toBeUndefined();
expect(result.output).toBe(
'Content was blocked due to safety settings with finish reason: SAFETY.',
);
expect(result.guardrails?.flagged).toBe(true);
});
it('should handle thinking tokens in response', async () => {
vi.mocked(cache.fetchWithCache).mockResolvedValueOnce({
data: {
candidates: [{ content: { parts: [{ text: 'thinking response' }] } }],
usageMetadata: {
promptTokenCount: 10,
candidatesTokenCount: 20,
totalTokenCount: 30,
thoughtsTokenCount: 100,
},
},
cached: false,
status: 200,
statusText: 'OK',
});
const result = await provider.callApi('test prompt');
expect(result.tokenUsage?.completionDetails?.reasoning).toBe(100);
});
});
describe('cost calculation', () => {
it('should return cost for AI Studio mode with known model', async () => {
const provider = new GoogleProvider('gemini-pro', {
config: { apiKey: 'test-key' },
});
vi.mocked(cache.fetchWithCache).mockResolvedValueOnce({
data: {
candidates: [{ content: { parts: [{ text: 'response' }] } }],
usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5, totalTokenCount: 15 },
},
cached: false,
status: 200,
statusText: 'OK',
});
const result = await provider.callApi('test prompt');
// gemini-pro: input=0.5/1e6, output=1.5/1e6
// cost = 0.5e-6 * 10 + 1.5e-6 * 5 = 1.25e-5
expect(result.cost).toBeCloseTo(1.25e-5, 10);
});
it('should return cost for Vertex AI mode with known model', async () => {
const provider = new GoogleProvider('gemini-pro', {
config: { vertexai: true, apiKey: 'test-vertex-key' },
});
vi.mocked(fetchUtil.fetchWithProxy).mockResolvedValueOnce({
ok: true,
json: vi.fn().mockResolvedValue({
candidates: [{ content: { parts: [{ text: 'response' }] } }],
usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 20, totalTokenCount: 30 },
}),
} as any);
const result = await provider.callApi('test prompt');
// gemini-pro: input 0.5/1e6, output 1.5/1e6
// 10 prompt + 20 completion = 0.000035
expect(result.cost).toBeCloseTo(0.000035, 10);
});
it('should use Vertex-specific pricing when it differs from AI Studio', async () => {
const provider = new GoogleProvider('gemini-2.0-flash', {
config: { vertexai: true, apiKey: 'test-vertex-key' },
});
vi.mocked(fetchUtil.fetchWithProxy).mockResolvedValueOnce({
ok: true,
json: vi.fn().mockResolvedValue({
candidates: [{ content: { parts: [{ text: 'response' }] } }],
usageMetadata: {
promptTokenCount: 1000,
candidatesTokenCount: 500,
totalTokenCount: 1500,
},
}),
} as any);
const result = await provider.callApi('test prompt');
// Vertex pricing for gemini-2.0-flash: input $0.15/1M, output $0.60/1M
// 1000 * 0.15/1e6 + 500 * 0.60/1e6 = 0.00045
expect(result.cost).toBeCloseTo(0.00045, 10);
});
it('should return undefined cost for cached responses', async () => {
const provider = new GoogleProvider('gemini-pro', {
config: { apiKey: 'test-key' },
});
vi.mocked(cache.fetchWithCache).mockResolvedValueOnce({
data: {
candidates: [{ content: { parts: [{ text: 'cached response' }] } }],
usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5, totalTokenCount: 15 },
},
cached: true,
status: 200,
statusText: 'OK',
});
const result = await provider.callApi('test prompt');
expect(result.cost).toBeUndefined();
});
it('should use tiered pricing when prompt tokens exceed threshold', async () => {
const provider = new GoogleProvider('gemini-2.5-pro', {
config: { apiKey: 'test-key' },
});
vi.mocked(cache.fetchWithCache).mockResolvedValueOnce({
data: {
candidates: [{ content: { parts: [{ text: 'response' }] } }],
usageMetadata: {
promptTokenCount: 250_000,
candidatesTokenCount: 1000,
totalTokenCount: 251_000,
},
},
cached: false,
status: 200,
statusText: 'OK',
});
const result = await provider.callApi('test prompt');
// gemini-2.5-pro tiered: input=2.5/1e6, output=15.0/1e6 (above 200k threshold)
// cost = 2.5e-6 * 250000 + 15.0e-6 * 1000 = 0.625 + 0.015 = 0.64
expect(result.cost).toBeCloseTo(0.64, 5);
});
it('should use standard pricing when prompt tokens are below threshold', async () => {
const provider = new GoogleProvider('gemini-2.5-pro', {
config: { apiKey: 'test-key' },
});
vi.mocked(cache.fetchWithCache).mockResolvedValueOnce({
data: {
candidates: [{ content: { parts: [{ text: 'response' }] } }],
usageMetadata: {
promptTokenCount: 100_000,
candidatesTokenCount: 1000,
totalTokenCount: 101_000,
},
},
cached: false,
status: 200,
statusText: 'OK',
});
const result = await provider.callApi('test prompt');
// gemini-2.5-pro standard: input=1.25/1e6, output=10.0/1e6 (below 200k threshold)
// cost = 1.25e-6 * 100000 + 10.0e-6 * 1000 = 0.125 + 0.01 = 0.135
expect(result.cost).toBeCloseTo(0.135, 5);
});
it('should use config.cost override when provided', async () => {
const provider = new GoogleProvider('gemini-pro', {
config: { apiKey: 'test-key', cost: 0.001 },
});
vi.mocked(cache.fetchWithCache).mockResolvedValueOnce({
data: {
candidates: [{ content: { parts: [{ text: 'response' }] } }],
usageMetadata: { promptTokenCount: 100, candidatesTokenCount: 50, totalTokenCount: 150 },
},
cached: false,
status: 200,
statusText: 'OK',
});
const result = await provider.callApi('test prompt');
// config.cost=0.001 applied to both input and output
// cost = 0.001 * 100 + 0.001 * 50 = 0.15
expect(result.cost).toBeCloseTo(0.15, 5);
});
it('should include thinking tokens in cost calculation', async () => {
const provider = new GoogleProvider('gemini-2.5-flash', {
config: { apiKey: 'test-key' },
});
vi.mocked(cache.fetchWithCache).mockResolvedValueOnce({
data: {
candidates: [{ content: { parts: [{ text: 'response' }] } }],
usageMetadata: {
promptTokenCount: 10,
candidatesTokenCount: 5,
totalTokenCount: 315,
thoughtsTokenCount: 300,
},
},
cached: false,
status: 200,
statusText: 'OK',
});
const result = await provider.callApi('test prompt');
// gemini-2.5-flash: input=0.3/1e6, output=2.5/1e6
// completionForCost = candidatesTokenCount + thoughtsTokenCount = 5 + 300 = 305
// cost = 0.3e-6 * 10 + 2.5e-6 * 305 = 0.000003 + 0.0007625 = 0.0007655
expect(result.cost).toBeCloseTo(0.0007655, 10);
});
it('should not double-count when thoughtsTokenCount is zero', async () => {
const provider = new GoogleProvider('gemini-2.5-flash', {
config: { apiKey: 'test-key' },
});
vi.mocked(cache.fetchWithCache).mockResolvedValueOnce({
data: {
candidates: [{ content: { parts: [{ text: 'response' }] } }],
usageMetadata: {
promptTokenCount: 10,
candidatesTokenCount: 5,
totalTokenCount: 15,
thoughtsTokenCount: 0,
},
},
cached: false,
status: 200,
statusText: 'OK',
});
const result = await provider.callApi('test prompt');
// gemini-2.5-flash: input=0.3/1e6, output=2.5/1e6
// completionForCost = 5 + 0 = 5
// cost = 0.3e-6 * 10 + 2.5e-6 * 5 = 0.000003 + 0.0000125 = 0.0000155
expect(result.cost).toBeCloseTo(0.0000155, 10);
});
});
describe('tool handling', () => {
it('should include tools in request body', async () => {
const provider = new GoogleProvider('gemini-pro', {
config: {
apiKey: 'test-key',
tools: [
{
functionDeclarations: [
{
name: 'test_function',
description: 'A test function',
parameters: { type: 'OBJECT', properties: {} },
},
],
},
],
},
});
vi.mocked(cache.fetchWithCache).mockResolvedValueOnce({
data: {
candidates: [{ content: { parts: [{ text: 'response' }] } }],
usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5, totalTokenCount: 15 },
},
cached: false,
status: 200,
statusText: 'OK',
});
await provider.callApi('test prompt');
const calledOptions = vi.mocked(cache.fetchWithCache).mock.calls[0][1] as any;
const body = JSON.parse(calledOptions.body);
expect(body.tools).toBeDefined();
expect(body.tools[0].functionDeclarations[0].name).toBe('test_function');
});
it('should include toolConfig in request body', async () => {
const provider = new GoogleProvider('gemini-pro', {
config: {
apiKey: 'test-key',
toolConfig: {
functionCallingConfig: {
mode: 'ANY',
},
},
},
});
vi.mocked(cache.fetchWithCache).mockResolvedValueOnce({
data: {
candidates: [{ content: { parts: [{ text: 'response' }] } }],
usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5, totalTokenCount: 15 },
},
cached: false,
status: 200,
statusText: 'OK',
});
await provider.callApi('test prompt');
const calledOptions = vi.mocked(cache.fetchWithCache).mock.calls[0][1] as any;
const body = JSON.parse(calledOptions.body);
expect(body.toolConfig).toEqual({ functionCallingConfig: { mode: 'ANY' } });
});
it.each([
[{ tool_choice: 'none' }],
[{ toolConfig: { functionCallingConfig: { mode: 'none' } } }],
[{ tool_config: { function_calling_config: { mode: 'none' } } }],
])('should enforce documented no-tools controls: %j', async (toolDisableConfig) => {
const callback = vi.fn(async () => 'callback result');
const provider = new GoogleProvider('gemini-pro', {
config: {
apiKey: 'test-key',
tools: [
{
functionDeclarations: [
{
name: 'test_function',
description: 'Test function',
parameters: { type: 'OBJECT', properties: {} },
},
],
},
],
functionToolCallbacks: { test_function: callback },
...toolDisableConfig,
} as any,
});
vi.mocked(cache.fetchWithCache).mockResolvedValueOnce({
data: {
candidates: [
{
content: {
parts: [
{
text: JSON.stringify({
functionCall: { name: 'test_function', args: {} },
}),
},
],
},
},
],
usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5, totalTokenCount: 15 },
},
cached: false,
status: 200,
statusText: 'OK',
});
const result = await provider.callApi('test prompt');
const calledOptions = vi.mocked(cache.fetchWithCache).mock.calls.at(-1)?.[1] as any;
const body = JSON.parse(calledOptions.body);
expect(body.toolConfig).toEqual({ functionCallingConfig: { mode: 'NONE' } });
expect(body.tools).toBeUndefined();
expect(callback).not.toHaveBeenCalled();
expect(result.output).toBe(
JSON.stringify({ functionCall: { name: 'test_function', args: {} } }),
);
});
it('should fall back to tool_choice when explicit toolConfig is invalid', async () => {
const provider = new GoogleProvider('gemini-pro', {
config: {
apiKey: 'test-key',
toolConfig: { functionCallingConfig: { mode: 'invalid' as any } },
tool_choice: 'required',
},
});
await provider.callApi('test prompt');
const calledOptions = vi.mocked(cache.fetchWithCache).mock.calls.at(-1)?.[1] as any;
const body = JSON.parse(calledOptions.body);
expect(body.toolConfig).toEqual({ functionCallingConfig: { mode: 'ANY' } });
});
it('should honor prompt-level snake_case no-tools overrides over provider toolConfig', async () => {
const provider = new GoogleProvider('gemini-pro', {
config: {
apiKey: 'test-key',
tools: [
{
functionDeclarations: [
{
name: 'test_function',
description: 'Test function',
parameters: { type: 'OBJECT', properties: {} },
},
],
},
],
toolConfig: { functionCallingConfig: { mode: 'AUTO' } },
},
});
await provider.callApi('test prompt', {
prompt: {
config: {
tool_config: { function_calling_config: { mode: 'none' } },
},
},
} as any);
const calledOptions = vi.mocked(cache.fetchWithCache).mock.calls.at(-1)?.[1] as any;
const body = JSON.parse(calledOptions.body);
expect(body.toolConfig).toEqual({ functionCallingConfig: { mode: 'NONE' } });
expect(body.tools).toBeUndefined();
});
it('should preserve non-function Google tools when function calling is disabled', async () => {
const provider = new GoogleProvider('gemini-pro', {
config: {
apiKey: 'test-key',
tools: [{ googleSearch: {} }],
toolConfig: { functionCallingConfig: { mode: 'NONE' } },
},
});
await provider.callApi('test prompt');
const calledOptions = vi.mocked(cache.fetchWithCache).mock.calls.at(-1)?.[1] as any;
const body = JSON.parse(calledOptions.body);
expect(body.toolConfig).toEqual({ functionCallingConfig: { mode: 'NONE' } });
expect(body.tools).toEqual([{ googleSearch: {} }]);
});
it('should skip executable tool files while preserving inline non-function tools when disabled', async () => {
const provider = new GoogleProvider('gemini-pro', {
config: {
apiKey: 'test-key',
tool_choice: 'none',
tools: [{ googleSearch: {} }, 'file://tools.js:getTools'] as any,
},
});
await provider.callApi('test prompt');
expect(mockMaybeLoadToolsFromExternalFile).toHaveBeenCalledWith(
[{ googleSearch: {} }],
undefined,
);
const calledOptions = vi.mocked(cache.fetchWithCache).mock.calls.at(-1)?.[1] as any;
const body = JSON.parse(calledOptions.body);
expect(body.toolConfig).toEqual({ functionCallingConfig: { mode: 'NONE' } });
expect(body.tools).toEqual([{ googleSearch: {} }]);
});
it('should preserve non-function tools loaded from data files when disabled', async () => {
mockMaybeLoadToolsFromExternalFile.mockResolvedValueOnce([
{
functionDeclarations: [
{
name: 'get_weather',
description: 'Get weather information',
parameters: { type: 'OBJECT' as const, properties: {} },
},
],
},
{ googleSearch: {} },
]);
const provider = new GoogleProvider('gemini-pro', {
config: {
apiKey: 'test-key',
tool_choice: 'none',
tools: 'file://tools.json' as any,
},
});
await provider.callApi('test prompt');
expect(mockMaybeLoadToolsFromExternalFile).toHaveBeenCalledWith(
'file://tools.json',
undefined,
);
const calledOptions = vi.mocked(cache.fetchWithCache).mock.calls.at(-1)?.[1] as any;
const body = JSON.parse(calledOptions.body);
expect(body.toolConfig).toEqual({ functionCallingConfig: { mode: 'NONE' } });
expect(body.tools).toEqual([{ googleSearch: {} }]);
});
it('should preserve supported explicit Google toolConfig fields', async () => {
const provider = new GoogleProvider('gemini-pro', {
config: {
apiKey: 'test-key',
toolConfig: {
functionCallingConfig: {
mode: 'VALIDATED',
streamFunctionCallArguments: true,
},
},
},
});
await provider.callApi('test prompt');
const calledOptions = vi.mocked(cache.fetchWithCache).mock.calls.at(-1)?.[1] as any;
const body = JSON.parse(calledOptions.body);
expect(body.toolConfig).toEqual({
functionCallingConfig: {
mode: 'VALIDATED',
streamFunctionCallArguments: true,
},
});
});
});
describe('error handling', () => {
let provider: GoogleProvider;
beforeEach(() => {
provider = new GoogleProvider('gemini-pro', {
config: { apiKey: 'test-key' },
});
});
it('should return error for API call failure', async () => {
vi.mocked(cache.fetchWithCache).mockRejectedValueOnce(new Error('Network error'));
const result = await provider.callApi('test prompt');
expect(result.error).toContain('API call error');
expect(result.error).toContain('Network error');
});
it('should return error for API error response', async () => {
vi.mocked(cache.fetchWithCache).mockResolvedValueOnce({
data: {
error: {
code: 400,
message: 'Invalid request',
},
},
cached: false,
status: 400,
statusText: 'Bad Request',
});
const result = await provider.callApi('test prompt');
expect(result.error).toContain('Error 400: Invalid request');
});
it('should handle express mode API error response', async () => {
const provider = new GoogleProvider('gemini-pro', {
config: {
vertexai: true,
apiKey: 'vertex-api-key',
expressMode: true, // Explicit for test clarity; auto-enabled when apiKey is present
},
});
const mockResponse = {
ok: false,
status: 401,
statusText: 'Unauthorized',
json: vi.fn().mockResolvedValue({ error: { message: 'Invalid API key' } }),
};
vi.mocked(fetchUtil.fetchWithProxy).mockResolvedValueOnce(mockResponse as any);
const result = await provider.callApi('test prompt');
expect(result.error).toContain('API call error: 401 Unauthorized');
});
});
describe('cleanup', () => {
it('should have cleanup method', () => {
const provider = new GoogleProvider('gemini-pro', {
config: { apiKey: 'test-key' },
});
expect(typeof provider.cleanup).toBe('function');
});
});
});