@startsimpli/llm 0.4.25 → 0.4.27

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 CHANGED
@@ -177,3 +177,41 @@ Test suite ships **32 tests across 4 files**: `json-extraction.test.ts`, `mock-p
177
177
  ## Shared-first
178
178
 
179
179
  Per CLAUDE.md **rule 9**, LLM client wiring never belongs in app `src/`. If you find a `lib/openai.ts` or an `LLMService` clone inside an app (today: `raise-simpli/web-app/src/integrations/providers/openai/`), the right move is to delete it and import from `@startsimpli/llm`. Domain prompts and Zod schemas stay in the app; provider abstraction, retries, JSON extraction, and cost accounting stay here.
180
+
181
+
182
+ ## Speech (`@startsimpli/llm/speech`)
183
+
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.
187
+
188
+ ```ts
189
+ import { SpeechService } from '@startsimpli/llm/speech';
190
+
191
+ const speech = new SpeechService(); // SPEECH_PROVIDER if set, else the cheapest vendor with a key
192
+ const { text, words } = await speech.transcribe({
193
+ audio, mimeType: 'audio/webm', timestamps: 'word', hints: ['Crochets'],
194
+ });
195
+ ```
196
+
197
+ | Env | Provider | List price |
198
+ | --- | --- | --- |
199
+ | `GROQ_API_KEY` | Groq `whisper-large-v3-turbo` | $0.04 / audio hour |
200
+ | `OPENAI_API_KEY` | OpenAI `gpt-4o-mini-transcribe` (default; `gpt-4o-transcribe` / `whisper-1` cost 2x) | $0.18 / hour |
201
+ | `ELEVENLABS_API_KEY` | ElevenLabs Scribe (keyterm hints are a billed add-on, so off unless `sendKeyterms`) | $0.22 / hour |
202
+ | `SPEECH_PROVIDER=mock` | deterministic, for tests | — |
203
+ | `SPEECH_PROVIDER` / `SPEECH_LANGUAGE` | force a vendor / pin the language (e.g. `en`) so short clips don't mis-detect | — |
204
+
205
+ Retryable errors back off and retry; a failure that survives falls through to the next
206
+ vendor (`fallback: false` to pin one). Bad audio (`INVALID_REQUEST`) never falls through.
207
+
208
+ **Add a vendor** — implement `ISpeechProvider` (state `costPerMinuteUsd`) and register it, no
209
+ service edit. An OpenAI-compatible host is a ten-line subclass of `OpenAISpeechProvider`
210
+ (see `providers/groq.ts`):
211
+
212
+ ```ts
213
+ registerSpeechProvider('mine', () => (process.env.MINE_KEY ? new MyProvider(process.env.MINE_KEY) : null));
214
+ ```
215
+
216
+ `registerLLMProvider(name, factory)` does the same for text. Both sit on `ProviderRegistry`,
217
+ so a new modality (TTS, embeddings) reuses it rather than hard-coding a provider list.
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@startsimpli/llm",
3
- "version": "0.4.25",
3
+ "version": "0.4.27",
4
4
  "description": "Generic LLM service infrastructure — provider abstraction, retry logic, JSON extraction",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
7
7
  "exports": {
8
8
  ".": "./src/index.ts",
9
- "./translation": "./src/translation/index.ts"
9
+ "./translation": "./src/translation/index.ts",
10
+ "./speech": "./src/speech/index.ts"
10
11
  },
11
12
  "files": [
12
13
  "src",
@@ -106,3 +106,19 @@ describe('LLMService', () => {
106
106
  })
107
107
  })
108
108
  })
109
+
110
+ describe('registerLLMProvider', () => {
111
+ it('adds a text provider without editing LLMService', async () => {
112
+ const { registerLLMProvider, llmProviders, LLMService } = await import('../index');
113
+ registerLLMProvider('custom', () => ({
114
+ name: 'custom', availableModels: ['m'], defaultModel: 'm', isAvailable: () => true,
115
+ generate: async () => ({ content: 'hi', usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, model: 'm', provider: 'custom' }),
116
+ }));
117
+ try {
118
+ const svc = new LLMService({ preferredProvider: 'custom' as never });
119
+ expect((await svc.chat('u', 's')).provider).toBe('custom');
120
+ } finally {
121
+ llmProviders.unregister('custom');
122
+ }
123
+ });
124
+ });
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import {
4
+ ElevenLabsSpeechProvider, GroqSpeechProvider, OpenAISpeechProvider, SpeechService, createGroqSpeechProvider,
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
+ it('groq whisper-large-v3-turbo is $0.04/hour', () => {
23
+ expect(new GroqSpeechProvider({ apiKey: 'k' }).costPerMinuteUsd()).toBeCloseTo(0.04 / 60);
24
+ });
25
+ });
26
+
27
+ describe('groq provider (OpenAI-compatible endpoint)', () => {
28
+ it('is only built when GROQ_API_KEY is set', () => {
29
+ expect(createGroqSpeechProvider({})).toBeNull();
30
+ expect(createGroqSpeechProvider({ GROQ_API_KEY: 'g' })?.name).toBe('groq');
31
+ });
32
+ it('posts to Groq with its own default model and reports itself as groq', async () => {
33
+ const f = okJson({ text: ' hi ' });
34
+ const out = await new GroqSpeechProvider({ apiKey: 'g', fetchImpl: f as never }).transcribe(req);
35
+ const [url, init] = f.mock.calls[0] as unknown as [string, RequestInit];
36
+ expect(url).toBe('https://api.groq.com/openai/v1/audio/transcriptions');
37
+ expect((init.body as FormData).get('model')).toBe('whisper-large-v3-turbo');
38
+ expect(out).toMatchObject({ text: 'hi', provider: 'groq' });
39
+ });
40
+ });
41
+
42
+ describe('elevenlabs keyterms are a BILLED add-on, so they are opt-in', () => {
43
+ it('does not send keyterms by default', async () => {
44
+ const f = okJson({ text: 'x' });
45
+ await new ElevenLabsSpeechProvider({ apiKey: 'k', fetchImpl: f as never }).transcribe(req);
46
+ expect(((f.mock.calls[0] as unknown as [unknown, RequestInit])[1].body as FormData).getAll('keyterms')).toEqual([]);
47
+ });
48
+ it('sends them when asked', async () => {
49
+ const f = okJson({ text: 'x' });
50
+ await new ElevenLabsSpeechProvider({ apiKey: 'k', sendKeyterms: true, fetchImpl: f as never }).transcribe(req);
51
+ expect(((f.mock.calls[0] as unknown as [unknown, RequestInit])[1].body as FormData).getAll('keyterms')).toEqual(['Crochets']);
52
+ });
53
+ });
54
+
55
+ describe('SpeechService picks the cheapest available vendor unless told otherwise', () => {
56
+ const fake = (name: string, cost: number | undefined): ISpeechProvider => ({
57
+ name, capabilities: { transcribe: true }, defaultModel: 'm', isAvailable: () => true,
58
+ ...(cost === undefined ? {} : { costPerMinuteUsd: () => cost }),
59
+ transcribe: (async () => ({ text: name, model: 'm', provider: name, usage: {} })) as never,
60
+ });
61
+ const svc = (config: ConstructorParameters<typeof SpeechService>[0], ...ps: ISpeechProvider[]) => {
62
+ const s = new SpeechService(config);
63
+ (s as any).providers = new Map(ps.map((p) => [p.name, p]));
64
+ return s;
65
+ };
66
+ it('cheapest wins by default', async () => {
67
+ const s = svc({}, fake('pricey', 0.0067), fake('cheap', 0.0007), fake('mid', 0.003));
68
+ expect((await s.transcribe(req)).provider).toBe('cheap');
69
+ });
70
+ it('a provider with no stated price is tried last, not first', async () => {
71
+ const s = svc({}, fake('unknown', undefined), fake('mid', 0.003));
72
+ expect((await s.transcribe(req)).provider).toBe('mid');
73
+ });
74
+ it('an explicit preference beats price', async () => {
75
+ const s = svc({ preferredProvider: 'pricey' }, fake('pricey', 0.0067), fake('cheap', 0.0007));
76
+ expect((await s.transcribe(req)).provider).toBe('pricey');
77
+ });
78
+ it('falls through to the next-cheapest when the cheapest fails', async () => {
79
+ const down: ISpeechProvider = { ...fake('cheap', 0.0007), transcribe: (async () => { throw Object.assign(new Error('down'), { code: 'SERVICE_UNAVAILABLE', retryable: false }); }) as never };
80
+ const s = svc({ retryDelayMs: 1, maxRetries: 0 }, down, fake('mid', 0.003), fake('pricey', 0.0067));
81
+ expect((await s.transcribe(req)).provider).toBe('mid');
82
+ });
83
+ });
@@ -0,0 +1,203 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import { LLMProviderError } from '../errors';
4
+ import { ProviderRegistry } from '../providers/registry';
5
+ import {
6
+ ElevenLabsSpeechProvider, MockSpeechProvider, OpenAISpeechProvider, SpeechService,
7
+ audioExtension, registerSpeechProvider, resetMockSpeechBehavior, setMockSpeechBehavior, speechProviders,
8
+ } from '../speech';
9
+ import type { ISpeechProvider, TranscribeRequest } from '../speech';
10
+
11
+ const req: TranscribeRequest = { audio: new Uint8Array([1, 2, 3]), mimeType: 'audio/mp4' };
12
+ afterEach(() => { resetMockSpeechBehavior(); vi.unstubAllEnvs(); });
13
+
14
+ /** One fake wire per vendor — the CONTRACT below is what every provider must satisfy. */
15
+ const wires: Array<{ name: string; make: (f: typeof fetch) => ISpeechProvider; ok: unknown; wordsOk: unknown }> = [
16
+ {
17
+ name: 'elevenlabs',
18
+ make: (fetchImpl) => new ElevenLabsSpeechProvider({ apiKey: 'k', fetchImpl }),
19
+ ok: { text: ' hello world ', language_code: 'en', words: [
20
+ { text: 'hello', start: 0, end: 0.4, type: 'word' }, { text: ' ', start: 0.4, end: 0.5, type: 'spacing' },
21
+ { text: 'world', start: 0.5, end: 1.2, type: 'word' } ] },
22
+ wordsOk: null,
23
+ },
24
+ {
25
+ name: 'openai',
26
+ make: (fetchImpl) => new OpenAISpeechProvider({ apiKey: 'k', fetchImpl }),
27
+ ok: { text: ' hello world ', language: 'en', duration: 1.2,
28
+ words: [{ word: 'hello', start: 0, end: 0.4 }, { word: 'world', start: 0.5, end: 1.2 }] },
29
+ wordsOk: null,
30
+ },
31
+ ];
32
+
33
+ describe.each(wires)('provider contract: $name', ({ name, make, ok }) => {
34
+ const respond = (body: unknown, status = 200) =>
35
+ vi.fn(async (_u: unknown, _i: RequestInit) => new Response(typeof body === 'string' ? body : JSON.stringify(body), { status }));
36
+
37
+ it('is unavailable without a key and available with one', () => {
38
+ expect(make(fetch).isAvailable()).toBe(true);
39
+ const keyless = name === 'elevenlabs' ? new ElevenLabsSpeechProvider() : new OpenAISpeechProvider();
40
+ expect(keyless.isAvailable()).toBe(false);
41
+ });
42
+
43
+ it('returns trimmed text, provider name, model, and usage', async () => {
44
+ const out = await make(respond(ok) as never).transcribe!({ ...req, timestamps: 'word' });
45
+ expect(out).toMatchObject({ text: 'hello world', provider: name, language: 'en' });
46
+ expect(out.words?.map((w) => w.text)).toEqual(['hello', 'world']);
47
+ expect(out.usage.audioSeconds).toBeCloseTo(1.2);
48
+ });
49
+
50
+ it('names the upload for its container so the vendor can decode it', async () => {
51
+ const f = respond(ok);
52
+ await make(f as never).transcribe!({ ...req, mimeType: 'audio/webm;codecs=opus' });
53
+ const file = ((f.mock.calls[0][1] as RequestInit).body as FormData).get('file') as File;
54
+ expect(file.name).toBe('audio.webm');
55
+ });
56
+
57
+ it.each([[401, 'AUTHENTICATION_ERROR', false], [429, 'RATE_LIMITED', true], [503, 'SERVICE_UNAVAILABLE', true], [400, 'INVALID_REQUEST', false]])(
58
+ 'classifies HTTP %i as %s (retryable=%s)', async (status, code, retryable) => {
59
+ const err = await make(respond('boom', status as number) as never).transcribe!(req).catch((e) => e);
60
+ expect(err).toBeInstanceOf(LLMProviderError);
61
+ expect(err).toMatchObject({ code, retryable, provider: name });
62
+ });
63
+
64
+ it('refuses empty audio without calling the vendor', async () => {
65
+ const f = respond(ok);
66
+ await expect(make(f as never).transcribe!({ ...req, audio: new Uint8Array() })).rejects.toMatchObject({ code: 'INVALID_REQUEST' });
67
+ expect(f).not.toHaveBeenCalled();
68
+ });
69
+
70
+ it('turns a network failure into a retryable error', async () => {
71
+ const f = vi.fn(async () => { throw new TypeError('fetch failed'); });
72
+ await expect(make(f as never).transcribe!(req)).rejects.toMatchObject({ retryable: true, code: 'SERVICE_UNAVAILABLE' });
73
+ });
74
+ });
75
+
76
+ describe('vendor specifics', () => {
77
+ it('openai: word timings switch to whisper-1 verbose_json', async () => {
78
+ const f = vi.fn(async () => new Response(JSON.stringify({ text: 'x', words: [] }), { status: 200 }));
79
+ await new OpenAISpeechProvider({ apiKey: 'k', fetchImpl: f as never }).transcribe({ ...req, timestamps: 'word', hints: ['Crochets', 'Raise'] });
80
+ const form = ((f.mock.calls[0] as unknown as [unknown, RequestInit])[1].body as FormData);
81
+ expect(form.get('model')).toBe('whisper-1');
82
+ expect(form.get('response_format')).toBe('verbose_json');
83
+ expect(form.get('prompt')).toBe('Crochets, Raise');
84
+ });
85
+ it('elevenlabs: sends the key header and Scribe model', async () => {
86
+ const f = vi.fn(async () => new Response(JSON.stringify({ text: 'x' }), { status: 200 }));
87
+ await new ElevenLabsSpeechProvider({ apiKey: 'sekret', fetchImpl: f as never }).transcribe(req);
88
+ const [url, init] = f.mock.calls[0] as unknown as [string, RequestInit];
89
+ expect(url).toBe('https://api.elevenlabs.io/v1/speech-to-text');
90
+ expect((init.headers as Record<string, string>)['xi-api-key']).toBe('sekret');
91
+ expect((init.body as FormData).get('model_id')).toBe('scribe_v1');
92
+ });
93
+ it('openai: a hint list handed back on silence is reported as no speech', async () => {
94
+ const f = vi.fn(async () => new Response(JSON.stringify({ text: 'Vault, Tandem, Foundry, Raise, Crochets' }), { status: 200 }));
95
+ const out = await new OpenAISpeechProvider({ apiKey: 'k', fetchImpl: f as never })
96
+ .transcribe({ ...req, hints: ['Crochets', 'Raise', 'Foundry', 'Tandem', 'Vault'] });
97
+ expect(out.text).toBe('');
98
+ });
99
+ it('openai: a scaffold word like "context:" on silence is no speech', async () => {
100
+ const f = vi.fn(async () => new Response(JSON.stringify({ text: 'context:' }), { status: 200 }));
101
+ const out = await new OpenAISpeechProvider({ apiKey: 'k', fetchImpl: f as never }).transcribe({ ...req, hints: ['Crochets'] });
102
+ expect(out.text).toBe('');
103
+ });
104
+ it('openai: real speech that merely mentions a hint is kept', async () => {
105
+ const f = vi.fn(async () => new Response(JSON.stringify({ text: 'Fix the Raise deck' }), { status: 200 }));
106
+ const out = await new OpenAISpeechProvider({ apiKey: 'k', fetchImpl: f as never }).transcribe({ ...req, hints: ['Raise'] });
107
+ expect(out.text).toBe('Fix the Raise deck');
108
+ });
109
+ it('audioExtension covers the Safari/Chrome split', () => {
110
+ expect(audioExtension('audio/mp4')).toBe('mp4');
111
+ expect(audioExtension('audio/webm;codecs=opus')).toBe('webm');
112
+ });
113
+ });
114
+
115
+ describe('SpeechService', () => {
116
+ const fake = (name: string, impl: () => Promise<any>): ISpeechProvider => ({
117
+ name, capabilities: { transcribe: true }, defaultModel: 'm', isAvailable: () => true,
118
+ transcribe: vi.fn(impl) as never,
119
+ });
120
+ const withProviders = (service: SpeechService, ...ps: ISpeechProvider[]) => {
121
+ (service as any).providers = new Map(ps.map((p) => [p.name, p]));
122
+ return service;
123
+ };
124
+ const okResult = (provider: string) => async () => ({ text: provider, model: 'm', provider, usage: {} });
125
+ const outage = async () => { throw new LLMProviderError('down', 'SERVICE_UNAVAILABLE', true, 503); };
126
+
127
+ it('prefers the configured provider', async () => {
128
+ const s = withProviders(new SpeechService({ preferredProvider: 'openai' }), fake('elevenlabs', okResult('elevenlabs')), fake('openai', okResult('openai')));
129
+ expect((await s.transcribe(req)).provider).toBe('openai');
130
+ });
131
+ it('with nothing preferred it uses what has a key (order = price, then registration)', async () => {
132
+ const s = withProviders(new SpeechService(), fake('elevenlabs', okResult('elevenlabs')), fake('openai', okResult('openai')));
133
+ expect((await s.transcribe(req)).provider).toBe('elevenlabs');
134
+ const only = withProviders(new SpeechService(), fake('openai', okResult('openai')));
135
+ expect((await only.transcribe(req)).provider).toBe('openai');
136
+ });
137
+ it('retries a retryable failure, then falls through to the next vendor', async () => {
138
+ const primary = fake('elevenlabs', outage);
139
+ const s = withProviders(new SpeechService({ retryDelayMs: 1, maxRetries: 2 }), primary, fake('openai', okResult('openai')));
140
+ expect((await s.transcribe(req)).provider).toBe('openai');
141
+ expect(primary.transcribe).toHaveBeenCalledTimes(3);
142
+ });
143
+ it('does not fall through on the caller\'s own bad input', async () => {
144
+ const bad = fake('elevenlabs', async () => { throw new LLMProviderError('bad audio', 'INVALID_REQUEST', false, 400); });
145
+ const next = fake('openai', okResult('openai'));
146
+ const s = withProviders(new SpeechService(), bad, next);
147
+ await expect(s.transcribe(req)).rejects.toMatchObject({ code: 'INVALID_REQUEST' });
148
+ expect(next.transcribe).not.toHaveBeenCalled();
149
+ });
150
+ it('fallback:false stays on one vendor', async () => {
151
+ const s = withProviders(new SpeechService({ fallback: false, retryDelayMs: 1, maxRetries: 0 }), fake('elevenlabs', outage), fake('openai', okResult('openai')));
152
+ await expect(s.transcribe(req)).rejects.toBeInstanceOf(LLMProviderError);
153
+ });
154
+ it('says what to set when nothing is configured', async () => {
155
+ const s = withProviders(new SpeechService());
156
+ await expect(s.transcribe(req)).rejects.toThrow(/GROQ_API_KEY, OPENAI_API_KEY or ELEVENLABS_API_KEY/);
157
+ expect(s.isAvailable()).toBe(false);
158
+ });
159
+ it('SPEECH_LANGUAGE is a default, and a per-call language wins', async () => {
160
+ vi.stubEnv('SPEECH_LANGUAGE', 'en');
161
+ const seen: Array<string | undefined> = [];
162
+ const spy: ISpeechProvider = { name: 'elevenlabs', capabilities: { transcribe: true }, defaultModel: 'm', isAvailable: () => true,
163
+ transcribe: (async (r: TranscribeRequest) => { seen.push(r.language); return { text: 'x', model: 'm', provider: 'elevenlabs', usage: {} }; }) as never };
164
+ const s = withProviders(new SpeechService(), spy);
165
+ await s.transcribe(req);
166
+ await s.transcribe({ ...req, language: 'fr' });
167
+ expect(seen).toEqual(['en', 'fr']);
168
+ });
169
+ it('SPEECH_PROVIDER=mock selects the deterministic provider', async () => {
170
+ vi.stubEnv('SPEECH_PROVIDER', 'mock');
171
+ setMockSpeechBehavior({ text: 'hi there' });
172
+ expect((await new SpeechService().transcribe(req)).text).toBe('hi there');
173
+ });
174
+ it('mock can simulate an outage that clears', async () => {
175
+ vi.stubEnv('SPEECH_PROVIDER', 'mock');
176
+ setMockSpeechBehavior({ failTimes: 2 });
177
+ expect((await new SpeechService({ retryDelayMs: 1 }).transcribe(req)).provider).toBe('mock');
178
+ });
179
+ });
180
+
181
+ describe('plugging in a new vendor', () => {
182
+ it('is one register call — no service edit', async () => {
183
+ class Whisperish implements ISpeechProvider {
184
+ name = 'selfhosted'; capabilities = { transcribe: true }; defaultModel = 'w';
185
+ isAvailable() { return true; }
186
+ async transcribe() { return { text: 'from my box', model: 'w', provider: 'selfhosted', usage: {} }; }
187
+ }
188
+ registerSpeechProvider('selfhosted', () => new Whisperish());
189
+ try {
190
+ const out = await new SpeechService({ preferredProvider: 'selfhosted' }).transcribe(req);
191
+ expect(out.text).toBe('from my box');
192
+ } finally {
193
+ speechProviders.unregister('selfhosted');
194
+ }
195
+ });
196
+ });
197
+
198
+ describe('ProviderRegistry', () => {
199
+ it('drops factories whose credentials are absent', () => {
200
+ const r = new ProviderRegistry<MockSpeechProvider>().register('a', () => new MockSpeechProvider()).register('b', () => null);
201
+ expect([...r.instantiate().keys()]).toEqual(['a']);
202
+ });
203
+ });
package/src/index.ts CHANGED
@@ -71,3 +71,7 @@ export type {
71
71
  AgentTurnResult,
72
72
  RunAgentOptions,
73
73
  } from './agent/types';
74
+
75
+ export { registerLLMProvider, llmProviders } from './service';
76
+ export { ProviderRegistry, pickProvider } from './providers/registry';
77
+ export type { Selectable, ProviderFactory } from './providers/registry';
@@ -0,0 +1,59 @@
1
+ /**
2
+ * One provider registry for every modality (text, speech, and whatever comes next).
3
+ *
4
+ * WHY THIS EXISTS. `LLMService` used to hard-code its providers in a private
5
+ * method, so adding one meant editing the service. Speech needed the same shape
6
+ * and would have copied that method. Now a modality owns a registry of
7
+ * name -> factory, built-ins register themselves, and a new provider — in this
8
+ * package or in an app — is a single `register(...)` call with no service edit.
9
+ *
10
+ * A factory returns `null` when its credentials are absent, which is how
11
+ * `createOpenAIProvider()` already behaved; the registry just formalises it.
12
+ */
13
+ export interface Selectable {
14
+ readonly name: string;
15
+ isAvailable(): boolean;
16
+ }
17
+
18
+ export type ProviderFactory<P extends Selectable> = () => P | null;
19
+
20
+ export class ProviderRegistry<P extends Selectable> {
21
+ private readonly factories = new Map<string, ProviderFactory<P>>();
22
+
23
+ /** Register (or replace) a provider by name. Later registrations win. */
24
+ register(name: string, factory: ProviderFactory<P>): this {
25
+ this.factories.set(name, factory);
26
+ return this;
27
+ }
28
+
29
+ unregister(name: string): boolean {
30
+ return this.factories.delete(name);
31
+ }
32
+
33
+ names(): string[] {
34
+ return [...this.factories.keys()];
35
+ }
36
+
37
+ /** Build every registered provider that its own environment allows. */
38
+ instantiate(): Map<string, P> {
39
+ const out = new Map<string, P>();
40
+ for (const [name, factory] of this.factories) {
41
+ const provider = factory();
42
+ if (provider) out.set(name, provider);
43
+ }
44
+ return out;
45
+ }
46
+ }
47
+
48
+ /** The preferred provider if it is usable, otherwise the first one that is. */
49
+ export function pickProvider<P extends Selectable>(
50
+ providers: Map<string, P>,
51
+ preferred?: string,
52
+ ): P | undefined {
53
+ const first = preferred ? providers.get(preferred) : undefined;
54
+ if (first?.isAvailable()) return first;
55
+ for (const provider of providers.values()) {
56
+ if (provider.isAvailable()) return provider;
57
+ }
58
+ return undefined;
59
+ }
package/src/service.ts CHANGED
@@ -26,6 +26,20 @@ import {
26
26
  import { OpenAIProvider, createOpenAIProvider } from './providers/openai';
27
27
  import { AnthropicProvider, createAnthropicProvider } from './providers/anthropic';
28
28
  import { MockLLMProvider } from './providers/mock';
29
+ import { ProviderRegistry, pickProvider } from './providers/registry';
30
+
31
+ /**
32
+ * The text-provider registry. Built-ins register below; anything else —
33
+ * a self-hosted model, a new vendor — is `registerLLMProvider(name, factory)`
34
+ * and needs no change to `LLMService`.
35
+ */
36
+ export const llmProviders = new ProviderRegistry<ILLMProvider>()
37
+ .register('openai', () => createOpenAIProvider())
38
+ .register('anthropic', () => createAnthropicProvider());
39
+
40
+ export function registerLLMProvider(name: string, factory: () => ILLMProvider | null): void {
41
+ llmProviders.register(name, factory);
42
+ }
29
43
 
30
44
  /**
31
45
  * Metadata about a generation attempt (tokens, model, retries).
@@ -83,22 +97,14 @@ export class LLMService<TOutput> {
83
97
  return;
84
98
  }
85
99
 
86
- const openai = createOpenAIProvider();
87
- if (openai) this.providers.set('openai', openai);
88
-
89
- const anthropic = createAnthropicProvider();
90
- if (anthropic) this.providers.set('anthropic', anthropic);
100
+ for (const [name, provider] of llmProviders.instantiate()) {
101
+ this.providers.set(name, provider);
102
+ }
91
103
  }
92
104
 
93
105
  private getProvider(): ILLMProvider {
94
- // Try preferred first
95
- const preferred = this.providers.get(this.config.preferredProvider);
96
- if (preferred?.isAvailable()) return preferred;
97
-
98
- // Fallback to any available
99
- for (const provider of this.providers.values()) {
100
- if (provider.isAvailable()) return provider;
101
- }
106
+ const picked = pickProvider(this.providers, this.config.preferredProvider);
107
+ if (picked) return picked;
102
108
 
103
109
  throw new LLMProviderError(
104
110
  'No LLM providers available. Please configure OPENAI_API_KEY or ANTHROPIC_API_KEY.',
@@ -0,0 +1,73 @@
1
+ import { LLMProviderError } from '../errors';
2
+ import type { LLMErrorCode } from '../errors';
3
+ import type { TranscribeRequest } from './types';
4
+
5
+ /** Container extension a vendor can recognise, from the recorder's MIME type. */
6
+ const EXTENSIONS: Record<string, string> = {
7
+ 'audio/webm': 'webm', 'video/webm': 'webm', 'audio/ogg': 'ogg', 'audio/mp4': 'mp4',
8
+ 'audio/x-m4a': 'm4a', 'audio/m4a': 'm4a', 'audio/mpeg': 'mp3', 'audio/mp3': 'mp3',
9
+ 'audio/wav': 'wav', 'audio/x-wav': 'wav', 'audio/flac': 'flac',
10
+ };
11
+
12
+ /**
13
+ * THE FILE NAME MATTERS. Vendors infer the container from it, and browsers
14
+ * disagree: Chrome/Firefox record webm/ogg, Safari records mp4. A webm blob
15
+ * uploaded as `.mp4` is rejected as unsupported, so the name comes from the
16
+ * MIME type, never a constant.
17
+ */
18
+ export function audioExtension(mimeType: string): string {
19
+ const base = (mimeType || '').split(';')[0].trim().toLowerCase();
20
+ return EXTENSIONS[base] ?? 'webm';
21
+ }
22
+
23
+ export function audioBlob(request: Pick<TranscribeRequest, 'audio' | 'mimeType'>): { blob: Blob; filename: string } {
24
+ const base = (request.mimeType || 'audio/webm').split(';')[0].trim() || 'audio/webm';
25
+ return {
26
+ blob: new Blob([new Uint8Array(request.audio)], { type: base }),
27
+ filename: `audio.${audioExtension(request.mimeType)}`,
28
+ };
29
+ }
30
+
31
+ export function assertAudio(request: Pick<TranscribeRequest, 'audio'>, provider: string): void {
32
+ if (!request.audio?.byteLength) {
33
+ throw new LLMProviderError('There is no audio to transcribe.', 'INVALID_REQUEST', false, undefined, provider);
34
+ }
35
+ }
36
+
37
+ /** One HTTP-status -> error-code mapping, so every vendor classifies failures the same way. */
38
+ export function speechHttpError(status: number, body: string, provider: string): LLMProviderError {
39
+ const code: LLMErrorCode =
40
+ status === 401 || status === 403 ? 'AUTHENTICATION_ERROR'
41
+ : status === 429 ? 'RATE_LIMITED'
42
+ : status === 408 || status === 504 ? 'TIMEOUT'
43
+ : status >= 500 ? 'SERVICE_UNAVAILABLE'
44
+ : status === 400 || status === 413 || status === 415 || status === 422 ? 'INVALID_REQUEST'
45
+ : 'UNKNOWN_ERROR';
46
+ const retryable = code === 'RATE_LIMITED' || code === 'SERVICE_UNAVAILABLE' || code === 'TIMEOUT';
47
+ return new LLMProviderError(
48
+ `${provider} transcription failed (${status}): ${body.slice(0, 300)}`,
49
+ code, retryable, status, provider,
50
+ );
51
+ }
52
+
53
+ /** fetch with a deadline; a timeout or network drop becomes a typed, retryable error. */
54
+ export async function speechFetch(
55
+ provider: string,
56
+ url: string,
57
+ init: RequestInit,
58
+ timeoutMs = 240_000,
59
+ fetchImpl: typeof fetch = fetch,
60
+ ): Promise<Response> {
61
+ try {
62
+ const response = await fetchImpl(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
63
+ if (!response.ok) throw speechHttpError(response.status, await response.text().catch(() => ''), provider);
64
+ return response;
65
+ } catch (error) {
66
+ if (error instanceof LLMProviderError) throw error;
67
+ const timedOut = error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError');
68
+ throw new LLMProviderError(
69
+ timedOut ? `${provider} did not answer within ${Math.round(timeoutMs / 1000)}s.` : `${provider} request failed: ${(error as Error)?.message ?? error}`,
70
+ timedOut ? 'TIMEOUT' : 'SERVICE_UNAVAILABLE', true, undefined, provider,
71
+ );
72
+ }
73
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `@startsimpli/llm/speech` — pluggable speech-to-text.
3
+ *
4
+ * const speech = new SpeechService(); // SPEECH_PROVIDER, else the cheapest vendor with a key
5
+ * const { text } = await speech.transcribe({ audio, mimeType: 'audio/webm' });
6
+ *
7
+ * A new vendor: implement `ISpeechProvider`, then `registerSpeechProvider(name, factory)`.
8
+ */
9
+ export { SpeechService, registerSpeechProvider, speechProviders } from './service';
10
+ export type { ISpeechProvider } from './interface';
11
+ export type {
12
+ SpeechServiceConfig, SpeechUsage, TranscribeRequest, TranscriptResult, TranscriptWord,
13
+ } from './types';
14
+ export { ElevenLabsSpeechProvider, createElevenLabsSpeechProvider } from './providers/elevenlabs';
15
+ export { OpenAISpeechProvider, createOpenAISpeechProvider } from './providers/openai';
16
+ export type { OpenAISpeechOptions } from './providers/openai';
17
+ export { GroqSpeechProvider, createGroqSpeechProvider } from './providers/groq';
18
+ export { MockSpeechProvider, setMockSpeechBehavior, resetMockSpeechBehavior } from './providers/mock';
19
+ export { audioExtension, speechHttpError } from './http';
@@ -0,0 +1,25 @@
1
+ import type { Selectable } from '../providers/registry';
2
+ import type { TranscribeRequest, TranscriptResult } from './types';
3
+
4
+ /**
5
+ * What a speech vendor implements. A provider supports whichever directions its
6
+ * API does — `capabilities` says which, so a caller (or the service) never has
7
+ * to call a method and catch "not supported".
8
+ *
9
+ * ADDING A VENDOR: implement this, then
10
+ * `registerSpeechProvider('name', () => new Mine(process.env.MINE_KEY) )` — no
11
+ * edit to `SpeechService`. Text-to-speech joins here as an optional
12
+ * `synthesize` alongside `transcribe` (bd startsim-2lnyg); nothing in the
13
+ * service needs to change for it either.
14
+ */
15
+ export interface ISpeechProvider extends Selectable {
16
+ readonly capabilities: { transcribe: boolean };
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;
24
+ transcribe?(request: TranscribeRequest): Promise<TranscriptResult>;
25
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * ElevenLabs Scribe speech-to-text. https://elevenlabs.io/docs/api-reference/speech-to-text
3
+ * Env: ELEVENLABS_API_KEY.
4
+ */
5
+ import type { ISpeechProvider } from '../interface';
6
+ import { assertAudio, audioBlob, speechFetch } from '../http';
7
+ import type { TranscribeRequest, TranscriptResult, TranscriptWord } from '../types';
8
+
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;
16
+ apiKey?: string;
17
+ baseUrl?: string;
18
+ fetchImpl?: typeof fetch;
19
+ }
20
+
21
+ interface ScribeWord { text: string; start?: number; end?: number; type?: string; speaker_id?: string }
22
+
23
+ export class ElevenLabsSpeechProvider implements ISpeechProvider {
24
+ readonly name = 'elevenlabs';
25
+ readonly capabilities = { transcribe: true };
26
+ readonly defaultModel = 'scribe_v1';
27
+ private readonly apiKey: string;
28
+ private readonly baseUrl: string;
29
+ private readonly fetchImpl?: typeof fetch;
30
+ private readonly sendKeyterms: boolean;
31
+
32
+ constructor(options: ElevenLabsOptions = {}) {
33
+ this.sendKeyterms = options.sendKeyterms ?? false;
34
+ this.apiKey = options.apiKey ?? '';
35
+ this.baseUrl = (options.baseUrl ?? 'https://api.elevenlabs.io').replace(/\/+$/, '');
36
+ this.fetchImpl = options.fetchImpl;
37
+ }
38
+
39
+ isAvailable(): boolean {
40
+ return Boolean(this.apiKey);
41
+ }
42
+
43
+ /** Scribe list price: $0.22 per audio hour. */
44
+ costPerMinuteUsd(): number {
45
+ return 0.22 / 60;
46
+ }
47
+
48
+ async transcribe(request: TranscribeRequest): Promise<TranscriptResult> {
49
+ assertAudio(request, this.name);
50
+ const model = request.model ?? this.defaultModel;
51
+ const { blob, filename } = audioBlob(request);
52
+ const form = new FormData();
53
+ form.append('file', blob, filename);
54
+ form.append('model_id', model);
55
+ form.append('timestamps_granularity', request.timestamps === 'none' ? 'none' : 'word');
56
+ if (request.language) form.append('language_code', request.language);
57
+ if (request.diarize) form.append('diarize', 'true');
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);
60
+
61
+ const response = await speechFetch(
62
+ this.name, `${this.baseUrl}/v1/speech-to-text`,
63
+ { method: 'POST', headers: { 'xi-api-key': this.apiKey }, body: form },
64
+ request.timeoutMs, this.fetchImpl,
65
+ );
66
+ const json = (await response.json()) as { text?: string; language_code?: string; words?: ScribeWord[] };
67
+ const words: TranscriptWord[] | undefined = json.words
68
+ ?.filter((w) => (w.type ?? 'word') === 'word' && typeof w.start === 'number' && typeof w.end === 'number')
69
+ .map((w) => ({ text: w.text, start: w.start as number, end: w.end as number, ...(w.speaker_id ? { speaker: w.speaker_id } : {}) }));
70
+ const audioSeconds = words?.length ? words[words.length - 1].end : undefined;
71
+ return {
72
+ text: (json.text ?? '').trim(),
73
+ ...(json.language_code ? { language: json.language_code } : {}),
74
+ ...(words?.length ? { words } : {}),
75
+ model, provider: this.name,
76
+ usage: audioSeconds !== undefined ? { audioSeconds } : {},
77
+ };
78
+ }
79
+ }
80
+
81
+ export function createElevenLabsSpeechProvider(
82
+ env: Record<string, string | undefined> = typeof process !== 'undefined' ? process.env : {},
83
+ ): ElevenLabsSpeechProvider | null {
84
+ return env.ELEVENLABS_API_KEY ? new ElevenLabsSpeechProvider({ apiKey: env.ELEVENLABS_API_KEY }) : null;
85
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Groq-hosted Whisper — the cheapest option we benchmarked ($0.04 per audio hour,
3
+ * about a fifth of ElevenLabs Scribe). Groq speaks the OpenAI transcription API,
4
+ * so this is the OpenAI provider pointed at a different host: that is the whole
5
+ * file, and the template for any other OpenAI-compatible vendor.
6
+ * Env: GROQ_API_KEY.
7
+ */
8
+ import { OpenAISpeechProvider } from './openai';
9
+ import type { OpenAISpeechOptions } from './openai';
10
+
11
+ const GROQ_PRICES: Record<string, number> = {
12
+ 'whisper-large-v3-turbo': 0.04 / 60,
13
+ 'whisper-large-v3': 0.111 / 60,
14
+ };
15
+
16
+ export class GroqSpeechProvider extends OpenAISpeechProvider {
17
+ constructor(options: Pick<OpenAISpeechOptions, 'apiKey' | 'fetchImpl' | 'baseUrl'> = {}) {
18
+ super({
19
+ ...options,
20
+ name: 'groq',
21
+ baseUrl: options.baseUrl ?? 'https://api.groq.com/openai',
22
+ defaultModel: 'whisper-large-v3-turbo',
23
+ // Groq's Whisper models return word timings from verbose_json themselves.
24
+ wordTimestampModel: 'whisper-large-v3-turbo',
25
+ prices: GROQ_PRICES,
26
+ fallbackCostPerMinuteUsd: 0.111 / 60,
27
+ });
28
+ }
29
+ }
30
+
31
+ export function createGroqSpeechProvider(
32
+ env: Record<string, string | undefined> = typeof process !== 'undefined' ? process.env : {},
33
+ ): GroqSpeechProvider | null {
34
+ return env.GROQ_API_KEY ? new GroqSpeechProvider({ apiKey: env.GROQ_API_KEY }) : null;
35
+ }
@@ -0,0 +1,41 @@
1
+ import { LLMProviderError } from '../../errors';
2
+ import type { ISpeechProvider } from '../interface';
3
+ import type { TranscribeRequest, TranscriptResult } from '../types';
4
+
5
+ export interface MockSpeechBehavior {
6
+ text?: string;
7
+ /** Throw this many retryable failures before succeeding. */
8
+ failTimes?: number;
9
+ /** Throw a non-retryable auth error every time. */
10
+ authError?: boolean;
11
+ }
12
+
13
+ let behavior: MockSpeechBehavior = {};
14
+ let remainingFailures = 0;
15
+ export function setMockSpeechBehavior(next: MockSpeechBehavior) {
16
+ behavior = next;
17
+ remainingFailures = next.failTimes ?? 0;
18
+ }
19
+ export function resetMockSpeechBehavior() {
20
+ behavior = {};
21
+ remainingFailures = 0;
22
+ }
23
+
24
+ /** Deterministic transcription for tests — selected with `SPEECH_PROVIDER=mock`. */
25
+ export class MockSpeechProvider implements ISpeechProvider {
26
+ readonly name = 'mock';
27
+ readonly capabilities = { transcribe: true };
28
+ readonly defaultModel = 'mock-transcribe';
29
+ isAvailable() { return true; }
30
+ async transcribe(request: TranscribeRequest): Promise<TranscriptResult> {
31
+ if (behavior.authError) throw new LLMProviderError('mock auth failure', 'AUTHENTICATION_ERROR', false, 401, this.name);
32
+ if (remainingFailures > 0) {
33
+ remainingFailures -= 1;
34
+ throw new LLMProviderError('mock outage', 'SERVICE_UNAVAILABLE', true, 503, this.name);
35
+ }
36
+ return {
37
+ text: behavior.text ?? `mock transcript of ${request.audio.byteLength} bytes`,
38
+ model: this.defaultModel, provider: this.name, usage: { audioSeconds: 1 },
39
+ };
40
+ }
41
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * OpenAI speech-to-text. Env: OPENAI_API_KEY (the same key the text provider uses).
3
+ *
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
6
+ * `whisper-1` (verbose_json), so asking for `timestamps: 'word'` switches model.
7
+ *
8
+ * ANY OpenAI-COMPATIBLE ENDPOINT IS THIS CLASS WITH DIFFERENT OPTIONS (see
9
+ * groq.ts): name, base URL, default model, word-timing model and price table.
10
+ */
11
+ import type { ISpeechProvider } from '../interface';
12
+ import { assertAudio, audioBlob, speechFetch } from '../http';
13
+ import type { TranscribeRequest, TranscriptResult, TranscriptWord } from '../types';
14
+
15
+ export interface OpenAISpeechOptions {
16
+ apiKey?: string;
17
+ baseUrl?: string;
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;
27
+ }
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
+
35
+ export class OpenAISpeechProvider implements ISpeechProvider {
36
+ readonly name: string;
37
+ readonly capabilities = { transcribe: true };
38
+ readonly defaultModel: string;
39
+ private readonly apiKey: string;
40
+ private readonly baseUrl: string;
41
+ private readonly fetchImpl?: typeof fetch;
42
+ private readonly wordModel: string;
43
+ private readonly prices: Record<string, number>;
44
+ private readonly fallbackPrice: number;
45
+
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;
52
+ this.apiKey = options.apiKey ?? '';
53
+ this.baseUrl = (options.baseUrl ?? 'https://api.openai.com').replace(/\/+$/, '');
54
+ this.fetchImpl = options.fetchImpl;
55
+ }
56
+
57
+ isAvailable(): boolean {
58
+ return Boolean(this.apiKey);
59
+ }
60
+
61
+ costPerMinuteUsd(model?: string): number {
62
+ return this.prices[model ?? this.defaultModel] ?? this.fallbackPrice;
63
+ }
64
+
65
+ async transcribe(request: TranscribeRequest): Promise<TranscriptResult> {
66
+ assertAudio(request, this.name);
67
+ const wantWords = request.timestamps === 'word';
68
+ const model = request.model ?? (wantWords ? this.wordModel : this.defaultModel);
69
+ const { blob, filename } = audioBlob(request);
70
+ const form = new FormData();
71
+ form.append('file', blob, filename);
72
+ form.append('model', model);
73
+ form.append('response_format', wantWords ? 'verbose_json' : 'json');
74
+ if (wantWords) form.append('timestamp_granularities[]', 'word');
75
+ if (request.language) form.append('language', request.language);
76
+ if (request.hints?.length) form.append('prompt', request.hints.join(', '));
77
+
78
+ const response = await speechFetch(
79
+ this.name, `${this.baseUrl}/v1/audio/transcriptions`,
80
+ { method: 'POST', headers: { authorization: `Bearer ${this.apiKey}` }, body: form },
81
+ request.timeoutMs, this.fetchImpl,
82
+ );
83
+ const json = (await response.json()) as {
84
+ text?: string; language?: string; duration?: number;
85
+ words?: Array<{ word: string; start: number; end: number }>;
86
+ usage?: { seconds?: number };
87
+ };
88
+ // On silence the model can hand the biasing prompt straight back as the
89
+ // "transcript". That is not speech, so report none rather than a confident lie.
90
+ const heard = (json.text ?? '').trim();
91
+ const echoed = isEcho(heard, request.hints);
92
+ const words: TranscriptWord[] | undefined = json.words?.map((w) => ({ text: w.word, start: w.start, end: w.end }));
93
+ const audioSeconds = json.usage?.seconds ?? json.duration;
94
+ return {
95
+ text: echoed ? '' : heard,
96
+ ...(json.language ? { language: json.language } : {}),
97
+ ...(words?.length ? { words } : {}),
98
+ model, provider: this.name,
99
+ usage: audioSeconds !== undefined ? { audioSeconds } : {},
100
+ };
101
+ }
102
+ }
103
+
104
+ export function createOpenAISpeechProvider(
105
+ env: Record<string, string | undefined> = typeof process !== 'undefined' ? process.env : {},
106
+ ): OpenAISpeechProvider | null {
107
+ return env.OPENAI_API_KEY ? new OpenAISpeechProvider({ apiKey: env.OPENAI_API_KEY }) : null;
108
+ }
109
+
110
+ const tokens = (text: string) => text.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
111
+
112
+ /** True when every word heard is one of the hint words — i.e. the prompt came back, not speech. */
113
+ function isEcho(heard: string, hints?: string[]): boolean {
114
+ if (!hints?.length) return false;
115
+ const said = tokens(heard);
116
+ // Scaffold words a model attaches when it parrots the prompt ("context:", "projects:").
117
+ const allowed = new Set([...tokens(hints.join(' ')), 'context', 'prompt', 'vocabulary', 'glossary', 'terms', 'projects', 'names', 'keywords']);
118
+ return said.length > 0 && said.every((word) => allowed.has(word));
119
+ }
@@ -0,0 +1,103 @@
1
+ import { LLMProviderError } from '../errors';
2
+ import { ProviderRegistry } from '../providers/registry';
3
+ import type { ISpeechProvider } from './interface';
4
+ import { createElevenLabsSpeechProvider } from './providers/elevenlabs';
5
+ import { createGroqSpeechProvider } from './providers/groq';
6
+ import { MockSpeechProvider } from './providers/mock';
7
+ import { createOpenAISpeechProvider } from './providers/openai';
8
+ import type { SpeechServiceConfig, TranscribeRequest, TranscriptResult } from './types';
9
+
10
+ /**
11
+ * The speech registry. Built-ins register here; a new vendor is
12
+ * `registerSpeechProvider('name', factory)` — no edit to `SpeechService`.
13
+ */
14
+ export const speechProviders = new ProviderRegistry<ISpeechProvider>()
15
+ .register('groq', () => createGroqSpeechProvider())
16
+ .register('openai', () => createOpenAISpeechProvider())
17
+ .register('elevenlabs', () => createElevenLabsSpeechProvider());
18
+
19
+ export function registerSpeechProvider(name: string, factory: () => ISpeechProvider | null): void {
20
+ speechProviders.register(name, factory);
21
+ }
22
+
23
+ const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
24
+
25
+ /**
26
+ * Provider-agnostic speech. Pick a vendor with `preferredProvider` or the
27
+ * `SPEECH_PROVIDER` env var; with neither it uses the CHEAPEST vendor that has a
28
+ * key (each provider states its list price) and falls back to the next cheapest. A retryable failure is retried with backoff, and
29
+ * one that survives the retries falls through to the next available vendor —
30
+ * unless the error is the caller's (bad audio), which no other vendor would fix.
31
+ */
32
+ export class SpeechService {
33
+ private readonly config: Required<SpeechServiceConfig>;
34
+ private readonly providers: Map<string, ISpeechProvider>;
35
+
36
+ constructor(config: SpeechServiceConfig = {}) {
37
+ const env: Record<string, string | undefined> = typeof process !== 'undefined' ? process.env : {};
38
+ this.config = {
39
+ preferredProvider: config.preferredProvider ?? env.SPEECH_PROVIDER ?? '',
40
+ maxRetries: config.maxRetries ?? 2,
41
+ fallback: config.fallback ?? true,
42
+ retryDelayMs: config.retryDelayMs ?? 500,
43
+ };
44
+ this.providers = env.SPEECH_PROVIDER === 'mock'
45
+ ? new Map([['mock', new MockSpeechProvider() as ISpeechProvider]])
46
+ : speechProviders.instantiate();
47
+ }
48
+
49
+ isAvailable(): boolean {
50
+ return this.getAvailableProviders().length > 0;
51
+ }
52
+
53
+ getAvailableProviders(): string[] {
54
+ return [...this.providers].filter(([, p]) => p.isAvailable() && p.capabilities.transcribe).map(([n]) => n);
55
+ }
56
+
57
+ /** Providers in the order they will be tried: the preferred one (if any), then cheapest first. */
58
+ private order(): ISpeechProvider[] {
59
+ const usable = [...this.providers.values()].filter((p) => p.isAvailable() && p.capabilities.transcribe);
60
+ const price = (p: ISpeechProvider) => p.costPerMinuteUsd?.() ?? Number.POSITIVE_INFINITY;
61
+ // Array.sort is stable, so equal/unknown prices keep registration order.
62
+ const byPrice = [...usable].sort((a, b) => price(a) - price(b));
63
+ const wanted = this.config.preferredProvider
64
+ ? usable.find((p) => p.name === this.config.preferredProvider)
65
+ : undefined;
66
+ const first = wanted ?? byPrice[0];
67
+ if (!first) return [];
68
+ return this.config.fallback ? [first, ...byPrice.filter((p) => p !== first)] : [first];
69
+ }
70
+
71
+ async transcribe(input: TranscribeRequest): Promise<TranscriptResult> {
72
+ // A deployment-wide language default stops auto-detect drifting on short clips
73
+ // (an English note came back in French once). A per-call `language` wins.
74
+ const env: Record<string, string | undefined> = typeof process !== 'undefined' ? process.env : {};
75
+ const request = input.language || !env.SPEECH_LANGUAGE ? input : { ...input, language: env.SPEECH_LANGUAGE };
76
+ const order = this.order();
77
+ if (!order.length) {
78
+ throw new LLMProviderError(
79
+ 'No speech providers available. Set GROQ_API_KEY, OPENAI_API_KEY or ELEVENLABS_API_KEY.',
80
+ 'AUTHENTICATION_ERROR', false,
81
+ );
82
+ }
83
+ let last: unknown;
84
+ for (const provider of order) {
85
+ for (let attempt = 0; ; attempt += 1) {
86
+ try {
87
+ return await provider.transcribe!(request);
88
+ } catch (error) {
89
+ last = error;
90
+ const typed = error instanceof LLMProviderError ? error : null;
91
+ if (typed?.retryable && attempt < this.config.maxRetries) {
92
+ await sleep(this.config.retryDelayMs * 2 ** attempt);
93
+ continue;
94
+ }
95
+ // The caller's input is bad — another vendor would say the same.
96
+ if (typed?.code === 'INVALID_REQUEST') throw error;
97
+ break;
98
+ }
99
+ }
100
+ }
101
+ throw last;
102
+ }
103
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Speech types — provider-neutral. Nothing here names a vendor, an app, or a
3
+ * use case (voice notes, video narration): a provider maps its own API onto
4
+ * these shapes and a caller never sees the vendor's.
5
+ */
6
+ export interface TranscriptWord {
7
+ text: string;
8
+ /** Seconds from the start of the audio. */
9
+ start: number;
10
+ end: number;
11
+ speaker?: string;
12
+ }
13
+
14
+ export interface SpeechUsage {
15
+ /** Billable input, when the provider reports it; else derived from word timings. */
16
+ audioSeconds?: number;
17
+ /** Characters synthesized (TTS). */
18
+ characters?: number;
19
+ }
20
+
21
+ export interface TranscribeRequest {
22
+ audio: Uint8Array;
23
+ /** The recorder's MIME type, parameters allowed (`audio/webm;codecs=opus`). */
24
+ mimeType: string;
25
+ /** ISO-639 hint. Omit to auto-detect. */
26
+ language?: string;
27
+ /**
28
+ * Words the speaker is likely to say (names, product terms). Providers that
29
+ * can bias recognition do; the rest ignore it.
30
+ */
31
+ hints?: string[];
32
+ /** `word` asks for per-word timings; providers that cannot return `words: undefined`. */
33
+ timestamps?: 'none' | 'word';
34
+ diarize?: boolean;
35
+ /** Provider-specific model override. */
36
+ model?: string;
37
+ timeoutMs?: number;
38
+ }
39
+
40
+ export interface TranscriptResult {
41
+ text: string;
42
+ language?: string;
43
+ words?: TranscriptWord[];
44
+ /** The model that answered, as the vendor reports it. */
45
+ model: string;
46
+ provider: string;
47
+ usage: SpeechUsage;
48
+ }
49
+
50
+ export interface SpeechServiceConfig {
51
+ /** Provider tried first. Default: `SPEECH_PROVIDER`, else the cheapest vendor with a key. */
52
+ preferredProvider?: string;
53
+ /** Retries on a retryable error, per provider. Default 2. */
54
+ maxRetries?: number;
55
+ /** On a failure that survives retries, try the next available provider. Default true. */
56
+ fallback?: boolean;
57
+ /** Base delay for exponential backoff, ms. Default 500. */
58
+ retryDelayMs?: number;
59
+ }