Skip to content

Commit 8f68bbd

Browse files
committed
feat(voice): all existing providers implement HealthyProvider
Adds priority, capabilities, and healthCheck() to: - DeepgramStreamingSTT (priority 10, realtime) - ElevenLabsStreamingSTT (priority 20, near-realtime chunked REST) - ElevenLabsStreamingTTS (priority 10, realtime) - OpenAIRealtimeTTS (priority 20, premium) - ElevenLabsBatchTTS (priority 80, batch) - OpenAIBatchTTS (priority 90, cheap batch) Each health check hits a lightweight introspection endpoint (/v1/projects for Deepgram, /v1/user for ElevenLabs, /v1/models for OpenAI) with a 1s timeout. The healthProbe field is injectable so tests are deterministic without hitting real networks. No behaviour change for existing callers — providerId / isStreaming / startSession() are untouched; only new optional fields added. Task 7/17.
1 parent ebd2c8a commit 8f68bbd

7 files changed

Lines changed: 495 additions & 6 deletions

File tree

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { DeepgramStreamingSTT } from '../providers/DeepgramStreamingSTT.js';
3+
import { ElevenLabsStreamingSTT } from '../providers/ElevenLabsStreamingSTT.js';
4+
import { ElevenLabsStreamingTTS } from '../providers/ElevenLabsStreamingTTS.js';
5+
import { OpenAIRealtimeTTS } from '../providers/OpenAIRealtimeTTS.js';
6+
import { ElevenLabsBatchTTS } from '../providers/ElevenLabsBatchTTS.js';
7+
import { OpenAIBatchTTS } from '../providers/OpenAIBatchTTS.js';
8+
9+
describe('provider HealthyProvider implementations', () => {
10+
it('Deepgram exposes capabilities + priority', () => {
11+
const p = new DeepgramStreamingSTT({ apiKey: 'test' });
12+
expect(p.providerId).toBe('deepgram-streaming');
13+
expect(p.priority).toBeGreaterThanOrEqual(0);
14+
expect(p.capabilities.streaming).toBe(true);
15+
expect(p.capabilities.latencyClass).toBe('realtime');
16+
});
17+
18+
it('ElevenLabs STT reports near-realtime latency (chunked REST)', () => {
19+
const p = new ElevenLabsStreamingSTT({ apiKey: 'test' });
20+
expect(p.providerId).toBe('elevenlabs-streaming-stt');
21+
expect(p.capabilities.latencyClass).toBe('near-realtime');
22+
});
23+
24+
it('ElevenLabs streaming TTS capabilities', () => {
25+
const p = new ElevenLabsStreamingTTS({ apiKey: 'test' });
26+
expect(p.providerId).toBe('elevenlabs-streaming');
27+
expect(p.capabilities.streaming).toBe(true);
28+
});
29+
30+
it('OpenAI Realtime TTS capabilities', () => {
31+
const p = new OpenAIRealtimeTTS({ apiKey: 'test' });
32+
expect(p.providerId).toBe('openai-realtime');
33+
expect(p.capabilities.streaming).toBe(true);
34+
});
35+
36+
it('ElevenLabs Batch TTS is non-streaming', () => {
37+
const p = new ElevenLabsBatchTTS({ apiKey: 'test' });
38+
expect(p.providerId).toBe('elevenlabs-batch');
39+
expect(p.capabilities.streaming).toBe(false);
40+
expect(p.capabilities.latencyClass).toBe('batch');
41+
});
42+
43+
it('OpenAI Batch TTS is non-streaming', () => {
44+
const p = new OpenAIBatchTTS({ apiKey: 'test' });
45+
expect(p.providerId).toMatch(/^openai-/);
46+
expect(p.capabilities.streaming).toBe(false);
47+
expect(p.capabilities.latencyClass).toBe('batch');
48+
});
49+
50+
it('healthCheck returns auth failure on 401 from probe', async () => {
51+
const p = new ElevenLabsStreamingTTS({
52+
apiKey: 'bogus',
53+
healthProbe: async () => ({ ok: false, status: 401, latencyMs: 50 }),
54+
});
55+
const result = await p.healthCheck();
56+
expect(result.ok).toBe(false);
57+
expect(result.error?.class).toBe('auth');
58+
});
59+
60+
it('healthCheck returns ok when probe succeeds', async () => {
61+
const p = new ElevenLabsStreamingTTS({
62+
apiKey: 'valid',
63+
healthProbe: async () => ({ ok: true, status: 200, latencyMs: 30 }),
64+
});
65+
const result = await p.healthCheck();
66+
expect(result.ok).toBe(true);
67+
expect(result.latencyMs).toBe(30);
68+
});
69+
70+
it('healthCheck catches thrown errors (network)', async () => {
71+
const p = new DeepgramStreamingSTT({
72+
apiKey: 'valid',
73+
healthProbe: async () => {
74+
throw Object.assign(new Error('ECONNRESET'), { code: 'ECONNRESET' });
75+
},
76+
});
77+
const result = await p.healthCheck();
78+
expect(result.ok).toBe(false);
79+
expect(result.error?.class).toBe('network');
80+
});
81+
});

