@stabgan/openrouter-mcp-multimodal 1.5.0 → 1.8.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 (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +164 -169
  3. package/dist/__tests__/image-utils.test.d.ts +1 -0
  4. package/dist/__tests__/image-utils.test.js +75 -0
  5. package/dist/__tests__/integration.test.d.ts +1 -0
  6. package/dist/__tests__/integration.test.js +111 -0
  7. package/dist/__tests__/model-cache.test.d.ts +1 -0
  8. package/dist/__tests__/model-cache.test.js +96 -0
  9. package/dist/index.js +47 -85
  10. package/dist/model-cache.d.ts +18 -46
  11. package/dist/model-cache.js +28 -125
  12. package/dist/openrouter-api.d.ts +2 -22
  13. package/dist/openrouter-api.js +34 -111
  14. package/dist/tool-handlers/analyze-image.d.ts +2 -17
  15. package/dist/tool-handlers/analyze-image.js +18 -325
  16. package/dist/tool-handlers/chat-completion.d.ts +1 -0
  17. package/dist/tool-handlers/chat-completion.js +10 -143
  18. package/dist/tool-handlers/generate-image.d.ts +30 -0
  19. package/dist/tool-handlers/generate-image.js +82 -0
  20. package/dist/tool-handlers/get-model-info.d.ts +5 -5
  21. package/dist/tool-handlers/get-model-info.js +11 -41
  22. package/dist/tool-handlers/image-utils.d.ts +11 -0
  23. package/dist/tool-handlers/image-utils.js +253 -0
  24. package/dist/tool-handlers/search-models.d.ts +3 -10
  25. package/dist/tool-handlers/search-models.js +6 -37
  26. package/dist/tool-handlers/validate-model.d.ts +5 -5
  27. package/dist/tool-handlers/validate-model.js +13 -37
  28. package/dist/tool-handlers.d.ts +1 -6
  29. package/dist/tool-handlers.js +55 -240
  30. package/package.json +20 -21
  31. package/dist/index.js.map +0 -1
  32. package/dist/model-cache.js.map +0 -1
  33. package/dist/openrouter-api.js.map +0 -1
  34. package/dist/tool-handlers/analyze-image.js.map +0 -1
  35. package/dist/tool-handlers/chat-completion.js.map +0 -1
  36. package/dist/tool-handlers/get-model-info.js.map +0 -1
  37. package/dist/tool-handlers/multi-image-analysis.d.ts +0 -46
  38. package/dist/tool-handlers/multi-image-analysis.js +0 -429
  39. package/dist/tool-handlers/multi-image-analysis.js.map +0 -1
  40. package/dist/tool-handlers/search-models.js.map +0 -1
  41. package/dist/tool-handlers/validate-model.js.map +0 -1
  42. package/dist/tool-handlers.js.map +0 -1
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 stabgan
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.
package/README.md CHANGED
@@ -1,124 +1,63 @@
1
+ [![MseeP.ai Security Assessment Badge](https://mseep.net/pr/stabgan-openrouter-mcp-multimodal-badge.png)](https://mseep.ai/app/stabgan-openrouter-mcp-multimodal)
2
+
1
3
  # OpenRouter MCP Multimodal Server
2
4
 
3
- [![Build Status](https://github.com/stabgan/openrouter-mcp-multimodal/actions/workflows/publish.yml/badge.svg)](https://github.com/stabgan/openrouter-mcp-multimodal/actions/workflows/publish.yml)
4
5
  [![npm version](https://img.shields.io/npm/v/@stabgan/openrouter-mcp-multimodal.svg)](https://www.npmjs.com/package/@stabgan/openrouter-mcp-multimodal)
5
6
  [![Docker Pulls](https://img.shields.io/docker/pulls/stabgandocker/openrouter-mcp-multimodal.svg)](https://hub.docker.com/r/stabgandocker/openrouter-mcp-multimodal)
7
+ [![Build Status](https://github.com/stabgan/openrouter-mcp-multimodal/actions/workflows/publish.yml/badge.svg)](https://github.com/stabgan/openrouter-mcp-multimodal/actions/workflows/publish.yml)
8
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
9
 
7
- An MCP (Model Context Protocol) server that provides chat and image analysis capabilities through OpenRouter.ai's diverse model ecosystem. This server combines text chat functionality with powerful image analysis capabilities.
8
-
9
- ## Features
10
-
11
- - **Text Chat:**
12
- - Direct access to all OpenRouter.ai chat models
13
- - Support for simple text and multimodal conversations
14
- - Configurable temperature and other parameters
15
-
16
- - **Image Analysis:**
17
- - Analyze single images with custom questions
18
- - Process multiple images simultaneously
19
- - Automatic image resizing and optimization
20
- - Support for various image sources (local files, URLs, data URLs)
21
-
22
- - **Model Selection:**
23
- - Search and filter available models
24
- - Validate model IDs
25
- - Get detailed model information
26
- - Support for default model configuration
27
-
28
- - **Performance Optimization:**
29
- - Smart model information caching
30
- - Exponential backoff for retries
31
- - Automatic rate limit handling
32
-
33
- ## What's New in 1.5.0
34
-
35
- - **Improved OS Compatibility:**
36
- - Enhanced path handling for Windows, macOS, and Linux
37
- - Better support for Windows-style paths with drive letters
38
- - Normalized path processing for consistent behavior across platforms
39
-
40
- - **MCP Configuration Support:**
41
- - Cursor MCP integration without requiring environment variables
42
- - Direct configuration via MCP parameters
43
- - Flexible API key and model specification options
44
-
45
- - **Robust Error Handling:**
46
- - Improved fallback mechanisms for image processing
47
- - Better error reporting with specific diagnostics
48
- - Multiple backup strategies for file reading
49
-
50
- - **Image Processing Enhancements:**
51
- - More reliable base64 encoding for all image types
52
- - Fallback options when Sharp module is unavailable
53
- - Better handling of large images with automatic optimization
10
+ An OpenRouter MCP server with native vision, image generation, and smart image optimization in one package.
54
11
 
55
- ## Installation
12
+ Access 300+ LLMs through [OpenRouter](https://openrouter.ai) via the [Model Context Protocol](https://modelcontextprotocol.io), with first-class support for multimodal workflows: analyze images, generate images, and chat — using free or paid models.
56
13
 
57
- ### Option 1: Install via npm
14
+ ## Why This One?
58
15
 
59
- ```bash
60
- npm install -g @stabgan/openrouter-mcp-multimodal
61
- ```
16
+ | Feature | This Server |
17
+ | ---------------------------- | ----------------------------------------------------------------- |
18
+ | Text chat with 300+ models | ✅ |
19
+ | Image analysis (vision) | ✅ Native with sharp optimization |
20
+ | Image generation | ✅ |
21
+ | Auto image resize & compress | ✅ (configurable; defaults 800px max, JPEG 80%) |
22
+ | Model search & validation | ✅ |
23
+ | Free model support | ✅ (default: free Nemotron VL) |
24
+ | Docker support | ✅ (~345MB Alpine image) |
25
+ | HTTP client | ✅ Node.js native `fetch` (no axios / node-fetch in this package) |
62
26
 
63
- ### Option 2: Run via Docker
27
+ ## Tools
64
28
 
65
- ```bash
66
- docker run -i -e OPENROUTER_API_KEY=your-api-key-here stabgandocker/openrouter-mcp-multimodal:latest
67
- ```
29
+ | Tool | Description |
30
+ | ----------------- | ------------------------------------------------------------------------------- |
31
+ | `chat_completion` | Send messages to any OpenRouter model. Supports text and multimodal content. |
32
+ | `analyze_image` | Analyze images from local files, URLs, or data URIs. Auto-optimized with sharp. |
33
+ | `generate_image` | Generate images from text prompts. Optionally save to disk. |
34
+ | `search_models` | Search/filter models by name, provider, or capabilities (e.g. vision-only). |
35
+ | `get_model_info` | Get pricing, context length, and capabilities for any model. |
36
+ | `validate_model` | Check if a model ID exists on OpenRouter. |
68
37
 
69
- ## Quick Start Configuration
38
+ ## Quick Start
70
39
 
71
40
  ### Prerequisites
72
41
 
73
- 1. Get your OpenRouter API key from [OpenRouter Keys](https://openrouter.ai/keys)
74
- 2. Choose a default model (optional)
42
+ Get a free API key from [openrouter.ai/keys](https://openrouter.ai/keys).
75
43
 
76
- ### MCP Configuration Options
77
-
78
- Add one of the following configurations to your MCP settings file (e.g., `cline_mcp_settings.json` or `claude_desktop_config.json`):
79
-
80
- #### Option 1: Using npx (Node.js)
44
+ ### Option 1: npx (no install)
81
45
 
82
46
  ```json
83
47
  {
84
48
  "mcpServers": {
85
49
  "openrouter": {
86
50
  "command": "npx",
87
- "args": [
88
- "-y",
89
- "@stabgan/openrouter-mcp-multimodal"
90
- ],
51
+ "args": ["-y", "@stabgan/openrouter-mcp-multimodal"],
91
52
  "env": {
92
- "OPENROUTER_API_KEY": "your-api-key-here",
93
- "DEFAULT_MODEL": "qwen/qwen2.5-vl-32b-instruct:free"
53
+ "OPENROUTER_API_KEY": "sk-or-v1-..."
94
54
  }
95
55
  }
96
56
  }
97
57
  }
98
58
  ```
99
59
 
100
- #### Option 2: Using uv (Python Package Manager)
101
-
102
- ```json
103
- {
104
- "mcpServers": {
105
- "openrouter": {
106
- "command": "uv",
107
- "args": [
108
- "run",
109
- "-m",
110
- "openrouter_mcp_multimodal"
111
- ],
112
- "env": {
113
- "OPENROUTER_API_KEY": "your-api-key-here",
114
- "DEFAULT_MODEL": "qwen/qwen2.5-vl-32b-instruct:free"
115
- }
116
- }
117
- }
118
- }
119
- ```
120
-
121
- #### Option 3: Using Docker
60
+ ### Option 2: Docker
122
61
 
123
62
  ```json
124
63
  {
@@ -129,8 +68,8 @@ Add one of the following configurations to your MCP settings file (e.g., `cline_
129
68
  "run",
130
69
  "--rm",
131
70
  "-i",
132
- "-e", "OPENROUTER_API_KEY=your-api-key-here",
133
- "-e", "DEFAULT_MODEL=qwen/qwen2.5-vl-32b-instruct:free",
71
+ "-e",
72
+ "OPENROUTER_API_KEY=sk-or-v1-...",
134
73
  "stabgandocker/openrouter-mcp-multimodal:latest"
135
74
  ]
136
75
  }
@@ -138,100 +77,156 @@ Add one of the following configurations to your MCP settings file (e.g., `cline_
138
77
  }
139
78
  ```
140
79
 
141
- #### Option 4: Using Smithery (recommended)
80
+ ### Option 3: Global install
81
+
82
+ ```bash
83
+ npm install -g @stabgan/openrouter-mcp-multimodal
84
+ ```
85
+
86
+ Then add to your MCP config:
142
87
 
143
88
  ```json
144
89
  {
145
90
  "mcpServers": {
146
91
  "openrouter": {
147
- "command": "smithery",
148
- "args": [
149
- "run",
150
- "stabgan/openrouter-mcp-multimodal"
151
- ],
92
+ "command": "openrouter-multimodal",
152
93
  "env": {
153
- "OPENROUTER_API_KEY": "your-api-key-here",
154
- "DEFAULT_MODEL": "qwen/qwen2.5-vl-32b-instruct:free"
94
+ "OPENROUTER_API_KEY": "sk-or-v1-..."
155
95
  }
156
96
  }
157
97
  }
158
98
  }
159
99
  ```
160
100
 
161
- ## Examples
101
+ ### Option 4: Smithery
102
+
103
+ ```bash
104
+ npx -y @smithery/cli install @stabgan/openrouter-mcp-multimodal --client claude
105
+ ```
106
+
107
+ ## Configuration
108
+
109
+ | Environment Variable | Required | Default | Description |
110
+ | ------------------------------------- | -------- | ------------------------------------- | ----------------------------------------------------- |
111
+ | `OPENROUTER_API_KEY` | Yes | — | Your OpenRouter API key |
112
+ | `OPENROUTER_DEFAULT_MODEL` | No | `nvidia/nemotron-nano-12b-v2-vl:free` | Default model for chat, analyze, and similar tools |
113
+ | `DEFAULT_MODEL` | No | — | Alias for `OPENROUTER_DEFAULT_MODEL` |
114
+ | `OPENROUTER_MODEL_CACHE_TTL_MS` | No | `3600000` | How long cached `/models` data is valid (ms) |
115
+ | `OPENROUTER_IMAGE_MAX_DIMENSION` | No | `800` | Longest edge for resize before vision requests (px) |
116
+ | `OPENROUTER_IMAGE_JPEG_QUALITY` | No | `80` | JPEG quality after optimization (1–100) |
117
+ | `OPENROUTER_IMAGE_FETCH_TIMEOUT_MS` | No | `30000` | Per-request timeout for image URLs |
118
+ | `OPENROUTER_IMAGE_MAX_DOWNLOAD_BYTES` | No | `26214400` | Max bytes when downloading an image URL (~25 MB) |
119
+ | `OPENROUTER_IMAGE_MAX_REDIRECTS` | No | `8` | Max HTTP redirects when fetching an image URL |
120
+ | `OPENROUTER_IMAGE_MAX_DATA_URL_BYTES` | No | `20971520` | Approx max decoded size for base64 data URLs (~20 MB) |
162
121
 
163
- For comprehensive examples of how to use this MCP server, check out the [examples directory](./examples/). We provide:
122
+ ### Security notes
164
123
 
165
- - JavaScript examples for Node.js applications
166
- - Python examples with interactive chat capabilities
167
- - Code snippets for integrating with various applications
124
+ - **`analyze_image`** can read **local files** the Node process can read and can **fetch HTTP(S) URLs**. URL fetches block private/link-local/reserved IPv4 and IPv6 targets (SSRF mitigation) and cap response size; they are still **server-side** requests—avoid pointing at internal-only hosts you rely on staying private.
125
+ - **`generate_image`** `save_path` writes to disk wherever the process has permission; treat prompts and paths like shell input from the MCP client user.
168
126
 
169
- Each example comes with clear documentation and step-by-step instructions.
127
+ ## Usage Examples
170
128
 
171
- ## Dependencies
129
+ ### Chat
172
130
 
173
- This project uses the following key dependencies:
131
+ ```
132
+ Use chat_completion to explain quantum computing in simple terms.
133
+ ```
174
134
 
175
- - `@modelcontextprotocol/sdk`: ^1.8.0 - Latest MCP SDK for tool implementation
176
- - `openai`: ^4.89.1 - OpenAI-compatible API client for OpenRouter
177
- - `sharp`: ^0.33.5 - Fast image processing library
178
- - `axios`: ^1.8.4 - HTTP client for API requests
179
- - `node-fetch`: ^3.3.2 - Modern fetch implementation
135
+ ### Analyze an Image
180
136
 
181
- Node.js 18 or later is required. All dependencies are regularly updated to ensure compatibility and security.
137
+ ```
138
+ Use analyze_image on /path/to/photo.jpg and tell me what you see.
139
+ ```
182
140
 
183
- ## Available Tools
141
+ ### Find Vision Models
184
142
 
185
- ### mcp_openrouter_chat_completion
143
+ ```
144
+ Use search_models with capabilities.vision = true to find models that can see images.
145
+ ```
186
146
 
187
- Send text or multimodal messages to OpenRouter models:
147
+ ### Generate an Image
188
148
 
189
- ```javascript
190
- use_mcp_tool({
191
- server_name: "openrouter",
192
- tool_name: "mcp_openrouter_chat_completion",
193
- arguments: {
194
- model: "google/gemini-2.5-pro-exp-03-25:free", // Optional if default is set
195
- messages: [
196
- {
197
- role: "system",
198
- content: "You are a helpful assistant."
199
- },
200
- {
201
- role: "user",
202
- content: "What is the capital of France?"
203
- }
204
- ],
205
- temperature: 0.7 // Optional, defaults to 1.0
206
- }
207
- });
208
- ```
209
-
210
- For multimodal messages with images:
211
-
212
- ```javascript
213
- use_mcp_tool({
214
- server_name: "openrouter",
215
- tool_name: "mcp_openrouter_chat_completion",
216
- arguments: {
217
- model: "anthropic/claude-3.5-sonnet",
218
- messages: [
219
- {
220
- role: "user",
221
- content: [
222
- {
223
- type: "text",
224
- text: "What's in this image?"
225
- },
226
- {
227
- type: "image_url",
228
- image_url: {
229
- url: "https://example.com/image.jpg"
230
- }
231
- }
232
- ]
233
- }
234
- ]
235
- }
236
- });
237
- ```
149
+ ```
150
+ Use generate_image with prompt "a cat astronaut on mars, digital art" and save to ./cat.png
151
+ ```
152
+
153
+ ## Architecture
154
+
155
+ ```
156
+ src/
157
+ ├── index.ts # Server entry point, env validation, graceful shutdown
158
+ ├── tool-handlers.ts # Tool registration and routing
159
+ ├── model-cache.ts # In-memory model cache (1hr TTL)
160
+ ├── openrouter-api.ts # OpenRouter REST client (native fetch)
161
+ └── tool-handlers/
162
+ ├── chat-completion.ts # Text & multimodal chat
163
+ ├── analyze-image.ts # Vision analysis pipeline
164
+ ├── generate-image.ts # Image generation
165
+ ├── image-utils.ts # Sharp optimization, format detection, fetch
166
+ ├── search-models.ts # Model search with filtering
167
+ ├── get-model-info.ts # Model detail lookup
168
+ └── validate-model.ts # Model existence check
169
+ ```
170
+
171
+ Key design decisions:
172
+
173
+ - **Native `fetch`** for OpenRouter and image URLs (no axios / node-fetch dependency in this package)
174
+ - **Lazy sharp loading** — `sharp` is loaded on first image operation, not at startup
175
+ - **Singleton model cache** — shared across tool handlers with configurable TTL (default 1 hour)
176
+ - **Bounded URL fetches** — timeouts, size limits, redirect cap, and blocked private networks for image URLs
177
+ - **Graceful error handling** — tools return structured errors instead of crashing the server
178
+ - **Process safety** — uncaught exceptions and unhandled rejections exit the process (no zombie servers)
179
+
180
+ ## Development
181
+
182
+ ```bash
183
+ git clone https://github.com/stabgan/openrouter-mcp-multimodal.git
184
+ cd openrouter-mcp-multimodal
185
+ npm install
186
+ cp .env.example .env # Add your API key
187
+ npm run build
188
+ npm start
189
+ ```
190
+
191
+ ### Run Tests
192
+
193
+ ```bash
194
+ npm test
195
+ ```
196
+
197
+ `npm test` runs **unit tests** only (fast, no API key). With `OPENROUTER_API_KEY` in `.env`, run **`npm run test:integration`** for live OpenRouter tests (slower; may time out on congested networks).
198
+
199
+ **npm releases:** This repo’s `publish-npm` job uses [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/) (GitHub Actions OIDC). The package on npmjs.com must list this repository and workflow `publish.yml` under **Settings → Trusted publisher**. No long-lived `NPMJS_TOKEN` is required for publish once that is configured.
200
+
201
+ ```bash
202
+ npm run lint
203
+ npm run format:check
204
+ ```
205
+
206
+ ### Docker Build
207
+
208
+ ```bash
209
+ docker build -t openrouter-mcp .
210
+ docker run -i -e OPENROUTER_API_KEY=sk-or-v1-... openrouter-mcp
211
+ ```
212
+
213
+ Multi-stage build: 345MB final image (Alpine + vips runtime only).
214
+
215
+ ## Compatibility
216
+
217
+ Works with any MCP client:
218
+
219
+ - [Claude Desktop](https://claude.ai/download)
220
+ - [Cursor](https://cursor.sh)
221
+ - [Kiro](https://kiro.dev)
222
+ - [Windsurf](https://codeium.com/windsurf)
223
+ - [Cline](https://github.com/cline/cline)
224
+ - Any MCP-compatible client
225
+
226
+ ## License
227
+
228
+ MIT
229
+
230
+ ## Contributing
231
+
232
+ Issues and PRs welcome. Please open an issue first for major changes.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,75 @@
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
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,111 @@
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 { OpenRouterAPIClient } from '../openrouter-api.js';
10
+ import { ModelCache } from '../model-cache.js';
11
+ import path from 'path';
12
+ config(); // Load .env
13
+ const API_KEY = process.env.OPENROUTER_API_KEY;
14
+ const DEFAULT_MODEL = 'nvidia/nemotron-nano-12b-v2-vl:free';
15
+ // Skip all integration tests if no API key
16
+ const describeIf = API_KEY ? describe : describe.skip;
17
+ describeIf('Integration: chat_completion', () => {
18
+ let openai;
19
+ beforeAll(() => {
20
+ openai = new OpenAI({ apiKey: API_KEY, baseURL: 'https://openrouter.ai/api/v1' });
21
+ });
22
+ it('should complete a simple text chat', async () => {
23
+ const result = await handleChatCompletion({
24
+ params: {
25
+ arguments: { messages: [{ role: 'user', content: 'Say "hello" and nothing else.' }] },
26
+ },
27
+ }, openai, DEFAULT_MODEL);
28
+ expect(result.isError).toBeFalsy();
29
+ expect(result.content[0].text.toLowerCase()).toContain('hello');
30
+ });
31
+ it('should return error for empty messages', async () => {
32
+ const result = await handleChatCompletion({ params: { arguments: { messages: [] } } }, openai, DEFAULT_MODEL);
33
+ expect(result.isError).toBe(true);
34
+ });
35
+ });
36
+ describeIf('Integration: analyze_image', () => {
37
+ let openai;
38
+ beforeAll(() => {
39
+ openai = new OpenAI({ apiKey: API_KEY, baseURL: 'https://openrouter.ai/api/v1' });
40
+ });
41
+ it('should analyze the test image from file path', async () => {
42
+ const testImg = path.resolve('test.png');
43
+ const result = await handleAnalyzeImage({ params: { arguments: { image_path: testImg, question: 'Describe this image briefly.' } } }, openai, DEFAULT_MODEL);
44
+ expect(result.isError).toBeFalsy();
45
+ expect(result.content[0].text.length).toBeGreaterThan(10);
46
+ });
47
+ it('should analyze an image from URL', async () => {
48
+ const result = await handleAnalyzeImage({
49
+ params: {
50
+ arguments: {
51
+ image_path: 'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png',
52
+ question: 'What do you see?',
53
+ },
54
+ },
55
+ }, openai, DEFAULT_MODEL);
56
+ expect(result.isError).toBeFalsy();
57
+ expect(result.content[0].text.length).toBeGreaterThan(10);
58
+ });
59
+ it('should return error for missing image_path', async () => {
60
+ const result = await handleAnalyzeImage({ params: { arguments: { image_path: '' } } }, openai, DEFAULT_MODEL);
61
+ expect(result.isError).toBe(true);
62
+ });
63
+ });
64
+ describeIf('Integration: search_models', () => {
65
+ let apiClient;
66
+ let cache;
67
+ beforeAll(() => {
68
+ apiClient = new OpenRouterAPIClient(API_KEY);
69
+ cache = ModelCache.getInstance();
70
+ });
71
+ it('should fetch and search models', async () => {
72
+ const result = await handleSearchModels({ params: { arguments: { query: 'free', limit: 5 } } }, apiClient, cache);
73
+ expect(result.isError).toBeFalsy();
74
+ const models = JSON.parse(result.content[0].text);
75
+ expect(models.length).toBeGreaterThan(0);
76
+ expect(models.length).toBeLessThanOrEqual(5);
77
+ });
78
+ it('should filter by vision capability', async () => {
79
+ const result = await handleSearchModels({ params: { arguments: { capabilities: { vision: true }, limit: 3 } } }, apiClient, cache);
80
+ const models = JSON.parse(result.content[0].text);
81
+ expect(models.every((m) => m.architecture?.input_modalities?.includes('image'))).toBe(true);
82
+ });
83
+ });
84
+ describeIf('Integration: get_model_info + validate_model', () => {
85
+ let apiClient;
86
+ let cache;
87
+ beforeAll(async () => {
88
+ apiClient = new OpenRouterAPIClient(API_KEY);
89
+ cache = ModelCache.getInstance();
90
+ });
91
+ it('should get info for a known model', async () => {
92
+ const result = await handleGetModelInfo({ params: { arguments: { model: DEFAULT_MODEL } } }, cache, apiClient);
93
+ expect(result.isError).toBeFalsy();
94
+ const info = JSON.parse(result.content[0].text);
95
+ expect(info.id).toBe(DEFAULT_MODEL);
96
+ });
97
+ it('should return error for unknown model', async () => {
98
+ const result = await handleGetModelInfo({ params: { arguments: { model: 'nonexistent/model-xyz' } } }, cache, apiClient);
99
+ expect(result.isError).toBe(true);
100
+ });
101
+ it('should validate a real model', async () => {
102
+ const result = await handleValidateModel({ params: { arguments: { model: DEFAULT_MODEL } } }, cache, apiClient);
103
+ const parsed = JSON.parse(result.content[0].text);
104
+ expect(parsed.valid).toBe(true);
105
+ });
106
+ it('should invalidate a fake model', async () => {
107
+ const result = await handleValidateModel({ params: { arguments: { model: 'fake/model' } } }, cache, apiClient);
108
+ const parsed = JSON.parse(result.content[0].text);
109
+ expect(parsed.valid).toBe(false);
110
+ });
111
+ });
@@ -0,0 +1 @@
1
+ export {};