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
788 lines
24 KiB
TypeScript
788 lines
24 KiB
TypeScript
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { clearCache, enableCache } from '../../src/cache';
|
|
import {
|
|
CloudflareGatewayAnthropicProvider,
|
|
type CloudflareGatewayConfig,
|
|
CloudflareGatewayOpenAiProvider,
|
|
createCloudflareGatewayProvider,
|
|
} from '../../src/providers/cloudflare-gateway';
|
|
import { loadApiProviders } from '../../src/providers/index';
|
|
import { mockProcessEnv } from '../util/utils';
|
|
|
|
import type { ProviderOptionsMap } from '../../src/types/index';
|
|
|
|
vi.mock('../../src/logger', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('../../src/logger')>();
|
|
return {
|
|
...actual,
|
|
default: {
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock('proxy-agent', async (importOriginal) => {
|
|
return {
|
|
...(await importOriginal()),
|
|
|
|
ProxyAgent: vi.fn().mockImplementation(function () {
|
|
return {};
|
|
}),
|
|
};
|
|
});
|
|
|
|
vi.mock('../../src/esm', async () => {
|
|
const actual = await vi.importActual<typeof import('../../src/esm')>('../../src/esm');
|
|
return {
|
|
...actual,
|
|
importModule: vi.fn(),
|
|
};
|
|
});
|
|
|
|
vi.mock('fs', async (importOriginal) => {
|
|
return {
|
|
...(await importOriginal()),
|
|
readFileSync: vi.fn(),
|
|
existsSync: vi.fn(),
|
|
mkdirSync: vi.fn(),
|
|
writeFileSync: vi.fn(),
|
|
};
|
|
});
|
|
|
|
vi.mock('glob', async (importOriginal) => {
|
|
return {
|
|
...(await importOriginal()),
|
|
globSync: vi.fn(),
|
|
};
|
|
});
|
|
|
|
vi.mock('../../src/database', async (importOriginal) => {
|
|
return {
|
|
...(await importOriginal()),
|
|
getDb: vi.fn(),
|
|
};
|
|
});
|
|
|
|
const mockFetch = vi.mocked(vi.fn());
|
|
(global as typeof globalThis).fetch = mockFetch as unknown as typeof fetch;
|
|
|
|
const defaultMockResponse = {
|
|
status: 200,
|
|
statusText: 'OK',
|
|
headers: {
|
|
get: vi.fn().mockReturnValue(null),
|
|
entries: vi.fn().mockReturnValue([]),
|
|
},
|
|
};
|
|
|
|
describe('CloudflareGateway Provider', () => {
|
|
beforeAll(() => {
|
|
enableCache();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.clearAllMocks();
|
|
await clearCache();
|
|
});
|
|
|
|
const minimumConfig: Required<
|
|
Pick<CloudflareGatewayConfig, 'accountId' | 'gatewayId' | 'apiKey'>
|
|
> = {
|
|
accountId: 'testAccountId',
|
|
gatewayId: 'testGatewayId',
|
|
apiKey: 'testApiKey',
|
|
};
|
|
|
|
const tokenUsageDefaultResponse = {
|
|
total: 50,
|
|
prompt: 25,
|
|
completion: 25,
|
|
numRequests: 1,
|
|
};
|
|
|
|
describe('createCloudflareGatewayProvider', () => {
|
|
it('should create an OpenAI provider for openai type', () => {
|
|
const provider = createCloudflareGatewayProvider('cloudflare-gateway:openai:gpt-4o', {
|
|
config: minimumConfig,
|
|
});
|
|
|
|
expect(provider).toBeInstanceOf(CloudflareGatewayOpenAiProvider);
|
|
expect(provider.id()).toBe('cloudflare-gateway:openai:gpt-4o');
|
|
});
|
|
|
|
it('should create an Anthropic provider for anthropic type', () => {
|
|
const provider = createCloudflareGatewayProvider(
|
|
'cloudflare-gateway:anthropic:claude-sonnet-4-20250514',
|
|
{
|
|
config: minimumConfig,
|
|
},
|
|
);
|
|
|
|
expect(provider).toBeInstanceOf(CloudflareGatewayAnthropicProvider);
|
|
expect(provider.id()).toBe('cloudflare-gateway:anthropic:claude-sonnet-4-20250514');
|
|
});
|
|
|
|
it('should create an OpenAI-compatible provider for groq', () => {
|
|
const provider = createCloudflareGatewayProvider(
|
|
'cloudflare-gateway:groq:llama-3.3-70b-versatile',
|
|
{
|
|
config: minimumConfig,
|
|
},
|
|
);
|
|
|
|
expect(provider).toBeInstanceOf(CloudflareGatewayOpenAiProvider);
|
|
expect(provider.id()).toBe('cloudflare-gateway:groq:llama-3.3-70b-versatile');
|
|
});
|
|
|
|
it('should throw error for invalid provider path format', () => {
|
|
expect(() => createCloudflareGatewayProvider('cloudflare-gateway:openai', {})).toThrow(
|
|
'Invalid cloudflare-gateway provider path',
|
|
);
|
|
});
|
|
|
|
it('should throw error for unsupported provider', () => {
|
|
expect(() =>
|
|
createCloudflareGatewayProvider('cloudflare-gateway:unsupported:model', {
|
|
config: minimumConfig,
|
|
}),
|
|
).toThrow('Unsupported Cloudflare AI Gateway provider');
|
|
});
|
|
|
|
it('should throw error when accountId is missing', () => {
|
|
expect(() =>
|
|
createCloudflareGatewayProvider('cloudflare-gateway:openai:gpt-4o', {
|
|
config: { gatewayId: 'test', apiKey: 'test' },
|
|
}),
|
|
).toThrow('Cloudflare account ID required');
|
|
});
|
|
|
|
it('should throw error when gatewayId is missing', () => {
|
|
expect(() =>
|
|
createCloudflareGatewayProvider('cloudflare-gateway:openai:gpt-4o', {
|
|
config: { accountId: 'test', apiKey: 'test' },
|
|
}),
|
|
).toThrow('Cloudflare AI Gateway ID required');
|
|
});
|
|
|
|
it('should handle model names with colons', () => {
|
|
const provider = createCloudflareGatewayProvider(
|
|
'cloudflare-gateway:openai:gpt-4o:with:colons',
|
|
{
|
|
config: minimumConfig,
|
|
},
|
|
);
|
|
|
|
expect(provider.id()).toBe('cloudflare-gateway:openai:gpt-4o:with:colons');
|
|
});
|
|
});
|
|
|
|
describe('CloudflareGatewayOpenAiProvider', () => {
|
|
it('should handle chat completion', async () => {
|
|
const provider = new CloudflareGatewayOpenAiProvider('openai', 'gpt-4o', {
|
|
config: minimumConfig,
|
|
});
|
|
|
|
const responsePayload = {
|
|
choices: [
|
|
{
|
|
message: {
|
|
content: 'Test response',
|
|
},
|
|
},
|
|
],
|
|
usage: {
|
|
total_tokens: 50,
|
|
prompt_tokens: 25,
|
|
completion_tokens: 25,
|
|
},
|
|
};
|
|
const mockResponse = {
|
|
...defaultMockResponse,
|
|
text: vi.fn().mockResolvedValue(JSON.stringify(responsePayload)),
|
|
ok: true,
|
|
};
|
|
|
|
mockFetch.mockResolvedValue(mockResponse);
|
|
const result = await provider.callApi('Test prompt');
|
|
|
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
expect(result.output).toBe('Test response');
|
|
expect(result.tokenUsage).toEqual(tokenUsageDefaultResponse);
|
|
});
|
|
|
|
it('should construct correct gateway URL', async () => {
|
|
const provider = new CloudflareGatewayOpenAiProvider('openai', 'gpt-4o', {
|
|
config: minimumConfig,
|
|
});
|
|
|
|
const responsePayload = {
|
|
choices: [{ message: { content: 'Test' } }],
|
|
usage: { total_tokens: 10, prompt_tokens: 5, completion_tokens: 5 },
|
|
};
|
|
const mockResponse = {
|
|
...defaultMockResponse,
|
|
text: vi.fn().mockResolvedValue(JSON.stringify(responsePayload)),
|
|
ok: true,
|
|
};
|
|
mockFetch.mockResolvedValue(mockResponse);
|
|
|
|
await provider.callApi('Test prompt');
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://gateway.ai.cloudflare.com/v1/testAccountId/testGatewayId/openai/chat/completions',
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it('should construct correct gateway URL for groq provider', async () => {
|
|
const provider = new CloudflareGatewayOpenAiProvider('groq', 'llama-3.3-70b-versatile', {
|
|
config: minimumConfig,
|
|
});
|
|
|
|
const responsePayload = {
|
|
choices: [{ message: { content: 'Test' } }],
|
|
usage: { total_tokens: 10, prompt_tokens: 5, completion_tokens: 5 },
|
|
};
|
|
const mockResponse = {
|
|
...defaultMockResponse,
|
|
text: vi.fn().mockResolvedValue(JSON.stringify(responsePayload)),
|
|
ok: true,
|
|
};
|
|
mockFetch.mockResolvedValue(mockResponse);
|
|
|
|
await provider.callApi('Test prompt');
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://gateway.ai.cloudflare.com/v1/testAccountId/testGatewayId/groq/chat/completions',
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it('should add cf-aig-authorization header when token is provided', async () => {
|
|
const provider = new CloudflareGatewayOpenAiProvider('openai', 'gpt-4o', {
|
|
config: {
|
|
...minimumConfig,
|
|
cfAigToken: 'test-token',
|
|
},
|
|
});
|
|
|
|
const responsePayload = {
|
|
choices: [{ message: { content: 'Test' } }],
|
|
usage: { total_tokens: 10, prompt_tokens: 5, completion_tokens: 5 },
|
|
};
|
|
const mockResponse = {
|
|
...defaultMockResponse,
|
|
text: vi.fn().mockResolvedValue(JSON.stringify(responsePayload)),
|
|
ok: true,
|
|
};
|
|
mockFetch.mockResolvedValue(mockResponse);
|
|
|
|
await provider.callApi('Test prompt');
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
expect.any(String),
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
'cf-aig-authorization': 'Bearer test-token',
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should return correct id()', () => {
|
|
const provider = new CloudflareGatewayOpenAiProvider('openai', 'gpt-4o', {
|
|
config: minimumConfig,
|
|
});
|
|
|
|
expect(provider.id()).toBe('cloudflare-gateway:openai:gpt-4o');
|
|
});
|
|
|
|
it('should return correct toString()', () => {
|
|
const provider = new CloudflareGatewayOpenAiProvider('openai', 'gpt-4o', {
|
|
config: minimumConfig,
|
|
});
|
|
|
|
expect(provider.toString()).toBe('[Cloudflare AI Gateway openai Provider gpt-4o]');
|
|
});
|
|
|
|
it('should not expose API key in toJSON()', () => {
|
|
const provider = new CloudflareGatewayOpenAiProvider('openai', 'gpt-4o', {
|
|
config: minimumConfig,
|
|
});
|
|
|
|
const json = provider.toJSON();
|
|
expect(json.config.apiKey).toBeUndefined();
|
|
expect(json.provider).toBe('cloudflare-gateway');
|
|
expect(json.underlyingProvider).toBe('openai');
|
|
expect(json.model).toBe('gpt-4o');
|
|
});
|
|
});
|
|
|
|
describe('CloudflareGatewayAnthropicProvider', () => {
|
|
it('should return correct id()', () => {
|
|
const provider = new CloudflareGatewayAnthropicProvider('claude-sonnet-4-20250514', {
|
|
config: minimumConfig,
|
|
});
|
|
|
|
expect(provider.id()).toBe('cloudflare-gateway:anthropic:claude-sonnet-4-20250514');
|
|
});
|
|
|
|
it('should return correct toString()', () => {
|
|
const provider = new CloudflareGatewayAnthropicProvider('claude-sonnet-4-20250514', {
|
|
config: minimumConfig,
|
|
});
|
|
|
|
expect(provider.toString()).toBe(
|
|
'[Cloudflare AI Gateway Anthropic Provider claude-sonnet-4-20250514]',
|
|
);
|
|
});
|
|
|
|
it('should not expose API key in toJSON()', () => {
|
|
const provider = new CloudflareGatewayAnthropicProvider('claude-sonnet-4-20250514', {
|
|
config: minimumConfig,
|
|
});
|
|
|
|
const json = provider.toJSON();
|
|
expect(json.config.apiKey).toBeUndefined();
|
|
expect(json.provider).toBe('cloudflare-gateway');
|
|
expect(json.underlyingProvider).toBe('anthropic');
|
|
expect(json.model).toBe('claude-sonnet-4-20250514');
|
|
});
|
|
|
|
it('should construct correct gateway URL for Anthropic', async () => {
|
|
const provider = new CloudflareGatewayAnthropicProvider('claude-sonnet-4-20250514', {
|
|
config: minimumConfig,
|
|
});
|
|
|
|
const responsePayload = {
|
|
id: 'msg_123',
|
|
type: 'message',
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text: 'Test' }],
|
|
model: 'claude-sonnet-4-20250514',
|
|
stop_reason: 'end_turn',
|
|
usage: { input_tokens: 5, output_tokens: 5 },
|
|
};
|
|
const mockResponse = {
|
|
...defaultMockResponse,
|
|
text: vi.fn().mockResolvedValue(JSON.stringify(responsePayload)),
|
|
ok: true,
|
|
};
|
|
mockFetch.mockResolvedValue(mockResponse);
|
|
|
|
await provider.callApi('Test prompt');
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://gateway.ai.cloudflare.com/v1/testAccountId/testGatewayId/anthropic/v1/messages',
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Error Handling', () => {
|
|
it('should handle API error responses (4xx)', async () => {
|
|
const provider = new CloudflareGatewayOpenAiProvider('openai', 'gpt-4o', {
|
|
config: minimumConfig,
|
|
});
|
|
|
|
const errorPayload = {
|
|
error: {
|
|
message: 'Invalid API key',
|
|
type: 'invalid_request_error',
|
|
code: 'invalid_api_key',
|
|
},
|
|
};
|
|
const mockResponse = {
|
|
...defaultMockResponse,
|
|
status: 401,
|
|
statusText: 'Unauthorized',
|
|
ok: false,
|
|
text: vi.fn().mockResolvedValue(JSON.stringify(errorPayload)),
|
|
};
|
|
|
|
mockFetch.mockResolvedValue(mockResponse);
|
|
const result = await provider.callApi('Test prompt');
|
|
|
|
expect(result.error).toBeDefined();
|
|
expect(result.error).toContain('401');
|
|
});
|
|
});
|
|
|
|
describe('Config Passthrough', () => {
|
|
it('should pass through temperature and max_tokens in request body', async () => {
|
|
const provider = new CloudflareGatewayOpenAiProvider('openai', 'gpt-4o', {
|
|
config: {
|
|
...minimumConfig,
|
|
temperature: 0.8,
|
|
max_tokens: 500,
|
|
},
|
|
});
|
|
|
|
const responsePayload = {
|
|
choices: [{ message: { content: 'Test' } }],
|
|
usage: { total_tokens: 10, prompt_tokens: 5, completion_tokens: 5 },
|
|
};
|
|
const mockResponse = {
|
|
...defaultMockResponse,
|
|
text: vi.fn().mockResolvedValue(JSON.stringify(responsePayload)),
|
|
ok: true,
|
|
};
|
|
mockFetch.mockResolvedValue(mockResponse);
|
|
|
|
await provider.callApi('Test prompt');
|
|
|
|
const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
expect(requestBody.temperature).toBe(0.8);
|
|
expect(requestBody.max_tokens).toBe(500);
|
|
});
|
|
|
|
it('should merge custom headers with gateway headers', async () => {
|
|
const provider = new CloudflareGatewayOpenAiProvider('openai', 'gpt-4o', {
|
|
config: {
|
|
...minimumConfig,
|
|
cfAigToken: 'gateway-token',
|
|
headers: { 'X-Custom-Header': 'custom-value' },
|
|
},
|
|
});
|
|
|
|
const responsePayload = {
|
|
choices: [{ message: { content: 'Test' } }],
|
|
usage: { total_tokens: 10, prompt_tokens: 5, completion_tokens: 5 },
|
|
};
|
|
const mockResponse = {
|
|
...defaultMockResponse,
|
|
text: vi.fn().mockResolvedValue(JSON.stringify(responsePayload)),
|
|
ok: true,
|
|
};
|
|
mockFetch.mockResolvedValue(mockResponse);
|
|
|
|
await provider.callApi('Test prompt');
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
expect.any(String),
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
'cf-aig-authorization': 'Bearer gateway-token',
|
|
'X-Custom-Header': 'custom-value',
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Provider Loading via loadApiProviders', () => {
|
|
it('should load cloudflare-gateway:openai provider via loadApiProviders', async () => {
|
|
const rawProviderConfigs: ProviderOptionsMap[] = [
|
|
{
|
|
'cloudflare-gateway:openai:gpt-4o': {
|
|
config: minimumConfig,
|
|
},
|
|
},
|
|
];
|
|
|
|
const providers = await loadApiProviders(rawProviderConfigs);
|
|
expect(providers).toHaveLength(1);
|
|
expect(providers[0].id()).toBe('cloudflare-gateway:openai:gpt-4o');
|
|
expect(providers[0].toString()).toContain('Cloudflare AI Gateway');
|
|
});
|
|
|
|
it('should load cloudflare-gateway:anthropic provider via loadApiProviders', async () => {
|
|
const rawProviderConfigs: ProviderOptionsMap[] = [
|
|
{
|
|
'cloudflare-gateway:anthropic:claude-sonnet-4-20250514': {
|
|
config: minimumConfig,
|
|
},
|
|
},
|
|
];
|
|
|
|
const providers = await loadApiProviders(rawProviderConfigs);
|
|
expect(providers).toHaveLength(1);
|
|
expect(providers[0].id()).toBe('cloudflare-gateway:anthropic:claude-sonnet-4-20250514');
|
|
expect(providers[0].toString()).toContain('Cloudflare AI Gateway');
|
|
});
|
|
|
|
it('should load multiple cloudflare-gateway providers', async () => {
|
|
const rawProviderConfigs: ProviderOptionsMap[] = [
|
|
{
|
|
'cloudflare-gateway:openai:gpt-4o': {
|
|
config: minimumConfig,
|
|
},
|
|
},
|
|
{
|
|
'cloudflare-gateway:anthropic:claude-sonnet-4-20250514': {
|
|
config: minimumConfig,
|
|
},
|
|
},
|
|
{
|
|
'cloudflare-gateway:groq:llama-3.3-70b-versatile': {
|
|
config: minimumConfig,
|
|
},
|
|
},
|
|
];
|
|
|
|
const providers = await loadApiProviders(rawProviderConfigs);
|
|
expect(providers).toHaveLength(3);
|
|
expect(providers[0].id()).toBe('cloudflare-gateway:openai:gpt-4o');
|
|
expect(providers[1].id()).toBe('cloudflare-gateway:anthropic:claude-sonnet-4-20250514');
|
|
expect(providers[2].id()).toBe('cloudflare-gateway:groq:llama-3.3-70b-versatile');
|
|
});
|
|
});
|
|
|
|
describe('Supported Providers', () => {
|
|
// Note: bedrock is NOT supported because it requires AWS request signing
|
|
// azure-openai and workers-ai have special URL handling but are supported
|
|
const supportedProviders = [
|
|
'openai',
|
|
'anthropic',
|
|
'groq',
|
|
'perplexity-ai',
|
|
'google-ai-studio',
|
|
'mistral',
|
|
'cohere',
|
|
'azure-openai',
|
|
'workers-ai',
|
|
'huggingface',
|
|
'replicate',
|
|
'grok',
|
|
];
|
|
|
|
it.each(supportedProviders)('should support %s provider', (providerName) => {
|
|
// azure-openai and workers-ai need extra config
|
|
const extraConfig =
|
|
providerName === 'azure-openai'
|
|
? { resourceName: 'test-resource', deploymentName: 'test-deployment' }
|
|
: {};
|
|
|
|
const provider = createCloudflareGatewayProvider(
|
|
`cloudflare-gateway:${providerName}:test-model`,
|
|
{
|
|
config: { ...minimumConfig, ...extraConfig },
|
|
},
|
|
);
|
|
|
|
expect(provider.id()).toBe(`cloudflare-gateway:${providerName}:test-model`);
|
|
});
|
|
|
|
it('should not support bedrock provider', () => {
|
|
expect(() =>
|
|
createCloudflareGatewayProvider('cloudflare-gateway:bedrock:anthropic.claude-v2', {
|
|
config: minimumConfig,
|
|
}),
|
|
).toThrow('Unsupported Cloudflare AI Gateway provider');
|
|
});
|
|
});
|
|
|
|
describe('Environment Variable Support', () => {
|
|
const originalEnv = { ...process.env };
|
|
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
mockProcessEnv({ ...originalEnv }, { clear: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
mockProcessEnv(originalEnv, { clear: true });
|
|
});
|
|
|
|
it('should use environment variables for account ID and gateway ID', () => {
|
|
mockProcessEnv({ CLOUDFLARE_ACCOUNT_ID: 'env-account-id' });
|
|
mockProcessEnv({ CLOUDFLARE_GATEWAY_ID: 'env-gateway-id' });
|
|
|
|
const provider = createCloudflareGatewayProvider('cloudflare-gateway:openai:gpt-4o', {
|
|
config: { apiKey: 'test-key' },
|
|
});
|
|
|
|
expect(provider.id()).toBe('cloudflare-gateway:openai:gpt-4o');
|
|
});
|
|
|
|
it('should prefer config values over environment variables', () => {
|
|
mockProcessEnv({ CLOUDFLARE_ACCOUNT_ID: 'env-account-id' });
|
|
mockProcessEnv({ CLOUDFLARE_GATEWAY_ID: 'env-gateway-id' });
|
|
|
|
const provider = new CloudflareGatewayOpenAiProvider('openai', 'gpt-4o', {
|
|
config: {
|
|
accountId: 'config-account-id',
|
|
gatewayId: 'config-gateway-id',
|
|
apiKey: 'test-key',
|
|
},
|
|
});
|
|
|
|
expect(provider.id()).toBe('cloudflare-gateway:openai:gpt-4o');
|
|
});
|
|
|
|
it('should support custom environment variable names', () => {
|
|
mockProcessEnv({ CUSTOM_ACCOUNT: 'custom-account-id' });
|
|
mockProcessEnv({ CUSTOM_GATEWAY: 'custom-gateway-id' });
|
|
|
|
const provider = createCloudflareGatewayProvider('cloudflare-gateway:openai:gpt-4o', {
|
|
config: {
|
|
accountIdEnvar: 'CUSTOM_ACCOUNT',
|
|
gatewayIdEnvar: 'CUSTOM_GATEWAY',
|
|
apiKey: 'test-key',
|
|
},
|
|
});
|
|
|
|
expect(provider.id()).toBe('cloudflare-gateway:openai:gpt-4o');
|
|
});
|
|
});
|
|
|
|
describe('Azure OpenAI Provider', () => {
|
|
it('should require resourceName for azure-openai', () => {
|
|
expect(() =>
|
|
createCloudflareGatewayProvider('cloudflare-gateway:azure-openai:gpt-4', {
|
|
config: {
|
|
...minimumConfig,
|
|
deploymentName: 'my-deployment',
|
|
},
|
|
}),
|
|
).toThrow('Azure OpenAI requires resourceName');
|
|
});
|
|
|
|
it('should require deploymentName for azure-openai', () => {
|
|
expect(() =>
|
|
createCloudflareGatewayProvider('cloudflare-gateway:azure-openai:gpt-4', {
|
|
config: {
|
|
...minimumConfig,
|
|
resourceName: 'my-resource',
|
|
},
|
|
}),
|
|
).toThrow('Azure OpenAI requires deploymentName');
|
|
});
|
|
|
|
it('should require API key for azure-openai', () => {
|
|
// Clear the environment variable to ensure validation triggers
|
|
const originalAzureKey = process.env.AZURE_OPENAI_API_KEY;
|
|
mockProcessEnv({ AZURE_OPENAI_API_KEY: undefined });
|
|
|
|
try {
|
|
expect(() =>
|
|
createCloudflareGatewayProvider('cloudflare-gateway:azure-openai:gpt-4', {
|
|
config: {
|
|
accountId: 'testAccountId',
|
|
gatewayId: 'testGatewayId',
|
|
resourceName: 'my-resource',
|
|
deploymentName: 'my-deployment',
|
|
// No apiKey provided
|
|
},
|
|
}),
|
|
).toThrow('Azure OpenAI API key is required');
|
|
} finally {
|
|
// Restore the environment variable
|
|
if (originalAzureKey !== undefined) {
|
|
mockProcessEnv({ AZURE_OPENAI_API_KEY: originalAzureKey });
|
|
}
|
|
}
|
|
});
|
|
|
|
it('should construct correct URL with resourceName and deploymentName', async () => {
|
|
const provider = new CloudflareGatewayOpenAiProvider('azure-openai', 'gpt-4', {
|
|
config: {
|
|
...minimumConfig,
|
|
resourceName: 'my-resource',
|
|
deploymentName: 'my-deployment',
|
|
},
|
|
});
|
|
|
|
const responsePayload = {
|
|
choices: [{ message: { content: 'Test' } }],
|
|
usage: { total_tokens: 10, prompt_tokens: 5, completion_tokens: 5 },
|
|
};
|
|
const mockResponse = {
|
|
...defaultMockResponse,
|
|
text: vi.fn().mockResolvedValue(JSON.stringify(responsePayload)),
|
|
ok: true,
|
|
};
|
|
mockFetch.mockResolvedValue(mockResponse);
|
|
|
|
await provider.callApi('Test prompt');
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
expect.stringContaining(
|
|
'https://gateway.ai.cloudflare.com/v1/testAccountId/testGatewayId/azure-openai/my-resource/my-deployment',
|
|
),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it('should include api-version query parameter', async () => {
|
|
const provider = new CloudflareGatewayOpenAiProvider('azure-openai', 'gpt-4', {
|
|
config: {
|
|
...minimumConfig,
|
|
resourceName: 'my-resource',
|
|
deploymentName: 'my-deployment',
|
|
apiVersion: '2024-06-01',
|
|
},
|
|
});
|
|
|
|
const responsePayload = {
|
|
choices: [{ message: { content: 'Test' } }],
|
|
usage: { total_tokens: 10, prompt_tokens: 5, completion_tokens: 5 },
|
|
};
|
|
const mockResponse = {
|
|
...defaultMockResponse,
|
|
text: vi.fn().mockResolvedValue(JSON.stringify(responsePayload)),
|
|
ok: true,
|
|
};
|
|
mockFetch.mockResolvedValue(mockResponse);
|
|
|
|
await provider.callApi('Test prompt');
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
expect.stringContaining('api-version=2024-06-01'),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Workers AI Provider', () => {
|
|
it('should construct correct URL with model in path', async () => {
|
|
const provider = new CloudflareGatewayOpenAiProvider(
|
|
'workers-ai',
|
|
'@cf/meta/llama-3.1-8b-instruct',
|
|
{
|
|
config: minimumConfig,
|
|
},
|
|
);
|
|
|
|
const responsePayload = {
|
|
choices: [{ message: { content: 'Test' } }],
|
|
usage: { total_tokens: 10, prompt_tokens: 5, completion_tokens: 5 },
|
|
};
|
|
const mockResponse = {
|
|
...defaultMockResponse,
|
|
text: vi.fn().mockResolvedValue(JSON.stringify(responsePayload)),
|
|
ok: true,
|
|
};
|
|
mockFetch.mockResolvedValue(mockResponse);
|
|
|
|
await provider.callApi('Test prompt');
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
expect.stringContaining(
|
|
'https://gateway.ai.cloudflare.com/v1/testAccountId/testGatewayId/workers-ai/@cf/meta/llama-3.1-8b-instruct',
|
|
),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it('should return correct id for workers-ai provider', () => {
|
|
const provider = new CloudflareGatewayOpenAiProvider(
|
|
'workers-ai',
|
|
'@cf/meta/llama-3.1-8b-instruct',
|
|
{
|
|
config: minimumConfig,
|
|
},
|
|
);
|
|
|
|
expect(provider.id()).toBe('cloudflare-gateway:workers-ai:@cf/meta/llama-3.1-8b-instruct');
|
|
});
|
|
});
|
|
});
|