@startsimpli/llm 0.4.24 → 0.4.26

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.
@@ -19,6 +19,7 @@ import type {
19
19
  NativeToolResponse,
20
20
  ReasoningEffort,
21
21
  LLMImage,
22
+ LLMDocument,
22
23
  TokenUsage,
23
24
  } from '../types';
24
25
  import { LLM_DEFAULTS } from '../types';
@@ -27,19 +28,28 @@ import type { LLMErrorCode } from '../errors';
27
28
 
28
29
  type OpenAIContentPart =
29
30
  | { type: 'text'; text: string }
30
- | { type: 'image_url'; image_url: { url: string } };
31
+ | { type: 'image_url'; image_url: { url: string } }
32
+ | { type: 'file'; file: { filename: string; file_data: string } };
31
33
 
32
34
  interface OpenAIMessage {
33
35
  role: 'system' | 'user' | 'assistant';
34
36
  content: string | OpenAIContentPart[];
35
37
  }
36
38
 
37
- /** The user message: plain text, or text plus images for vision models. */
38
- function userContent(prompt: string, images?: LLMImage[]): string | OpenAIContentPart[] {
39
- if (!images?.length) return prompt;
39
+ /**
40
+ * The user message: plain text, or text followed by documents (file parts) and
41
+ * images. OpenAI requires a filename on a file part, so an unnamed document
42
+ * gets a default one.
43
+ */
44
+ function userContent(prompt: string, images?: LLMImage[], documents?: LLMDocument[]): string | OpenAIContentPart[] {
45
+ if (!images?.length && !documents?.length) return prompt;
40
46
  return [
41
47
  { type: 'text', text: prompt },
42
- ...images.map((img) => ({ type: 'image_url' as const, image_url: { url: `data:${img.mediaType};base64,${img.data}` } })),
48
+ ...(documents ?? []).map((doc, i) => ({
49
+ type: 'file' as const,
50
+ file: { filename: doc.filename || `document-${i + 1}.pdf`, file_data: `data:${doc.mediaType};base64,${doc.data}` },
51
+ })),
52
+ ...(images ?? []).map((img) => ({ type: 'image_url' as const, image_url: { url: `data:${img.mediaType};base64,${img.data}` } })),
43
53
  ];
44
54
  }
45
55
 
@@ -364,7 +374,7 @@ export class OpenAIProvider implements ILLMProvider {
364
374
  model,
365
375
  messages: [
366
376
  { role: 'system' as const, content: systemPrompt },
367
- { role: 'user' as const, content: userContent(userPrompt, options?.images) },
377
+ { role: 'user' as const, content: userContent(userPrompt, options?.images, options?.documents) },
368
378
  ],
369
379
  response_format: { type: 'json_object' as const },
370
380
  stream: true,
@@ -466,7 +476,7 @@ export class OpenAIProvider implements ILLMProvider {
466
476
  model,
467
477
  messages: [
468
478
  { role: 'system' as const, content: systemPrompt },
469
- { role: 'user' as const, content: userContent(userPrompt, options?.images) },
479
+ { role: 'user' as const, content: userContent(userPrompt, options?.images, options?.documents) },
470
480
  ],
471
481
  response_format: { type: 'json_object' as const },
472
482
  };
@@ -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,17 @@
1
+ /**
2
+ * `@startsimpli/llm/speech` — pluggable speech-to-text.
3
+ *
4
+ * const speech = new SpeechService(); // SPEECH_PROVIDER or ElevenLabs, fallback to OpenAI
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 { MockSpeechProvider, setMockSpeechBehavior, resetMockSpeechBehavior } from './providers/mock';
17
+ export { audioExtension, speechHttpError } from './http';
@@ -0,0 +1,19 @@
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
+ transcribe?(request: TranscribeRequest): Promise<TranscriptResult>;
19
+ }
@@ -0,0 +1,72 @@
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
+ apiKey?: string;
11
+ baseUrl?: string;
12
+ fetchImpl?: typeof fetch;
13
+ }
14
+
15
+ interface ScribeWord { text: string; start?: number; end?: number; type?: string; speaker_id?: string }
16
+
17
+ export class ElevenLabsSpeechProvider implements ISpeechProvider {
18
+ readonly name = 'elevenlabs';
19
+ readonly capabilities = { transcribe: true };
20
+ readonly defaultModel = 'scribe_v1';
21
+ private readonly apiKey: string;
22
+ private readonly baseUrl: string;
23
+ private readonly fetchImpl?: typeof fetch;
24
+
25
+ constructor(options: ElevenLabsOptions = {}) {
26
+ this.apiKey = options.apiKey ?? '';
27
+ this.baseUrl = (options.baseUrl ?? 'https://api.elevenlabs.io').replace(/\/+$/, '');
28
+ this.fetchImpl = options.fetchImpl;
29
+ }
30
+
31
+ isAvailable(): boolean {
32
+ return Boolean(this.apiKey);
33
+ }
34
+
35
+ async transcribe(request: TranscribeRequest): Promise<TranscriptResult> {
36
+ assertAudio(request, this.name);
37
+ const model = request.model ?? this.defaultModel;
38
+ const { blob, filename } = audioBlob(request);
39
+ const form = new FormData();
40
+ form.append('file', blob, filename);
41
+ form.append('model_id', model);
42
+ form.append('timestamps_granularity', request.timestamps === 'none' ? 'none' : 'word');
43
+ if (request.language) form.append('language_code', request.language);
44
+ 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);
47
+
48
+ const response = await speechFetch(
49
+ this.name, `${this.baseUrl}/v1/speech-to-text`,
50
+ { method: 'POST', headers: { 'xi-api-key': this.apiKey }, body: form },
51
+ request.timeoutMs, this.fetchImpl,
52
+ );
53
+ const json = (await response.json()) as { text?: string; language_code?: string; words?: ScribeWord[] };
54
+ const words: TranscriptWord[] | undefined = json.words
55
+ ?.filter((w) => (w.type ?? 'word') === 'word' && typeof w.start === 'number' && typeof w.end === 'number')
56
+ .map((w) => ({ text: w.text, start: w.start as number, end: w.end as number, ...(w.speaker_id ? { speaker: w.speaker_id } : {}) }));
57
+ const audioSeconds = words?.length ? words[words.length - 1].end : undefined;
58
+ return {
59
+ text: (json.text ?? '').trim(),
60
+ ...(json.language_code ? { language: json.language_code } : {}),
61
+ ...(words?.length ? { words } : {}),
62
+ model, provider: this.name,
63
+ usage: audioSeconds !== undefined ? { audioSeconds } : {},
64
+ };
65
+ }
66
+ }
67
+
68
+ export function createElevenLabsSpeechProvider(
69
+ env: Record<string, string | undefined> = typeof process !== 'undefined' ? process.env : {},
70
+ ): ElevenLabsSpeechProvider | null {
71
+ return env.ELEVENLABS_API_KEY ? new ElevenLabsSpeechProvider({ apiKey: env.ELEVENLABS_API_KEY }) : null;
72
+ }
@@ -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,89 @@
1
+ /**
2
+ * OpenAI speech-to-text. Env: OPENAI_API_KEY (the same key the text provider uses).
3
+ *
4
+ * `gpt-4o-transcribe` for plain text; per-word timings are only offered by
5
+ * `whisper-1` (verbose_json), so asking for `timestamps: 'word'` switches model.
6
+ */
7
+ import type { ISpeechProvider } from '../interface';
8
+ import { assertAudio, audioBlob, speechFetch } from '../http';
9
+ import type { TranscribeRequest, TranscriptResult, TranscriptWord } from '../types';
10
+
11
+ export interface OpenAISpeechOptions {
12
+ apiKey?: string;
13
+ baseUrl?: string;
14
+ fetchImpl?: typeof fetch;
15
+ }
16
+
17
+ export class OpenAISpeechProvider implements ISpeechProvider {
18
+ readonly name = 'openai';
19
+ readonly capabilities = { transcribe: true };
20
+ readonly defaultModel = 'gpt-4o-transcribe';
21
+ private readonly apiKey: string;
22
+ private readonly baseUrl: string;
23
+ private readonly fetchImpl?: typeof fetch;
24
+
25
+ constructor(options: OpenAISpeechOptions = {}) {
26
+ this.apiKey = options.apiKey ?? '';
27
+ this.baseUrl = (options.baseUrl ?? 'https://api.openai.com').replace(/\/+$/, '');
28
+ this.fetchImpl = options.fetchImpl;
29
+ }
30
+
31
+ isAvailable(): boolean {
32
+ return Boolean(this.apiKey);
33
+ }
34
+
35
+ async transcribe(request: TranscribeRequest): Promise<TranscriptResult> {
36
+ assertAudio(request, this.name);
37
+ const wantWords = request.timestamps === 'word';
38
+ const model = request.model ?? (wantWords ? 'whisper-1' : this.defaultModel);
39
+ const { blob, filename } = audioBlob(request);
40
+ const form = new FormData();
41
+ form.append('file', blob, filename);
42
+ form.append('model', model);
43
+ form.append('response_format', wantWords ? 'verbose_json' : 'json');
44
+ if (wantWords) form.append('timestamp_granularities[]', 'word');
45
+ if (request.language) form.append('language', request.language);
46
+ if (request.hints?.length) form.append('prompt', request.hints.join(', '));
47
+
48
+ const response = await speechFetch(
49
+ this.name, `${this.baseUrl}/v1/audio/transcriptions`,
50
+ { method: 'POST', headers: { authorization: `Bearer ${this.apiKey}` }, body: form },
51
+ request.timeoutMs, this.fetchImpl,
52
+ );
53
+ const json = (await response.json()) as {
54
+ text?: string; language?: string; duration?: number;
55
+ words?: Array<{ word: string; start: number; end: number }>;
56
+ usage?: { seconds?: number };
57
+ };
58
+ // On silence the model can hand the biasing prompt straight back as the
59
+ // "transcript". That is not speech, so report none rather than a confident lie.
60
+ const heard = (json.text ?? '').trim();
61
+ const echoed = isEcho(heard, request.hints);
62
+ const words: TranscriptWord[] | undefined = json.words?.map((w) => ({ text: w.word, start: w.start, end: w.end }));
63
+ const audioSeconds = json.usage?.seconds ?? json.duration;
64
+ return {
65
+ text: echoed ? '' : heard,
66
+ ...(json.language ? { language: json.language } : {}),
67
+ ...(words?.length ? { words } : {}),
68
+ model, provider: this.name,
69
+ usage: audioSeconds !== undefined ? { audioSeconds } : {},
70
+ };
71
+ }
72
+ }
73
+
74
+ export function createOpenAISpeechProvider(
75
+ env: Record<string, string | undefined> = typeof process !== 'undefined' ? process.env : {},
76
+ ): OpenAISpeechProvider | null {
77
+ return env.OPENAI_API_KEY ? new OpenAISpeechProvider({ apiKey: env.OPENAI_API_KEY }) : null;
78
+ }
79
+
80
+ const tokens = (text: string) => text.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
81
+
82
+ /** True when every word heard is one of the hint words — i.e. the prompt came back, not speech. */
83
+ function isEcho(heard: string, hints?: string[]): boolean {
84
+ if (!hints?.length) return false;
85
+ const said = tokens(heard);
86
+ // Scaffold words a model attaches when it parrots the prompt ("context:", "projects:").
87
+ const allowed = new Set([...tokens(hints.join(' ')), 'context', 'prompt', 'vocabulary', 'glossary', 'terms', 'projects', 'names', 'keywords']);
88
+ return said.length > 0 && said.every((word) => allowed.has(word));
89
+ }
@@ -0,0 +1,95 @@
1
+ import { LLMProviderError } from '../errors';
2
+ import { ProviderRegistry, pickProvider } from '../providers/registry';
3
+ import type { ISpeechProvider } from './interface';
4
+ import { createElevenLabsSpeechProvider } from './providers/elevenlabs';
5
+ import { MockSpeechProvider } from './providers/mock';
6
+ import { createOpenAISpeechProvider } from './providers/openai';
7
+ import type { SpeechServiceConfig, TranscribeRequest, TranscriptResult } from './types';
8
+
9
+ /**
10
+ * The speech registry. Built-ins register here; a new vendor is
11
+ * `registerSpeechProvider('name', factory)` — no edit to `SpeechService`.
12
+ */
13
+ export const speechProviders = new ProviderRegistry<ISpeechProvider>()
14
+ .register('elevenlabs', () => createElevenLabsSpeechProvider())
15
+ .register('openai', () => createOpenAISpeechProvider());
16
+
17
+ export function registerSpeechProvider(name: string, factory: () => ISpeechProvider | null): void {
18
+ speechProviders.register(name, factory);
19
+ }
20
+
21
+ const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
22
+
23
+ /**
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
27
+ * one that survives the retries falls through to the next available vendor —
28
+ * unless the error is the caller's (bad audio), which no other vendor would fix.
29
+ */
30
+ export class SpeechService {
31
+ private readonly config: Required<SpeechServiceConfig>;
32
+ private readonly providers: Map<string, ISpeechProvider>;
33
+
34
+ constructor(config: SpeechServiceConfig = {}) {
35
+ const env: Record<string, string | undefined> = typeof process !== 'undefined' ? process.env : {};
36
+ this.config = {
37
+ preferredProvider: config.preferredProvider ?? env.SPEECH_PROVIDER ?? 'elevenlabs',
38
+ maxRetries: config.maxRetries ?? 2,
39
+ fallback: config.fallback ?? true,
40
+ retryDelayMs: config.retryDelayMs ?? 500,
41
+ };
42
+ this.providers = env.SPEECH_PROVIDER === 'mock'
43
+ ? new Map([['mock', new MockSpeechProvider() as ISpeechProvider]])
44
+ : speechProviders.instantiate();
45
+ }
46
+
47
+ isAvailable(): boolean {
48
+ return this.getAvailableProviders().length > 0;
49
+ }
50
+
51
+ getAvailableProviders(): string[] {
52
+ return [...this.providers].filter(([, p]) => p.isAvailable() && p.capabilities.transcribe).map(([n]) => n);
53
+ }
54
+
55
+ /** Providers in the order they will be tried. */
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);
59
+ if (!first) return [];
60
+ return this.config.fallback ? [first, ...[...usable.values()].filter((p) => p !== first)] : [first];
61
+ }
62
+
63
+ async transcribe(input: TranscribeRequest): Promise<TranscriptResult> {
64
+ // A deployment-wide language default stops auto-detect drifting on short clips
65
+ // (an English note came back in French once). A per-call `language` wins.
66
+ const env: Record<string, string | undefined> = typeof process !== 'undefined' ? process.env : {};
67
+ const request = input.language || !env.SPEECH_LANGUAGE ? input : { ...input, language: env.SPEECH_LANGUAGE };
68
+ const order = this.order();
69
+ if (!order.length) {
70
+ throw new LLMProviderError(
71
+ 'No speech providers available. Set ELEVENLABS_API_KEY or OPENAI_API_KEY.',
72
+ 'AUTHENTICATION_ERROR', false,
73
+ );
74
+ }
75
+ let last: unknown;
76
+ for (const provider of order) {
77
+ for (let attempt = 0; ; attempt += 1) {
78
+ try {
79
+ return await provider.transcribe!(request);
80
+ } catch (error) {
81
+ last = error;
82
+ const typed = error instanceof LLMProviderError ? error : null;
83
+ if (typed?.retryable && attempt < this.config.maxRetries) {
84
+ await sleep(this.config.retryDelayMs * 2 ** attempt);
85
+ continue;
86
+ }
87
+ // The caller's input is bad — another vendor would say the same.
88
+ if (typed?.code === 'INVALID_REQUEST') throw error;
89
+ break;
90
+ }
91
+ }
92
+ }
93
+ throw last;
94
+ }
95
+ }
@@ -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 `elevenlabs`. */
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
+ }
package/src/types.ts CHANGED
@@ -38,6 +38,18 @@ export interface LLMImage {
38
38
  mediaType: 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp';
39
39
  }
40
40
 
41
+ /**
42
+ * A document attached to a prompt (e.g. a PDF a user uploaded): base64 data
43
+ * (no data: prefix). Providers send it as their native document/file part, so a
44
+ * text-layer or scanned PDF reaches the model without app-side extraction.
45
+ */
46
+ export interface LLMDocument {
47
+ data: string;
48
+ mediaType: 'application/pdf';
49
+ /** Original filename; OpenAI's file part requires one (a default is used when absent). */
50
+ filename?: string;
51
+ }
52
+
41
53
  export interface GenerateOptions {
42
54
  /** Model to use (provider-specific) */
43
55
  model?: string;
@@ -58,6 +70,11 @@ export interface GenerateOptions {
58
70
  * a model look at a rendering of what it designed).
59
71
  */
60
72
  images?: LLMImage[];
73
+ /**
74
+ * Documents (PDFs) sent with the user prompt, as the provider's native
75
+ * document part (Anthropic `document` block, OpenAI `file` part).
76
+ */
77
+ documents?: LLMDocument[];
61
78
  /**
62
79
  * Prompt caching (OpenAI). Requests that share a long identical opening
63
80
  * (instructions first, per-request data last) are billed at the cached rate