@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.
- package/LICENSE +21 -0
- package/README.md +164 -169
- package/dist/__tests__/image-utils.test.d.ts +1 -0
- package/dist/__tests__/image-utils.test.js +75 -0
- package/dist/__tests__/integration.test.d.ts +1 -0
- package/dist/__tests__/integration.test.js +111 -0
- package/dist/__tests__/model-cache.test.d.ts +1 -0
- package/dist/__tests__/model-cache.test.js +96 -0
- package/dist/index.js +47 -85
- package/dist/model-cache.d.ts +18 -46
- package/dist/model-cache.js +28 -125
- package/dist/openrouter-api.d.ts +2 -22
- package/dist/openrouter-api.js +34 -111
- package/dist/tool-handlers/analyze-image.d.ts +2 -17
- package/dist/tool-handlers/analyze-image.js +18 -325
- package/dist/tool-handlers/chat-completion.d.ts +1 -0
- package/dist/tool-handlers/chat-completion.js +10 -143
- package/dist/tool-handlers/generate-image.d.ts +30 -0
- package/dist/tool-handlers/generate-image.js +82 -0
- package/dist/tool-handlers/get-model-info.d.ts +5 -5
- package/dist/tool-handlers/get-model-info.js +11 -41
- package/dist/tool-handlers/image-utils.d.ts +11 -0
- package/dist/tool-handlers/image-utils.js +253 -0
- package/dist/tool-handlers/search-models.d.ts +3 -10
- package/dist/tool-handlers/search-models.js +6 -37
- package/dist/tool-handlers/validate-model.d.ts +5 -5
- package/dist/tool-handlers/validate-model.js +13 -37
- package/dist/tool-handlers.d.ts +1 -6
- package/dist/tool-handlers.js +55 -240
- package/package.json +20 -21
- package/dist/index.js.map +0 -1
- package/dist/model-cache.js.map +0 -1
- package/dist/openrouter-api.js.map +0 -1
- package/dist/tool-handlers/analyze-image.js.map +0 -1
- package/dist/tool-handlers/chat-completion.js.map +0 -1
- package/dist/tool-handlers/get-model-info.js.map +0 -1
- package/dist/tool-handlers/multi-image-analysis.d.ts +0 -46
- package/dist/tool-handlers/multi-image-analysis.js +0 -429
- package/dist/tool-handlers/multi-image-analysis.js.map +0 -1
- package/dist/tool-handlers/search-models.js.map +0 -1
- package/dist/tool-handlers/validate-model.js.map +0 -1
- 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
|
+
[](https://mseep.ai/app/stabgan-openrouter-mcp-multimodal)
|
|
2
|
+
|
|
1
3
|
# OpenRouter MCP Multimodal Server
|
|
2
4
|
|
|
3
|
-
[](https://github.com/stabgan/openrouter-mcp-multimodal/actions/workflows/publish.yml)
|
|
4
5
|
[](https://www.npmjs.com/package/@stabgan/openrouter-mcp-multimodal)
|
|
5
6
|
[](https://hub.docker.com/r/stabgandocker/openrouter-mcp-multimodal)
|
|
7
|
+
[](https://github.com/stabgan/openrouter-mcp-multimodal/actions/workflows/publish.yml)
|
|
8
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
9
|
|
|
7
|
-
An MCP
|
|
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
|
-
|
|
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
|
-
|
|
14
|
+
## Why This One?
|
|
58
15
|
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
27
|
+
## Tools
|
|
64
28
|
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
38
|
+
## Quick Start
|
|
70
39
|
|
|
71
40
|
### Prerequisites
|
|
72
41
|
|
|
73
|
-
|
|
74
|
-
2. Choose a default model (optional)
|
|
42
|
+
Get a free API key from [openrouter.ai/keys](https://openrouter.ai/keys).
|
|
75
43
|
|
|
76
|
-
###
|
|
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": "
|
|
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
|
-
|
|
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",
|
|
133
|
-
"
|
|
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
|
-
|
|
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": "
|
|
148
|
-
"args": [
|
|
149
|
-
"run",
|
|
150
|
-
"stabgan/openrouter-mcp-multimodal"
|
|
151
|
-
],
|
|
92
|
+
"command": "openrouter-multimodal",
|
|
152
93
|
"env": {
|
|
153
|
-
"OPENROUTER_API_KEY": "
|
|
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
|
-
|
|
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
|
-
|
|
122
|
+
### Security notes
|
|
164
123
|
|
|
165
|
-
-
|
|
166
|
-
-
|
|
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
|
-
|
|
127
|
+
## Usage Examples
|
|
170
128
|
|
|
171
|
-
|
|
129
|
+
### Chat
|
|
172
130
|
|
|
173
|
-
|
|
131
|
+
```
|
|
132
|
+
Use chat_completion to explain quantum computing in simple terms.
|
|
133
|
+
```
|
|
174
134
|
|
|
175
|
-
|
|
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
|
-
|
|
137
|
+
```
|
|
138
|
+
Use analyze_image on /path/to/photo.jpg and tell me what you see.
|
|
139
|
+
```
|
|
182
140
|
|
|
183
|
-
|
|
141
|
+
### Find Vision Models
|
|
184
142
|
|
|
185
|
-
|
|
143
|
+
```
|
|
144
|
+
Use search_models with capabilities.vision = true to find models that can see images.
|
|
145
|
+
```
|
|
186
146
|
|
|
187
|
-
|
|
147
|
+
### Generate an Image
|
|
188
148
|
|
|
189
|
-
```
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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 {};
|