@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.
- package/README.md +33 -0
- package/package.json +3 -2
- package/src/__tests__/documents.test.ts +139 -0
- package/src/__tests__/extract-validate-correct.test.ts +130 -0
- package/src/__tests__/llm-service.test.ts +16 -0
- package/src/__tests__/speech.test.ts +203 -0
- package/src/__tests__/untrusted-document.test.ts +37 -0
- package/src/index.ts +14 -0
- package/src/providers/anthropic.ts +8 -3
- package/src/providers/openai.ts +17 -7
- package/src/providers/registry.ts +59 -0
- package/src/service.ts +19 -13
- package/src/speech/http.ts +73 -0
- package/src/speech/index.ts +17 -0
- package/src/speech/interface.ts +19 -0
- package/src/speech/providers/elevenlabs.ts +72 -0
- package/src/speech/providers/mock.ts +41 -0
- package/src/speech/providers/openai.ts +89 -0
- package/src/speech/service.ts +95 -0
- package/src/speech/types.ts +59 -0
- package/src/types.ts +17 -0
- package/src/utils/extract-validate-correct.ts +110 -0
- package/src/utils/untrusted-document.ts +23 -0
package/src/providers/openai.ts
CHANGED
|
@@ -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
|
-
/**
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
...
|
|
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
|
|
87
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|