@stabgan/openrouter-mcp-multimodal 2.0.0 → 3.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.
Files changed (54) hide show
  1. package/README.md +137 -43
  2. package/dist/errors.d.ts +42 -0
  3. package/dist/errors.js +46 -0
  4. package/dist/index.js +1 -1
  5. package/dist/logger.d.ts +22 -0
  6. package/dist/logger.js +47 -0
  7. package/dist/model-cache.d.ts +10 -0
  8. package/dist/model-cache.js +31 -1
  9. package/dist/openrouter-api.d.ts +54 -0
  10. package/dist/openrouter-api.js +128 -12
  11. package/dist/tool-handlers/analyze-audio.d.ts +5 -9
  12. package/dist/tool-handlers/analyze-audio.js +41 -8
  13. package/dist/tool-handlers/analyze-image.d.ts +5 -9
  14. package/dist/tool-handlers/analyze-image.js +38 -8
  15. package/dist/tool-handlers/analyze-video.d.ts +19 -0
  16. package/dist/tool-handlers/analyze-video.js +93 -0
  17. package/dist/tool-handlers/audio-utils.js +7 -9
  18. package/dist/tool-handlers/chat-completion.d.ts +6 -10
  19. package/dist/tool-handlers/chat-completion.js +27 -7
  20. package/dist/tool-handlers/completion-utils.d.ts +27 -0
  21. package/dist/tool-handlers/completion-utils.js +69 -0
  22. package/dist/tool-handlers/fetch-utils.d.ts +21 -0
  23. package/dist/tool-handlers/fetch-utils.js +166 -11
  24. package/dist/tool-handlers/generate-audio.d.ts +32 -12
  25. package/dist/tool-handlers/generate-audio.js +77 -46
  26. package/dist/tool-handlers/generate-image.d.ts +26 -10
  27. package/dist/tool-handlers/generate-image.js +79 -27
  28. package/dist/tool-handlers/generate-video.d.ts +78 -0
  29. package/dist/tool-handlers/generate-video.js +353 -0
  30. package/dist/tool-handlers/get-model-info.js +8 -2
  31. package/dist/tool-handlers/image-utils.d.ts +17 -1
  32. package/dist/tool-handlers/image-utils.js +66 -13
  33. package/dist/tool-handlers/openrouter-errors.d.ts +18 -0
  34. package/dist/tool-handlers/openrouter-errors.js +99 -0
  35. package/dist/tool-handlers/path-safety.d.ts +11 -0
  36. package/dist/tool-handlers/path-safety.js +88 -0
  37. package/dist/tool-handlers/search-models.js +1 -3
  38. package/dist/tool-handlers/validate-model.js +8 -2
  39. package/dist/tool-handlers/video-utils.d.ts +29 -0
  40. package/dist/tool-handlers/video-utils.js +174 -0
  41. package/dist/tool-handlers.js +199 -21
  42. package/package.json +3 -3
  43. package/dist/__tests__/audio-utils.test.d.ts +0 -1
  44. package/dist/__tests__/audio-utils.test.js +0 -120
  45. package/dist/__tests__/fetch-utils.test.d.ts +0 -1
  46. package/dist/__tests__/fetch-utils.test.js +0 -76
  47. package/dist/__tests__/generate-audio.test.d.ts +0 -1
  48. package/dist/__tests__/generate-audio.test.js +0 -90
  49. package/dist/__tests__/image-utils.test.d.ts +0 -1
  50. package/dist/__tests__/image-utils.test.js +0 -75
  51. package/dist/__tests__/integration.test.d.ts +0 -1
  52. package/dist/__tests__/integration.test.js +0 -219
  53. package/dist/__tests__/model-cache.test.d.ts +0 -1
  54. package/dist/__tests__/model-cache.test.js +0 -96
