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
3121 lines
100 KiB
TypeScript
3121 lines
100 KiB
TypeScript
import * as fs from 'fs';
|
|
|
|
import * as nunjucks from 'nunjucks';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
vi.mock('../../../src/logger', () => ({
|
|
default: {
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
import logger from '../../../src/logger';
|
|
import { GOOGLE_MODELS } from '../../../src/providers/google/shared';
|
|
import {
|
|
calculateGoogleCost,
|
|
clearCachedAuth,
|
|
collectGroundingMetadata,
|
|
geminiFormatAndSystemInstructions,
|
|
loadFile,
|
|
maybeCoerceToGeminiFormat,
|
|
mergeGoogleCompletionOptions,
|
|
mergeParts,
|
|
normalizeSafetySettings,
|
|
normalizeTools,
|
|
parseStringObject,
|
|
removeGoogleFunctionDeclarations,
|
|
resolveGoogleToolConfig,
|
|
resolveProjectId,
|
|
sanitizeSchemaForGemini,
|
|
stripExecutableToolFileReferences,
|
|
validateFunctionCall,
|
|
} from '../../../src/providers/google/util';
|
|
|
|
import type { Tool } from '../../../src/providers/google/types';
|
|
|
|
// Create a comprehensive mock for Google Auth Library
|
|
// This prevents the real library from reading ~/.config/gcloud/ or environment
|
|
const googleAuthMock = vi.hoisted(() => {
|
|
const mockAuthInstance = {
|
|
getClient: vi.fn().mockResolvedValue({ name: 'mockClient' }),
|
|
fromJSON: vi.fn().mockImplementation((credentials: any) => {
|
|
return Promise.resolve({ name: 'mockCredentialClient', credentials });
|
|
}),
|
|
getProjectId: vi.fn().mockResolvedValue('google-auth-project'),
|
|
};
|
|
|
|
return {
|
|
GoogleAuth: vi.fn().mockImplementation(function (this: any) {
|
|
// Return the mock instance
|
|
Object.assign(this, mockAuthInstance);
|
|
return this;
|
|
}),
|
|
mockAuthInstance, // Export for test access
|
|
};
|
|
});
|
|
|
|
function resetGoogleAuthMock() {
|
|
const { GoogleAuth, mockAuthInstance } = googleAuthMock;
|
|
|
|
mockAuthInstance.getClient.mockReset();
|
|
mockAuthInstance.fromJSON.mockReset();
|
|
mockAuthInstance.getProjectId.mockReset();
|
|
GoogleAuth.mockReset();
|
|
|
|
mockAuthInstance.getClient.mockResolvedValue({ name: 'mockClient' });
|
|
mockAuthInstance.fromJSON.mockImplementation((credentials: any) => {
|
|
return Promise.resolve({ name: 'mockCredentialClient', credentials });
|
|
});
|
|
mockAuthInstance.getProjectId.mockResolvedValue('google-auth-project');
|
|
GoogleAuth.mockImplementation(function (this: any) {
|
|
Object.assign(this, mockAuthInstance);
|
|
return this;
|
|
});
|
|
}
|
|
|
|
// Mock both the module and dynamic imports
|
|
vi.mock('google-auth-library', () => googleAuthMock);
|
|
|
|
vi.mock('glob', async (importOriginal) => {
|
|
return {
|
|
...(await importOriginal()),
|
|
globSync: vi.fn().mockReturnValue([]),
|
|
|
|
hasMagic: (path: string) => {
|
|
// Match the real hasMagic behavior: only detect patterns in forward-slash paths
|
|
// This mimics glob's actual behavior where backslash paths return false
|
|
return /[*?[\]{}]/.test(path) && !path.includes('\\');
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock('fs', async (importOriginal) => {
|
|
return {
|
|
...(await importOriginal()),
|
|
|
|
existsSync: vi.fn().mockImplementation(function (path) {
|
|
// Block gcloud config directory access
|
|
if (
|
|
typeof path === 'string' &&
|
|
(path.includes('.config/gcloud') || path.includes('gcloud/configurations'))
|
|
) {
|
|
return false;
|
|
}
|
|
if (path === 'file://system_instruction.json') {
|
|
return true;
|
|
}
|
|
return false;
|
|
}),
|
|
|
|
readFileSync: vi.fn().mockImplementation(function (path) {
|
|
// Block gcloud config file reads
|
|
if (
|
|
typeof path === 'string' &&
|
|
(path.includes('.config/gcloud') || path.includes('gcloud/configurations'))
|
|
) {
|
|
throw new Error('ENOENT: no such file or directory');
|
|
}
|
|
if (path === 'file://system_instruction.json') {
|
|
return 'system instruction';
|
|
}
|
|
throw new Error(`Mock file not found: ${path}`);
|
|
}),
|
|
|
|
writeFileSync: vi.fn(),
|
|
statSync: vi.fn(),
|
|
mkdirSync: vi.fn(),
|
|
};
|
|
});
|
|
|
|
describe('util', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
resetGoogleAuthMock();
|
|
});
|
|
|
|
describe('parseStringObject', () => {
|
|
it('should parse string input to object', () => {
|
|
const input = '{"key": "value"}';
|
|
expect(parseStringObject(input)).toEqual({ key: 'value' });
|
|
});
|
|
|
|
it('should return object input as-is', () => {
|
|
const input = { key: 'value' };
|
|
expect(parseStringObject(input)).toBe(input);
|
|
});
|
|
|
|
it('should return undefined as-is', () => {
|
|
expect(parseStringObject(undefined)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('validateFunctionCall', () => {
|
|
const mockFunctions: Tool[] = [
|
|
{
|
|
functionDeclarations: [
|
|
{
|
|
name: 'testFunction',
|
|
parameters: {
|
|
type: 'OBJECT',
|
|
properties: {
|
|
param1: { type: 'STRING' },
|
|
},
|
|
required: ['param1'],
|
|
},
|
|
},
|
|
{
|
|
name: 'emptyFunction',
|
|
},
|
|
{
|
|
name: 'invalidSchemaFunction',
|
|
parameters: {
|
|
type: 'OBJECT',
|
|
properties: {
|
|
param1: { type: 'STRING' },
|
|
param2: { type: 'STRING', enum: ['test'] },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: 'propertyOrderingFunction',
|
|
parameters: {
|
|
type: 'OBJECT',
|
|
properties: {
|
|
param1: { type: 'STRING' },
|
|
},
|
|
propertyOrdering: ['param1', 'param2'],
|
|
},
|
|
},
|
|
{
|
|
name: 'uncompilableFunction',
|
|
parameters: {
|
|
type: 'OBJECT',
|
|
properties: {
|
|
param1: { type: 'TYPE_UNSPECIFIED' },
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
it('should validate Vertex/AIS format function call', () => {
|
|
const output = [
|
|
{
|
|
functionCall: {
|
|
name: 'testFunction',
|
|
args: '{"param1": "test"}',
|
|
},
|
|
},
|
|
];
|
|
expect(() => validateFunctionCall(output, mockFunctions)).not.toThrow();
|
|
});
|
|
|
|
it('should validate Live format function call', () => {
|
|
const output = {
|
|
toolCall: {
|
|
functionCalls: [
|
|
{
|
|
name: 'testFunction',
|
|
args: '{"param1": "test"}',
|
|
},
|
|
],
|
|
},
|
|
};
|
|
expect(() => validateFunctionCall(output, mockFunctions)).not.toThrow();
|
|
});
|
|
|
|
it('should validate empty function args', () => {
|
|
const output = [
|
|
{
|
|
functionCall: {
|
|
name: 'emptyFunction',
|
|
args: '{}',
|
|
},
|
|
},
|
|
];
|
|
expect(() => validateFunctionCall(output, mockFunctions)).not.toThrow();
|
|
});
|
|
|
|
it('should validate function with no parameters', () => {
|
|
const output = [
|
|
{
|
|
functionCall: {
|
|
name: 'emptyFunction',
|
|
args: '{}',
|
|
},
|
|
},
|
|
];
|
|
expect(() => validateFunctionCall(output, mockFunctions)).not.toThrow();
|
|
});
|
|
|
|
it('should throw error for invalid function call format', () => {
|
|
const output = {
|
|
invalidFormat: true,
|
|
};
|
|
expect(() => validateFunctionCall(output, mockFunctions)).toThrow(
|
|
'Google did not return a valid-looking function call',
|
|
);
|
|
});
|
|
|
|
it('should throw error for non-existent function', () => {
|
|
const output = [
|
|
{
|
|
functionCall: {
|
|
name: 'nonExistentFunction',
|
|
args: '{}',
|
|
},
|
|
},
|
|
];
|
|
expect(() => validateFunctionCall(output, mockFunctions)).toThrow(
|
|
'Called "nonExistentFunction", but there is no function with that name',
|
|
);
|
|
});
|
|
|
|
it('should throw error for invalid args', () => {
|
|
const output = [
|
|
{
|
|
functionCall: {
|
|
name: 'testFunction',
|
|
args: '{}',
|
|
},
|
|
},
|
|
];
|
|
expect(() => validateFunctionCall(output, mockFunctions)).toThrow(/does not match schema/);
|
|
});
|
|
|
|
it('should throw error when schema compilation fails', () => {
|
|
const output = [
|
|
{
|
|
functionCall: {
|
|
name: 'uncompilableFunction',
|
|
args: '{"param1": "test"}',
|
|
},
|
|
},
|
|
];
|
|
expect(() => validateFunctionCall(output, mockFunctions)).toThrow(
|
|
/Tool schema doesn't compile with ajv:.*If this is a valid tool schema you may need to reformulate your assertion without is-valid-function-call/,
|
|
);
|
|
});
|
|
|
|
it('should throw error when propertyOrdering references invalid property', () => {
|
|
const output = [
|
|
{
|
|
functionCall: {
|
|
name: 'propertyOrderingFunction',
|
|
args: '{"param1": "test"}',
|
|
},
|
|
},
|
|
];
|
|
expect(() => validateFunctionCall(output, mockFunctions)).toThrow(
|
|
/Tool schema doesn't compile with ajv:.*If this is a valid tool schema you may need to reformulate your assertion without is-valid-function-call/,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('maybeCoerceToGeminiFormat', () => {
|
|
it('should handle string input', () => {
|
|
const input = 'test message';
|
|
const result = maybeCoerceToGeminiFormat(input);
|
|
expect(result).toEqual({
|
|
contents: [{ parts: [{ text: 'test message' }] }],
|
|
coerced: true,
|
|
systemInstruction: undefined,
|
|
});
|
|
});
|
|
|
|
it('should handle OpenAI chat format', () => {
|
|
const input = [
|
|
{ role: 'user', content: 'Hello' },
|
|
{ role: 'model', content: 'Hi there' },
|
|
];
|
|
const result = maybeCoerceToGeminiFormat(input);
|
|
expect(result).toEqual({
|
|
contents: [
|
|
{ role: 'user', parts: [{ text: 'Hello' }] },
|
|
{ role: 'model', parts: [{ text: 'Hi there' }] },
|
|
],
|
|
coerced: true,
|
|
systemInstruction: undefined,
|
|
});
|
|
});
|
|
|
|
it('should map assistant role to model role by default', () => {
|
|
const input = [
|
|
{ role: 'user', content: 'What is the capital of France?' },
|
|
{ role: 'assistant', content: 'The capital of France is Paris.' },
|
|
{ role: 'user', content: 'What is its population?' },
|
|
];
|
|
const result = maybeCoerceToGeminiFormat(input);
|
|
expect(result).toEqual({
|
|
contents: [
|
|
{ role: 'user', parts: [{ text: 'What is the capital of France?' }] },
|
|
{ role: 'model', parts: [{ text: 'The capital of France is Paris.' }] }, // assistant mapped to model
|
|
{ role: 'user', parts: [{ text: 'What is its population?' }] },
|
|
],
|
|
coerced: true,
|
|
systemInstruction: undefined,
|
|
});
|
|
});
|
|
|
|
it('should preserve assistant role when useAssistantRole is true', () => {
|
|
const input = [
|
|
{ role: 'user', content: 'What is the capital of France?' },
|
|
{ role: 'assistant', content: 'The capital of France is Paris.' },
|
|
{ role: 'user', content: 'What is its population?' },
|
|
];
|
|
const result = maybeCoerceToGeminiFormat(input, { useAssistantRole: true });
|
|
expect(result).toEqual({
|
|
contents: [
|
|
{ role: 'user', parts: [{ text: 'What is the capital of France?' }] },
|
|
{ role: 'assistant', parts: [{ text: 'The capital of France is Paris.' }] }, // assistant preserved
|
|
{ role: 'user', parts: [{ text: 'What is its population?' }] },
|
|
],
|
|
coerced: true,
|
|
systemInstruction: undefined,
|
|
});
|
|
});
|
|
|
|
it('should map assistant to model when useAssistantRole is false', () => {
|
|
const input = [
|
|
{ role: 'user', content: 'Hello' },
|
|
{ role: 'assistant', content: 'Hi there' },
|
|
];
|
|
const result = maybeCoerceToGeminiFormat(input, { useAssistantRole: false });
|
|
expect(result).toEqual({
|
|
contents: [
|
|
{ role: 'user', parts: [{ text: 'Hello' }] },
|
|
{ role: 'model', parts: [{ text: 'Hi there' }] },
|
|
],
|
|
coerced: true,
|
|
systemInstruction: undefined,
|
|
});
|
|
});
|
|
|
|
it('should handle OpenAI chat format with array content', () => {
|
|
const input = [
|
|
{
|
|
role: 'user',
|
|
content: ['Hello', { type: 'text', text: 'World' }],
|
|
},
|
|
];
|
|
const result = maybeCoerceToGeminiFormat(input);
|
|
expect(result).toEqual({
|
|
contents: [
|
|
{
|
|
role: 'user',
|
|
parts: [{ text: 'Hello' }, { text: 'World' }],
|
|
},
|
|
],
|
|
coerced: true,
|
|
systemInstruction: undefined,
|
|
});
|
|
});
|
|
|
|
it('should respect useAssistantRole flag with array content', () => {
|
|
const input = [
|
|
{ role: 'user', content: [{ type: 'text', text: 'Question' }] },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'Answer' }] },
|
|
];
|
|
|
|
// Test with useAssistantRole: true
|
|
const resultWithAssistant = maybeCoerceToGeminiFormat(input, { useAssistantRole: true });
|
|
expect(resultWithAssistant.contents[1].role).toBe('assistant');
|
|
|
|
// Test with useAssistantRole: false (default)
|
|
const resultWithModel = maybeCoerceToGeminiFormat(input, { useAssistantRole: false });
|
|
expect(resultWithModel.contents[1].role).toBe('model');
|
|
});
|
|
|
|
it('should handle OpenAI chat format with object content', () => {
|
|
const input = [
|
|
{
|
|
role: 'user',
|
|
content: { text: 'Hello' },
|
|
},
|
|
];
|
|
const result = maybeCoerceToGeminiFormat(input);
|
|
expect(result).toEqual({
|
|
contents: [
|
|
{
|
|
role: 'user',
|
|
parts: [{ text: 'Hello' }],
|
|
},
|
|
],
|
|
coerced: true,
|
|
systemInstruction: undefined,
|
|
});
|
|
});
|
|
|
|
it('should handle single content object', () => {
|
|
const input = {
|
|
parts: [{ text: 'test' }],
|
|
};
|
|
const result = maybeCoerceToGeminiFormat(input);
|
|
expect(result).toEqual({
|
|
contents: [{ parts: [{ text: 'test' }] }],
|
|
coerced: true,
|
|
systemInstruction: undefined,
|
|
});
|
|
});
|
|
|
|
it('should extract system instructions', () => {
|
|
const input = [
|
|
{ role: 'system', content: 'System instruction' },
|
|
{ role: 'user', content: 'User message' },
|
|
];
|
|
const result = maybeCoerceToGeminiFormat(input);
|
|
expect(result).toEqual({
|
|
contents: [{ role: 'user', parts: [{ text: 'User message' }] }],
|
|
coerced: true,
|
|
systemInstruction: {
|
|
parts: [{ text: 'System instruction' }],
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should handle unknown format and return empty array for non-array input', () => {
|
|
const input = { unknown: 'format' };
|
|
const result = maybeCoerceToGeminiFormat(input);
|
|
expect(result).toEqual({
|
|
contents: [],
|
|
coerced: false,
|
|
systemInstruction: undefined,
|
|
});
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
`Unknown format for Gemini: ${JSON.stringify(input)}`,
|
|
);
|
|
});
|
|
|
|
it('should handle null input and return empty array', () => {
|
|
const input = null;
|
|
const result = maybeCoerceToGeminiFormat(input);
|
|
expect(result).toEqual({
|
|
contents: [],
|
|
coerced: false,
|
|
systemInstruction: undefined,
|
|
});
|
|
expect(logger.warn).toHaveBeenCalledWith(`Unknown format for Gemini: null`);
|
|
});
|
|
|
|
it('should handle undefined input and return empty array', () => {
|
|
const input = undefined;
|
|
const result = maybeCoerceToGeminiFormat(input);
|
|
expect(result).toEqual({
|
|
contents: [],
|
|
coerced: false,
|
|
systemInstruction: undefined,
|
|
});
|
|
expect(logger.warn).toHaveBeenCalledWith(`Unknown format for Gemini: undefined`);
|
|
});
|
|
|
|
it('should handle number input and return empty array', () => {
|
|
const input = 42;
|
|
const result = maybeCoerceToGeminiFormat(input);
|
|
expect(result).toEqual({
|
|
contents: [],
|
|
coerced: false,
|
|
systemInstruction: undefined,
|
|
});
|
|
expect(logger.warn).toHaveBeenCalledWith(`Unknown format for Gemini: 42`);
|
|
});
|
|
|
|
it('should handle boolean input and return empty array', () => {
|
|
const input = true;
|
|
const result = maybeCoerceToGeminiFormat(input);
|
|
expect(result).toEqual({
|
|
contents: [],
|
|
coerced: false,
|
|
systemInstruction: undefined,
|
|
});
|
|
expect(logger.warn).toHaveBeenCalledWith(`Unknown format for Gemini: true`);
|
|
});
|
|
|
|
it('should handle array input in unknown format path and return array as-is', () => {
|
|
// Arrays that don't match known formats are still arrays, so they're returned as-is
|
|
// This is safe because arrays won't cause .map() errors downstream
|
|
const input = [1, 2, 3];
|
|
const result = maybeCoerceToGeminiFormat(input);
|
|
expect(result).toEqual({
|
|
contents: [1, 2, 3],
|
|
coerced: false,
|
|
systemInstruction: undefined,
|
|
});
|
|
expect(logger.warn).toHaveBeenCalledWith(`Unknown format for Gemini: [1,2,3]`);
|
|
});
|
|
|
|
it('should handle OpenAI chat format with mixed content types', () => {
|
|
const input = [
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
'Text message',
|
|
{ type: 'text', text: 'Formatted text' },
|
|
{ type: 'image', url: 'http://example.com/image.jpg' },
|
|
],
|
|
},
|
|
];
|
|
const result = maybeCoerceToGeminiFormat(input);
|
|
expect(result).toEqual({
|
|
contents: [
|
|
{
|
|
role: 'user',
|
|
parts: [
|
|
{ text: 'Text message' },
|
|
{ text: 'Formatted text' },
|
|
{ type: 'image', url: 'http://example.com/image.jpg' },
|
|
],
|
|
},
|
|
],
|
|
coerced: true,
|
|
systemInstruction: undefined,
|
|
});
|
|
});
|
|
|
|
it('should handle content with MAX_TOKENS finish reason', () => {
|
|
const input = [
|
|
{
|
|
role: 'model',
|
|
content: { text: 'Truncated response' },
|
|
finishReason: 'MAX_TOKENS',
|
|
},
|
|
];
|
|
const result = maybeCoerceToGeminiFormat(input);
|
|
expect(result.contents[0]).toEqual({
|
|
role: 'model',
|
|
parts: [{ text: 'Truncated response' }],
|
|
});
|
|
});
|
|
|
|
it('should handle content with RECITATION finish reason', () => {
|
|
const input = [
|
|
{
|
|
role: 'model',
|
|
content: { text: 'Recited content' },
|
|
finishReason: 'RECITATION',
|
|
},
|
|
];
|
|
const result = maybeCoerceToGeminiFormat(input);
|
|
expect(result.contents[0]).toEqual({
|
|
role: 'model',
|
|
parts: [{ text: 'Recited content' }],
|
|
});
|
|
});
|
|
|
|
it('should handle content with BLOCKLIST finish reason', () => {
|
|
const input = [
|
|
{
|
|
role: 'model',
|
|
content: { text: 'Blocked content' },
|
|
finishReason: 'BLOCKLIST',
|
|
},
|
|
];
|
|
const result = maybeCoerceToGeminiFormat(input);
|
|
expect(result.contents[0]).toEqual({
|
|
role: 'model',
|
|
parts: [{ text: 'Blocked content' }],
|
|
});
|
|
});
|
|
|
|
it('should handle content with PROHIBITED_CONTENT finish reason', () => {
|
|
const input = [
|
|
{
|
|
role: 'model',
|
|
content: { text: 'Prohibited content' },
|
|
finishReason: 'PROHIBITED_CONTENT',
|
|
},
|
|
];
|
|
const result = maybeCoerceToGeminiFormat(input);
|
|
expect(result.contents[0]).toEqual({
|
|
role: 'model',
|
|
parts: [{ text: 'Prohibited content' }],
|
|
});
|
|
});
|
|
|
|
it('should handle content with SPII finish reason', () => {
|
|
const input = [
|
|
{
|
|
role: 'model',
|
|
content: { text: 'Sensitive information' },
|
|
finishReason: 'SPII',
|
|
},
|
|
];
|
|
const result = maybeCoerceToGeminiFormat(input);
|
|
expect(result.contents[0]).toEqual({
|
|
role: 'model',
|
|
parts: [{ text: 'Sensitive information' }],
|
|
});
|
|
});
|
|
|
|
it('should return unmodified content if it matches GeminiFormat', () => {
|
|
const input = [
|
|
{
|
|
role: 'user',
|
|
parts: [{ text: 'Hello, Gemini!' }],
|
|
},
|
|
];
|
|
const result = maybeCoerceToGeminiFormat(input);
|
|
expect(result).toEqual({
|
|
contents: input,
|
|
coerced: false,
|
|
systemInstruction: undefined,
|
|
});
|
|
});
|
|
|
|
it('should coerce OpenAI chat format to GeminiFormat', () => {
|
|
const input = [
|
|
{ role: 'user', content: 'Hello' },
|
|
{ role: 'user', content: ', ' },
|
|
{ role: 'user', content: 'Gemini!' },
|
|
];
|
|
const expected = [
|
|
{
|
|
role: 'user',
|
|
parts: [{ text: 'Hello' }],
|
|
},
|
|
{
|
|
role: 'user',
|
|
parts: [{ text: ', ' }],
|
|
},
|
|
{
|
|
role: 'user',
|
|
parts: [{ text: 'Gemini!' }],
|
|
},
|
|
];
|
|
const result = maybeCoerceToGeminiFormat(input);
|
|
expect(result).toEqual({
|
|
contents: expected,
|
|
coerced: true,
|
|
systemInstruction: undefined,
|
|
});
|
|
});
|
|
|
|
it('should coerce string input to GeminiFormat', () => {
|
|
const input = 'Hello, Gemini!';
|
|
const expected = [
|
|
{
|
|
parts: [{ text: 'Hello, Gemini!' }],
|
|
},
|
|
];
|
|
const result = maybeCoerceToGeminiFormat(input);
|
|
expect(result).toEqual({
|
|
contents: expected,
|
|
coerced: true,
|
|
systemInstruction: undefined,
|
|
});
|
|
});
|
|
|
|
it('should handle system messages and create systemInstruction', () => {
|
|
const input = [
|
|
{ role: 'system', content: 'You are a helpful assistant.' },
|
|
{ role: 'user', content: 'Hello!' },
|
|
];
|
|
const result = maybeCoerceToGeminiFormat(input);
|
|
expect(result).toEqual({
|
|
contents: [
|
|
{
|
|
role: 'user',
|
|
parts: [{ text: 'Hello!' }],
|
|
},
|
|
],
|
|
coerced: true,
|
|
systemInstruction: {
|
|
parts: [{ text: 'You are a helpful assistant.' }],
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should convert system-only prompts to user messages', () => {
|
|
const input = [{ role: 'system', content: 'You are a helpful assistant.' }];
|
|
const result = maybeCoerceToGeminiFormat(input);
|
|
expect(result).toEqual({
|
|
contents: [{ role: 'user', parts: [{ text: 'You are a helpful assistant.' }] }],
|
|
coerced: true,
|
|
systemInstruction: undefined,
|
|
});
|
|
});
|
|
|
|
it('should convert multiple system-only messages to single user message', () => {
|
|
const input = [
|
|
{ role: 'system', content: 'First instruction.' },
|
|
{ role: 'system', content: 'Second instruction.' },
|
|
];
|
|
const result = maybeCoerceToGeminiFormat(input);
|
|
expect(result).toEqual({
|
|
contents: [
|
|
{
|
|
role: 'user',
|
|
parts: [{ text: 'First instruction.' }, { text: 'Second instruction.' }],
|
|
},
|
|
],
|
|
coerced: true,
|
|
systemInstruction: undefined,
|
|
});
|
|
});
|
|
|
|
it('should log a warning and return empty array for unknown non-array formats', () => {
|
|
const loggerSpy = vi.spyOn(logger, 'warn');
|
|
const input = { unknownFormat: 'test' };
|
|
const result = maybeCoerceToGeminiFormat(input);
|
|
expect(result).toEqual({
|
|
contents: [],
|
|
coerced: false,
|
|
systemInstruction: undefined,
|
|
});
|
|
expect(loggerSpy).toHaveBeenCalledWith(`Unknown format for Gemini: ${JSON.stringify(input)}`);
|
|
});
|
|
|
|
it('should handle OpenAI chat format with content as an array of objects', () => {
|
|
const input = [
|
|
{
|
|
role: 'system',
|
|
content: 'You are a helpful AI assistant.',
|
|
},
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: 'What is {{thing}}?',
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
const result = maybeCoerceToGeminiFormat(input);
|
|
|
|
expect(result).toEqual({
|
|
contents: [
|
|
{
|
|
role: 'user',
|
|
parts: [
|
|
{
|
|
text: 'What is {{thing}}?',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
coerced: true,
|
|
systemInstruction: {
|
|
parts: [{ text: 'You are a helpful AI assistant.' }],
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should handle string content', () => {
|
|
const input = [
|
|
{
|
|
role: 'system',
|
|
content: 'You are a helpful AI assistant.',
|
|
},
|
|
{
|
|
role: 'user',
|
|
content: 'What is {{thing}}?',
|
|
},
|
|
];
|
|
|
|
const result = maybeCoerceToGeminiFormat(input);
|
|
|
|
expect(result).toEqual({
|
|
contents: [
|
|
{
|
|
role: 'user',
|
|
parts: [{ text: 'What is {{thing}}?' }],
|
|
},
|
|
],
|
|
coerced: true,
|
|
systemInstruction: {
|
|
parts: [{ text: 'You are a helpful AI assistant.' }],
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should handle mixed content types', () => {
|
|
const input = [
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{ type: 'text', text: 'First part' },
|
|
'Second part as string',
|
|
{ type: 'image', url: 'https://example.com/image.jpg' },
|
|
],
|
|
},
|
|
];
|
|
|
|
const result = maybeCoerceToGeminiFormat(input);
|
|
|
|
expect(result).toEqual({
|
|
contents: [
|
|
{
|
|
role: 'user',
|
|
parts: [
|
|
{ text: 'First part' },
|
|
{ text: 'Second part as string' },
|
|
{ type: 'image', url: 'https://example.com/image.jpg' },
|
|
],
|
|
},
|
|
],
|
|
coerced: true,
|
|
systemInstruction: undefined,
|
|
});
|
|
});
|
|
|
|
it('should handle native Gemini format with system_instruction field', () => {
|
|
const input = {
|
|
system_instruction: { parts: [{ text: 'You are a helpful assistant' }] },
|
|
contents: [
|
|
{
|
|
parts: [{ text: 'Hello' }],
|
|
},
|
|
],
|
|
};
|
|
|
|
const result = maybeCoerceToGeminiFormat(input);
|
|
|
|
expect(result).toEqual({
|
|
contents: [
|
|
{
|
|
parts: [{ text: 'Hello' }],
|
|
},
|
|
],
|
|
coerced: true,
|
|
systemInstruction: { parts: [{ text: 'You are a helpful assistant' }] },
|
|
});
|
|
});
|
|
|
|
it('should handle native Gemini format with system_instruction but no contents field', () => {
|
|
const input = {
|
|
system_instruction: { parts: [{ text: 'You are a helpful assistant' }] },
|
|
};
|
|
|
|
const result = maybeCoerceToGeminiFormat(input);
|
|
|
|
expect(result).toEqual({
|
|
contents: [],
|
|
coerced: true,
|
|
systemInstruction: { parts: [{ text: 'You are a helpful assistant' }] },
|
|
});
|
|
});
|
|
|
|
it('should handle native Gemini format with system_instruction and empty contents array', () => {
|
|
const input = {
|
|
system_instruction: { parts: [{ text: 'You are a helpful assistant' }] },
|
|
contents: [],
|
|
};
|
|
|
|
const result = maybeCoerceToGeminiFormat(input);
|
|
|
|
expect(result).toEqual({
|
|
contents: [],
|
|
coerced: true,
|
|
systemInstruction: { parts: [{ text: 'You are a helpful assistant' }] },
|
|
});
|
|
});
|
|
|
|
it('should handle valid GeminiFormat array with system_instruction field', () => {
|
|
const input = {
|
|
system_instruction: { parts: [{ text: 'You are a helpful assistant' }] },
|
|
contents: [
|
|
{
|
|
role: 'user',
|
|
parts: [{ text: 'Hello, Gemini!' }],
|
|
},
|
|
],
|
|
};
|
|
|
|
const result = maybeCoerceToGeminiFormat(input);
|
|
|
|
expect(result).toEqual({
|
|
contents: [
|
|
{
|
|
role: 'user',
|
|
parts: [{ text: 'Hello, Gemini!' }],
|
|
},
|
|
],
|
|
coerced: true,
|
|
systemInstruction: { parts: [{ text: 'You are a helpful assistant' }] },
|
|
});
|
|
});
|
|
|
|
it('should handle system_instruction with different formats', () => {
|
|
const input = {
|
|
system_instruction: {
|
|
parts: [{ text: 'You are a helpful assistant' }, { text: 'Be concise and accurate' }],
|
|
},
|
|
contents: [
|
|
{
|
|
parts: [{ text: 'Hello' }],
|
|
},
|
|
],
|
|
};
|
|
|
|
const result = maybeCoerceToGeminiFormat(input);
|
|
|
|
expect(result).toEqual({
|
|
contents: [
|
|
{
|
|
parts: [{ text: 'Hello' }],
|
|
},
|
|
],
|
|
coerced: true,
|
|
systemInstruction: {
|
|
parts: [{ text: 'You are a helpful assistant' }, { text: 'Be concise and accurate' }],
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('getGoogleClient', () => {
|
|
beforeEach(() => {
|
|
// Just reset mock state, don't use vi.resetModules() as it breaks dynamic import mocks
|
|
vi.clearAllMocks();
|
|
|
|
// Reset mock to default state
|
|
const { mockAuthInstance } = googleAuthMock;
|
|
mockAuthInstance.getClient.mockClear();
|
|
mockAuthInstance.getProjectId.mockClear();
|
|
mockAuthInstance.getClient.mockResolvedValue({ name: 'mockClient' });
|
|
mockAuthInstance.getProjectId.mockResolvedValue('test-project');
|
|
});
|
|
|
|
it('should create and return Google client', async () => {
|
|
const mockClient = { name: 'mockClient' };
|
|
const mockProjectId = 'test-project';
|
|
|
|
const { mockAuthInstance } = googleAuthMock;
|
|
mockAuthInstance.getClient.mockResolvedValue(mockClient);
|
|
mockAuthInstance.getProjectId.mockResolvedValue(mockProjectId);
|
|
|
|
const { getGoogleClient } = await import('../../../src/providers/google/util');
|
|
|
|
const result = await getGoogleClient();
|
|
expect(result).toEqual({
|
|
client: mockClient,
|
|
projectId: mockProjectId,
|
|
});
|
|
});
|
|
|
|
it('should create new auth client per call (SDK aligned, no global cache)', async () => {
|
|
const mockClient = { name: 'mockClient' };
|
|
const mockProjectId = 'test-project';
|
|
|
|
const { mockAuthInstance } = googleAuthMock;
|
|
mockAuthInstance.getClient.mockResolvedValue(mockClient);
|
|
mockAuthInstance.getProjectId.mockResolvedValue(mockProjectId);
|
|
|
|
const { getGoogleClient } = await import('../../../src/providers/google/util');
|
|
|
|
// Clear call count to start fresh
|
|
googleAuthMock.GoogleAuth.mockClear();
|
|
|
|
await getGoogleClient();
|
|
const googleAuthCalls = googleAuthMock.GoogleAuth.mock.calls.length;
|
|
|
|
await getGoogleClient();
|
|
// Per SDK alignment, we no longer cache auth globally - each call creates new instance
|
|
// This matches @google/genai SDK behavior where auth is per-instance, not global
|
|
expect(googleAuthMock.GoogleAuth.mock.calls).toHaveLength(googleAuthCalls + 1);
|
|
});
|
|
});
|
|
|
|
describe('hasGoogleDefaultCredentials', () => {
|
|
beforeEach(() => {
|
|
// Just reset mock state, don't use vi.resetModules() as it breaks dynamic import mocks
|
|
vi.clearAllMocks();
|
|
|
|
// Reset mock to default state
|
|
const { mockAuthInstance } = googleAuthMock;
|
|
mockAuthInstance.getClient.mockClear();
|
|
mockAuthInstance.getProjectId.mockClear();
|
|
mockAuthInstance.getClient.mockResolvedValue({});
|
|
mockAuthInstance.getProjectId.mockResolvedValue('test-project');
|
|
});
|
|
|
|
it('should return true when credentials are available', async () => {
|
|
const { mockAuthInstance } = googleAuthMock;
|
|
mockAuthInstance.getClient.mockResolvedValue({});
|
|
mockAuthInstance.getProjectId.mockResolvedValue('test-project');
|
|
|
|
const { hasGoogleDefaultCredentials } = await import('../../../src/providers/google/util');
|
|
|
|
const result = await hasGoogleDefaultCredentials();
|
|
expect(result).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('loadFile', () => {
|
|
it('should load from variable', async () => {
|
|
nunjucks.configure({ autoescape: false });
|
|
|
|
const config_var = '{{tool_file}}';
|
|
const context_vars = {
|
|
tool_file:
|
|
'[\n' +
|
|
' {\n' +
|
|
' "functionDeclarations": [\n' +
|
|
' {\n' +
|
|
' "name": "fakeTool",\n' +
|
|
' "description": "fake tool description"\n' +
|
|
' }\n' +
|
|
' ]\n' +
|
|
' }\n' +
|
|
']',
|
|
};
|
|
const result = loadFile(config_var, context_vars);
|
|
expect(result).toEqual(JSON.parse(context_vars.tool_file));
|
|
});
|
|
|
|
it('should load directly from provider', async () => {
|
|
const config_var = 'file://fp.json';
|
|
const tools =
|
|
'[\n' +
|
|
' {\n' +
|
|
' "functionDeclarations": [\n' +
|
|
' {\n' +
|
|
' "name": "fakeTool",\n' +
|
|
' "description": "fake tool description"\n' +
|
|
' }\n' +
|
|
' ]\n' +
|
|
' }\n' +
|
|
']';
|
|
const context_vars = {};
|
|
|
|
// existsSync no longer called due to TOCTOU fix
|
|
vi.spyOn(fs, 'readFileSync').mockReturnValue(tools);
|
|
const result = loadFile(config_var, context_vars);
|
|
expect(result).toEqual(JSON.parse(tools));
|
|
expect(fs.readFileSync).toHaveBeenCalledWith(expect.stringContaining('fp.json'), 'utf8');
|
|
});
|
|
});
|
|
|
|
describe('geminiFormatSystemInstructions', () => {
|
|
it('should handle system messages in prompt', async () => {
|
|
const prompt = [
|
|
{ role: 'system', content: [{ text: 'system instruction' }] },
|
|
{ role: 'user', content: [{ text: 'user message' }] },
|
|
];
|
|
|
|
const { contents, systemInstruction } = geminiFormatAndSystemInstructions(
|
|
JSON.stringify(prompt),
|
|
{},
|
|
undefined,
|
|
);
|
|
|
|
expect(contents).toEqual([{ parts: [{ text: 'user message' }], role: 'user' }]);
|
|
expect(systemInstruction).toEqual({ parts: [{ text: 'system instruction' }] });
|
|
});
|
|
|
|
it('should handle system messages in config', async () => {
|
|
const prompt = [{ role: 'user', parts: [{ text: 'user message' }] }];
|
|
|
|
const { contents, systemInstruction } = geminiFormatAndSystemInstructions(
|
|
JSON.stringify(prompt),
|
|
{},
|
|
{ parts: [{ text: 'system instruction' }] },
|
|
);
|
|
|
|
expect(contents).toEqual([{ parts: [{ text: 'user message' }], role: 'user' }]);
|
|
expect(systemInstruction).toEqual({ parts: [{ text: 'system instruction' }] });
|
|
});
|
|
|
|
it('should handle system messages in variables', async () => {
|
|
const prompt = [{ role: 'user', parts: [{ text: 'user message' }] }];
|
|
|
|
const { contents, systemInstruction } = geminiFormatAndSystemInstructions(
|
|
JSON.stringify(prompt),
|
|
{ system_instruction: 'system instruction' },
|
|
{ parts: [{ text: '{{system_instruction}}' }] },
|
|
);
|
|
|
|
expect(contents).toEqual([{ parts: [{ text: 'user message' }], role: 'user' }]);
|
|
expect(systemInstruction).toEqual({ parts: [{ text: 'system instruction' }] });
|
|
});
|
|
|
|
it('should handle string system messages in config', async () => {
|
|
const prompt = [{ role: 'user', parts: [{ text: 'user message' }] }];
|
|
|
|
const { contents, systemInstruction } = geminiFormatAndSystemInstructions(
|
|
JSON.stringify(prompt),
|
|
{},
|
|
'system instruction',
|
|
);
|
|
|
|
expect(contents).toEqual([{ parts: [{ text: 'user message' }], role: 'user' }]);
|
|
expect(systemInstruction).toEqual({ parts: [{ text: 'system instruction' }] });
|
|
});
|
|
|
|
it('should handle filepath system messages in variables', async () => {
|
|
const prompt = [{ role: 'user', parts: [{ text: 'user message' }] }];
|
|
const system_instruction = JSON.stringify({ parts: [{ text: 'system instruction' }] });
|
|
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
|
vi.spyOn(fs, 'readFileSync').mockReturnValue(system_instruction);
|
|
|
|
const { contents, systemInstruction } = geminiFormatAndSystemInstructions(
|
|
JSON.stringify(prompt),
|
|
{ system_instruction: 'file://system_instruction.json' },
|
|
'{{system_instruction}}',
|
|
);
|
|
|
|
expect(contents).toEqual([{ parts: [{ text: 'user message' }], role: 'user' }]);
|
|
expect(systemInstruction).toEqual({ parts: [{ text: 'system instruction' }] });
|
|
});
|
|
|
|
it('should merge system messages from both prompt and config with string config', () => {
|
|
const prompt = [
|
|
{ role: 'system', content: 'prompt system instruction' },
|
|
{ role: 'user', content: 'user message' },
|
|
];
|
|
const { contents, systemInstruction } = geminiFormatAndSystemInstructions(
|
|
JSON.stringify(prompt),
|
|
{},
|
|
'config system instruction',
|
|
);
|
|
expect(contents).toEqual([{ parts: [{ text: 'user message' }], role: 'user' }]);
|
|
expect(systemInstruction).toEqual({
|
|
parts: [{ text: 'config system instruction' }, { text: 'prompt system instruction' }],
|
|
});
|
|
});
|
|
|
|
it('should merge system messages from both prompt and config with object config', () => {
|
|
const prompt = [
|
|
{ role: 'system', content: 'prompt system instruction' },
|
|
{ role: 'user', content: 'user message' },
|
|
];
|
|
const { contents, systemInstruction } = geminiFormatAndSystemInstructions(
|
|
JSON.stringify(prompt),
|
|
{},
|
|
{ parts: [{ text: 'config system instruction' }] },
|
|
);
|
|
expect(contents).toEqual([{ parts: [{ text: 'user message' }], role: 'user' }]);
|
|
expect(systemInstruction).toEqual({
|
|
parts: [{ text: 'config system instruction' }, { text: 'prompt system instruction' }],
|
|
});
|
|
});
|
|
|
|
it('should merge multiple parts from config systemInstruction', () => {
|
|
const prompt = [
|
|
{ role: 'system', content: 'prompt system instruction' },
|
|
{ role: 'user', content: 'user message' },
|
|
];
|
|
const { contents, systemInstruction } = geminiFormatAndSystemInstructions(
|
|
JSON.stringify(prompt),
|
|
{},
|
|
{
|
|
parts: [{ text: 'config system instruction 1' }, { text: 'config system instruction 2' }],
|
|
},
|
|
);
|
|
expect(contents).toEqual([{ parts: [{ text: 'user message' }], role: 'user' }]);
|
|
expect(systemInstruction).toEqual({
|
|
parts: [
|
|
{ text: 'config system instruction 1' },
|
|
{ text: 'config system instruction 2' },
|
|
{ text: 'prompt system instruction' },
|
|
],
|
|
});
|
|
});
|
|
|
|
it('should merge multiple system messages from prompt', () => {
|
|
const prompt = [
|
|
{ role: 'system', content: 'prompt system instruction 1' },
|
|
{ role: 'system', content: 'prompt system instruction 2' },
|
|
{ role: 'user', content: 'user message' },
|
|
];
|
|
const { contents, systemInstruction } = geminiFormatAndSystemInstructions(
|
|
JSON.stringify(prompt),
|
|
{},
|
|
'config system instruction',
|
|
);
|
|
expect(contents).toEqual([{ parts: [{ text: 'user message' }], role: 'user' }]);
|
|
expect(systemInstruction).toEqual({
|
|
parts: [
|
|
{ text: 'config system instruction' },
|
|
{ text: 'prompt system instruction 1' },
|
|
{ text: 'prompt system instruction 2' },
|
|
],
|
|
});
|
|
});
|
|
|
|
it('should render Nunjucks templates in config systemInstruction', () => {
|
|
const prompt = [{ role: 'user', content: 'user message' }];
|
|
const { contents, systemInstruction } = geminiFormatAndSystemInstructions(
|
|
JSON.stringify(prompt),
|
|
{ role: 'a helpful assistant', language: 'Japanese' },
|
|
'You are {{role}}. Respond in {{language}}.',
|
|
);
|
|
expect(contents).toEqual([{ parts: [{ text: 'user message' }], role: 'user' }]);
|
|
expect(systemInstruction).toEqual({
|
|
parts: [{ text: 'You are a helpful assistant. Respond in Japanese.' }],
|
|
});
|
|
});
|
|
|
|
it('should skip empty string config systemInstruction', () => {
|
|
const prompt = [
|
|
{ role: 'system', content: 'prompt system instruction' },
|
|
{ role: 'user', content: 'user message' },
|
|
];
|
|
const { contents, systemInstruction } = geminiFormatAndSystemInstructions(
|
|
JSON.stringify(prompt),
|
|
{},
|
|
'',
|
|
);
|
|
expect(contents).toEqual([{ parts: [{ text: 'user message' }], role: 'user' }]);
|
|
// Empty string is falsy, so config systemInstruction is not processed
|
|
expect(systemInstruction).toEqual({
|
|
parts: [{ text: 'prompt system instruction' }],
|
|
});
|
|
});
|
|
|
|
describe('support for images in contents', () => {
|
|
const validBase64Image =
|
|
'/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA/8A/9k=';
|
|
|
|
it('should preserve text formatting when no images are present', () => {
|
|
const prompt = JSON.stringify([
|
|
{
|
|
role: 'user',
|
|
parts: [
|
|
{
|
|
text: 'Hello world!\n\n This is indented text.\n\nAnd this has multiple\n\n\nEmpty lines.',
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
|
|
const contextVars = {
|
|
someVar: 'not an image',
|
|
};
|
|
|
|
const { contents } = geminiFormatAndSystemInstructions(prompt, contextVars);
|
|
|
|
expect(contents).toEqual([
|
|
{
|
|
role: 'user',
|
|
parts: [
|
|
{
|
|
text: 'Hello world!\n\n This is indented text.\n\nAnd this has multiple\n\n\nEmpty lines.',
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should preserve text formatting when no context variables are provided', () => {
|
|
const prompt = JSON.stringify([
|
|
{
|
|
role: 'user',
|
|
parts: [
|
|
{
|
|
text: 'Hello world!\n\n This is indented text.\n\nAnd this has multiple\n\n\nEmpty lines.',
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
|
|
const { contents } = geminiFormatAndSystemInstructions(prompt);
|
|
|
|
expect(contents).toEqual([
|
|
{
|
|
role: 'user',
|
|
parts: [
|
|
{
|
|
text: 'Hello world!\n\n This is indented text.\n\nAnd this has multiple\n\n\nEmpty lines.',
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should convert base64 images from context variables while preserving other text', () => {
|
|
const prompt = JSON.stringify([
|
|
{
|
|
role: 'user',
|
|
parts: [
|
|
{
|
|
text: `Here is some text before the image:\n\n${validBase64Image}\n\nAnd here is some text after the image.`,
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
|
|
const contextVars = {
|
|
image1: validBase64Image,
|
|
};
|
|
|
|
const { contents } = geminiFormatAndSystemInstructions(prompt, contextVars);
|
|
|
|
expect(contents).toEqual([
|
|
{
|
|
role: 'user',
|
|
parts: [
|
|
{
|
|
text: 'Here is some text before the image:\n',
|
|
},
|
|
{
|
|
inlineData: {
|
|
mimeType: 'image/jpeg',
|
|
data: validBase64Image,
|
|
},
|
|
},
|
|
{
|
|
text: 'And here is some text after the image.',
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should handle multiple images mixed with text', () => {
|
|
const image1 = validBase64Image;
|
|
const image2 =
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGA5w5EQwA7BHigu/QKBgAAAABJRU5ErkJggg==';
|
|
|
|
const prompt = JSON.stringify([
|
|
{
|
|
role: 'user',
|
|
parts: [
|
|
{
|
|
text: `First line of text.\n${image1}\nMiddle text line.\n${image2}\nLast line of text.`,
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
|
|
const contextVars = {
|
|
image1: image1,
|
|
image2: image2,
|
|
};
|
|
|
|
const { contents } = geminiFormatAndSystemInstructions(prompt, contextVars);
|
|
|
|
expect(contents).toEqual([
|
|
{
|
|
role: 'user',
|
|
parts: [
|
|
{
|
|
text: 'First line of text.',
|
|
},
|
|
{
|
|
inlineData: {
|
|
mimeType: 'image/jpeg',
|
|
data: image1,
|
|
},
|
|
},
|
|
{
|
|
text: 'Middle text line.',
|
|
},
|
|
{
|
|
inlineData: {
|
|
mimeType: 'image/png',
|
|
data: image2,
|
|
},
|
|
},
|
|
{
|
|
text: 'Last line of text.',
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should preserve complex formatting with whitespace and empty lines', () => {
|
|
const prompt = JSON.stringify([
|
|
{
|
|
role: 'user',
|
|
parts: [
|
|
{
|
|
text: 'Title\n\n Indented paragraph with spaces\n\n\n Another indented paragraph\n with more indentation\n\n',
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
|
|
const contextVars = {
|
|
notAnImage: 'just text',
|
|
};
|
|
|
|
const { contents } = geminiFormatAndSystemInstructions(prompt, contextVars);
|
|
|
|
expect(contents).toEqual([
|
|
{
|
|
role: 'user',
|
|
parts: [
|
|
{
|
|
text: 'Title\n\n Indented paragraph with spaces\n\n\n Another indented paragraph\n with more indentation\n\n',
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should handle edge case with image at the beginning', () => {
|
|
const prompt = JSON.stringify([
|
|
{
|
|
role: 'user',
|
|
parts: [
|
|
{
|
|
text: `${validBase64Image}\nText after image`,
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
|
|
const contextVars = {
|
|
image1: validBase64Image,
|
|
};
|
|
|
|
const { contents } = geminiFormatAndSystemInstructions(prompt, contextVars);
|
|
|
|
expect(contents).toEqual([
|
|
{
|
|
role: 'user',
|
|
parts: [
|
|
{
|
|
inlineData: {
|
|
mimeType: 'image/jpeg',
|
|
data: validBase64Image,
|
|
},
|
|
},
|
|
{
|
|
text: 'Text after image',
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should handle edge case with image at the end', () => {
|
|
const prompt = JSON.stringify([
|
|
{
|
|
role: 'user',
|
|
parts: [
|
|
{
|
|
text: `Text before image\n${validBase64Image}`,
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
|
|
const contextVars = {
|
|
image1: validBase64Image,
|
|
};
|
|
|
|
const { contents } = geminiFormatAndSystemInstructions(prompt, contextVars);
|
|
|
|
expect(contents).toEqual([
|
|
{
|
|
role: 'user',
|
|
parts: [
|
|
{
|
|
text: 'Text before image',
|
|
},
|
|
{
|
|
inlineData: {
|
|
mimeType: 'image/jpeg',
|
|
data: validBase64Image,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should handle non-text parts without modification', () => {
|
|
const prompt = JSON.stringify([
|
|
{
|
|
role: 'user',
|
|
parts: [
|
|
{
|
|
text: 'Some text',
|
|
},
|
|
{
|
|
inlineData: {
|
|
mimeType: 'image/png',
|
|
data: 'existing-image-data',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
|
|
const contextVars = {
|
|
image1: validBase64Image,
|
|
};
|
|
|
|
const { contents } = geminiFormatAndSystemInstructions(prompt, contextVars);
|
|
|
|
expect(contents).toEqual([
|
|
{
|
|
role: 'user',
|
|
parts: [
|
|
{
|
|
text: 'Some text',
|
|
},
|
|
{
|
|
inlineData: {
|
|
mimeType: 'image/png',
|
|
data: 'existing-image-data',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should handle empty text parts', () => {
|
|
const prompt = JSON.stringify([
|
|
{
|
|
role: 'user',
|
|
parts: [
|
|
{
|
|
text: '',
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
|
|
const contextVars = {
|
|
image1: validBase64Image,
|
|
};
|
|
|
|
const { contents } = geminiFormatAndSystemInstructions(prompt, contextVars);
|
|
|
|
expect(contents).toEqual([
|
|
{
|
|
role: 'user',
|
|
parts: [
|
|
{
|
|
text: '',
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should correctly detect and process WebP images', () => {
|
|
// WebP file starts with "RIFF" (UklGR in base64) followed by file size
|
|
// This is a longer base64 string to meet the 100 character minimum requirement
|
|
const webpBase64 =
|
|
'UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAgA0JaQAA3AA/vuUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=';
|
|
|
|
const prompt = JSON.stringify([
|
|
{
|
|
role: 'user',
|
|
parts: [
|
|
{
|
|
text: `Here is a WebP image:\n${webpBase64}\nEnd of image.`,
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
|
|
const contextVars = {
|
|
webpImage: webpBase64,
|
|
};
|
|
|
|
const { contents } = geminiFormatAndSystemInstructions(prompt, contextVars);
|
|
|
|
expect(contents).toEqual([
|
|
{
|
|
role: 'user',
|
|
parts: [
|
|
{
|
|
text: 'Here is a WebP image:',
|
|
},
|
|
{
|
|
inlineData: {
|
|
mimeType: 'image/webp',
|
|
data: webpBase64,
|
|
},
|
|
},
|
|
{
|
|
text: 'End of image.',
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should correctly detect WebP images with variable file sizes', () => {
|
|
// Different valid WebP base64 strings that start with UklGR (not UklGRg)
|
|
// These represent "RIFF" followed by different file size bytes
|
|
// Each is padded to be over 100 characters to meet the minimum requirement
|
|
const webpVariants = [
|
|
'UklGRjAAAABXRUJQVlA4IBQAAAAwAQCdASoBAAEAAQAcJaACdLoB/AAAA0AA/v359OAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==',
|
|
'UklGRkAAAABXRUJQVlA4IEQAAAAwAgCdASoCAAIAAQAcJaACdLoD/AAAA8AAAAj17Zs+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
|
|
'UklGRlAAAABXRUJQVlA4IEQAAAAwAgCdASoCAAIAAQAcJaACdLoD/AAAA8AAAAj17Zs+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
|
|
];
|
|
|
|
webpVariants.forEach((webpData, index) => {
|
|
const prompt = JSON.stringify([
|
|
{
|
|
role: 'user',
|
|
parts: [
|
|
{
|
|
text: webpData,
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
|
|
const contextVars = {
|
|
[`webpImage${index}`]: webpData,
|
|
};
|
|
|
|
const { contents } = geminiFormatAndSystemInstructions(prompt, contextVars);
|
|
|
|
expect(contents[0].parts).toEqual([
|
|
{
|
|
inlineData: {
|
|
mimeType: 'image/webp',
|
|
data: webpData,
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('data URL support', () => {
|
|
it('should handle JPEG data URLs and extract base64', () => {
|
|
const base64Data = validBase64Image;
|
|
const dataUrl = `data:image/jpeg;base64,${base64Data}`;
|
|
|
|
const prompt = JSON.stringify([
|
|
{
|
|
role: 'user',
|
|
parts: [
|
|
{
|
|
text: `Here is an image:\n${dataUrl}\nEnd of image.`,
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
|
|
const contextVars = {
|
|
image1: dataUrl,
|
|
};
|
|
|
|
const { contents } = geminiFormatAndSystemInstructions(prompt, contextVars);
|
|
|
|
expect(contents).toEqual([
|
|
{
|
|
role: 'user',
|
|
parts: [
|
|
{
|
|
text: 'Here is an image:',
|
|
},
|
|
{
|
|
inlineData: {
|
|
mimeType: 'image/jpeg',
|
|
data: base64Data, // Should extract raw base64
|
|
},
|
|
},
|
|
{
|
|
text: 'End of image.',
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should handle PNG data URLs', () => {
|
|
const base64Data =
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGA5w5EQwA7BHigu/QKBgAAAABJRU5ErkJggg==';
|
|
const dataUrl = `data:image/png;base64,${base64Data}`;
|
|
|
|
const prompt = JSON.stringify([
|
|
{
|
|
role: 'user',
|
|
parts: [
|
|
{
|
|
text: dataUrl,
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
|
|
const contextVars = {
|
|
image1: dataUrl,
|
|
};
|
|
|
|
const { contents } = geminiFormatAndSystemInstructions(prompt, contextVars);
|
|
|
|
expect(contents[0].parts).toEqual([
|
|
{
|
|
inlineData: {
|
|
mimeType: 'image/png',
|
|
data: base64Data,
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should handle GIF data URLs', () => {
|
|
const base64Data =
|
|
'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==';
|
|
const dataUrl = `data:image/gif;base64,${base64Data}`;
|
|
|
|
const prompt = JSON.stringify([
|
|
{
|
|
role: 'user',
|
|
parts: [
|
|
{
|
|
text: dataUrl,
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
|
|
const contextVars = {
|
|
image1: dataUrl,
|
|
};
|
|
|
|
const { contents } = geminiFormatAndSystemInstructions(prompt, contextVars);
|
|
|
|
expect(contents[0].parts).toEqual([
|
|
{
|
|
inlineData: {
|
|
mimeType: 'image/gif',
|
|
data: base64Data,
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should handle WebP data URLs', () => {
|
|
const base64Data =
|
|
'UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAgA0JaQAA3AA/vuUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=';
|
|
const dataUrl = `data:image/webp;base64,${base64Data}`;
|
|
|
|
const prompt = JSON.stringify([
|
|
{
|
|
role: 'user',
|
|
parts: [
|
|
{
|
|
text: dataUrl,
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
|
|
const contextVars = {
|
|
image1: dataUrl,
|
|
};
|
|
|
|
const { contents } = geminiFormatAndSystemInstructions(prompt, contextVars);
|
|
|
|
expect(contents[0].parts).toEqual([
|
|
{
|
|
inlineData: {
|
|
mimeType: 'image/webp',
|
|
data: base64Data,
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should handle mixed data URLs and raw base64', () => {
|
|
const rawBase64 = validBase64Image;
|
|
const dataUrlBase64 =
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGA5w5EQwA7BHigu/QKBgAAAABJRU5ErkJggg==';
|
|
const dataUrl = `data:image/png;base64,${dataUrlBase64}`;
|
|
|
|
const prompt = JSON.stringify([
|
|
{
|
|
role: 'user',
|
|
parts: [
|
|
{
|
|
text: `Raw image:\n${rawBase64}\nData URL image:\n${dataUrl}\nEnd.`,
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
|
|
const contextVars = {
|
|
rawImage: rawBase64,
|
|
dataUrlImage: dataUrl,
|
|
};
|
|
|
|
const { contents } = geminiFormatAndSystemInstructions(prompt, contextVars);
|
|
|
|
expect(contents).toEqual([
|
|
{
|
|
role: 'user',
|
|
parts: [
|
|
{
|
|
text: 'Raw image:',
|
|
},
|
|
{
|
|
inlineData: {
|
|
mimeType: 'image/jpeg',
|
|
data: rawBase64,
|
|
},
|
|
},
|
|
{
|
|
text: 'Data URL image:',
|
|
},
|
|
{
|
|
inlineData: {
|
|
mimeType: 'image/png',
|
|
data: dataUrlBase64, // Should extract raw base64 from data URL
|
|
},
|
|
},
|
|
{
|
|
text: 'End.',
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should preserve MIME type from data URL when available', () => {
|
|
// Test that MIME type from data URL takes precedence over magic number detection
|
|
const base64Data = validBase64Image;
|
|
// Use a different MIME type in data URL (though this would be unusual in practice)
|
|
const dataUrl = `data:image/jpeg;base64,${base64Data}`;
|
|
|
|
const prompt = JSON.stringify([
|
|
{
|
|
role: 'user',
|
|
parts: [
|
|
{
|
|
text: dataUrl,
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
|
|
const contextVars = {
|
|
image1: dataUrl,
|
|
};
|
|
|
|
const { contents } = geminiFormatAndSystemInstructions(prompt, contextVars);
|
|
|
|
expect(contents[0].parts[0]).toMatchObject({
|
|
inlineData: {
|
|
mimeType: 'image/jpeg',
|
|
data: base64Data,
|
|
},
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('edge cases for contents array handling', () => {
|
|
it('should handle empty contents array', () => {
|
|
const prompt = JSON.stringify([]);
|
|
const { contents } = geminiFormatAndSystemInstructions(prompt);
|
|
expect(Array.isArray(contents)).toBe(true);
|
|
expect(contents).toEqual([]);
|
|
});
|
|
|
|
it('should handle malformed prompt that results in empty array gracefully', () => {
|
|
// This tests the defensive guard in processImagesInContents
|
|
// by ensuring the function doesn't crash even with edge cases
|
|
const prompt = 'invalid json that cannot be parsed';
|
|
// parseChatPrompt will handle this and return a default format
|
|
const { contents } = geminiFormatAndSystemInstructions(prompt);
|
|
expect(Array.isArray(contents)).toBe(true);
|
|
// Should have at least one element with the prompt text
|
|
expect(contents.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should handle empty array with contextVars without error', () => {
|
|
const prompt = JSON.stringify([]);
|
|
const contextVars = {
|
|
image1:
|
|
'/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA/8A/9k=',
|
|
};
|
|
const { contents } = geminiFormatAndSystemInstructions(prompt, contextVars);
|
|
expect(Array.isArray(contents)).toBe(true);
|
|
expect(contents).toEqual([]);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('normalizeTools', () => {
|
|
it('should convert snake_case to camelCase for tool properties', () => {
|
|
const tools = [
|
|
{
|
|
google_search: {},
|
|
} as any,
|
|
{
|
|
code_execution: {},
|
|
} as any,
|
|
{
|
|
google_search_retrieval: {
|
|
dynamicRetrievalConfig: {
|
|
mode: 'MODE_DYNAMIC',
|
|
dynamicThreshold: 0,
|
|
},
|
|
},
|
|
} as any,
|
|
];
|
|
|
|
const normalized = normalizeTools(tools);
|
|
|
|
expect(normalized).toEqual([
|
|
{
|
|
google_search: {},
|
|
googleSearch: {},
|
|
},
|
|
{
|
|
code_execution: {},
|
|
codeExecution: {},
|
|
},
|
|
{
|
|
google_search_retrieval: {
|
|
dynamicRetrievalConfig: {
|
|
mode: 'MODE_DYNAMIC',
|
|
dynamicThreshold: 0,
|
|
},
|
|
},
|
|
googleSearchRetrieval: {
|
|
dynamicRetrievalConfig: {
|
|
mode: 'MODE_DYNAMIC',
|
|
dynamicThreshold: 0,
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should not override existing camelCase properties', () => {
|
|
const tools = [
|
|
{
|
|
google_search: { property1: 'value1' },
|
|
googleSearch: { property2: 'value2' },
|
|
} as any,
|
|
];
|
|
|
|
const normalized = normalizeTools(tools);
|
|
|
|
expect(normalized).toEqual([
|
|
{
|
|
google_search: { property1: 'value1' },
|
|
googleSearch: { property2: 'value2' },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should leave other properties unchanged', () => {
|
|
const tools = [
|
|
{
|
|
functionDeclarations: [
|
|
{
|
|
name: 'testFunction',
|
|
description: 'A test function',
|
|
},
|
|
],
|
|
google_search: {},
|
|
} as any,
|
|
];
|
|
|
|
const normalized = normalizeTools(tools);
|
|
|
|
expect(normalized).toEqual([
|
|
{
|
|
functionDeclarations: [
|
|
{
|
|
name: 'testFunction',
|
|
description: 'A test function',
|
|
},
|
|
],
|
|
google_search: {},
|
|
googleSearch: {},
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should handle empty arrays', () => {
|
|
const tools: any[] = [];
|
|
const normalized = normalizeTools(tools);
|
|
expect(normalized).toEqual([]);
|
|
});
|
|
|
|
it('should sanitize function declaration schemas by removing additionalProperties', () => {
|
|
const tools = [
|
|
{
|
|
functionDeclarations: [
|
|
{
|
|
name: 'test_tool',
|
|
description: 'A test tool',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
query: { type: 'string' },
|
|
},
|
|
additionalProperties: false, // Should be removed
|
|
},
|
|
},
|
|
],
|
|
} as any,
|
|
];
|
|
|
|
const normalized = normalizeTools(tools);
|
|
|
|
expect(normalized[0].functionDeclarations![0].parameters).not.toHaveProperty(
|
|
'additionalProperties',
|
|
);
|
|
expect(normalized[0].functionDeclarations![0].parameters).toEqual({
|
|
type: 'OBJECT',
|
|
properties: {
|
|
query: { type: 'STRING' },
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should sanitize nested schemas in function declarations', () => {
|
|
const tools = [
|
|
{
|
|
functionDeclarations: [
|
|
{
|
|
name: 'nested_tool',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
options: {
|
|
type: 'object',
|
|
properties: {
|
|
value: { type: 'number', default: 10 },
|
|
},
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
additionalProperties: false,
|
|
$schema: 'http://json-schema.org/draft-07/schema#',
|
|
},
|
|
},
|
|
],
|
|
} as any,
|
|
];
|
|
|
|
const normalized = normalizeTools(tools);
|
|
|
|
const params = normalized[0].functionDeclarations![0].parameters as any;
|
|
expect(params).not.toHaveProperty('additionalProperties');
|
|
expect(params).not.toHaveProperty('$schema');
|
|
expect(params.properties.options).not.toHaveProperty('additionalProperties');
|
|
expect(params.properties.options.properties.value).not.toHaveProperty('default');
|
|
});
|
|
|
|
it('should handle tools without functionDeclarations', () => {
|
|
const tools = [
|
|
{
|
|
googleSearch: {},
|
|
} as any,
|
|
];
|
|
|
|
const normalized = normalizeTools(tools);
|
|
|
|
expect(normalized).toEqual([
|
|
{
|
|
googleSearch: {},
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should handle functionDeclarations without parameters', () => {
|
|
const tools = [
|
|
{
|
|
functionDeclarations: [
|
|
{
|
|
name: 'no_params_tool',
|
|
description: 'Tool without parameters',
|
|
},
|
|
],
|
|
} as any,
|
|
];
|
|
|
|
const normalized = normalizeTools(tools);
|
|
|
|
expect(normalized[0].functionDeclarations![0]).toEqual({
|
|
name: 'no_params_tool',
|
|
description: 'Tool without parameters',
|
|
parameters: undefined,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('resolveProjectId', () => {
|
|
const mockProjectId = 'google-auth-project';
|
|
|
|
beforeEach(async () => {
|
|
// Clear the cached GoogleAuth instance before each test
|
|
clearCachedAuth();
|
|
|
|
// Stub environment variables to prevent Google Auth Library from reading local gcloud config
|
|
vi.stubEnv('VERTEX_PROJECT_ID', undefined);
|
|
vi.stubEnv('GOOGLE_PROJECT_ID', undefined);
|
|
vi.stubEnv('GCLOUD_PROJECT', undefined);
|
|
vi.stubEnv('GOOGLE_CLOUD_PROJECT', undefined);
|
|
vi.stubEnv('GOOGLE_APPLICATION_CREDENTIALS', undefined);
|
|
vi.stubEnv('CLOUDSDK_CONFIG', undefined);
|
|
vi.stubEnv('CLOUDSDK_CORE_PROJECT', undefined);
|
|
|
|
// Don't use vi.resetModules() - it breaks the mock for dynamic imports
|
|
// Instead, just reset the mock state
|
|
const { mockAuthInstance } = googleAuthMock;
|
|
mockAuthInstance.getClient.mockClear();
|
|
mockAuthInstance.fromJSON.mockClear();
|
|
mockAuthInstance.getProjectId.mockClear();
|
|
|
|
mockAuthInstance.getClient.mockResolvedValue({ name: 'mockClient' });
|
|
mockAuthInstance.fromJSON.mockImplementation((credentials: any) => {
|
|
return Promise.resolve({ name: 'mockCredentialClient', credentials });
|
|
});
|
|
mockAuthInstance.getProjectId.mockResolvedValue(mockProjectId);
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Restore environment variables
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
it('should prioritize explicit config over environment variables', async () => {
|
|
const config = { projectId: 'explicit-project' };
|
|
const env = { VERTEX_PROJECT_ID: 'env-project' };
|
|
|
|
const result = await resolveProjectId(config, env);
|
|
expect(result).toBe('explicit-project');
|
|
});
|
|
|
|
it('should use environment variables when no explicit config', async () => {
|
|
const config = {};
|
|
const env = { VERTEX_PROJECT_ID: 'env-project' };
|
|
|
|
const result = await resolveProjectId(config, env);
|
|
expect(result).toBe('env-project');
|
|
});
|
|
|
|
it('should fall back to Google Auth Library when no config or env vars', async () => {
|
|
clearCachedAuth();
|
|
const { mockAuthInstance } = googleAuthMock;
|
|
|
|
const config = {};
|
|
const env = {};
|
|
|
|
const result = await resolveProjectId(config, env);
|
|
|
|
// Verify the mock was called - this confirms our mock isolation is working
|
|
expect(mockAuthInstance.getProjectId).toHaveBeenCalled();
|
|
expect(result).toBe(mockProjectId);
|
|
});
|
|
|
|
it('should handle Google Auth Library getProjectId failure gracefully', async () => {
|
|
// Override mock to make getProjectId fail
|
|
const { mockAuthInstance } = googleAuthMock;
|
|
mockAuthInstance.getClient.mockResolvedValue({ name: 'mockClient' });
|
|
mockAuthInstance.fromJSON.mockResolvedValue({ name: 'mockCredentialClient' });
|
|
mockAuthInstance.getProjectId.mockRejectedValue(
|
|
new Error('Unable to detect a Project Id in the current environment'),
|
|
);
|
|
|
|
// Test that explicit config projectId is still used even when getProjectId fails
|
|
const config = {
|
|
projectId: 'explicit-project',
|
|
credentials: '{"type": "service_account", "project_id": "creds-project"}',
|
|
};
|
|
const env = {};
|
|
|
|
const result = await resolveProjectId(config, env);
|
|
expect(result).toBe('explicit-project');
|
|
|
|
// Verify that getProjectId was called but failed gracefully
|
|
expect(mockAuthInstance.getProjectId).toHaveBeenCalled();
|
|
expect(mockAuthInstance.fromJSON).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return empty string when all sources fail', async () => {
|
|
// Override the mock to make getProjectId fail
|
|
const { mockAuthInstance } = googleAuthMock;
|
|
mockAuthInstance.getProjectId.mockRejectedValue(
|
|
new Error('Unable to detect a Project Id in the current environment'),
|
|
);
|
|
|
|
// Test that when no projectId is available anywhere, we get empty string
|
|
const config = {};
|
|
const env = {};
|
|
|
|
const result = await resolveProjectId(config, env);
|
|
|
|
expect(result).toBe('');
|
|
// Verify that getProjectId was called but failed gracefully
|
|
expect(mockAuthInstance.getProjectId).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('sanitizeSchemaForGemini', () => {
|
|
it('should remove additionalProperties from schema', () => {
|
|
const schema = {
|
|
type: 'object',
|
|
properties: {
|
|
name: { type: 'string' },
|
|
},
|
|
additionalProperties: false,
|
|
};
|
|
|
|
const result = sanitizeSchemaForGemini(schema);
|
|
|
|
expect(result).toEqual({
|
|
type: 'OBJECT',
|
|
properties: {
|
|
name: { type: 'STRING' },
|
|
},
|
|
});
|
|
expect(result).not.toHaveProperty('additionalProperties');
|
|
});
|
|
|
|
it('should remove $schema from schema', () => {
|
|
const schema = {
|
|
$schema: 'http://json-schema.org/draft-07/schema#',
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'string' },
|
|
},
|
|
};
|
|
|
|
const result = sanitizeSchemaForGemini(schema);
|
|
|
|
expect(result).not.toHaveProperty('$schema');
|
|
expect(result).toEqual({
|
|
type: 'OBJECT',
|
|
properties: {
|
|
id: { type: 'STRING' },
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should remove default values from schema', () => {
|
|
const schema = {
|
|
type: 'object',
|
|
properties: {
|
|
count: {
|
|
type: 'number',
|
|
default: 10,
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = sanitizeSchemaForGemini(schema);
|
|
|
|
expect(result.properties.count).not.toHaveProperty('default');
|
|
expect(result).toEqual({
|
|
type: 'OBJECT',
|
|
properties: {
|
|
count: { type: 'NUMBER' },
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should convert lowercase types to uppercase', () => {
|
|
const schema = {
|
|
type: 'object',
|
|
properties: {
|
|
str: { type: 'string' },
|
|
num: { type: 'number' },
|
|
int: { type: 'integer' },
|
|
bool: { type: 'boolean' },
|
|
arr: { type: 'array', items: { type: 'string' } },
|
|
obj: { type: 'object', properties: {} },
|
|
},
|
|
};
|
|
|
|
const result = sanitizeSchemaForGemini(schema);
|
|
|
|
expect(result.type).toBe('OBJECT');
|
|
expect(result.properties.str.type).toBe('STRING');
|
|
expect(result.properties.num.type).toBe('NUMBER');
|
|
expect(result.properties.int.type).toBe('INTEGER');
|
|
expect(result.properties.bool.type).toBe('BOOLEAN');
|
|
expect(result.properties.arr.type).toBe('ARRAY');
|
|
expect(result.properties.obj.type).toBe('OBJECT');
|
|
});
|
|
|
|
it('should preserve already uppercase types', () => {
|
|
const schema = {
|
|
type: 'OBJECT',
|
|
properties: {
|
|
name: { type: 'STRING' },
|
|
},
|
|
};
|
|
|
|
const result = sanitizeSchemaForGemini(schema);
|
|
|
|
expect(result.type).toBe('OBJECT');
|
|
expect(result.properties.name.type).toBe('STRING');
|
|
});
|
|
|
|
it('should recursively sanitize nested properties', () => {
|
|
const schema = {
|
|
type: 'object',
|
|
properties: {
|
|
user: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
properties: {
|
|
name: { type: 'string', default: 'unknown' },
|
|
address: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
properties: {
|
|
street: { type: 'string' },
|
|
city: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
additionalProperties: false,
|
|
};
|
|
|
|
const result = sanitizeSchemaForGemini(schema);
|
|
|
|
expect(result).not.toHaveProperty('additionalProperties');
|
|
expect(result.properties.user).not.toHaveProperty('additionalProperties');
|
|
expect(result.properties.user.properties.name).not.toHaveProperty('default');
|
|
expect(result.properties.user.properties.address).not.toHaveProperty('additionalProperties');
|
|
|
|
expect(result.type).toBe('OBJECT');
|
|
expect(result.properties.user.type).toBe('OBJECT');
|
|
expect(result.properties.user.properties.address.type).toBe('OBJECT');
|
|
expect(result.properties.user.properties.address.properties.street.type).toBe('STRING');
|
|
});
|
|
|
|
it('should recursively sanitize array items', () => {
|
|
const schema = {
|
|
type: 'array',
|
|
items: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
properties: {
|
|
id: { type: 'string' },
|
|
tags: {
|
|
type: 'array',
|
|
items: { type: 'string', default: '' },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = sanitizeSchemaForGemini(schema);
|
|
|
|
expect(result.items).not.toHaveProperty('additionalProperties');
|
|
expect(result.items.properties.tags.items).not.toHaveProperty('default');
|
|
|
|
expect(result.type).toBe('ARRAY');
|
|
expect(result.items.type).toBe('OBJECT');
|
|
expect(result.items.properties.id.type).toBe('STRING');
|
|
expect(result.items.properties.tags.type).toBe('ARRAY');
|
|
expect(result.items.properties.tags.items.type).toBe('STRING');
|
|
});
|
|
|
|
it('should preserve supported Gemini schema properties', () => {
|
|
const schema = {
|
|
type: 'object',
|
|
format: 'date-time',
|
|
description: 'A user object',
|
|
nullable: true,
|
|
enum: ['a', 'b', 'c'],
|
|
required: ['name'],
|
|
properties: {
|
|
name: { type: 'string', description: 'User name' },
|
|
},
|
|
};
|
|
|
|
const result = sanitizeSchemaForGemini(schema);
|
|
|
|
expect(result).toEqual({
|
|
type: 'OBJECT',
|
|
format: 'date-time',
|
|
description: 'A user object',
|
|
nullable: true,
|
|
enum: ['a', 'b', 'c'],
|
|
required: ['name'],
|
|
properties: {
|
|
name: { type: 'STRING', description: 'User name' },
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should preserve minItems and maxItems for arrays', () => {
|
|
const schema = {
|
|
type: 'array',
|
|
minItems: 1,
|
|
maxItems: 10,
|
|
items: { type: 'string' },
|
|
};
|
|
|
|
const result = sanitizeSchemaForGemini(schema);
|
|
|
|
expect(result).toEqual({
|
|
type: 'ARRAY',
|
|
minItems: 1,
|
|
maxItems: 10,
|
|
items: { type: 'STRING' },
|
|
});
|
|
});
|
|
|
|
it('should handle real MCP SDK Zod-generated schema', () => {
|
|
// This represents what MCP SDK generates from Zod schemas
|
|
const mcpZodSchema = {
|
|
type: 'object',
|
|
properties: {
|
|
prompt: {
|
|
type: 'string',
|
|
description: 'The prompt to analyze',
|
|
},
|
|
maxTokens: {
|
|
type: 'number',
|
|
description: 'Maximum tokens to generate',
|
|
default: 1000,
|
|
},
|
|
options: {
|
|
type: 'object',
|
|
properties: {
|
|
temperature: {
|
|
type: 'number',
|
|
default: 0.7,
|
|
},
|
|
topK: {
|
|
type: 'integer',
|
|
},
|
|
},
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
required: ['prompt'],
|
|
additionalProperties: false,
|
|
$schema: 'http://json-schema.org/draft-07/schema#',
|
|
};
|
|
|
|
const result = sanitizeSchemaForGemini(mcpZodSchema);
|
|
|
|
// Should not have any unsupported properties
|
|
expect(result).not.toHaveProperty('$schema');
|
|
expect(result).not.toHaveProperty('additionalProperties');
|
|
expect(result.properties.maxTokens).not.toHaveProperty('default');
|
|
expect(result.properties.options).not.toHaveProperty('additionalProperties');
|
|
expect(result.properties.options.properties.temperature).not.toHaveProperty('default');
|
|
|
|
// Should have all supported properties with correct types
|
|
expect(result).toEqual({
|
|
type: 'OBJECT',
|
|
properties: {
|
|
prompt: {
|
|
type: 'STRING',
|
|
description: 'The prompt to analyze',
|
|
},
|
|
maxTokens: {
|
|
type: 'NUMBER',
|
|
description: 'Maximum tokens to generate',
|
|
},
|
|
options: {
|
|
type: 'OBJECT',
|
|
properties: {
|
|
temperature: {
|
|
type: 'NUMBER',
|
|
},
|
|
topK: {
|
|
type: 'INTEGER',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
required: ['prompt'],
|
|
});
|
|
});
|
|
|
|
it('should handle null or undefined schema gracefully', () => {
|
|
expect(sanitizeSchemaForGemini(null as any)).toBe(null);
|
|
expect(sanitizeSchemaForGemini(undefined as any)).toBe(undefined);
|
|
});
|
|
|
|
it('should handle empty schema object', () => {
|
|
const schema = {};
|
|
|
|
const result = sanitizeSchemaForGemini(schema);
|
|
|
|
expect(result).toEqual({});
|
|
});
|
|
|
|
it('should handle null type mapping', () => {
|
|
// Gemini doesn't support null type directly, map to STRING
|
|
const schema = {
|
|
type: 'null',
|
|
};
|
|
|
|
const result = sanitizeSchemaForGemini(schema);
|
|
|
|
expect(result.type).toBe('STRING');
|
|
});
|
|
|
|
it('should remove anyOf, oneOf, allOf (not supported by Gemini)', () => {
|
|
const schema = {
|
|
type: 'object',
|
|
properties: {
|
|
value: {
|
|
anyOf: [{ type: 'string' }, { type: 'number' }],
|
|
},
|
|
},
|
|
oneOf: [{ type: 'object' }],
|
|
allOf: [{ required: ['name'] }],
|
|
};
|
|
|
|
const result = sanitizeSchemaForGemini(schema);
|
|
|
|
expect(result).not.toHaveProperty('anyOf');
|
|
expect(result).not.toHaveProperty('oneOf');
|
|
expect(result).not.toHaveProperty('allOf');
|
|
// The nested anyOf in value property should also be removed
|
|
expect(result.properties.value).not.toHaveProperty('anyOf');
|
|
});
|
|
});
|
|
|
|
describe('calculateGoogleCost', () => {
|
|
it('should return undefined for missing token counts', () => {
|
|
expect(calculateGoogleCost('gemini-pro', {}, undefined, 100)).toBeUndefined();
|
|
expect(calculateGoogleCost('gemini-pro', {}, 100, undefined)).toBeUndefined();
|
|
expect(calculateGoogleCost('gemini-pro', {}, undefined, undefined)).toBeUndefined();
|
|
});
|
|
|
|
it('should return undefined for unknown models', () => {
|
|
expect(calculateGoogleCost('unknown-model', {}, 100, 50)).toBeUndefined();
|
|
});
|
|
|
|
it('should calculate cost for gemini-pro model', () => {
|
|
// gemini-pro: input=0.5/1M, output=1.5/1M
|
|
const cost = calculateGoogleCost('gemini-pro', {}, 1000, 500);
|
|
// Expected: (1000 * 0.5 + 500 * 1.5) / 1M = (500 + 750) / 1M = 0.00125
|
|
expect(cost).toBeCloseTo(0.00125, 10);
|
|
});
|
|
|
|
it('should calculate cost for gemini-2.0-flash model', () => {
|
|
// gemini-2.0-flash: input=0.1/1M, output=0.4/1M
|
|
const cost = calculateGoogleCost('gemini-2.0-flash', {}, 10000, 5000);
|
|
// Expected: (10000 * 0.1 + 5000 * 0.4) / 1M = (1000 + 2000) / 1M = 0.003
|
|
expect(cost).toBeCloseTo(0.003, 10);
|
|
});
|
|
|
|
it('should calculate cost for gemini-2.5-flash model', () => {
|
|
// gemini-2.5-flash: input=0.3/1M, output=2.5/1M
|
|
const cost = calculateGoogleCost('gemini-2.5-flash', {}, 1000, 500);
|
|
// Expected: (1000 * 0.3 + 500 * 2.5) / 1M = (300 + 1250) / 1M = 0.00155
|
|
expect(cost).toBeCloseTo(0.00155, 10);
|
|
});
|
|
|
|
it('should apply tiered pricing for gemini-3.1-pro-preview when above threshold', () => {
|
|
// gemini-3.1-pro-preview: base input=2.0/1M, output=12.0/1M
|
|
// tiered (>200k): input=4.0/1M, output=18.0/1M
|
|
const costBelowThreshold = calculateGoogleCost('gemini-3.1-pro-preview', {}, 100000, 50000);
|
|
// Expected (below 200k): (100000 * 2.0 + 50000 * 12.0) / 1M = 0.8
|
|
expect(costBelowThreshold).toBeCloseTo(0.8, 10);
|
|
|
|
const costAboveThreshold = calculateGoogleCost('gemini-3.1-pro-preview', {}, 250000, 50000);
|
|
// Expected (above 200k): (250000 * 4.0 + 50000 * 18.0) / 1M = 1.9
|
|
expect(costAboveThreshold).toBeCloseTo(1.9, 10);
|
|
});
|
|
|
|
it('should apply tiered pricing for gemini-3.1-pro-preview-customtools when above threshold', () => {
|
|
// gemini-3.1-pro-preview-customtools: base input=2.0/1M, output=12.0/1M
|
|
// tiered (>200k): input=4.0/1M, output=18.0/1M
|
|
const costBelowThreshold = calculateGoogleCost(
|
|
'gemini-3.1-pro-preview-customtools',
|
|
{},
|
|
100000,
|
|
50000,
|
|
);
|
|
expect(costBelowThreshold).toBeCloseTo(0.8, 10);
|
|
|
|
const costAboveThreshold = calculateGoogleCost(
|
|
'gemini-3.1-pro-preview-customtools',
|
|
{},
|
|
250000,
|
|
50000,
|
|
);
|
|
expect(costAboveThreshold).toBeCloseTo(1.9, 10);
|
|
});
|
|
|
|
it('should preserve tiered pricing for gemini-3-pro-preview alias', () => {
|
|
const cost = calculateGoogleCost('gemini-3-pro-preview', {}, 250000, 50000);
|
|
expect(cost).toBeCloseTo(1.9, 10);
|
|
});
|
|
|
|
it('should calculate cost for gemini-3.1-flash-lite-preview', () => {
|
|
// gemini-3.1-flash-lite-preview: input=0.25/1M, output=1.5/1M
|
|
const cost = calculateGoogleCost('gemini-3.1-flash-lite-preview', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo(0.001, 10);
|
|
});
|
|
|
|
it('should calculate cost for gemini-3.1-flash-lite (GA)', () => {
|
|
// gemini-3.1-flash-lite: input=0.25/1M, output=1.5/1M
|
|
const cost = calculateGoogleCost('gemini-3.1-flash-lite', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo(0.001, 10);
|
|
});
|
|
|
|
it('should calculate cost for gemini-3.5-flash', () => {
|
|
// gemini-3.5-flash: input=1.5/1M, output=9.0/1M
|
|
const cost = calculateGoogleCost('gemini-3.5-flash', {}, 1000, 500);
|
|
// Expected: (1000 * 1.5 + 500 * 9.0) / 1M = (1500 + 4500) / 1M = 0.006
|
|
expect(cost).toBeCloseTo(0.006, 10);
|
|
});
|
|
|
|
it('should not apply long-context tiered pricing to gemini-3.5-flash', () => {
|
|
// gemini-3.5-flash is flat-rate: prompts above 200k tokens bill at the
|
|
// standard rate (unlike the tiered gemini-3.1-pro-preview).
|
|
const cost = calculateGoogleCost('gemini-3.5-flash', {}, 250000, 50000);
|
|
// Expected: (250000 * 1.5 + 50000 * 9.0) / 1M = (375000 + 450000) / 1M = 0.825
|
|
expect(cost).toBeCloseTo(0.825, 10);
|
|
});
|
|
|
|
it('should calculate cost for gemini-embedding-2', () => {
|
|
// gemini-embedding-2: input=0.2/1M, output=0
|
|
const cost = calculateGoogleCost('gemini-embedding-2', {}, 10000, 0);
|
|
// Expected: (10000 * 0.2) / 1M = 0.002
|
|
expect(cost).toBeCloseTo(0.002, 10);
|
|
});
|
|
|
|
it('should calculate cost for gemini-embedding-2-preview (tracks gemini-embedding-2)', () => {
|
|
// gemini-embedding-2-preview: input=0.2/1M, output=0
|
|
const cost = calculateGoogleCost('gemini-embedding-2-preview', {}, 10000, 0);
|
|
expect(cost).toBeCloseTo(0.002, 10);
|
|
});
|
|
|
|
it('should apply Gemini 3 Pro tiered pricing for the gemini-pro-latest alias', () => {
|
|
// gemini-pro-latest resolves to the current Gemini Pro snapshot
|
|
// (gemini-3.1-pro-preview): base input=2.0/1M, output=12.0/1M;
|
|
// tiered (>200k): input=4.0/1M, output=18.0/1M.
|
|
const costBelowThreshold = calculateGoogleCost('gemini-pro-latest', {}, 100000, 50000);
|
|
// Expected (below 200k): (100000 * 2.0 + 50000 * 12.0) / 1M = 0.8
|
|
expect(costBelowThreshold).toBeCloseTo(0.8, 10);
|
|
|
|
const costAboveThreshold = calculateGoogleCost('gemini-pro-latest', {}, 250000, 50000);
|
|
// Expected (above 200k): (250000 * 4.0 + 50000 * 18.0) / 1M = 1.9
|
|
expect(costAboveThreshold).toBeCloseTo(1.9, 10);
|
|
});
|
|
|
|
it('should apply tiered pricing for gemini-2.5-pro when above threshold', () => {
|
|
// gemini-2.5-pro: base input=1.25/1M, output=10.0/1M
|
|
// tiered (>200k): input=2.5/1M, output=15.0/1M
|
|
const costBelowThreshold = calculateGoogleCost('gemini-2.5-pro', {}, 100000, 50000);
|
|
// Expected (below 200k): (100000 * 1.25 + 50000 * 10.0) / 1M = 0.625
|
|
expect(costBelowThreshold).toBeCloseTo(0.625, 10);
|
|
|
|
const costAboveThreshold = calculateGoogleCost('gemini-2.5-pro', {}, 250000, 50000);
|
|
// Expected (above 200k): (250000 * 2.5 + 50000 * 15.0) / 1M = 1.375
|
|
expect(costAboveThreshold).toBeCloseTo(1.375, 10);
|
|
});
|
|
|
|
it('should apply tiered pricing for gemini-1.5-pro when above threshold', () => {
|
|
// gemini-1.5-pro: base input=1.25/1M, output=5.0/1M
|
|
// tiered (>128k): input=2.5/1M, output=10.0/1M
|
|
const costBelowThreshold = calculateGoogleCost('gemini-1.5-pro', {}, 100000, 50000);
|
|
// Expected (below 128k): (100000 * 1.25 + 50000 * 5.0) / 1M = 0.375
|
|
expect(costBelowThreshold).toBeCloseTo(0.375, 10);
|
|
|
|
const costAboveThreshold = calculateGoogleCost('gemini-1.5-pro', {}, 150000, 50000);
|
|
// Expected (above 128k): (150000 * 2.5 + 50000 * 10.0) / 1M = 0.875
|
|
expect(costAboveThreshold).toBeCloseTo(0.875, 10);
|
|
});
|
|
|
|
it('should apply tiered pricing for gemini-1.5-flash when above threshold', () => {
|
|
// gemini-1.5-flash: base input=0.075/1M, output=0.3/1M
|
|
// tiered (>128k): input=0.15/1M, output=0.6/1M
|
|
const costBelowThreshold = calculateGoogleCost('gemini-1.5-flash', {}, 100000, 50000);
|
|
// Expected (below 128k): (100000 * 0.075 + 50000 * 0.3) / 1M = 0.0225
|
|
expect(costBelowThreshold).toBeCloseTo(0.0225, 10);
|
|
|
|
const costAboveThreshold = calculateGoogleCost('gemini-1.5-flash', {}, 150000, 50000);
|
|
// Expected (above 128k): (150000 * 0.15 + 50000 * 0.6) / 1M = 0.0525
|
|
expect(costAboveThreshold).toBeCloseTo(0.0525, 10);
|
|
});
|
|
|
|
it('should apply tiered pricing for gemini-1.5-flash-8b when above threshold', () => {
|
|
// gemini-1.5-flash-8b: base input=0.0375/1M, output=0.15/1M
|
|
// tiered (>128k): input=0.075/1M, output=0.3/1M
|
|
const costBelowThreshold = calculateGoogleCost('gemini-1.5-flash-8b', {}, 100000, 50000);
|
|
// Expected (below 128k): (100000 * 0.0375 + 50000 * 0.15) / 1M = 0.01125
|
|
expect(costBelowThreshold).toBeCloseTo(0.01125, 10);
|
|
|
|
const costAboveThreshold = calculateGoogleCost('gemini-1.5-flash-8b', {}, 150000, 50000);
|
|
// Expected (above 128k): (150000 * 0.075 + 50000 * 0.3) / 1M = 0.02625
|
|
expect(costAboveThreshold).toBeCloseTo(0.02625, 10);
|
|
});
|
|
|
|
it('should calculate cost for gemini-embedding-001', () => {
|
|
// gemini-embedding-001: input=0.15/1M, output=0
|
|
const cost = calculateGoogleCost('gemini-embedding-001', {}, 10000, 0);
|
|
// Expected: (10000 * 0.15 + 0 * 0) / 1M = 0.0015
|
|
expect(cost).toBeCloseTo(0.0015, 10);
|
|
});
|
|
|
|
it('should calculate cost for gemini-robotics-er-1.6-preview', () => {
|
|
// gemini-robotics-er-1.6-preview: input=1.0/1M, output=5.0/1M
|
|
const cost = calculateGoogleCost('gemini-robotics-er-1.6-preview', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo(0.0035, 10);
|
|
});
|
|
|
|
it('should calculate cost for gemini-flash-latest using the tier it currently resolves to', () => {
|
|
// gemini-flash-latest resolves server-side to gemini-3.5-flash: input=1.5/1M, output=9/1M
|
|
const cost = calculateGoogleCost('gemini-flash-latest', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo(0.006, 10);
|
|
});
|
|
|
|
it('should calculate cost for gemini-flash-lite-latest using the tier it currently resolves to', () => {
|
|
// gemini-flash-lite-latest resolves server-side to gemini-3.1-flash-lite: input=0.25/1M, output=1.5/1M
|
|
const cost = calculateGoogleCost('gemini-flash-lite-latest', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo(0.001, 10);
|
|
});
|
|
|
|
it('should return undefined for shutdown models', () => {
|
|
// Deprecated/shutdown Google model IDs: these should not appear in GOOGLE_MODELS
|
|
// and should return undefined pricing if referenced directly.
|
|
// Keep this list aligned with Google's model lifecycle/deprecation documentation.
|
|
const shutdownModels = [
|
|
'gemini-2.5-pro-preview-05-06',
|
|
'gemini-2.5-pro-preview-06-05',
|
|
'gemini-2.5-flash-preview-05-20',
|
|
'gemini-2.5-flash-preview-09-2025',
|
|
'gemini-2.5-flash-lite-preview-09-2025',
|
|
'gemini-2.0-pro',
|
|
'gemini-2.0-flash-exp',
|
|
'gemini-2.0-flash-thinking-exp',
|
|
'gemini-2.0-flash-lite-preview-02-05',
|
|
'gemini-robotics-er-1.5-preview',
|
|
];
|
|
const catalogModelIds = GOOGLE_MODELS.map((model) => model.id);
|
|
|
|
for (const model of shutdownModels) {
|
|
expect(catalogModelIds).not.toContain(model);
|
|
expect(calculateGoogleCost(model, {}, 1000, 500)).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it('should return undefined for models without pricing data', () => {
|
|
// Legacy PaLM models don't have pricing
|
|
expect(calculateGoogleCost('chat-bison', {}, 100, 50)).toBeUndefined();
|
|
expect(calculateGoogleCost('gemma', {}, 100, 50)).toBeUndefined();
|
|
});
|
|
|
|
it('should respect custom cost override in config', () => {
|
|
// When config.cost is set, it should override default pricing
|
|
const config = { cost: 0.001 }; // $1 per 1000 tokens
|
|
const cost = calculateGoogleCost('gemini-pro', config, 1000, 500);
|
|
// Expected: (1000 + 500) * 0.001 = 1.5
|
|
expect(cost).toBeCloseTo(1.5, 10);
|
|
});
|
|
|
|
it('should respect separate custom input and output cost overrides in config', () => {
|
|
const config = { inputCost: 0.001, outputCost: 0.003 };
|
|
const cost = calculateGoogleCost('gemini-pro', config, 1000, 500);
|
|
expect(cost).toBeCloseTo(2.5, 10);
|
|
});
|
|
|
|
it('should prefer separate custom costs over custom cost', () => {
|
|
const config = { cost: 0.02, inputCost: 0.001, outputCost: 0.003 };
|
|
const cost = calculateGoogleCost('gemini-pro', config, 1000, 500);
|
|
expect(cost).toBeCloseTo(2.5, 10);
|
|
});
|
|
|
|
it('should respect separate custom costs for tiered pricing', () => {
|
|
const config = { inputCost: 0.001, outputCost: 0.003 };
|
|
const cost = calculateGoogleCost('gemini-2.5-pro', config, 250000, 50000);
|
|
expect(cost).toBeCloseTo(400, 10);
|
|
});
|
|
|
|
it('should use Vertex-specific pricing for gemini-2.0-flash in Vertex mode', () => {
|
|
// AI Studio: input=0.1/1M, output=0.4/1M
|
|
// Vertex AI: input=0.15/1M, output=0.6/1M
|
|
const aiStudioCost = calculateGoogleCost('gemini-2.0-flash', {}, 10000, 5000);
|
|
const vertexCost = calculateGoogleCost('gemini-2.0-flash', {}, 10000, 5000, true);
|
|
// AI Studio: (10000 * 0.1 + 5000 * 0.4) / 1M = 0.003
|
|
expect(aiStudioCost).toBeCloseTo(0.003, 10);
|
|
// Vertex: (10000 * 0.15 + 5000 * 0.6) / 1M = 0.0045
|
|
expect(vertexCost).toBeCloseTo(0.0045, 10);
|
|
});
|
|
|
|
it('should respect separate custom costs for Vertex-specific pricing', () => {
|
|
const config = { inputCost: 0.001, outputCost: 0.003 };
|
|
const cost = calculateGoogleCost('gemini-2.0-flash', config, 10000, 5000, true);
|
|
expect(cost).toBeCloseTo(25, 10);
|
|
});
|
|
|
|
it('should use standard pricing for models without Vertex-specific pricing in Vertex mode', () => {
|
|
// gemini-2.5-flash has no vertexCost, so Vertex mode uses the same price
|
|
const aiStudioCost = calculateGoogleCost('gemini-2.5-flash', {}, 1000, 500);
|
|
const vertexCost = calculateGoogleCost('gemini-2.5-flash', {}, 1000, 500, true);
|
|
expect(vertexCost).toBeCloseTo(aiStudioCost!, 10);
|
|
});
|
|
});
|
|
|
|
describe('normalizeSafetySettings', () => {
|
|
it('should return undefined when given undefined', () => {
|
|
expect(normalizeSafetySettings(undefined)).toBeUndefined();
|
|
});
|
|
|
|
it('should pass through threshold field as-is', () => {
|
|
const result = normalizeSafetySettings([
|
|
{ category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_ONLY_HIGH' },
|
|
]);
|
|
expect(result).toEqual([
|
|
{ category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_ONLY_HIGH' },
|
|
]);
|
|
});
|
|
|
|
it('should map legacy probability field to threshold', () => {
|
|
const result = normalizeSafetySettings([
|
|
{ category: 'HARM_CATEGORY_HARASSMENT', probability: 'BLOCK_MEDIUM_AND_ABOVE' },
|
|
]);
|
|
expect(result).toEqual([
|
|
{ category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_MEDIUM_AND_ABOVE' },
|
|
]);
|
|
});
|
|
|
|
it('should prefer threshold over probability when both are set', () => {
|
|
const result = normalizeSafetySettings([
|
|
{
|
|
category: 'HARM_CATEGORY_HARASSMENT',
|
|
threshold: 'BLOCK_ONLY_HIGH',
|
|
probability: 'BLOCK_MEDIUM_AND_ABOVE',
|
|
},
|
|
]);
|
|
expect(result).toEqual([
|
|
{ category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_ONLY_HIGH' },
|
|
]);
|
|
});
|
|
|
|
it('should handle multiple safety settings', () => {
|
|
const result = normalizeSafetySettings([
|
|
{ category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_ONLY_HIGH' },
|
|
{ category: 'HARM_CATEGORY_HATE_SPEECH', probability: 'BLOCK_MEDIUM_AND_ABOVE' },
|
|
]);
|
|
expect(result).toEqual([
|
|
{ category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_ONLY_HIGH' },
|
|
{ category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_MEDIUM_AND_ABOVE' },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('resolveGoogleToolConfig', () => {
|
|
it('any disable signal forces NONE, even when explicit toolConfig says AUTO', () => {
|
|
// Documents the safety bias: a "no tools" signal from any source wins.
|
|
// If this needs to flip, update the test with the new precedence rule.
|
|
const result = resolveGoogleToolConfig({
|
|
toolConfig: { functionCallingConfig: { mode: 'AUTO' } },
|
|
tool_choice: 'none',
|
|
});
|
|
expect(result).toEqual({
|
|
toolConfig: { functionCallingConfig: { mode: 'NONE' } },
|
|
toolsDisabled: true,
|
|
});
|
|
});
|
|
|
|
it('preserves AUTO when no disable signal is present', () => {
|
|
const result = resolveGoogleToolConfig({
|
|
toolConfig: { functionCallingConfig: { mode: 'AUTO' } },
|
|
});
|
|
expect(result).toEqual({
|
|
toolConfig: { functionCallingConfig: { mode: 'AUTO' } },
|
|
toolsDisabled: false,
|
|
});
|
|
});
|
|
|
|
it('falls back to tool_choice when explicit toolConfig has invalid mode', () => {
|
|
const result = resolveGoogleToolConfig({
|
|
toolConfig: { functionCallingConfig: { mode: 'BOGUS' as any } },
|
|
tool_choice: 'required',
|
|
});
|
|
expect(result).toEqual({
|
|
toolConfig: { functionCallingConfig: { mode: 'ANY' } },
|
|
toolsDisabled: false,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('removeGoogleFunctionDeclarations', () => {
|
|
it('drops tools that only contain functionDeclarations', () => {
|
|
const tools = [{ functionDeclarations: [{ name: 'foo' }] }, { googleSearch: {} }] as Tool[];
|
|
expect(removeGoogleFunctionDeclarations(tools)).toEqual([{ googleSearch: {} }]);
|
|
});
|
|
|
|
it('drops empty functionDeclarations entries', () => {
|
|
const tools = [{ functionDeclarations: [] }] as Tool[];
|
|
expect(removeGoogleFunctionDeclarations(tools)).toEqual([]);
|
|
});
|
|
|
|
it('strips functionDeclarations from mixed-capability tool entries', () => {
|
|
const tools = [
|
|
{
|
|
functionDeclarations: [{ name: 'foo' }],
|
|
googleSearch: {},
|
|
},
|
|
] as Tool[];
|
|
expect(removeGoogleFunctionDeclarations(tools)).toEqual([{ googleSearch: {} }]);
|
|
});
|
|
|
|
it('passes through non-function tools unchanged', () => {
|
|
const tools = [{ googleSearch: {} }, { codeExecution: {} }] as Tool[];
|
|
expect(removeGoogleFunctionDeclarations(tools)).toEqual([
|
|
{ googleSearch: {} },
|
|
{ codeExecution: {} },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('stripExecutableToolFileReferences', () => {
|
|
it('removes executable refs while preserving inline and data-file tools', () => {
|
|
expect(
|
|
stripExecutableToolFileReferences([
|
|
{ googleSearch: {} },
|
|
'file://tools.js:getTools',
|
|
'file://tools.py:get_tools',
|
|
'file://tools.json',
|
|
'file://tools.yaml',
|
|
{ codeExecution: {} },
|
|
]),
|
|
).toEqual([
|
|
{ googleSearch: {} },
|
|
'file://tools.json',
|
|
'file://tools.yaml',
|
|
{ codeExecution: {} },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('mergeParts', () => {
|
|
it('detaches the initial multipart chunk and reuses the accumulator thereafter', () => {
|
|
const firstChunk = [{ functionCall: { name: 'look_up', args: { query: 'weather' } } }];
|
|
const secondChunk = [{ text: 'done' }];
|
|
|
|
const accumulator = mergeParts(undefined, firstChunk);
|
|
if (!Array.isArray(accumulator)) {
|
|
throw new Error('Expected multipart output');
|
|
}
|
|
|
|
expect(accumulator).not.toBe(firstChunk);
|
|
expect(mergeParts(accumulator, secondChunk)).toBe(accumulator);
|
|
expect(accumulator).toEqual([...firstChunk, ...secondChunk]);
|
|
expect(firstChunk).toEqual([
|
|
{ functionCall: { name: 'look_up', args: { query: 'weather' } } },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('mergeGoogleCompletionOptions', () => {
|
|
it('treats prompt-level undefined as "not set" and preserves base policy', () => {
|
|
const merged = mergeGoogleCompletionOptions(
|
|
{
|
|
toolConfig: { functionCallingConfig: { mode: 'AUTO' } },
|
|
},
|
|
{ tool_choice: undefined } as any,
|
|
);
|
|
expect(merged.toolConfig).toEqual({ functionCallingConfig: { mode: 'AUTO' } });
|
|
expect(merged.tool_choice).toBeUndefined();
|
|
});
|
|
|
|
it('replaces all three policy fields when prompt sets any one explicitly', () => {
|
|
const merged = mergeGoogleCompletionOptions(
|
|
{
|
|
toolConfig: { functionCallingConfig: { mode: 'AUTO' } },
|
|
tool_config: { function_calling_config: { mode: 'AUTO' } },
|
|
},
|
|
{ tool_choice: 'none' },
|
|
);
|
|
expect(merged.tool_choice).toBe('none');
|
|
expect(merged.toolConfig).toBeUndefined();
|
|
expect(merged.tool_config).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('collectGroundingMetadata', () => {
|
|
it('returns empty object when no candidates carry grounding signals', () => {
|
|
const result = collectGroundingMetadata([
|
|
{ candidates: [{ content: { parts: [{ text: 'plain' }] } }] } as any,
|
|
]);
|
|
expect(result).toEqual({});
|
|
});
|
|
|
|
it('returns the single candidate metadata as-is', () => {
|
|
const groundingMetadata = {
|
|
webSearchQueries: ['q'],
|
|
groundingChunks: [{ web: { uri: 'https://x' } }],
|
|
};
|
|
const result = collectGroundingMetadata([
|
|
{
|
|
candidates: [{ content: { parts: [{ text: 't' }] }, groundingMetadata }],
|
|
} as any,
|
|
]);
|
|
expect(result.groundingMetadata).toEqual(groundingMetadata);
|
|
});
|
|
|
|
it('concatenates array fields across nested groundingMetadata chunks and keeps the last refinement', () => {
|
|
const result = collectGroundingMetadata([
|
|
{
|
|
candidates: [
|
|
{
|
|
content: { parts: [{ text: 'a' }] },
|
|
groundingMetadata: {
|
|
webSearchQueries: ['q1'],
|
|
groundingChunks: [{ web: { uri: 'https://1' } }],
|
|
searchEntryPoint: { renderedContent: 'old' },
|
|
},
|
|
},
|
|
],
|
|
} as any,
|
|
{
|
|
candidates: [
|
|
{
|
|
content: { parts: [{ text: 'b' }] },
|
|
groundingMetadata: {
|
|
webSearchQueries: ['q2'],
|
|
groundingChunks: [{ web: { uri: 'https://2' } }],
|
|
groundingSupports: [{ segment: { startIndex: 0, endIndex: 1 } }],
|
|
searchEntryPoint: { renderedContent: 'new' },
|
|
retrievalMetadata: { dynamicRetrievalScore: 0.9 },
|
|
},
|
|
},
|
|
],
|
|
} as any,
|
|
]);
|
|
const merged = result.groundingMetadata as Record<string, any>;
|
|
expect(merged.webSearchQueries).toEqual(['q1', 'q2']);
|
|
expect(merged.groundingChunks).toEqual([
|
|
{ web: { uri: 'https://1' } },
|
|
{ web: { uri: 'https://2' } },
|
|
]);
|
|
expect(merged.groundingSupports).toEqual([{ segment: { startIndex: 0, endIndex: 1 } }]);
|
|
expect(merged.searchEntryPoint).toEqual({ renderedContent: 'new' });
|
|
expect(merged.retrievalMetadata).toEqual({ dynamicRetrievalScore: 0.9 });
|
|
});
|
|
|
|
it('concatenates flat grounding fields when candidates carry them directly', () => {
|
|
const result = collectGroundingMetadata([
|
|
{
|
|
candidates: [
|
|
{
|
|
content: { parts: [{ text: 'a' }] },
|
|
webSearchQueries: ['x'],
|
|
groundingChunks: [{ web: { uri: 'https://x' } }],
|
|
},
|
|
],
|
|
} as any,
|
|
{
|
|
candidates: [
|
|
{
|
|
content: { parts: [{ text: 'b' }] },
|
|
webSearchQueries: ['y'],
|
|
groundingSupports: [{ segment: { startIndex: 0, endIndex: 1 } }],
|
|
},
|
|
],
|
|
} as any,
|
|
]);
|
|
expect(result.webSearchQueries).toEqual(['x', 'y']);
|
|
expect(result.groundingChunks).toEqual([{ web: { uri: 'https://x' } }]);
|
|
expect(result.groundingSupports).toEqual([{ segment: { startIndex: 0, endIndex: 1 } }]);
|
|
expect(result.groundingMetadata).toBeUndefined();
|
|
});
|
|
});
|
|
});
|