src/voice-pipeline/providers/DeepgramStreamingSTT.ts

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,34 @@ import type {
3535
TranscriptEvent,
3636
TranscriptWord,
3737
} from '../types.js';
38+
import {
39+
defaultCapabilities,
40+
type HealthyProvider,
41+
type HealthCheckResult,
42+
type ProviderCapabilities,
43+
} from '../HealthyProvider.js';
44+
import { VoicePipelineError } from '../VoicePipelineError.js';
45+
46+
/**
47+
* Shape of the injected health probe used for deterministic tests.
48+
* Default implementation hits Deepgram's /v1/projects endpoint.
49+
*/
50+
export type VoiceHealthProbe = (
51+
apiKey: string
52+
) => Promise<{ ok: boolean; status: number; latencyMs: number }>;
53+
54+
async function defaultDeepgramProbe(apiKey: string) {
55+
const start = Date.now();
56+
try {
57+
const res = await fetch('https://api.deepgram.com/v1/projects', {
58+
headers: { Authorization: `Token ${apiKey}` },
59+
signal: AbortSignal.timeout(1000),
60+
});
61+
return { ok: res.ok, status: res.status, latencyMs: Date.now() - start };
62+
} catch (err) {
63+
throw err;
64+
}
65+
}
3866

3967
// ---------------------------------------------------------------------------
4068
// Configuration
@@ -58,6 +86,18 @@ export interface DeepgramStreamingSTTConfig {
5886
* @default 'nova-2'
5987
*/
6088
model?: string;
89+
90+
/**
91+
* Chain priority. Lower values are tried first.
92+
* @default 10
93+
*/
94+
priority?: number;
95+
96+
/** Optional capability overrides. Merged into defaultCapabilities(). */
97+
capabilities?: Partial<ProviderCapabilities>;
98+
99+
/** Injectable health probe for tests. Defaults to Deepgram /v1/projects. */
100+
healthProbe?: VoiceHealthProbe;
61101
}
62102

