@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 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, else ElevenLabs, else any vendor with a key
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
- | `ELEVENLABS_API_KEY` | ElevenLabs Scribe (default) |
198
- | `OPENAI_API_KEY` | OpenAI `gpt-4o-transcribe` / `whisper-1` for word timings |
199
- | `SPEECH_PROVIDER=mock` | deterministic, for tests |
200
- | `SPEECH_PROVIDER` / `SPEECH_LANGUAGE` | pick the vendor / pin the language (e.g. `en`) so short clips don't mis-detect |
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
- available vendor (`fallback: false` to pin one). Bad audio (`INVALID_REQUEST`) never falls through.
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; no service edit:
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/llm",
3
- "version": "0.4.26",
3
+ "version": "0.4.28",
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",
@@ -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('defaults to ElevenLabs, and falls back to what has a key', async () => {
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(/ELEVENLABS_API_KEY or OPENAI_API_KEY/);
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 () => {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `@startsimpli/llm/speech` — pluggable speech-to-text.
3
3
  *
4
- * const speech = new SpeechService(); // SPEECH_PROVIDER or ElevenLabs, fallback to OpenAI
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';
@@ -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 on models that take it.
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` for plain text; per-word timings are only offered by
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 = 'openai';
36
+ readonly name: string;
19
37
  readonly capabilities = { transcribe: true };
20
- readonly defaultModel = 'gpt-4o-transcribe';
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 ? 'whisper-1' : this.defaultModel);
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);
@@ -1,5 +1,5 @@
1
1
  import { LLMProviderError } from '../errors';
2
- import { ProviderRegistry, pickProvider } from '../providers/registry';
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('elevenlabs', () => createElevenLabsSpeechProvider())
15
- .register('openai', () => createOpenAISpeechProvider());
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 prefers ElevenLabs and falls back
26
- * to whatever else has a key. A retryable failure is retried with backoff, and
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 ?? 'elevenlabs',
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 = new Map([...this.providers].filter(([, p]) => p.isAvailable() && p.capabilities.transcribe));
58
- const first = pickProvider(usable, this.config.preferredProvider);
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, ...[...usable.values()].filter((p) => p !== first)] : [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 ELEVENLABS_API_KEY or OPENAI_API_KEY.',
77
+ 'No speech providers available. Set OPENAI_API_KEY or ELEVENLABS_API_KEY.',
72
78
  'AUTHENTICATION_ERROR', false,
73
79
  );
74
80
  }
@@ -48,7 +48,7 @@ export interface TranscriptResult {
48
48
  }
49
49
 
50
50
  export interface SpeechServiceConfig {
51
- /** Provider tried first. Default: `SPEECH_PROVIDER`, else `elevenlabs`. */
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;