@stabgan/openrouter-mcp-multimodal 1.4.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 -147
- 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 -46
- 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 -259
- 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 -2
- package/dist/tool-handlers.js +55 -234
- 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 -405
- 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,102 +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
|
|
10
|
+
An OpenRouter MCP server with native vision, image generation, and smart image optimization in one package.
|
|
32
11
|
|
|
33
|
-
|
|
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.
|
|
34
13
|
|
|
35
|
-
|
|
14
|
+
## Why This One?
|
|
36
15
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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) |
|
|
40
26
|
|
|
41
|
-
|
|
27
|
+
## Tools
|
|
42
28
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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. |
|
|
46
37
|
|
|
47
|
-
## Quick Start
|
|
38
|
+
## Quick Start
|
|
48
39
|
|
|
49
40
|
### Prerequisites
|
|
50
41
|
|
|
51
|
-
|
|
52
|
-
2. Choose a default model (optional)
|
|
53
|
-
|
|
54
|
-
### MCP Configuration Options
|
|
42
|
+
Get a free API key from [openrouter.ai/keys](https://openrouter.ai/keys).
|
|
55
43
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
#### Option 1: Using npx (Node.js)
|
|
44
|
+
### Option 1: npx (no install)
|
|
59
45
|
|
|
60
46
|
```json
|
|
61
47
|
{
|
|
62
48
|
"mcpServers": {
|
|
63
49
|
"openrouter": {
|
|
64
50
|
"command": "npx",
|
|
65
|
-
"args": [
|
|
66
|
-
"-y",
|
|
67
|
-
"@stabgan/openrouter-mcp-multimodal"
|
|
68
|
-
],
|
|
51
|
+
"args": ["-y", "@stabgan/openrouter-mcp-multimodal"],
|
|
69
52
|
"env": {
|
|
70
|
-
"OPENROUTER_API_KEY": "
|
|
71
|
-
"DEFAULT_MODEL": "qwen/qwen2.5-vl-32b-instruct:free"
|
|
53
|
+
"OPENROUTER_API_KEY": "sk-or-v1-..."
|
|
72
54
|
}
|
|
73
55
|
}
|
|
74
56
|
}
|
|
75
57
|
}
|
|
76
58
|
```
|
|
77
59
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
```json
|
|
81
|
-
{
|
|
82
|
-
"mcpServers": {
|
|
83
|
-
"openrouter": {
|
|
84
|
-
"command": "uv",
|
|
85
|
-
"args": [
|
|
86
|
-
"run",
|
|
87
|
-
"-m",
|
|
88
|
-
"openrouter_mcp_multimodal"
|
|
89
|
-
],
|
|
90
|
-
"env": {
|
|
91
|
-
"OPENROUTER_API_KEY": "your-api-key-here",
|
|
92
|
-
"DEFAULT_MODEL": "qwen/qwen2.5-vl-32b-instruct:free"
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
#### Option 3: Using Docker
|
|
60
|
+
### Option 2: Docker
|
|
100
61
|
|
|
101
62
|
```json
|
|
102
63
|
{
|
|
@@ -107,8 +68,8 @@ Add one of the following configurations to your MCP settings file (e.g., `cline_
|
|
|
107
68
|
"run",
|
|
108
69
|
"--rm",
|
|
109
70
|
"-i",
|
|
110
|
-
"-e",
|
|
111
|
-
"
|
|
71
|
+
"-e",
|
|
72
|
+
"OPENROUTER_API_KEY=sk-or-v1-...",
|
|
112
73
|
"stabgandocker/openrouter-mcp-multimodal:latest"
|
|
113
74
|
]
|
|
114
75
|
}
|
|
@@ -116,100 +77,156 @@ Add one of the following configurations to your MCP settings file (e.g., `cline_
|
|
|
116
77
|
}
|
|
117
78
|
```
|
|
118
79
|
|
|
119
|
-
|
|
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:
|
|
120
87
|
|
|
121
88
|
```json
|
|
122
89
|
{
|
|
123
90
|
"mcpServers": {
|
|
124
91
|
"openrouter": {
|
|
125
|
-
"command": "
|
|
126
|
-
"args": [
|
|
127
|
-
"run",
|
|
128
|
-
"stabgan/openrouter-mcp-multimodal"
|
|
129
|
-
],
|
|
92
|
+
"command": "openrouter-multimodal",
|
|
130
93
|
"env": {
|
|
131
|
-
"OPENROUTER_API_KEY": "
|
|
132
|
-
"DEFAULT_MODEL": "qwen/qwen2.5-vl-32b-instruct:free"
|
|
94
|
+
"OPENROUTER_API_KEY": "sk-or-v1-..."
|
|
133
95
|
}
|
|
134
96
|
}
|
|
135
97
|
}
|
|
136
98
|
}
|
|
137
99
|
```
|
|
138
100
|
|
|
139
|
-
|
|
101
|
+
### Option 4: Smithery
|
|
140
102
|
|
|
141
|
-
|
|
103
|
+
```bash
|
|
104
|
+
npx -y @smithery/cli install @stabgan/openrouter-mcp-multimodal --client claude
|
|
105
|
+
```
|
|
142
106
|
|
|
143
|
-
|
|
144
|
-
- Python examples with interactive chat capabilities
|
|
145
|
-
- Code snippets for integrating with various applications
|
|
107
|
+
## Configuration
|
|
146
108
|
|
|
147
|
-
|
|
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) |
|
|
148
121
|
|
|
149
|
-
|
|
122
|
+
### Security notes
|
|
150
123
|
|
|
151
|
-
|
|
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.
|
|
152
126
|
|
|
153
|
-
|
|
154
|
-
- `openai`: ^4.89.1 - OpenAI-compatible API client for OpenRouter
|
|
155
|
-
- `sharp`: ^0.33.5 - Fast image processing library
|
|
156
|
-
- `axios`: ^1.8.4 - HTTP client for API requests
|
|
157
|
-
- `node-fetch`: ^3.3.2 - Modern fetch implementation
|
|
127
|
+
## Usage Examples
|
|
158
128
|
|
|
159
|
-
|
|
129
|
+
### Chat
|
|
160
130
|
|
|
161
|
-
|
|
131
|
+
```
|
|
132
|
+
Use chat_completion to explain quantum computing in simple terms.
|
|
133
|
+
```
|
|
162
134
|
|
|
163
|
-
###
|
|
135
|
+
### Analyze an Image
|
|
164
136
|
|
|
165
|
-
|
|
137
|
+
```
|
|
138
|
+
Use analyze_image on /path/to/photo.jpg and tell me what you see.
|
|
139
|
+
```
|
|
166
140
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
```
|
|
141
|
+
### Find Vision Models
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
Use search_models with capabilities.vision = true to find models that can see images.
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Generate an Image
|
|
148
|
+
|
|
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 {};
|