63103
// ---------------------------------------------------------------------------
@@ -331,13 +371,54 @@ class DeepgramStreamingSTTSession extends EventEmitter implements StreamingSTTSe
331371
* session.on('transcript', (event) => console.log(event.text));
332372
* ```
333373
*/
334-
export class DeepgramStreamingSTT implements IStreamingSTT {
374+
export class DeepgramStreamingSTT implements IStreamingSTT, HealthyProvider {
335375
readonly providerId = 'deepgram-streaming';
336376
readonly isStreaming = true;
377+
readonly priority: number;
378+
readonly capabilities: ProviderCapabilities;
337379
private readonly keyPool: ApiKeyPool;
380+
private readonly healthProbe: VoiceHealthProbe;
338381

339382
constructor(private readonly config: DeepgramStreamingSTTConfig) {
340383
this.keyPool = new ApiKeyPool(config.apiKey);
384+
this.priority = config.priority ?? 10;
385+
this.capabilities = defaultCapabilities({
386+
languages: ['*'],
387+
streaming: true,
388+
costTier: 'standard',
389+
latencyClass: 'realtime',
390+
...(config.capabilities ?? {}),
391+
});
392+
this.healthProbe = config.healthProbe ?? defaultDeepgramProbe;
393+
}
394+
395+
async healthCheck(): Promise<HealthCheckResult> {
396+
if (!this.keyPool.hasKeys) {
397+
return { ok: false, error: { class: 'auth', message: 'no api key available' } };
398+
}
399+
const key = this.keyPool.next();
400+
try {
401+
const res = await this.healthProbe(key);
402+
if (res.ok) return { ok: true, latencyMs: res.latencyMs };
403+
const classified = VoicePipelineError.classifyError(
404+
new Error(`HTTP ${res.status}`),
405+
{ kind: 'stt', provider: this.providerId }
406+
);
407+
return {
408+
ok: false,
409+
latencyMs: res.latencyMs,
410+
error: { class: classified.errorClass, message: `HTTP ${res.status}` },
411+
};
412+
} catch (err) {
413+
const classified = VoicePipelineError.classifyError(err, {
414+
kind: 'stt',
415+
provider: this.providerId,
416+
});
417+
return {
418+
ok: false,
419+
error: { class: classified.errorClass, message: classified.message },
420+
};
421+
}
341422
}
342423

343424
/**

src/voice-pipeline/providers/ElevenLabsBatchTTS.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,22 @@
88
import type { IBatchTTS, BatchTTSConfig, BatchTTSResult } from '../types.js';
99
import { ApiKeyPool } from '../../core/providers/ApiKeyPool.js';
1010
import { isQuotaError } from '../../core/providers/quotaErrors.js';
11+
import {
12+
defaultCapabilities,
13+
type HealthyProvider,
14+
type HealthCheckResult,
15+
type ProviderCapabilities,
16+
} from '../HealthyProvider.js';
17+
import { VoicePipelineError } from '../VoicePipelineError.js';
18+
19+
async function defaultElevenLabsBatchProbe(apiKey: string) {
20+
const start = Date.now();
21+
const res = await fetch('https://api.elevenlabs.io/v1/user', {
22+
headers: { 'xi-api-key': apiKey },
23+
signal: AbortSignal.timeout(1000),
24+
});
25+
return { ok: res.ok, status: res.status, latencyMs: Date.now() - start };
26+
}
1127

1228
/** Configuration for the ElevenLabs batch TTS provider. */
1329
export interface ElevenLabsBatchTTSConfig {
@@ -19,6 +35,12 @@ export interface ElevenLabsBatchTTSConfig {
1935
model?: string;
2036
/** Base URL for the ElevenLabs API. Defaults to 'https://api.elevenlabs.io/v1'. */
2137
baseUrl?: string;
38+
/** Chain priority. Lower values are tried first. @default 80 */
39+
priority?: number;
40+
/** Optional capability overrides. */
41+
capabilities?: Partial<ProviderCapabilities>;
42+
/** Injectable health probe for tests. */
43+
healthProbe?: (apiKey: string) => Promise<{ ok: boolean; status: number; latencyMs: number }>;
2244
}
2345

2446
/** Approximate bytes per second for 128kbps MP3 audio. */
@@ -30,8 +52,10 @@ const BYTES_PER_SEC_MP3 = 16_000;
3052
* Accepts complete text and returns finished MP3 audio with voice settings
3153
* control via `providerOptions` (stability, similarityBoost, style, useSpeakerBoost).
3254
*/
33-
export class ElevenLabsBatchTTS implements IBatchTTS {
55+
export class ElevenLabsBatchTTS implements IBatchTTS, HealthyProvider {
3456
readonly providerId = 'elevenlabs-batch';
57+
readonly priority: number;
58+
readonly capabilities: ProviderCapabilities;
3559

3660
/** API key pool for round-robin rotation and quota failover. */
3761
private readonly keyPool: ApiKeyPool;
@@ -45,11 +69,52 @@ export class ElevenLabsBatchTTS implements IBatchTTS {
4569
/** Base URL for all API requests. */
4670
private readonly baseUrl: string;
4771

72+
/** Injectable health probe for tests. */
73+
private readonly healthProbe: NonNullable<ElevenLabsBatchTTSConfig['healthProbe']>;
74+
4875
constructor(config: ElevenLabsBatchTTSConfig) {
4976
this.keyPool = new ApiKeyPool(config.apiKey);
5077
this.defaultVoiceId = config.voiceId ?? 'EXAVITQu4vr4xnSDxMaL';
5178
this.model = config.model ?? 'eleven_multilingual_v2';
5279
this.baseUrl = config.baseUrl ?? 'https://api.elevenlabs.io/v1';
80+
this.priority = config.priority ?? 80;
81+
this.capabilities = defaultCapabilities({
82+
languages: ['*'],
83+
streaming: false,
84+
costTier: 'standard',
85+
latencyClass: 'batch',
86+
...(config.capabilities ?? {}),
87+
});
88+
this.healthProbe = config.healthProbe ?? defaultElevenLabsBatchProbe;
89+
}
90+
91+
async healthCheck(): Promise<HealthCheckResult> {
92+
if (!this.keyPool.hasKeys) {
93+
return { ok: false, error: { class: 'auth', message: 'no api key available' } };
94+
}
95+
const key = this.keyPool.next();
96+
try {
97+
const res = await this.healthProbe(key);
98+
if (res.ok) return { ok: true, latencyMs: res.latencyMs };
99+
const classified = VoicePipelineError.classifyError(
100+
new Error(`HTTP ${res.status}`),
101+
{ kind: 'tts', provider: this.providerId }
102+
);
103+
return {
104+
ok: false,
105+
latencyMs: res.latencyMs,
106+
error: { class: classified.errorClass, message: `HTTP ${res.status}` },
107+
};
108+
} catch (err) {
109+
const classified = VoicePipelineError.classifyError(err, {
110+
kind: 'tts',
111+
provider: this.providerId,
112+
});
113+
return {
114+
ok: false,
115+
error: { class: classified.errorClass, message: classified.message },
116+
};
117+
}
53118
}
54119

55120
/**

src/voice-pipeline/providers/ElevenLabsStreamingSTT.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,22 @@ import type {
3434
TranscriptWord,
3535
} from '../types.js';
3636
import { ApiKeyPool } from '../../core/providers/ApiKeyPool.js';
37+
import {
38+
defaultCapabilities,
39+
type HealthyProvider,
40+
type HealthCheckResult,
41+
type ProviderCapabilities,
42+
} from '../HealthyProvider.js';
43+
import { VoicePipelineError } from '../VoicePipelineError.js';
44+
45+
async function defaultElevenLabsProbe(apiKey: string) {
46+
const start = Date.now();
47+
const res = await fetch('https://api.elevenlabs.io/v1/user', {
48+
headers: { 'xi-api-key': apiKey },
49+
signal: AbortSignal.timeout(1000),
50+
});
51+
return { ok: res.ok, status: res.status, latencyMs: Date.now() - start };
52+
}
3753

3854
// ---------------------------------------------------------------------------
3955
// Configuration
@@ -57,6 +73,15 @@ export interface ElevenLabsStreamingSTTConfig {
5773
* @default 'scribe_v1'
5874
*/
5975
model?: string;
76+
77+
/** Chain priority. Lower values are tried first. @default 20 */
78+
priority?: number;
79+
80+
/** Optional capability overrides. */
81+
capabilities?: Partial<ProviderCapabilities>;
82+
83+
/** Injectable health probe for tests. */
84+
healthProbe?: (apiKey: string) => Promise<{ ok: boolean; status: number; latencyMs: number }>;
6085
}
6186

6287
// ---------------------------------------------------------------------------
@@ -333,13 +358,56 @@ class ElevenLabsChunkedSTTSession extends EventEmitter implements StreamingSTTSe
333358
* session.on('transcript', (event) => console.log(event.text));
334359
* ```
335360
*/
336-
export class ElevenLabsStreamingSTT implements IStreamingSTT {
361+
export class ElevenLabsStreamingSTT implements IStreamingSTT, HealthyProvider {
337362
readonly providerId = 'elevenlabs-streaming-stt';
338363
readonly isStreaming = true;
364+
readonly priority: number;
365+
readonly capabilities: ProviderCapabilities;
339366
private readonly keyPool: ApiKeyPool;
367+
private readonly healthProbe: NonNullable<
368+
ElevenLabsStreamingSTTConfig['healthProbe']
369+
>;
340370

341371
constructor(private readonly config: ElevenLabsStreamingSTTConfig) {
342372
this.keyPool = new ApiKeyPool(config.apiKey);
373+
this.priority = config.priority ?? 20;
374+
this.capabilities = defaultCapabilities({
375+
languages: ['*'],
376+
streaming: true,
377+
costTier: 'standard',
378+
latencyClass: 'near-realtime',
379+
...(config.capabilities ?? {}),
380+
});
381+
this.healthProbe = config.healthProbe ?? defaultElevenLabsProbe;
382+
}
383+
384+
async healthCheck(): Promise<HealthCheckResult> {
385+
if (!this.keyPool.hasKeys) {
386+
return { ok: false, error: { class: 'auth', message: 'no api key available' } };
387+
}
388+
const key = this.keyPool.next();
389+
try {
390+
const res = await this.healthProbe(key);
391+
if (res.ok) return { ok: true, latencyMs: res.latencyMs };
392+
const classified = VoicePipelineError.classifyError(
393+
new Error(`HTTP ${res.status}`),
394+
{ kind: 'stt', provider: this.providerId }
395+
);
396+
return {
397+
ok: false,
398+
latencyMs: res.latencyMs,
399+
error: { class: classified.errorClass, message: `HTTP ${res.status}` },
400+
};
401+
} catch (err) {
402+
const classified = VoicePipelineError.classifyError(err, {
403+
kind: 'stt',
404+
provider: this.providerId,
405+
});
406+
return {
407+
ok: false,
408+
error: { class: classified.errorClass, message: classified.message },
409+
};
410+
}
343411
}
344412

345413
/**

0 commit comments

Comments
 (0)