@stabgan/openrouter-mcp-multimodal 1.8.2 → 2.0.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.
@@ -0,0 +1,34 @@
1
+ import { prepareAudioData } from './audio-utils.js';
2
+ const DEFAULT_MODEL = 'google/gemini-2.5-flash';
3
+ export async function handleAnalyzeAudio(request, openai, defaultModel) {
4
+ const { audio_path, question, model } = request.params.arguments;
5
+ if (!audio_path) {
6
+ return { content: [{ type: 'text', text: 'audio_path is required.' }], isError: true };
7
+ }
8
+ try {
9
+ const audioData = await prepareAudioData(audio_path);
10
+ const completion = await openai.chat.completions.create({
11
+ model: model || defaultModel || DEFAULT_MODEL,
12
+ messages: [
13
+ {
14
+ role: 'user',
15
+ content: [
16
+ { type: 'text', text: question || 'Please transcribe and analyze this audio file.' },
17
+ {
18
+ type: 'input_audio',
19
+ input_audio: {
20
+ data: audioData.data,
21
+ format: audioData.format,
22
+ },
23
+ },
24
+ ],
25
+ },
26
+ ],
27
+ });
28
+ return { content: [{ type: 'text', text: completion.choices[0].message.content || '' }] };
29
+ }
30
+ catch (error) {
31
+ const msg = error instanceof Error ? error.message : String(error);
32
+ return { content: [{ type: 'text', text: `Error: ${msg}` }], isError: true };
33
+ }
34
+ }
@@ -0,0 +1,19 @@
1
+ export { isBlockedIPv4, assertUrlSafeForFetch } from './fetch-utils.js';
2
+ /** File-extension formats (matchable by .ext) */
3
+ declare const FILE_AUDIO_FORMATS: readonly ["wav", "mp3", "aiff", "aac", "ogg", "flac", "m4a"];
4
+ export declare const SUPPORTED_AUDIO_FORMATS: readonly ["wav", "mp3", "aiff", "aac", "ogg", "flac", "m4a", "pcm16", "pcm24"];
5
+ export type AudioFormat = (typeof SUPPORTED_AUDIO_FORMATS)[number];
6
+ type FileAudioFormat = (typeof FILE_AUDIO_FORMATS)[number];
7
+ /** Get audio format from file extension. Returns undefined for non-audio or API-only formats. */
8
+ export declare function getAudioFormat(filePath: string): FileAudioFormat | undefined;
9
+ /** Get MIME type for an audio format. */
10
+ export declare function getAudioMimeType(format: AudioFormat): string;
11
+ export interface AudioData {
12
+ data: string;
13
+ format: AudioFormat;
14
+ }
15
+ /**
16
+ * Prepare audio from any source (data URL, HTTP URL, local file) as base64 + format.
17
+ * OpenRouter requires audio to be base64-encoded; direct URLs are NOT supported.
18
+ */
19
+ export declare function prepareAudioData(source: string): Promise<AudioData>;
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Audio format detection, base64 encoding, and fetch utilities.
3
+ * Network/security logic is delegated to fetch-utils.ts (zero duplication).
4
+ */
5
+ import path from 'path';
6
+ import { promises as fs } from 'fs';
7
+ import { readEnvInt, fetchHttpResource } from './fetch-utils.js';
8
+ // Re-export for tests
9
+ export { isBlockedIPv4, assertUrlSafeForFetch } from './fetch-utils.js';
10
+ const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
11
+ const DEFAULT_MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024;
12
+ const DEFAULT_MAX_REDIRECTS = 8;
13
+ const DEFAULT_MAX_DATA_URL_BYTES = 20 * 1024 * 1024;
14
+ function getFetchTimeoutMs() {
15
+ return readEnvInt('OPENROUTER_AUDIO_FETCH_TIMEOUT_MS', DEFAULT_FETCH_TIMEOUT_MS, 1000);
16
+ }
17
+ function getMaxDownloadBytes() {
18
+ return readEnvInt('OPENROUTER_AUDIO_MAX_DOWNLOAD_BYTES', DEFAULT_MAX_DOWNLOAD_BYTES, 1024);
19
+ }
20
+ function getMaxRedirects() {
21
+ return readEnvInt('OPENROUTER_AUDIO_MAX_REDIRECTS', DEFAULT_MAX_REDIRECTS, 0);
22
+ }
23
+ function getMaxDataUrlBytes() {
24
+ return readEnvInt('OPENROUTER_AUDIO_MAX_DATA_URL_BYTES', DEFAULT_MAX_DATA_URL_BYTES, 1024);
25
+ }
26
+ /** File-extension formats (matchable by .ext) */
27
+ const FILE_AUDIO_FORMATS = ['wav', 'mp3', 'aiff', 'aac', 'ogg', 'flac', 'm4a'];
28
+ /** API-only formats (no real file extension) */
29
+ const API_AUDIO_FORMATS = ['pcm16', 'pcm24'];
30
+ export const SUPPORTED_AUDIO_FORMATS = [...FILE_AUDIO_FORMATS, ...API_AUDIO_FORMATS];
31
+ /** Get audio format from file extension. Returns undefined for non-audio or API-only formats. */
32
+ export function getAudioFormat(filePath) {
33
+ const ext = path.extname(filePath).toLowerCase().slice(1);
34
+ return FILE_AUDIO_FORMATS.includes(ext)
35
+ ? ext
36
+ : undefined;
37
+ }
38
+ /** Get MIME type for an audio format. */
39
+ export function getAudioMimeType(format) {
40
+ const map = {
41
+ wav: 'audio/wav',
42
+ mp3: 'audio/mpeg',
43
+ aiff: 'audio/aiff',
44
+ aac: 'audio/aac',
45
+ ogg: 'audio/ogg',
46
+ flac: 'audio/flac',
47
+ m4a: 'audio/mp4',
48
+ pcm16: 'audio/pcm',
49
+ pcm24: 'audio/pcm',
50
+ };
51
+ return map[format] || 'audio/wav';
52
+ }
53
+ /** Map a MIME subtype (e.g. "mpeg", "wave") to an AudioFormat. */
54
+ function mimeSubtypeToFormat(subtype) {
55
+ const aliasMap = {
56
+ mpeg: 'mp3',
57
+ wav: 'wav',
58
+ wave: 'wav',
59
+ mp3: 'mp3',
60
+ flac: 'flac',
61
+ ogg: 'ogg',
62
+ aac: 'aac',
63
+ 'x-aac': 'aac',
64
+ m4a: 'm4a',
65
+ mp4: 'm4a',
66
+ aiff: 'aiff',
67
+ 'x-aiff': 'aiff',
68
+ pcm: 'pcm16',
69
+ };
70
+ const lower = subtype.toLowerCase();
71
+ return (aliasMap[lower] ??
72
+ (SUPPORTED_AUDIO_FORMATS.includes(lower)
73
+ ? lower
74
+ : undefined));
75
+ }
76
+ /** Derive AudioFormat from a Content-Type header value. */
77
+ function formatFromContentType(ct) {
78
+ if (!ct)
79
+ return undefined;
80
+ const mime = ct.split(';')[0].trim().toLowerCase();
81
+ if (!mime.startsWith('audio/'))
82
+ return undefined;
83
+ return mimeSubtypeToFormat(mime.slice(6));
84
+ }
85
+ /**
86
+ * Prepare audio from any source (data URL, HTTP URL, local file) as base64 + format.
87
+ * OpenRouter requires audio to be base64-encoded; direct URLs are NOT supported.
88
+ */
89
+ export async function prepareAudioData(source) {
90
+ // --- data URL ---
91
+ if (source.startsWith('data:')) {
92
+ const match = source.match(/^data:([^;]+);base64,(.+)$/);
93
+ if (!match)
94
+ throw new Error('Invalid data URL format');
95
+ const mime = match[1];
96
+ const b64 = match[2];
97
+ const format = mimeSubtypeToFormat(mime.split('/')[1] ?? '');
98
+ if (!format) {
99
+ throw new Error(`Unsupported audio format from MIME: ${mime}. Supported: ${SUPPORTED_AUDIO_FORMATS.join(', ')}`);
100
+ }
101
+ const approxBytes = Math.ceil((b64.length * 3) / 4);
102
+ if (approxBytes > getMaxDataUrlBytes())
103
+ throw new Error('Data URL too large');
104
+ return { data: b64, format };
105
+ }
106
+ // --- HTTP(S) URL ---
107
+ if (source.startsWith('http://') || source.startsWith('https://')) {
108
+ const { buffer, contentType } = await fetchHttpResource(source, {
109
+ timeoutMs: getFetchTimeoutMs(),
110
+ maxBytes: getMaxDownloadBytes(),
111
+ maxRedirects: getMaxRedirects(),
112
+ });
113
+ // Try URL path extension first, fall back to Content-Type header
114
+ const urlPath = new URL(source).pathname;
115
+ const format = getAudioFormat(urlPath) ?? formatFromContentType(contentType);
116
+ if (!format) {
117
+ throw new Error(`Could not determine audio format from URL: ${source}. Supported: ${SUPPORTED_AUDIO_FORMATS.join(', ')}`);
118
+ }
119
+ return { data: buffer.toString('base64'), format };
120
+ }
121
+ // --- local file ---
122
+ const format = getAudioFormat(source);
123
+ if (!format) {
124
+ throw new Error(`Unsupported audio format for file: ${source}. Supported: ${SUPPORTED_AUDIO_FORMATS.join(', ')}`);
125
+ }
126
+ const buffer = await fs.readFile(source);
127
+ return { data: buffer.toString('base64'), format };
128
+ }
@@ -0,0 +1,18 @@
1
+ export declare function readEnvInt(name: string, fallback: number, min?: number): number;
2
+ /** Blocks RFC1918, loopback, link-local, CGNAT, metadata. */
3
+ export declare function isBlockedIPv4(ip: string): boolean;
4
+ /** Resolve hostname and ensure the resolved address is not private/link-local. */
5
+ export declare function assertUrlSafeForFetch(urlString: string): Promise<URL>;
6
+ export interface FetchOptions {
7
+ timeoutMs: number;
8
+ maxBytes: number;
9
+ maxRedirects: number;
10
+ }
11
+ /**
12
+ * Fetch a remote HTTP(S) resource with SSRF protection, size limits,
13
+ * redirect cap, and timeout. Returns body Buffer + Content-Type header.
14
+ */
15
+ export declare function fetchHttpResource(urlString: string, opts: FetchOptions): Promise<{
16
+ buffer: Buffer;
17
+ contentType: string | null;
18
+ }>;
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Shared network/security utilities for fetching remote resources.
3
+ * Used by both image-utils and audio-utils to avoid duplication.
4
+ */
5
+ import dns from 'node:dns/promises';
6
+ export function readEnvInt(name, fallback, min = 1) {
7
+ const raw = process.env[name];
8
+ if (raw === undefined || raw === '')
9
+ return fallback;
10
+ const n = parseInt(raw, 10);
11
+ return Number.isFinite(n) && n >= min ? n : fallback;
12
+ }
13
+ function ipv4ToUint(ip) {
14
+ const parts = ip.split('.').map((p) => parseInt(p, 10));
15
+ if (parts.length !== 4 || parts.some((p) => !Number.isInteger(p) || p < 0 || p > 255)) {
16
+ throw new Error('Invalid IPv4');
17
+ }
18
+ return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
19
+ }
20
+ /** Blocks RFC1918, loopback, link-local, CGNAT, metadata. */
21
+ export function isBlockedIPv4(ip) {
22
+ const n = ipv4ToUint(ip);
23
+ if (n >>> 24 === 127)
24
+ return true;
25
+ if (n >>> 24 === 10)
26
+ return true;
27
+ if (n >>> 20 === 0xac1)
28
+ return true;
29
+ if (n >>> 16 === 0xc0a8)
30
+ return true;
31
+ if (n >>> 16 === 0xa9fe)
32
+ return true;
33
+ if (n >>> 24 === 0)
34
+ return true;
35
+ if (n >= 0x64400000 && n <= 0x647fffff)
36
+ return true;
37
+ return false;
38
+ }
39
+ function isBlockedIPv6(ip) {
40
+ const raw = ip.includes('%') ? ip.split('%')[0] : ip;
41
+ const x = raw.toLowerCase();
42
+ if (x === '::1')
43
+ return true;
44
+ if (x.startsWith('fe80:') || x.startsWith('fec0:'))
45
+ return true;
46
+ const first = x.split(':').find((p) => p.length > 0);
47
+ if (first) {
48
+ const v = parseInt(first, 16);
49
+ if (!Number.isNaN(v) && v >= 0xfc00 && v <= 0xfdff)
50
+ return true;
51
+ }
52
+ return false;
53
+ }
54
+ function isIPv4Literal(host) {
55
+ return /^\d{1,3}(\.\d{1,3}){3}$/.test(host);
56
+ }
57
+ /** Resolve hostname and ensure the resolved address is not private/link-local. */
58
+ export async function assertUrlSafeForFetch(urlString) {
59
+ let url;
60
+ try {
61
+ url = new URL(urlString);
62
+ }
63
+ catch {
64
+ throw new Error('Invalid URL');
65
+ }
66
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
67
+ throw new Error('Only HTTP(S) URLs are allowed');
68
+ }
69
+ if (url.username || url.password) {
70
+ throw new Error('URL with credentials is not allowed');
71
+ }
72
+ const host = url.hostname.toLowerCase();
73
+ if (host === 'localhost' || host.endsWith('.localhost')) {
74
+ throw new Error('Blocked host');
75
+ }
76
+ if (isIPv4Literal(host)) {
77
+ if (isBlockedIPv4(host))
78
+ throw new Error('Blocked host');
79
+ return url;
80
+ }
81
+ if (host.includes(':') && !host.startsWith('[')) {
82
+ if (isBlockedIPv6(host))
83
+ throw new Error('Blocked host');
84
+ return url;
85
+ }
86
+ let lookupHost = host;
87
+ if (host.startsWith('[') && host.endsWith(']')) {
88
+ lookupHost = host.slice(1, -1);
89
+ if (isBlockedIPv6(lookupHost))
90
+ throw new Error('Blocked host');
91
+ return url;
92
+ }
93
+ const records = await dns.lookup(lookupHost, { all: true, verbatim: true });
94
+ if (!records.length)
95
+ throw new Error('Could not resolve host');
96
+ for (const r of records) {
97
+ const { address, family } = r;
98
+ if (family === 4) {
99
+ if (isBlockedIPv4(address))
100
+ throw new Error('Blocked host');
101
+ }
102
+ else if (family === 6) {
103
+ if (isBlockedIPv6(address))
104
+ throw new Error('Blocked host');
105
+ }
106
+ }
107
+ return url;
108
+ }
109
+ async function readResponseBodyWithLimit(res, maxBytes) {
110
+ const reader = res.body?.getReader();
111
+ if (!reader) {
112
+ const buf = Buffer.from(await res.arrayBuffer());
113
+ if (buf.length > maxBytes)
114
+ throw new Error('Response too large');
115
+ return buf;
116
+ }
117
+ const chunks = [];
118
+ let total = 0;
119
+ for (;;) {
120
+ const { done, value } = await reader.read();
121
+ if (done)
122
+ break;
123
+ total += value.byteLength;
124
+ if (total > maxBytes)
125
+ throw new Error('Response too large');
126
+ chunks.push(Buffer.from(value));
127
+ }
128
+ return Buffer.concat(chunks);
129
+ }
130
+ /**
131
+ * Fetch a remote HTTP(S) resource with SSRF protection, size limits,
132
+ * redirect cap, and timeout. Returns body Buffer + Content-Type header.
133
+ */
134
+ export async function fetchHttpResource(urlString, opts) {
135
+ let current = urlString;
136
+ for (let hop = 0; hop <= opts.maxRedirects; hop++) {
137
+ const validated = await assertUrlSafeForFetch(current);
138
+ const target = validated.href;
139
+ const controller = new AbortController();
140
+ const t = setTimeout(() => controller.abort(), opts.timeoutMs);
141
+ let res;
142
+ try {
143
+ res = await fetch(target, { redirect: 'manual', signal: controller.signal });
144
+ }
145
+ finally {
146
+ clearTimeout(t);
147
+ }
148
+ if (res.status >= 300 && res.status < 400) {
149
+ const loc = res.headers.get('location');
150
+ if (!loc)
151
+ throw new Error('Redirect without Location header');
152
+ current = new URL(loc, target).href;
153
+ continue;
154
+ }
155
+ if (!res.ok)
156
+ throw new Error(`HTTP ${res.status}`);
157
+ const buffer = await readResponseBodyWithLimit(res, opts.maxBytes);
158
+ return { buffer, contentType: res.headers.get('content-type') };
159
+ }
160
+ throw new Error('Too many redirects');
161
+ }
@@ -0,0 +1,45 @@
1
+ import OpenAI from 'openai';
2
+ export interface GenerateAudioToolRequest {
3
+ prompt: string;
4
+ model?: string;
5
+ voice?: string;
6
+ format?: string;
7
+ save_path?: string;
8
+ }
9
+ /** Create a 44-byte WAV header for raw PCM16 data. */
10
+ export declare function createWavHeader(dataLength: number, sampleRate?: number): Buffer;
11
+ /**
12
+ * Detect audio container format from magic bytes.
13
+ * Uses subarray (not deprecated slice). Tighter MP3 frame sync validation.
14
+ */
15
+ export declare function detectAudioFormat(data: Buffer): {
16
+ ext: string;
17
+ mimeType: string;
18
+ };
19
+ export declare function wrapPcmInWav(pcmData: Buffer): Buffer;
20
+ /** Strip existing extension (if any) and append a new one. */
21
+ export declare function replaceExtension(filePath: string, newExt: string): string;
22
+ export declare function handleGenerateAudio(request: {
23
+ params: {
24
+ arguments: GenerateAudioToolRequest;
25
+ };
26
+ }, openai: OpenAI): Promise<{
27
+ content: {
28
+ type: string;
29
+ text: string;
30
+ }[];
31
+ isError: boolean;
32
+ } | {
33
+ content: ({
34
+ type: string;
35
+ text: string;
36
+ mimeType?: undefined;
37
+ data?: undefined;
38
+ } | {
39
+ type: string;
40
+ mimeType: string;
41
+ data: string;
42
+ text?: undefined;
43
+ })[];
44
+ isError?: undefined;
45
+ }>;
@@ -0,0 +1,154 @@
1
+ import { promises as fs } from 'fs';
2
+ import { dirname, extname } from 'path';
3
+ const DEFAULT_MODEL = 'openai/gpt-audio';
4
+ const DEFAULT_VOICE = 'alloy';
5
+ const DEFAULT_FORMAT = 'pcm16';
6
+ const VALID_FORMATS = ['wav', 'mp3', 'flac', 'opus', 'pcm16'];
7
+ const PCM_SAMPLE_RATE = 24000;
8
+ const PCM_BITS_PER_SAMPLE = 16;
9
+ const PCM_NUM_CHANNELS = 1;
10
+ /** Create a 44-byte WAV header for raw PCM16 data. */
11
+ export function createWavHeader(dataLength, sampleRate = PCM_SAMPLE_RATE) {
12
+ const header = Buffer.alloc(44);
13
+ const byteRate = sampleRate * PCM_NUM_CHANNELS * (PCM_BITS_PER_SAMPLE / 8);
14
+ const blockAlign = PCM_NUM_CHANNELS * (PCM_BITS_PER_SAMPLE / 8);
15
+ header.write('RIFF', 0);
16
+ header.writeUInt32LE(36 + dataLength, 4);
17
+ header.write('WAVE', 8);
18
+ header.write('fmt ', 12);
19
+ header.writeUInt32LE(16, 16);
20
+ header.writeUInt16LE(1, 20);
21
+ header.writeUInt16LE(PCM_NUM_CHANNELS, 22);
22
+ header.writeUInt32LE(sampleRate, 24);
23
+ header.writeUInt32LE(byteRate, 28);
24
+ header.writeUInt16LE(blockAlign, 32);
25
+ header.writeUInt16LE(PCM_BITS_PER_SAMPLE, 34);
26
+ header.write('data', 36);
27
+ header.writeUInt32LE(dataLength, 40);
28
+ return header;
29
+ }
30
+ /**
31
+ * Detect audio container format from magic bytes.
32
+ * Uses subarray (not deprecated slice). Tighter MP3 frame sync validation.
33
+ */
34
+ export function detectAudioFormat(data) {
35
+ if (data.length >= 3) {
36
+ if (data[0] === 0x49 && data[1] === 0x44 && data[2] === 0x33) {
37
+ return { ext: 'mp3', mimeType: 'audio/mpeg' };
38
+ }
39
+ if (data[0] === 0xff && (data[1] & 0xe0) === 0xe0) {
40
+ const versionBits = (data[1] >> 3) & 0x03;
41
+ if (versionBits !== 0x01) {
42
+ return { ext: 'mp3', mimeType: 'audio/mpeg' };
43
+ }
44
+ }
45
+ }
46
+ if (data.length >= 12) {
47
+ const riff = data.subarray(0, 4).toString('ascii');
48
+ const wave = data.subarray(8, 12).toString('ascii');
49
+ if (riff === 'RIFF' && wave === 'WAVE') {
50
+ return { ext: 'wav', mimeType: 'audio/wav' };
51
+ }
52
+ }
53
+ if (data.length >= 4) {
54
+ const magic = data.subarray(0, 4).toString('ascii');
55
+ if (magic === 'fLaC')
56
+ return { ext: 'flac', mimeType: 'audio/flac' };
57
+ if (magic === 'OggS')
58
+ return { ext: 'ogg', mimeType: 'audio/ogg' };
59
+ }
60
+ return { ext: 'pcm', mimeType: 'audio/pcm' };
61
+ }
62
+ export function wrapPcmInWav(pcmData) {
63
+ return Buffer.concat([createWavHeader(pcmData.length), pcmData]);
64
+ }
65
+ /** Strip existing extension (if any) and append a new one. */
66
+ export function replaceExtension(filePath, newExt) {
67
+ const current = extname(filePath);
68
+ const base = current ? filePath.slice(0, -current.length) : filePath;
69
+ return `${base}.${newExt}`;
70
+ }
71
+ export async function handleGenerateAudio(request, openai) {
72
+ const { prompt, model, voice, format, save_path } = request.params.arguments;
73
+ if (!prompt?.trim()) {
74
+ return { content: [{ type: 'text', text: 'Prompt is required.' }], isError: true };
75
+ }
76
+ const selectedFormat = VALID_FORMATS.includes(format ?? '')
77
+ ? format
78
+ : DEFAULT_FORMAT;
79
+ const selectedVoice = voice?.trim() || DEFAULT_VOICE;
80
+ try {
81
+ const stream = await openai.chat.completions.create({
82
+ model: model || DEFAULT_MODEL,
83
+ messages: [{ role: 'user', content: prompt }],
84
+ modalities: ['text', 'audio'],
85
+ audio: { voice: selectedVoice, format: selectedFormat },
86
+ stream: true,
87
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
88
+ });
89
+ const audioChunks = [];
90
+ const transcriptChunks = [];
91
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
92
+ for await (const chunk of stream) {
93
+ const delta = chunk.choices?.[0]?.delta;
94
+ if (delta?.audio) {
95
+ if (delta.audio.data)
96
+ audioChunks.push(delta.audio.data);
97
+ if (delta.audio.transcript)
98
+ transcriptChunks.push(delta.audio.transcript);
99
+ }
100
+ }
101
+ const fullAudioBase64 = audioChunks.join('');
102
+ const transcript = transcriptChunks.join('');
103
+ if (!fullAudioBase64) {
104
+ return { content: [{ type: 'text', text: transcript || 'No audio generated.' }] };
105
+ }
106
+ let audioBuffer = Buffer.from(fullAudioBase64, 'base64');
107
+ const detected = detectAudioFormat(audioBuffer);
108
+ // Always wrap raw PCM in WAV so it's playable
109
+ if (detected.ext === 'pcm') {
110
+ audioBuffer = wrapPcmInWav(audioBuffer);
111
+ detected.ext = 'wav';
112
+ detected.mimeType = 'audio/wav';
113
+ }
114
+ const returnBase64 = audioBuffer.toString('base64');
115
+ if (save_path) {
116
+ const dir = dirname(save_path);
117
+ await fs.mkdir(dir, { recursive: true });
118
+ const fileExt = extname(save_path).toLowerCase().slice(1);
119
+ const actualSavePath = fileExt === detected.ext ? save_path : replaceExtension(save_path, detected.ext);
120
+ await fs.writeFile(actualSavePath, audioBuffer);
121
+ const formatNote = actualSavePath !== save_path
122
+ ? ` (detected ${detected.ext.toUpperCase()}, saved as ${actualSavePath})`
123
+ : '';
124
+ const result = transcript
125
+ ? `Audio saved to: ${actualSavePath}${formatNote}\nTranscript: ${transcript}`
126
+ : `Audio saved to: ${actualSavePath}${formatNote}`;
127
+ return {
128
+ content: [
129
+ { type: 'text', text: result },
130
+ { type: 'audio', mimeType: detected.mimeType, data: returnBase64 },
131
+ ],
132
+ };
133
+ }
134
+ return {
135
+ content: [
136
+ { type: 'text', text: transcript || 'Audio generated successfully.' },
137
+ { type: 'audio', mimeType: detected.mimeType, data: returnBase64 },
138
+ ],
139
+ };
140
+ }
141
+ catch (error) {
142
+ let msg;
143
+ if (error instanceof Error) {
144
+ msg = error.message;
145
+ const oaiErr = error;
146
+ if (oaiErr.error?.message)
147
+ msg = `${msg} - ${oaiErr.error.message}`;
148
+ }
149
+ else {
150
+ msg = String(error);
151
+ }
152
+ return { content: [{ type: 'text', text: `Error: ${msg}` }], isError: true };
153
+ }
154
+ }
@@ -1,10 +1,9 @@
1
+ import { isBlockedIPv4 as _isBlockedIPv4, assertUrlSafeForFetch as _assertUrlSafeForFetch } from './fetch-utils.js';
2
+ export declare const isBlockedIPv4: typeof _isBlockedIPv4;
3
+ export declare const assertUrlSafeForFetch: typeof _assertUrlSafeForFetch;
1
4
  export declare function getMaxImageDimension(): number;
2
5
  export declare function getImageJpegQuality(): number;
3
6
  export declare function getMimeType(filePath: string): string;
4
- /** Blocks RFC1918, loopback, link-local, CGNAT, metadata (e.g. 169.254.169.254). */
5
- export declare function isBlockedIPv4(ip: string): boolean;
6
- /** Resolve hostname and ensure the resolved address is not private/link-local. */
7
- export declare function assertUrlSafeForFetch(urlString: string): Promise<URL>;
8
7
  export declare function fetchHttpImage(urlString: string): Promise<Buffer>;
9
8
  export declare function fetchImage(source: string): Promise<Buffer>;
10
9
  export declare function optimizeImage(buffer: Buffer): Promise<string>;