@moxxy/plugin-stt-whisper 0.27.0

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/src/whisper.ts ADDED
@@ -0,0 +1,212 @@
1
+ import OpenAI, { APIError, APIConnectionError, APIUserAbortError } from 'openai';
2
+ import {
3
+ classifyHttpStatus,
4
+ classifyNetworkError,
5
+ MoxxyError,
6
+ type Transcriber,
7
+ type TranscribeOptions,
8
+ type TranscriptionResult,
9
+ } from '@moxxy/sdk';
10
+ import { normalizeWhisperUpload } from './audio.js';
11
+
12
+ export type WhisperModel = 'whisper-1' | 'gpt-4o-transcribe' | 'gpt-4o-mini-transcribe';
13
+
14
+ /** Provider tag attached to classified errors for logs/debug context. */
15
+ const WHISPER_PROVIDER_ID = 'openai';
16
+
17
+ export interface WhisperTranscriberOptions {
18
+ readonly apiKey?: string;
19
+ readonly baseURL?: string;
20
+ /** Defaults to `whisper-1`. */
21
+ readonly model?: WhisperModel;
22
+ /**
23
+ * Default language hint (BCP-47). Overridden per-call by
24
+ * `TranscribeOptions.language`. Omit to let Whisper auto-detect.
25
+ */
26
+ readonly language?: string;
27
+ /** Inject a pre-built OpenAI client (tests pass a stub here). */
28
+ readonly client?: OpenAI;
29
+ }
30
+
31
+ /**
32
+ * `Transcriber` backed by OpenAI's audio.transcriptions endpoint
33
+ * (Whisper-1 by default). Requests `verbose_json` so we can return
34
+ * `language`, `durationSec`, and per-segment text without an extra call.
35
+ *
36
+ * Audio bytes come in as `Uint8Array | ArrayBuffer`; we wrap them in a
37
+ * Node `File` for upload (Node 20.10+ provides File / Blob globals).
38
+ */
39
+ export class WhisperTranscriber implements Transcriber {
40
+ readonly name: string;
41
+ private readonly client: OpenAI;
42
+ private readonly model: WhisperModel;
43
+ private readonly defaultLanguage: string | undefined;
44
+
45
+ constructor(opts: WhisperTranscriberOptions = {}) {
46
+ this.model = opts.model ?? 'whisper-1';
47
+ this.name = `openai-${this.model}`;
48
+ this.defaultLanguage = opts.language;
49
+ this.client =
50
+ opts.client ??
51
+ new OpenAI({
52
+ apiKey: opts.apiKey ?? process.env.OPENAI_API_KEY,
53
+ ...(opts.baseURL ? { baseURL: opts.baseURL } : {}),
54
+ });
55
+ }
56
+
57
+ async transcribe(
58
+ audio: Uint8Array | ArrayBuffer,
59
+ opts: TranscribeOptions = {},
60
+ ): Promise<TranscriptionResult> {
61
+ // Route through the shared Whisper-family preprocessor so the project's
62
+ // raw-PCM16 contract (MOXXY_PCM16_24KHZ_MIME) gets WAV-wrapped and filename
63
+ // inference stays in lockstep with the Codex sibling. Default the MIME to
64
+ // `audio/ogg` to preserve prior behavior for callers that omit it.
65
+ const upload = normalizeWhisperUpload(audio, opts.mimeType ?? 'audio/ogg');
66
+ // Node 20.10+ exposes File globally; the OpenAI SDK accepts it as
67
+ // an `Uploadable`.
68
+ const file = new File([upload.bytes], upload.filename, { type: upload.mimeType });
69
+ const language = opts.language ?? this.defaultLanguage;
70
+ // verbose_json is only supported by whisper-1; the gpt-4o family
71
+ // returns plain JSON. Branch so callers get rich segments when
72
+ // available, and a graceful text-only result when not.
73
+ if (this.model === 'whisper-1') {
74
+ const response = await this.run(() =>
75
+ this.client.audio.transcriptions.create(
76
+ {
77
+ model: this.model,
78
+ file,
79
+ response_format: 'verbose_json',
80
+ ...(language ? { language } : {}),
81
+ ...(opts.prompt ? { prompt: opts.prompt } : {}),
82
+ },
83
+ { signal: opts.signal },
84
+ ),
85
+ );
86
+ // OpenAI verbose-json response: { text, language, duration, segments[] }
87
+ const r = requireTextResponse(response) as {
88
+ text: string;
89
+ language?: unknown;
90
+ duration?: unknown;
91
+ segments?: unknown;
92
+ };
93
+ const result: {
94
+ text: string;
95
+ language?: string;
96
+ durationSec?: number;
97
+ segments?: Array<{ start: number; end: number; text: string }>;
98
+ } = { text: r.text };
99
+ if (typeof r.language === 'string') result.language = r.language;
100
+ if (typeof r.duration === 'number') result.durationSec = r.duration;
101
+ if (Array.isArray(r.segments)) {
102
+ // The vendor response is untrusted: a malformed/partial `segments`
103
+ // entry (`null`, a non-object, or one missing the numeric start/end /
104
+ // string text) would either crash the blind `.start` read or leak a
105
+ // value that violates the `TranscriptionSegment` type contract.
106
+ // Validate each element and drop the ones that don't conform rather
107
+ // than crashing or emitting `{ text: 123 }` downstream.
108
+ result.segments = sanitizeSegments(r.segments);
109
+ }
110
+ return result;
111
+ }
112
+ const response = await this.run(() =>
113
+ this.client.audio.transcriptions.create(
114
+ {
115
+ model: this.model,
116
+ file,
117
+ ...(language ? { language } : {}),
118
+ ...(opts.prompt ? { prompt: opts.prompt } : {}),
119
+ },
120
+ { signal: opts.signal },
121
+ ),
122
+ );
123
+ return { text: requireTextResponse(response).text };
124
+ }
125
+
126
+ /**
127
+ * Run an SDK transcription call, translating failures into structured
128
+ * `MoxxyError`s (network vs. HTTP status) to match the codex sibling.
129
+ * User aborts re-throw unchanged so cancellation isn't masked as an error.
130
+ */
131
+ private async run<T>(call: () => Promise<T>): Promise<T> {
132
+ try {
133
+ return await call();
134
+ } catch (err) {
135
+ // Intentional cancellation: propagate as-is so callers see the abort.
136
+ if (err instanceof APIUserAbortError) throw err;
137
+ const ctx = { provider: WHISPER_PROVIDER_ID, url: this.client.baseURL };
138
+ if (err instanceof APIConnectionError) {
139
+ const network = classifyNetworkError(err.cause ?? err, ctx);
140
+ if (network) throw network;
141
+ }
142
+ if (err instanceof APIError && typeof err.status === 'number') {
143
+ const classified = classifyHttpStatus(err.status, { ...ctx, body: err.message });
144
+ if (classified) throw classified;
145
+ throw new MoxxyError({
146
+ code: 'PROVIDER_BAD_REQUEST',
147
+ message: `OpenAI transcription returned HTTP ${err.status}.`,
148
+ context: { ...ctx, status: err.status },
149
+ cause: err,
150
+ });
151
+ }
152
+ const network = classifyNetworkError(err, ctx);
153
+ if (network) throw network;
154
+ throw err;
155
+ }
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Validate that an OpenAI transcription response carries a string `text`
161
+ * field before we trust it. The SDK's union return type is wider than what
162
+ * we consume, so we narrow it here; a response missing `text` (or whose
163
+ * `text` isn't a string) is a real contract violation worth surfacing as a
164
+ * structured error rather than silently returning `{ text: undefined }`.
165
+ */
166
+ function requireTextResponse(response: unknown): { text: string } & Record<string, unknown> {
167
+ if (
168
+ !response ||
169
+ typeof response !== 'object' ||
170
+ typeof (response as { text?: unknown }).text !== 'string'
171
+ ) {
172
+ throw new MoxxyError({
173
+ code: 'PROVIDER_UNKNOWN_RESPONSE',
174
+ message: 'OpenAI transcription response was missing a text field.',
175
+ context: { provider: WHISPER_PROVIDER_ID },
176
+ });
177
+ }
178
+ return response as { text: string } & Record<string, unknown>;
179
+ }
180
+
181
+ /**
182
+ * Defensively narrow an untrusted `segments` array from a vendor response into
183
+ * the well-typed `TranscriptionSegment[]` contract. Each element is validated
184
+ * to be an object carrying finite numeric `start`/`end` and a string `text`;
185
+ * non-conforming entries (`null`, primitives, NaN/Infinity bounds, missing
186
+ * fields) are dropped so a hostile/partial response degrades to fewer (or zero)
187
+ * segments instead of crashing the caller or leaking off-contract values.
188
+ */
189
+ function sanitizeSegments(
190
+ raw: readonly unknown[],
191
+ ): Array<{ start: number; end: number; text: string }> {
192
+ const out: Array<{ start: number; end: number; text: string }> = [];
193
+ for (const seg of raw) {
194
+ if (!seg || typeof seg !== 'object') continue;
195
+ const s = seg as { start?: unknown; end?: unknown; text?: unknown };
196
+ if (
197
+ typeof s.start !== 'number' ||
198
+ typeof s.end !== 'number' ||
199
+ typeof s.text !== 'string' ||
200
+ !Number.isFinite(s.start) ||
201
+ !Number.isFinite(s.end)
202
+ ) {
203
+ continue;
204
+ }
205
+ out.push({ start: s.start, end: s.end, text: s.text });
206
+ }
207
+ return out;
208
+ }
209
+
210
+ export function createWhisperTranscriber(opts: WhisperTranscriberOptions = {}): WhisperTranscriber {
211
+ return new WhisperTranscriber(opts);
212
+ }