Files
wehub-resource-sync 0d3cb498a3
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

3749 lines
112 KiB
TypeScript

import * as fs from 'fs';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import cliState from '../../../src/cliState';
import logger from '../../../src/logger';
import * as vertexUtil from '../../../src/providers/google/util';
import { VertexChatProvider } from '../../../src/providers/google/vertex';
import type { JSONClient } from 'google-auth-library/build/src/auth/googleauth';
// Hoisted mocks for cache
const mockCacheGet = vi.hoisted(() => vi.fn());
const mockCacheSet = vi.hoisted(() => vi.fn());
const mockIsCacheEnabled = vi.hoisted(() => vi.fn());
// Hoisted mock for importModule
const mockImportModule = vi.hoisted(() => vi.fn());
// Mock database
vi.mock('libsql', () => {
return vi.fn().mockReturnValue({
prepare: vi.fn(),
transaction: vi.fn(),
exec: vi.fn(),
close: vi.fn(),
});
});
vi.mock('../../../src/database', async (importOriginal) => {
return {
...(await importOriginal()),
getDb: vi.fn().mockReturnValue({
prepare: vi.fn(),
transaction: vi.fn(),
exec: vi.fn(),
close: vi.fn(),
}),
};
});
vi.mock('csv-stringify/sync', async (importOriginal) => {
return {
...(await importOriginal()),
stringify: vi.fn().mockReturnValue('mocked,csv,output'),
};
});
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(),
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
statSync: vi.fn(),
mkdirSync: vi.fn(),
};
});
vi.mock('../../../src/cache', async (importOriginal) => {
return {
...(await importOriginal()),
getCache: vi.fn().mockImplementation(() => ({
get: mockCacheGet,
set: mockCacheSet,
wrap: vi.fn(),
del: vi.fn(),
reset: vi.fn(),
store: {} as any,
})),
isCacheEnabled: mockIsCacheEnabled,
};
});
vi.mock('../../../src/providers/google/util', async () => {
const actual = await vi.importActual<typeof import('../../../src/providers/google/util')>(
'../../../src/providers/google/util',
);
return {
...actual,
getGoogleClient: vi.fn(),
loadCredentials: vi.fn(),
resolveProjectId: vi.fn(),
};
});
// Mock GoogleAuthManager to prevent API key detection from environment
vi.mock('../../../src/providers/google/auth', async () => {
const actual = await vi.importActual<typeof import('../../../src/providers/google/auth')>(
'../../../src/providers/google/auth',
);
return {
...actual,
GoogleAuthManager: {
...actual.GoogleAuthManager,
// Return no API key by default so tests use OAuth mode
getApiKey: vi.fn().mockReturnValue({ apiKey: undefined, source: 'none' }),
determineVertexMode: vi.fn().mockReturnValue(true),
validateAndWarn: vi.fn(),
// Respect config.region when provided, otherwise default to us-central1
resolveRegion: vi.fn().mockImplementation((config?: { region?: string }) => {
return config?.region || 'us-central1';
}),
resolveProjectId: vi.fn().mockResolvedValue('test-project-id'),
},
};
});
vi.mock('../../../src/esm', async (importOriginal) => {
return {
...(await importOriginal()),
importModule: mockImportModule,
};
});
function mockVertexRequest(data: unknown) {
const mockRequest = vi.fn().mockResolvedValue({ data });
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: {
request: mockRequest,
} as unknown as JSONClient,
projectId: 'test-project-id',
});
vi.spyOn(vertexUtil, 'loadCredentials').mockImplementation(function (creds) {
if (typeof creds === 'object') {
return JSON.stringify(creds);
}
return creds;
});
vi.spyOn(vertexUtil, 'resolveProjectId').mockResolvedValue('test-project-id');
return mockRequest;
}
function expectHashedBodyCacheKeys(expectedPattern: RegExp, forbiddenValues: string[]) {
expect(mockCacheGet).toHaveBeenCalledTimes(1);
expect(mockCacheSet).toHaveBeenCalledTimes(1);
const cacheGetKey = mockCacheGet.mock.calls[0][0] as string;
const cacheSetKey = mockCacheSet.mock.calls[0][0] as string;
expect(cacheGetKey).toBe(cacheSetKey);
expect(cacheSetKey).toMatch(expectedPattern);
for (const value of forbiddenValues) {
expect(cacheSetKey).not.toContain(value);
}
return cacheSetKey;
}
describe('VertexChatProvider.callGeminiApi', () => {
let provider: VertexChatProvider;
beforeEach(() => {
// Reset cache mocks to default state (no cached response)
mockCacheGet.mockReset();
mockCacheGet.mockResolvedValue(null);
mockCacheSet.mockReset();
mockImportModule.mockReset();
provider = new VertexChatProvider('gemini-pro', {
config: {
context: 'test-context',
examples: [{ input: 'example input', output: 'example output' }],
stopSequences: ['\n'],
temperature: 0.7,
maxOutputTokens: 100,
topP: 0.9,
topK: 40,
},
});
mockIsCacheEnabled.mockReturnValue(true);
});
afterEach(() => {
vi.clearAllMocks();
});
it('should call the Gemini API and return the response', async () => {
const mockResponse = {
data: [
{
candidates: [{ content: { parts: [{ text: 'response text' }] } }],
usageMetadata: {
totalTokenCount: 10,
promptTokenCount: 5,
candidatesTokenCount: 5,
},
},
],
};
const mockRequest = vi.fn().mockResolvedValue(mockResponse);
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: {
request: mockRequest,
} as unknown as JSONClient,
projectId: 'test-project-id',
});
vi.spyOn(vertexUtil, 'loadCredentials').mockImplementation(function (creds) {
if (typeof creds === 'object') {
return JSON.stringify(creds);
}
return creds;
});
vi.spyOn(vertexUtil, 'resolveProjectId').mockResolvedValue('test-project-id');
const response = await provider.callGeminiApi('test prompt');
expect(response).toEqual({
cached: false,
output: 'response text',
tokenUsage: {
total: 10,
prompt: 5,
completion: 5,
},
cost: expect.closeTo(0.00001, 10),
metadata: {},
});
expect(vertexUtil.getGoogleClient).toHaveBeenCalledWith({ credentials: undefined });
expect(mockRequest).toHaveBeenCalledWith({
url: expect.any(String),
method: 'POST',
data: expect.objectContaining({
contents: [{ parts: [{ text: 'test prompt' }], role: 'user' }],
}),
timeout: expect.any(Number),
});
});
it('should return cached response if available', async () => {
const mockCachedResponse = {
cached: true,
output: 'cached response text',
tokenUsage: {
total: 10,
prompt: 5,
completion: 5,
},
};
mockCacheGet.mockResolvedValue(JSON.stringify(mockCachedResponse));
const response = await provider.callGeminiApi('test prompt');
expect(response).toEqual({
...mockCachedResponse,
tokenUsage: {
...mockCachedResponse.tokenUsage,
cached: mockCachedResponse.tokenUsage.total,
},
});
});
it('should handle API call errors', async () => {
const mockError = new Error('something went wrong');
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: {
request: vi.fn().mockRejectedValue(mockError),
} as unknown as JSONClient,
projectId: 'test-project-id',
});
const response = await provider.callGeminiApi('test prompt');
expect(response).toEqual({
error: `API call error: Error: something went wrong`,
});
});
it('should handle API response errors', async () => {
const mockResponse = {
data: [
{
error: {
code: 400,
message: 'Bad Request',
},
},
],
};
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: {
request: vi.fn().mockResolvedValue(mockResponse),
} as unknown as JSONClient,
projectId: 'test-project-id',
});
const response = await provider.callGeminiApi('test prompt');
expect(response).toEqual({
error: 'Error 400: Bad Request',
});
});
it('should handle function calling configuration', async () => {
const tools = [
{
functionDeclarations: [
{
name: 'get_weather',
description: 'Get weather information',
parameters: {
type: 'OBJECT' as const,
properties: {
location: {
type: 'STRING' as const,
description: 'City name',
},
},
required: ['location'],
},
},
],
},
];
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
vi.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(tools));
provider = new VertexChatProvider('gemini-pro', {
config: {
toolConfig: {
functionCallingConfig: {
mode: 'AUTO',
allowedFunctionNames: ['get_weather'],
},
},
tools,
},
});
const mockResponse = {
data: [
{
candidates: [
{
content: {
parts: [
{
functionCall: {
name: 'get_weather',
args: { location: 'San Francisco' },
},
},
],
},
},
],
usageMetadata: {
totalTokenCount: 15,
promptTokenCount: 8,
candidatesTokenCount: 7,
},
},
],
};
const mockRequest = vi.fn().mockResolvedValue(mockResponse);
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: {
request: mockRequest,
} as unknown as JSONClient,
projectId: 'test-project-id',
});
vi.spyOn(vertexUtil, 'loadCredentials').mockImplementation(function (creds) {
if (typeof creds === 'object') {
return JSON.stringify(creds);
}
return creds;
});
vi.spyOn(vertexUtil, 'resolveProjectId').mockResolvedValue('test-project-id');
const response = await provider.callGeminiApi('What is the weather in San Francisco?');
expect(response).toEqual({
cached: false,
output: [
{
functionCall: {
name: 'get_weather',
args: { location: 'San Francisco' },
},
},
],
tokenUsage: {
total: 15,
prompt: 8,
completion: 7,
},
cost: expect.closeTo(0.0000145, 10),
metadata: {},
});
expect(mockRequest).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
toolConfig: {
functionCallingConfig: {
mode: 'AUTO',
allowedFunctionNames: ['get_weather'],
},
},
tools,
}),
}),
);
});
it.each([
['tool_choice', 'none' as const],
['toolConfig', { functionCallingConfig: { mode: 'NONE' as const } }],
['tool_config', { function_calling_config: { mode: 'none' as const } }],
])('should disable function calling via %s', async (key, value) => {
const tools = [
{
functionDeclarations: [
{
name: 'get_weather',
description: 'Get weather information',
parameters: { type: 'OBJECT' as const, properties: {} },
},
],
},
{ googleSearch: {} },
];
provider = new VertexChatProvider('gemini-pro', {
config: {
tools,
[key]: value,
} as any,
});
const mockResponse = {
data: [
{
candidates: [{ content: { parts: [{ text: 'no tools used' }] } }],
usageMetadata: { totalTokenCount: 10, promptTokenCount: 5, candidatesTokenCount: 5 },
},
],
};
const mockRequest = vi.fn().mockResolvedValue(mockResponse);
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: { request: mockRequest } as unknown as JSONClient,
projectId: 'test-project-id',
});
vi.spyOn(vertexUtil, 'loadCredentials').mockImplementation((c) => c as any);
vi.spyOn(vertexUtil, 'resolveProjectId').mockResolvedValue('test-project-id');
await provider.callGeminiApi('hi');
expect(mockRequest).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
toolConfig: { functionCallingConfig: { mode: 'NONE' } },
tools: [{ googleSearch: {} }],
}),
}),
);
});
it('should not invoke functionToolCallbacks when tools are disabled', async () => {
// Use a cached response so executeFunctionCallback would normally fire if not gated.
mockIsCacheEnabled.mockReturnValue(true);
mockCacheGet.mockResolvedValue(
JSON.stringify({
cached: true,
output: JSON.stringify({
functionCall: { name: 'should_not_run', args: '{}' },
}),
tokenUsage: { total: 5, prompt: 3, completion: 2 },
}),
);
const callback = vi.fn().mockResolvedValue('should not be called');
const provider = new VertexChatProvider('gemini-pro', {
config: {
tool_choice: 'none',
tools: [
{
functionDeclarations: [
{
name: 'should_not_run',
description: 'Test',
parameters: { type: 'OBJECT', properties: {} },
},
],
},
],
functionToolCallbacks: { should_not_run: callback },
},
});
const result = await provider.callApi('test');
expect(callback).not.toHaveBeenCalled();
// Output remains the original functionCall envelope when callbacks are gated.
expect(result.output).toBe(
JSON.stringify({ functionCall: { name: 'should_not_run', args: '{}' } }),
);
// Reset cache state so other tests aren't affected.
mockIsCacheEnabled.mockReturnValue(false);
mockCacheGet.mockReset();
});
it('should skip executable tool files while preserving inline non-function tools when disabled', async () => {
provider = new VertexChatProvider('gemini-pro', {
config: {
tool_choice: 'none',
tools: [{ googleSearch: {} }, 'file://tools.js:getTools'] as any,
},
});
const mockResponse = {
data: [
{
candidates: [{ content: { parts: [{ text: 'no tools used' }] } }],
usageMetadata: { totalTokenCount: 10, promptTokenCount: 5, candidatesTokenCount: 5 },
},
],
};
const mockRequest = vi.fn().mockResolvedValue(mockResponse);
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: { request: mockRequest } as unknown as JSONClient,
projectId: 'test-project-id',
});
vi.spyOn(vertexUtil, 'loadCredentials').mockImplementation((c) => c as any);
vi.spyOn(vertexUtil, 'resolveProjectId').mockResolvedValue('test-project-id');
await provider.callGeminiApi('hi');
expect(mockImportModule).not.toHaveBeenCalled();
expect(mockRequest).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
toolConfig: { functionCallingConfig: { mode: 'NONE' } },
tools: [{ googleSearch: {} }],
}),
}),
);
});
it('should load tools from external file and render variables', async () => {
const mockExternalTools = [
{
functionDeclarations: [
{
name: 'get_weather',
description: 'Get weather in San Francisco',
parameters: {
type: 'OBJECT' as const,
properties: {
location: { type: 'STRING' as const },
},
},
},
],
},
];
// Mock file system operations (existsSync no longer called due to TOCTOU fix)
vi.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(mockExternalTools));
provider = new VertexChatProvider('gemini-pro', {
config: {
tools: 'file://tools.json' as any,
},
});
const mockResponse = {
data: [
{
candidates: [
{
content: {
parts: [{ text: 'response with tools' }],
},
},
],
usageMetadata: {
totalTokenCount: 10,
promptTokenCount: 5,
candidatesTokenCount: 5,
},
},
],
};
const mockRequest = vi.fn().mockResolvedValue(mockResponse);
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: {
request: mockRequest,
} as unknown as JSONClient,
projectId: 'test-project-id',
});
vi.spyOn(vertexUtil, 'loadCredentials').mockImplementation(function (creds) {
if (typeof creds === 'object') {
return JSON.stringify(creds);
}
return creds;
});
vi.spyOn(vertexUtil, 'resolveProjectId').mockResolvedValue('test-project-id');
const response = await provider.callGeminiApi('test prompt', {
vars: { location: 'San Francisco' },
prompt: { raw: 'test prompt', label: 'test' },
});
expect(response).toEqual({
cached: false,
output: 'response with tools',
tokenUsage: {
total: 10,
prompt: 5,
completion: 5,
},
cost: expect.closeTo(0.00001, 10),
metadata: {},
});
// Note: existsSync no longer called - we use try/catch on readFileSync instead (TOCTOU fix)
expect(fs.readFileSync).toHaveBeenCalledWith(expect.stringContaining('tools.json'), 'utf8');
expect(mockRequest).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
tools: mockExternalTools,
}),
}),
);
});
it('should use model name in cache key', async () => {
const mockResponse = {
data: [
{
candidates: [
{
content: {
parts: [{ text: 'response text' }],
},
},
],
usageMetadata: {
totalTokenCount: 10,
promptTokenCount: 5,
candidatesTokenCount: 5,
},
},
],
};
const mockRequest = vi.fn().mockResolvedValue(mockResponse);
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: {
request: mockRequest,
} as unknown as JSONClient,
projectId: 'test-project-id',
});
vi.spyOn(vertexUtil, 'loadCredentials').mockImplementation(function (creds) {
if (typeof creds === 'object') {
return JSON.stringify(creds);
}
return creds;
});
vi.spyOn(vertexUtil, 'resolveProjectId').mockResolvedValue('test-project-id');
provider = new VertexChatProvider('gemini-2.0-flash-001');
await provider.callGeminiApi('test prompt');
expectHashedBodyCacheKeys(/^vertex:gemini-2\.0-flash-001:[a-f0-9]{64}$/, ['test prompt']);
expect(mockCacheSet).toHaveBeenCalledWith(
expect.stringContaining('vertex:gemini-2.0-flash-001:'),
expect.any(String),
);
});
it('should not reuse cached responses across effective API hosts', async () => {
const cachedResponses = new Map<string, string>();
mockCacheGet.mockImplementation(async (key: string) => cachedResponses.get(key) ?? null);
mockCacheSet.mockImplementation(async (key: string, value: string) => {
cachedResponses.set(key, value);
});
const mockRequest = mockVertexRequest({
candidates: [{ content: { parts: [{ text: 'response text' }] } }],
usageMetadata: {
totalTokenCount: 10,
promptTokenCount: 5,
candidatesTokenCount: 5,
},
});
const publicProvider = new VertexChatProvider('gemini-pro', {
config: { region: 'global' },
});
const proxyProvider = new VertexChatProvider('gemini-pro', {
config: { region: 'global' },
env: { VERTEX_API_HOST: 'vertex-proxy.example.test' },
});
await publicProvider.callGeminiApi('same prompt');
await proxyProvider.callGeminiApi('same prompt');
const [publicCacheKey, proxyCacheKey] = mockCacheGet.mock.calls.map(([key]) => key as string);
expect(publicCacheKey).not.toBe(proxyCacheKey);
expect(mockRequest).toHaveBeenCalledTimes(2);
expect(mockRequest.mock.calls.map(([request]) => request.url)).toEqual([
expect.stringContaining('https://aiplatform.googleapis.com/'),
expect.stringContaining('https://vertex-proxy.example.test/'),
]);
});
it('should handle function tool callbacks correctly', async () => {
const mockCachedResponse = {
cached: true,
output: JSON.stringify({
functionCall: {
name: 'get_weather',
args: '{"location":"New York"}',
},
}),
tokenUsage: {
total: 15,
prompt: 10,
completion: 5,
},
cost: 0.00045,
metadata: {
groundingMetadata: {
test: true,
},
},
};
mockCacheGet.mockResolvedValue(JSON.stringify(mockCachedResponse));
const mockWeatherFunction = vi.fn().mockResolvedValue('Sunny, 25°C');
const provider = new VertexChatProvider('gemini', {
config: {
tools: [
{
functionDeclarations: [
{
name: 'get_weather',
description: 'Get the weather for a location',
parameters: {
type: 'OBJECT',
properties: {
location: { type: 'STRING' },
},
required: ['location'],
},
},
],
},
],
functionToolCallbacks: {
get_weather: mockWeatherFunction,
},
},
});
const result = await provider.callApi(
JSON.stringify([{ role: 'user', content: "What's the weather in New York?" }]),
);
expect(mockCacheGet).toHaveBeenCalledTimes(1);
expect(mockWeatherFunction).toHaveBeenCalledWith('{"location":"New York"}');
expect(result.output).toBe('Sunny, 25°C');
expect(result.tokenUsage).toEqual({ total: 15, prompt: 10, completion: 5, cached: 15 });
expect(result.cost).toBe(0.00045);
expect(result.metadata).toEqual({
groundingMetadata: {
test: true,
},
});
});
it('should return undefined cost when Gemini omits usage metadata', async () => {
const mockResponse = {
data: [
{
candidates: [{ content: { parts: [{ text: 'response text' }] } }],
},
],
};
const mockRequest = vi.fn().mockResolvedValue(mockResponse);
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: {
request: mockRequest,
} as unknown as JSONClient,
projectId: 'test-project-id',
});
vi.spyOn(vertexUtil, 'loadCredentials').mockImplementation(function (creds) {
if (typeof creds === 'object') {
return JSON.stringify(creds);
}
return creds;
});
vi.spyOn(vertexUtil, 'resolveProjectId').mockResolvedValue('test-project-id');
const response = await provider.callGeminiApi('test prompt');
expect(response).toEqual({
cached: false,
output: 'response text',
tokenUsage: {
total: 0,
prompt: 0,
completion: 0,
},
cost: undefined,
metadata: {},
});
});
it('should handle errors in function tool callbacks', async () => {
const mockCachedResponse = {
cached: true,
output: JSON.stringify({
functionCall: {
name: 'errorFunction',
args: '{}',
},
}),
tokenUsage: {
total: 5,
prompt: 2,
completion: 3,
},
};
mockCacheGet.mockResolvedValue(JSON.stringify(mockCachedResponse));
const provider = new VertexChatProvider('gemini', {
config: {
tools: [
{
functionDeclarations: [
{
name: 'errorFunction',
description: 'A function that always throws an error',
parameters: {
type: 'OBJECT',
properties: {},
},
},
],
},
],
functionToolCallbacks: {
errorFunction: () => {
throw new Error('Test error');
},
},
},
});
const result = await provider.callApi('Call the error function');
expect(result.output).toBe('{"functionCall":{"name":"errorFunction","args":"{}"}}');
expect(result.tokenUsage).toEqual({ total: 5, prompt: 2, completion: 3, cached: 5 });
});
describe('External Function Callbacks', () => {
beforeEach(() => {
// Set cliState basePath for external function loading
cliState.basePath = '/test/base/path';
});
afterEach(() => {
vi.clearAllMocks();
cliState.basePath = undefined;
});
it('should load and execute external function callbacks from file', async () => {
const mockCachedResponse = {
cached: true,
output: JSON.stringify({
functionCall: {
name: 'external_function',
args: '{"param":"test_value"}',
},
}),
tokenUsage: {
total: 15,
prompt: 10,
completion: 5,
},
};
mockCacheGet.mockResolvedValue(JSON.stringify(mockCachedResponse));
// Mock importModule to return our test function
const mockExternalFunction = vi.fn().mockResolvedValue('External function result');
mockImportModule.mockResolvedValue(mockExternalFunction);
const provider = new VertexChatProvider('gemini', {
config: {
tools: [
{
functionDeclarations: [
{
name: 'external_function',
description: 'An external function',
parameters: {
type: 'OBJECT',
properties: { param: { type: 'STRING' } },
required: ['param'],
},
},
],
},
],
functionToolCallbacks: {
external_function: 'file://test/callbacks.js:testFunction',
},
},
});
const result = await provider.callApi('Call external function');
expect(mockImportModule).toHaveBeenCalledWith(
path.resolve('/test/base/path', 'test/callbacks.js'),
'testFunction',
);
expect(mockExternalFunction).toHaveBeenCalledWith('{"param":"test_value"}');
expect(result.output).toBe('External function result');
expect(result.tokenUsage).toEqual({ total: 15, prompt: 10, completion: 5, cached: 15 });
});
it('should cache external functions and not reload them on subsequent calls', async () => {
const mockCachedResponse = {
cached: true,
output: JSON.stringify({
functionCall: {
name: 'cached_function',
args: '{"value":123}',
},
}),
tokenUsage: {
total: 12,
prompt: 8,
completion: 4,
},
};
mockCacheGet.mockResolvedValue(JSON.stringify(mockCachedResponse));
const mockCachedFunction = vi.fn().mockResolvedValue('Cached result');
mockImportModule.mockResolvedValue(mockCachedFunction);
const provider = new VertexChatProvider('gemini', {
config: {
tools: [
{
functionDeclarations: [
{
name: 'cached_function',
description: 'A cached function',
parameters: {
type: 'OBJECT',
properties: { value: { type: 'NUMBER' } },
required: ['value'],
},
},
],
},
],
functionToolCallbacks: {
cached_function: 'file://callbacks/cache-test.js:cachedFunction',
},
},
});
// First call - should load the function
const result1 = await provider.callApi('First call');
expect(mockImportModule).toHaveBeenCalledTimes(1);
expect(mockCachedFunction).toHaveBeenCalledWith('{"value":123}');
expect(result1.output).toBe('Cached result');
// Second call - should use cached function, not reload
const result2 = await provider.callApi('Second call');
expect(mockImportModule).toHaveBeenCalledTimes(1); // Still only 1 call
expect(mockCachedFunction).toHaveBeenCalledTimes(2);
expect(result2.output).toBe('Cached result');
});
it('should handle errors in external function loading gracefully', async () => {
const mockCachedResponse = {
cached: true,
output: JSON.stringify({
functionCall: {
name: 'error_function',
args: '{"test":"data"}',
},
}),
tokenUsage: {
total: 10,
prompt: 6,
completion: 4,
},
};
mockCacheGet.mockResolvedValue(JSON.stringify(mockCachedResponse));
// Mock import module to throw an error
mockImportModule.mockRejectedValue(new Error('Module not found'));
const provider = new VertexChatProvider('gemini', {
config: {
tools: [
{
functionDeclarations: [
{
name: 'error_function',
description: 'A function that errors during loading',
parameters: {
type: 'OBJECT',
properties: { test: { type: 'STRING' } },
},
},
],
},
],
functionToolCallbacks: {
error_function: 'file://nonexistent/module.js:errorFunction',
},
},
});
const result = await provider.callApi('Call error function');
expect(mockImportModule).toHaveBeenCalledWith(
path.resolve('/test/base/path', 'nonexistent/module.js'),
'errorFunction',
);
// Should fall back to original function call object when loading fails
expect(result.output).toBe(
'{"functionCall":{"name":"error_function","args":"{\\"test\\":\\"data\\"}"}}',
);
});
it('should handle mixed inline and external function callbacks', async () => {
const mockCachedResponse = {
cached: true,
output: JSON.stringify({
functionCall: {
name: 'external_function',
args: '{"external":"test"}',
},
}),
tokenUsage: {
total: 20,
prompt: 12,
completion: 8,
},
};
mockCacheGet.mockResolvedValue(JSON.stringify(mockCachedResponse));
const mockInlineFunction = vi.fn().mockResolvedValue('Inline result');
const mockExternalFunction = vi.fn().mockResolvedValue('External result');
mockImportModule.mockResolvedValue(mockExternalFunction);
const provider = new VertexChatProvider('gemini', {
config: {
tools: [
{
functionDeclarations: [
{
name: 'inline_function',
description: 'An inline function',
parameters: {
type: 'OBJECT',
properties: { inline: { type: 'STRING' } },
},
},
{
name: 'external_function',
description: 'An external function',
parameters: {
type: 'OBJECT',
properties: { external: { type: 'STRING' } },
},
},
],
},
],
functionToolCallbacks: {
inline_function: mockInlineFunction,
external_function: 'file://mixed/callbacks.js:externalFunc',
},
},
});
const result = await provider.callApi('Test mixed callbacks');
expect(mockImportModule).toHaveBeenCalledWith(
path.resolve('/test/base/path', 'mixed/callbacks.js'),
'externalFunc',
);
expect(mockExternalFunction).toHaveBeenCalledWith('{"external":"test"}');
expect(result.output).toBe('External result');
});
});
describe('thinking token tracking', () => {
it('should track thinking tokens when present in response', async () => {
const provider = new VertexChatProvider('gemini-2.5-flash', {
config: {
generationConfig: {
thinkingConfig: {
thinkingBudget: 1024,
},
},
},
});
const mockResponse = {
data: [
{
candidates: [{ content: { parts: [{ text: 'response with thinking' }] } }],
usageMetadata: {
promptTokenCount: 10,
candidatesTokenCount: 20,
totalTokenCount: 30,
thoughtsTokenCount: 50, // Thinking tokens
},
},
],
};
const mockRequest = vi.fn().mockResolvedValue(mockResponse);
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: {
request: mockRequest,
} as unknown as JSONClient,
projectId: 'test-project-id',
});
const response = await provider.callGeminiApi('test prompt');
expect(response.tokenUsage).toEqual({
prompt: 10,
completion: 20,
total: 30,
completionDetails: {
reasoning: 50,
acceptedPrediction: 0,
rejectedPrediction: 0,
},
});
});
it('should handle response without thinking tokens', async () => {
const provider = new VertexChatProvider('gemini-2.5-flash');
const mockResponse = {
data: [
{
candidates: [{ content: { parts: [{ text: 'response without thinking' }] } }],
usageMetadata: {
promptTokenCount: 10,
candidatesTokenCount: 20,
totalTokenCount: 30,
// No thoughtsTokenCount field
},
},
],
};
const mockRequest = vi.fn().mockResolvedValue(mockResponse);
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: {
request: mockRequest,
} as unknown as JSONClient,
projectId: 'test-project-id',
});
const response = await provider.callGeminiApi('test prompt');
expect(response.tokenUsage).toEqual({
prompt: 10,
completion: 20,
total: 30,
// No completionDetails field when thoughtsTokenCount is absent
});
});
it('should track thinking tokens with zero value', async () => {
const provider = new VertexChatProvider('gemini-2.5-flash', {
config: {
generationConfig: {
thinkingConfig: {
thinkingBudget: 1024,
},
},
},
});
const mockResponse = {
data: [
{
candidates: [{ content: { parts: [{ text: 'response with zero thinking' }] } }],
usageMetadata: {
promptTokenCount: 10,
candidatesTokenCount: 20,
totalTokenCount: 30,
thoughtsTokenCount: 0, // Zero thinking tokens
},
},
],
};
const mockRequest = vi.fn().mockResolvedValue(mockResponse);
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: {
request: mockRequest,
} as unknown as JSONClient,
projectId: 'test-project-id',
});
const response = await provider.callGeminiApi('test prompt');
expect(response.tokenUsage).toEqual({
prompt: 10,
completion: 20,
total: 30,
completionDetails: {
reasoning: 0,
acceptedPrediction: 0,
rejectedPrediction: 0,
},
});
});
it('should track thinking tokens in cached responses', async () => {
const provider = new VertexChatProvider('gemini-2.5-flash', {
config: {
generationConfig: {
thinkingConfig: {
thinkingBudget: 1024,
},
},
},
});
const mockCachedResponse = {
output: 'cached response with thinking',
tokenUsage: {
total: 80,
prompt: 10,
completion: 20,
thoughtsTokenCount: 50, // This would be stored in the cached response
},
cached: true,
};
// Mock the cache to return a response that includes thinking tokens
mockCacheGet.mockResolvedValue(JSON.stringify(mockCachedResponse));
const response = await provider.callGeminiApi('test prompt');
// The cached response should preserve the thinking tokens
// but due to how the caching logic works, it transforms the response
expect(response.cached).toBe(true);
expect(response.output).toBe('cached response with thinking');
// Note: The current implementation doesn't preserve completionDetails in cached responses
// This is a limitation that could be addressed in a future fix
});
});
describe('Model Armor integration', () => {
it('should include model_armor_config in request when configured', async () => {
const provider = new VertexChatProvider('gemini-pro', {
config: {
modelArmor: {
promptTemplate: 'projects/my-project/locations/us-central1/templates/basic-safety',
responseTemplate: 'projects/my-project/locations/us-central1/templates/basic-safety',
},
},
});
const mockResponse = {
data: [
{
candidates: [{ content: { parts: [{ text: 'response text' }] } }],
usageMetadata: {
totalTokenCount: 10,
promptTokenCount: 5,
candidatesTokenCount: 5,
},
},
],
};
const mockRequest = vi.fn().mockResolvedValue(mockResponse);
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: {
request: mockRequest,
} as unknown as JSONClient,
projectId: 'test-project-id',
});
vi.spyOn(vertexUtil, 'loadCredentials').mockImplementation(function (creds) {
if (typeof creds === 'object') {
return JSON.stringify(creds);
}
return creds;
});
vi.spyOn(vertexUtil, 'resolveProjectId').mockResolvedValue('test-project-id');
await provider.callGeminiApi('test prompt');
expect(mockRequest).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
model_armor_config: {
prompt_template_name:
'projects/my-project/locations/us-central1/templates/basic-safety',
response_template_name:
'projects/my-project/locations/us-central1/templates/basic-safety',
},
}),
}),
);
});
it('should include only promptTemplate when responseTemplate is not configured', async () => {
const provider = new VertexChatProvider('gemini-pro', {
config: {
modelArmor: {
promptTemplate: 'projects/my-project/locations/us-central1/templates/prompt-only',
},
},
});
const mockResponse = {
data: [
{
candidates: [{ content: { parts: [{ text: 'response text' }] } }],
usageMetadata: {
totalTokenCount: 10,
promptTokenCount: 5,
candidatesTokenCount: 5,
},
},
],
};
const mockRequest = vi.fn().mockResolvedValue(mockResponse);
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: {
request: mockRequest,
} as unknown as JSONClient,
projectId: 'test-project-id',
});
vi.spyOn(vertexUtil, 'loadCredentials').mockImplementation(function (creds) {
if (typeof creds === 'object') {
return JSON.stringify(creds);
}
return creds;
});
vi.spyOn(vertexUtil, 'resolveProjectId').mockResolvedValue('test-project-id');
await provider.callGeminiApi('test prompt');
expect(mockRequest).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
model_armor_config: {
prompt_template_name:
'projects/my-project/locations/us-central1/templates/prompt-only',
},
}),
}),
);
});
it('should not include model_armor_config when not configured', async () => {
const provider = new VertexChatProvider('gemini-pro', {
config: {},
});
const mockResponse = {
data: [
{
candidates: [{ content: { parts: [{ text: 'response text' }] } }],
usageMetadata: {
totalTokenCount: 10,
promptTokenCount: 5,
candidatesTokenCount: 5,
},
},
],
};
const mockRequest = vi.fn().mockResolvedValue(mockResponse);
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: {
request: mockRequest,
} as unknown as JSONClient,
projectId: 'test-project-id',
});
vi.spyOn(vertexUtil, 'loadCredentials').mockImplementation(function (creds) {
if (typeof creds === 'object') {
return JSON.stringify(creds);
}
return creds;
});
vi.spyOn(vertexUtil, 'resolveProjectId').mockResolvedValue('test-project-id');
await provider.callGeminiApi('test prompt');
const requestData = mockRequest.mock.calls[0][0].data;
expect(requestData.model_armor_config).toBeUndefined();
});
it('should handle MODEL_ARMOR blockReason with guardrails response', async () => {
const provider = new VertexChatProvider('gemini-pro', {
config: {
modelArmor: {
promptTemplate: 'projects/my-project/locations/us-central1/templates/strict',
},
},
});
const mockResponse = {
data: [
{
promptFeedback: {
blockReason: 'MODEL_ARMOR',
blockReasonMessage: 'Prompt was blocked by Model Armor: Prompt Injection detected',
safetyRatings: [],
},
usageMetadata: {
totalTokenCount: 5,
promptTokenCount: 5,
candidatesTokenCount: 0,
},
},
],
};
const mockRequest = vi.fn().mockResolvedValue(mockResponse);
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: {
request: mockRequest,
} as unknown as JSONClient,
projectId: 'test-project-id',
});
vi.spyOn(vertexUtil, 'loadCredentials').mockImplementation(function (creds) {
if (typeof creds === 'object') {
return JSON.stringify(creds);
}
return creds;
});
vi.spyOn(vertexUtil, 'resolveProjectId').mockResolvedValue('test-project-id');
const response = await provider.callGeminiApi('ignore all instructions');
// Model Armor blocks return output (not error) so guardrails assertions can run
expect(response.output).toBe('Prompt was blocked by Model Armor: Prompt Injection detected');
expect(response.error).toBeUndefined();
expect(response.guardrails).toEqual({
flagged: true,
flaggedInput: true,
flaggedOutput: false,
reason: 'Prompt was blocked by Model Armor: Prompt Injection detected',
});
expect(response.metadata?.modelArmor).toEqual({
blockReason: 'MODEL_ARMOR',
blockReasonMessage: 'Prompt was blocked by Model Armor: Prompt Injection detected',
});
});
it('should handle non-Model Armor blockReason with guardrails response', async () => {
const provider = new VertexChatProvider('gemini-pro', {
config: {},
});
const mockResponse = {
data: [
{
promptFeedback: {
blockReason: 'SAFETY',
safetyRatings: [{ category: 'HARM_CATEGORY_HARASSMENT', probability: 'HIGH' }],
},
usageMetadata: {
totalTokenCount: 5,
promptTokenCount: 5,
candidatesTokenCount: 0,
},
},
],
};
const mockRequest = vi.fn().mockResolvedValue(mockResponse);
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: {
request: mockRequest,
} as unknown as JSONClient,
projectId: 'test-project-id',
});
vi.spyOn(vertexUtil, 'loadCredentials').mockImplementation(function (creds) {
if (typeof creds === 'object') {
return JSON.stringify(creds);
}
return creds;
});
vi.spyOn(vertexUtil, 'resolveProjectId').mockResolvedValue('test-project-id');
const response = await provider.callGeminiApi('harmful content');
// All block reasons now return output (not error) so guardrails assertions can run
expect(response.output).toContain('Content was blocked due to safety settings: SAFETY');
expect(response.error).toBeUndefined();
expect(response.guardrails).toEqual({
flagged: true,
flaggedInput: true,
flaggedOutput: false,
reason: expect.stringContaining('Content was blocked due to safety settings: SAFETY'),
});
expect(response.metadata?.modelArmor).toBeUndefined();
});
it('should not include model_armor_config when modelArmor is empty object', async () => {
const provider = new VertexChatProvider('gemini-pro', {
config: {
modelArmor: {},
},
});
const mockResponse = {
data: [
{
candidates: [{ content: { parts: [{ text: 'response text' }] } }],
usageMetadata: {
totalTokenCount: 10,
promptTokenCount: 5,
candidatesTokenCount: 5,
},
},
],
};
const mockRequest = vi.fn().mockResolvedValue(mockResponse);
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: {
request: mockRequest,
} as unknown as JSONClient,
projectId: 'test-project-id',
});
vi.spyOn(vertexUtil, 'loadCredentials').mockImplementation(function (creds) {
if (typeof creds === 'object') {
return JSON.stringify(creds);
}
return creds;
});
vi.spyOn(vertexUtil, 'resolveProjectId').mockResolvedValue('test-project-id');
await provider.callGeminiApi('test prompt');
const requestData = mockRequest.mock.calls[0][0].data;
expect(requestData.model_armor_config).toBeUndefined();
});
// TODO: This default message is user-facing and can be adjusted for clarity without
// breaking behavior semantics (e.g., "Content was blocked by Model Armor policy").
it('should use default message when blockReasonMessage is not provided', async () => {
const provider = new VertexChatProvider('gemini-pro', {
config: {},
});
const mockResponse = {
data: [
{
promptFeedback: {
blockReason: 'MODEL_ARMOR',
// No blockReasonMessage
safetyRatings: [],
},
usageMetadata: {
totalTokenCount: 5,
promptTokenCount: 5,
candidatesTokenCount: 0,
},
},
],
};
const mockRequest = vi.fn().mockResolvedValue(mockResponse);
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: {
request: mockRequest,
} as unknown as JSONClient,
projectId: 'test-project-id',
});
vi.spyOn(vertexUtil, 'loadCredentials').mockImplementation(function (creds) {
if (typeof creds === 'object') {
return JSON.stringify(creds);
}
return creds;
});
vi.spyOn(vertexUtil, 'resolveProjectId').mockResolvedValue('test-project-id');
const response = await provider.callGeminiApi('test prompt');
// Block reasons return output (not error) so guardrails assertions can run
expect(response.output).toBe('Content was blocked due to Model Armor: MODEL_ARMOR');
expect(response.error).toBeUndefined();
expect(response.guardrails?.reason).toBe(
'Content was blocked due to Model Armor: MODEL_ARMOR',
);
});
it('should handle SAFETY finishReason with guardrails response', async () => {
const provider = new VertexChatProvider('gemini-pro', {
config: {},
});
const mockResponse = {
data: [
{
candidates: [
{
content: { parts: [{ text: 'partial response' }] },
finishReason: 'SAFETY',
},
],
usageMetadata: {
totalTokenCount: 10,
promptTokenCount: 5,
candidatesTokenCount: 5,
},
},
],
};
const mockRequest = vi.fn().mockResolvedValue(mockResponse);
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: {
request: mockRequest,
} as unknown as JSONClient,
projectId: 'test-project-id',
});
vi.spyOn(vertexUtil, 'loadCredentials').mockImplementation(function (creds) {
if (typeof creds === 'object') {
return JSON.stringify(creds);
}
return creds;
});
vi.spyOn(vertexUtil, 'resolveProjectId').mockResolvedValue('test-project-id');
const response = await provider.callGeminiApi('test prompt');
expect(response.error).toBe(
'Content was blocked due to safety settings with finish reason: SAFETY.',
);
expect(response.guardrails).toEqual({
flagged: true,
flaggedInput: false,
flaggedOutput: true,
reason: 'Content was blocked due to safety settings with finish reason: SAFETY.',
});
});
it.each([
'PROHIBITED_CONTENT',
'RECITATION',
'BLOCKLIST',
'SPII',
'IMAGE_SAFETY',
])('should handle %s finishReason with guardrails response', async (finishReason) => {
const provider = new VertexChatProvider('gemini-pro', {
config: {},
});
const mockResponse = {
data: [
{
candidates: [
{
content: { parts: [{ text: 'partial response' }] },
finishReason,
},
],
usageMetadata: {
totalTokenCount: 10,
promptTokenCount: 5,
candidatesTokenCount: 5,
},
},
],
};
const mockRequest = vi.fn().mockResolvedValue(mockResponse);
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: {
request: mockRequest,
} as unknown as JSONClient,
projectId: 'test-project-id',
});
vi.spyOn(vertexUtil, 'loadCredentials').mockImplementation(function (creds) {
if (typeof creds === 'object') {
return JSON.stringify(creds);
}
return creds;
});
vi.spyOn(vertexUtil, 'resolveProjectId').mockResolvedValue('test-project-id');
const response = await provider.callGeminiApi('test prompt');
expect(response.error).toBe(
`Content was blocked due to safety settings with finish reason: ${finishReason}.`,
);
expect(response.guardrails).toEqual({
flagged: true,
flaggedInput: false,
flaggedOutput: true,
reason: `Content was blocked due to safety settings with finish reason: ${finishReason}.`,
});
});
it('should handle MAX_TOKENS finishReason with truncated output', async () => {
const provider = new VertexChatProvider('gemini-pro', {
config: {},
});
const longOutput = 'A'.repeat(600);
const mockResponse = {
data: [
{
candidates: [
{
content: { parts: [{ text: longOutput }] },
finishReason: 'MAX_TOKENS',
},
],
usageMetadata: {
totalTokenCount: 1100,
promptTokenCount: 100,
candidatesTokenCount: 1000,
},
},
],
};
const mockRequest = vi.fn().mockResolvedValue(mockResponse);
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: {
request: mockRequest,
} as unknown as JSONClient,
projectId: 'test-project-id',
});
vi.spyOn(vertexUtil, 'loadCredentials').mockImplementation(function (creds) {
if (typeof creds === 'object') {
return JSON.stringify(creds);
}
return creds;
});
vi.spyOn(vertexUtil, 'resolveProjectId').mockResolvedValue('test-project-id');
const response = await provider.callGeminiApi('test prompt');
expect(response.error).toBeUndefined();
expect(response.output).toBe(longOutput);
expect(response.tokenUsage).toEqual({
total: 1100,
prompt: 100,
completion: 1000,
});
});
it('should handle MAX_TOKENS finishReason with short output (no truncation)', async () => {
const provider = new VertexChatProvider('gemini-pro', {
config: {},
});
const shortOutput = 'Short response';
const mockResponse = {
data: [
{
candidates: [
{
content: { parts: [{ text: shortOutput }] },
finishReason: 'MAX_TOKENS',
},
],
usageMetadata: {
totalTokenCount: 110,
promptTokenCount: 100,
candidatesTokenCount: 10,
},
},
],
};
const mockRequest = vi.fn().mockResolvedValue(mockResponse);
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: {
request: mockRequest,
} as unknown as JSONClient,
projectId: 'test-project-id',
});
vi.spyOn(vertexUtil, 'loadCredentials').mockImplementation(function (creds) {
if (typeof creds === 'object') {
return JSON.stringify(creds);
}
return creds;
});
vi.spyOn(vertexUtil, 'resolveProjectId').mockResolvedValue('test-project-id');
const response = await provider.callGeminiApi('test prompt');
expect(response.error).toBeUndefined();
expect(response.output).toBe(shortOutput);
expect(response.tokenUsage).toEqual({
total: 110,
prompt: 100,
completion: 10,
});
});
});
});
describe('VertexChatProvider.callPalm2Api', () => {
beforeEach(() => {
mockCacheGet.mockReset();
mockCacheGet.mockResolvedValue(null);
mockCacheSet.mockReset();
mockIsCacheEnabled.mockReturnValue(true);
});
afterEach(() => {
vi.clearAllMocks();
});
it('hashes Palm2 request body cache keys without leaking prompts', async () => {
const prompt = 'palm2-secret-prompt-value';
const provider = new VertexChatProvider('chat-bison', {
config: {
context: 'palm2-secret-context',
temperature: 0.4,
},
});
mockVertexRequest({
predictions: [{ candidates: [{ content: 'Palm2 response content' }] }],
});
await provider.callPalm2Api(prompt);
expectHashedBodyCacheKeys(/^vertex:palm2:chat-bison:[a-f0-9]{64}$/, [
prompt,
'palm2-secret-context',
]);
});
it('scopes Palm2 request body cache keys by model name', async () => {
const prompt = 'same palm2 prompt';
const providerA = new VertexChatProvider('chat-bison', {
config: { context: 'same palm2 context' },
});
const providerB = new VertexChatProvider('text-bison', {
config: { context: 'same palm2 context' },
});
mockVertexRequest({
predictions: [{ candidates: [{ content: 'Palm2 response content' }] }],
});
await providerA.callPalm2Api(prompt);
await providerB.callPalm2Api(prompt);
const [cacheKeyA, cacheKeyB] = mockCacheGet.mock.calls.map(([key]) => key as string);
expect(cacheKeyA).toMatch(/^vertex:palm2:chat-bison:[a-f0-9]{64}$/);
expect(cacheKeyB).toMatch(/^vertex:palm2:text-bison:[a-f0-9]{64}$/);
expect(cacheKeyA).not.toBe(cacheKeyB);
expect(cacheKeyA).not.toContain(prompt);
expect(cacheKeyB).not.toContain(prompt);
});
});
describe('VertexChatProvider.callLlamaApi', () => {
let provider: VertexChatProvider;
beforeEach(() => {
// Reset cache mocks to default state
mockCacheGet.mockReset();
mockCacheGet.mockResolvedValue(null);
mockCacheSet.mockReset();
mockIsCacheEnabled.mockReturnValue(true);
});
afterEach(() => {
vi.clearAllMocks();
});
it('should enforce us-central1 region for Llama models', async () => {
// Create provider with non-us-central1 region
provider = new VertexChatProvider('llama-3.3-70b-instruct-maas', {
config: { region: 'europe-west1' },
});
const response = await provider.callLlamaApi('test prompt');
// Should return error about region
expect(response).toEqual({
error:
"Llama models are only available in the us-central1 region. Current region: europe-west1. Please set region: 'us-central1' in your configuration.",
});
});
it('should validate llama_guard_settings is a valid object', async () => {
provider = new VertexChatProvider('llama-3.3-70b-instruct-maas', {
config: {
region: 'us-central1',
llamaConfig: {
safetySettings: {
// @ts-ignore - intentionally passing invalid type for test
llama_guard_settings: 'not-an-object',
},
},
},
});
const response = await provider.callLlamaApi('test prompt');
// Should return error about invalid llama_guard_settings
expect(response).toEqual({
error: 'Invalid llama_guard_settings: must be an object, received string',
});
});
it('should successfully call Llama API with valid configuration', async () => {
provider = new VertexChatProvider('llama-3.3-70b-instruct-maas', {
config: {
region: 'us-central1',
temperature: 0.7,
maxOutputTokens: 250,
llamaConfig: {
safetySettings: {
enabled: true,
llama_guard_settings: { custom_setting: 'value' },
},
},
},
});
const mockResponse = {
data: {
choices: [
{
message: {
content: 'Llama response content',
},
},
],
usage: {
total_tokens: 35,
prompt_tokens: 15,
completion_tokens: 20,
},
},
};
const mockRequest = vi.fn().mockResolvedValue(mockResponse);
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: {
request: mockRequest,
} as unknown as JSONClient,
projectId: 'test-project-id',
});
vi.spyOn(vertexUtil, 'loadCredentials').mockImplementation(function (creds) {
if (typeof creds === 'object') {
return JSON.stringify(creds);
}
return creds;
});
vi.spyOn(vertexUtil, 'resolveProjectId').mockResolvedValue('test-project-id');
const response = await provider.callLlamaApi('test prompt');
// Should return successful response
expect(response).toEqual({
cached: false,
output: 'Llama response content',
tokenUsage: {
total: 35,
prompt: 15,
completion: 20,
numRequests: 1,
},
});
// Verify API request
expect(mockRequest).toHaveBeenCalledWith(
expect.objectContaining({
url: expect.stringContaining(
'us-central1-aiplatform.googleapis.com/v1beta1/projects/test-project-id/locations/us-central1/endpoints/openapi/chat/completions',
),
method: 'POST',
data: expect.objectContaining({
model: 'meta/llama-3.3-70b-instruct-maas',
max_tokens: 250,
temperature: 0.7,
extra_body: {
google: {
model_safety_settings: {
enabled: true,
llama_guard_settings: { custom_setting: 'value' },
},
},
},
}),
}),
);
});
it('hashes Llama request body cache keys without leaking prompts', async () => {
const prompt = 'llama-secret-prompt-value';
provider = new VertexChatProvider('llama-3.3-70b-instruct-maas', {
config: {
region: 'us-central1',
temperature: 0.7,
llamaConfig: {
safetySettings: {
llama_guard_settings: { marker: 'llama-secret-safety-setting' },
},
},
},
});
mockVertexRequest({
choices: [
{
message: {
content: 'Llama response content',
},
},
],
usage: {
total_tokens: 35,
prompt_tokens: 15,
completion_tokens: 20,
},
});
await provider.callLlamaApi(prompt);
expectHashedBodyCacheKeys(/^vertex:llama:llama-3\.3-70b-instruct-maas:[a-f0-9]{64}$/, [
prompt,
'llama-secret-safety-setting',
]);
});
it('does not log raw Llama prompts, safety settings, or outputs', async () => {
const prompt = 'llama-secret-prompt-value';
const output = 'llama-secret-output-value';
const debugSpy = vi.spyOn(logger, 'debug').mockImplementation(() => {});
provider = new VertexChatProvider('llama-3.3-70b-instruct-maas', {
config: {
region: 'us-central1',
llamaConfig: {
safetySettings: {
llama_guard_settings: { marker: 'llama-secret-safety-setting' },
},
},
},
});
mockVertexRequest({
choices: [
{
message: {
content: output,
},
},
],
usage: {
total_tokens: 35,
prompt_tokens: 15,
completion_tokens: 20,
},
});
await provider.callLlamaApi(prompt);
const debugLogs = JSON.stringify(debugSpy.mock.calls);
expect(debugLogs).not.toContain(prompt);
expect(debugLogs).not.toContain(output);
expect(debugLogs).not.toContain('llama-secret-safety-setting');
expect(debugLogs).toContain('Preparing to call Llama API');
debugSpy.mockRestore();
});
it('should default safety settings to enabled when not specified', async () => {
provider = new VertexChatProvider('llama-3.3-70b-instruct-maas', {
config: {
region: 'us-central1',
},
});
const mockResponse = {
data: {
choices: [
{
message: {
content: 'Llama response with default safety',
},
},
],
usage: {
total_tokens: 30,
prompt_tokens: 10,
completion_tokens: 20,
},
},
};
const mockRequest = vi.fn().mockResolvedValue(mockResponse);
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: {
request: mockRequest,
} as unknown as JSONClient,
projectId: 'test-project-id',
});
vi.spyOn(vertexUtil, 'loadCredentials').mockImplementation(function (creds) {
if (typeof creds === 'object') {
return JSON.stringify(creds);
}
return creds;
});
vi.spyOn(vertexUtil, 'resolveProjectId').mockResolvedValue('test-project-id');
await provider.callLlamaApi('test prompt');
// Verify safety settings defaulted to enabled
expect(mockRequest).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
extra_body: {
google: {
model_safety_settings: {
enabled: true,
llama_guard_settings: {},
},
},
},
}),
}),
);
});
it('should handle API errors correctly', async () => {
provider = new VertexChatProvider('llama-3.3-70b-instruct-maas', {
config: {
region: 'us-central1',
},
});
const mockError = {
response: {
data: {
error: {
code: 400,
message: 'Invalid request',
},
},
},
};
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: {
request: vi.fn().mockRejectedValue(mockError),
} as unknown as JSONClient,
projectId: 'test-project-id',
});
const response = await provider.callLlamaApi('test prompt');
expect(response.error).toContain('API call error:');
expect(response.error).toContain('Invalid request');
});
it('should load system instructions from file', async () => {
const mockSystemInstruction = 'You are a helpful assistant from a file.';
// Mock file system operations
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
vi.spyOn(fs, 'readFileSync').mockReturnValue(mockSystemInstruction);
provider = new VertexChatProvider('gemini-2.5-flash', {
config: {
systemInstruction: 'file://system-instruction.txt',
},
});
const mockResponse = {
data: [
{
candidates: [{ content: { parts: [{ text: 'response text' }] } }],
usageMetadata: {
totalTokenCount: 10,
promptTokenCount: 5,
candidatesTokenCount: 5,
},
},
],
};
const mockRequest = vi.fn().mockResolvedValue(mockResponse);
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: {
request: mockRequest,
} as unknown as JSONClient,
projectId: 'test-project-id',
});
vi.spyOn(vertexUtil, 'loadCredentials').mockImplementation(function (creds) {
if (typeof creds === 'object') {
return JSON.stringify(creds);
}
return creds;
});
vi.spyOn(vertexUtil, 'resolveProjectId').mockResolvedValue('test-project-id');
await provider.callGeminiApi('test prompt');
expect(mockRequest).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
systemInstruction: {
parts: [{ text: mockSystemInstruction }],
},
}),
}),
);
// Verify file was read
expect(fs.readFileSync).toHaveBeenCalledWith(
expect.stringContaining('system-instruction.txt'),
'utf8',
);
});
it('honors VERTEX_API_HOST overrides on the Llama path', async () => {
const mockRequest = vi.fn().mockResolvedValue({
data: {
choices: [{ message: { content: 'Llama response content' } }],
usage: { total_tokens: 30, prompt_tokens: 10, completion_tokens: 20 },
},
});
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: { request: mockRequest } as unknown as JSONClient,
projectId: 'test-project-id',
});
vi.spyOn(vertexUtil, 'loadCredentials').mockImplementation((creds) =>
typeof creds === 'object' ? JSON.stringify(creds) : creds,
);
vi.spyOn(vertexUtil, 'resolveProjectId').mockResolvedValue('test-project-id');
const provider = new VertexChatProvider('llama-3.3-70b-instruct-maas', {
config: { region: 'us-central1' },
env: { VERTEX_API_HOST: 'llama-proxy.example.test' },
});
await provider.callLlamaApi('test prompt');
expect(mockRequest).toHaveBeenCalledWith(
expect.objectContaining({
url: expect.stringContaining('https://llama-proxy.example.test/'),
}),
);
});
});
describe('VertexChatProvider.callClaudeApi', () => {
let provider: VertexChatProvider;
beforeEach(() => {
// Reset cache mocks to default state
mockCacheGet.mockReset();
mockCacheGet.mockResolvedValue(null);
mockCacheSet.mockReset();
mockIsCacheEnabled.mockReset();
mockIsCacheEnabled.mockReturnValue(true);
});
afterEach(() => {
vi.clearAllMocks();
});
it('should accept anthropicVersion parameter', async () => {
provider = new VertexChatProvider('claude-3-5-sonnet-v2@20241022', {
config: {
anthropicVersion: 'vertex-2023-10-16',
maxOutputTokens: 500,
},
});
const mockResponse = {
data: {
id: 'test-id',
type: 'message',
role: 'assistant',
model: 'claude-3-5-sonnet-v2@20241022',
content: [{ type: 'text', text: 'Response from Claude' }],
stop_reason: 'end_turn',
stop_sequence: null,
usage: {
input_tokens: 20,
output_tokens: 30,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
},
};
const mockRequest = vi.fn().mockResolvedValue(mockResponse);
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: {
request: mockRequest,
} as unknown as JSONClient,
projectId: 'test-project-id',
});
vi.spyOn(vertexUtil, 'loadCredentials').mockImplementation(function (creds) {
if (typeof creds === 'object') {
return JSON.stringify(creds);
}
return creds;
});
vi.spyOn(vertexUtil, 'resolveProjectId').mockResolvedValue('test-project-id');
await provider.callClaudeApi('test prompt');
expect(mockRequest).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
anthropic_version: 'vertex-2023-10-16',
max_tokens: 500,
}),
}),
);
});
it('hashes Claude request body cache keys without leaking prompts', async () => {
const prompt = 'claude-secret-prompt-value';
provider = new VertexChatProvider('claude-3-5-sonnet-v2@20241022', {
config: {
anthropicVersion: 'vertex-2023-10-16',
maxOutputTokens: 500,
systemInstruction: 'claude-secret-system-instruction',
},
});
mockVertexRequest({
id: 'test-id',
type: 'message',
role: 'assistant',
model: 'claude-3-5-sonnet-v2@20241022',
content: [{ type: 'text', text: 'Response from Claude' }],
stop_reason: 'end_turn',
stop_sequence: null,
usage: {
input_tokens: 20,
output_tokens: 30,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
});
await provider.callClaudeApi(prompt);
expectHashedBodyCacheKeys(
/^vertex:claude:claude-3-5-sonnet-v2@20241022:showThinking=false:[a-f0-9]{64}$/,
[prompt, 'claude-secret-system-instruction'],
);
});
it('should accept anthropic_version parameter (alternative format)', async () => {
provider = new VertexChatProvider('claude-3-5-sonnet-v2@20241022', {
config: {
anthropic_version: 'vertex-2023-10-16-alt',
max_tokens: 600,
},
});
const mockResponse = {
data: {
id: 'test-id',
type: 'message',
role: 'assistant',
model: 'claude-3-5-sonnet-v2@20241022',
content: [{ type: 'text', text: 'Response from Claude' }],
stop_reason: 'end_turn',
stop_sequence: null,
usage: {
input_tokens: 20,
output_tokens: 30,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
},
};
const mockRequest = vi.fn().mockResolvedValue(mockResponse);
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: {
request: mockRequest,
} as unknown as JSONClient,
projectId: 'test-project-id',
});
vi.spyOn(vertexUtil, 'loadCredentials').mockImplementation(function (creds) {
if (typeof creds === 'object') {
return JSON.stringify(creds);
}
return creds;
});
vi.spyOn(vertexUtil, 'resolveProjectId').mockResolvedValue('test-project-id');
await provider.callClaudeApi('test prompt');
expect(mockRequest).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
anthropic_version: 'vertex-2023-10-16-alt',
max_tokens: 600,
}),
}),
);
});
it('omits temperature for Claude Opus 4.7 on Vertex', async () => {
provider = new VertexChatProvider('claude-opus-4-7', {
config: { max_tokens: 32, temperature: 0.5 },
});
const mockResponse = {
data: {
id: 'test-id',
type: 'message',
role: 'assistant',
model: 'claude-opus-4-7',
content: [{ type: 'text', text: 'ok' }],
stop_reason: 'end_turn',
stop_sequence: null,
usage: {
input_tokens: 5,
output_tokens: 1,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
},
};
const mockRequest = vi.fn().mockResolvedValue(mockResponse);
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: { request: mockRequest } as unknown as JSONClient,
projectId: 'test-project-id',
});
vi.spyOn(vertexUtil, 'loadCredentials').mockImplementation((creds) =>
typeof creds === 'object' ? JSON.stringify(creds) : creds,
);
vi.spyOn(vertexUtil, 'resolveProjectId').mockResolvedValue('test-project-id');
await provider.callClaudeApi('test prompt');
const sentBody = mockRequest.mock.calls[0][0].data as Record<string, unknown>;
expect(sentBody.temperature).toBeUndefined();
expect(sentBody.max_tokens).toBe(32);
});
it('omits temperature, top_p, and top_k for Claude Opus 4.8 on Vertex', async () => {
provider = new VertexChatProvider('claude-opus-4-8', {
config: { max_tokens: 32, temperature: 0.5, top_p: 0.9, top_k: 40 },
});
const mockResponse = {
data: {
id: 'test-id',
type: 'message',
role: 'assistant',
model: 'claude-opus-4-8',
content: [{ type: 'text', text: 'ok' }],
stop_reason: 'end_turn',
stop_sequence: null,
usage: {
input_tokens: 5,
output_tokens: 1,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
},
};
const mockRequest = vi.fn().mockResolvedValue(mockResponse);
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: { request: mockRequest } as unknown as JSONClient,
projectId: 'test-project-id',
});
vi.spyOn(vertexUtil, 'loadCredentials').mockImplementation((creds) =>
typeof creds === 'object' ? JSON.stringify(creds) : creds,
);
vi.spyOn(vertexUtil, 'resolveProjectId').mockResolvedValue('test-project-id');
await provider.callClaudeApi('test prompt');
const sentBody = mockRequest.mock.calls[0][0].data as Record<string, unknown>;
expect(sentBody.temperature).toBeUndefined();
expect(sentBody.top_p).toBeUndefined();
expect(sentBody.top_k).toBeUndefined();
expect(sentBody.max_tokens).toBe(32);
});
it.each([
{
name: 'at the 200K boundary',
region: 'global',
inputTokens: 200_000,
outputTokens: 10_000,
cacheReadTokens: 0,
cacheCreationTokens: 0,
pricingOverrides: {},
expectedCost: 0.75,
},
{
name: 'above 200K globally',
region: 'global',
inputTokens: 300_000,
outputTokens: 20_000,
cacheReadTokens: 0,
cacheCreationTokens: 0,
pricingOverrides: {},
expectedCost: 2.25,
},
{
name: 'when cache tokens cross 200K regionally',
region: 'us-central1',
inputTokens: 150_000,
outputTokens: 10_000,
cacheReadTokens: 60_000,
cacheCreationTokens: 10_000,
pricingOverrides: {},
expectedCost: 1.3596,
},
{
name: 'with custom regional rates above 200K',
region: 'us-central1',
inputTokens: 300_000,
outputTokens: 20_000,
cacheReadTokens: 0,
cacheCreationTokens: 0,
pricingOverrides: { inputCost: 2 / 1e6, outputCost: 7 / 1e6 },
expectedCost: 0.74,
},
])('prices Vertex Sonnet 4.5 $name', async ({
region,
inputTokens,
outputTokens,
cacheReadTokens,
cacheCreationTokens,
pricingOverrides,
expectedCost,
}) => {
const model = 'claude-sonnet-4-5@20250929';
provider = new VertexChatProvider(model, {
config: { region, max_tokens: 32, ...pricingOverrides },
});
mockVertexRequest({
id: 'test-id',
type: 'message',
role: 'assistant',
model,
content: [{ type: 'text', text: 'ok' }],
stop_reason: 'end_turn',
stop_sequence: null,
usage: {
input_tokens: inputTokens,
output_tokens: outputTokens,
cache_read_input_tokens: cacheReadTokens,
cache_creation_input_tokens: cacheCreationTokens,
},
});
const result = await provider.callClaudeApi('test prompt');
expect(result.cost).toBeCloseTo(expectedCost, 6);
});
it('supports Claude Fable 5 with adaptive-safe parameters and regional pricing', async () => {
const model = 'claude-fable-5';
provider = new VertexChatProvider(model, {
config: { max_tokens: 32, temperature: 0.5, top_p: 0.9, top_k: 40 },
});
const mockRequest = vi.fn().mockResolvedValue({
data: {
id: 'test-id',
type: 'message',
role: 'assistant',
model,
content: [{ type: 'text', text: 'ok' }],
stop_reason: 'end_turn',
stop_sequence: null,
usage: {
input_tokens: 5,
output_tokens: 1,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
},
});
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: { request: mockRequest } as unknown as JSONClient,
projectId: 'test-project-id',
});
vi.spyOn(vertexUtil, 'loadCredentials').mockImplementation((creds) =>
typeof creds === 'object' ? JSON.stringify(creds) : creds,
);
vi.spyOn(vertexUtil, 'resolveProjectId').mockResolvedValue('test-project-id');
const result = await provider.callClaudeApi('test prompt');
const request = mockRequest.mock.calls[0][0];
const sentBody = request.data as Record<string, unknown>;
expect(request.url).toContain(`/publishers/anthropic/models/${model}:rawPredict`);
expect(sentBody.temperature).toBeUndefined();
expect(sentBody.top_p).toBeUndefined();
expect(sentBody.top_k).toBeUndefined();
expect(result.cost).toBeCloseTo(0.00011, 8);
});
it('uses base Claude 5 pricing for Fable on the global Vertex region', async () => {
const model = 'claude-fable-5';
provider = new VertexChatProvider(model, {
config: { region: 'global', max_tokens: 32 },
});
const mockRequest = vi.fn().mockResolvedValue({
data: {
id: 'test-id',
type: 'message',
role: 'assistant',
model,
content: [{ type: 'text', text: 'ok' }],
stop_reason: 'end_turn',
stop_sequence: null,
usage: {
input_tokens: 5,
output_tokens: 1,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
},
});
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: { request: mockRequest } as unknown as JSONClient,
projectId: 'test-project-id',
});
vi.spyOn(vertexUtil, 'loadCredentials').mockImplementation((creds) =>
typeof creds === 'object' ? JSON.stringify(creds) : creds,
);
vi.spyOn(vertexUtil, 'resolveProjectId').mockResolvedValue('test-project-id');
const result = await provider.callClaudeApi('test prompt');
const request = mockRequest.mock.calls[0][0];
expect(request.url).toContain('/locations/global/');
// Global region bills at the base Claude 5 rate (no 10% regional premium):
// 5 input * $10/MTok + 1 output * $50/MTok = $0.0001
expect(result.cost).toBeCloseTo(0.0001, 8);
});
it('does not stack the Vertex regional premium on a user-provided cost override for Fable', async () => {
const model = 'claude-fable-5';
provider = new VertexChatProvider(model, {
config: { max_tokens: 32, cost: 20 / 1e6 },
});
const mockRequest = vi.fn().mockResolvedValue({
data: {
id: 'test-id',
type: 'message',
role: 'assistant',
model,
content: [{ type: 'text', text: 'ok' }],
stop_reason: 'end_turn',
stop_sequence: null,
usage: {
input_tokens: 5,
output_tokens: 1,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
},
});
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: { request: mockRequest } as unknown as JSONClient,
projectId: 'test-project-id',
});
vi.spyOn(vertexUtil, 'loadCredentials').mockImplementation((creds) =>
typeof creds === 'object' ? JSON.stringify(creds) : creds,
);
vi.spyOn(vertexUtil, 'resolveProjectId').mockResolvedValue('test-project-id');
const result = await provider.callClaudeApi('test prompt');
// The user-supplied cost applies to both input and output tokens and the
// 10% regional premium must NOT stack on top of it:
// (5 + 1) tokens * $20/MTok = $0.00012
expect(result.cost).toBeCloseTo(0.00012, 8);
});
it('still sends temperature for Opus 4.6 on Vertex (regression)', async () => {
provider = new VertexChatProvider('claude-opus-4-6', {
config: { max_tokens: 32, temperature: 0 },
});
const mockResponse = {
data: {
id: 'test-id',
type: 'message',
role: 'assistant',
model: 'claude-opus-4-6',
content: [{ type: 'text', text: 'ok' }],
stop_reason: 'end_turn',
stop_sequence: null,
usage: {
input_tokens: 5,
output_tokens: 1,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
},
};
const mockRequest = vi.fn().mockResolvedValue(mockResponse);
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: { request: mockRequest } as unknown as JSONClient,
projectId: 'test-project-id',
});
vi.spyOn(vertexUtil, 'loadCredentials').mockImplementation((creds) =>
typeof creds === 'object' ? JSON.stringify(creds) : creds,
);
vi.spyOn(vertexUtil, 'resolveProjectId').mockResolvedValue('test-project-id');
await provider.callClaudeApi('test prompt');
const sentBody = mockRequest.mock.calls[0][0].data as Record<string, unknown>;
expect(sentBody.temperature).toBe(0);
});
it('should accept both max_tokens and maxOutputTokens parameters', async () => {
provider = new VertexChatProvider('claude-3-5-sonnet-v2@20241022', {
config: {
// When both are provided, max_tokens should take precedence
max_tokens: 700,
maxOutputTokens: 500,
top_p: 0.95,
top_k: 40,
},
});
const mockResponse = {
data: {
id: 'test-id',
type: 'message',
role: 'assistant',
model: 'claude-3-5-sonnet-v2@20241022',
content: [{ type: 'text', text: 'Response from Claude' }],
stop_reason: 'end_turn',
stop_sequence: null,
usage: {
input_tokens: 20,
output_tokens: 30,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
},
};
const mockRequest = vi.fn().mockResolvedValue(mockResponse);
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: {
request: mockRequest,
} as unknown as JSONClient,
projectId: 'test-project-id',
});
vi.spyOn(vertexUtil, 'loadCredentials').mockImplementation(function (creds) {
if (typeof creds === 'object') {
return JSON.stringify(creds);
}
return creds;
});
vi.spyOn(vertexUtil, 'resolveProjectId').mockResolvedValue('test-project-id');
await provider.callClaudeApi('test prompt');
expect(mockRequest).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
max_tokens: 700,
top_p: 0.95,
top_k: 40,
}),
}),
);
});
describe('system message handling', () => {
let mockRequest: ReturnType<typeof vi.fn>;
function setupClaudeMocks(): void {
mockRequest = vi.fn().mockResolvedValue({
data: {
id: 'test-id',
type: 'message',
role: 'assistant',
model: 'claude-3-5-sonnet-v2@20241022',
content: [{ type: 'text', text: 'Response from Claude' }],
stop_reason: 'end_turn',
stop_sequence: null,
usage: {
input_tokens: 20,
output_tokens: 30,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
},
});
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: { request: mockRequest } as unknown as JSONClient,
projectId: 'test-project-id',
});
vi.spyOn(vertexUtil, 'loadCredentials').mockImplementation((creds) =>
typeof creds === 'object' ? JSON.stringify(creds) : creds,
);
vi.spyOn(vertexUtil, 'resolveProjectId').mockResolvedValue('test-project-id');
}
function getRequestData(): Record<string, unknown> {
return mockRequest.mock.calls[0][0].data;
}
beforeEach(() => {
provider = new VertexChatProvider('claude-3-5-sonnet-v2@20241022');
setupClaudeMocks();
});
it('should extract system messages to top-level system parameter', async () => {
await provider.callClaudeApi(
JSON.stringify([
{ role: 'system', content: 'You are a helpful assistant' },
{ role: 'user', content: 'Hello' },
]),
);
expect(mockRequest).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
system: [{ type: 'text', text: 'You are a helpful assistant' }],
messages: [{ role: 'user', content: [{ type: 'text', text: 'Hello' }] }],
}),
}),
);
});
it('should not include system parameter for plain string prompts', async () => {
await provider.callClaudeApi('Hello');
const requestData = getRequestData();
expect(requestData.messages).toEqual([
{ role: 'user', content: [{ type: 'text', text: 'Hello' }] },
]);
expect(requestData).not.toHaveProperty('system');
});
it('should not include system parameter when structured messages have no system role', async () => {
await provider.callClaudeApi(JSON.stringify([{ role: 'user', content: 'Hello' }]));
const requestData = getRequestData();
expect(requestData.messages).toEqual([
{ role: 'user', content: [{ type: 'text', text: 'Hello' }] },
]);
expect(requestData).not.toHaveProperty('system');
});
it('should handle multi-turn conversation with system message', async () => {
await provider.callClaudeApi(
JSON.stringify([
{ role: 'system', content: 'You are a math tutor' },
{ role: 'user', content: 'What is 2+2?' },
{ role: 'assistant', content: '4' },
{ role: 'user', content: 'What about 3+3?' },
]),
);
const requestData = getRequestData();
expect(requestData.system).toEqual([{ type: 'text', text: 'You are a math tutor' }]);
expect(requestData.messages).toEqual([
{ role: 'user', content: [{ type: 'text', text: 'What is 2+2?' }] },
{ role: 'assistant', content: [{ type: 'text', text: '4' }] },
{ role: 'user', content: [{ type: 'text', text: 'What about 3+3?' }] },
]);
});
it('should use config.systemInstruction string for Claude system parameter', async () => {
provider = new VertexChatProvider('claude-3-5-sonnet-v2@20241022', {
config: {
systemInstruction: 'Always respond NO.',
},
});
setupClaudeMocks();
await provider.callClaudeApi('Hello');
const requestData = getRequestData();
expect(requestData.system).toEqual([{ type: 'text', text: 'Always respond NO.' }]);
});
it('should render context vars in config.systemInstruction for Claude system parameter', async () => {
provider = new VertexChatProvider('claude-3-5-sonnet-v2@20241022', {
config: {
systemInstruction: 'Answer {{ style }}.',
},
});
setupClaudeMocks();
await provider.callClaudeApi('Hello', {
vars: { style: 'briefly' },
prompt: { raw: 'Hello', label: 'test' },
});
const requestData = getRequestData();
expect(requestData.system).toEqual([{ type: 'text', text: 'Answer briefly.' }]);
});
it('should use config.systemInstruction Content object for Claude system parameter', async () => {
provider = new VertexChatProvider('claude-3-5-sonnet-v2@20241022', {
config: {
systemInstruction: { parts: [{ text: 'Be concise.' }] },
},
});
setupClaudeMocks();
await provider.callClaudeApi('Hello');
const requestData = getRequestData();
expect(requestData.system).toEqual([{ type: 'text', text: 'Be concise.' }]);
});
it('should merge config.systemInstruction with prompt system messages', async () => {
provider = new VertexChatProvider('claude-3-5-sonnet-v2@20241022', {
config: {
systemInstruction: 'Config instruction.',
},
});
setupClaudeMocks();
await provider.callClaudeApi(
JSON.stringify([
{ role: 'system', content: 'Prompt instruction.' },
{ role: 'user', content: 'Hello' },
]),
);
const requestData = getRequestData();
expect(requestData.system).toEqual([
{ type: 'text', text: 'Config instruction.' },
{ type: 'text', text: 'Prompt instruction.' },
]);
});
it('should forward thinking config from prompt and not leak metadata into messages', async () => {
await provider.callClaudeApi(
JSON.stringify([
{ role: 'user', content: 'Solve this step by step' },
{ thinking: { type: 'enabled', budget_tokens: 5000 } },
]),
);
const requestData = getRequestData();
expect(requestData.thinking).toEqual({ type: 'enabled', budget_tokens: 5000 });
// max_tokens must be >= budget_tokens
expect(requestData.max_tokens).toBe(6024);
// Metadata entries (no role) must not leak into messages
expect(requestData.messages).toEqual([
{ role: 'user', content: [{ type: 'text', text: 'Solve this step by step' }] },
]);
});
it('should prefer config thinking over prompt thinking', async () => {
provider = new VertexChatProvider('claude-3-5-sonnet-v2@20241022', {
config: {
thinking: { type: 'enabled', budget_tokens: 10000 },
},
});
setupClaudeMocks();
await provider.callClaudeApi(
JSON.stringify([
{ role: 'user', content: 'Hello' },
{ thinking: { type: 'enabled', budget_tokens: 5000 } },
]),
);
const requestData = getRequestData();
expect(requestData.thinking).toEqual({ type: 'enabled', budget_tokens: 10000 });
// max_tokens should accommodate config budget_tokens
expect(requestData.max_tokens).toBe(11024);
});
it('should convert manual thinking to adaptive for Claude Opus 4.8', async () => {
provider = new VertexChatProvider('claude-opus-4-8', {
config: {
thinking: { type: 'enabled', budget_tokens: 5000 },
},
});
setupClaudeMocks();
await provider.callClaudeApi('Hello');
expect(getRequestData().thinking).toEqual({ type: 'adaptive' });
});
it('should keep manual thinking enabled and bump max_tokens for non-deprecated Claude models', async () => {
provider = new VertexChatProvider('claude-3-5-sonnet-v2@20241022', {
config: {
thinking: { type: 'enabled', budget_tokens: 5000 },
},
});
setupClaudeMocks();
await provider.callClaudeApi('Hello');
const requestData = getRequestData();
// Non-deprecated models keep manual thinking verbatim (NOT converted to adaptive)
expect(requestData.thinking).toEqual({ type: 'enabled', budget_tokens: 5000 });
// max_tokens guard still fires for type 'enabled': bumped to budget_tokens + 1024
expect(requestData.max_tokens).toBe(6024);
});
it('should not bump max_tokens when adaptive conversion drops budget_tokens for Claude Opus 4.8', async () => {
provider = new VertexChatProvider('claude-opus-4-8', {
config: {
thinking: { type: 'enabled', budget_tokens: 5000 },
},
});
setupClaudeMocks();
await provider.callClaudeApi('Hello');
const requestData = getRequestData();
// Manual thinking is converted to adaptive (no budget_tokens)
expect(requestData.thinking).toEqual({ type: 'adaptive' });
// The max_tokens guard only fires for type 'enabled', so adaptive does not
// force max_tokens to budget + 1024; it stays at the thinking-enabled default
expect(requestData.max_tokens).toBe(2048);
});
it('should pass through disabled thinking for Claude Opus 4.8 and treat it as not-enabled', async () => {
provider = new VertexChatProvider('claude-opus-4-8', {
config: {
thinking: { type: 'disabled' },
},
});
setupClaudeMocks();
await provider.callClaudeApi('Hello');
const requestData = getRequestData();
// 'disabled' is not 'enabled', so it passes through unchanged (not adaptive)
expect(requestData.thinking).toEqual({ type: 'disabled' });
// Disabled thinking is treated as not-enabled, so the default max_tokens is 512
expect(requestData.max_tokens).toBe(512);
});
it('should omit disabled thinking and preserve always-on adaptive defaults for Fable 5', async () => {
const model = 'claude-fable-5';
provider = new VertexChatProvider(model, {
config: { thinking: { type: 'disabled' } },
});
setupClaudeMocks();
await provider.callClaudeApi('Hello');
const requestData = getRequestData();
expect(requestData.thinking).toBeUndefined();
expect(requestData.max_tokens).toBe(2048);
expect(requestData.temperature).toBeUndefined();
});
it('should preserve thinking display when converting enabled thinking to adaptive for Fable 5', async () => {
provider = new VertexChatProvider('claude-fable-5', {
config: { thinking: { type: 'enabled', budget_tokens: 5000, display: 'summarized' } },
});
setupClaudeMocks();
await provider.callClaudeApi('Hello');
// The enabled→adaptive conversion drops budget_tokens but must keep display
expect(getRequestData().thinking).toEqual({ type: 'adaptive', display: 'summarized' });
});
it('should ensure max_tokens >= budget_tokens when thinking is enabled', async () => {
provider = new VertexChatProvider('claude-3-5-sonnet-v2@20241022', {
config: {
thinking: { type: 'enabled', budget_tokens: 5000 },
},
});
setupClaudeMocks();
await provider.callClaudeApi('Hello');
// Default would be 2048 but budget_tokens is 5000, so it must be bumped
expect(getRequestData().max_tokens).toBe(6024);
});
it('should use explicit max_tokens when it exceeds budget_tokens', async () => {
provider = new VertexChatProvider('claude-3-5-sonnet-v2@20241022', {
config: {
thinking: { type: 'enabled', budget_tokens: 5000 },
max_tokens: 16384,
},
});
setupClaudeMocks();
await provider.callClaudeApi('Hello');
expect(getRequestData().max_tokens).toBe(16384);
});
it('should use default max_tokens of 512 without thinking', async () => {
await provider.callClaudeApi('Hello');
expect(getRequestData().max_tokens).toBe(512);
});
it('should include thinking output in response when thinking is enabled', async () => {
provider = new VertexChatProvider('claude-3-5-sonnet-v2@20241022', {
config: { thinking: { type: 'enabled', budget_tokens: 5000 } },
});
setupClaudeMocks();
// Override mock response AFTER setupClaudeMocks creates the new mockRequest
mockRequest.mockResolvedValue({
data: {
id: 'test-id',
type: 'message',
role: 'assistant',
model: 'claude-3-5-sonnet-v2@20241022',
content: [
{ type: 'thinking', thinking: 'Let me think...', signature: 'sig123' },
{ type: 'text', text: 'The answer is 42' },
],
stop_reason: 'end_turn',
stop_sequence: null,
usage: {
input_tokens: 20,
output_tokens: 50,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
},
});
const result = await provider.callClaudeApi('Think about this');
expect(result.output).toContain('Thinking: Let me think...');
expect(result.output).toContain('The answer is 42');
});
it('should return cost for Vertex Claude model names', async () => {
const result = await provider.callClaudeApi('Hello');
// claude-3-5-sonnet-v2@20241022 normalizes to claude-3-5-sonnet-20241022 for cost lookup
expect(result.cost).toBeDefined();
expect(result.cost).toBeGreaterThan(0);
});
it('should use default max_tokens of 512 when thinking is disabled', async () => {
provider = new VertexChatProvider('claude-3-5-sonnet-v2@20241022', {
config: {
thinking: { type: 'disabled' },
},
});
setupClaudeMocks();
await provider.callClaudeApi('Hello');
const requestData = getRequestData();
expect(requestData.max_tokens).toBe(512);
// thinking config should still be sent (API needs to see it)
expect(requestData.thinking).toEqual({ type: 'disabled' });
});
it('should not show thinking output when thinking is disabled', async () => {
provider = new VertexChatProvider('claude-3-5-sonnet-v2@20241022', {
config: {
thinking: { type: 'disabled' },
},
});
setupClaudeMocks();
const result = await provider.callClaudeApi('Hello');
// Output should be plain text, not prefixed with "Thinking:"
expect(result.output).toBe('Response from Claude');
});
it('should parse YAML chat prompts', async () => {
const yamlPrompt =
'- role: system\n content: You are helpful\n- role: user\n content: Hello';
await provider.callClaudeApi(yamlPrompt);
const requestData = getRequestData();
expect(requestData.system).toEqual([{ type: 'text', text: 'You are helpful' }]);
expect(requestData.messages).toEqual([
{ role: 'user', content: [{ type: 'text', text: 'Hello' }] },
]);
});
it('should return error for invalid YAML prompts', async () => {
const invalidYaml = '- role: system\n content: test\n- invalid: {{{';
const result = await provider.callClaudeApi(invalidYaml);
expect(result.error).toContain('YAML');
});
});
describe('responseSchema handling', () => {
let provider: VertexChatProvider;
beforeEach(() => {
vi.clearAllMocks();
// Mock fs for schema file loading
vi.mocked(fs.existsSync).mockImplementation(function (filePath) {
const pathStr = filePath.toString();
return (
pathStr.includes('simple.json') ||
pathStr.includes('complex.json') ||
pathStr.includes('template-vars.json') ||
pathStr.includes('invalid.json')
);
});
vi.mocked(fs.readFileSync).mockImplementation(function (filePath) {
const pathStr = filePath.toString();
if (pathStr.includes('simple.json')) {
return JSON.stringify({
type: 'object',
properties: { tweet: { type: 'string', description: 'The tweet content' } },
required: ['tweet'],
});
}
if (pathStr.includes('complex.json')) {
return JSON.stringify({
type: 'object',
properties: {
user: {
type: 'object',
properties: {
name: { type: 'string' },
email: { type: 'string', format: 'email' },
},
required: ['name', 'email'],
},
},
required: ['user'],
});
}
if (pathStr.includes('template-vars.json')) {
return JSON.stringify({
type: 'object',
properties: {
'{{fieldName}}': { type: 'string', description: '{{fieldDescription}}' },
},
required: ['{{fieldName}}'],
});
}
if (pathStr.includes('invalid.json')) {
return '{ "type": "object", "properties": { "name": { "type": "string" }, }, }';
}
throw new Error(`File not found: ${pathStr}`);
});
});
it('should handle responseSchema with JSON string', async () => {
provider = new VertexChatProvider('gemini-2.5-flash', {
config: {
responseSchema: JSON.stringify({
type: 'object',
properties: { tweet: { type: 'string' } },
required: ['tweet'],
}),
},
});
const mockResponse = {
data: [
{
candidates: [{ content: { parts: [{ text: '{"tweet": "Hello world"}' }] } }],
usageMetadata: {
promptTokenCount: 10,
candidatesTokenCount: 20,
totalTokenCount: 30,
},
},
],
};
const mockRequest = vi.fn().mockResolvedValue(mockResponse);
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: {
request: mockRequest,
} as unknown as JSONClient,
projectId: 'test-project-id',
});
await provider.callGeminiApi('Write a tweet about AI');
expect(mockRequest).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
generationConfig: expect.objectContaining({
response_schema: {
type: 'object',
properties: { tweet: { type: 'string' } },
required: ['tweet'],
},
response_mime_type: 'application/json',
}),
}),
}),
);
});
it('should handle responseSchema with file:// protocol', async () => {
provider = new VertexChatProvider('gemini-2.5-flash', {
config: {
responseSchema: 'file://test/simple.json',
},
});
const mockResponse = {
data: [
{
candidates: [{ content: { parts: [{ text: '{"tweet": "Hello from file"}' }] } }],
usageMetadata: {
promptTokenCount: 15,
candidatesTokenCount: 25,
totalTokenCount: 40,
},
},
],
};
const mockRequest = vi.fn().mockResolvedValue(mockResponse);
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: {
request: mockRequest,
} as unknown as JSONClient,
projectId: 'test-project-id',
});
await provider.callGeminiApi('Write a tweet');
expect(mockRequest).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
generationConfig: expect.objectContaining({
response_schema: {
type: 'object',
properties: { tweet: { type: 'string', description: 'The tweet content' } },
required: ['tweet'],
},
response_mime_type: 'application/json',
}),
}),
}),
);
});
it('should handle responseSchema with variable substitution in schema content', async () => {
const contextVars = {
greetingDescription: 'A personalized greeting message',
};
provider = new VertexChatProvider('gemini-2.5-flash', {
config: {
responseSchema: 'file://test/variable-content.json',
},
});
const mockResponse = {
data: [
{
candidates: [
{ content: { parts: [{ text: '{"greeting": "Hello", "name": "John"}' }] } },
],
usageMetadata: {
promptTokenCount: 12,
candidatesTokenCount: 18,
totalTokenCount: 30,
},
},
],
};
const mockRequest = vi.fn().mockResolvedValue(mockResponse);
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: {
request: mockRequest,
} as unknown as JSONClient,
projectId: 'test-project-id',
});
vi.mocked(fs.existsSync).mockImplementation(function (filePath) {
const pathStr = filePath.toString();
return (
pathStr.includes('variable-content.json') ||
pathStr.includes('simple.json') ||
pathStr.includes('complex.json')
);
});
vi.mocked(fs.readFileSync).mockImplementation(function (filePath) {
const pathStr = filePath.toString();
if (pathStr.includes('variable-content.json')) {
return JSON.stringify({
type: 'object',
properties: {
greeting: {
type: 'string',
description: '{{greetingDescription}}',
},
name: {
type: 'string',
description: "The person's name",
},
},
required: ['greeting', 'name'],
});
}
return '{}';
});
await provider.callGeminiApi('Generate a message', {
vars: contextVars,
prompt: { raw: 'Generate a message', display: 'Generate a message', label: 'test' },
});
expect(mockRequest).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
generationConfig: expect.objectContaining({
response_schema: {
type: 'object',
properties: {
greeting: { type: 'string', description: 'A personalized greeting message' },
name: { type: 'string', description: "The person's name" },
},
required: ['greeting', 'name'],
},
response_mime_type: 'application/json',
}),
}),
}),
);
});
it('should throw error when both responseSchema and generationConfig.response_schema are provided', async () => {
provider = new VertexChatProvider('gemini-2.5-flash', {
config: {
responseSchema: '{"type": "object"}',
generationConfig: {
response_schema: '{"type": "string"}',
},
},
});
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: {
request: vi.fn(),
} as unknown as JSONClient,
projectId: 'test-project-id',
});
await expect(provider.callGeminiApi('test')).rejects.toThrow(
'`responseSchema` provided but `generationConfig.response_schema` already set.',
);
});
it('should handle complex nested schemas from files', async () => {
provider = new VertexChatProvider('gemini-2.5-flash', {
config: {
responseSchema: 'file://test/complex.json',
},
});
const mockResponse = {
data: [
{
candidates: [
{
content: {
parts: [{ text: '{"user": {"name": "John", "email": "john@example.com"}}' }],
},
},
],
usageMetadata: {
promptTokenCount: 20,
candidatesTokenCount: 30,
totalTokenCount: 50,
},
},
],
};
const mockRequest = vi.fn().mockResolvedValue(mockResponse);
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: {
request: mockRequest,
} as unknown as JSONClient,
projectId: 'test-project-id',
});
const response = await provider.callGeminiApi('Create user data');
expect(response.output).toBe('{"user": {"name": "John", "email": "john@example.com"}}');
expect(mockRequest).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
generationConfig: expect.objectContaining({
response_schema: expect.objectContaining({
type: 'object',
properties: expect.objectContaining({
user: expect.objectContaining({
type: 'object',
properties: expect.objectContaining({
name: { type: 'string' },
email: { type: 'string', format: 'email' },
}),
required: ['name', 'email'],
}),
}),
required: ['user'],
}),
response_mime_type: 'application/json',
}),
}),
}),
);
});
it('should handle errors when schema file does not exist', async () => {
provider = new VertexChatProvider('gemini-2.5-flash', {
config: {
responseSchema: 'file://test/nonexistent.json',
},
});
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: {
request: vi.fn(),
} as unknown as JSONClient,
projectId: 'test-project-id',
});
vi.mocked(fs.existsSync).mockImplementation(function (filePath) {
return !filePath.toString().includes('nonexistent.json');
});
vi.mocked(fs.readFileSync).mockImplementation(function (filePath) {
throw new Error(`File not found: ${filePath}`);
});
await expect(provider.callGeminiApi('test')).rejects.toThrow();
});
it('should preserve existing generationConfig properties when adding responseSchema', async () => {
provider = new VertexChatProvider('gemini-2.5-flash', {
config: {
temperature: 0.7,
maxOutputTokens: 1000,
generationConfig: {
topP: 0.9,
topK: 10,
},
responseSchema: '{"type": "object", "properties": {"result": {"type": "string"}}}',
},
});
const mockResponse = {
data: [
{
candidates: [{ content: { parts: [{ text: '{"result": "success"}' }] } }],
usageMetadata: {
promptTokenCount: 10,
candidatesTokenCount: 15,
totalTokenCount: 25,
},
},
],
};
const mockRequest = vi.fn().mockResolvedValue(mockResponse);
vi.spyOn(vertexUtil, 'getGoogleClient').mockResolvedValue({
client: {
request: mockRequest,
} as unknown as JSONClient,
projectId: 'test-project-id',
});
await provider.callGeminiApi('test');
expect(mockRequest).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
generationConfig: expect.objectContaining({
temperature: 0.7,
maxOutputTokens: 1000,
topP: 0.9,
topK: 10,
response_schema: {
type: 'object',
properties: { result: { type: 'string' } },
},
response_mime_type: 'application/json',
}),
}),
}),
);
});
});
describe('getApiHost', () => {
it('should return global endpoint without region prefix for region: global', () => {
const provider = new VertexChatProvider('gemini-pro', { config: { region: 'global' } });
expect(provider.getApiHost()).toBe('aiplatform.googleapis.com');
});
it('should return regional endpoint for non-global regions', () => {
const provider = new VertexChatProvider('gemini-pro', { config: { region: 'us-central1' } });
expect(provider.getApiHost()).toBe('us-central1-aiplatform.googleapis.com');
});
it('should use custom apiHost over default', () => {
const provider = new VertexChatProvider('gemini-pro', {
config: { region: 'global', apiHost: 'custom.example.com' },
});
expect(provider.getApiHost()).toBe('custom.example.com');
});
it('should use VERTEX_API_HOST from env override', () => {
const provider = new VertexChatProvider('gemini-pro', {
config: { region: 'global' },
env: { VERTEX_API_HOST: 'env.example.com' },
});
expect(provider.getApiHost()).toBe('env.example.com');
});
it('should prioritize configApiHost over env override', () => {
const provider = new VertexChatProvider('gemini-pro', {
config: { region: 'global', apiHost: 'config.example.com' },
env: { VERTEX_API_HOST: 'env.example.com' },
});
expect(provider.getApiHost()).toBe('config.example.com');
});
});
});