Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

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);
});
});