@startsimpli/llm 0.4.26 → 0.4.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -10
- package/package.json +1 -1
- package/src/__tests__/speech-cost.test.ts +81 -0
- package/src/__tests__/speech.test.ts +2 -2
- package/src/speech/index.ts +2 -1
- package/src/speech/interface.ts +6 -0
- package/src/speech/providers/elevenlabs.ts +15 -2
- package/src/speech/providers/openai.ts +34 -4
- package/src/speech/service.ts +17 -11
- package/src/speech/types.ts +1 -1
package/README.md
CHANGED
|
@@ -181,28 +181,32 @@ Per CLAUDE.md **rule 9**, LLM client wiring never belongs in app `src/`. If you
|
|
|
181
181
|
|
|
182
182
|
## Speech (`@startsimpli/llm/speech`)
|
|
183
183
|
|
|
184
|
-
Pluggable speech-to-text, same provider pattern as the text service.
|
|
184
|
+
Pluggable speech-to-text, same provider pattern as the text service. **It picks the
|
|
185
|
+
cheapest vendor that has a key** — each provider states its list price — and falls
|
|
186
|
+
through to the next cheapest on failure.
|
|
185
187
|
|
|
186
188
|
```ts
|
|
187
189
|
import { SpeechService } from '@startsimpli/llm/speech';
|
|
188
190
|
|
|
189
|
-
const speech = new SpeechService(); // SPEECH_PROVIDER
|
|
191
|
+
const speech = new SpeechService(); // SPEECH_PROVIDER if set, else the cheapest vendor with a key
|
|
190
192
|
const { text, words } = await speech.transcribe({
|
|
191
193
|
audio, mimeType: 'audio/webm', timestamps: 'word', hints: ['Crochets'],
|
|
192
194
|
});
|
|
193
195
|
```
|
|
194
196
|
|
|
195
|
-
| Env | Provider |
|
|
196
|
-
| --- | --- |
|
|
197
|
-
| `
|
|
198
|
-
| `
|
|
199
|
-
| `SPEECH_PROVIDER=mock` | deterministic, for tests |
|
|
200
|
-
| `SPEECH_PROVIDER` / `SPEECH_LANGUAGE` |
|
|
197
|
+
| Env | Provider | List price |
|
|
198
|
+
| --- | --- | --- |
|
|
199
|
+
| `OPENAI_API_KEY` | OpenAI `gpt-4o-mini-transcribe` (default; `gpt-4o-transcribe` / `whisper-1` cost 2x) | $0.18 / hour |
|
|
200
|
+
| `ELEVENLABS_API_KEY` | ElevenLabs Scribe (keyterm hints are a billed add-on, so off unless `sendKeyterms`) | $0.22 / hour |
|
|
201
|
+
| `SPEECH_PROVIDER=mock` | deterministic, for tests | — |
|
|
202
|
+
| `SPEECH_PROVIDER` / `SPEECH_LANGUAGE` | force a vendor / pin the language (e.g. `en`) so short clips don't mis-detect | — |
|
|
201
203
|
|
|
202
204
|
Retryable errors back off and retry; a failure that survives falls through to the next
|
|
203
|
-
|
|
205
|
+
vendor (`fallback: false` to pin one). Bad audio (`INVALID_REQUEST`) never falls through.
|
|
204
206
|
|
|
205
|
-
**Add a vendor** — implement `ISpeechProvider` and register it
|
|
207
|
+
**Add a vendor** — implement `ISpeechProvider` (state `costPerMinuteUsd`) and register it, no
|
|
208
|
+
service edit. An OpenAI-compatible host is just `OpenAISpeechProvider` with a different
|
|
209
|
+
`name` / `baseUrl` / `defaultModel` / `prices`:
|
|
206
210
|
|
|
207
211
|
```ts
|
|
208
212
|
registerSpeechProvider('mine', () => (process.env.MINE_KEY ? new MyProvider(process.env.MINE_KEY) : null));
|
package/package.json
CHANGED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ElevenLabsSpeechProvider, OpenAISpeechProvider, SpeechService,
|
|
5
|
+
} from '../speech';
|
|
6
|
+
import type { ISpeechProvider, TranscribeRequest } from '../speech';
|
|
7
|
+
|
|
8
|
+
const req: TranscribeRequest = { audio: new Uint8Array([1, 2, 3]), mimeType: 'audio/webm', hints: ['Crochets'] };
|
|
9
|
+
const okJson = (body: unknown) => vi.fn(async () => new Response(JSON.stringify(body), { status: 200 }));
|
|
10
|
+
|
|
11
|
+
describe('what each vendor costs per audio minute (USD, list price)', () => {
|
|
12
|
+
it('openai: the cheap default model, and the dearer ones priced as such', () => {
|
|
13
|
+
const p = new OpenAISpeechProvider({ apiKey: 'k' });
|
|
14
|
+
expect(p.defaultModel).toBe('gpt-4o-mini-transcribe');
|
|
15
|
+
expect(p.costPerMinuteUsd()).toBeCloseTo(0.003);
|
|
16
|
+
expect(p.costPerMinuteUsd('gpt-4o-transcribe')).toBeCloseTo(0.006);
|
|
17
|
+
expect(p.costPerMinuteUsd('whisper-1')).toBeCloseTo(0.006);
|
|
18
|
+
});
|
|
19
|
+
it('elevenlabs Scribe is $0.22/hour', () => {
|
|
20
|
+
expect(new ElevenLabsSpeechProvider({ apiKey: 'k' }).costPerMinuteUsd()).toBeCloseTo(0.22 / 60);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('an OpenAI-compatible host is the OpenAI provider with different options', () => {
|
|
25
|
+
it('reports its own name, model and price and posts to its own base URL', async () => {
|
|
26
|
+
const f = okJson({ text: ' hi ' });
|
|
27
|
+
const p = new OpenAISpeechProvider({
|
|
28
|
+
apiKey: 'k', name: 'selfhosted', baseUrl: 'https://stt.example.com/openai', defaultModel: 'w',
|
|
29
|
+
prices: { w: 0.001 }, fetchImpl: f as never,
|
|
30
|
+
});
|
|
31
|
+
const out = await p.transcribe(req);
|
|
32
|
+
const [url, init] = f.mock.calls[0] as unknown as [string, RequestInit];
|
|
33
|
+
expect(url).toBe('https://stt.example.com/openai/v1/audio/transcriptions');
|
|
34
|
+
expect((init.body as FormData).get('model')).toBe('w');
|
|
35
|
+
expect(out).toMatchObject({ text: 'hi', provider: 'selfhosted' });
|
|
36
|
+
expect(p.costPerMinuteUsd()).toBeCloseTo(0.001);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('elevenlabs keyterms are a BILLED add-on, so they are opt-in', () => {
|
|
41
|
+
it('does not send keyterms by default', async () => {
|
|
42
|
+
const f = okJson({ text: 'x' });
|
|
43
|
+
await new ElevenLabsSpeechProvider({ apiKey: 'k', fetchImpl: f as never }).transcribe(req);
|
|
44
|
+
expect(((f.mock.calls[0] as unknown as [unknown, RequestInit])[1].body as FormData).getAll('keyterms')).toEqual([]);
|
|
45
|
+
});
|
|
46
|
+
it('sends them when asked', async () => {
|
|
47
|
+
const f = okJson({ text: 'x' });
|
|
48
|
+
await new ElevenLabsSpeechProvider({ apiKey: 'k', sendKeyterms: true, fetchImpl: f as never }).transcribe(req);
|
|
49
|
+
expect(((f.mock.calls[0] as unknown as [unknown, RequestInit])[1].body as FormData).getAll('keyterms')).toEqual(['Crochets']);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('SpeechService picks the cheapest available vendor unless told otherwise', () => {
|
|
54
|
+
const fake = (name: string, cost: number | undefined): ISpeechProvider => ({
|
|
55
|
+
name, capabilities: { transcribe: true }, defaultModel: 'm', isAvailable: () => true,
|
|
56
|
+
...(cost === undefined ? {} : { costPerMinuteUsd: () => cost }),
|
|
57
|
+
transcribe: (async () => ({ text: name, model: 'm', provider: name, usage: {} })) as never,
|
|
58
|
+
});
|
|
59
|
+
const svc = (config: ConstructorParameters<typeof SpeechService>[0], ...ps: ISpeechProvider[]) => {
|
|
60
|
+
const s = new SpeechService(config);
|
|
61
|
+
(s as any).providers = new Map(ps.map((p) => [p.name, p]));
|
|
62
|
+
return s;
|
|
63
|
+
};
|
|
64
|
+
it('cheapest wins by default', async () => {
|
|
65
|
+
const s = svc({}, fake('pricey', 0.0067), fake('cheap', 0.0007), fake('mid', 0.003));
|
|
66
|
+
expect((await s.transcribe(req)).provider).toBe('cheap');
|
|
67
|
+
});
|
|
68
|
+
it('a provider with no stated price is tried last, not first', async () => {
|
|
69
|
+
const s = svc({}, fake('unknown', undefined), fake('mid', 0.003));
|
|
70
|
+
expect((await s.transcribe(req)).provider).toBe('mid');
|
|
71
|
+
});
|
|
72
|
+
it('an explicit preference beats price', async () => {
|
|
73
|
+
const s = svc({ preferredProvider: 'pricey' }, fake('pricey', 0.0067), fake('cheap', 0.0007));
|
|
74
|
+
expect((await s.transcribe(req)).provider).toBe('pricey');
|
|
75
|
+
});
|
|
76
|
+
it('falls through to the next-cheapest when the cheapest fails', async () => {
|
|
77
|
+
const down: ISpeechProvider = { ...fake('cheap', 0.0007), transcribe: (async () => { throw Object.assign(new Error('down'), { code: 'SERVICE_UNAVAILABLE', retryable: false }); }) as never };
|
|
78
|
+
const s = svc({ retryDelayMs: 1, maxRetries: 0 }, down, fake('mid', 0.003), fake('pricey', 0.0067));
|
|
79
|
+
expect((await s.transcribe(req)).provider).toBe('mid');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -128,7 +128,7 @@ describe('SpeechService', () => {
|
|
|
128
128
|
const s = withProviders(new SpeechService({ preferredProvider: 'openai' }), fake('elevenlabs', okResult('elevenlabs')), fake('openai', okResult('openai')));
|
|
129
129
|
expect((await s.transcribe(req)).provider).toBe('openai');
|
|
130
130
|
});
|
|
131
|
-
it('
|
|
131
|
+
it('with nothing preferred it uses what has a key (order = price, then registration)', async () => {
|
|
132
132
|
const s = withProviders(new SpeechService(), fake('elevenlabs', okResult('elevenlabs')), fake('openai', okResult('openai')));
|
|
133
133
|
expect((await s.transcribe(req)).provider).toBe('elevenlabs');
|
|
134
134
|
const only = withProviders(new SpeechService(), fake('openai', okResult('openai')));
|
|
@@ -153,7 +153,7 @@ describe('SpeechService', () => {
|
|
|
153
153
|
});
|
|
154
154
|
it('says what to set when nothing is configured', async () => {
|
|
155
155
|
const s = withProviders(new SpeechService());
|
|
156
|
-
await expect(s.transcribe(req)).rejects.toThrow(/
|
|
156
|
+
await expect(s.transcribe(req)).rejects.toThrow(/OPENAI_API_KEY or ELEVENLABS_API_KEY/);
|
|
157
157
|
expect(s.isAvailable()).toBe(false);
|
|
158
158
|
});
|
|
159
159
|
it('SPEECH_LANGUAGE is a default, and a per-call language wins', async () => {
|
package/src/speech/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `@startsimpli/llm/speech` — pluggable speech-to-text.
|
|
3
3
|
*
|
|
4
|
-
* const speech = new SpeechService(); // SPEECH_PROVIDER
|
|
4
|
+
* const speech = new SpeechService(); // SPEECH_PROVIDER, else the cheapest vendor with a key
|
|
5
5
|
* const { text } = await speech.transcribe({ audio, mimeType: 'audio/webm' });
|
|
6
6
|
*
|
|
7
7
|
* A new vendor: implement `ISpeechProvider`, then `registerSpeechProvider(name, factory)`.
|
|
@@ -13,5 +13,6 @@ export type {
|
|
|
13
13
|
} from './types';
|
|
14
14
|
export { ElevenLabsSpeechProvider, createElevenLabsSpeechProvider } from './providers/elevenlabs';
|
|
15
15
|
export { OpenAISpeechProvider, createOpenAISpeechProvider } from './providers/openai';
|
|
16
|
+
export type { OpenAISpeechOptions } from './providers/openai';
|
|
16
17
|
export { MockSpeechProvider, setMockSpeechBehavior, resetMockSpeechBehavior } from './providers/mock';
|
|
17
18
|
export { audioExtension, speechHttpError } from './http';
|
package/src/speech/interface.ts
CHANGED
|
@@ -15,5 +15,11 @@ import type { TranscribeRequest, TranscriptResult } from './types';
|
|
|
15
15
|
export interface ISpeechProvider extends Selectable {
|
|
16
16
|
readonly capabilities: { transcribe: boolean };
|
|
17
17
|
readonly defaultModel: string;
|
|
18
|
+
/**
|
|
19
|
+
* List price per audio minute, USD, for `model` (default model when omitted).
|
|
20
|
+
* `SpeechService` uses it to prefer the cheapest available vendor. A provider
|
|
21
|
+
* that does not state a price is tried after every provider that does.
|
|
22
|
+
*/
|
|
23
|
+
costPerMinuteUsd?(model?: string): number;
|
|
18
24
|
transcribe?(request: TranscribeRequest): Promise<TranscriptResult>;
|
|
19
25
|
}
|
|
@@ -7,6 +7,12 @@ import { assertAudio, audioBlob, speechFetch } from '../http';
|
|
|
7
7
|
import type { TranscribeRequest, TranscriptResult, TranscriptWord } from '../types';
|
|
8
8
|
|
|
9
9
|
export interface ElevenLabsOptions {
|
|
10
|
+
/**
|
|
11
|
+
* Send `hints` as Scribe `keyterms`. OFF by default: keyterm prompting is a
|
|
12
|
+
* separately BILLED add-on (~+$0.07/hour on top of $0.22), and plain Scribe
|
|
13
|
+
* transcribed our project names correctly in the benchmark.
|
|
14
|
+
*/
|
|
15
|
+
sendKeyterms?: boolean;
|
|
10
16
|
apiKey?: string;
|
|
11
17
|
baseUrl?: string;
|
|
12
18
|
fetchImpl?: typeof fetch;
|
|
@@ -21,8 +27,10 @@ export class ElevenLabsSpeechProvider implements ISpeechProvider {
|
|
|
21
27
|
private readonly apiKey: string;
|
|
22
28
|
private readonly baseUrl: string;
|
|
23
29
|
private readonly fetchImpl?: typeof fetch;
|
|
30
|
+
private readonly sendKeyterms: boolean;
|
|
24
31
|
|
|
25
32
|
constructor(options: ElevenLabsOptions = {}) {
|
|
33
|
+
this.sendKeyterms = options.sendKeyterms ?? false;
|
|
26
34
|
this.apiKey = options.apiKey ?? '';
|
|
27
35
|
this.baseUrl = (options.baseUrl ?? 'https://api.elevenlabs.io').replace(/\/+$/, '');
|
|
28
36
|
this.fetchImpl = options.fetchImpl;
|
|
@@ -32,6 +40,11 @@ export class ElevenLabsSpeechProvider implements ISpeechProvider {
|
|
|
32
40
|
return Boolean(this.apiKey);
|
|
33
41
|
}
|
|
34
42
|
|
|
43
|
+
/** Scribe list price: $0.22 per audio hour. */
|
|
44
|
+
costPerMinuteUsd(): number {
|
|
45
|
+
return 0.22 / 60;
|
|
46
|
+
}
|
|
47
|
+
|
|
35
48
|
async transcribe(request: TranscribeRequest): Promise<TranscriptResult> {
|
|
36
49
|
assertAudio(request, this.name);
|
|
37
50
|
const model = request.model ?? this.defaultModel;
|
|
@@ -42,8 +55,8 @@ export class ElevenLabsSpeechProvider implements ISpeechProvider {
|
|
|
42
55
|
form.append('timestamps_granularity', request.timestamps === 'none' ? 'none' : 'word');
|
|
43
56
|
if (request.language) form.append('language_code', request.language);
|
|
44
57
|
if (request.diarize) form.append('diarize', 'true');
|
|
45
|
-
// Scribe has no free-text prompt; `keyterms` is its bias list
|
|
46
|
-
for (const term of request.hints ?? []) form.append('keyterms', term);
|
|
58
|
+
// Scribe has no free-text prompt; `keyterms` is its (billed) bias list.
|
|
59
|
+
if (this.sendKeyterms) for (const term of request.hints ?? []) form.append('keyterms', term);
|
|
47
60
|
|
|
48
61
|
const response = await speechFetch(
|
|
49
62
|
this.name, `${this.baseUrl}/v1/speech-to-text`,
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* OpenAI speech-to-text. Env: OPENAI_API_KEY (the same key the text provider uses).
|
|
3
3
|
*
|
|
4
|
-
* `gpt-4o-transcribe`
|
|
4
|
+
* Default model is `gpt-4o-mini-transcribe` — half the price of `gpt-4o-transcribe`
|
|
5
|
+
* and equally accurate on our benchmark. Per-word timings are only offered by
|
|
5
6
|
* `whisper-1` (verbose_json), so asking for `timestamps: 'word'` switches model.
|
|
7
|
+
*
|
|
8
|
+
* ANY OpenAI-COMPATIBLE ENDPOINT IS THIS CLASS WITH DIFFERENT OPTIONS: name, base
|
|
9
|
+
* URL, default model, word-timing model and price table.
|
|
6
10
|
*/
|
|
7
11
|
import type { ISpeechProvider } from '../interface';
|
|
8
12
|
import { assertAudio, audioBlob, speechFetch } from '../http';
|
|
@@ -12,17 +16,39 @@ export interface OpenAISpeechOptions {
|
|
|
12
16
|
apiKey?: string;
|
|
13
17
|
baseUrl?: string;
|
|
14
18
|
fetchImpl?: typeof fetch;
|
|
19
|
+
/** Provider name reported in results and errors. Default `openai`. */
|
|
20
|
+
name?: string;
|
|
21
|
+
defaultModel?: string;
|
|
22
|
+
/** Model used when per-word timings are requested. Default `whisper-1`. */
|
|
23
|
+
wordTimestampModel?: string;
|
|
24
|
+
/** USD per audio minute by model; unknown models use `fallbackCostPerMinuteUsd`. */
|
|
25
|
+
prices?: Record<string, number>;
|
|
26
|
+
fallbackCostPerMinuteUsd?: number;
|
|
15
27
|
}
|
|
16
28
|
|
|
29
|
+
const OPENAI_PRICES: Record<string, number> = {
|
|
30
|
+
'gpt-4o-mini-transcribe': 0.003,
|
|
31
|
+
'gpt-4o-transcribe': 0.006,
|
|
32
|
+
'whisper-1': 0.006,
|
|
33
|
+
};
|
|
34
|
+
|
|
17
35
|
export class OpenAISpeechProvider implements ISpeechProvider {
|
|
18
|
-
readonly name
|
|
36
|
+
readonly name: string;
|
|
19
37
|
readonly capabilities = { transcribe: true };
|
|
20
|
-
readonly defaultModel
|
|
38
|
+
readonly defaultModel: string;
|
|
21
39
|
private readonly apiKey: string;
|
|
22
40
|
private readonly baseUrl: string;
|
|
23
41
|
private readonly fetchImpl?: typeof fetch;
|
|
42
|
+
private readonly wordModel: string;
|
|
43
|
+
private readonly prices: Record<string, number>;
|
|
44
|
+
private readonly fallbackPrice: number;
|
|
24
45
|
|
|
25
46
|
constructor(options: OpenAISpeechOptions = {}) {
|
|
47
|
+
this.name = options.name ?? 'openai';
|
|
48
|
+
this.defaultModel = options.defaultModel ?? 'gpt-4o-mini-transcribe';
|
|
49
|
+
this.wordModel = options.wordTimestampModel ?? 'whisper-1';
|
|
50
|
+
this.prices = options.prices ?? OPENAI_PRICES;
|
|
51
|
+
this.fallbackPrice = options.fallbackCostPerMinuteUsd ?? 0.006;
|
|
26
52
|
this.apiKey = options.apiKey ?? '';
|
|
27
53
|
this.baseUrl = (options.baseUrl ?? 'https://api.openai.com').replace(/\/+$/, '');
|
|
28
54
|
this.fetchImpl = options.fetchImpl;
|
|
@@ -32,10 +58,14 @@ export class OpenAISpeechProvider implements ISpeechProvider {
|
|
|
32
58
|
return Boolean(this.apiKey);
|
|
33
59
|
}
|
|
34
60
|
|
|
61
|
+
costPerMinuteUsd(model?: string): number {
|
|
62
|
+
return this.prices[model ?? this.defaultModel] ?? this.fallbackPrice;
|
|
63
|
+
}
|
|
64
|
+
|
|
35
65
|
async transcribe(request: TranscribeRequest): Promise<TranscriptResult> {
|
|
36
66
|
assertAudio(request, this.name);
|
|
37
67
|
const wantWords = request.timestamps === 'word';
|
|
38
|
-
const model = request.model ?? (wantWords ?
|
|
68
|
+
const model = request.model ?? (wantWords ? this.wordModel : this.defaultModel);
|
|
39
69
|
const { blob, filename } = audioBlob(request);
|
|
40
70
|
const form = new FormData();
|
|
41
71
|
form.append('file', blob, filename);
|
package/src/speech/service.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { LLMProviderError } from '../errors';
|
|
2
|
-
import { ProviderRegistry
|
|
2
|
+
import { ProviderRegistry } from '../providers/registry';
|
|
3
3
|
import type { ISpeechProvider } from './interface';
|
|
4
4
|
import { createElevenLabsSpeechProvider } from './providers/elevenlabs';
|
|
5
5
|
import { MockSpeechProvider } from './providers/mock';
|
|
@@ -11,8 +11,8 @@ import type { SpeechServiceConfig, TranscribeRequest, TranscriptResult } from '.
|
|
|
11
11
|
* `registerSpeechProvider('name', factory)` — no edit to `SpeechService`.
|
|
12
12
|
*/
|
|
13
13
|
export const speechProviders = new ProviderRegistry<ISpeechProvider>()
|
|
14
|
-
.register('
|
|
15
|
-
.register('
|
|
14
|
+
.register('openai', () => createOpenAISpeechProvider())
|
|
15
|
+
.register('elevenlabs', () => createElevenLabsSpeechProvider());
|
|
16
16
|
|
|
17
17
|
export function registerSpeechProvider(name: string, factory: () => ISpeechProvider | null): void {
|
|
18
18
|
speechProviders.register(name, factory);
|
|
@@ -22,8 +22,8 @@ const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve,
|
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* Provider-agnostic speech. Pick a vendor with `preferredProvider` or the
|
|
25
|
-
* `SPEECH_PROVIDER` env var; with neither it
|
|
26
|
-
*
|
|
25
|
+
* `SPEECH_PROVIDER` env var; with neither it uses the CHEAPEST vendor that has a
|
|
26
|
+
* key (each provider states its list price) and falls back to the next cheapest. A retryable failure is retried with backoff, and
|
|
27
27
|
* one that survives the retries falls through to the next available vendor —
|
|
28
28
|
* unless the error is the caller's (bad audio), which no other vendor would fix.
|
|
29
29
|
*/
|
|
@@ -34,7 +34,7 @@ export class SpeechService {
|
|
|
34
34
|
constructor(config: SpeechServiceConfig = {}) {
|
|
35
35
|
const env: Record<string, string | undefined> = typeof process !== 'undefined' ? process.env : {};
|
|
36
36
|
this.config = {
|
|
37
|
-
preferredProvider: config.preferredProvider ?? env.SPEECH_PROVIDER ?? '
|
|
37
|
+
preferredProvider: config.preferredProvider ?? env.SPEECH_PROVIDER ?? '',
|
|
38
38
|
maxRetries: config.maxRetries ?? 2,
|
|
39
39
|
fallback: config.fallback ?? true,
|
|
40
40
|
retryDelayMs: config.retryDelayMs ?? 500,
|
|
@@ -52,12 +52,18 @@ export class SpeechService {
|
|
|
52
52
|
return [...this.providers].filter(([, p]) => p.isAvailable() && p.capabilities.transcribe).map(([n]) => n);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
/** Providers in the order they will be tried. */
|
|
55
|
+
/** Providers in the order they will be tried: the preferred one (if any), then cheapest first. */
|
|
56
56
|
private order(): ISpeechProvider[] {
|
|
57
|
-
const usable =
|
|
58
|
-
const
|
|
57
|
+
const usable = [...this.providers.values()].filter((p) => p.isAvailable() && p.capabilities.transcribe);
|
|
58
|
+
const price = (p: ISpeechProvider) => p.costPerMinuteUsd?.() ?? Number.POSITIVE_INFINITY;
|
|
59
|
+
// Array.sort is stable, so equal/unknown prices keep registration order.
|
|
60
|
+
const byPrice = [...usable].sort((a, b) => price(a) - price(b));
|
|
61
|
+
const wanted = this.config.preferredProvider
|
|
62
|
+
? usable.find((p) => p.name === this.config.preferredProvider)
|
|
63
|
+
: undefined;
|
|
64
|
+
const first = wanted ?? byPrice[0];
|
|
59
65
|
if (!first) return [];
|
|
60
|
-
return this.config.fallback ? [first, ...
|
|
66
|
+
return this.config.fallback ? [first, ...byPrice.filter((p) => p !== first)] : [first];
|
|
61
67
|
}
|
|
62
68
|
|
|
63
69
|
async transcribe(input: TranscribeRequest): Promise<TranscriptResult> {
|
|
@@ -68,7 +74,7 @@ export class SpeechService {
|
|
|
68
74
|
const order = this.order();
|
|
69
75
|
if (!order.length) {
|
|
70
76
|
throw new LLMProviderError(
|
|
71
|
-
'No speech providers available. Set
|
|
77
|
+
'No speech providers available. Set OPENAI_API_KEY or ELEVENLABS_API_KEY.',
|
|
72
78
|
'AUTHENTICATION_ERROR', false,
|
|
73
79
|
);
|
|
74
80
|
}
|
package/src/speech/types.ts
CHANGED
|
@@ -48,7 +48,7 @@ export interface TranscriptResult {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
export interface SpeechServiceConfig {
|
|
51
|
-
/** Provider tried first. Default: `SPEECH_PROVIDER`, else
|
|
51
|
+
/** Provider tried first. Default: `SPEECH_PROVIDER`, else the cheapest vendor with a key. */
|
|
52
52
|
preferredProvider?: string;
|
|
53
53
|
/** Retries on a retryable error, per provider. Default 2. */
|
|
54
54
|
maxRetries?: number;
|