@@ -1,75 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { getMimeType, fetchImage, optimizeImage, prepareImageUrl, isBlockedIPv4, assertUrlSafeForFetch, } from '../tool-handlers/image-utils.js';
3
- import path from 'path';
4
- import { writeFileSync } from 'fs';
5
- import { tmpdir } from 'os';
6
- describe('getMimeType', () => {
7
- it('should return correct MIME types', () => {
8
- expect(getMimeType('photo.png')).toBe('image/png');
9
- expect(getMimeType('photo.jpg')).toBe('image/jpeg');
10
- expect(getMimeType('photo.jpeg')).toBe('image/jpeg');
11
- expect(getMimeType('photo.webp')).toBe('image/webp');
12
- expect(getMimeType('photo.gif')).toBe('image/gif');
13
- expect(getMimeType('photo.bmp')).toBe('image/bmp');
14
- });
15
- it('should default to image/jpeg for unknown extensions', () => {
16
- expect(getMimeType('file.xyz')).toBe('image/jpeg');
17
- expect(getMimeType('noext')).toBe('image/jpeg');
18
- });
19
- });
20
- describe('fetchImage', () => {
21
- it('should decode base64 data URLs', async () => {
22
- const data = Buffer.from('hello').toString('base64');
23
- const buf = await fetchImage(`data:image/png;base64,${data}`);
24
- expect(buf.toString()).toBe('hello');
25
- });
26
- it('should reject invalid data URLs', async () => {
27
- await expect(fetchImage('data:invalid')).rejects.toThrow('Invalid data URL');
28
- });
29
- it('should read local files', async () => {
30
- const tmpFile = path.join(tmpdir(), `test-img-${Date.now()}.txt`);
31
- writeFileSync(tmpFile, 'test-content');
32
- const buf = await fetchImage(tmpFile);
33
- expect(buf.toString()).toBe('test-content');
34
- });
35
- it('should throw on missing files', async () => {
36
- await expect(fetchImage('/nonexistent/path/image.png')).rejects.toThrow();
37
- });
38
- it('should reject private IPv4 URLs', async () => {
39
- await expect(fetchImage('http://127.0.0.1:8080/x')).rejects.toThrow();
40
- await expect(fetchImage('http://192.168.1.1/x')).rejects.toThrow();
41
- });
42
- it('should reject localhost hostnames', async () => {
43
- await expect(assertUrlSafeForFetch('http://localhost/foo')).rejects.toThrow();
44
- });
45
- });
46
- describe('isBlockedIPv4', () => {
47
- it('identifies loopback and RFC1918', () => {
48
- expect(isBlockedIPv4('127.0.0.1')).toBe(true);
49
- expect(isBlockedIPv4('10.0.0.1')).toBe(true);
50
- expect(isBlockedIPv4('8.8.8.8')).toBe(false);
51
- });
52
- });
53
- describe('optimizeImage', () => {
54
- it('should return base64 string for any buffer', async () => {
55
- // Even without sharp, fallback should return base64
56
- const buf = Buffer.from('fake-image-data');
57
- const result = await optimizeImage(buf);
58
- expect(typeof result).toBe('string');
59
- expect(result.length).toBeGreaterThan(0);
60
- });
61
- });
62
- describe('prepareImageUrl', () => {
63
- it('should pass through data URLs unchanged', async () => {
64
- const dataUrl = 'data:image/png;base64,iVBORw0KGgo=';
65
- const result = await prepareImageUrl(dataUrl);
66
- expect(result).toBe(dataUrl);
67
- });
68
- it('should convert local files to data URLs', async () => {
69
- // Create a tiny valid file
70
- const tmpFile = path.join(tmpdir(), `test-prep-${Date.now()}.png`);
71
- writeFileSync(tmpFile, Buffer.from([0x89, 0x50, 0x4e, 0x47])); // PNG magic bytes
72
- const result = await prepareImageUrl(tmpFile);
73
- expect(result).toMatch(/^data:image\/png;base64,/);
74
- });
75
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,219 +0,0 @@
1
- import { describe, it, expect, beforeAll } from 'vitest';
2
- import { config } from 'dotenv';
3
- import OpenAI from 'openai';
4
- import { handleChatCompletion } from '../tool-handlers/chat-completion.js';
5
- import { handleAnalyzeImage } from '../tool-handlers/analyze-image.js';
6
- import { handleSearchModels } from '../tool-handlers/search-models.js';
7
- import { handleGetModelInfo } from '../tool-handlers/get-model-info.js';
8
- import { handleValidateModel } from '../tool-handlers/validate-model.js';
9
- import { handleAnalyzeAudio } from '../tool-handlers/analyze-audio.js';
10
- import { handleGenerateAudio } from '../tool-handlers/generate-audio.js';
11
- import { OpenRouterAPIClient } from '../openrouter-api.js';
12
- import { ModelCache } from '../model-cache.js';
13
- import path from 'path';
14
- import { promises as fsPromises } from 'fs';
15
- config(); // Load .env
16
- const API_KEY = process.env.OPENROUTER_API_KEY;
17
- const DEFAULT_MODEL = 'nvidia/nemotron-nano-12b-v2-vl:free';
18
- // Skip all integration tests if no API key
19
- const describeIf = API_KEY ? describe : describe.skip;
20
- describeIf('Integration: chat_completion', () => {
21
- let openai;
22
- beforeAll(() => {
23
- openai = new OpenAI({ apiKey: API_KEY, baseURL: 'https://openrouter.ai/api/v1' });
24
- });
25
- it('should complete a simple text chat', async () => {
26
- const result = await handleChatCompletion({
27
- params: {
28
- arguments: { messages: [{ role: 'user', content: 'Say "hello" and nothing else.' }] },
29
- },
30
- }, openai, DEFAULT_MODEL);
31
- expect(result.isError).toBeFalsy();
32
- expect(result.content[0].text.toLowerCase()).toContain('hello');
33
- });
34
- it('should return error for empty messages', async () => {
35
- const result = await handleChatCompletion({ params: { arguments: { messages: [] } } }, openai, DEFAULT_MODEL);
36
- expect(result.isError).toBe(true);
37
- });
38
- });
39
- describeIf('Integration: analyze_image', () => {
40
- let openai;
41
- beforeAll(() => {
42
- openai = new OpenAI({ apiKey: API_KEY, baseURL: 'https://openrouter.ai/api/v1' });
43
- });
44
- it('should analyze the test image from file path', async () => {
45
- const testImg = path.resolve('test.png');
46
- const result = await handleAnalyzeImage({ params: { arguments: { image_path: testImg, question: 'Describe this image briefly.' } } }, openai, DEFAULT_MODEL);
47
- expect(result.isError).toBeFalsy();
48
- expect(result.content[0].text.length).toBeGreaterThan(10);
49
- });
50
- it('should analyze an image from URL', async () => {
51
- const result = await handleAnalyzeImage({
52
- params: {
53
- arguments: {
54
- image_path: 'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png',
55
- question: 'What do you see?',
56
- },
57
- },
58
- }, openai, DEFAULT_MODEL);
59
- expect(result.isError).toBeFalsy();
60
- expect(result.content[0].text.length).toBeGreaterThan(10);
61
- });
62
- it('should return error for missing image_path', async () => {
63
- const result = await handleAnalyzeImage({ params: { arguments: { image_path: '' } } }, openai, DEFAULT_MODEL);
64
- expect(result.isError).toBe(true);
65
- });
66
- });
67
- describeIf('Integration: search_models', () => {
68
- let apiClient;
69
- let cache;
70
- beforeAll(() => {
71
- apiClient = new OpenRouterAPIClient(API_KEY);
72
- cache = ModelCache.getInstance();
73
- });
74
- it('should fetch and search models', async () => {
75
- const result = await handleSearchModels({ params: { arguments: { query: 'free', limit: 5 } } }, apiClient, cache);
76
- expect(result.isError).toBeFalsy();
77
- const models = JSON.parse(result.content[0].text);
78
- expect(models.length).toBeGreaterThan(0);
79
- expect(models.length).toBeLessThanOrEqual(5);
80
- });
81
- it('should filter by vision capability', async () => {
82
- const result = await handleSearchModels({ params: { arguments: { capabilities: { vision: true }, limit: 3 } } }, apiClient, cache);
83
- const models = JSON.parse(result.content[0].text);
84
- expect(models.every((m) => m.architecture?.input_modalities?.includes('image'))).toBe(true);
85
- });
86
- });
87
- describeIf('Integration: get_model_info + validate_model', () => {
88
- let apiClient;
89
- let cache;
90
- beforeAll(async () => {
91
- apiClient = new OpenRouterAPIClient(API_KEY);
92
- cache = ModelCache.getInstance();
93
- });
94
- it('should get info for a known model', async () => {
95
- const result = await handleGetModelInfo({ params: { arguments: { model: DEFAULT_MODEL } } }, cache, apiClient);
96
- expect(result.isError).toBeFalsy();
97
- const info = JSON.parse(result.content[0].text);
98
- expect(info.id).toBe(DEFAULT_MODEL);
99
- });
100
- it('should return error for unknown model', async () => {
101
- const result = await handleGetModelInfo({ params: { arguments: { model: 'nonexistent/model-xyz' } } }, cache, apiClient);
102
- expect(result.isError).toBe(true);
103
- });
104
- it('should validate a real model', async () => {
105
- const result = await handleValidateModel({ params: { arguments: { model: DEFAULT_MODEL } } }, cache, apiClient);
106
- const parsed = JSON.parse(result.content[0].text);
107
- expect(parsed.valid).toBe(true);
108
- });
109
- it('should invalidate a fake model', async () => {
110
- const result = await handleValidateModel({ params: { arguments: { model: 'fake/model' } } }, cache, apiClient);
111
- const parsed = JSON.parse(result.content[0].text);
112
- expect(parsed.valid).toBe(false);
113
- });
114
- });
115
- describeIf('Integration: analyze_audio', () => {
116
- let openai;
117
- beforeAll(() => {
118
- openai = new OpenAI({ apiKey: API_KEY, baseURL: 'https://openrouter.ai/api/v1' });
119
- });
120
- it('should analyze audio from a data URL', async () => {
121
- // Create a minimal WAV file (44-byte header + tiny PCM data) as a data URL
122
- const header = Buffer.alloc(44);
123
- header.write('RIFF', 0);
124
- header.writeUInt32LE(36 + 100, 4);
125
- header.write('WAVE', 8);
126
- header.write('fmt ', 12);
127
- header.writeUInt32LE(16, 16);
128
- header.writeUInt16LE(1, 20);
129
- header.writeUInt16LE(1, 22);
130
- header.writeUInt32LE(16000, 24);
131
- header.writeUInt32LE(32000, 28);
132
- header.writeUInt16LE(2, 32);
133
- header.writeUInt16LE(16, 34);
134
- header.write('data', 36);
135
- header.writeUInt32LE(100, 40);
136
- const pcmData = Buffer.alloc(100); // silence
137
- const wavBuffer = Buffer.concat([header, pcmData]);
138
- const b64 = wavBuffer.toString('base64');
139
- const result = await handleAnalyzeAudio({
140
- params: {
141
- arguments: {
142
- audio_path: `data:audio/wav;base64,${b64}`,
143
- question: 'What do you hear?',
144
- model: 'google/gemini-2.5-flash',
145
- },
146
- },
147
- }, openai);
148
- if (result.isError) {
149
- // 402 = insufficient balance — code works, account needs credits
150
- const errText = result.content[0].text;
151
- console.log('analyze_audio error:', errText);
152
- if (errText.includes('402') || errText.includes('balance')) {
153
- // Expected when account has no audio credits — test the code path worked
154
- expect(errText).toContain('402');
155
- return;
156
- }
157
- }
158
- expect(result.isError).toBeFalsy();
159
- expect(result.content[0].text.length).toBeGreaterThan(0);
160
- }, 30000);
161
- it('should return error for missing audio_path', async () => {
162
- const result = await handleAnalyzeAudio({ params: { arguments: { audio_path: '' } } }, openai);
163
- expect(result.isError).toBe(true);
164
- });
165
- });
166
- describeIf('Integration: generate_audio', () => {
167
- let openai;
168
- beforeAll(() => {
169
- openai = new OpenAI({ apiKey: API_KEY, baseURL: 'https://openrouter.ai/api/v1' });
170
- });
171
- it('should generate audio from a text prompt', async () => {
172
- const result = await handleGenerateAudio({
173
- params: {
174
- arguments: {
175
- prompt: 'Say hello world',
176
- model: 'openai/gpt-4o-mini-audio-preview',
177
- voice: 'alloy',
178
- },
179
- },
180
- }, openai);
181
- // Either we get audio back or a graceful error (model availability varies)
182
- expect(result.content.length).toBeGreaterThan(0);
183
- if (!result.isError) {
184
- const audioContent = result.content.find((c) => c.type === 'audio');
185
- if (audioContent) {
186
- expect(audioContent.data.length).toBeGreaterThan(0);
187
- }
188
- }
189
- }, 60000);
190
- it('should save audio to file and auto-correct extension', async () => {
191
- const tmpPath = path.join('/tmp', `test-gen-audio-${Date.now()}.wav`);
192
- const result = await handleGenerateAudio({
193
- params: {
194
- arguments: {
195
- prompt: 'Say the word test',
196
- model: 'openai/gpt-4o-mini-audio-preview',
197
- voice: 'alloy',
198
- save_path: tmpPath,
199
- },
200
- },
201
- }, openai);
202
- if (!result.isError) {
203
- const textContent = result.content.find((c) => c.type === 'text');
204
- expect(textContent.text).toContain('Audio saved to:');
205
- // Clean up - the actual path may have been corrected
206
- const savedPath = textContent.text.match(/Audio saved to: (.+?)(\s|\n|$)/)?.[1];
207
- if (savedPath) {
208
- try {
209
- await fsPromises.unlink(savedPath);
210
- }
211
- catch { /* ignore */ }
212
- }
213
- }
214
- }, 60000);
215
- it('should return error for empty prompt', async () => {
216
- const result = await handleGenerateAudio({ params: { arguments: { prompt: '' } } }, openai);
217
- expect(result.isError).toBe(true);
218
- });
219
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,96 +0,0 @@
1
- import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
2
- import { ModelCache } from '../model-cache.js';
3
- describe('ModelCache', () => {
4
- let cache;
5
- beforeEach(() => {
6
- vi.unstubAllEnvs();
7
- cache = ModelCache.getInstance();
8
- cache.setModels([]);
9
- });
10
- afterEach(() => {
11
- vi.unstubAllEnvs();
12
- });
13
- const sampleModels = [
14
- {
15
- id: 'openai/gpt-4',
16
- name: 'OpenAI: GPT-4',
17
- architecture: { input_modalities: ['text', 'image'] },
18
- context_length: 128000,
19
- },
20
- {
21
- id: 'anthropic/claude-3',
22
- name: 'Anthropic: Claude 3',
23
- architecture: { input_modalities: ['text', 'image'] },
24
- context_length: 200000,
25
- },
26
- {
27
- id: 'meta/llama-3',
28
- name: 'Meta: Llama 3',
29
- architecture: { input_modalities: ['text'] },
30
- context_length: 8192,
31
- },
32
- {
33
- id: 'qwen/qwen-vl:free',
34
- name: 'Qwen: VL (free)',
35
- architecture: { input_modalities: ['text', 'image'] },
36
- context_length: 32000,
37
- },
38
- ];
39
- it('should be a singleton', () => {
40
- const a = ModelCache.getInstance();
41
- const b = ModelCache.getInstance();
42
- expect(a).toBe(b);
43
- });
44
- it('should store and retrieve models', () => {
45
- cache.setModels(sampleModels);
46
- expect(cache.getAll()).toHaveLength(4);
47
- expect(cache.get('openai/gpt-4')).toEqual(sampleModels[0]);
48
- expect(cache.get('nonexistent')).toBeNull();
49
- });
50
- it('should check model existence', () => {
51
- cache.setModels(sampleModels);
52
- expect(cache.has('openai/gpt-4')).toBe(true);
53
- expect(cache.has('nonexistent')).toBe(false);
54
- });
55
- it('should report valid cache after setModels', () => {
56
- expect(cache.isValid()).toBe(false);
57
- cache.setModels(sampleModels);
58
- expect(cache.isValid()).toBe(true);
59
- });
60
- it('should search by query', () => {
61
- cache.setModels(sampleModels);
62
- const results = cache.search({ query: 'gpt' });
63
- expect(results).toHaveLength(1);
64
- expect(results[0].id).toBe('openai/gpt-4');
65
- });
66
- it('should search by provider', () => {
67
- cache.setModels(sampleModels);
68
- const results = cache.search({ provider: 'anthropic' });
69
- expect(results).toHaveLength(1);
70
- expect(results[0].id).toBe('anthropic/claude-3');
71
- });
72
- it('should filter by vision capability', () => {
73
- cache.setModels(sampleModels);
74
- const results = cache.search({ capabilities: { vision: true } });
75
- expect(results).toHaveLength(3);
76
- expect(results.every((m) => m.architecture?.input_modalities?.includes('image'))).toBe(true);
77
- });
78
- it('should respect limit', () => {
79
- cache.setModels(sampleModels);
80
- const results = cache.search({ limit: 2 });
81
- expect(results).toHaveLength(2);
82
- });
83
- it('should combine filters', () => {
84
- cache.setModels(sampleModels);
85
- const results = cache.search({ query: 'free', capabilities: { vision: true } });
86
- expect(results).toHaveLength(1);
87
- expect(results[0].id).toBe('qwen/qwen-vl:free');
88
- });
89
- it('should expire cache after TTL from env', async () => {
90
- vi.stubEnv('OPENROUTER_MODEL_CACHE_TTL_MS', '25');
91
- cache.setModels(sampleModels);
92
- expect(cache.isValid()).toBe(true);
93
- await new Promise((r) => setTimeout(r, 60));
94
- expect(cache.isValid()).toBe(false);
95
- });
96
- });