0d3cb498a3
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
360 lines
11 KiB
TypeScript
360 lines
11 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { ElevenLabsClient } from '../../../src/providers/elevenlabs/client';
|
|
import {
|
|
ElevenLabsAPIError,
|
|
ElevenLabsAuthError,
|
|
ElevenLabsRateLimitError,
|
|
} from '../../../src/providers/elevenlabs/errors';
|
|
import { fetchWithProxy } from '../../../src/util/fetch/index';
|
|
|
|
vi.mock('../../../src/util/fetch/index.ts');
|
|
vi.mock('../../../src/logger', () => ({
|
|
default: {
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
const mockFetch = vi.mocked(fetchWithProxy);
|
|
|
|
describe('ElevenLabsClient', () => {
|
|
let client: ElevenLabsClient;
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
mockFetch.mockReset();
|
|
client = new ElevenLabsClient({
|
|
apiKey: 'test-api-key',
|
|
timeout: 5000,
|
|
retries: 2,
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
describe('constructor', () => {
|
|
it('should initialize with correct defaults', () => {
|
|
const defaultClient = new ElevenLabsClient({ apiKey: 'test-key' });
|
|
expect(defaultClient).toBeDefined();
|
|
});
|
|
|
|
it('should use custom base URL if provided', () => {
|
|
const customClient = new ElevenLabsClient({
|
|
apiKey: 'test-key',
|
|
baseUrl: 'https://custom.api.com/v1',
|
|
});
|
|
expect(customClient).toBeDefined();
|
|
});
|
|
|
|
it('should preserve explicit zero retries', () => {
|
|
const zeroRetryClient = new ElevenLabsClient({ apiKey: 'test-key', retries: 0 });
|
|
|
|
expect((zeroRetryClient as any).retries).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('post', () => {
|
|
it('should make successful POST request with JSON response', async () => {
|
|
const mockResponse = { success: true, data: 'test' };
|
|
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
headers: new Headers({ 'content-type': 'application/json' }),
|
|
json: async () => mockResponse,
|
|
} as Response);
|
|
|
|
const result = await client.post<{ success: boolean; data: string }>('/test', {
|
|
foo: 'bar',
|
|
});
|
|
|
|
expect(result).toEqual(mockResponse);
|
|
const [url, options] = mockFetch.mock.calls[0];
|
|
const headers = new Headers(options?.headers);
|
|
|
|
expect(url).toBe('https://api.elevenlabs.io/v1/test');
|
|
expect(options?.method).toBe('POST');
|
|
expect(options?.body).toBe(JSON.stringify({ foo: 'bar' }));
|
|
expect(options?.headers).not.toBeInstanceOf(Headers);
|
|
expect(headers.get('xi-api-key')).toBe('test-api-key');
|
|
expect(headers.get('Content-Type')).toBe('application/json');
|
|
});
|
|
|
|
it('should normalize caller Headers to a plain object before fetchWithProxy', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
headers: new Headers({ 'content-type': 'application/json' }),
|
|
json: async () => ({ success: true }),
|
|
} as Response);
|
|
|
|
await client.post<{ success: boolean }>(
|
|
'/test',
|
|
{ foo: 'bar' },
|
|
{ headers: new Headers({ 'Idempotency-Key': 'request-123' }) },
|
|
);
|
|
|
|
const [, options] = mockFetch.mock.calls[0];
|
|
expect(options?.headers).toEqual({
|
|
'idempotency-key': 'request-123',
|
|
'xi-api-key': 'test-api-key',
|
|
'content-type': 'application/json',
|
|
});
|
|
});
|
|
|
|
it('should handle binary response', async () => {
|
|
const mockBuffer = new ArrayBuffer(100);
|
|
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
headers: new Headers({ 'content-type': 'audio/mpeg' }),
|
|
arrayBuffer: async () => mockBuffer,
|
|
} as Response);
|
|
|
|
const result = await client.post<ArrayBuffer>('/text-to-speech/voice-id', { text: 'test' });
|
|
|
|
expect(result).toEqual(mockBuffer);
|
|
});
|
|
|
|
it('should throw ElevenLabsAuthError on 401', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 401,
|
|
headers: new Headers(),
|
|
text: async () => JSON.stringify({ message: 'Unauthorized' }),
|
|
} as Response);
|
|
|
|
await expect(client.post('/test', {})).rejects.toThrow(ElevenLabsAuthError);
|
|
});
|
|
|
|
it('should throw ElevenLabsRateLimitError on 429', async () => {
|
|
const rateLimitResponse = {
|
|
ok: false,
|
|
status: 429,
|
|
headers: new Headers(),
|
|
text: async () => JSON.stringify({ message: 'Rate limited' }),
|
|
} as Response;
|
|
mockFetch.mockResolvedValue(rateLimitResponse);
|
|
|
|
const promise = client.post('/test', {}).catch((e) => e);
|
|
await vi.runAllTimersAsync();
|
|
const error = await promise;
|
|
expect(error).toBeInstanceOf(ElevenLabsRateLimitError);
|
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should make one attempt by default on network error', async () => {
|
|
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
|
|
|
await expect(client.post<{ success: boolean }>('/test', {})).rejects.toThrow('Network error');
|
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should make one attempt when retries is set to 0', async () => {
|
|
const zeroRetryClient = new ElevenLabsClient({
|
|
apiKey: 'test-api-key',
|
|
timeout: 5000,
|
|
retries: 0,
|
|
});
|
|
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
|
|
|
await expect(zeroRetryClient.post('/test', {})).rejects.toThrow('Network error');
|
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should retry non-idempotent POSTs only when explicitly allowed', async () => {
|
|
mockFetch
|
|
.mockRejectedValueOnce(new Error('Network error'))
|
|
.mockRejectedValueOnce(new Error('Network error'))
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
headers: new Headers({ 'content-type': 'application/json' }),
|
|
json: async () => ({ success: true }),
|
|
} as Response);
|
|
|
|
const promise = client.post<{ success: boolean }>(
|
|
'/test',
|
|
{},
|
|
{ allowRetriesForNonIdempotent: true },
|
|
);
|
|
await vi.runAllTimersAsync();
|
|
const result = await promise;
|
|
|
|
expect(result).toEqual({ success: true });
|
|
expect(mockFetch).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('should retry POSTs when an idempotency-key header is present', async () => {
|
|
mockFetch.mockRejectedValueOnce(new Error('Network error')).mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
headers: new Headers({ 'content-type': 'application/json' }),
|
|
json: async () => ({ success: true }),
|
|
} as Response);
|
|
|
|
const promise = client.post<{ success: boolean }>(
|
|
'/test',
|
|
{},
|
|
{ headers: { 'Idempotency-Key': 'req-123' } },
|
|
);
|
|
await vi.runAllTimersAsync();
|
|
const result = await promise;
|
|
|
|
expect(result).toEqual({ success: true });
|
|
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('should throw ElevenLabsAPIError on 500 without retrying', async () => {
|
|
const errorResponse = {
|
|
ok: false,
|
|
status: 500,
|
|
headers: new Headers(),
|
|
text: async () => JSON.stringify({ message: 'Internal server error' }),
|
|
} as Response;
|
|
mockFetch.mockResolvedValue(errorResponse);
|
|
|
|
const promise = client.post('/test', {}).catch((e) => e);
|
|
await vi.runAllTimersAsync();
|
|
const error = await promise;
|
|
expect(error).toBeInstanceOf(ElevenLabsAPIError);
|
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('get', () => {
|
|
it('should make successful GET request', async () => {
|
|
const mockResponse = { voice_id: 'test-voice', name: 'Test Voice' };
|
|
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
headers: new Headers({ 'content-type': 'application/json' }),
|
|
json: async () => mockResponse,
|
|
} as Response);
|
|
|
|
const result = await client.get<{ voice_id: string; name: string }>('/voices/test-voice');
|
|
|
|
expect(result).toEqual(mockResponse);
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://api.elevenlabs.io/v1/voices/test-voice',
|
|
expect.objectContaining({
|
|
method: 'GET',
|
|
headers: expect.objectContaining({
|
|
'xi-api-key': 'test-api-key',
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should normalize caller Headers to a plain object for GET requests', async () => {
|
|
const mockResponse = { voice_id: 'test-voice', name: 'Test Voice' };
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
headers: new Headers({ 'content-type': 'application/json' }),
|
|
json: async () => mockResponse,
|
|
} as Response);
|
|
|
|
await client.get('/voices/test-voice', {
|
|
headers: new Headers({ 'Idempotency-Key': 'request-123' }),
|
|
});
|
|
|
|
const [, options] = mockFetch.mock.calls[0];
|
|
expect(options?.headers).toEqual({
|
|
'idempotency-key': 'request-123',
|
|
'xi-api-key': 'test-api-key',
|
|
});
|
|
});
|
|
|
|
it('should prefer client xi-api-key when GET caller headers include mixed-case api key', async () => {
|
|
const mockResponse = { voice_id: 'test-voice', name: 'Test Voice' };
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
headers: new Headers({ 'content-type': 'application/json' }),
|
|
json: async () => mockResponse,
|
|
} as Response);
|
|
|
|
await client.get('/voices/test-voice', {
|
|
headers: {
|
|
'Idempotency-Key': 'request-123',
|
|
'Xi-Api-Key': 'caller-key',
|
|
},
|
|
});
|
|
|
|
const [, options] = mockFetch.mock.calls[0];
|
|
expect(options?.headers).toEqual({
|
|
'idempotency-key': 'request-123',
|
|
'xi-api-key': 'test-api-key',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('delete', () => {
|
|
it('should make successful DELETE request', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 204,
|
|
headers: new Headers(),
|
|
} as Response);
|
|
|
|
await expect(client.delete('/agents/test-agent')).resolves.not.toThrow();
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://api.elevenlabs.io/v1/agents/test-agent',
|
|
expect.objectContaining({
|
|
method: 'DELETE',
|
|
headers: expect.objectContaining({
|
|
'xi-api-key': 'test-api-key',
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should normalize caller Headers to a plain object for DELETE requests', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 204,
|
|
headers: new Headers(),
|
|
} as Response);
|
|
|
|
await client.delete('/agents/test-agent', {
|
|
headers: new Headers({ 'Idempotency-Key': 'request-123' }),
|
|
});
|
|
|
|
const [, options] = mockFetch.mock.calls[0];
|
|
expect(options?.headers).toEqual({
|
|
'idempotency-key': 'request-123',
|
|
'xi-api-key': 'test-api-key',
|
|
});
|
|
});
|
|
|
|
it('should prefer client xi-api-key when DELETE caller Headers include api key', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 204,
|
|
headers: new Headers(),
|
|
} as Response);
|
|
|
|
await client.delete('/agents/test-agent', {
|
|
headers: new Headers({
|
|
'Idempotency-Key': 'request-123',
|
|
'xi-api-key': 'caller-key',
|
|
}),
|
|
});
|
|
|
|
const [, options] = mockFetch.mock.calls[0];
|
|
expect(options?.headers).toEqual({
|
|
'idempotency-key': 'request-123',
|
|
'xi-api-key': 'test-api-key',
|
|
});
|
|
});
|
|
});
|
|
});
|