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
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:
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user