@moxxy/plugin-tts-openai 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Moxxy (moxxy.ai)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,26 @@
1
+ import { type Plugin } from '@moxxy/sdk';
2
+ import { type OpenAiSynthesizerOptions } from './openai-tts.js';
3
+ export { OpenAiSynthesizer, createOpenAiSynthesizer, capInput, clampSpeed, type OpenAiSynthesizerOptions, type OpenAiTtsFormat, type FetchLike, } from './openai-tts.js';
4
+ /** The single registered synthesizer name — surfaces show this in `set_voice`. */
5
+ export declare const OPENAI_TTS_SYNTHESIZER_NAME = "openai-tts";
6
+ export interface BuildOpenAiTtsPluginOptions {
7
+ /**
8
+ * Build-time defaults baked into the synthesizer def. Per-activation config
9
+ * (`session.synthesizers.setActive(name, config)`) still overrides `model` /
10
+ * `voice` / `format`. Mainly a seam for tests to inject `fetchImpl` / `apiKey`.
11
+ */
12
+ readonly defaults?: Omit<OpenAiSynthesizerOptions, 'getSecret'>;
13
+ }
14
+ /**
15
+ * Build the @moxxy/plugin-tts-openai plugin. Registers exactly one synthesizer,
16
+ * `openai-tts`, backed by OpenAI's `/v1/audio/speech`.
17
+ *
18
+ * The plugin is intentionally side-effect free — it never calls `setActive`.
19
+ * The `SynthesizerRegistry` is `autoAdoptFirst`, so the first synthesizer
20
+ * registered (this one, on a fresh install) becomes active on read without any
21
+ * activation step here; the agent switches voices via the `set_voice` tool.
22
+ */
23
+ export declare function buildOpenAiTtsPlugin(opts?: BuildOpenAiTtsPluginOptions): Plugin;
24
+ declare const _default: Plugin;
25
+ export default _default;
26
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAmC,KAAK,MAAM,EAAE,MAAM,YAAY,CAAC;AAC1E,OAAO,EAEL,KAAK,wBAAwB,EAE9B,MAAM,iBAAiB,CAAC;AAEzB,OAAO,EACL,iBAAiB,EACjB,uBAAuB,EACvB,QAAQ,EACR,UAAU,EACV,KAAK,wBAAwB,EAC7B,KAAK,eAAe,EACpB,KAAK,SAAS,GACf,MAAM,iBAAiB,CAAC;AAEzB,kFAAkF;AAClF,eAAO,MAAM,2BAA2B,eAAe,CAAC;AAExD,MAAM,WAAW,2BAA2B;IAC1C;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,IAAI,CAAC,wBAAwB,EAAE,WAAW,CAAC,CAAC;CACjE;AAED;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,GAAE,2BAAgC,GAAG,MAAM,CAoBnF;;AA2BD,wBAAsC"}
package/dist/index.js ADDED
@@ -0,0 +1,63 @@
1
+ import { definePlugin, defineSynthesizer } from '@moxxy/sdk';
2
+ import { OpenAiSynthesizer, } from './openai-tts.js';
3
+ export { OpenAiSynthesizer, createOpenAiSynthesizer, capInput, clampSpeed, } from './openai-tts.js';
4
+ /** The single registered synthesizer name — surfaces show this in `set_voice`. */
5
+ export const OPENAI_TTS_SYNTHESIZER_NAME = 'openai-tts';
6
+ /**
7
+ * Build the @moxxy/plugin-tts-openai plugin. Registers exactly one synthesizer,
8
+ * `openai-tts`, backed by OpenAI's `/v1/audio/speech`.
9
+ *
10
+ * The plugin is intentionally side-effect free — it never calls `setActive`.
11
+ * The `SynthesizerRegistry` is `autoAdoptFirst`, so the first synthesizer
12
+ * registered (this one, on a fresh install) becomes active on read without any
13
+ * activation step here; the agent switches voices via the `set_voice` tool.
14
+ */
15
+ export function buildOpenAiTtsPlugin(opts = {}) {
16
+ const defaults = opts.defaults ?? {};
17
+ return definePlugin({
18
+ name: '@moxxy/plugin-tts-openai',
19
+ version: '0.0.0',
20
+ synthesizers: [
21
+ defineSynthesizer({
22
+ name: OPENAI_TTS_SYNTHESIZER_NAME,
23
+ displayName: 'OpenAI TTS',
24
+ // `create` runs lazily and may re-run (buildOnRead) — keep it cheap and
25
+ // side-effect free; the key is resolved inside `synthesize`.
26
+ create: (ctx) => new OpenAiSynthesizer({
27
+ ...defaults,
28
+ ...configToOptions(ctx.config),
29
+ ...(ctx.getSecret ? { getSecret: ctx.getSecret } : {}),
30
+ }),
31
+ }),
32
+ ],
33
+ });
34
+ }
35
+ /** Narrow the untrusted per-activation config record into typed options. Only
36
+ * string `model` / `voice` / `baseURL` / `apiKey` and a known `format` are
37
+ * honored; anything else is ignored so a malformed config can't break create. */
38
+ function configToOptions(config) {
39
+ const out = {};
40
+ if (typeof config.model === 'string' && config.model)
41
+ out.model = config.model;
42
+ if (typeof config.voice === 'string' && config.voice)
43
+ out.voice = config.voice;
44
+ if (typeof config.baseURL === 'string' && config.baseURL)
45
+ out.baseURL = config.baseURL;
46
+ if (typeof config.apiKey === 'string' && config.apiKey)
47
+ out.apiKey = config.apiKey;
48
+ const format = normalizeFormat(config.format);
49
+ if (format)
50
+ out.format = format;
51
+ return out;
52
+ }
53
+ const KNOWN_FORMATS = ['mp3', 'opus', 'wav', 'aac'];
54
+ /** Accept only the exact supported format strings (guards against inherited
55
+ * keys like `constructor` slipping through an `in` check). */
56
+ function normalizeFormat(value) {
57
+ return typeof value === 'string' && KNOWN_FORMATS.includes(value)
58
+ ? value
59
+ : undefined;
60
+ }
61
+ // Discovery entry: `createPluginLoader` requires a default Plugin export.
62
+ export default buildOpenAiTtsPlugin();
63
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,iBAAiB,EAAe,MAAM,YAAY,CAAC;AAC1E,OAAO,EACL,iBAAiB,GAGlB,MAAM,iBAAiB,CAAC;AAEzB,OAAO,EACL,iBAAiB,EACjB,uBAAuB,EACvB,QAAQ,EACR,UAAU,GAIX,MAAM,iBAAiB,CAAC;AAEzB,kFAAkF;AAClF,MAAM,CAAC,MAAM,2BAA2B,GAAG,YAAY,CAAC;AAWxD;;;;;;;;GAQG;AACH,MAAM,UAAU,oBAAoB,CAAC,OAAoC,EAAE;IACzE,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,IAAI,EAAE,CAAC;IACrC,OAAO,YAAY,CAAC;QAClB,IAAI,EAAE,0BAA0B;QAChC,OAAO,EAAE,OAAO;QAChB,YAAY,EAAE;YACZ,iBAAiB,CAAC;gBAChB,IAAI,EAAE,2BAA2B;gBACjC,WAAW,EAAE,YAAY;gBACzB,wEAAwE;gBACxE,6DAA6D;gBAC7D,MAAM,EAAE,CAAC,GAAG,EAAE,EAAE,CACd,IAAI,iBAAiB,CAAC;oBACpB,GAAG,QAAQ;oBACX,GAAG,eAAe,CAAC,GAAG,CAAC,MAAM,CAAC;oBAC9B,GAAG,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,GAAG,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;iBACvD,CAAC;aACL,CAAC;SACH;KACF,CAAC,CAAC;AACL,CAAC;AAED;;kFAEkF;AAClF,SAAS,eAAe,CAAC,MAA+B;IACtD,MAAM,GAAG,GAAsF,EAAE,CAAC;IAClG,IAAI,OAAO,MAAM,CAAC,KAAK,KAAK,QAAQ,IAAI,MAAM,CAAC,KAAK;QAAE,GAAG,CAAC,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC;IAC/E,IAAI,OAAO,MAAM,CAAC,KAAK,KAAK,QAAQ,IAAI,MAAM,CAAC,KAAK;QAAE,GAAG,CAAC,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC;IAC/E,IAAI,OAAO,MAAM,CAAC,OAAO,KAAK,QAAQ,IAAI,MAAM,CAAC,OAAO;QAAE,GAAG,CAAC,OAAO,GAAG,MAAM,CAAC,OAAO,CAAC;IACvF,IAAI,OAAO,MAAM,CAAC,MAAM,KAAK,QAAQ,IAAI,MAAM,CAAC,MAAM;QAAE,GAAG,CAAC,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC;IACnF,MAAM,MAAM,GAAG,eAAe,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IAC9C,IAAI,MAAM;QAAE,GAAG,CAAC,MAAM,GAAG,MAAM,CAAC;IAChC,OAAO,GAAG,CAAC;AACb,CAAC;AAED,MAAM,aAAa,GAAmC,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,CAAC,CAAC;AAEpF;+DAC+D;AAC/D,SAAS,eAAe,CAAC,KAAc;IACrC,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAK,aAAuC,CAAC,QAAQ,CAAC,KAAK,CAAC;QAC1F,CAAC,CAAE,KAAyB;QAC5B,CAAC,CAAC,SAAS,CAAC;AAChB,CAAC;AAED,0EAA0E;AAC1E,eAAe,oBAAoB,EAAE,CAAC"}
@@ -0,0 +1,76 @@
1
+ import { type Synthesizer, type SynthesizeOptions, type SynthesisResult } from '@moxxy/sdk';
2
+ /** OpenAI `/v1/audio/speech` response formats we expose, mapped to the MIME
3
+ * type the desktop/channels play the returned bytes as. Kept a small closed
4
+ * map (OpenAI also serves flac/pcm — not surfaced here). */
5
+ declare const MIME_BY_FORMAT: {
6
+ readonly mp3: "audio/mpeg";
7
+ readonly opus: "audio/ogg";
8
+ readonly wav: "audio/wav";
9
+ readonly aac: "audio/aac";
10
+ };
11
+ export type OpenAiTtsFormat = keyof typeof MIME_BY_FORMAT;
12
+ /** Injectable `fetch` — the default is the global; tests pass a stub. Widened
13
+ * from the global signature so a plain `(url, init) => Response` stub fits. */
14
+ export type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
15
+ export interface OpenAiSynthesizerOptions {
16
+ /** Explicit API key. Normally omitted — the key is read via `getSecret`. */
17
+ readonly apiKey?: string;
18
+ /** Vault-backed secret resolver handed in by `SynthesizerCreateContext`. */
19
+ readonly getSecret?: (name: string) => Promise<string | null>;
20
+ /** API base, default `https://api.openai.com/v1`. Trailing slashes trimmed. */
21
+ readonly baseURL?: string;
22
+ /** TTS model, default `gpt-4o-mini-tts`. */
23
+ readonly model?: string;
24
+ /** Default voice, default `alloy`. Overridden per-call by `opts.voice`. */
25
+ readonly voice?: string;
26
+ /** Output container, default `mp3`. Determines the returned `mimeType`. */
27
+ readonly format?: OpenAiTtsFormat;
28
+ /** Injected `fetch` (tests). Defaults to the global. */
29
+ readonly fetchImpl?: FetchLike;
30
+ /** Per-request timeout in ms. Default 60000. */
31
+ readonly timeoutMs?: number;
32
+ }
33
+ /**
34
+ * `Synthesizer` backed by OpenAI's `POST /v1/audio/speech` endpoint — one JSON
35
+ * POST returning raw audio bytes, so there's no need for the `openai` SDK. The
36
+ * API key rides the vault (`ctx.getSecret('OPENAI_API_KEY')`, the same key the
37
+ * OpenAI provider uses) with a `process.env.OPENAI_API_KEY` fallback, resolved
38
+ * lazily on the first `synthesize` and cached so `create()` stays cheap.
39
+ */
40
+ export declare class OpenAiSynthesizer implements Synthesizer {
41
+ readonly name = "openai-tts";
42
+ /** MIME type of the bytes this instance returns (derived from `format`). */
43
+ readonly mimeType: string;
44
+ private readonly explicitKey;
45
+ private readonly getSecret;
46
+ private readonly baseURL;
47
+ private readonly model;
48
+ private readonly voice;
49
+ private readonly format;
50
+ private readonly fetchImpl;
51
+ private readonly timeoutMs;
52
+ private key;
53
+ constructor(opts?: OpenAiSynthesizerOptions);
54
+ synthesize(text: string, opts?: SynthesizeOptions): Promise<SynthesisResult>;
55
+ /**
56
+ * Resolve the API key lazily: explicit option → vault (`OPENAI_API_KEY`) →
57
+ * `process.env.OPENAI_API_KEY`. Cache only a successful resolution so a key
58
+ * added after a first failed attempt is still picked up on retry. A missing
59
+ * key becomes a classified, actionable `MoxxyError`.
60
+ */
61
+ private resolveKey;
62
+ }
63
+ /** Map a `SynthesizeOptions.rate` multiplier onto OpenAI's `speed`, clamped to
64
+ * the accepted 0.25–4.0 range. Non-finite / absent rates yield `undefined` so
65
+ * the field is omitted and OpenAI uses its default. */
66
+ export declare function clampSpeed(rate: number | undefined): number | undefined;
67
+ /**
68
+ * Cap `text` to OpenAI's 4096-char `input` limit. When over, truncate at the
69
+ * last sentence boundary (`. `, `! `, `? `, newline) inside the budget — as
70
+ * long as that keeps most of the text — else hard-slice, and append a single
71
+ * ellipsis so the result is always ≤ 4096 characters.
72
+ */
73
+ export declare function capInput(text: string): string;
74
+ export declare function createOpenAiSynthesizer(opts?: OpenAiSynthesizerOptions): OpenAiSynthesizer;
75
+ export {};
76
+ //# sourceMappingURL=openai-tts.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"openai-tts.d.ts","sourceRoot":"","sources":["../src/openai-tts.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,WAAW,EAChB,KAAK,iBAAiB,EACtB,KAAK,eAAe,EACrB,MAAM,YAAY,CAAC;AAKpB;;6DAE6D;AAC7D,QAAA,MAAM,cAAc;;;;;CAKV,CAAC;AAEX,MAAM,MAAM,eAAe,GAAG,MAAM,OAAO,cAAc,CAAC;AAe1D;gFACgF;AAChF,MAAM,MAAM,SAAS,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;AAEhF,MAAM,WAAW,wBAAwB;IACvC,4EAA4E;IAC5E,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,4EAA4E;IAC5E,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC9D,+EAA+E;IAC/E,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,4CAA4C;IAC5C,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,2EAA2E;IAC3E,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,2EAA2E;IAC3E,QAAQ,CAAC,MAAM,CAAC,EAAE,eAAe,CAAC;IAClC,wDAAwD;IACxD,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,CAAC;IAC/B,gDAAgD;IAChD,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED;;;;;;GAMG;AACH,qBAAa,iBAAkB,YAAW,WAAW;IACnD,QAAQ,CAAC,IAAI,gBAAgB;IAC7B,4EAA4E;IAC5E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAE1B,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAqB;IACjD,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAyD;IACnF,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAS;IAC/B,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAS;IAC/B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAkB;IACzC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAY;IACtC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,GAAG,CAAuB;gBAEtB,IAAI,GAAE,wBAA6B;IAYzC,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE,iBAAsB,GAAG,OAAO,CAAC,eAAe,CAAC;IA+EtF;;;;;OAKG;YACW,UAAU;CAmBzB;AAED;;wDAEwD;AACxD,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAGvE;AAED;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAc7C;AAED,wBAAgB,uBAAuB,CAAC,IAAI,GAAE,wBAA6B,GAAG,iBAAiB,CAE9F"}
@@ -0,0 +1,187 @@
1
+ import { classifyHttpStatus, classifyNetworkError, MoxxyError, } from '@moxxy/sdk';
2
+ /** Provider tag attached to classified errors for logs/debug context. */
3
+ const OPENAI_TTS_PROVIDER_ID = 'openai';
4
+ /** OpenAI `/v1/audio/speech` response formats we expose, mapped to the MIME
5
+ * type the desktop/channels play the returned bytes as. Kept a small closed
6
+ * map (OpenAI also serves flac/pcm — not surfaced here). */
7
+ const MIME_BY_FORMAT = {
8
+ mp3: 'audio/mpeg',
9
+ opus: 'audio/ogg',
10
+ wav: 'audio/wav',
11
+ aac: 'audio/aac',
12
+ };
13
+ /** OpenAI rejects `input` longer than 4096 characters. Channels routinely send
14
+ * long replies, so we truncate at a sentence boundary rather than let the API
15
+ * 400 the whole read-aloud. */
16
+ const MAX_INPUT_CHARS = 4096;
17
+ /** OpenAI clamps `speed` to this inclusive range; anything outside 400s. */
18
+ const MIN_SPEED = 0.25;
19
+ const MAX_SPEED = 4.0;
20
+ /** Default per-request deadline. Read-aloud is cancellable, but a hung socket
21
+ * should still free itself so callers can fall back to the OS voice. */
22
+ const DEFAULT_TIMEOUT_MS = 60_000;
23
+ /**
24
+ * `Synthesizer` backed by OpenAI's `POST /v1/audio/speech` endpoint — one JSON
25
+ * POST returning raw audio bytes, so there's no need for the `openai` SDK. The
26
+ * API key rides the vault (`ctx.getSecret('OPENAI_API_KEY')`, the same key the
27
+ * OpenAI provider uses) with a `process.env.OPENAI_API_KEY` fallback, resolved
28
+ * lazily on the first `synthesize` and cached so `create()` stays cheap.
29
+ */
30
+ export class OpenAiSynthesizer {
31
+ name = 'openai-tts';
32
+ /** MIME type of the bytes this instance returns (derived from `format`). */
33
+ mimeType;
34
+ explicitKey;
35
+ getSecret;
36
+ baseURL;
37
+ model;
38
+ voice;
39
+ format;
40
+ fetchImpl;
41
+ timeoutMs;
42
+ key = null;
43
+ constructor(opts = {}) {
44
+ this.explicitKey = opts.apiKey;
45
+ this.getSecret = opts.getSecret;
46
+ this.baseURL = (opts.baseURL ?? 'https://api.openai.com/v1').replace(/\/+$/, '');
47
+ this.model = opts.model ?? 'gpt-4o-mini-tts';
48
+ this.voice = opts.voice ?? 'alloy';
49
+ this.format = opts.format ?? 'mp3';
50
+ this.mimeType = MIME_BY_FORMAT[this.format];
51
+ this.fetchImpl = opts.fetchImpl ?? globalThis.fetch;
52
+ this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
53
+ }
54
+ async synthesize(text, opts = {}) {
55
+ // Fast-path a caller who cancelled before we started — propagate their reason.
56
+ opts.signal?.throwIfAborted();
57
+ const key = await this.resolveKey();
58
+ const input = capInput(text);
59
+ const voice = opts.voice ?? this.voice;
60
+ const speed = clampSpeed(opts.rate);
61
+ const body = {
62
+ model: this.model,
63
+ voice,
64
+ input,
65
+ response_format: this.format,
66
+ ...(speed !== undefined ? { speed } : {}),
67
+ };
68
+ const url = `${this.baseURL}/audio/speech`;
69
+ // Chain the caller's abort signal with our own timeout onto one controller
70
+ // passed to fetch, so either cancels the request and frees the socket.
71
+ const controller = new AbortController();
72
+ let timedOut = false;
73
+ const timer = setTimeout(() => {
74
+ timedOut = true;
75
+ controller.abort();
76
+ }, this.timeoutMs);
77
+ const forward = () => controller.abort();
78
+ if (opts.signal) {
79
+ // The signal may have fired during key resolution above; a listener added
80
+ // to an already-aborted signal is never called, so abort directly here.
81
+ if (opts.signal.aborted)
82
+ controller.abort();
83
+ else
84
+ opts.signal.addEventListener('abort', forward, { once: true });
85
+ }
86
+ let res;
87
+ try {
88
+ res = await this.fetchImpl(url, {
89
+ method: 'POST',
90
+ headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
91
+ body: JSON.stringify(body),
92
+ signal: controller.signal,
93
+ });
94
+ }
95
+ catch (err) {
96
+ // Caller-initiated cancellation: re-throw their reason unchanged so a
97
+ // stopped read-aloud isn't masked as a provider/network error.
98
+ if (opts.signal?.aborted && !timedOut)
99
+ throw opts.signal.reason ?? err;
100
+ if (timedOut) {
101
+ throw new MoxxyError({
102
+ code: 'NETWORK_TIMEOUT',
103
+ message: `OpenAI text-to-speech request timed out after ${this.timeoutMs} ms.`,
104
+ hint: 'Retry, or shorten the text being read aloud.',
105
+ context: { provider: OPENAI_TTS_PROVIDER_ID, url },
106
+ });
107
+ }
108
+ const network = classifyNetworkError(err, { provider: OPENAI_TTS_PROVIDER_ID, url });
109
+ if (network)
110
+ throw network;
111
+ throw err;
112
+ }
113
+ finally {
114
+ clearTimeout(timer);
115
+ opts.signal?.removeEventListener('abort', forward);
116
+ }
117
+ if (!res.ok) {
118
+ const classified = classifyHttpStatus(res.status, {
119
+ provider: OPENAI_TTS_PROVIDER_ID,
120
+ url,
121
+ body: await res.text().catch(() => ''),
122
+ });
123
+ if (classified)
124
+ throw classified;
125
+ throw new MoxxyError({
126
+ code: 'PROVIDER_BAD_REQUEST',
127
+ message: `OpenAI text-to-speech returned HTTP ${res.status}.`,
128
+ context: { provider: OPENAI_TTS_PROVIDER_ID, url, status: res.status },
129
+ });
130
+ }
131
+ const audio = new Uint8Array(await res.arrayBuffer());
132
+ return { audio, mimeType: this.mimeType };
133
+ }
134
+ /**
135
+ * Resolve the API key lazily: explicit option → vault (`OPENAI_API_KEY`) →
136
+ * `process.env.OPENAI_API_KEY`. Cache only a successful resolution so a key
137
+ * added after a first failed attempt is still picked up on retry. A missing
138
+ * key becomes a classified, actionable `MoxxyError`.
139
+ */
140
+ async resolveKey() {
141
+ if (this.key)
142
+ return this.key;
143
+ const resolved = this.explicitKey ??
144
+ (await this.getSecret?.('OPENAI_API_KEY')) ??
145
+ process.env.OPENAI_API_KEY ??
146
+ null;
147
+ if (!resolved) {
148
+ throw new MoxxyError({
149
+ code: 'AUTH_NO_CREDENTIALS',
150
+ message: 'No OpenAI API key for text-to-speech. It rides the same OPENAI_API_KEY as the OpenAI provider.',
151
+ hint: 'Run `moxxy init` (stores it in the vault) or set OPENAI_API_KEY.',
152
+ context: { provider: OPENAI_TTS_PROVIDER_ID },
153
+ });
154
+ }
155
+ this.key = resolved;
156
+ return resolved;
157
+ }
158
+ }
159
+ /** Map a `SynthesizeOptions.rate` multiplier onto OpenAI's `speed`, clamped to
160
+ * the accepted 0.25–4.0 range. Non-finite / absent rates yield `undefined` so
161
+ * the field is omitted and OpenAI uses its default. */
162
+ export function clampSpeed(rate) {
163
+ if (rate === undefined || !Number.isFinite(rate))
164
+ return undefined;
165
+ return Math.min(MAX_SPEED, Math.max(MIN_SPEED, rate));
166
+ }
167
+ /**
168
+ * Cap `text` to OpenAI's 4096-char `input` limit. When over, truncate at the
169
+ * last sentence boundary (`. `, `! `, `? `, newline) inside the budget — as
170
+ * long as that keeps most of the text — else hard-slice, and append a single
171
+ * ellipsis so the result is always ≤ 4096 characters.
172
+ */
173
+ export function capInput(text) {
174
+ if (text.length <= MAX_INPUT_CHARS)
175
+ return text;
176
+ const budget = MAX_INPUT_CHARS - 1; // leave room for the ellipsis
177
+ const slice = text.slice(0, budget);
178
+ const boundary = Math.max(slice.lastIndexOf('. '), slice.lastIndexOf('! '), slice.lastIndexOf('? '), slice.lastIndexOf('\n'));
179
+ // Only honor a boundary that doesn't discard more than half the budget,
180
+ // otherwise a single very long sentence would be cut to almost nothing.
181
+ const cut = boundary > budget * 0.5 ? boundary + 1 : budget;
182
+ return `${slice.slice(0, cut).trimEnd()}…`;
183
+ }
184
+ export function createOpenAiSynthesizer(opts = {}) {
185
+ return new OpenAiSynthesizer(opts);
186
+ }
187
+ //# sourceMappingURL=openai-tts.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"openai-tts.js","sourceRoot":"","sources":["../src/openai-tts.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "name": "@moxxy/plugin-tts-openai",
3
+ "version": "0.27.0",
4
+ "description": "OpenAI text-to-speech Synthesizer for moxxy. Text → spoken audio bytes via POST /v1/audio/speech. Plugs into the session's SynthesizerRegistry; read-aloud surfaces (desktop 'Read aloud', a voice channel) consume it transparently. The first shipped Synthesizer backend.",
5
+ "keywords": [
6
+ "moxxy",
7
+ "agent",
8
+ "tts",
9
+ "text-to-speech",
10
+ "openai",
11
+ "synthesizer"
12
+ ],
13
+ "homepage": "https://moxxy.ai",
14
+ "bugs": {
15
+ "url": "https://github.com/moxxy-ai/moxxy/issues"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/moxxy-ai/moxxy.git",
20
+ "directory": "packages/plugin-tts-openai"
21
+ },
22
+ "author": "Michal Makowski <michal.makowski97@gmail.com>",
23
+ "license": "MIT",
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "type": "module",
28
+ "main": "./dist/index.js",
29
+ "types": "./dist/index.d.ts",
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/index.d.ts",
33
+ "import": "./dist/index.js"
34
+ }
35
+ },
36
+ "files": [
37
+ "dist",
38
+ "src"
39
+ ],
40
+ "moxxy": {
41
+ "plugin": {
42
+ "entry": "./dist/index.js",
43
+ "kind": "synthesizer"
44
+ },
45
+ "setup": {
46
+ "title": "OpenAI text-to-speech",
47
+ "description": "Read-aloud through OpenAI's /v1/audio/speech. Uses the same OPENAI_API_KEY as the OpenAI provider — if you already added that (via `moxxy init` or the OpenAI provider), you can skip this; leave it blank to reuse the existing key.",
48
+ "fields": [
49
+ {
50
+ "key": "apiKey",
51
+ "label": "OpenAI API key",
52
+ "kind": "secret",
53
+ "vaultKey": "OPENAI_API_KEY",
54
+ "required": false,
55
+ "placeholder": "sk-…",
56
+ "description": "Reuses the OpenAI provider key. Only needed if you haven't already stored OPENAI_API_KEY."
57
+ }
58
+ ]
59
+ }
60
+ },
61
+ "dependencies": {
62
+ "@moxxy/sdk": "0.27.0"
63
+ },
64
+ "devDependencies": {
65
+ "@types/node": "^22.10.0",
66
+ "typescript": "^5.7.3",
67
+ "vitest": "^2.1.8",
68
+ "@moxxy/vitest-preset": "0.0.0",
69
+ "@moxxy/tsconfig": "0.0.0"
70
+ },
71
+ "scripts": {
72
+ "build": "tsc -p tsconfig.json",
73
+ "typecheck": "tsc -p tsconfig.json --noEmit",
74
+ "test": "vitest run",
75
+ "clean": "rm -rf dist .turbo"
76
+ }
77
+ }
package/src/index.ts ADDED
@@ -0,0 +1,86 @@
1
+ import { definePlugin, defineSynthesizer, type Plugin } from '@moxxy/sdk';
2
+ import {
3
+ OpenAiSynthesizer,
4
+ type OpenAiSynthesizerOptions,
5
+ type OpenAiTtsFormat,
6
+ } from './openai-tts.js';
7
+
8
+ export {
9
+ OpenAiSynthesizer,
10
+ createOpenAiSynthesizer,
11
+ capInput,
12
+ clampSpeed,
13
+ type OpenAiSynthesizerOptions,
14
+ type OpenAiTtsFormat,
15
+ type FetchLike,
16
+ } from './openai-tts.js';
17
+
18
+ /** The single registered synthesizer name — surfaces show this in `set_voice`. */
19
+ export const OPENAI_TTS_SYNTHESIZER_NAME = 'openai-tts';
20
+
21
+ export interface BuildOpenAiTtsPluginOptions {
22
+ /**
23
+ * Build-time defaults baked into the synthesizer def. Per-activation config
24
+ * (`session.synthesizers.setActive(name, config)`) still overrides `model` /
25
+ * `voice` / `format`. Mainly a seam for tests to inject `fetchImpl` / `apiKey`.
26
+ */
27
+ readonly defaults?: Omit<OpenAiSynthesizerOptions, 'getSecret'>;
28
+ }
29
+
30
+ /**
31
+ * Build the @moxxy/plugin-tts-openai plugin. Registers exactly one synthesizer,
32
+ * `openai-tts`, backed by OpenAI's `/v1/audio/speech`.
33
+ *
34
+ * The plugin is intentionally side-effect free — it never calls `setActive`.
35
+ * The `SynthesizerRegistry` is `autoAdoptFirst`, so the first synthesizer
36
+ * registered (this one, on a fresh install) becomes active on read without any
37
+ * activation step here; the agent switches voices via the `set_voice` tool.
38
+ */
39
+ export function buildOpenAiTtsPlugin(opts: BuildOpenAiTtsPluginOptions = {}): Plugin {
40
+ const defaults = opts.defaults ?? {};
41
+ return definePlugin({
42
+ name: '@moxxy/plugin-tts-openai',
43
+ version: '0.0.0',
44
+ synthesizers: [
45
+ defineSynthesizer({
46
+ name: OPENAI_TTS_SYNTHESIZER_NAME,
47
+ displayName: 'OpenAI TTS',
48
+ // `create` runs lazily and may re-run (buildOnRead) — keep it cheap and
49
+ // side-effect free; the key is resolved inside `synthesize`.
50
+ create: (ctx) =>
51
+ new OpenAiSynthesizer({
52
+ ...defaults,
53
+ ...configToOptions(ctx.config),
54
+ ...(ctx.getSecret ? { getSecret: ctx.getSecret } : {}),
55
+ }),
56
+ }),
57
+ ],
58
+ });
59
+ }
60
+
61
+ /** Narrow the untrusted per-activation config record into typed options. Only
62
+ * string `model` / `voice` / `baseURL` / `apiKey` and a known `format` are
63
+ * honored; anything else is ignored so a malformed config can't break create. */
64
+ function configToOptions(config: Record<string, unknown>): Partial<OpenAiSynthesizerOptions> {
65
+ const out: { -readonly [K in keyof OpenAiSynthesizerOptions]?: OpenAiSynthesizerOptions[K] } = {};
66
+ if (typeof config.model === 'string' && config.model) out.model = config.model;
67
+ if (typeof config.voice === 'string' && config.voice) out.voice = config.voice;
68
+ if (typeof config.baseURL === 'string' && config.baseURL) out.baseURL = config.baseURL;
69
+ if (typeof config.apiKey === 'string' && config.apiKey) out.apiKey = config.apiKey;
70
+ const format = normalizeFormat(config.format);
71
+ if (format) out.format = format;
72
+ return out;
73
+ }
74
+
75
+ const KNOWN_FORMATS: ReadonlyArray<OpenAiTtsFormat> = ['mp3', 'opus', 'wav', 'aac'];
76
+
77
+ /** Accept only the exact supported format strings (guards against inherited
78
+ * keys like `constructor` slipping through an `in` check). */
79
+ function normalizeFormat(value: unknown): OpenAiTtsFormat | undefined {
80
+ return typeof value === 'string' && (KNOWN_FORMATS as ReadonlyArray<string>).includes(value)
81
+ ? (value as OpenAiTtsFormat)
82
+ : undefined;
83
+ }
84
+
85
+ // Discovery entry: `createPluginLoader` requires a default Plugin export.
86
+ export default buildOpenAiTtsPlugin();
@@ -0,0 +1,269 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { OpenAiSynthesizer, capInput, clampSpeed, type FetchLike } from './openai-tts.js';
3
+ import { buildOpenAiTtsPlugin, OPENAI_TTS_SYNTHESIZER_NAME } from './index.js';
4
+
5
+ interface Captured {
6
+ url: string;
7
+ init: RequestInit;
8
+ }
9
+
10
+ /** A stub `fetch` that records the request and returns fixed audio bytes. */
11
+ function okFetch(
12
+ bytes: Uint8Array = new Uint8Array([1, 2, 3, 4]),
13
+ status = 200,
14
+ ): { fetchImpl: FetchLike; calls: Captured[] } {
15
+ const calls: Captured[] = [];
16
+ const fetchImpl: FetchLike = async (url, init) => {
17
+ calls.push({ url, init });
18
+ return new Response(bytes, { status });
19
+ };
20
+ return { fetchImpl, calls };
21
+ }
22
+
23
+ /** A stub `fetch` that returns an error status with a body. */
24
+ function statusFetch(status: number, body = 'boom'): FetchLike {
25
+ return async () => new Response(body, { status });
26
+ }
27
+
28
+ /** A `fetch` that never resolves and only rejects when its signal aborts —
29
+ * checking `aborted` synchronously (as real fetch does) so it works even when
30
+ * the signal fired before the request was issued. Drives abort + timeout. */
31
+ const abortAwareFetch: FetchLike = (_url, init) =>
32
+ new Promise((_resolve, reject) => {
33
+ const signal = init.signal;
34
+ const fail = (): void => reject(new DOMException('Aborted', 'AbortError'));
35
+ if (signal?.aborted) fail();
36
+ else signal?.addEventListener('abort', fail, { once: true });
37
+ });
38
+
39
+ /** Parse the JSON body a captured request carried. */
40
+ function bodyOf(calls: Captured[]): Record<string, unknown> {
41
+ return JSON.parse(calls[0]!.init.body as string) as Record<string, unknown>;
42
+ }
43
+
44
+ describe('OpenAiSynthesizer.synthesize', () => {
45
+ it('returns audio bytes and the mp3 mimeType by default', async () => {
46
+ const { fetchImpl, calls } = okFetch(new Uint8Array([9, 8, 7]));
47
+ const synth = new OpenAiSynthesizer({ apiKey: 'sk-test', fetchImpl });
48
+ const out = await synth.synthesize('hello world');
49
+
50
+ expect(Array.from(out.audio)).toEqual([9, 8, 7]);
51
+ expect(out.mimeType).toBe('audio/mpeg');
52
+ expect(calls[0]!.url).toBe('https://api.openai.com/v1/audio/speech');
53
+ expect(calls[0]!.init.method).toBe('POST');
54
+ const headers = calls[0]!.init.headers as Record<string, string>;
55
+ expect(headers.authorization).toBe('Bearer sk-test');
56
+ expect(headers['content-type']).toBe('application/json');
57
+ const body = bodyOf(calls);
58
+ expect(body.model).toBe('gpt-4o-mini-tts');
59
+ expect(body.voice).toBe('alloy');
60
+ expect(body.input).toBe('hello world');
61
+ expect(body.response_format).toBe('mp3');
62
+ expect(body.speed).toBeUndefined();
63
+ });
64
+
65
+ it.each([
66
+ ['mp3', 'audio/mpeg'],
67
+ ['opus', 'audio/ogg'],
68
+ ['wav', 'audio/wav'],
69
+ ['aac', 'audio/aac'],
70
+ ] as const)('maps format %s to mimeType %s', async (format, mimeType) => {
71
+ const { fetchImpl, calls } = okFetch();
72
+ const synth = new OpenAiSynthesizer({ apiKey: 'sk-test', fetchImpl, format });
73
+ const out = await synth.synthesize('hi');
74
+ expect(out.mimeType).toBe(mimeType);
75
+ expect(bodyOf(calls).response_format).toBe(format);
76
+ });
77
+
78
+ it('lets opts.voice override the configured voice', async () => {
79
+ const { fetchImpl, calls } = okFetch();
80
+ const synth = new OpenAiSynthesizer({ apiKey: 'sk-test', fetchImpl, voice: 'alloy' });
81
+ await synth.synthesize('hi', { voice: 'nova' });
82
+ expect(bodyOf(calls).voice).toBe('nova');
83
+ });
84
+
85
+ it('maps rate to speed', async () => {
86
+ const { fetchImpl, calls } = okFetch();
87
+ const synth = new OpenAiSynthesizer({ apiKey: 'sk-test', fetchImpl });
88
+ await synth.synthesize('hi', { rate: 1.5 });
89
+ expect(bodyOf(calls).speed).toBe(1.5);
90
+ });
91
+
92
+ it.each([
93
+ [10, 4.0],
94
+ [0.01, 0.25],
95
+ [Number.NaN, undefined],
96
+ ])('clamps rate %s to speed %s', async (rate, expected) => {
97
+ const { fetchImpl, calls } = okFetch();
98
+ const synth = new OpenAiSynthesizer({ apiKey: 'sk-test', fetchImpl });
99
+ await synth.synthesize('hi', { rate });
100
+ expect(bodyOf(calls).speed).toBe(expected);
101
+ });
102
+
103
+ it('truncates input over 4096 chars at a sentence boundary with an ellipsis', async () => {
104
+ const { fetchImpl, calls } = okFetch();
105
+ const synth = new OpenAiSynthesizer({ apiKey: 'sk-test', fetchImpl });
106
+ // 'a'*4000 + '. ' then more — the sentence boundary at index 4000 is the
107
+ // last one inside the 4095-char budget.
108
+ const long = `${'a'.repeat(4000)}. ${'b'.repeat(200)}. ${'c'.repeat(200)}`;
109
+ await synth.synthesize(long);
110
+ const input = bodyOf(calls).input as string;
111
+ expect(input.length).toBeLessThanOrEqual(4096);
112
+ expect(input.length).toBeLessThan(long.length);
113
+ expect(input.endsWith('…')).toBe(true);
114
+ // Cut fell right after the sentence-ending period, not mid-word.
115
+ expect(input.endsWith('.…')).toBe(true);
116
+ });
117
+
118
+ it('leaves short input untouched (no ellipsis)', async () => {
119
+ const { fetchImpl, calls } = okFetch();
120
+ const synth = new OpenAiSynthesizer({ apiKey: 'sk-test', fetchImpl });
121
+ await synth.synthesize('a short reply.');
122
+ expect(bodyOf(calls).input).toBe('a short reply.');
123
+ });
124
+
125
+ it('throws AUTH_NO_CREDENTIALS when no key is available anywhere', async () => {
126
+ const prev = process.env.OPENAI_API_KEY;
127
+ delete process.env.OPENAI_API_KEY;
128
+ try {
129
+ const { fetchImpl } = okFetch();
130
+ const synth = new OpenAiSynthesizer({ getSecret: async () => null, fetchImpl });
131
+ await expect(synth.synthesize('hi')).rejects.toMatchObject({
132
+ code: 'AUTH_NO_CREDENTIALS',
133
+ });
134
+ } finally {
135
+ if (prev !== undefined) process.env.OPENAI_API_KEY = prev;
136
+ }
137
+ });
138
+
139
+ it('reads the key from getSecret (OPENAI_API_KEY) when no explicit key', async () => {
140
+ const { fetchImpl, calls } = okFetch();
141
+ const synth = new OpenAiSynthesizer({
142
+ getSecret: async (name) => (name === 'OPENAI_API_KEY' ? 'sk-vault' : null),
143
+ fetchImpl,
144
+ });
145
+ await synth.synthesize('hi');
146
+ const headers = calls[0]!.init.headers as Record<string, string>;
147
+ expect(headers.authorization).toBe('Bearer sk-vault');
148
+ });
149
+
150
+ it.each([
151
+ [401, 'AUTH_INVALID'],
152
+ [403, 'AUTH_DENIED'],
153
+ [429, 'PROVIDER_RATE_LIMITED'],
154
+ [500, 'PROVIDER_SERVER_ERROR'],
155
+ [503, 'PROVIDER_SERVER_ERROR'],
156
+ ])('maps HTTP %s to %s', async (status, code) => {
157
+ const synth = new OpenAiSynthesizer({ apiKey: 'sk-test', fetchImpl: statusFetch(status) });
158
+ await expect(synth.synthesize('hi')).rejects.toMatchObject({ code });
159
+ });
160
+
161
+ it('falls back to PROVIDER_BAD_REQUEST for an unmapped status', async () => {
162
+ const synth = new OpenAiSynthesizer({ apiKey: 'sk-test', fetchImpl: statusFetch(418) });
163
+ await expect(synth.synthesize('hi')).rejects.toMatchObject({
164
+ code: 'PROVIDER_BAD_REQUEST',
165
+ context: { status: 418 },
166
+ });
167
+ });
168
+
169
+ it('classifies a network failure', async () => {
170
+ const fetchImpl: FetchLike = async () => {
171
+ throw new Error('fetch failed');
172
+ };
173
+ const synth = new OpenAiSynthesizer({ apiKey: 'sk-test', fetchImpl });
174
+ await expect(synth.synthesize('hi')).rejects.toMatchObject({
175
+ code: 'NETWORK_UNREACHABLE',
176
+ });
177
+ });
178
+
179
+ it('honors an already-aborted signal, propagating its reason', async () => {
180
+ const { fetchImpl } = okFetch();
181
+ const synth = new OpenAiSynthesizer({ apiKey: 'sk-test', fetchImpl });
182
+ const ac = new AbortController();
183
+ const reason = new Error('user stopped');
184
+ ac.abort(reason);
185
+ await expect(synth.synthesize('hi', { signal: ac.signal })).rejects.toBe(reason);
186
+ });
187
+
188
+ it('honors an in-flight abort, propagating the caller reason', async () => {
189
+ const synth = new OpenAiSynthesizer({ apiKey: 'sk-test', fetchImpl: abortAwareFetch });
190
+ const ac = new AbortController();
191
+ const reason = new Error('cancelled mid-flight');
192
+ const p = synth.synthesize('hi', { signal: ac.signal });
193
+ ac.abort(reason);
194
+ await expect(p).rejects.toBe(reason);
195
+ });
196
+
197
+ it('times out a hung request as NETWORK_TIMEOUT', async () => {
198
+ const synth = new OpenAiSynthesizer({
199
+ apiKey: 'sk-test',
200
+ fetchImpl: abortAwareFetch,
201
+ timeoutMs: 10,
202
+ });
203
+ await expect(synth.synthesize('hi')).rejects.toMatchObject({ code: 'NETWORK_TIMEOUT' });
204
+ });
205
+ });
206
+
207
+ describe('capInput / clampSpeed helpers', () => {
208
+ it('capInput passes through under the limit', () => {
209
+ expect(capInput('short')).toBe('short');
210
+ expect(capInput('a'.repeat(4096))).toBe('a'.repeat(4096));
211
+ });
212
+
213
+ it('capInput hard-slices a single boundary-less sentence', () => {
214
+ const out = capInput('x'.repeat(5000));
215
+ expect(out.length).toBe(4096); // 4095 chars + the ellipsis
216
+ expect(out.endsWith('…')).toBe(true);
217
+ });
218
+
219
+ it('clampSpeed clamps and drops non-finite', () => {
220
+ expect(clampSpeed(undefined)).toBeUndefined();
221
+ expect(clampSpeed(Number.POSITIVE_INFINITY)).toBeUndefined();
222
+ expect(clampSpeed(1)).toBe(1);
223
+ expect(clampSpeed(9)).toBe(4);
224
+ expect(clampSpeed(0)).toBe(0.25);
225
+ });
226
+ });
227
+
228
+ describe('buildOpenAiTtsPlugin', () => {
229
+ it('registers exactly one synthesizer named openai-tts', () => {
230
+ const plugin = buildOpenAiTtsPlugin();
231
+ expect(plugin.synthesizers).toHaveLength(1);
232
+ const def = plugin.synthesizers![0]!;
233
+ expect(def.name).toBe(OPENAI_TTS_SYNTHESIZER_NAME);
234
+ expect(def.displayName).toBe('OpenAI TTS');
235
+ });
236
+
237
+ it('create() yields a synthesizer whose config voice/format flow through', async () => {
238
+ const { fetchImpl, calls } = okFetch();
239
+ const plugin = buildOpenAiTtsPlugin({ defaults: { fetchImpl, apiKey: 'sk-test' } });
240
+ const def = plugin.synthesizers![0]!;
241
+ const inst = def.create({ config: { voice: 'nova', format: 'opus' } });
242
+ expect(inst.name).toBe('openai-tts');
243
+ const out = await inst.synthesize('hi');
244
+ expect(out.mimeType).toBe('audio/ogg');
245
+ expect(bodyOf(calls).voice).toBe('nova');
246
+ });
247
+
248
+ it('create() wires ctx.getSecret through for key resolution', async () => {
249
+ const { fetchImpl, calls } = okFetch();
250
+ const plugin = buildOpenAiTtsPlugin({ defaults: { fetchImpl } });
251
+ const def = plugin.synthesizers![0]!;
252
+ const inst = def.create({
253
+ config: {},
254
+ getSecret: async (name) => (name === 'OPENAI_API_KEY' ? 'sk-ctx' : null),
255
+ });
256
+ await inst.synthesize('hi');
257
+ const headers = calls[0]!.init.headers as Record<string, string>;
258
+ expect(headers.authorization).toBe('Bearer sk-ctx');
259
+ });
260
+
261
+ it('create() ignores an unknown config format', async () => {
262
+ const { fetchImpl } = okFetch();
263
+ const plugin = buildOpenAiTtsPlugin({ defaults: { fetchImpl, apiKey: 'sk-test' } });
264
+ const inst = plugin.synthesizers![0]!.create({ config: { format: 'flac' } });
265
+ const out = await inst.synthesize('hi');
266
+ // Unknown format ignored → default mp3.
267
+ expect(out.mimeType).toBe('audio/mpeg');
268
+ });
269
+ });
@@ -0,0 +1,233 @@
1
+ import {
2
+ classifyHttpStatus,
3
+ classifyNetworkError,
4
+ MoxxyError,
5
+ type Synthesizer,
6
+ type SynthesizeOptions,
7
+ type SynthesisResult,
8
+ } from '@moxxy/sdk';
9
+
10
+ /** Provider tag attached to classified errors for logs/debug context. */
11
+ const OPENAI_TTS_PROVIDER_ID = 'openai';
12
+
13
+ /** OpenAI `/v1/audio/speech` response formats we expose, mapped to the MIME
14
+ * type the desktop/channels play the returned bytes as. Kept a small closed
15
+ * map (OpenAI also serves flac/pcm — not surfaced here). */
16
+ const MIME_BY_FORMAT = {
17
+ mp3: 'audio/mpeg',
18
+ opus: 'audio/ogg',
19
+ wav: 'audio/wav',
20
+ aac: 'audio/aac',
21
+ } as const;
22
+
23
+ export type OpenAiTtsFormat = keyof typeof MIME_BY_FORMAT;
24
+
25
+ /** OpenAI rejects `input` longer than 4096 characters. Channels routinely send
26
+ * long replies, so we truncate at a sentence boundary rather than let the API
27
+ * 400 the whole read-aloud. */
28
+ const MAX_INPUT_CHARS = 4096;
29
+
30
+ /** OpenAI clamps `speed` to this inclusive range; anything outside 400s. */
31
+ const MIN_SPEED = 0.25;
32
+ const MAX_SPEED = 4.0;
33
+
34
+ /** Default per-request deadline. Read-aloud is cancellable, but a hung socket
35
+ * should still free itself so callers can fall back to the OS voice. */
36
+ const DEFAULT_TIMEOUT_MS = 60_000;
37
+
38
+ /** Injectable `fetch` — the default is the global; tests pass a stub. Widened
39
+ * from the global signature so a plain `(url, init) => Response` stub fits. */
40
+ export type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
41
+
42
+ export interface OpenAiSynthesizerOptions {
43
+ /** Explicit API key. Normally omitted — the key is read via `getSecret`. */
44
+ readonly apiKey?: string;
45
+ /** Vault-backed secret resolver handed in by `SynthesizerCreateContext`. */
46
+ readonly getSecret?: (name: string) => Promise<string | null>;
47
+ /** API base, default `https://api.openai.com/v1`. Trailing slashes trimmed. */
48
+ readonly baseURL?: string;
49
+ /** TTS model, default `gpt-4o-mini-tts`. */
50
+ readonly model?: string;
51
+ /** Default voice, default `alloy`. Overridden per-call by `opts.voice`. */
52
+ readonly voice?: string;
53
+ /** Output container, default `mp3`. Determines the returned `mimeType`. */
54
+ readonly format?: OpenAiTtsFormat;
55
+ /** Injected `fetch` (tests). Defaults to the global. */
56
+ readonly fetchImpl?: FetchLike;
57
+ /** Per-request timeout in ms. Default 60000. */
58
+ readonly timeoutMs?: number;
59
+ }
60
+
61
+ /**
62
+ * `Synthesizer` backed by OpenAI's `POST /v1/audio/speech` endpoint — one JSON
63
+ * POST returning raw audio bytes, so there's no need for the `openai` SDK. The
64
+ * API key rides the vault (`ctx.getSecret('OPENAI_API_KEY')`, the same key the
65
+ * OpenAI provider uses) with a `process.env.OPENAI_API_KEY` fallback, resolved
66
+ * lazily on the first `synthesize` and cached so `create()` stays cheap.
67
+ */
68
+ export class OpenAiSynthesizer implements Synthesizer {
69
+ readonly name = 'openai-tts';
70
+ /** MIME type of the bytes this instance returns (derived from `format`). */
71
+ readonly mimeType: string;
72
+
73
+ private readonly explicitKey: string | undefined;
74
+ private readonly getSecret: ((name: string) => Promise<string | null>) | undefined;
75
+ private readonly baseURL: string;
76
+ private readonly model: string;
77
+ private readonly voice: string;
78
+ private readonly format: OpenAiTtsFormat;
79
+ private readonly fetchImpl: FetchLike;
80
+ private readonly timeoutMs: number;
81
+ private key: string | null = null;
82
+
83
+ constructor(opts: OpenAiSynthesizerOptions = {}) {
84
+ this.explicitKey = opts.apiKey;
85
+ this.getSecret = opts.getSecret;
86
+ this.baseURL = (opts.baseURL ?? 'https://api.openai.com/v1').replace(/\/+$/, '');
87
+ this.model = opts.model ?? 'gpt-4o-mini-tts';
88
+ this.voice = opts.voice ?? 'alloy';
89
+ this.format = opts.format ?? 'mp3';
90
+ this.mimeType = MIME_BY_FORMAT[this.format];
91
+ this.fetchImpl = opts.fetchImpl ?? (globalThis.fetch as FetchLike);
92
+ this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
93
+ }
94
+
95
+ async synthesize(text: string, opts: SynthesizeOptions = {}): Promise<SynthesisResult> {
96
+ // Fast-path a caller who cancelled before we started — propagate their reason.
97
+ opts.signal?.throwIfAborted();
98
+
99
+ const key = await this.resolveKey();
100
+ const input = capInput(text);
101
+ const voice = opts.voice ?? this.voice;
102
+ const speed = clampSpeed(opts.rate);
103
+ const body = {
104
+ model: this.model,
105
+ voice,
106
+ input,
107
+ response_format: this.format,
108
+ ...(speed !== undefined ? { speed } : {}),
109
+ };
110
+ const url = `${this.baseURL}/audio/speech`;
111
+
112
+ // Chain the caller's abort signal with our own timeout onto one controller
113
+ // passed to fetch, so either cancels the request and frees the socket.
114
+ const controller = new AbortController();
115
+ let timedOut = false;
116
+ const timer = setTimeout(() => {
117
+ timedOut = true;
118
+ controller.abort();
119
+ }, this.timeoutMs);
120
+ const forward = (): void => controller.abort();
121
+ if (opts.signal) {
122
+ // The signal may have fired during key resolution above; a listener added
123
+ // to an already-aborted signal is never called, so abort directly here.
124
+ if (opts.signal.aborted) controller.abort();
125
+ else opts.signal.addEventListener('abort', forward, { once: true });
126
+ }
127
+
128
+ let res: Response;
129
+ try {
130
+ res = await this.fetchImpl(url, {
131
+ method: 'POST',
132
+ headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
133
+ body: JSON.stringify(body),
134
+ signal: controller.signal,
135
+ });
136
+ } catch (err) {
137
+ // Caller-initiated cancellation: re-throw their reason unchanged so a
138
+ // stopped read-aloud isn't masked as a provider/network error.
139
+ if (opts.signal?.aborted && !timedOut) throw opts.signal.reason ?? err;
140
+ if (timedOut) {
141
+ throw new MoxxyError({
142
+ code: 'NETWORK_TIMEOUT',
143
+ message: `OpenAI text-to-speech request timed out after ${this.timeoutMs} ms.`,
144
+ hint: 'Retry, or shorten the text being read aloud.',
145
+ context: { provider: OPENAI_TTS_PROVIDER_ID, url },
146
+ });
147
+ }
148
+ const network = classifyNetworkError(err, { provider: OPENAI_TTS_PROVIDER_ID, url });
149
+ if (network) throw network;
150
+ throw err;
151
+ } finally {
152
+ clearTimeout(timer);
153
+ opts.signal?.removeEventListener('abort', forward);
154
+ }
155
+
156
+ if (!res.ok) {
157
+ const classified = classifyHttpStatus(res.status, {
158
+ provider: OPENAI_TTS_PROVIDER_ID,
159
+ url,
160
+ body: await res.text().catch(() => ''),
161
+ });
162
+ if (classified) throw classified;
163
+ throw new MoxxyError({
164
+ code: 'PROVIDER_BAD_REQUEST',
165
+ message: `OpenAI text-to-speech returned HTTP ${res.status}.`,
166
+ context: { provider: OPENAI_TTS_PROVIDER_ID, url, status: res.status },
167
+ });
168
+ }
169
+
170
+ const audio = new Uint8Array(await res.arrayBuffer());
171
+ return { audio, mimeType: this.mimeType };
172
+ }
173
+
174
+ /**
175
+ * Resolve the API key lazily: explicit option → vault (`OPENAI_API_KEY`) →
176
+ * `process.env.OPENAI_API_KEY`. Cache only a successful resolution so a key
177
+ * added after a first failed attempt is still picked up on retry. A missing
178
+ * key becomes a classified, actionable `MoxxyError`.
179
+ */
180
+ private async resolveKey(): Promise<string> {
181
+ if (this.key) return this.key;
182
+ const resolved =
183
+ this.explicitKey ??
184
+ (await this.getSecret?.('OPENAI_API_KEY')) ??
185
+ process.env.OPENAI_API_KEY ??
186
+ null;
187
+ if (!resolved) {
188
+ throw new MoxxyError({
189
+ code: 'AUTH_NO_CREDENTIALS',
190
+ message:
191
+ 'No OpenAI API key for text-to-speech. It rides the same OPENAI_API_KEY as the OpenAI provider.',
192
+ hint: 'Run `moxxy init` (stores it in the vault) or set OPENAI_API_KEY.',
193
+ context: { provider: OPENAI_TTS_PROVIDER_ID },
194
+ });
195
+ }
196
+ this.key = resolved;
197
+ return resolved;
198
+ }
199
+ }
200
+
201
+ /** Map a `SynthesizeOptions.rate` multiplier onto OpenAI's `speed`, clamped to
202
+ * the accepted 0.25–4.0 range. Non-finite / absent rates yield `undefined` so
203
+ * the field is omitted and OpenAI uses its default. */
204
+ export function clampSpeed(rate: number | undefined): number | undefined {
205
+ if (rate === undefined || !Number.isFinite(rate)) return undefined;
206
+ return Math.min(MAX_SPEED, Math.max(MIN_SPEED, rate));
207
+ }
208
+
209
+ /**
210
+ * Cap `text` to OpenAI's 4096-char `input` limit. When over, truncate at the
211
+ * last sentence boundary (`. `, `! `, `? `, newline) inside the budget — as
212
+ * long as that keeps most of the text — else hard-slice, and append a single
213
+ * ellipsis so the result is always ≤ 4096 characters.
214
+ */
215
+ export function capInput(text: string): string {
216
+ if (text.length <= MAX_INPUT_CHARS) return text;
217
+ const budget = MAX_INPUT_CHARS - 1; // leave room for the ellipsis
218
+ const slice = text.slice(0, budget);
219
+ const boundary = Math.max(
220
+ slice.lastIndexOf('. '),
221
+ slice.lastIndexOf('! '),
222
+ slice.lastIndexOf('? '),
223
+ slice.lastIndexOf('\n'),
224
+ );
225
+ // Only honor a boundary that doesn't discard more than half the budget,
226
+ // otherwise a single very long sentence would be cut to almost nothing.
227
+ const cut = boundary > budget * 0.5 ? boundary + 1 : budget;
228
+ return `${slice.slice(0, cut).trimEnd()}…`;
229
+ }
230
+
231
+ export function createOpenAiSynthesizer(opts: OpenAiSynthesizerOptions = {}): OpenAiSynthesizer {
232
+ return new OpenAiSynthesizer(opts);
233
+ }