@stabgan/openrouter-mcp-multimodal 2.0.0 → 3.1.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/README.md +198 -145
- package/dist/errors.d.ts +42 -0
- package/dist/errors.js +46 -0
- package/dist/index.js +1 -1
- package/dist/logger.d.ts +22 -0
- package/dist/logger.js +47 -0
- package/dist/model-cache.d.ts +10 -0
- package/dist/model-cache.js +31 -1
- package/dist/openrouter-api.d.ts +54 -0
- package/dist/openrouter-api.js +136 -12
- package/dist/tool-handlers/analyze-audio.d.ts +5 -9
- package/dist/tool-handlers/analyze-audio.js +41 -8
- package/dist/tool-handlers/analyze-image.d.ts +5 -9
- package/dist/tool-handlers/analyze-image.js +38 -8
- package/dist/tool-handlers/analyze-video.d.ts +19 -0
- package/dist/tool-handlers/analyze-video.js +93 -0
- package/dist/tool-handlers/audio-utils.js +7 -9
- package/dist/tool-handlers/chat-completion.d.ts +6 -10
- package/dist/tool-handlers/chat-completion.js +27 -7
- package/dist/tool-handlers/completion-utils.d.ts +27 -0
- package/dist/tool-handlers/completion-utils.js +69 -0
- package/dist/tool-handlers/fetch-utils.d.ts +32 -0
- package/dist/tool-handlers/fetch-utils.js +216 -12
- package/dist/tool-handlers/generate-audio.d.ts +32 -12
- package/dist/tool-handlers/generate-audio.js +77 -46
- package/dist/tool-handlers/generate-image.d.ts +48 -10
- package/dist/tool-handlers/generate-image.js +148 -33
- package/dist/tool-handlers/generate-video.d.ts +78 -0
- package/dist/tool-handlers/generate-video.js +353 -0
- package/dist/tool-handlers/get-model-info.d.ts +2 -9
- package/dist/tool-handlers/get-model-info.js +15 -5
- package/dist/tool-handlers/image-utils.d.ts +17 -1
- package/dist/tool-handlers/image-utils.js +66 -13
- package/dist/tool-handlers/openrouter-errors.d.ts +18 -0
- package/dist/tool-handlers/openrouter-errors.js +99 -0
- package/dist/tool-handlers/path-safety.d.ts +11 -0
- package/dist/tool-handlers/path-safety.js +88 -0
- package/dist/tool-handlers/search-models.d.ts +2 -9
- package/dist/tool-handlers/search-models.js +10 -6
- package/dist/tool-handlers/validate-model.d.ts +2 -9
- package/dist/tool-handlers/validate-model.js +15 -4
- package/dist/tool-handlers/video-utils.d.ts +29 -0
- package/dist/tool-handlers/video-utils.js +174 -0
- package/dist/tool-handlers.js +229 -21
- package/package.json +3 -3
- package/dist/__tests__/audio-utils.test.d.ts +0 -1
- package/dist/__tests__/audio-utils.test.js +0 -120
- package/dist/__tests__/fetch-utils.test.d.ts +0 -1
- package/dist/__tests__/fetch-utils.test.js +0 -76
- package/dist/__tests__/generate-audio.test.d.ts +0 -1
- package/dist/__tests__/generate-audio.test.js +0 -90
- package/dist/__tests__/image-utils.test.d.ts +0 -1
- package/dist/__tests__/image-utils.test.js +0 -75
- package/dist/__tests__/integration.test.d.ts +0 -1
- package/dist/__tests__/integration.test.js +0 -219
- package/dist/__tests__/model-cache.test.d.ts +0 -1
- package/dist/__tests__/model-cache.test.js +0 -96
package/dist/tool-handlers.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js';
|
|
1
|
+
import { CallToolRequestSchema, ErrorCode as McpErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js';
|
|
2
2
|
import OpenAI from 'openai';
|
|
3
3
|
import { ModelCache } from './model-cache.js';
|
|
4
4
|
import { OpenRouterAPIClient } from './openrouter-api.js';
|
|
@@ -10,6 +10,8 @@ import { handleValidateModel } from './tool-handlers/validate-model.js';
|
|
|
10
10
|
import { handleGenerateImage } from './tool-handlers/generate-image.js';
|
|
11
11
|
import { handleAnalyzeAudio } from './tool-handlers/analyze-audio.js';
|
|
12
12
|
import { handleGenerateAudio } from './tool-handlers/generate-audio.js';
|
|
13
|
+
import { handleAnalyzeVideo } from './tool-handlers/analyze-video.js';
|
|
14
|
+
import { handleGenerateVideo, handleGetVideoStatus, } from './tool-handlers/generate-video.js';
|
|
13
15
|
function wrapToolArgs(a) {
|
|
14
16
|
return { params: { arguments: a ?? {} } };
|
|
15
17
|
}
|
|
@@ -33,6 +35,11 @@ export class ToolHandlers {
|
|
|
33
35
|
{
|
|
34
36
|
name: 'chat_completion',
|
|
35
37
|
description: 'Send messages to an OpenRouter model and get a response',
|
|
38
|
+
annotations: {
|
|
39
|
+
readOnlyHint: false,
|
|
40
|
+
destructiveHint: false,
|
|
41
|
+
idempotentHint: false,
|
|
42
|
+
},
|
|
36
43
|
inputSchema: {
|
|
37
44
|
type: 'object',
|
|
38
45
|
properties: {
|
|
@@ -60,6 +67,11 @@ export class ToolHandlers {
|
|
|
60
67
|
{
|
|
61
68
|
name: 'analyze_image',
|
|
62
69
|
description: 'Analyze an image using a vision model',
|
|
70
|
+
annotations: {
|
|
71
|
+
readOnlyHint: true,
|
|
72
|
+
destructiveHint: false,
|
|
73
|
+
idempotentHint: false,
|
|
74
|
+
},
|
|
63
75
|
inputSchema: {
|
|
64
76
|
type: 'object',
|
|
65
77
|
properties: {
|
|
@@ -70,15 +82,75 @@ export class ToolHandlers {
|
|
|
70
82
|
required: ['image_path'],
|
|
71
83
|
},
|
|
72
84
|
},
|
|
85
|
+
{
|
|
86
|
+
name: 'analyze_audio',
|
|
87
|
+
description: 'Analyze or transcribe an audio file using a multimodal model',
|
|
88
|
+
annotations: {
|
|
89
|
+
readOnlyHint: true,
|
|
90
|
+
destructiveHint: false,
|
|
91
|
+
idempotentHint: false,
|
|
92
|
+
},
|
|
93
|
+
inputSchema: {
|
|
94
|
+
type: 'object',
|
|
95
|
+
properties: {
|
|
96
|
+
audio_path: {
|
|
97
|
+
type: 'string',
|
|
98
|
+
description: 'File path, URL, or data URL (base64-encoded audio)',
|
|
99
|
+
},
|
|
100
|
+
question: {
|
|
101
|
+
type: 'string',
|
|
102
|
+
description: 'Question or instruction about the audio (default: transcribe)',
|
|
103
|
+
},
|
|
104
|
+
model: { type: 'string' },
|
|
105
|
+
},
|
|
106
|
+
required: ['audio_path'],
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: 'analyze_video',
|
|
111
|
+
description: 'Analyze or transcribe a video file using a multimodal model. Accepts mp4, mpeg, mov, or webm from a local file path, HTTP(S) URL, or base64 data URL. Default model: google/gemini-2.5-flash.',
|
|
112
|
+
annotations: {
|
|
113
|
+
readOnlyHint: true,
|
|
114
|
+
destructiveHint: false,
|
|
115
|
+
idempotentHint: false,
|
|
116
|
+
},
|
|
117
|
+
inputSchema: {
|
|
118
|
+
type: 'object',
|
|
119
|
+
properties: {
|
|
120
|
+
video_path: {
|
|
121
|
+
type: 'string',
|
|
122
|
+
description: 'File path, HTTP(S) URL, or base64 data URL. Supported formats: mp4, mpeg, mov, webm.',
|
|
123
|
+
},
|
|
124
|
+
question: {
|
|
125
|
+
type: 'string',
|
|
126
|
+
description: 'Question or instruction about the video (default: describe).',
|
|
127
|
+
},
|
|
128
|
+
model: { type: 'string', description: 'Override the model ID.' },
|
|
129
|
+
},
|
|
130
|
+
required: ['video_path'],
|
|
131
|
+
},
|
|
132
|
+
},
|
|
73
133
|
{
|
|
74
134
|
name: 'search_models',
|
|
75
135
|
description: 'Search available OpenRouter models',
|
|
136
|
+
annotations: {
|
|
137
|
+
readOnlyHint: true,
|
|
138
|
+
destructiveHint: false,
|
|
139
|
+
idempotentHint: true,
|
|
140
|
+
},
|
|
76
141
|
inputSchema: {
|
|
77
142
|
type: 'object',
|
|
78
143
|
properties: {
|
|
79
144
|
query: { type: 'string' },
|
|
80
145
|
provider: { type: 'string' },
|
|
81
|
-
capabilities: {
|
|
146
|
+
capabilities: {
|
|
147
|
+
type: 'object',
|
|
148
|
+
properties: {
|
|
149
|
+
vision: { type: 'boolean' },
|
|
150
|
+
audio: { type: 'boolean' },
|
|
151
|
+
video: { type: 'boolean' },
|
|
152
|
+
},
|
|
153
|
+
},
|
|
82
154
|
limit: { type: 'number', minimum: 1, maximum: 50 },
|
|
83
155
|
},
|
|
84
156
|
},
|
|
@@ -86,6 +158,11 @@ export class ToolHandlers {
|
|
|
86
158
|
{
|
|
87
159
|
name: 'get_model_info',
|
|
88
160
|
description: 'Get details about a specific model',
|
|
161
|
+
annotations: {
|
|
162
|
+
readOnlyHint: true,
|
|
163
|
+
destructiveHint: false,
|
|
164
|
+
idempotentHint: true,
|
|
165
|
+
},
|
|
89
166
|
inputSchema: {
|
|
90
167
|
type: 'object',
|
|
91
168
|
properties: { model: { type: 'string' } },
|
|
@@ -95,6 +172,11 @@ export class ToolHandlers {
|
|
|
95
172
|
{
|
|
96
173
|
name: 'validate_model',
|
|
97
174
|
description: 'Check if a model ID exists',
|
|
175
|
+
annotations: {
|
|
176
|
+
readOnlyHint: true,
|
|
177
|
+
destructiveHint: false,
|
|
178
|
+
idempotentHint: true,
|
|
179
|
+
},
|
|
98
180
|
inputSchema: {
|
|
99
181
|
type: 'object',
|
|
100
182
|
properties: { model: { type: 'string' } },
|
|
@@ -104,46 +186,166 @@ export class ToolHandlers {
|
|
|
104
186
|
{
|
|
105
187
|
name: 'generate_image',
|
|
106
188
|
description: 'Generate an image from a text prompt',
|
|
189
|
+
annotations: {
|
|
190
|
+
readOnlyHint: false,
|
|
191
|
+
destructiveHint: false,
|
|
192
|
+
idempotentHint: false,
|
|
193
|
+
},
|
|
107
194
|
inputSchema: {
|
|
108
195
|
type: 'object',
|
|
109
196
|
properties: {
|
|
110
197
|
prompt: { type: 'string' },
|
|
111
198
|
model: { type: 'string' },
|
|
112
|
-
|
|
199
|
+
aspect_ratio: {
|
|
200
|
+
type: 'string',
|
|
201
|
+
description: 'Output aspect ratio (e.g. 1:1, 16:9, 9:16, 4:3, 3:4, 21:9). Model-dependent.',
|
|
202
|
+
enum: [
|
|
203
|
+
'1:1',
|
|
204
|
+
'2:3',
|
|
205
|
+
'3:2',
|
|
206
|
+
'3:4',
|
|
207
|
+
'4:3',
|
|
208
|
+
'4:5',
|
|
209
|
+
'5:4',
|
|
210
|
+
'9:16',
|
|
211
|
+
'16:9',
|
|
212
|
+
'21:9',
|
|
213
|
+
'1:4',
|
|
214
|
+
'4:1',
|
|
215
|
+
'1:8',
|
|
216
|
+
'8:1',
|
|
217
|
+
],
|
|
218
|
+
},
|
|
219
|
+
image_size: {
|
|
220
|
+
type: 'string',
|
|
221
|
+
description: 'Output resolution bucket. 1K is the default; 0.5K / 2K / 4K are model-dependent.',
|
|
222
|
+
enum: ['0.5K', '1K', '2K', '4K'],
|
|
223
|
+
},
|
|
224
|
+
max_tokens: {
|
|
225
|
+
type: 'number',
|
|
226
|
+
minimum: 1,
|
|
227
|
+
description: 'Cap on completion tokens. Defaults to the model context window, which can trip free-tier quotas; set e.g. 4096 on low-credit accounts.',
|
|
228
|
+
},
|
|
229
|
+
save_path: {
|
|
230
|
+
type: 'string',
|
|
231
|
+
description: 'Optional path to save the image. Routed through the OPENROUTER_OUTPUT_DIR sandbox.',
|
|
232
|
+
},
|
|
113
233
|
},
|
|
114
234
|
required: ['prompt'],
|
|
115
235
|
},
|
|
116
236
|
},
|
|
117
237
|
{
|
|
118
|
-
name: '
|
|
119
|
-
description: '
|
|
238
|
+
name: 'generate_audio',
|
|
239
|
+
description: 'Generate audio from a text prompt. Conversational models (e.g. openai/gpt-audio) respond in spoken audio. Music models (e.g. google/lyria-3-clip-preview) need a structured prompt. Output format is auto-detected and file extension is corrected automatically.',
|
|
240
|
+
annotations: {
|
|
241
|
+
readOnlyHint: false,
|
|
242
|
+
destructiveHint: false,
|
|
243
|
+
idempotentHint: false,
|
|
244
|
+
},
|
|
120
245
|
inputSchema: {
|
|
121
246
|
type: 'object',
|
|
122
247
|
properties: {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
248
|
+
prompt: { type: 'string', description: 'Text input' },
|
|
249
|
+
model: { type: 'string', description: 'Model ID (default: openai/gpt-audio)' },
|
|
250
|
+
voice: { type: 'string', description: 'Voice name (default: alloy)' },
|
|
251
|
+
format: {
|
|
252
|
+
type: 'string',
|
|
253
|
+
description: 'Requested format: pcm16 (default), mp3, flac, opus',
|
|
254
|
+
},
|
|
255
|
+
save_path: {
|
|
256
|
+
type: 'string',
|
|
257
|
+
description: 'Optional path to save the audio. Extension auto-corrected and routed through OPENROUTER_OUTPUT_DIR sandbox.',
|
|
258
|
+
},
|
|
126
259
|
},
|
|
127
|
-
required: ['
|
|
260
|
+
required: ['prompt'],
|
|
128
261
|
},
|
|
129
262
|
},
|
|
130
263
|
{
|
|
131
|
-
name: '
|
|
132
|
-
description: 'Generate
|
|
133
|
-
'
|
|
134
|
-
'
|
|
264
|
+
name: 'generate_video',
|
|
265
|
+
description: 'Generate a video from a text prompt using an OpenRouter video-generation model (default: google/veo-3.1). ' +
|
|
266
|
+
'Submits an async job, polls until completion or max_wait_ms, then downloads the result. ' +
|
|
267
|
+
'Optionally conditioned on first/last-frame images or reference images. ' +
|
|
268
|
+
'Large outputs are auto-saved when save_path is provided and path-sandboxed.',
|
|
269
|
+
annotations: {
|
|
270
|
+
readOnlyHint: false,
|
|
271
|
+
destructiveHint: false,
|
|
272
|
+
idempotentHint: false,
|
|
273
|
+
},
|
|
135
274
|
inputSchema: {
|
|
136
275
|
type: 'object',
|
|
137
276
|
properties: {
|
|
138
|
-
prompt: { type: 'string', description: 'Text
|
|
139
|
-
model: { type: 'string', description: '
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
277
|
+
prompt: { type: 'string', description: 'Text description of the desired video.' },
|
|
278
|
+
model: { type: 'string', description: 'Override the video model ID.' },
|
|
279
|
+
resolution: {
|
|
280
|
+
type: 'string',
|
|
281
|
+
description: '480p / 720p / 1080p / 1K / 2K / 4K (model-dependent).',
|
|
282
|
+
},
|
|
283
|
+
aspect_ratio: {
|
|
284
|
+
type: 'string',
|
|
285
|
+
description: '16:9 / 9:16 / 1:1 / 4:3 / 3:4 / 21:9 / 9:21 (model-dependent).',
|
|
286
|
+
},
|
|
287
|
+
duration: {
|
|
288
|
+
type: 'number',
|
|
289
|
+
minimum: 1,
|
|
290
|
+
description: 'Duration in seconds (model-dependent).',
|
|
291
|
+
},
|
|
292
|
+
seed: { type: 'number', description: 'Deterministic seed when supported.' },
|
|
293
|
+
first_frame_image: {
|
|
294
|
+
type: 'string',
|
|
295
|
+
description: 'Optional image (path, URL, or data URL) used as the first frame for image-to-video.',
|
|
296
|
+
},
|
|
297
|
+
last_frame_image: {
|
|
298
|
+
type: 'string',
|
|
299
|
+
description: 'Optional image used as the last frame for frame transitions.',
|
|
300
|
+
},
|
|
301
|
+
reference_images: {
|
|
302
|
+
type: 'array',
|
|
303
|
+
items: { type: 'string' },
|
|
304
|
+
description: 'Optional style/content reference images.',
|
|
305
|
+
},
|
|
306
|
+
provider: {
|
|
307
|
+
type: 'object',
|
|
308
|
+
description: 'Provider-specific passthrough options keyed by provider slug.',
|
|
309
|
+
},
|
|
310
|
+
save_path: {
|
|
311
|
+
type: 'string',
|
|
312
|
+
description: 'Where to save the video. Routed through the OPENROUTER_OUTPUT_DIR sandbox; extension auto-corrected.',
|
|
313
|
+
},
|
|
314
|
+
max_wait_ms: {
|
|
315
|
+
type: 'number',
|
|
316
|
+
minimum: 10000,
|
|
317
|
+
description: 'Total time to wait for the async job before returning a resumable handle (default 600000 ms).',
|
|
318
|
+
},
|
|
319
|
+
poll_interval_ms: {
|
|
320
|
+
type: 'number',
|
|
321
|
+
minimum: 2000,
|
|
322
|
+
description: 'Polling cadence (default 15000 ms).',
|
|
323
|
+
},
|
|
143
324
|
},
|
|
144
325
|
required: ['prompt'],
|
|
145
326
|
},
|
|
146
327
|
},
|
|
328
|
+
{
|
|
329
|
+
name: 'get_video_status',
|
|
330
|
+
description: 'Resume a previously submitted video generation job by id. Returns the latest status; if completed, ' +
|
|
331
|
+
'downloads the video (and saves it when save_path is provided).',
|
|
332
|
+
annotations: {
|
|
333
|
+
readOnlyHint: true,
|
|
334
|
+
destructiveHint: false,
|
|
335
|
+
idempotentHint: true,
|
|
336
|
+
},
|
|
337
|
+
inputSchema: {
|
|
338
|
+
type: 'object',
|
|
339
|
+
properties: {
|
|
340
|
+
video_id: { type: 'string', description: 'Job id from a previous generate_video call.' },
|
|
341
|
+
save_path: {
|
|
342
|
+
type: 'string',
|
|
343
|
+
description: 'Optional save path (applies when the job is already completed).',
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
required: ['video_id'],
|
|
347
|
+
},
|
|
348
|
+
},
|
|
147
349
|
],
|
|
148
350
|
}));
|
|
149
351
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
@@ -153,6 +355,10 @@ export class ToolHandlers {
|
|
|
153
355
|
return handleChatCompletion(wrapToolArgs(args), this.openai, this.defaultModel);
|
|
154
356
|
case 'analyze_image':
|
|
155
357
|
return handleAnalyzeImage(wrapToolArgs(args), this.openai, this.defaultModel);
|
|
358
|
+
case 'analyze_audio':
|
|
359
|
+
return handleAnalyzeAudio(wrapToolArgs(args), this.openai, this.defaultModel);
|
|
360
|
+
case 'analyze_video':
|
|
361
|
+
return handleAnalyzeVideo(wrapToolArgs(args), this.openai, this.defaultModel);
|
|
156
362
|
case 'search_models':
|
|
157
363
|
return handleSearchModels(wrapToolArgs(args), this.apiClient, this.modelCache);
|
|
158
364
|
case 'get_model_info':
|
|
@@ -161,12 +367,14 @@ export class ToolHandlers {
|
|
|
161
367
|
return handleValidateModel(wrapToolArgs(args), this.modelCache, this.apiClient);
|
|
162
368
|
case 'generate_image':
|
|
163
369
|
return handleGenerateImage(wrapToolArgs(args), this.openai);
|
|
164
|
-
case 'analyze_audio':
|
|
165
|
-
return handleAnalyzeAudio(wrapToolArgs(args), this.openai, this.defaultModel);
|
|
166
370
|
case 'generate_audio':
|
|
167
371
|
return handleGenerateAudio(wrapToolArgs(args), this.openai);
|
|
372
|
+
case 'generate_video':
|
|
373
|
+
return handleGenerateVideo(wrapToolArgs(args), this.apiClient);
|
|
374
|
+
case 'get_video_status':
|
|
375
|
+
return handleGetVideoStatus(wrapToolArgs(args), this.apiClient);
|
|
168
376
|
default:
|
|
169
|
-
throw new McpError(
|
|
377
|
+
throw new McpError(McpErrorCode.MethodNotFound, `Unknown tool: ${name}`);
|
|
170
378
|
}
|
|
171
379
|
});
|
|
172
380
|
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stabgan/openrouter-mcp-multimodal",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"mcpName": "io.github.stabgan/openrouter-multimodal",
|
|
5
|
-
"description": "MCP server for OpenRouter with text chat, image analysis
|
|
5
|
+
"description": "MCP server for OpenRouter with text chat, image analysis + generation, audio analysis + generation, video analysis, and video generation (Veo 3.1 / Sora 2 Pro / Seedance / Wan)",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "dist/index.js",
|
|
8
8
|
"bin": {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
],
|
|
15
15
|
"scripts": {
|
|
16
16
|
"build": "tsc && shx chmod +x dist/*.js",
|
|
17
|
-
"
|
|
17
|
+
"prepublishOnly": "npm run build",
|
|
18
18
|
"start": "node dist/index.js",
|
|
19
19
|
"lint": "eslint src",
|
|
20
20
|
"format": "prettier --write \"src/**/*.ts\" *.json \"*.md\"",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { getAudioFormat, getAudioMimeType, prepareAudioData, isBlockedIPv4, assertUrlSafeForFetch, SUPPORTED_AUDIO_FORMATS, } from '../tool-handlers/audio-utils.js';
|
|
3
|
-
import path from 'path';
|
|
4
|
-
import { writeFileSync, unlinkSync } from 'fs';
|
|
5
|
-
import { tmpdir } from 'os';
|
|
6
|
-
describe('getAudioFormat', () => {
|
|
7
|
-
it('returns correct format for supported extensions', () => {
|
|
8
|
-
expect(getAudioFormat('audio.wav')).toBe('wav');
|
|
9
|
-
expect(getAudioFormat('audio.mp3')).toBe('mp3');
|
|
10
|
-
expect(getAudioFormat('audio.flac')).toBe('flac');
|
|
11
|
-
expect(getAudioFormat('audio.ogg')).toBe('ogg');
|
|
12
|
-
expect(getAudioFormat('audio.aac')).toBe('aac');
|
|
13
|
-
expect(getAudioFormat('audio.m4a')).toBe('m4a');
|
|
14
|
-
expect(getAudioFormat('audio.aiff')).toBe('aiff');
|
|
15
|
-
});
|
|
16
|
-
it('returns undefined for unsupported extensions', () => {
|
|
17
|
-
expect(getAudioFormat('audio.xyz')).toBeUndefined();
|
|
18
|
-
expect(getAudioFormat('audio.mid')).toBeUndefined();
|
|
19
|
-
expect(getAudioFormat('noext')).toBeUndefined();
|
|
20
|
-
});
|
|
21
|
-
it('returns undefined for API-only formats (pcm16/pcm24 are not file extensions)', () => {
|
|
22
|
-
expect(getAudioFormat('audio.pcm16')).toBeUndefined();
|
|
23
|
-
expect(getAudioFormat('audio.pcm24')).toBeUndefined();
|
|
24
|
-
});
|
|
25
|
-
it('handles uppercase extensions', () => {
|
|
26
|
-
expect(getAudioFormat('audio.WAV')).toBe('wav');
|
|
27
|
-
expect(getAudioFormat('audio.MP3')).toBe('mp3');
|
|
28
|
-
expect(getAudioFormat('audio.FLAC')).toBe('flac');
|
|
29
|
-
});
|
|
30
|
-
});
|
|
31
|
-
describe('getAudioMimeType', () => {
|
|
32
|
-
it('returns correct MIME types', () => {
|
|
33
|
-
expect(getAudioMimeType('wav')).toBe('audio/wav');
|
|
34
|
-
expect(getAudioMimeType('mp3')).toBe('audio/mpeg');
|
|
35
|
-
expect(getAudioMimeType('flac')).toBe('audio/flac');
|
|
36
|
-
expect(getAudioMimeType('ogg')).toBe('audio/ogg');
|
|
37
|
-
expect(getAudioMimeType('aac')).toBe('audio/aac');
|
|
38
|
-
expect(getAudioMimeType('m4a')).toBe('audio/mp4');
|
|
39
|
-
expect(getAudioMimeType('aiff')).toBe('audio/aiff');
|
|
40
|
-
expect(getAudioMimeType('pcm16')).toBe('audio/pcm');
|
|
41
|
-
expect(getAudioMimeType('pcm24')).toBe('audio/pcm');
|
|
42
|
-
});
|
|
43
|
-
});
|
|
44
|
-
describe('SUPPORTED_AUDIO_FORMATS', () => {
|
|
45
|
-
it('includes file formats and API formats', () => {
|
|
46
|
-
expect(SUPPORTED_AUDIO_FORMATS).toContain('wav');
|
|
47
|
-
expect(SUPPORTED_AUDIO_FORMATS).toContain('mp3');
|
|
48
|
-
expect(SUPPORTED_AUDIO_FORMATS).toContain('flac');
|
|
49
|
-
expect(SUPPORTED_AUDIO_FORMATS).toContain('ogg');
|
|
50
|
-
expect(SUPPORTED_AUDIO_FORMATS).toContain('aac');
|
|
51
|
-
expect(SUPPORTED_AUDIO_FORMATS).toContain('m4a');
|
|
52
|
-
expect(SUPPORTED_AUDIO_FORMATS).toContain('pcm16');
|
|
53
|
-
expect(SUPPORTED_AUDIO_FORMATS).toContain('pcm24');
|
|
54
|
-
});
|
|
55
|
-
});
|
|
56
|
-
describe('prepareAudioData', () => {
|
|
57
|
-
it('decodes base64 data URLs with correct format', async () => {
|
|
58
|
-
const audioData = Buffer.from('fake-audio-data').toString('base64');
|
|
59
|
-
const result = await prepareAudioData(`data:audio/wav;base64,${audioData}`);
|
|
60
|
-
expect(result.data).toBe(audioData);
|
|
61
|
-
expect(result.format).toBe('wav');
|
|
62
|
-
});
|
|
63
|
-
it('maps audio/mpeg MIME to mp3 format', async () => {
|
|
64
|
-
const audioData = Buffer.from('fake-audio-data').toString('base64');
|
|
65
|
-
const result = await prepareAudioData(`data:audio/mpeg;base64,${audioData}`);
|
|
66
|
-
expect(result.data).toBe(audioData);
|
|
67
|
-
expect(result.format).toBe('mp3');
|
|
68
|
-
});
|
|
69
|
-
it('rejects invalid data URLs', async () => {
|
|
70
|
-
await expect(prepareAudioData('data:invalid')).rejects.toThrow('Invalid data URL');
|
|
71
|
-
});
|
|
72
|
-
it('rejects unsupported MIME types', async () => {
|
|
73
|
-
const audioData = Buffer.from('fake').toString('base64');
|
|
74
|
-
await expect(prepareAudioData(`data:audio/xyz;base64,${audioData}`)).rejects.toThrow('Unsupported audio format');
|
|
75
|
-
});
|
|
76
|
-
it('reads local files and returns base64 with format', async () => {
|
|
77
|
-
const tmpFile = path.join(tmpdir(), `test-audio-${Date.now()}.wav`);
|
|
78
|
-
writeFileSync(tmpFile, Buffer.from('fake-audio-content'));
|
|
79
|
-
try {
|
|
80
|
-
const result = await prepareAudioData(tmpFile);
|
|
81
|
-
expect(result.data).toBe(Buffer.from('fake-audio-content').toString('base64'));
|
|
82
|
-
expect(result.format).toBe('wav');
|
|
83
|
-
}
|
|
84
|
-
finally {
|
|
85
|
-
unlinkSync(tmpFile);
|
|
86
|
-
}
|
|
87
|
-
});
|
|
88
|
-
it('throws on missing files', async () => {
|
|
89
|
-
await expect(prepareAudioData('/nonexistent/path/audio.wav')).rejects.toThrow();
|
|
90
|
-
});
|
|
91
|
-
it('throws on unsupported file extensions', async () => {
|
|
92
|
-
const tmpFile = path.join(tmpdir(), `test-audio-${Date.now()}.xyz`);
|
|
93
|
-
writeFileSync(tmpFile, Buffer.from('fake'));
|
|
94
|
-
try {
|
|
95
|
-
await expect(prepareAudioData(tmpFile)).rejects.toThrow('Unsupported audio format');
|
|
96
|
-
}
|
|
97
|
-
finally {
|
|
98
|
-
unlinkSync(tmpFile);
|
|
99
|
-
}
|
|
100
|
-
});
|
|
101
|
-
it('rejects private IPv4 URLs', async () => {
|
|
102
|
-
await expect(prepareAudioData('http://127.0.0.1:8080/audio.wav')).rejects.toThrow();
|
|
103
|
-
await expect(prepareAudioData('http://192.168.1.1/audio.mp3')).rejects.toThrow();
|
|
104
|
-
});
|
|
105
|
-
it('rejects localhost hostnames', async () => {
|
|
106
|
-
await expect(assertUrlSafeForFetch('http://localhost/audio.wav')).rejects.toThrow();
|
|
107
|
-
});
|
|
108
|
-
});
|
|
109
|
-
describe('isBlockedIPv4 (re-exported from fetch-utils)', () => {
|
|
110
|
-
it('identifies loopback and RFC1918', () => {
|
|
111
|
-
expect(isBlockedIPv4('127.0.0.1')).toBe(true);
|
|
112
|
-
expect(isBlockedIPv4('10.0.0.1')).toBe(true);
|
|
113
|
-
expect(isBlockedIPv4('192.168.1.1')).toBe(true);
|
|
114
|
-
expect(isBlockedIPv4('172.16.0.1')).toBe(true);
|
|
115
|
-
expect(isBlockedIPv4('8.8.8.8')).toBe(false);
|
|
116
|
-
});
|
|
117
|
-
it('blocks metadata endpoint IP', () => {
|
|
118
|
-
expect(isBlockedIPv4('169.254.169.254')).toBe(true);
|
|
119
|
-
});
|
|
120
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { readEnvInt, isBlockedIPv4, assertUrlSafeForFetch } from '../tool-handlers/fetch-utils.js';
|
|
3
|
-
describe('readEnvInt', () => {
|
|
4
|
-
it('returns fallback when env var is missing', () => {
|
|
5
|
-
delete process.env['TEST_MISSING_VAR'];
|
|
6
|
-
expect(readEnvInt('TEST_MISSING_VAR', 42)).toBe(42);
|
|
7
|
-
});
|
|
8
|
-
it('returns fallback when env var is empty', () => {
|
|
9
|
-
process.env['TEST_EMPTY_VAR'] = '';
|
|
10
|
-
expect(readEnvInt('TEST_EMPTY_VAR', 42)).toBe(42);
|
|
11
|
-
delete process.env['TEST_EMPTY_VAR'];
|
|
12
|
-
});
|
|
13
|
-
it('parses valid integer', () => {
|
|
14
|
-
process.env['TEST_INT_VAR'] = '100';
|
|
15
|
-
expect(readEnvInt('TEST_INT_VAR', 42)).toBe(100);
|
|
16
|
-
delete process.env['TEST_INT_VAR'];
|
|
17
|
-
});
|
|
18
|
-
it('returns fallback for non-numeric value', () => {
|
|
19
|
-
process.env['TEST_NAN_VAR'] = 'abc';
|
|
20
|
-
expect(readEnvInt('TEST_NAN_VAR', 42)).toBe(42);
|
|
21
|
-
delete process.env['TEST_NAN_VAR'];
|
|
22
|
-
});
|
|
23
|
-
it('returns fallback when value is below min', () => {
|
|
24
|
-
process.env['TEST_LOW_VAR'] = '0';
|
|
25
|
-
expect(readEnvInt('TEST_LOW_VAR', 42, 1)).toBe(42);
|
|
26
|
-
delete process.env['TEST_LOW_VAR'];
|
|
27
|
-
});
|
|
28
|
-
});
|
|
29
|
-
describe('isBlockedIPv4', () => {
|
|
30
|
-
it('blocks loopback', () => {
|
|
31
|
-
expect(isBlockedIPv4('127.0.0.1')).toBe(true);
|
|
32
|
-
expect(isBlockedIPv4('127.255.255.255')).toBe(true);
|
|
33
|
-
});
|
|
34
|
-
it('blocks RFC1918 10.x', () => {
|
|
35
|
-
expect(isBlockedIPv4('10.0.0.1')).toBe(true);
|
|
36
|
-
expect(isBlockedIPv4('10.255.255.255')).toBe(true);
|
|
37
|
-
});
|
|
38
|
-
it('blocks RFC1918 172.16-31.x', () => {
|
|
39
|
-
expect(isBlockedIPv4('172.16.0.1')).toBe(true);
|
|
40
|
-
expect(isBlockedIPv4('172.31.255.255')).toBe(true);
|
|
41
|
-
});
|
|
42
|
-
it('blocks RFC1918 192.168.x', () => {
|
|
43
|
-
expect(isBlockedIPv4('192.168.0.1')).toBe(true);
|
|
44
|
-
expect(isBlockedIPv4('192.168.255.255')).toBe(true);
|
|
45
|
-
});
|
|
46
|
-
it('blocks link-local 169.254.x', () => {
|
|
47
|
-
expect(isBlockedIPv4('169.254.169.254')).toBe(true);
|
|
48
|
-
});
|
|
49
|
-
it('blocks CGNAT 100.64-127.x', () => {
|
|
50
|
-
expect(isBlockedIPv4('100.64.0.1')).toBe(true);
|
|
51
|
-
expect(isBlockedIPv4('100.127.255.255')).toBe(true);
|
|
52
|
-
});
|
|
53
|
-
it('allows public IPs', () => {
|
|
54
|
-
expect(isBlockedIPv4('8.8.8.8')).toBe(false);
|
|
55
|
-
expect(isBlockedIPv4('1.1.1.1')).toBe(false);
|
|
56
|
-
expect(isBlockedIPv4('142.250.80.46')).toBe(false);
|
|
57
|
-
});
|
|
58
|
-
});
|
|
59
|
-
describe('assertUrlSafeForFetch', () => {
|
|
60
|
-
it('rejects localhost', async () => {
|
|
61
|
-
await expect(assertUrlSafeForFetch('http://localhost/foo')).rejects.toThrow('Blocked host');
|
|
62
|
-
});
|
|
63
|
-
it('rejects private IPv4', async () => {
|
|
64
|
-
await expect(assertUrlSafeForFetch('http://127.0.0.1/foo')).rejects.toThrow('Blocked host');
|
|
65
|
-
await expect(assertUrlSafeForFetch('http://192.168.1.1/foo')).rejects.toThrow('Blocked host');
|
|
66
|
-
});
|
|
67
|
-
it('rejects non-HTTP protocols', async () => {
|
|
68
|
-
await expect(assertUrlSafeForFetch('ftp://example.com/foo')).rejects.toThrow('Only HTTP(S)');
|
|
69
|
-
});
|
|
70
|
-
it('rejects URLs with credentials', async () => {
|
|
71
|
-
await expect(assertUrlSafeForFetch('http://user:pass@example.com/foo')).rejects.toThrow('credentials');
|
|
72
|
-
});
|
|
73
|
-
it('rejects invalid URLs', async () => {
|
|
74
|
-
await expect(assertUrlSafeForFetch('not-a-url')).rejects.toThrow('Invalid URL');
|
|
75
|
-
});
|
|
76
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|