chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:24:08 +08:00
commit 0d3cb498a3
5438 changed files with 1316560 additions and 0 deletions
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import {
AIStudioChatProvider,
getGoogleAiStudioProviders,
} from '../../../src/providers/google/ai.studio';
describe('Google AI Studio Default Providers', () => {
it('should create providers with correct model names', () => {
const providers = getGoogleAiStudioProviders();
expect(providers.gradingProvider.id()).toBe('google:gemini-2.5-pro');
expect(providers.gradingJsonProvider.id()).toBe('google:gemini-2.5-pro');
expect(providers.llmRubricProvider.id()).toBe('google:gemini-2.5-pro');
expect(providers.suggestionsProvider.id()).toBe('google:gemini-2.5-pro');
expect(providers.synthesizeProvider.id()).toBe('google:gemini-2.5-pro');
});
it('should create AIStudioChatProvider instances', () => {
const providers = getGoogleAiStudioProviders();
expect(providers.gradingProvider).toBeInstanceOf(AIStudioChatProvider);
expect(providers.gradingJsonProvider).toBeInstanceOf(AIStudioChatProvider);
expect(providers.llmRubricProvider).toBeInstanceOf(AIStudioChatProvider);
});
it('should configure JSON provider with correct response format', () => {
const providers = getGoogleAiStudioProviders();
expect(providers.gradingJsonProvider.config.generationConfig?.response_mime_type).toBe(
'application/json',
);
});
it('should not configure JSON response format for non-JSON providers', () => {
const providers = getGoogleAiStudioProviders();
expect(providers.gradingProvider.config.generationConfig?.response_mime_type).toBeUndefined();
expect(providers.llmRubricProvider.config.generationConfig?.response_mime_type).toBeUndefined();
});
it('should share a single chat provider instance across grading/suggestions/synthesize', () => {
const providers = getGoogleAiStudioProviders();
expect(providers.suggestionsProvider).toBe(providers.gradingProvider);
expect(providers.synthesizeProvider).toBe(providers.gradingProvider);
});
it('should return fresh provider instances on each call', () => {
const a = getGoogleAiStudioProviders();
const b = getGoogleAiStudioProviders();
expect(b.gradingProvider).not.toBe(a.gradingProvider);
expect(b.gradingJsonProvider).not.toBe(a.gradingJsonProvider);
expect(b.llmRubricProvider).not.toBe(a.llmRubricProvider);
});
it('should propagate env overrides into provider api keys', () => {
const providers = getGoogleAiStudioProviders({ GOOGLE_API_KEY: 'override-key' });
expect(providers.gradingProvider.getApiKey()).toBe('override-key');
expect(providers.gradingJsonProvider.getApiKey()).toBe('override-key');
expect(providers.llmRubricProvider.getApiKey()).toBe('override-key');
});
});
File diff suppressed because it is too large Load Diff
+854
View File
@@ -0,0 +1,854 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { GoogleAuthManager } from '../../../src/providers/google/auth';
import { mockProcessEnv } from '../../util/utils';
// Mock dependencies
vi.mock('../../../src/envars', () => ({
getEnvString: vi.fn(),
}));
vi.mock('../../../src/logger', () => ({
default: {
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('../../../src/util/file', () => ({
maybeLoadFromExternalFile: vi.fn(),
}));
const mockGoogleAuthInstance = {
getClient: vi.fn().mockResolvedValue({}),
getProjectId: vi.fn().mockResolvedValue('detected-project'),
fromJSON: vi.fn().mockResolvedValue({}),
};
vi.mock('google-auth-library', () => {
const MockGoogleAuth = vi.fn().mockImplementation(() => mockGoogleAuthInstance);
return {
GoogleAuth: MockGoogleAuth,
default: { GoogleAuth: MockGoogleAuth },
};
});
import { getEnvString } from '../../../src/envars';
import logger from '../../../src/logger';
import { maybeLoadFromExternalFile } from '../../../src/util/file';
describe('GoogleAuthManager', () => {
beforeEach(() => {
vi.clearAllMocks();
GoogleAuthManager.clearCache();
// Reset mock instance methods
mockGoogleAuthInstance.getClient.mockClear().mockResolvedValue({});
mockGoogleAuthInstance.getProjectId.mockClear().mockResolvedValue('detected-project');
mockGoogleAuthInstance.fromJSON.mockClear().mockResolvedValue({});
// Clear environment variables
mockProcessEnv({ GOOGLE_API_KEY: undefined });
mockProcessEnv({ GEMINI_API_KEY: undefined });
mockProcessEnv({ VERTEX_API_KEY: undefined });
mockProcessEnv({ PALM_API_KEY: undefined });
mockProcessEnv({ GOOGLE_GENAI_USE_VERTEXAI: undefined });
mockProcessEnv({ GOOGLE_CLOUD_PROJECT: undefined });
mockProcessEnv({ GOOGLE_CLOUD_LOCATION: undefined });
});
// Captured once during collection so afterEach can guarantee no test leaks a patched
// process.emitWarning into the next one (the suppression helper swaps it process-globally).
const realEmitWarning = process.emitWarning;
afterEach(() => {
vi.clearAllMocks();
if (process.emitWarning !== realEmitWarning) {
process.emitWarning = realEmitWarning;
}
});
describe('getApiKey', () => {
it('should prioritize config.apiKey over all env vars', () => {
vi.mocked(getEnvString).mockImplementation((key: string, defaultValue?: string) => {
if (key === 'GOOGLE_API_KEY') {
return 'google-key';
}
if (key === 'GEMINI_API_KEY') {
return 'gemini-key';
}
return defaultValue as string;
});
const result = GoogleAuthManager.getApiKey({ apiKey: 'config-key' });
expect(result.apiKey).toBe('config-key');
expect(result.source).toBe('config');
});
it('should prioritize VERTEX_API_KEY in vertex mode', () => {
vi.mocked(getEnvString).mockImplementation((key: string, defaultValue?: string) => {
if (key === 'VERTEX_API_KEY') {
return 'vertex-key';
}
if (key === 'GOOGLE_API_KEY') {
return 'google-key';
}
if (key === 'GEMINI_API_KEY') {
return 'gemini-key';
}
return defaultValue as string;
});
const result = GoogleAuthManager.getApiKey({}, undefined, true);
expect(result.apiKey).toBe('vertex-key');
expect(result.source).toBe('VERTEX_API_KEY');
});
it('should not use VERTEX_API_KEY in non-vertex mode', () => {
vi.mocked(getEnvString).mockImplementation((key: string, defaultValue?: string) => {
if (key === 'VERTEX_API_KEY') {
return 'vertex-key';
}
if (key === 'GOOGLE_API_KEY') {
return 'google-key';
}
return defaultValue as string;
});
const result = GoogleAuthManager.getApiKey({}, undefined, false);
expect(result.apiKey).toBe('google-key');
expect(result.source).toBe('GOOGLE_API_KEY');
});
it('should prioritize GOOGLE_API_KEY over GEMINI_API_KEY (Python SDK alignment)', () => {
vi.mocked(getEnvString).mockImplementation((key: string, defaultValue?: string) => {
if (key === 'GOOGLE_API_KEY') {
return 'google-key';
}
if (key === 'GEMINI_API_KEY') {
return 'gemini-key';
}
return defaultValue as string;
});
const result = GoogleAuthManager.getApiKey({});
expect(result.apiKey).toBe('google-key');
expect(result.source).toBe('GOOGLE_API_KEY');
});
it('should fall back to GEMINI_API_KEY when GOOGLE_API_KEY is not set', () => {
vi.mocked(getEnvString).mockImplementation((key: string, defaultValue?: string) => {
if (key === 'GEMINI_API_KEY') {
return 'gemini-key';
}
return defaultValue as string;
});
const result = GoogleAuthManager.getApiKey({});
expect(result.apiKey).toBe('gemini-key');
expect(result.source).toBe('GEMINI_API_KEY');
});
it('should fall back to PALM_API_KEY in non-vertex mode', () => {
vi.mocked(getEnvString).mockImplementation((key: string, defaultValue?: string) => {
if (key === 'PALM_API_KEY') {
return 'palm-key';
}
return defaultValue as string;
});
const result = GoogleAuthManager.getApiKey({}, undefined, false);
expect(result.apiKey).toBe('palm-key');
expect(result.source).toBe('PALM_API_KEY');
});
it('should not use PALM_API_KEY in vertex mode', () => {
vi.mocked(getEnvString).mockImplementation((key: string, defaultValue?: string) => {
if (key === 'PALM_API_KEY') {
return 'palm-key';
}
return defaultValue as string;
});
const result = GoogleAuthManager.getApiKey({}, undefined, true);
expect(result.apiKey).toBeUndefined();
expect(result.source).toBe('none');
});
it('should not use GEMINI_API_KEY in vertex mode', () => {
vi.mocked(getEnvString).mockImplementation((key: string, defaultValue?: string) => {
if (key === 'GEMINI_API_KEY') {
return 'gemini-key';
}
return defaultValue as string;
});
const result = GoogleAuthManager.getApiKey({}, undefined, true);
expect(result.apiKey).toBeUndefined();
expect(result.source).toBe('none');
});
it('should log debug when both GOOGLE_API_KEY and GEMINI_API_KEY are set (SDK aligned)', () => {
vi.mocked(getEnvString).mockImplementation((key: string, defaultValue?: string) => {
if (key === 'GOOGLE_API_KEY') {
return 'google-key';
}
if (key === 'GEMINI_API_KEY') {
return 'gemini-key';
}
return defaultValue as string;
});
GoogleAuthManager.getApiKey({});
// This is not an error condition - GOOGLE_API_KEY correctly takes precedence
expect(logger.debug).toHaveBeenCalledWith(
'[Google] Both GOOGLE_API_KEY and GEMINI_API_KEY are set. Using GOOGLE_API_KEY.',
);
});
it('should log debug even when both keys have the same value (SDK aligned)', () => {
vi.mocked(getEnvString).mockImplementation((key: string, defaultValue?: string) => {
if (key === 'GOOGLE_API_KEY') {
return 'same-key';
}
if (key === 'GEMINI_API_KEY') {
return 'same-key';
}
return defaultValue as string;
});
GoogleAuthManager.getApiKey({});
// This is not an error condition - just informational
expect(logger.debug).toHaveBeenCalledWith(
'[Google] Both GOOGLE_API_KEY and GEMINI_API_KEY are set. Using GOOGLE_API_KEY.',
);
});
it('should use env overrides over process.env', () => {
vi.mocked(getEnvString).mockReturnValue(undefined as unknown as string);
const result = GoogleAuthManager.getApiKey({}, { GOOGLE_API_KEY: 'override-key' });
expect(result.apiKey).toBe('override-key');
expect(result.source).toBe('GOOGLE_API_KEY');
});
it('should return none when no API key is available', () => {
vi.mocked(getEnvString).mockReturnValue(undefined as unknown as string);
const result = GoogleAuthManager.getApiKey({});
expect(result.apiKey).toBeUndefined();
expect(result.source).toBe('none');
});
});
describe('determineVertexMode', () => {
it('should use explicit vertexai config flag', () => {
expect(GoogleAuthManager.determineVertexMode({ vertexai: true })).toBe(true);
expect(GoogleAuthManager.determineVertexMode({ vertexai: false })).toBe(false);
});
it('should check GOOGLE_GENAI_USE_VERTEXAI env var', () => {
vi.mocked(getEnvString).mockImplementation((key: string, defaultValue?: string) => {
if (key === 'GOOGLE_GENAI_USE_VERTEXAI') {
return 'true';
}
return defaultValue as string;
});
expect(GoogleAuthManager.determineVertexMode({})).toBe(true);
expect(logger.debug).toHaveBeenCalledWith(
expect.stringContaining('Vertex AI mode enabled via GOOGLE_GENAI_USE_VERTEXAI'),
);
});
it('should handle GOOGLE_GENAI_USE_VERTEXAI=1', () => {
vi.mocked(getEnvString).mockImplementation((key: string, defaultValue?: string) => {
if (key === 'GOOGLE_GENAI_USE_VERTEXAI') {
return '1';
}
return defaultValue as string;
});
expect(GoogleAuthManager.determineVertexMode({})).toBe(true);
});
it('should handle GOOGLE_GENAI_USE_VERTEXAI=false', () => {
vi.mocked(getEnvString).mockImplementation((key: string, defaultValue?: string) => {
if (key === 'GOOGLE_GENAI_USE_VERTEXAI') {
return 'false';
}
return defaultValue as string;
});
expect(GoogleAuthManager.determineVertexMode({})).toBe(false);
});
it('should auto-detect vertex mode from projectId', () => {
vi.mocked(getEnvString).mockImplementation((key: string, defaultValue?: string) => {
if (key === 'VERTEX_PROJECT_ID') {
return 'my-project';
}
return defaultValue as string;
});
expect(GoogleAuthManager.determineVertexMode({})).toBe(true);
expect(logger.debug).toHaveBeenCalledWith(
expect.stringContaining('Auto-detected Vertex AI mode'),
);
});
it('should auto-detect vertex mode from GOOGLE_CLOUD_PROJECT', () => {
vi.mocked(getEnvString).mockImplementation((key: string, defaultValue?: string) => {
if (key === 'GOOGLE_CLOUD_PROJECT') {
return 'my-project';
}
return defaultValue as string;
});
expect(GoogleAuthManager.determineVertexMode({})).toBe(true);
});
it('should auto-detect vertex mode from credentials config', () => {
vi.mocked(getEnvString).mockReturnValue(undefined as unknown as string);
expect(GoogleAuthManager.determineVertexMode({ credentials: '{}' })).toBe(true);
});
it('should default to Google AI Studio mode', () => {
vi.mocked(getEnvString).mockReturnValue(undefined as unknown as string);
expect(GoogleAuthManager.determineVertexMode({})).toBe(false);
});
it('should prefer explicit config over env var', () => {
vi.mocked(getEnvString).mockImplementation((key: string, defaultValue?: string) => {
if (key === 'GOOGLE_GENAI_USE_VERTEXAI') {
return 'true';
}
return defaultValue as string;
});
expect(GoogleAuthManager.determineVertexMode({ vertexai: false })).toBe(false);
});
});
describe('validateAndWarn', () => {
it('should warn when GOOGLE_GENAI_USE_VERTEXAI conflicts with config', () => {
vi.mocked(getEnvString).mockImplementation((key: string, defaultValue?: string) => {
if (key === 'GOOGLE_GENAI_USE_VERTEXAI') {
return 'true';
}
return defaultValue as string;
});
GoogleAuthManager.validateAndWarn({ vertexai: false });
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('GOOGLE_GENAI_USE_VERTEXAI is set but vertexai: false'),
);
});
it('should warn when GOOGLE_CLOUD_PROJECT conflicts with config.projectId', () => {
vi.mocked(getEnvString).mockImplementation((key: string, defaultValue?: string) => {
if (key === 'GOOGLE_CLOUD_PROJECT') {
return 'env-project';
}
return defaultValue as string;
});
GoogleAuthManager.validateAndWarn({ projectId: 'config-project' });
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('Both GOOGLE_CLOUD_PROJECT and config.projectId are set'),
);
});
it('should log debug when both apiKey and credentials are set', () => {
// When both are set, API key takes precedence (express mode is automatic)
GoogleAuthManager.validateAndWarn({ apiKey: 'key', credentials: '{}' });
expect(logger.debug).toHaveBeenCalledWith(
expect.stringContaining('Both apiKey and credentials are set'),
);
});
describe('mutual exclusivity (strictMutualExclusivity)', () => {
it('should warn by default when both apiKey and projectId are set', () => {
vi.mocked(getEnvString).mockReturnValue(undefined as unknown as string);
// Should not throw by default (permissive mode)
expect(() => {
GoogleAuthManager.validateAndWarn({ apiKey: 'key', projectId: 'project' });
}).not.toThrow();
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('Project/location and API key are mutually exclusive'),
);
});
it('should warn by default when both apiKey and region are set', () => {
vi.mocked(getEnvString).mockReturnValue(undefined as unknown as string);
// Should not throw by default (permissive mode)
expect(() => {
GoogleAuthManager.validateAndWarn({ apiKey: 'key', region: 'us-central1' });
}).not.toThrow();
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('Project/location and API key are mutually exclusive'),
);
});
it('should throw error when strictMutualExclusivity is true', () => {
vi.mocked(getEnvString).mockReturnValue(undefined as unknown as string);
expect(() => {
GoogleAuthManager.validateAndWarn({
apiKey: 'key',
projectId: 'project',
strictMutualExclusivity: true,
});
}).toThrow('Project/location and API key are mutually exclusive');
});
it('should only warn when strictMutualExclusivity is false', () => {
vi.mocked(getEnvString).mockReturnValue(undefined as unknown as string);
// Should not throw
expect(() => {
GoogleAuthManager.validateAndWarn({
apiKey: 'key',
projectId: 'project',
strictMutualExclusivity: false,
});
}).not.toThrow();
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('Project/location and API key are mutually exclusive'),
);
});
it('should not throw when only apiKey is set', () => {
vi.mocked(getEnvString).mockReturnValue(undefined as unknown as string);
expect(() => {
GoogleAuthManager.validateAndWarn({ apiKey: 'key' });
}).not.toThrow();
});
it('should not throw when only projectId is set', () => {
vi.mocked(getEnvString).mockReturnValue(undefined as unknown as string);
expect(() => {
GoogleAuthManager.validateAndWarn({ projectId: 'project' });
}).not.toThrow();
});
});
});
describe('resolveRegion', () => {
it('should prioritize config.region', () => {
vi.mocked(getEnvString).mockImplementation((key: string, defaultValue?: string) => {
if (key === 'VERTEX_REGION') {
return 'env-region';
}
return defaultValue as string;
});
expect(GoogleAuthManager.resolveRegion({ region: 'config-region' })).toBe('config-region');
});
it('should use VERTEX_REGION env var', () => {
vi.mocked(getEnvString).mockImplementation((key: string, defaultValue?: string) => {
if (key === 'VERTEX_REGION') {
return 'env-region';
}
return defaultValue as string;
});
expect(GoogleAuthManager.resolveRegion({})).toBe('env-region');
});
it('should use GOOGLE_CLOUD_LOCATION env var (Python SDK)', () => {
vi.mocked(getEnvString).mockImplementation((key: string, defaultValue?: string) => {
if (key === 'GOOGLE_CLOUD_LOCATION') {
return 'cloud-location';
}
return defaultValue as string;
});
expect(GoogleAuthManager.resolveRegion({})).toBe('cloud-location');
});
it('should default to us-central1 when hasApiKey is undefined', () => {
vi.mocked(getEnvString).mockReturnValue(undefined as unknown as string);
expect(GoogleAuthManager.resolveRegion({})).toBe('us-central1');
});
it('should default to us-central1 when hasApiKey is true (API key mode)', () => {
vi.mocked(getEnvString).mockReturnValue(undefined as unknown as string);
expect(GoogleAuthManager.resolveRegion({}, undefined, true)).toBe('us-central1');
});
it('should default to global when hasApiKey is false (OAuth mode - SDK aligned)', () => {
vi.mocked(getEnvString).mockReturnValue(undefined as unknown as string);
expect(GoogleAuthManager.resolveRegion({}, undefined, false)).toBe('global');
});
it('should use env overrides', () => {
vi.mocked(getEnvString).mockReturnValue(undefined as unknown as string);
expect(GoogleAuthManager.resolveRegion({}, { VERTEX_REGION: 'override-region' })).toBe(
'override-region',
);
});
it('should prioritize config.region over hasApiKey default', () => {
vi.mocked(getEnvString).mockReturnValue(undefined as unknown as string);
// Even with hasApiKey: false, explicit config should win
expect(GoogleAuthManager.resolveRegion({ region: 'explicit-region' }, undefined, false)).toBe(
'explicit-region',
);
});
});
describe('hasDefaultCredentials', () => {
it('should cache successful default credential probes', async () => {
const getOAuthClientSpy = vi
.spyOn(GoogleAuthManager, 'getOAuthClient')
.mockResolvedValue({ client: {}, projectId: 'detected-project' });
await expect(GoogleAuthManager.hasDefaultCredentials()).resolves.toBe(true);
await expect(GoogleAuthManager.hasDefaultCredentials()).resolves.toBe(true);
expect(getOAuthClientSpy).toHaveBeenCalledTimes(1);
getOAuthClientSpy.mockRestore();
});
it('should cache failed default credential probes', async () => {
const getOAuthClientSpy = vi
.spyOn(GoogleAuthManager, 'getOAuthClient')
.mockRejectedValue(new Error('no default credentials'));
await expect(GoogleAuthManager.hasDefaultCredentials()).resolves.toBe(false);
await expect(GoogleAuthManager.hasDefaultCredentials()).resolves.toBe(false);
expect(getOAuthClientSpy).toHaveBeenCalledTimes(1);
getOAuthClientSpy.mockRestore();
});
it('should suppress expected metadata lookup warnings during optional probes', async () => {
const emitWarningSpy = vi.spyOn(process, 'emitWarning').mockImplementation(() => {});
const getOAuthClientSpy = vi
.spyOn(GoogleAuthManager, 'getOAuthClient')
.mockImplementation(async () => {
process.emitWarning(
'received unexpected error = All promises were rejected code = UNKNOWN',
'MetadataLookupWarning',
);
throw new Error('no default credentials');
});
try {
await expect(GoogleAuthManager.hasDefaultCredentials()).resolves.toBe(false);
expect(emitWarningSpy).not.toHaveBeenCalled();
} finally {
getOAuthClientSpy.mockRestore();
emitWarningSpy.mockRestore();
}
});
it('should suppress metadata lookup warnings for any unreachable-host error code', async () => {
// gcp-metadata interpolates the underlying error + code into the message, so the suffix
// varies by failure mode (the codeless `All promises were rejected`/`UNKNOWN` aggregate,
// plus non-allowlisted codes like ETIMEDOUT or EAI_AGAIN). All are the same optional probe
// failing to reach metadata, so the stable prefix must suppress every variant — and tolerate
// a dependency reword of the interpolated suffix.
const emitWarningSpy = vi.spyOn(process, 'emitWarning').mockImplementation(() => {});
const getOAuthClientSpy = vi
.spyOn(GoogleAuthManager, 'getOAuthClient')
.mockImplementation(async () => {
process.emitWarning(
'received unexpected error = connect ETIMEDOUT 169.254.169.254:80 code = ETIMEDOUT',
'MetadataLookupWarning',
);
throw new Error('no default credentials');
});
try {
await expect(GoogleAuthManager.hasDefaultCredentials()).resolves.toBe(false);
expect(emitWarningSpy).not.toHaveBeenCalled();
} finally {
getOAuthClientSpy.mockRestore();
emitWarningSpy.mockRestore();
}
});
it('should forward unrelated warnings during optional probes', async () => {
const emitWarningSpy = vi.spyOn(process, 'emitWarning').mockImplementation(() => {});
const getOAuthClientSpy = vi
.spyOn(GoogleAuthManager, 'getOAuthClient')
.mockImplementation(async () => {
process.emitWarning('different warning', 'MetadataLookupWarning');
throw new Error('no default credentials');
});
try {
await expect(GoogleAuthManager.hasDefaultCredentials()).resolves.toBe(false);
expect(emitWarningSpy).toHaveBeenCalledWith('different warning', 'MetadataLookupWarning');
} finally {
getOAuthClientSpy.mockRestore();
emitWarningSpy.mockRestore();
}
});
it('should forward matching metadata warning messages with a different warning type', async () => {
const emitWarningSpy = vi.spyOn(process, 'emitWarning').mockImplementation(() => {});
const getOAuthClientSpy = vi
.spyOn(GoogleAuthManager, 'getOAuthClient')
.mockImplementation(async () => {
process.emitWarning(
'received unexpected error = All promises were rejected code = UNKNOWN',
'DeprecationWarning',
);
throw new Error('no default credentials');
});
try {
await expect(GoogleAuthManager.hasDefaultCredentials()).resolves.toBe(false);
expect(emitWarningSpy).toHaveBeenCalledWith(
'received unexpected error = All promises were rejected code = UNKNOWN',
'DeprecationWarning',
);
} finally {
getOAuthClientSpy.mockRestore();
emitWarningSpy.mockRestore();
}
});
it('should forward Error instance warnings during optional probes', async () => {
const emitWarningSpy = vi.spyOn(process, 'emitWarning').mockImplementation(() => {});
const warning = new Error('different warning');
warning.name = 'MetadataLookupWarning';
const getOAuthClientSpy = vi
.spyOn(GoogleAuthManager, 'getOAuthClient')
.mockImplementation(async () => {
process.emitWarning(warning);
throw new Error('no default credentials');
});
try {
await expect(GoogleAuthManager.hasDefaultCredentials()).resolves.toBe(false);
expect(emitWarningSpy).toHaveBeenCalledWith(warning);
} finally {
getOAuthClientSpy.mockRestore();
emitWarningSpy.mockRestore();
}
});
it('should restore process.emitWarning after an optional probe', async () => {
const originalEmitWarning = process.emitWarning;
const getOAuthClientSpy = vi
.spyOn(GoogleAuthManager, 'getOAuthClient')
.mockRejectedValue(new Error('no default credentials'));
try {
await expect(GoogleAuthManager.hasDefaultCredentials()).resolves.toBe(false);
expect(process.emitWarning).toBe(originalEmitWarning);
} finally {
getOAuthClientSpy.mockRestore();
}
});
it('should preserve a newer process.emitWarning owner after an optional probe', async () => {
const originalEmitWarning = process.emitWarning;
const replacementEmitWarning = vi.fn() as typeof process.emitWarning;
const getOAuthClientSpy = vi
.spyOn(GoogleAuthManager, 'getOAuthClient')
.mockImplementation(async () => {
process.emitWarning = replacementEmitWarning;
throw new Error('no default credentials');
});
try {
await expect(GoogleAuthManager.hasDefaultCredentials()).resolves.toBe(false);
expect(process.emitWarning).toBe(replacementEmitWarning);
} finally {
process.emitWarning = originalEmitWarning;
getOAuthClientSpy.mockRestore();
}
});
it('should restore process.emitWarning after overlapping probes settle out of order', async () => {
const originalEmitWarning = process.emitWarning;
let rejectFirstProbe!: (reason?: unknown) => void;
let rejectSecondProbe!: (reason?: unknown) => void;
const firstProbeAuthCall = new Promise((_, reject) => {
rejectFirstProbe = reject;
});
const secondProbeAuthCall = new Promise((_, reject) => {
rejectSecondProbe = reject;
});
const getOAuthClientSpy = vi.spyOn(GoogleAuthManager, 'getOAuthClient');
getOAuthClientSpy
.mockImplementationOnce(() => firstProbeAuthCall as Promise<any>)
.mockImplementationOnce(() => secondProbeAuthCall as Promise<any>);
const firstProbe = GoogleAuthManager.hasDefaultCredentials();
GoogleAuthManager.clearCache();
const secondProbe = GoogleAuthManager.hasDefaultCredentials();
try {
rejectFirstProbe(new Error('first probe failed'));
await expect(firstProbe).resolves.toBe(false);
expect(process.emitWarning).not.toBe(originalEmitWarning);
rejectSecondProbe(new Error('second probe failed'));
await expect(secondProbe).resolves.toBe(false);
expect(process.emitWarning).toBe(originalEmitWarning);
} finally {
rejectFirstProbe(new Error('cleanup'));
rejectSecondProbe(new Error('cleanup'));
await Promise.allSettled([firstProbe, secondProbe]);
process.emitWarning = originalEmitWarning;
getOAuthClientSpy.mockRestore();
}
});
it('should clear cached default credential probe results', async () => {
const getOAuthClientSpy = vi
.spyOn(GoogleAuthManager, 'getOAuthClient')
.mockRejectedValue(new Error('no default credentials'));
await expect(GoogleAuthManager.hasDefaultCredentials()).resolves.toBe(false);
GoogleAuthManager.clearCache();
await expect(GoogleAuthManager.hasDefaultCredentials()).resolves.toBe(false);
expect(getOAuthClientSpy).toHaveBeenCalledTimes(2);
getOAuthClientSpy.mockRestore();
});
it('should deduplicate concurrent default credential probes', async () => {
const getOAuthClientSpy = vi.spyOn(GoogleAuthManager, 'getOAuthClient').mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(() => resolve({ client: {}, projectId: 'detected-project' }), 0);
}),
);
const firstProbe = GoogleAuthManager.hasDefaultCredentials();
const secondProbe = GoogleAuthManager.hasDefaultCredentials();
const [firstResult, secondResult] = await Promise.all([firstProbe, secondProbe]);
expect(firstResult).toBe(true);
expect(secondResult).toBe(true);
expect(getOAuthClientSpy).toHaveBeenCalledTimes(1);
getOAuthClientSpy.mockRestore();
});
it('should not allow a stale probe to overwrite cache after clearCache', async () => {
let rejectFirstProbe!: (reason?: unknown) => void;
const firstProbeAuthCall = new Promise((_, reject) => {
rejectFirstProbe = reject;
});
const getOAuthClientSpy = vi.spyOn(GoogleAuthManager, 'getOAuthClient');
getOAuthClientSpy
.mockImplementationOnce(() => firstProbeAuthCall as Promise<any>)
.mockResolvedValueOnce({ client: {}, projectId: 'detected-project' });
const firstProbe = GoogleAuthManager.hasDefaultCredentials();
GoogleAuthManager.clearCache();
await expect(GoogleAuthManager.hasDefaultCredentials()).resolves.toBe(true);
rejectFirstProbe(new Error('first probe failed'));
await expect(firstProbe).resolves.toBe(false);
await expect(GoogleAuthManager.hasDefaultCredentials()).resolves.toBe(true);
expect(getOAuthClientSpy).toHaveBeenCalledTimes(2);
getOAuthClientSpy.mockRestore();
});
});
describe('loadCredentials', () => {
it('should return undefined for undefined input', () => {
expect(GoogleAuthManager.loadCredentials(undefined)).toBeUndefined();
});
it('should return raw credentials string as-is', () => {
const creds = '{"type":"service_account"}';
expect(GoogleAuthManager.loadCredentials(creds)).toBe(creds);
});
it('should load credentials from file:// path', () => {
const fileCreds = '{"type":"service_account","project_id":"test"}';
vi.mocked(maybeLoadFromExternalFile).mockReturnValue(fileCreds);
const result = GoogleAuthManager.loadCredentials('file:///path/to/creds.json');
expect(maybeLoadFromExternalFile).toHaveBeenCalledWith('file:///path/to/creds.json');
expect(result).toBe(fileCreds);
});
it('should throw error when file loading fails', () => {
vi.mocked(maybeLoadFromExternalFile).mockImplementation(() => {
throw new Error('File not found');
});
expect(() => GoogleAuthManager.loadCredentials('file:///bad/path.json')).toThrow(
'Failed to load credentials from file',
);
});
it('should handle pre-parsed object credentials (from config loading pipeline)', () => {
// When config loading processes file:// paths to JSON files, it may return
// parsed objects instead of strings. The fix handles this case.
const parsedCreds = { type: 'service_account', project_id: 'test-project' };
const result = GoogleAuthManager.loadCredentials(parsedCreds as unknown as string);
expect(result).toBe(JSON.stringify(parsedCreds));
});
it('should handle maybeLoadFromExternalFile returning parsed object for JSON files', () => {
// maybeLoadFromExternalFile parses JSON files and returns objects, not strings
const parsedCreds = { type: 'service_account', project_id: 'test-project' };
vi.mocked(maybeLoadFromExternalFile).mockReturnValue(parsedCreds);
const result = GoogleAuthManager.loadCredentials('file:///path/to/creds.json');
expect(maybeLoadFromExternalFile).toHaveBeenCalledWith('file:///path/to/creds.json');
expect(result).toBe(JSON.stringify(parsedCreds));
});
});
// Note: getOAuthClient, resolveProjectId (OAuth path), and clearCache tests require
// the google-auth-library package and use dynamic imports that are difficult to mock.
// These are tested in integration tests with the actual library.
// See: test/providers/google/vertex.test.ts for integration coverage
// Note: getOAuthClient and resolveProjectId tests require the google-auth-library
// package with proper constructor mocking. These are tested in integration tests.
// See: test/providers/google/vertex.test.ts for integration coverage
describe('clearCache', () => {
it('should clear internal credential detection caches', () => {
expect(() => GoogleAuthManager.clearCache()).not.toThrow();
});
});
});
+517
View File
@@ -0,0 +1,517 @@
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock dependencies before importing the base class
vi.mock('../../../src/envars', () => ({
getEnvString: vi.fn(),
getEnvInt: vi.fn().mockReturnValue(300000),
}));
vi.mock('../../../src/logger', () => ({
default: {
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('../../../src/cliState', () => ({
default: {
basePath: '/test/base/path',
},
}));
vi.mock('../../../src/esm', () => ({
importModule: vi.fn(),
}));
vi.mock('../../../src/util/file', () => ({
maybeLoadFromExternalFile: vi.fn(),
}));
vi.mock('../../../src/util/index', () => ({
maybeLoadToolsFromExternalFile: vi.fn().mockResolvedValue([]),
}));
// Use vi.hoisted to hoist mock definitions before vi.mock
const { mockMcpInstance, MockMCPClient } = vi.hoisted(() => {
const mockMcpInstance = {
initialize: vi.fn().mockResolvedValue(undefined),
getAllTools: vi.fn().mockReturnValue([]),
cleanup: vi.fn().mockResolvedValue(undefined),
};
class MockMCPClient {
initialize = mockMcpInstance.initialize;
getAllTools = mockMcpInstance.getAllTools;
cleanup = mockMcpInstance.cleanup;
}
return { mockMcpInstance, MockMCPClient };
});
vi.mock('../../../src/providers/mcp/client', () => ({
MCPClient: MockMCPClient,
}));
vi.mock('../../../src/providers/mcp/transform', () => ({
transformMCPToolsToGoogle: vi.fn().mockReturnValue([]),
}));
vi.mock('../../../src/providers/google/auth', () => ({
GoogleAuthManager: {
determineVertexMode: vi.fn().mockReturnValue(false),
validateAndWarn: vi.fn(),
getApiKey: vi.fn().mockReturnValue({ apiKey: 'test-key', source: 'config' }),
resolveRegion: vi.fn().mockReturnValue('us-central1'),
resolveProjectId: vi.fn().mockResolvedValue('test-project'),
},
}));
vi.mock('../../../src/providers/google/util', () => ({
normalizeTools: vi.fn((tools) => tools),
}));
vi.mock('../../../src/util/templates', () => ({
getNunjucksEngine: vi.fn(() => ({
renderString: vi.fn((str) => str),
})),
}));
import cliState from '../../../src/cliState';
import { importModule } from '../../../src/esm';
import { GoogleAuthManager } from '../../../src/providers/google/auth';
import { GoogleGenericProvider } from '../../../src/providers/google/base';
import { maybeLoadToolsFromExternalFile } from '../../../src/util/index';
import type { CallApiContextParams, ProviderResponse } from '../../../src/types/index';
// Create a concrete implementation for testing
class TestGoogleProvider extends GoogleGenericProvider {
getApiEndpoint(action?: string): string {
return `https://test-api.example.com/${this.modelName}${action ? `:${action}` : ''}`;
}
async getAuthHeaders(): Promise<Record<string, string>> {
return { Authorization: 'Bearer test-token' };
}
async callApi(prompt: string, _context?: CallApiContextParams): Promise<ProviderResponse> {
return { output: `Response to: ${prompt}` };
}
}
describe('GoogleGenericProvider', () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset hoisted mock instance methods to ensure test isolation
mockMcpInstance.initialize.mockReset().mockResolvedValue(undefined);
mockMcpInstance.getAllTools.mockReset().mockReturnValue([]);
mockMcpInstance.cleanup.mockReset().mockResolvedValue(undefined);
// Reset utility mocks
vi.mocked(maybeLoadToolsFromExternalFile).mockReset().mockResolvedValue([]);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('constructor', () => {
it('should initialize with model name and default config', () => {
const provider = new TestGoogleProvider('gemini-2.5-pro');
expect(provider.modelName).toBe('gemini-2.5-pro');
expect(provider.config).toEqual({});
expect(GoogleAuthManager.determineVertexMode).toHaveBeenCalled();
expect(GoogleAuthManager.validateAndWarn).toHaveBeenCalled();
});
it('should initialize with custom config', () => {
const config = { apiKey: 'custom-key', temperature: 0.7 };
const provider = new TestGoogleProvider('gemini-2.5-pro', { config });
expect(provider.config).toEqual(config);
});
it('should store env overrides', () => {
const env = { GOOGLE_API_KEY: 'env-key' };
const provider = new TestGoogleProvider('gemini-2.5-pro', { env });
expect(provider.env).toEqual(env);
});
it('should support custom id', () => {
const provider = new TestGoogleProvider('gemini-2.5-pro', { id: 'custom-id' });
expect(provider.id()).toBe('custom-id');
});
it('should initialize MCP client when configured', async () => {
const mcpConfig = { enabled: true, servers: [] };
const _provider = new TestGoogleProvider('gemini-2.5-pro', {
config: { mcp: mcpConfig },
});
// Wait for MCP initialization
await vi.waitFor(() => {
expect(mockMcpInstance.initialize).toHaveBeenCalled();
});
});
it('should not initialize MCP when not configured', () => {
mockMcpInstance.initialize.mockClear();
new TestGoogleProvider('gemini-2.5-pro');
// Verify initialize was not called since MCP was not configured
expect(mockMcpInstance.initialize).not.toHaveBeenCalled();
});
});
describe('id()', () => {
it('should return google prefix for non-vertex mode', () => {
vi.mocked(GoogleAuthManager.determineVertexMode).mockReturnValue(false);
const provider = new TestGoogleProvider('gemini-2.5-pro');
expect(provider.id()).toBe('google:gemini-2.5-pro');
});
it('should return vertex prefix for vertex mode', () => {
vi.mocked(GoogleAuthManager.determineVertexMode).mockReturnValue(true);
const provider = new TestGoogleProvider('gemini-2.5-pro');
expect(provider.id()).toBe('vertex:gemini-2.5-pro');
});
it('should use custom id when provided', () => {
const provider = new TestGoogleProvider('gemini-2.5-pro', { id: 'my-custom-provider' });
expect(provider.id()).toBe('my-custom-provider');
});
});
describe('toString()', () => {
it('should return Google AI Studio string for non-vertex mode', () => {
vi.mocked(GoogleAuthManager.determineVertexMode).mockReturnValue(false);
const provider = new TestGoogleProvider('gemini-2.5-pro');
expect(provider.toString()).toBe('[Google Google AI Studio Provider gemini-2.5-pro]');
});
it('should return Vertex AI string for vertex mode', () => {
vi.mocked(GoogleAuthManager.determineVertexMode).mockReturnValue(true);
const provider = new TestGoogleProvider('gemini-2.5-pro');
expect(provider.toString()).toBe('[Google Vertex AI Provider gemini-2.5-pro]');
});
});
describe('getApiKey()', () => {
it('should delegate to GoogleAuthManager', () => {
vi.mocked(GoogleAuthManager.getApiKey).mockReturnValue({
apiKey: 'resolved-key',
source: 'GOOGLE_API_KEY',
});
const provider = new TestGoogleProvider('gemini-2.5-pro', {
config: { apiKey: 'config-key' },
env: { GOOGLE_API_KEY: 'env-key' },
});
const result = provider.getApiKey();
expect(result).toBe('resolved-key');
expect(GoogleAuthManager.getApiKey).toHaveBeenCalled();
});
});
describe('getRegion()', () => {
it('should delegate to GoogleAuthManager', () => {
vi.mocked(GoogleAuthManager.resolveRegion).mockReturnValue('europe-west1');
const provider = new TestGoogleProvider('gemini-2.5-pro', {
config: { region: 'europe-west1' },
});
expect(provider.getRegion()).toBe('europe-west1');
});
});
describe('getProjectId()', () => {
it('should delegate to GoogleAuthManager', async () => {
vi.mocked(GoogleAuthManager.resolveProjectId).mockResolvedValue('my-project');
const provider = new TestGoogleProvider('gemini-2.5-pro', {
config: { projectId: 'my-project' },
});
const result = await provider.getProjectId();
expect(result).toBe('my-project');
});
});
describe('getAllTools()', () => {
it('should return empty array when no tools configured', async () => {
const provider = new TestGoogleProvider('gemini-2.5-pro');
const tools = await provider['getAllTools']();
expect(tools).toEqual([]);
});
it('should load tools from config', async () => {
const configTools = [{ functionDeclarations: [{ name: 'test_fn' }] }];
vi.mocked(maybeLoadToolsFromExternalFile).mockResolvedValue(configTools);
const provider = new TestGoogleProvider('gemini-2.5-pro', {
config: { tools: configTools },
});
const tools = await provider['getAllTools']();
expect(maybeLoadToolsFromExternalFile).toHaveBeenCalledWith(configTools, undefined);
expect(tools).toEqual(configTools);
});
it('should combine MCP tools with config tools', async () => {
const mcpConfig = { enabled: true, servers: [] };
const configTools = [{ functionDeclarations: [{ name: 'config_fn' }] }];
// Set up mocks before creating provider
vi.mocked(maybeLoadToolsFromExternalFile).mockResolvedValue(configTools);
const { transformMCPToolsToGoogle } = await import('../../../src/providers/mcp/transform');
const mcpTool = { functionDeclarations: [{ name: 'mcp_fn' }] };
vi.mocked(transformMCPToolsToGoogle).mockReturnValue([mcpTool]);
const provider = new TestGoogleProvider('gemini-2.5-pro', {
config: { mcp: mcpConfig, tools: configTools },
});
// Wait for MCP initialization to complete
await vi.waitFor(() => {
expect(mockMcpInstance.initialize).toHaveBeenCalled();
});
const tools = await provider['getAllTools']();
// Should have MCP tool + config tool
expect(transformMCPToolsToGoogle).toHaveBeenCalled();
expect(tools).toContainEqual(mcpTool);
expect(tools.length).toBeGreaterThanOrEqual(1);
});
});
describe('loadExternalFunction()', () => {
it('should load function from file', async () => {
const mockFn = vi.fn().mockReturnValue('result');
vi.mocked(importModule).mockResolvedValue(mockFn);
const provider = new TestGoogleProvider('gemini-2.5-pro');
const fn = await provider['loadExternalFunction']('file://test.js');
expect(importModule).toHaveBeenCalledWith(
path.resolve('/test/base/path', 'test.js'),
undefined,
);
expect(fn).toBe(mockFn);
});
it('should load named function from file', async () => {
const mockFn = vi.fn().mockReturnValue('result');
vi.mocked(importModule).mockResolvedValue({ myFunction: mockFn });
const provider = new TestGoogleProvider('gemini-2.5-pro');
const fn = await provider['loadExternalFunction']('file://test.js:myFunction');
expect(importModule).toHaveBeenCalledWith(
path.resolve('/test/base/path', 'test.js'),
'myFunction',
);
expect(fn).toBe(mockFn);
});
it('should load named function from Windows-style file paths', async () => {
const mockFn = vi.fn().mockReturnValue('result');
vi.mocked(importModule).mockResolvedValue({ myFunction: mockFn });
// Use a base path that contains the target on Windows (where path.resolve
// recognises `C:/...` as absolute) so the loader's path-traversal guard
// does not reject it. On POSIX the drive letter is treated as a relative
// segment, which still keeps the resolved path inside the base.
const originalBasePath = cliState.basePath;
cliState.basePath = 'C:/';
try {
const provider = new TestGoogleProvider('gemini-2.5-pro');
const fn = await provider['loadExternalFunction']('file://C:/path/to/test.js:myFunction');
expect(importModule).toHaveBeenCalledWith(
path.resolve('C:/', 'C:/path/to/test.js'),
'myFunction',
);
expect(fn).toBe(mockFn);
} finally {
cliState.basePath = originalBasePath;
}
});
it('should throw error when function not found', async () => {
vi.mocked(importModule).mockResolvedValue({ someOtherFn: vi.fn() });
const provider = new TestGoogleProvider('gemini-2.5-pro');
await expect(provider['loadExternalFunction']('file://test.js:myFunction')).rejects.toThrow(
'Function callback malformed',
);
});
it('should throw error when module export is not a function', async () => {
vi.mocked(importModule).mockResolvedValue('not a function');
const provider = new TestGoogleProvider('gemini-2.5-pro');
await expect(provider['loadExternalFunction']('file://test.js')).rejects.toThrow(
'Function callback malformed',
);
});
});
describe('executeFunctionCallback()', () => {
it('should execute function callback from config', async () => {
const mockCallback = vi.fn().mockResolvedValue({ result: 'success' });
const provider = new TestGoogleProvider('gemini-2.5-pro');
const config = {
functionToolCallbacks: {
test_function: mockCallback,
},
};
const result = await provider['executeFunctionCallback'](
'test_function',
'{"arg": "value"}',
config,
);
expect(mockCallback).toHaveBeenCalledWith('{"arg": "value"}');
expect(result).toEqual({ result: 'success' });
});
it('should cache loaded function callbacks', async () => {
const mockCallback = vi.fn().mockResolvedValue({ result: 'success' });
const provider = new TestGoogleProvider('gemini-2.5-pro');
const config = {
functionToolCallbacks: {
test_function: mockCallback,
},
};
await provider['executeFunctionCallback']('test_function', '{}', config);
await provider['executeFunctionCallback']('test_function', '{}', config);
// Callback should be cached after first call
expect(provider['loadedFunctionCallbacks']['test_function']).toBe(mockCallback);
});
it('should load function from file:// reference', async () => {
const mockFn = vi.fn().mockResolvedValue({ loaded: true });
vi.mocked(importModule).mockResolvedValue(mockFn);
const provider = new TestGoogleProvider('gemini-2.5-pro');
const config = {
functionToolCallbacks: {
file_function: 'file://callbacks.js:myCallback',
},
};
const result = await provider['executeFunctionCallback']('file_function', '{}', config);
expect(result).toEqual({ loaded: true });
});
it('should throw error when callback not found', async () => {
const provider = new TestGoogleProvider('gemini-2.5-pro');
const config = {
functionToolCallbacks: {},
};
await expect(
provider['executeFunctionCallback']('missing_function', '{}', config),
).rejects.toThrow("No callback found for function 'missing_function'");
});
});
describe('cleanup()', () => {
it('should cleanup MCP client when initialized', async () => {
const mcpConfig = { enabled: true, servers: [] };
const provider = new TestGoogleProvider('gemini-2.5-pro', {
config: { mcp: mcpConfig },
});
// Wait for MCP initialization
await vi.waitFor(() => {
expect(mockMcpInstance.initialize).toHaveBeenCalled();
});
await provider.cleanup();
// MCPClient.cleanup should have been called
expect(mockMcpInstance.cleanup).toHaveBeenCalled();
});
it('should handle cleanup when no MCP client', async () => {
const provider = new TestGoogleProvider('gemini-2.5-pro');
// Should not throw
await expect(provider.cleanup()).resolves.toBeUndefined();
});
});
describe('getTimeout()', () => {
it('should return config timeout when set', () => {
const provider = new TestGoogleProvider('gemini-2.5-pro', {
config: { timeoutMs: 60000 },
});
expect(provider['getTimeout']()).toBe(60000);
});
it('should return default timeout when not configured', () => {
const provider = new TestGoogleProvider('gemini-2.5-pro');
// Default is REQUEST_TIMEOUT_MS from shared.ts
expect(provider['getTimeout']()).toBeGreaterThan(0);
});
});
describe('abstract method implementations', () => {
it('should call getApiEndpoint on subclass', () => {
const provider = new TestGoogleProvider('gemini-2.5-pro');
expect(provider.getApiEndpoint()).toBe('https://test-api.example.com/gemini-2.5-pro');
expect(provider.getApiEndpoint('generateContent')).toBe(
'https://test-api.example.com/gemini-2.5-pro:generateContent',
);
});
it('should call getAuthHeaders on subclass', async () => {
const provider = new TestGoogleProvider('gemini-2.5-pro');
const headers = await provider.getAuthHeaders();
expect(headers).toEqual({ Authorization: 'Bearer test-token' });
});
it('should call callApi on subclass', async () => {
const provider = new TestGoogleProvider('gemini-2.5-pro');
const response = await provider.callApi('Hello');
expect(response).toEqual({ output: 'Response to: Hello' });
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,442 @@
/**
* Integration test for GitHub issue #6902:
* "Gemini provider fails with MCP tools due to unsupported additionalProperties in JSON Schema"
*
* This test verifies the complete flow from MCP tools through Google providers
* to ensure schemas are properly sanitized before being sent to the Gemini API.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock MCP client to return tools with problematic schemas (additionalProperties, $schema, etc.)
const mcpMocks = vi.hoisted(() => {
const createMockTools = () => [
{
name: 'analyze_prompt',
description: 'Analyzes a prompt for security vulnerabilities',
inputSchema: {
type: 'object',
properties: {
prompt: {
type: 'string',
description: 'The prompt to analyze',
},
severity: {
type: 'string',
enum: ['low', 'medium', 'high', 'critical'],
},
options: {
type: 'object',
properties: {
maxTokens: {
type: 'number',
default: 1000, // Should be removed
},
includeRecommendations: {
type: 'boolean',
default: true, // Should be removed
},
},
additionalProperties: false, // Should be removed
},
},
required: ['prompt'],
additionalProperties: false, // This is the main issue - should be removed
$schema: 'http://json-schema.org/draft-07/schema#', // Should be removed
},
},
{
name: 'search_documents',
description: 'Searches through documents',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string' },
filters: {
type: 'array',
items: {
type: 'object',
properties: {
field: { type: 'string' },
value: { type: 'string' },
},
additionalProperties: false, // Nested - should be removed
},
},
},
additionalProperties: false,
},
},
];
const mockInitialize = vi.fn().mockResolvedValue(undefined);
const mockCleanup = vi.fn().mockResolvedValue(undefined);
const mockGetAllTools = vi.fn().mockReturnValue(createMockTools());
const mockCallTool = vi.fn().mockResolvedValue({
content: 'Tool result',
});
class MockMCPClient {
initialize = mockInitialize;
cleanup = mockCleanup;
getAllTools = mockGetAllTools;
callTool = mockCallTool;
}
return {
MockMCPClient,
mockInitialize,
mockCleanup,
mockGetAllTools,
mockCallTool,
createMockTools,
};
});
// Mock dependencies
vi.mock('../../../src/cache', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../src/cache')>();
return {
...actual,
fetchWithCache: vi.fn(),
getCache: vi.fn().mockResolvedValue({
get: vi.fn().mockResolvedValue(null),
set: vi.fn().mockResolvedValue(undefined),
}),
isCacheEnabled: vi.fn().mockReturnValue(false),
};
});
vi.mock('../../../src/logger', () => ({
default: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('../../../src/providers/mcp/client', () => ({
MCPClient: mcpMocks.MockMCPClient,
}));
vi.mock('../../../src/util/templates', () => ({
getNunjucksEngine: vi.fn(() => ({
renderString: vi.fn((str) => str),
})),
renderPrompt: vi.fn((prompt) => prompt),
}));
import * as cache from '../../../src/cache';
import { AIStudioChatProvider } from '../../../src/providers/google/ai.studio';
import { transformMCPToolsToGoogle } from '../../../src/providers/mcp/transform';
describe('Google Providers MCP Integration (GitHub #6902)', () => {
let capturedRequestBody: any;
beforeEach(() => {
vi.clearAllMocks();
mcpMocks.mockInitialize.mockReset().mockResolvedValue(undefined);
mcpMocks.mockCleanup.mockReset().mockResolvedValue(undefined);
mcpMocks.mockGetAllTools.mockReset().mockReturnValue(mcpMocks.createMockTools());
mcpMocks.mockCallTool.mockReset().mockResolvedValue({
content: 'Tool result',
});
capturedRequestBody = null;
// Mock the API response and capture request body
vi.mocked(cache.fetchWithCache).mockImplementation(async (_url, options: any) => {
// Capture the request body for validation
capturedRequestBody = JSON.parse(options.body);
return {
data: {
candidates: [
{
content: {
parts: [{ text: 'Test response' }],
role: 'model',
},
finishReason: 'STOP',
},
],
usageMetadata: {
promptTokenCount: 10,
candidatesTokenCount: 5,
totalTokenCount: 15,
},
},
cached: false,
status: 200,
statusText: 'OK',
};
});
});
afterEach(async () => {
vi.clearAllMocks();
});
describe('AIStudioChatProvider with MCP', () => {
let provider: AIStudioChatProvider;
beforeEach(() => {
provider = new AIStudioChatProvider('gemini-2.0-flash', {
config: {
apiKey: 'test-api-key',
mcp: {
enabled: true,
server: {
command: 'npx',
args: ['test-mcp-server'],
name: 'test-mcp',
},
},
},
});
});
afterEach(async () => {
await provider.cleanup();
});
it('should sanitize MCP tool schemas before sending to Gemini API', async () => {
// Wait for MCP initialization
await (provider as any).initializationPromise;
// Make an API call
await provider.callApi('Test prompt');
// Verify request was made
expect(cache.fetchWithCache).toHaveBeenCalled();
expect(capturedRequestBody).not.toBeNull();
// Verify tools are in the request
expect(capturedRequestBody.tools).toBeDefined();
expect(capturedRequestBody.tools.length).toBeGreaterThan(0);
const functionDeclarations = capturedRequestBody.tools[0].functionDeclarations;
expect(functionDeclarations).toBeDefined();
expect(functionDeclarations.length).toBe(2);
// Verify analyze_prompt tool is sanitized
const analyzePromptTool = functionDeclarations.find((f: any) => f.name === 'analyze_prompt');
expect(analyzePromptTool).toBeDefined();
const params = analyzePromptTool.parameters;
// Check that unsupported properties are removed at root level
expect(params).not.toHaveProperty('additionalProperties');
expect(params).not.toHaveProperty('$schema');
// Check that types are converted to uppercase
expect(params.type).toBe('OBJECT');
expect(params.properties.prompt.type).toBe('STRING');
expect(params.properties.severity.type).toBe('STRING');
// Check nested options object
const optionsSchema = params.properties.options;
expect(optionsSchema).not.toHaveProperty('additionalProperties');
expect(optionsSchema.type).toBe('OBJECT');
expect(optionsSchema.properties.maxTokens).not.toHaveProperty('default');
expect(optionsSchema.properties.includeRecommendations).not.toHaveProperty('default');
expect(optionsSchema.properties.maxTokens.type).toBe('NUMBER');
expect(optionsSchema.properties.includeRecommendations.type).toBe('BOOLEAN');
// Check that required and enum are preserved
expect(params.required).toEqual(['prompt']);
expect(params.properties.severity.enum).toEqual(['low', 'medium', 'high', 'critical']);
});
it('should sanitize deeply nested array item schemas', async () => {
await (provider as any).initializationPromise;
await provider.callApi('Test prompt');
const functionDeclarations = capturedRequestBody.tools[0].functionDeclarations;
const searchTool = functionDeclarations.find((f: any) => f.name === 'search_documents');
expect(searchTool).toBeDefined();
const params = searchTool.parameters;
// Root level
expect(params).not.toHaveProperty('additionalProperties');
expect(params.type).toBe('OBJECT');
// Array items - nested object schema should also be sanitized
const filtersSchema = params.properties.filters;
expect(filtersSchema.type).toBe('ARRAY');
expect(filtersSchema.items).not.toHaveProperty('additionalProperties');
expect(filtersSchema.items.type).toBe('OBJECT');
expect(filtersSchema.items.properties.field.type).toBe('STRING');
expect(filtersSchema.items.properties.value.type).toBe('STRING');
});
it('should produce valid Gemini request without schema errors', async () => {
await (provider as any).initializationPromise;
// This should not throw - if the schema was not sanitized properly,
// the real Gemini API would return an error like:
// "GenerateContentRequest.tools[0].function_declarations[0].parameters.additionalProperties:
// should not be set for TYPE_OBJECT"
const result = await provider.callApi('Analyze this prompt for security issues');
expect(result.output).toBe('Test response');
expect(result.error).toBeUndefined();
// Verify the complete request body is valid for Gemini
expect(capturedRequestBody).toMatchObject({
contents: expect.any(Array),
tools: [
{
functionDeclarations: expect.arrayContaining([
expect.objectContaining({
name: 'analyze_prompt',
parameters: expect.objectContaining({
type: 'OBJECT',
}),
}),
]),
},
],
});
});
});
describe('VertexChatProvider with MCP', () => {
// Note: VertexChatProvider uses the same transformMCPToolsToGoogle function
// as AIStudioChatProvider, so the schema sanitization is identical.
// The difference is in the API endpoint and authentication.
// The core schema transformation is tested via AIStudio tests above.
it('should use the same MCP tool transformation as AIStudio', () => {
// This test verifies that both providers share the same transformation code
// The transformMCPToolsToGoogle function is used by both AIStudio and Vertex providers
const mcpTools = [
{
name: 'test_tool',
description: 'A test tool',
inputSchema: {
type: 'object',
properties: { query: { type: 'string' } },
additionalProperties: false,
},
},
];
const result = transformMCPToolsToGoogle(mcpTools);
// Verify the transformation works correctly
expect(result[0].functionDeclarations![0].parameters).not.toHaveProperty(
'additionalProperties',
);
expect(result[0].functionDeclarations![0].parameters!.type).toBe('OBJECT');
});
});
describe('Schema validation for Gemini compatibility', () => {
it('should only include Gemini-supported schema properties', async () => {
const provider = new AIStudioChatProvider('gemini-2.0-flash', {
config: {
apiKey: 'test-api-key',
mcp: {
enabled: true,
server: { command: 'test', args: [], name: 'test' },
},
},
});
await (provider as any).initializationPromise;
await provider.callApi('Test');
await provider.cleanup();
const functionDeclarations = capturedRequestBody.tools[0].functionDeclarations;
// Allowed Gemini schema properties
const allowedProperties = new Set([
'type',
'format',
'description',
'nullable',
'enum',
'maxItems',
'minItems',
'properties',
'required',
'propertyOrdering',
'items',
]);
// Check all tools recursively for unsupported properties
const checkSchema = (schema: any, path: string = 'root') => {
if (!schema || typeof schema !== 'object') {
return;
}
for (const key of Object.keys(schema)) {
if (!allowedProperties.has(key)) {
throw new Error(`Unsupported property "${key}" found at ${path}`);
}
// Recurse into nested schemas
if (key === 'properties' && typeof schema[key] === 'object') {
for (const [propName, propSchema] of Object.entries(schema[key])) {
checkSchema(propSchema, `${path}.properties.${propName}`);
}
}
if (key === 'items' && typeof schema[key] === 'object') {
checkSchema(schema[key], `${path}.items`);
}
}
};
// This should not throw
for (const func of functionDeclarations) {
expect(() => checkSchema(func.parameters, func.name)).not.toThrow();
}
});
it('should have all types in uppercase format', async () => {
const provider = new AIStudioChatProvider('gemini-2.0-flash', {
config: {
apiKey: 'test-api-key',
mcp: {
enabled: true,
server: { command: 'test', args: [], name: 'test' },
},
},
});
await (provider as any).initializationPromise;
await provider.callApi('Test');
await provider.cleanup();
const functionDeclarations = capturedRequestBody.tools[0].functionDeclarations;
const validTypes = ['STRING', 'NUMBER', 'INTEGER', 'BOOLEAN', 'ARRAY', 'OBJECT'];
const checkTypes = (schema: any, path: string = 'root') => {
if (!schema || typeof schema !== 'object') {
return;
}
if (schema.type) {
if (!validTypes.includes(schema.type)) {
throw new Error(`Invalid type "${schema.type}" at ${path} - should be uppercase`);
}
}
if (schema.properties) {
for (const [propName, propSchema] of Object.entries(schema.properties)) {
checkTypes(propSchema, `${path}.properties.${propName}`);
}
}
if (schema.items) {
checkTypes(schema.items, `${path}.items`);
}
};
for (const func of functionDeclarations) {
expect(() => checkTypes(func.parameters, func.name)).not.toThrow();
}
});
});
});
+410
View File
@@ -0,0 +1,410 @@
import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
import { fetchWithCache } from '../../../src/cache';
import { GoogleImageProvider } from '../../../src/providers/google/image';
import { mockProcessEnv } from '../../util/utils';
vi.mock('../../../src/cache', async (importOriginal) => {
return {
...(await importOriginal()),
fetchWithCache: vi.fn(),
};
});
vi.mock('../../../src/providers/google/util', async (importOriginal) => {
return {
...(await importOriginal()),
getGoogleClient: vi.fn(),
loadCredentials: vi.fn(),
resolveProjectId: vi.fn(),
createAuthCacheDiscriminator: vi.fn().mockReturnValue(''),
};
});
describe('GoogleImageProvider', async () => {
const mockFetchWithCache = fetchWithCache as Mock;
const utilMocks = await import('../../../src/providers/google/util');
const mockGetGoogleClient = utilMocks.getGoogleClient as Mock;
const mockLoadCredentials = utilMocks.loadCredentials as Mock;
const mockResolveProjectId = utilMocks.resolveProjectId as Mock;
beforeEach(() => {
vi.clearAllMocks();
mockProcessEnv({ GOOGLE_API_KEY: 'test-api-key' });
mockProcessEnv({ GOOGLE_GENERATIVE_AI_API_KEY: undefined });
mockProcessEnv({ GEMINI_API_KEY: undefined });
mockProcessEnv({ GOOGLE_PROJECT_ID: undefined });
mockProcessEnv({ GOOGLE_CLOUD_PROJECT: undefined });
// Set up default mock behaviors
mockLoadCredentials.mockImplementation(function (creds) {
return creds;
});
mockResolveProjectId.mockResolvedValue('test-project');
});
afterEach(() => {
mockProcessEnv({ GOOGLE_API_KEY: undefined });
mockProcessEnv({ GOOGLE_PROJECT_ID: undefined });
mockProcessEnv({ GOOGLE_CLOUD_PROJECT: undefined });
mockProcessEnv({ GOOGLE_GENERATIVE_AI_API_KEY: undefined });
mockProcessEnv({ GEMINI_API_KEY: undefined });
});
it('should construct with model name', () => {
const provider = new GoogleImageProvider('imagen-3.0-generate-001');
expect(provider.id()).toBe('google:image:imagen-3.0-generate-001');
expect(provider.toString()).toBe('[Google Image Generation Provider imagen-3.0-generate-001]');
});
it('should use Google AI Studio when project ID is missing but API key is available', async () => {
mockProcessEnv({ GOOGLE_PROJECT_ID: undefined });
const provider = new GoogleImageProvider('imagen-3.0-generate-001');
mockFetchWithCache.mockResolvedValueOnce({
status: 200,
data: {
predictions: [
{
bytesBase64Encoded: 'base64data',
mimeType: 'image/png',
},
],
},
cached: false,
});
const result = await provider.callApi('Test prompt');
expect(mockFetchWithCache).toHaveBeenCalledWith(
'https://generativelanguage.googleapis.com/v1beta/models/imagen-3.0-generate-001:predict',
expect.objectContaining({
headers: expect.objectContaining({
'x-goog-api-key': 'test-api-key',
}),
}),
expect.any(Number),
'json',
);
expect(result.output).toContain('data:image/png;base64,base64data');
expect(result.images).toEqual([
{ data: 'data:image/png;base64,base64data', mimeType: 'image/png' },
]);
});
it('should return error when both project ID and API key are missing', async () => {
mockProcessEnv({ GOOGLE_PROJECT_ID: undefined });
mockProcessEnv({ GOOGLE_API_KEY: undefined });
mockProcessEnv({ GOOGLE_GENERATIVE_AI_API_KEY: undefined });
mockProcessEnv({ GEMINI_API_KEY: undefined });
const provider = new GoogleImageProvider('imagen-3.0-generate-001');
const result = await provider.callApi('Test prompt');
expect(result.error).toContain('Imagen models require either:');
expect(result.error).toContain('Google AI Studio');
expect(result.error).toContain('Vertex AI');
});
describe('Vertex AI', () => {
beforeEach(() => {
mockProcessEnv({ GOOGLE_PROJECT_ID: 'test-project' });
});
it('should use OAuth authentication for Vertex AI', async () => {
const provider = new GoogleImageProvider('imagen-3.0-generate-001', {
config: {
projectId: 'test-project',
},
});
const mockClient = {
request: vi.fn().mockResolvedValue({
data: {
predictions: [
{
image: {
mimeType: 'image/png',
bytesBase64Encoded: 'base64data',
},
},
],
},
}),
};
mockGetGoogleClient.mockResolvedValue({
client: mockClient,
projectId: 'test-project',
});
const result = await provider.callApi('Test prompt');
expect(mockGetGoogleClient).toHaveBeenCalled();
expect(mockClient.request).toHaveBeenCalledWith({
url: expect.stringContaining('aiplatform.googleapis.com'),
method: 'POST',
headers: expect.objectContaining({
'Content-Type': 'application/json',
}),
data: expect.objectContaining({
instances: [{ prompt: 'Test prompt' }],
}),
timeout: 300000,
});
expect(result.output).toContain('data:image/png;base64,base64data');
expect(result.images).toEqual([
{ data: 'data:image/png;base64,base64data', mimeType: 'image/png' },
]);
});
it('should handle OAuth errors', async () => {
const provider = new GoogleImageProvider('imagen-3.0-generate-001', {
config: {
projectId: 'test-project',
},
});
mockGetGoogleClient.mockRejectedValue(new Error('Google auth library not found'));
const result = await provider.callApi('Test prompt');
expect(result.error).toContain('Failed to call Vertex AI');
expect(result.error).toContain('Google auth library not found');
});
});
it('should support different model name formats', () => {
const provider1 = new GoogleImageProvider('imagen-3.0-generate-001');
expect(provider1.id()).toBe('google:image:imagen-3.0-generate-001');
// When model name already includes 'imagen', it should be preserved
const provider2 = new GoogleImageProvider('gemini/imagen-3.0-generate-001');
expect(provider2.id()).toBe('google:image:gemini/imagen-3.0-generate-001');
// When model name doesn't include 'imagen', ID still includes full model name
const provider3 = new GoogleImageProvider('3.0-generate-001');
expect(provider3.id()).toBe('google:image:3.0-generate-001');
});
it('should handle model path prefixing correctly', async () => {
const testCases = [
{ input: 'imagen-3.0-generate-001', expected: 'imagen-3.0-generate-001' },
{ input: '3.0-generate-001', expected: 'imagen-3.0-generate-001' },
{ input: 'custom-imagen-model', expected: 'imagen-custom-imagen-model' }, // Should be prefixed
{ input: 'imagen-4.0-ultra', expected: 'imagen-4.0-ultra' },
];
for (const { input, expected } of testCases) {
const provider = new GoogleImageProvider(input, {
config: {
projectId: 'test-project', // Ensure Vertex AI is used
},
});
// Mock the API response to extract the model path from the request
const mockClient = {
request: vi.fn().mockResolvedValue({
data: {
predictions: [{ bytesBase64Encoded: 'test', mimeType: 'image/png' }],
},
}),
};
mockGetGoogleClient.mockResolvedValue({
client: mockClient,
projectId: 'test-project',
});
await provider.callApi('test prompt');
expect(mockClient.request).toHaveBeenCalledWith(
expect.objectContaining({
url: expect.stringContaining(`/models/${expected}:predict`),
}),
);
}
});
it('should return correct cost for different models', async () => {
// Test costs through actual API responses
const testCases = [
// Imagen 4 GA names
{ model: 'imagen-4.0-ultra-generate-001', expectedCost: 0.06 },
{ model: 'imagen-4.0-generate-001', expectedCost: 0.04 },
{ model: 'imagen-4.0-fast-generate-001', expectedCost: 0.02 },
// Imagen 4 preview aliases
{ model: 'imagen-4.0-ultra-generate-preview-06-06', expectedCost: 0.06 },
{ model: 'imagen-4.0-generate-preview-06-06', expectedCost: 0.04 },
{ model: 'imagen-4.0-fast-generate-preview-06-06', expectedCost: 0.02 },
{ model: '3.0-generate-001', expectedCost: 0.04 }, // Without prefix
{ model: 'unknown-model', expectedCost: 0.04 }, // Default cost
];
for (const { model, expectedCost } of testCases) {
const provider = new GoogleImageProvider(model);
mockFetchWithCache.mockResolvedValueOnce({
status: 200,
data: {
predictions: [{ bytesBase64Encoded: 'base64data', mimeType: 'image/png' }],
},
cached: false,
});
const result = await provider.callApi('Test prompt');
expect(result.cost).toBe(expectedCost);
}
});
it('should not report cost for cached responses', async () => {
const provider = new GoogleImageProvider('imagen-4.0-fast-generate-001');
mockFetchWithCache.mockResolvedValueOnce({
status: 200,
data: {
predictions: [{ bytesBase64Encoded: 'base64data', mimeType: 'image/png' }],
},
cached: true,
});
const result = await provider.callApi('Test prompt');
expect(result.cached).toBe(true);
expect(result.cost).toBeUndefined();
});
describe('Google AI Studio', () => {
beforeEach(() => {
mockProcessEnv({ GOOGLE_PROJECT_ID: undefined });
mockProcessEnv({ GOOGLE_API_KEY: 'test-api-key' });
});
it('should make correct API request to Google AI Studio', async () => {
const provider = new GoogleImageProvider('imagen-4.0-generate-preview-06-06', {
config: {
n: 2,
aspectRatio: '16:9',
safetyFilterLevel: 'block_few',
personGeneration: 'allow_adult',
},
});
mockFetchWithCache.mockResolvedValueOnce({
status: 200,
data: {
predictions: [
{
bytesBase64Encoded: 'base64data1',
mimeType: 'image/png',
},
{
bytesBase64Encoded: 'base64data2',
mimeType: 'image/png',
},
],
},
cached: false,
});
const result = await provider.callApi('Test prompt');
expect(mockFetchWithCache).toHaveBeenCalledWith(
'https://generativelanguage.googleapis.com/v1beta/models/imagen-4.0-generate-preview-06-06:predict',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Content-Type': 'application/json',
'x-goog-api-key': 'test-api-key',
}),
body: JSON.stringify({
instances: [
{
prompt: 'Test prompt',
},
],
parameters: {
sampleCount: 2,
aspectRatio: '16:9',
personGeneration: 'allow_adult',
safetySetting: 'block_low_and_above',
},
}),
}),
expect.any(Number),
'json',
);
// First image as output for blob externalization
expect(result.output).toBe('data:image/png;base64,base64data1');
// All images in structured field
expect(result.images).toEqual([
{ data: 'data:image/png;base64,base64data1', mimeType: 'image/png' },
{ data: 'data:image/png;base64,base64data2', mimeType: 'image/png' },
]);
expect(result.cached).toBe(false);
expect(result.cost).toBe(0.08); // 2 images * 0.04
});
it('should handle API errors from Google AI Studio', async () => {
const provider = new GoogleImageProvider('imagen-3.0-generate-001');
mockFetchWithCache.mockResolvedValueOnce({
status: 400,
data: {
error: {
message: 'Invalid request',
},
},
});
const result = await provider.callApi('Test prompt');
expect(result.error).toBe('Invalid request');
});
it('should handle missing API key for Google AI Studio', async () => {
mockProcessEnv({ GOOGLE_API_KEY: undefined });
mockProcessEnv({ GOOGLE_GENERATIVE_AI_API_KEY: undefined });
mockProcessEnv({ GEMINI_API_KEY: undefined });
const provider = new GoogleImageProvider('imagen-3.0-generate-001');
const result = await provider.callApi('Test prompt');
expect(result.error).toContain('Imagen models require either:');
});
it('should support different API key environment variables', async () => {
mockProcessEnv({ GOOGLE_API_KEY: undefined });
mockProcessEnv({ GEMINI_API_KEY: 'gemini-key' });
const provider = new GoogleImageProvider('imagen-3.0-generate-001');
mockFetchWithCache.mockResolvedValueOnce({
status: 200,
data: {
predictions: [
{
bytesBase64Encoded: 'base64data',
mimeType: 'image/png',
},
],
},
cached: false,
});
const result = await provider.callApi('Test prompt');
expect(mockFetchWithCache).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({
'x-goog-api-key': 'gemini-key',
}),
}),
expect.any(Number),
'json',
);
expect(result.output).toContain('data:image/png;base64,base64data');
});
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import {
getGoogleVertexEmbeddingProvider,
getGoogleVertexProviders,
VertexChatProvider,
VertexEmbeddingProvider,
} from '../../../src/providers/google/vertex';
describe('Google Vertex default providers', () => {
it('should create providers with correct model names', () => {
const providers = getGoogleVertexProviders();
expect(providers.embeddingProvider.modelName).toBe('gemini-embedding-001');
expect(providers.embeddingProvider.id()).toBe('vertex:gemini-embedding-001');
expect(providers.gradingProvider.modelName).toBe('gemini-3.1-pro-preview');
expect(providers.gradingProvider.id()).toBe('vertex:gemini-3.1-pro-preview');
});
it('should create correct provider instances', () => {
const providers = getGoogleVertexProviders();
expect(providers.embeddingProvider).toBeInstanceOf(VertexEmbeddingProvider);
expect(providers.gradingProvider).toBeInstanceOf(VertexChatProvider);
});
it('should keep the default Gemini 3.1 provider global while honoring API host overrides', () => {
const providers = getGoogleVertexProviders({
VERTEX_REGION: 'us-central1',
VERTEX_API_HOST: 'vertex-proxy.example.test',
});
expect(providers.gradingProvider.getRegion()).toBe('global');
expect(providers.gradingProvider.getApiHost()).toBe('vertex-proxy.example.test');
});
it('should share a single chat provider instance across grading roles', () => {
const providers = getGoogleVertexProviders();
expect(providers.gradingJsonProvider).toBe(providers.gradingProvider);
expect(providers.suggestionsProvider).toBe(providers.gradingProvider);
expect(providers.synthesizeProvider).toBe(providers.gradingProvider);
});
it('should return fresh provider instances on each call', () => {
const a = getGoogleVertexProviders();
const b = getGoogleVertexProviders();
expect(b.gradingProvider).not.toBe(a.gradingProvider);
expect(b.embeddingProvider).not.toBe(a.embeddingProvider);
});
});
describe('getGoogleVertexEmbeddingProvider', () => {
it('should return a VertexEmbeddingProvider with the default model', () => {
const embedding = getGoogleVertexEmbeddingProvider();
expect(embedding).toBeInstanceOf(VertexEmbeddingProvider);
expect(embedding.modelName).toBe('gemini-embedding-001');
});
it('should return fresh instances on each call', () => {
expect(getGoogleVertexEmbeddingProvider()).not.toBe(getGoogleVertexEmbeddingProvider());
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff