0d3cb498a3
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Waiting to run
Test and Publish Multi-arch Docker Image / test (push) Waiting to run
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Blocked by required conditions
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) Blocked by required conditions
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Blocked by required conditions
Validate Renovate Config / Validate Renovate Configuration (push) Waiting to run
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
1069 lines
37 KiB
TypeScript
1069 lines
37 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { getCache, isCacheEnabled } from '../../src/cache';
|
|
import {
|
|
createVercelProvider,
|
|
VercelAiEmbeddingProvider,
|
|
VercelAiProvider,
|
|
} from '../../src/providers/vercel';
|
|
|
|
// Mock the cache module
|
|
vi.mock('../../src/cache', async () => ({
|
|
...(await vi.importActual('../../src/cache')),
|
|
getCache: vi.fn(),
|
|
isCacheEnabled: vi.fn(),
|
|
}));
|
|
|
|
// Mock the ai SDK module
|
|
vi.mock('ai', () => {
|
|
const createGatewayMock = vi.fn(() => {
|
|
const gateway = Object.assign(
|
|
vi.fn((modelName: string) => ({ modelName })),
|
|
{
|
|
textEmbeddingModel: vi.fn((modelName: string) => ({ modelName, type: 'embedding' })),
|
|
},
|
|
);
|
|
return gateway;
|
|
});
|
|
return {
|
|
createGateway: createGatewayMock,
|
|
generateText: vi.fn(),
|
|
streamText: vi.fn(),
|
|
generateObject: vi.fn(),
|
|
embed: vi.fn(),
|
|
jsonSchema: vi.fn((schema: unknown) => schema),
|
|
};
|
|
});
|
|
|
|
describe('VercelAiProvider', () => {
|
|
let mockCache: {
|
|
get: ReturnType<typeof vi.fn>;
|
|
set: ReturnType<typeof vi.fn>;
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
|
|
// Reset cache mock
|
|
mockCache = {
|
|
get: vi.fn().mockResolvedValue(null),
|
|
set: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
vi.mocked(getCache).mockResolvedValue(mockCache as any);
|
|
vi.mocked(isCacheEnabled).mockReturnValue(false);
|
|
|
|
// Reset ai module mocks
|
|
const { generateText, streamText, generateObject, embed } = await import('ai');
|
|
vi.mocked(generateText).mockReset();
|
|
vi.mocked(streamText).mockReset();
|
|
vi.mocked(generateObject).mockReset();
|
|
vi.mocked(embed).mockReset();
|
|
});
|
|
|
|
describe('constructor', () => {
|
|
it('should create a provider with default options', () => {
|
|
const provider = new VercelAiProvider('openai/gpt-4o-mini');
|
|
expect(provider.modelName).toBe('openai/gpt-4o-mini');
|
|
expect(provider.config).toEqual({});
|
|
});
|
|
|
|
it('should create a provider with custom options', () => {
|
|
const provider = new VercelAiProvider('anthropic/claude-sonnet-4.5', {
|
|
config: { temperature: 0.7, maxTokens: 1024 },
|
|
});
|
|
expect(provider.modelName).toBe('anthropic/claude-sonnet-4.5');
|
|
expect(provider.config).toEqual({ temperature: 0.7, maxTokens: 1024 });
|
|
});
|
|
});
|
|
|
|
describe('id()', () => {
|
|
it('should return correct provider id', () => {
|
|
const provider = new VercelAiProvider('openai/gpt-4o');
|
|
expect(provider.id()).toBe('vercel:openai/gpt-4o');
|
|
});
|
|
|
|
it('should use custom id if provided', () => {
|
|
const provider = new VercelAiProvider('openai/gpt-4o', {
|
|
id: 'custom-vercel-provider',
|
|
});
|
|
expect(provider.id()).toBe('custom-vercel-provider');
|
|
});
|
|
});
|
|
|
|
describe('toString()', () => {
|
|
it('should return correct string representation', () => {
|
|
const provider = new VercelAiProvider('openai/gpt-4o-mini');
|
|
expect(provider.toString()).toBe('[Vercel AI Gateway Provider openai/gpt-4o-mini]');
|
|
});
|
|
});
|
|
|
|
describe('configuration options', () => {
|
|
it('should store apiKeyEnvar in config', () => {
|
|
const provider = new VercelAiProvider('openai/gpt-4o', {
|
|
config: { apiKeyEnvar: 'MY_CUSTOM_API_KEY' },
|
|
});
|
|
expect(provider.config.apiKeyEnvar).toBe('MY_CUSTOM_API_KEY');
|
|
});
|
|
|
|
it('should store headers in config', () => {
|
|
const provider = new VercelAiProvider('openai/gpt-4o', {
|
|
config: { headers: { 'X-Custom-Header': 'test-value' } },
|
|
});
|
|
expect(provider.config.headers).toEqual({ 'X-Custom-Header': 'test-value' });
|
|
});
|
|
|
|
it('should store baseUrl in config', () => {
|
|
const provider = new VercelAiProvider('openai/gpt-4o', {
|
|
config: { baseUrl: 'https://custom-gateway.example.com' },
|
|
});
|
|
expect(provider.config.baseUrl).toBe('https://custom-gateway.example.com');
|
|
});
|
|
|
|
it('should store all config options together', () => {
|
|
const provider = new VercelAiProvider('openai/gpt-4o', {
|
|
config: {
|
|
apiKey: 'test-key',
|
|
apiKeyEnvar: 'MY_API_KEY',
|
|
baseUrl: 'https://custom.example.com',
|
|
headers: { 'X-Test': 'value' },
|
|
temperature: 0.5,
|
|
maxTokens: 1000,
|
|
topP: 0.9,
|
|
topK: 40,
|
|
frequencyPenalty: 0.1,
|
|
presencePenalty: 0.2,
|
|
stopSequences: ['\n\n'],
|
|
timeout: 30000,
|
|
streaming: true,
|
|
responseSchema: { type: 'object' },
|
|
},
|
|
});
|
|
expect(provider.config).toEqual({
|
|
apiKey: 'test-key',
|
|
apiKeyEnvar: 'MY_API_KEY',
|
|
baseUrl: 'https://custom.example.com',
|
|
headers: { 'X-Test': 'value' },
|
|
temperature: 0.5,
|
|
maxTokens: 1000,
|
|
topP: 0.9,
|
|
topK: 40,
|
|
frequencyPenalty: 0.1,
|
|
presencePenalty: 0.2,
|
|
stopSequences: ['\n\n'],
|
|
timeout: 30000,
|
|
streaming: true,
|
|
responseSchema: { type: 'object' },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('callApi() - non-streaming', () => {
|
|
it('should return text response', async () => {
|
|
const { generateText } = await import('ai');
|
|
vi.mocked(generateText).mockResolvedValueOnce({
|
|
text: 'Hello from GPT-4o!',
|
|
usage: { promptTokens: 10, completionTokens: 20, totalTokens: 30 },
|
|
finishReason: 'stop',
|
|
} as any);
|
|
|
|
const provider = new VercelAiProvider('openai/gpt-4o-mini');
|
|
const result = await provider.callApi('Hello');
|
|
|
|
expect(result).toEqual({
|
|
output: 'Hello from GPT-4o!',
|
|
tokenUsage: {
|
|
prompt: 10,
|
|
completion: 20,
|
|
total: 30,
|
|
numRequests: 1,
|
|
},
|
|
finishReason: 'stop',
|
|
});
|
|
});
|
|
|
|
it('should include token usage', async () => {
|
|
const { generateText } = await import('ai');
|
|
vi.mocked(generateText).mockResolvedValueOnce({
|
|
text: 'Response',
|
|
usage: { promptTokens: 5, completionTokens: 15, totalTokens: 20 },
|
|
finishReason: 'stop',
|
|
} as any);
|
|
|
|
const provider = new VercelAiProvider('openai/gpt-4o');
|
|
const result = await provider.callApi('Test');
|
|
|
|
expect(result.tokenUsage).toEqual({
|
|
prompt: 5,
|
|
completion: 15,
|
|
total: 20,
|
|
numRequests: 1,
|
|
});
|
|
});
|
|
|
|
it('should parse JSON chat messages from prompt', async () => {
|
|
const { generateText } = await import('ai');
|
|
vi.mocked(generateText).mockResolvedValueOnce({
|
|
text: 'Response to chat',
|
|
usage: { promptTokens: 10, completionTokens: 15 },
|
|
finishReason: 'stop',
|
|
} as any);
|
|
|
|
const provider = new VercelAiProvider('openai/gpt-4o');
|
|
const chatPrompt = JSON.stringify([
|
|
{ role: 'system', content: 'You are a helpful assistant.' },
|
|
{ role: 'user', content: 'Hello!' },
|
|
]);
|
|
await provider.callApi(chatPrompt);
|
|
|
|
expect(vi.mocked(generateText)).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
messages: [
|
|
{ role: 'system', content: 'You are a helpful assistant.' },
|
|
{ role: 'user', content: 'Hello!' },
|
|
],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should pass config options to generateText', async () => {
|
|
const { generateText } = await import('ai');
|
|
vi.mocked(generateText).mockResolvedValueOnce({
|
|
text: 'Response',
|
|
usage: { promptTokens: 5, completionTokens: 10 },
|
|
finishReason: 'stop',
|
|
} as any);
|
|
|
|
const provider = new VercelAiProvider('openai/gpt-4o', {
|
|
config: {
|
|
temperature: 0.8,
|
|
maxTokens: 500,
|
|
topP: 0.9,
|
|
},
|
|
});
|
|
await provider.callApi('Test');
|
|
|
|
expect(vi.mocked(generateText)).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
messages: [{ role: 'user', content: 'Test' }],
|
|
temperature: 0.8,
|
|
maxTokens: 500,
|
|
topP: 0.9,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should handle errors gracefully', async () => {
|
|
const { generateText } = await import('ai');
|
|
vi.mocked(generateText).mockRejectedValueOnce(new Error('API rate limit exceeded'));
|
|
|
|
const provider = new VercelAiProvider('openai/gpt-4o-mini');
|
|
const result = await provider.callApi('Hello');
|
|
|
|
expect(result).toEqual({
|
|
error: 'API call error: API rate limit exceeded',
|
|
});
|
|
});
|
|
|
|
it('should handle timeout errors', async () => {
|
|
const { generateText } = await import('ai');
|
|
const abortError = new Error('The operation was aborted');
|
|
abortError.name = 'AbortError';
|
|
vi.mocked(generateText).mockRejectedValueOnce(abortError);
|
|
|
|
const provider = new VercelAiProvider('openai/gpt-4o-mini', {
|
|
config: { timeout: 5000 },
|
|
});
|
|
const result = await provider.callApi('Hello');
|
|
|
|
expect(result).toEqual({
|
|
error: 'Request timed out after 5000ms',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('callApi() - streaming', () => {
|
|
it('should handle streaming responses', async () => {
|
|
const { streamText } = await import('ai');
|
|
|
|
// Mock async generator for textStream
|
|
async function* mockTextStream() {
|
|
yield 'Hello ';
|
|
yield 'from ';
|
|
yield 'streaming!';
|
|
}
|
|
|
|
vi.mocked(streamText).mockReturnValueOnce({
|
|
textStream: mockTextStream(),
|
|
usage: Promise.resolve({ promptTokens: 5, completionTokens: 15, totalTokens: 20 }),
|
|
finishReason: Promise.resolve('stop'),
|
|
} as any);
|
|
|
|
const provider = new VercelAiProvider('openai/gpt-4o', {
|
|
config: { streaming: true },
|
|
});
|
|
const result = await provider.callApi('Hello');
|
|
|
|
expect(result).toEqual({
|
|
output: 'Hello from streaming!',
|
|
tokenUsage: {
|
|
prompt: 5,
|
|
completion: 15,
|
|
total: 20,
|
|
numRequests: 1,
|
|
},
|
|
finishReason: 'stop',
|
|
});
|
|
});
|
|
|
|
it('should pass config options to streamText', async () => {
|
|
const { streamText } = await import('ai');
|
|
|
|
async function* mockTextStream() {
|
|
yield 'Response';
|
|
}
|
|
|
|
vi.mocked(streamText).mockReturnValueOnce({
|
|
textStream: mockTextStream(),
|
|
usage: Promise.resolve({ promptTokens: 5, completionTokens: 10 }),
|
|
finishReason: Promise.resolve('stop'),
|
|
} as any);
|
|
|
|
const provider = new VercelAiProvider('anthropic/claude-sonnet-4.5', {
|
|
config: {
|
|
streaming: true,
|
|
temperature: 0.5,
|
|
maxTokens: 1000,
|
|
},
|
|
});
|
|
await provider.callApi('Test');
|
|
|
|
expect(vi.mocked(streamText)).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
messages: [{ role: 'user', content: 'Test' }],
|
|
temperature: 0.5,
|
|
maxTokens: 1000,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should handle streaming errors', async () => {
|
|
const { streamText } = await import('ai');
|
|
vi.mocked(streamText).mockImplementationOnce(() => {
|
|
throw new Error('Stream connection failed');
|
|
});
|
|
|
|
const provider = new VercelAiProvider('openai/gpt-4o', {
|
|
config: { streaming: true },
|
|
});
|
|
const result = await provider.callApi('Hello');
|
|
|
|
expect(result).toEqual({
|
|
error: 'API call error: Stream connection failed',
|
|
});
|
|
});
|
|
|
|
it('should handle streaming timeout errors', async () => {
|
|
const { streamText } = await import('ai');
|
|
const abortError = new Error('The operation was aborted');
|
|
abortError.name = 'AbortError';
|
|
vi.mocked(streamText).mockImplementationOnce(() => {
|
|
throw abortError;
|
|
});
|
|
|
|
const provider = new VercelAiProvider('openai/gpt-4o', {
|
|
config: { streaming: true, timeout: 10000 },
|
|
});
|
|
const result = await provider.callApi('Hello');
|
|
|
|
expect(result).toEqual({
|
|
error: 'Request timed out after 10000ms',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('caching', () => {
|
|
it('should return cached response when available', async () => {
|
|
vi.mocked(isCacheEnabled).mockReturnValue(true);
|
|
mockCache.get.mockResolvedValueOnce(
|
|
JSON.stringify({
|
|
output: 'Cached response',
|
|
tokenUsage: { prompt: 5, completion: 10, total: 15 },
|
|
finishReason: 'stop',
|
|
}),
|
|
);
|
|
|
|
const prompt = 'PFQA_VERCEL_PROMPT_SENTINEL';
|
|
const provider = new VercelAiProvider('openai/gpt-4o-mini');
|
|
const result = await provider.callApi(prompt);
|
|
|
|
expect(result).toEqual({
|
|
output: 'Cached response',
|
|
tokenUsage: { prompt: 5, completion: 10, total: 15 },
|
|
finishReason: 'stop',
|
|
cached: true,
|
|
});
|
|
const cacheKey = mockCache.get.mock.calls[0][0] as string;
|
|
expect(cacheKey).toMatch(/^vercel:openai\/gpt-4o-mini:[a-f0-9]{64}$/);
|
|
expect(cacheKey).not.toContain(prompt);
|
|
});
|
|
|
|
it('should cache response after successful API call', async () => {
|
|
const { generateText } = await import('ai');
|
|
vi.mocked(isCacheEnabled).mockReturnValue(true);
|
|
vi.mocked(generateText).mockResolvedValueOnce({
|
|
text: 'Fresh response',
|
|
usage: { promptTokens: 10, completionTokens: 20 },
|
|
finishReason: 'stop',
|
|
} as any);
|
|
|
|
const prompt = 'PFQA_VERCEL_PROMPT_SENTINEL';
|
|
const provider = new VercelAiProvider('openai/gpt-4o-mini');
|
|
await provider.callApi(prompt);
|
|
|
|
const cacheKey = mockCache.set.mock.calls[0][0] as string;
|
|
expect(cacheKey).toMatch(/^vercel:openai\/gpt-4o-mini:[a-f0-9]{64}$/);
|
|
expect(cacheKey).not.toContain(prompt);
|
|
expect(mockCache.get).toHaveBeenCalledWith(cacheKey);
|
|
expect(mockCache.set).toHaveBeenCalledWith(
|
|
cacheKey,
|
|
expect.stringContaining('Fresh response'),
|
|
);
|
|
});
|
|
|
|
it('should include gateway identity in cache keys without leaking secrets', async () => {
|
|
const { generateText } = await import('ai');
|
|
vi.mocked(isCacheEnabled).mockReturnValue(true);
|
|
vi.mocked(generateText).mockResolvedValue({
|
|
text: 'Fresh response',
|
|
usage: { promptTokens: 10, completionTokens: 20 },
|
|
finishReason: 'stop',
|
|
} as any);
|
|
|
|
const prompt = 'PFQA_VERCEL_PROMPT_SENTINEL';
|
|
const firstProvider = new VercelAiProvider('openai/gpt-4o-mini', {
|
|
config: {
|
|
apiKey: 'PFQA_VERCEL_API_KEY_A',
|
|
baseUrl: 'https://gateway.example.com/shared',
|
|
headers: { 'X-Tenant': 'PFQA_VERCEL_TENANT_A' },
|
|
},
|
|
});
|
|
const secondProvider = new VercelAiProvider('openai/gpt-4o-mini', {
|
|
config: {
|
|
apiKey: 'PFQA_VERCEL_API_KEY_B',
|
|
baseUrl: 'https://gateway.example.com/shared',
|
|
headers: { 'X-Tenant': 'PFQA_VERCEL_TENANT_B' },
|
|
},
|
|
});
|
|
|
|
await firstProvider.callApi(prompt);
|
|
await secondProvider.callApi(prompt);
|
|
|
|
const firstKey = mockCache.get.mock.calls[0][0] as string;
|
|
const secondKey = mockCache.get.mock.calls[1][0] as string;
|
|
|
|
expect(firstKey).not.toBe(secondKey);
|
|
expect(firstKey).not.toContain(prompt);
|
|
expect(firstKey).not.toContain('PFQA_VERCEL_API_KEY_A');
|
|
expect(firstKey).not.toContain('PFQA_VERCEL_TENANT_A');
|
|
expect(secondKey).not.toContain('PFQA_VERCEL_API_KEY_B');
|
|
expect(secondKey).not.toContain('PFQA_VERCEL_TENANT_B');
|
|
});
|
|
|
|
it('should separate cache keys for different gateway header values with the same header names', async () => {
|
|
const { generateText } = await import('ai');
|
|
vi.mocked(isCacheEnabled).mockReturnValue(true);
|
|
vi.mocked(generateText).mockResolvedValue({
|
|
text: 'Fresh response',
|
|
usage: { promptTokens: 10, completionTokens: 20 },
|
|
finishReason: 'stop',
|
|
} as any);
|
|
|
|
const prompt = 'PFQA_VERCEL_PROMPT_SENTINEL';
|
|
const firstProvider = new VercelAiProvider('openai/gpt-4o-mini', {
|
|
config: {
|
|
apiKey: 'PFQA_VERCEL_SHARED_API_KEY',
|
|
baseUrl: 'https://gateway.example.com/shared',
|
|
headers: { 'X-Tenant': 'PFQA_VERCEL_TENANT_A' },
|
|
},
|
|
});
|
|
const secondProvider = new VercelAiProvider('openai/gpt-4o-mini', {
|
|
config: {
|
|
apiKey: 'PFQA_VERCEL_SHARED_API_KEY',
|
|
baseUrl: 'https://gateway.example.com/shared',
|
|
headers: { 'X-Tenant': 'PFQA_VERCEL_TENANT_B' },
|
|
},
|
|
});
|
|
|
|
await firstProvider.callApi(prompt);
|
|
await secondProvider.callApi(prompt);
|
|
|
|
const firstKey = mockCache.get.mock.calls[0][0] as string;
|
|
const secondKey = mockCache.get.mock.calls[1][0] as string;
|
|
|
|
expect(firstKey).not.toBe(secondKey);
|
|
expect(firstKey).not.toContain('PFQA_VERCEL_SHARED_API_KEY');
|
|
expect(firstKey).not.toContain('PFQA_VERCEL_TENANT_A');
|
|
expect(secondKey).not.toContain('PFQA_VERCEL_TENANT_B');
|
|
});
|
|
|
|
it('should reuse cache keys for equivalent gateway header name casing', async () => {
|
|
const { generateText } = await import('ai');
|
|
vi.mocked(isCacheEnabled).mockReturnValue(true);
|
|
vi.mocked(generateText).mockResolvedValue({
|
|
text: 'Fresh response',
|
|
usage: { promptTokens: 10, completionTokens: 20 },
|
|
finishReason: 'stop',
|
|
} as any);
|
|
|
|
const prompt = 'PFQA_VERCEL_PROMPT_SENTINEL';
|
|
const firstProvider = new VercelAiProvider('openai/gpt-4o-mini', {
|
|
config: {
|
|
apiKey: 'PFQA_VERCEL_SHARED_API_KEY',
|
|
baseUrl: 'https://gateway.example.com/shared',
|
|
headers: { 'X-Tenant': 'PFQA_VERCEL_TENANT_A' },
|
|
},
|
|
});
|
|
const secondProvider = new VercelAiProvider('openai/gpt-4o-mini', {
|
|
config: {
|
|
apiKey: 'PFQA_VERCEL_SHARED_API_KEY',
|
|
baseUrl: 'https://gateway.example.com/shared',
|
|
headers: { 'x-tenant': 'PFQA_VERCEL_TENANT_A' },
|
|
},
|
|
});
|
|
|
|
await firstProvider.callApi(prompt);
|
|
await secondProvider.callApi(prompt);
|
|
|
|
const firstKey = mockCache.get.mock.calls[0][0] as string;
|
|
const secondKey = mockCache.get.mock.calls[1][0] as string;
|
|
|
|
expect(firstKey).toBe(secondKey);
|
|
expect(firstKey).not.toContain(prompt);
|
|
expect(firstKey).not.toContain('PFQA_VERCEL_SHARED_API_KEY');
|
|
expect(firstKey).not.toContain('PFQA_VERCEL_TENANT_A');
|
|
});
|
|
|
|
it('should separate cache keys when only gateway baseUrl changes', async () => {
|
|
const { generateText } = await import('ai');
|
|
vi.mocked(isCacheEnabled).mockReturnValue(true);
|
|
vi.mocked(generateText).mockResolvedValue({
|
|
text: 'Fresh response',
|
|
usage: { promptTokens: 10, completionTokens: 20 },
|
|
finishReason: 'stop',
|
|
} as any);
|
|
|
|
const prompt = 'PFQA_VERCEL_PROMPT_SENTINEL';
|
|
const firstProvider = new VercelAiProvider('openai/gpt-4o-mini', {
|
|
config: {
|
|
apiKey: 'PFQA_VERCEL_SHARED_API_KEY',
|
|
baseUrl: 'https://gateway-a.example.com',
|
|
headers: { 'X-Tenant': 'PFQA_VERCEL_SHARED_TENANT' },
|
|
},
|
|
});
|
|
const secondProvider = new VercelAiProvider('openai/gpt-4o-mini', {
|
|
config: {
|
|
apiKey: 'PFQA_VERCEL_SHARED_API_KEY',
|
|
baseUrl: 'https://gateway-b.example.com',
|
|
headers: { 'X-Tenant': 'PFQA_VERCEL_SHARED_TENANT' },
|
|
},
|
|
});
|
|
|
|
await firstProvider.callApi(prompt);
|
|
await secondProvider.callApi(prompt);
|
|
|
|
const firstKey = mockCache.get.mock.calls[0][0] as string;
|
|
const secondKey = mockCache.get.mock.calls[1][0] as string;
|
|
|
|
expect(firstKey).not.toBe(secondKey);
|
|
expect(firstKey).not.toContain(prompt);
|
|
expect(firstKey).not.toContain('PFQA_VERCEL_SHARED_API_KEY');
|
|
expect(firstKey).not.toContain('PFQA_VERCEL_SHARED_TENANT');
|
|
expect(firstKey).not.toContain('gateway-a.example.com');
|
|
expect(secondKey).not.toContain('gateway-b.example.com');
|
|
});
|
|
|
|
it('should reuse cache keys when the same API key resolves from different sources', async () => {
|
|
const { generateText } = await import('ai');
|
|
vi.mocked(isCacheEnabled).mockReturnValue(true);
|
|
vi.mocked(generateText).mockResolvedValue({
|
|
text: 'Fresh response',
|
|
usage: { promptTokens: 10, completionTokens: 20 },
|
|
finishReason: 'stop',
|
|
} as any);
|
|
|
|
const prompt = 'PFQA_VERCEL_PROMPT_SENTINEL';
|
|
const firstProvider = new VercelAiProvider('openai/gpt-4o-mini', {
|
|
config: {
|
|
apiKey: 'PFQA_VERCEL_SHARED_API_KEY',
|
|
baseUrl: 'https://gateway.example.com/shared',
|
|
},
|
|
});
|
|
const secondProvider = new VercelAiProvider('openai/gpt-4o-mini', {
|
|
config: {
|
|
apiKeyEnvar: 'PFQA_VERCEL_CUSTOM_KEY',
|
|
baseUrl: 'https://gateway.example.com/shared',
|
|
},
|
|
env: { PFQA_VERCEL_CUSTOM_KEY: 'PFQA_VERCEL_SHARED_API_KEY' } as any,
|
|
});
|
|
|
|
await firstProvider.callApi(prompt);
|
|
await secondProvider.callApi(prompt);
|
|
|
|
const firstKey = mockCache.get.mock.calls[0][0] as string;
|
|
const secondKey = mockCache.get.mock.calls[1][0] as string;
|
|
|
|
expect(firstKey).toBe(secondKey);
|
|
expect(firstKey).not.toContain(prompt);
|
|
expect(firstKey).not.toContain('PFQA_VERCEL_SHARED_API_KEY');
|
|
});
|
|
|
|
it('should reuse cache keys when optional config auth fields are undefined', async () => {
|
|
const { generateText } = await import('ai');
|
|
vi.mocked(isCacheEnabled).mockReturnValue(true);
|
|
vi.mocked(generateText).mockResolvedValue({
|
|
text: 'Fresh response',
|
|
usage: { promptTokens: 10, completionTokens: 20 },
|
|
finishReason: 'stop',
|
|
} as any);
|
|
|
|
const env = { VERCEL_AI_GATEWAY_API_KEY: 'PFQA_VERCEL_ENV_API_KEY' };
|
|
const firstProvider = new VercelAiProvider('openai/gpt-4o-mini', {
|
|
config: {
|
|
apiKey: undefined,
|
|
apiKeyEnvar: undefined,
|
|
},
|
|
env,
|
|
});
|
|
const secondProvider = new VercelAiProvider('openai/gpt-4o-mini', {
|
|
config: {},
|
|
env,
|
|
});
|
|
|
|
await firstProvider.callApi('PFQA_VERCEL_PROMPT_SENTINEL');
|
|
await secondProvider.callApi('PFQA_VERCEL_PROMPT_SENTINEL');
|
|
|
|
const firstKey = mockCache.get.mock.calls[0][0] as string;
|
|
const secondKey = mockCache.get.mock.calls[1][0] as string;
|
|
|
|
expect(firstKey).toBe(secondKey);
|
|
expect(firstKey).not.toContain('PFQA_VERCEL_ENV_API_KEY');
|
|
});
|
|
|
|
it('should separate cache keys for custom env var API key values without leaking them', async () => {
|
|
const { generateText } = await import('ai');
|
|
vi.mocked(isCacheEnabled).mockReturnValue(true);
|
|
vi.mocked(generateText).mockResolvedValue({
|
|
text: 'Fresh response',
|
|
usage: { promptTokens: 10, completionTokens: 20 },
|
|
finishReason: 'stop',
|
|
} as any);
|
|
|
|
const firstProvider = new VercelAiProvider('openai/gpt-4o-mini', {
|
|
config: { apiKeyEnvar: 'PFQA_VERCEL_CUSTOM_KEY' },
|
|
env: { PFQA_VERCEL_CUSTOM_KEY: 'PFQA_VERCEL_CUSTOM_KEY_A' } as any,
|
|
});
|
|
const secondProvider = new VercelAiProvider('openai/gpt-4o-mini', {
|
|
config: { apiKeyEnvar: 'PFQA_VERCEL_CUSTOM_KEY' },
|
|
env: { PFQA_VERCEL_CUSTOM_KEY: 'PFQA_VERCEL_CUSTOM_KEY_B' } as any,
|
|
});
|
|
|
|
await firstProvider.callApi('PFQA_VERCEL_PROMPT_SENTINEL');
|
|
await secondProvider.callApi('PFQA_VERCEL_PROMPT_SENTINEL');
|
|
|
|
const firstKey = mockCache.get.mock.calls[0][0] as string;
|
|
const secondKey = mockCache.get.mock.calls[1][0] as string;
|
|
|
|
expect(firstKey).not.toBe(secondKey);
|
|
expect(firstKey).not.toContain('PFQA_VERCEL_CUSTOM_KEY_A');
|
|
expect(secondKey).not.toContain('PFQA_VERCEL_CUSTOM_KEY_B');
|
|
});
|
|
|
|
it('should not cache error responses', async () => {
|
|
const { generateText } = await import('ai');
|
|
vi.mocked(isCacheEnabled).mockReturnValue(true);
|
|
vi.mocked(generateText).mockRejectedValueOnce(new Error('API error'));
|
|
|
|
const provider = new VercelAiProvider('openai/gpt-4o-mini');
|
|
await provider.callApi('Hello');
|
|
|
|
expect(mockCache.set).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should bypass cache when bustCache is true', async () => {
|
|
const { generateText } = await import('ai');
|
|
vi.mocked(isCacheEnabled).mockReturnValue(true);
|
|
mockCache.get.mockResolvedValueOnce(
|
|
JSON.stringify({
|
|
output: 'Cached response',
|
|
}),
|
|
);
|
|
vi.mocked(generateText).mockResolvedValueOnce({
|
|
text: 'Fresh response',
|
|
usage: { promptTokens: 10, completionTokens: 20 },
|
|
finishReason: 'stop',
|
|
} as any);
|
|
|
|
const provider = new VercelAiProvider('openai/gpt-4o-mini');
|
|
const result = await provider.callApi('Hello', { bustCache: true } as any);
|
|
|
|
expect(result.output).toBe('Fresh response');
|
|
expect(result.cached).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('callApi() - structured output', () => {
|
|
it('should return object response with schema', async () => {
|
|
const { generateObject } = await import('ai');
|
|
vi.mocked(generateObject).mockResolvedValueOnce({
|
|
object: { sentiment: 'positive', confidence: 0.95 },
|
|
usage: { promptTokens: 15, completionTokens: 25, totalTokens: 40 },
|
|
finishReason: 'stop',
|
|
} as any);
|
|
|
|
const provider = new VercelAiProvider('openai/gpt-4o', {
|
|
config: {
|
|
responseSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
sentiment: { type: 'string', enum: ['positive', 'negative', 'neutral'] },
|
|
confidence: { type: 'number' },
|
|
},
|
|
required: ['sentiment', 'confidence'],
|
|
},
|
|
},
|
|
});
|
|
const result = await provider.callApi('Analyze this text');
|
|
|
|
expect(result).toEqual({
|
|
output: { sentiment: 'positive', confidence: 0.95 },
|
|
tokenUsage: {
|
|
prompt: 15,
|
|
completion: 25,
|
|
total: 40,
|
|
numRequests: 1,
|
|
},
|
|
finishReason: 'stop',
|
|
});
|
|
});
|
|
|
|
it('should pass schema to generateObject', async () => {
|
|
const { generateObject } = await import('ai');
|
|
vi.mocked(generateObject).mockResolvedValueOnce({
|
|
object: { name: 'Test', value: 42 },
|
|
usage: { promptTokens: 10, completionTokens: 20 },
|
|
finishReason: 'stop',
|
|
} as any);
|
|
|
|
const testSchema = {
|
|
type: 'object',
|
|
properties: {
|
|
name: { type: 'string' },
|
|
value: { type: 'number' },
|
|
},
|
|
};
|
|
|
|
const provider = new VercelAiProvider('openai/gpt-4o', {
|
|
config: {
|
|
responseSchema: testSchema,
|
|
temperature: 0.5,
|
|
},
|
|
});
|
|
await provider.callApi('Generate data');
|
|
|
|
expect(vi.mocked(generateObject)).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
messages: [{ role: 'user', content: 'Generate data' }],
|
|
// OpenAI requires additionalProperties: false, so provider auto-adds it
|
|
schema: { ...testSchema, additionalProperties: false },
|
|
temperature: 0.5,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should handle structured output errors', async () => {
|
|
const { generateObject } = await import('ai');
|
|
vi.mocked(generateObject).mockRejectedValueOnce(new Error('Schema validation failed'));
|
|
|
|
const provider = new VercelAiProvider('openai/gpt-4o', {
|
|
config: {
|
|
responseSchema: {
|
|
type: 'object',
|
|
properties: { data: { type: 'string' } },
|
|
},
|
|
},
|
|
});
|
|
const result = await provider.callApi('Generate data');
|
|
|
|
expect(result).toEqual({
|
|
error: 'API call error: Schema validation failed',
|
|
});
|
|
});
|
|
|
|
it('should handle structured output timeout errors', async () => {
|
|
const { generateObject } = await import('ai');
|
|
const abortError = new Error('The operation was aborted');
|
|
abortError.name = 'AbortError';
|
|
vi.mocked(generateObject).mockRejectedValueOnce(abortError);
|
|
|
|
const provider = new VercelAiProvider('openai/gpt-4o', {
|
|
config: {
|
|
responseSchema: { type: 'object', properties: {} },
|
|
timeout: 8000,
|
|
},
|
|
});
|
|
const result = await provider.callApi('Generate data');
|
|
|
|
expect(result).toEqual({
|
|
error: 'Request timed out after 8000ms',
|
|
});
|
|
});
|
|
|
|
it('should prioritize structured output over streaming', async () => {
|
|
const { generateObject, streamText } = await import('ai');
|
|
vi.mocked(generateObject).mockResolvedValueOnce({
|
|
object: { result: 'structured' },
|
|
usage: { promptTokens: 10, completionTokens: 15 },
|
|
finishReason: 'stop',
|
|
} as any);
|
|
|
|
const provider = new VercelAiProvider('openai/gpt-4o', {
|
|
config: {
|
|
streaming: true,
|
|
responseSchema: { type: 'object', properties: { result: { type: 'string' } } },
|
|
},
|
|
});
|
|
const result = await provider.callApi('Test');
|
|
|
|
expect(vi.mocked(generateObject)).toHaveBeenCalled();
|
|
expect(vi.mocked(streamText)).not.toHaveBeenCalled();
|
|
expect(result.output).toEqual({ result: 'structured' });
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('VercelAiEmbeddingProvider', () => {
|
|
let mockCache: {
|
|
get: ReturnType<typeof vi.fn>;
|
|
set: ReturnType<typeof vi.fn>;
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
|
|
mockCache = {
|
|
get: vi.fn().mockResolvedValue(null),
|
|
set: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
vi.mocked(getCache).mockResolvedValue(mockCache as any);
|
|
vi.mocked(isCacheEnabled).mockReturnValue(false);
|
|
|
|
const { embed } = await import('ai');
|
|
vi.mocked(embed).mockReset();
|
|
});
|
|
|
|
describe('constructor', () => {
|
|
it('should create a provider with default options', () => {
|
|
const provider = new VercelAiEmbeddingProvider('openai/text-embedding-3-small');
|
|
expect(provider.modelName).toBe('openai/text-embedding-3-small');
|
|
expect(provider.config).toEqual({});
|
|
});
|
|
});
|
|
|
|
describe('id()', () => {
|
|
it('should return correct provider id', () => {
|
|
const provider = new VercelAiEmbeddingProvider('openai/text-embedding-3-small');
|
|
expect(provider.id()).toBe('vercel:embedding:openai/text-embedding-3-small');
|
|
});
|
|
});
|
|
|
|
describe('toString()', () => {
|
|
it('should return correct string representation', () => {
|
|
const provider = new VercelAiEmbeddingProvider('openai/text-embedding-3-small');
|
|
expect(provider.toString()).toBe(
|
|
'[Vercel AI Gateway Embedding Provider openai/text-embedding-3-small]',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('callApi()', () => {
|
|
it('should return error for callApi on embedding provider', async () => {
|
|
const provider = new VercelAiEmbeddingProvider('openai/text-embedding-3-small');
|
|
const result = await provider.callApi('test');
|
|
|
|
expect(result).toEqual({
|
|
error: 'Use callEmbeddingApi for embedding models',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('callEmbeddingApi()', () => {
|
|
it('should return embedding vector', async () => {
|
|
const { embed } = await import('ai');
|
|
vi.mocked(embed).mockResolvedValueOnce({
|
|
embedding: [0.1, 0.2, 0.3, 0.4, 0.5],
|
|
usage: { tokens: 10 },
|
|
} as any);
|
|
|
|
const provider = new VercelAiEmbeddingProvider('openai/text-embedding-3-small');
|
|
const result = await provider.callEmbeddingApi('Test text');
|
|
|
|
expect(result).toEqual({
|
|
embedding: [0.1, 0.2, 0.3, 0.4, 0.5],
|
|
tokenUsage: {
|
|
total: 10,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should handle API errors', async () => {
|
|
const { embed } = await import('ai');
|
|
vi.mocked(embed).mockRejectedValueOnce(new Error('Embedding API error'));
|
|
|
|
const provider = new VercelAiEmbeddingProvider('openai/text-embedding-3-small');
|
|
const result = await provider.callEmbeddingApi('Test text');
|
|
|
|
expect(result).toEqual({
|
|
error: 'API call error: Embedding API error',
|
|
});
|
|
});
|
|
|
|
it('should cache embedding responses', async () => {
|
|
const { embed } = await import('ai');
|
|
vi.mocked(isCacheEnabled).mockReturnValue(true);
|
|
vi.mocked(embed).mockResolvedValueOnce({
|
|
embedding: [0.1, 0.2, 0.3],
|
|
usage: { tokens: 5 },
|
|
} as any);
|
|
|
|
const input = 'PFQA_VERCEL_EMBEDDING_INPUT_SENTINEL';
|
|
const provider = new VercelAiEmbeddingProvider('openai/text-embedding-3-small');
|
|
await provider.callEmbeddingApi(input);
|
|
|
|
const cacheKey = mockCache.set.mock.calls[0][0] as string;
|
|
expect(cacheKey).toMatch(/^vercel:embedding:openai\/text-embedding-3-small:[a-f0-9]{64}$/);
|
|
expect(cacheKey).not.toContain(input);
|
|
expect(mockCache.get).toHaveBeenCalledWith(cacheKey);
|
|
expect(mockCache.set).toHaveBeenCalledWith(cacheKey, expect.any(String));
|
|
});
|
|
|
|
it('should include gateway identity in embedding cache keys without leaking secrets', async () => {
|
|
const { embed } = await import('ai');
|
|
vi.mocked(isCacheEnabled).mockReturnValue(true);
|
|
vi.mocked(embed).mockResolvedValue({
|
|
embedding: [0.1, 0.2, 0.3],
|
|
usage: { tokens: 5 },
|
|
} as any);
|
|
|
|
const input = 'PFQA_VERCEL_EMBEDDING_INPUT_SENTINEL';
|
|
const firstProvider = new VercelAiEmbeddingProvider('openai/text-embedding-3-small', {
|
|
config: {
|
|
apiKey: 'PFQA_VERCEL_EMBEDDING_API_KEY_A',
|
|
baseUrl: 'https://gateway.example.com/shared',
|
|
headers: { 'X-Tenant': 'PFQA_VERCEL_EMBEDDING_TENANT_A' },
|
|
},
|
|
});
|
|
const secondProvider = new VercelAiEmbeddingProvider('openai/text-embedding-3-small', {
|
|
config: {
|
|
apiKey: 'PFQA_VERCEL_EMBEDDING_API_KEY_B',
|
|
baseUrl: 'https://gateway.example.com/shared',
|
|
headers: { 'X-Tenant': 'PFQA_VERCEL_EMBEDDING_TENANT_B' },
|
|
},
|
|
});
|
|
|
|
await firstProvider.callEmbeddingApi(input);
|
|
await secondProvider.callEmbeddingApi(input);
|
|
|
|
const firstKey = mockCache.get.mock.calls[0][0] as string;
|
|
const secondKey = mockCache.get.mock.calls[1][0] as string;
|
|
|
|
expect(firstKey).not.toBe(secondKey);
|
|
expect(firstKey).not.toContain(input);
|
|
expect(firstKey).not.toContain('PFQA_VERCEL_EMBEDDING_API_KEY_A');
|
|
expect(firstKey).not.toContain('PFQA_VERCEL_EMBEDDING_TENANT_A');
|
|
expect(secondKey).not.toContain('PFQA_VERCEL_EMBEDDING_API_KEY_B');
|
|
expect(secondKey).not.toContain('PFQA_VERCEL_EMBEDDING_TENANT_B');
|
|
});
|
|
|
|
it('should separate embedding cache keys when only gateway baseUrl changes', async () => {
|
|
const { embed } = await import('ai');
|
|
vi.mocked(isCacheEnabled).mockReturnValue(true);
|
|
vi.mocked(embed).mockResolvedValue({
|
|
embedding: [0.1, 0.2, 0.3],
|
|
usage: { tokens: 5 },
|
|
} as any);
|
|
|
|
const input = 'PFQA_VERCEL_EMBEDDING_INPUT_SENTINEL';
|
|
const firstProvider = new VercelAiEmbeddingProvider('openai/text-embedding-3-small', {
|
|
config: {
|
|
apiKey: 'PFQA_VERCEL_EMBEDDING_SHARED_API_KEY',
|
|
baseUrl: 'https://gateway-a.example.com',
|
|
headers: { 'X-Tenant': 'PFQA_VERCEL_EMBEDDING_SHARED_TENANT' },
|
|
},
|
|
});
|
|
const secondProvider = new VercelAiEmbeddingProvider('openai/text-embedding-3-small', {
|
|
config: {
|
|
apiKey: 'PFQA_VERCEL_EMBEDDING_SHARED_API_KEY',
|
|
baseUrl: 'https://gateway-b.example.com',
|
|
headers: { 'X-Tenant': 'PFQA_VERCEL_EMBEDDING_SHARED_TENANT' },
|
|
},
|
|
});
|
|
|
|
await firstProvider.callEmbeddingApi(input);
|
|
await secondProvider.callEmbeddingApi(input);
|
|
|
|
const firstKey = mockCache.get.mock.calls[0][0] as string;
|
|
const secondKey = mockCache.get.mock.calls[1][0] as string;
|
|
|
|
expect(firstKey).not.toBe(secondKey);
|
|
expect(firstKey).not.toContain(input);
|
|
expect(firstKey).not.toContain('PFQA_VERCEL_EMBEDDING_SHARED_API_KEY');
|
|
expect(firstKey).not.toContain('PFQA_VERCEL_EMBEDDING_SHARED_TENANT');
|
|
expect(firstKey).not.toContain('gateway-a.example.com');
|
|
expect(secondKey).not.toContain('gateway-b.example.com');
|
|
});
|
|
|
|
it('should return cached embedding when available', async () => {
|
|
vi.mocked(isCacheEnabled).mockReturnValue(true);
|
|
mockCache.get.mockResolvedValueOnce(
|
|
JSON.stringify({
|
|
embedding: [0.5, 0.6, 0.7],
|
|
tokenUsage: { total: 8 },
|
|
}),
|
|
);
|
|
|
|
const input = 'PFQA_VERCEL_EMBEDDING_INPUT_SENTINEL';
|
|
const provider = new VercelAiEmbeddingProvider('openai/text-embedding-3-small');
|
|
const result = await provider.callEmbeddingApi(input);
|
|
|
|
expect(result).toEqual({
|
|
embedding: [0.5, 0.6, 0.7],
|
|
tokenUsage: { total: 8 },
|
|
cached: true,
|
|
});
|
|
const cacheKey = mockCache.get.mock.calls[0][0] as string;
|
|
expect(cacheKey).toMatch(/^vercel:embedding:openai\/text-embedding-3-small:[a-f0-9]{64}$/);
|
|
expect(cacheKey).not.toContain(input);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('createVercelProvider', () => {
|
|
it('should create text generation provider for standard path', () => {
|
|
const provider = createVercelProvider('vercel:openai/gpt-4o-mini');
|
|
expect(provider).toBeInstanceOf(VercelAiProvider);
|
|
expect(provider.id()).toBe('vercel:openai/gpt-4o-mini');
|
|
});
|
|
|
|
it('should create embedding provider for embedding path', () => {
|
|
const provider = createVercelProvider('vercel:embedding:openai/text-embedding-3-small');
|
|
expect(provider).toBeInstanceOf(VercelAiEmbeddingProvider);
|
|
expect(provider.id()).toBe('vercel:embedding:openai/text-embedding-3-small');
|
|
});
|
|
|
|
it('should pass options to created provider', () => {
|
|
const provider = createVercelProvider('vercel:openai/gpt-4o', {
|
|
config: { temperature: 0.5 },
|
|
}) as VercelAiProvider;
|
|
expect(provider.config.temperature).toBe(0.5);
|
|
});
|
|
});
|