@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
|
@@ -1,269 +1,28 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
import fetch from 'node-fetch';
|
|
4
|
-
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
|
|
5
|
-
import { findSuitableFreeModel } from './multi-image-analysis.js';
|
|
6
|
-
// Default model for image analysis
|
|
7
|
-
const DEFAULT_FREE_MODEL = 'qwen/qwen2.5-vl-32b-instruct:free';
|
|
8
|
-
let sharp;
|
|
9
|
-
try {
|
|
10
|
-
sharp = require('sharp');
|
|
11
|
-
}
|
|
12
|
-
catch (e) {
|
|
13
|
-
console.error('Warning: sharp module not available, using fallback image processing');
|
|
14
|
-
// Mock implementation that just passes through the base64 data
|
|
15
|
-
sharp = (buffer) => ({
|
|
16
|
-
metadata: async () => ({ width: 800, height: 600 }),
|
|
17
|
-
resize: () => ({
|
|
18
|
-
jpeg: () => ({
|
|
19
|
-
toBuffer: async () => buffer
|
|
20
|
-
})
|
|
21
|
-
}),
|
|
22
|
-
jpeg: () => ({
|
|
23
|
-
toBuffer: async () => buffer
|
|
24
|
-
})
|
|
25
|
-
});
|
|
26
|
-
}
|
|
27
|
-
async function fetchImageAsBuffer(url) {
|
|
28
|
-
try {
|
|
29
|
-
// Handle data URLs
|
|
30
|
-
if (url.startsWith('data:')) {
|
|
31
|
-
const matches = url.match(/^data:([A-Za-z-+\/]+);base64,(.+)$/);
|
|
32
|
-
if (!matches || matches.length !== 3) {
|
|
33
|
-
throw new Error('Invalid data URL');
|
|
34
|
-
}
|
|
35
|
-
return Buffer.from(matches[2], 'base64');
|
|
36
|
-
}
|
|
37
|
-
// Handle file URLs
|
|
38
|
-
if (url.startsWith('file://')) {
|
|
39
|
-
const filePath = url.replace('file://', '');
|
|
40
|
-
return await fs.readFile(filePath);
|
|
41
|
-
}
|
|
42
|
-
// Handle http/https URLs
|
|
43
|
-
if (url.startsWith('http://') || url.startsWith('https://')) {
|
|
44
|
-
const response = await fetch(url);
|
|
45
|
-
if (!response.ok) {
|
|
46
|
-
throw new Error(`HTTP error! status: ${response.status}`);
|
|
47
|
-
}
|
|
48
|
-
return Buffer.from(await response.arrayBuffer());
|
|
49
|
-
}
|
|
50
|
-
// Handle regular file paths
|
|
51
|
-
return await fs.readFile(url);
|
|
52
|
-
}
|
|
53
|
-
catch (error) {
|
|
54
|
-
console.error(`Error fetching image from ${url}:`, error);
|
|
55
|
-
throw error;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
/**
|
|
59
|
-
* Processes an image with minimal processing when sharp isn't available
|
|
60
|
-
*/
|
|
61
|
-
async function processImageFallback(buffer) {
|
|
62
|
-
try {
|
|
63
|
-
// Just return the buffer as base64 without processing
|
|
64
|
-
return buffer.toString('base64');
|
|
65
|
-
}
|
|
66
|
-
catch (error) {
|
|
67
|
-
console.error('Error in fallback image processing:', error);
|
|
68
|
-
throw error;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
async function processImage(buffer) {
|
|
72
|
-
try {
|
|
73
|
-
if (typeof sharp !== 'function') {
|
|
74
|
-
console.warn('Using fallback image processing (sharp not available)');
|
|
75
|
-
return processImageFallback(buffer);
|
|
76
|
-
}
|
|
77
|
-
// Get image metadata
|
|
78
|
-
let metadata;
|
|
79
|
-
try {
|
|
80
|
-
metadata = await sharp(buffer).metadata();
|
|
81
|
-
}
|
|
82
|
-
catch (error) {
|
|
83
|
-
console.warn('Error getting image metadata, using fallback:', error);
|
|
84
|
-
return processImageFallback(buffer);
|
|
85
|
-
}
|
|
86
|
-
// Calculate dimensions to keep base64 size reasonable
|
|
87
|
-
const MAX_DIMENSION = 800;
|
|
88
|
-
const JPEG_QUALITY = 80;
|
|
89
|
-
if (metadata.width && metadata.height) {
|
|
90
|
-
const largerDimension = Math.max(metadata.width, metadata.height);
|
|
91
|
-
if (largerDimension > MAX_DIMENSION) {
|
|
92
|
-
const resizeOptions = metadata.width > metadata.height
|
|
93
|
-
? { width: MAX_DIMENSION }
|
|
94
|
-
: { height: MAX_DIMENSION };
|
|
95
|
-
const resizedBuffer = await sharp(buffer)
|
|
96
|
-
.resize(resizeOptions)
|
|
97
|
-
.jpeg({ quality: JPEG_QUALITY })
|
|
98
|
-
.toBuffer();
|
|
99
|
-
return resizedBuffer.toString('base64');
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
// If no resizing needed, just convert to JPEG
|
|
103
|
-
const jpegBuffer = await sharp(buffer)
|
|
104
|
-
.jpeg({ quality: JPEG_QUALITY })
|
|
105
|
-
.toBuffer();
|
|
106
|
-
return jpegBuffer.toString('base64');
|
|
107
|
-
}
|
|
108
|
-
catch (error) {
|
|
109
|
-
console.error('Error processing image, using fallback:', error);
|
|
110
|
-
return processImageFallback(buffer);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
/**
|
|
114
|
-
* Processes an image from a path or base64 string to a proper base64 format for APIs
|
|
115
|
-
*/
|
|
116
|
-
async function prepareImage(imagePath) {
|
|
117
|
-
try {
|
|
118
|
-
// Check if already a base64 data URL
|
|
119
|
-
if (imagePath.startsWith('data:')) {
|
|
120
|
-
const matches = imagePath.match(/^data:([A-Za-z-+\/]+);base64,(.+)$/);
|
|
121
|
-
if (!matches || matches.length !== 3) {
|
|
122
|
-
throw new McpError(ErrorCode.InvalidParams, 'Invalid base64 data URL format');
|
|
123
|
-
}
|
|
124
|
-
return { base64: matches[2], mimeType: matches[1] };
|
|
125
|
-
}
|
|
126
|
-
// Check if image is a URL
|
|
127
|
-
if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) {
|
|
128
|
-
try {
|
|
129
|
-
const buffer = await fetchImageAsBuffer(imagePath);
|
|
130
|
-
const processed = await processImage(buffer);
|
|
131
|
-
return { base64: processed, mimeType: 'image/jpeg' }; // We convert everything to JPEG
|
|
132
|
-
}
|
|
133
|
-
catch (error) {
|
|
134
|
-
throw new McpError(ErrorCode.InvalidParams, `Failed to fetch image from URL: ${error.message}`);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
// Handle file paths
|
|
138
|
-
let absolutePath = imagePath;
|
|
139
|
-
// Ensure the image path is absolute if it's a file path
|
|
140
|
-
if (!imagePath.startsWith('data:') && !path.isAbsolute(imagePath)) {
|
|
141
|
-
throw new McpError(ErrorCode.InvalidParams, 'Image path must be absolute');
|
|
142
|
-
}
|
|
143
|
-
try {
|
|
144
|
-
// Check if the file exists
|
|
145
|
-
await fs.access(absolutePath);
|
|
146
|
-
}
|
|
147
|
-
catch (error) {
|
|
148
|
-
throw new McpError(ErrorCode.InvalidParams, `File not found: ${absolutePath}`);
|
|
149
|
-
}
|
|
150
|
-
// Read the file as a buffer
|
|
151
|
-
const buffer = await fs.readFile(absolutePath);
|
|
152
|
-
// Determine MIME type from file extension
|
|
153
|
-
const extension = path.extname(absolutePath).toLowerCase();
|
|
154
|
-
let mimeType;
|
|
155
|
-
switch (extension) {
|
|
156
|
-
case '.png':
|
|
157
|
-
mimeType = 'image/png';
|
|
158
|
-
break;
|
|
159
|
-
case '.jpg':
|
|
160
|
-
case '.jpeg':
|
|
161
|
-
mimeType = 'image/jpeg';
|
|
162
|
-
break;
|
|
163
|
-
case '.webp':
|
|
164
|
-
mimeType = 'image/webp';
|
|
165
|
-
break;
|
|
166
|
-
case '.gif':
|
|
167
|
-
mimeType = 'image/gif';
|
|
168
|
-
break;
|
|
169
|
-
case '.bmp':
|
|
170
|
-
mimeType = 'image/bmp';
|
|
171
|
-
break;
|
|
172
|
-
default:
|
|
173
|
-
mimeType = 'application/octet-stream';
|
|
174
|
-
}
|
|
175
|
-
// Process and optimize the image
|
|
176
|
-
const processed = await processImage(buffer);
|
|
177
|
-
return { base64: processed, mimeType };
|
|
178
|
-
}
|
|
179
|
-
catch (error) {
|
|
180
|
-
console.error('Error preparing image:', error);
|
|
181
|
-
throw error;
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
/**
|
|
185
|
-
* Handler for analyzing a single image
|
|
186
|
-
*/
|
|
1
|
+
import { prepareImageUrl } from './image-utils.js';
|
|
2
|
+
const DEFAULT_MODEL = 'nvidia/nemotron-nano-12b-v2-vl:free';
|
|
187
3
|
export async function handleAnalyzeImage(request, openai, defaultModel) {
|
|
188
|
-
const
|
|
4
|
+
const { image_path, question, model } = request.params.arguments;
|
|
5
|
+
if (!image_path) {
|
|
6
|
+
return { content: [{ type: 'text', text: 'image_path is required.' }], isError: true };
|
|
7
|
+
}
|
|
189
8
|
try {
|
|
190
|
-
|
|
191
|
-
if (!args.image_path) {
|
|
192
|
-
throw new McpError(ErrorCode.InvalidParams, 'An image path, URL, or base64 data is required');
|
|
193
|
-
}
|
|
194
|
-
const question = args.question || "What's in this image?";
|
|
195
|
-
console.error(`Processing image: ${args.image_path.substring(0, 100)}${args.image_path.length > 100 ? '...' : ''}`);
|
|
196
|
-
// Convert the image to base64
|
|
197
|
-
const { base64, mimeType } = await prepareImage(args.image_path);
|
|
198
|
-
// Create the content array for the OpenAI API
|
|
199
|
-
const content = [
|
|
200
|
-
{
|
|
201
|
-
type: 'text',
|
|
202
|
-
text: question
|
|
203
|
-
},
|
|
204
|
-
{
|
|
205
|
-
type: 'image_url',
|
|
206
|
-
image_url: {
|
|
207
|
-
url: `data:${mimeType};base64,${base64}`
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
];
|
|
211
|
-
// Select model with priority:
|
|
212
|
-
// 1. User-specified model
|
|
213
|
-
// 2. Default model from environment
|
|
214
|
-
// 3. Default free vision model (qwen/qwen2.5-vl-32b-instruct:free)
|
|
215
|
-
let model = args.model || defaultModel || DEFAULT_FREE_MODEL;
|
|
216
|
-
// If a model is specified but not our default free model, verify it exists
|
|
217
|
-
if (model !== DEFAULT_FREE_MODEL) {
|
|
218
|
-
try {
|
|
219
|
-
await openai.models.retrieve(model);
|
|
220
|
-
}
|
|
221
|
-
catch (error) {
|
|
222
|
-
console.error(`Specified model ${model} not found, falling back to auto-selection`);
|
|
223
|
-
model = await findSuitableFreeModel(openai);
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
console.error(`Making API call with model: ${model}`);
|
|
227
|
-
// Make the API call
|
|
9
|
+
const imageUrl = await prepareImageUrl(image_path);
|
|
228
10
|
const completion = await openai.chat.completions.create({
|
|
229
|
-
model,
|
|
230
|
-
messages: [
|
|
231
|
-
role: 'user',
|
|
232
|
-
content
|
|
233
|
-
}]
|
|
234
|
-
});
|
|
235
|
-
// Return the analysis result
|
|
236
|
-
return {
|
|
237
|
-
content: [
|
|
11
|
+
model: model || defaultModel || DEFAULT_MODEL,
|
|
12
|
+
messages: [
|
|
238
13
|
{
|
|
239
|
-
|
|
240
|
-
|
|
14
|
+
role: 'user',
|
|
15
|
+
content: [
|
|
16
|
+
{ type: 'text', text: question || "What's in this image?" },
|
|
17
|
+
{ type: 'image_url', image_url: { url: imageUrl } },
|
|
18
|
+
],
|
|
241
19
|
},
|
|
242
20
|
],
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
usage: completion.usage
|
|
246
|
-
}
|
|
247
|
-
};
|
|
21
|
+
});
|
|
22
|
+
return { content: [{ type: 'text', text: completion.choices[0].message.content || '' }] };
|
|
248
23
|
}
|
|
249
24
|
catch (error) {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
throw error;
|
|
253
|
-
}
|
|
254
|
-
return {
|
|
255
|
-
content: [
|
|
256
|
-
{
|
|
257
|
-
type: 'text',
|
|
258
|
-
text: `Error analyzing image: ${error instanceof Error ? error.message : String(error)}`,
|
|
259
|
-
},
|
|
260
|
-
],
|
|
261
|
-
isError: true,
|
|
262
|
-
metadata: {
|
|
263
|
-
error_type: error instanceof Error ? error.constructor.name : 'Unknown',
|
|
264
|
-
error_message: error instanceof Error ? error.message : String(error)
|
|
265
|
-
}
|
|
266
|
-
};
|
|
25
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
26
|
+
return { content: [{ type: 'text', text: `Error: ${msg}` }], isError: true };
|
|
267
27
|
}
|
|
268
28
|
}
|
|
269
|
-
//# sourceMappingURL=analyze-image.js.map
|
|
@@ -1,152 +1,19 @@
|
|
|
1
|
-
// Maximum context tokens
|
|
2
|
-
const MAX_CONTEXT_TOKENS = 200000;
|
|
3
|
-
// Utility function to estimate token count (simplified)
|
|
4
|
-
function estimateTokenCount(text) {
|
|
5
|
-
// Rough approximation: 4 characters per token
|
|
6
|
-
return Math.ceil(text.length / 4);
|
|
7
|
-
}
|
|
8
|
-
// Truncate messages to fit within the context window
|
|
9
|
-
function truncateMessagesToFit(messages, maxTokens) {
|
|
10
|
-
const truncated = [];
|
|
11
|
-
let currentTokenCount = 0;
|
|
12
|
-
// Always include system message first if present
|
|
13
|
-
if (messages[0]?.role === 'system') {
|
|
14
|
-
truncated.push(messages[0]);
|
|
15
|
-
currentTokenCount += estimateTokenCount(messages[0].content);
|
|
16
|
-
}
|
|
17
|
-
// Add messages from the end, respecting the token limit
|
|
18
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
19
|
-
const message = messages[i];
|
|
20
|
-
// Skip if it's the system message we've already added
|
|
21
|
-
if (i === 0 && message.role === 'system')
|
|
22
|
-
continue;
|
|
23
|
-
// For string content, estimate tokens directly
|
|
24
|
-
if (typeof message.content === 'string') {
|
|
25
|
-
const messageTokens = estimateTokenCount(message.content);
|
|
26
|
-
if (currentTokenCount + messageTokens > maxTokens)
|
|
27
|
-
break;
|
|
28
|
-
truncated.unshift(message);
|
|
29
|
-
currentTokenCount += messageTokens;
|
|
30
|
-
}
|
|
31
|
-
// For multimodal content (array), estimate tokens for text content
|
|
32
|
-
else if (Array.isArray(message.content)) {
|
|
33
|
-
let messageTokens = 0;
|
|
34
|
-
for (const part of message.content) {
|
|
35
|
-
if (part.type === 'text' && part.text) {
|
|
36
|
-
messageTokens += estimateTokenCount(part.text);
|
|
37
|
-
}
|
|
38
|
-
else if (part.type === 'image_url') {
|
|
39
|
-
// Add a token cost estimate for images - this is a simplification
|
|
40
|
-
// Actual image token costs depend on resolution and model
|
|
41
|
-
messageTokens += 1000;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
if (currentTokenCount + messageTokens > maxTokens)
|
|
45
|
-
break;
|
|
46
|
-
truncated.unshift(message);
|
|
47
|
-
currentTokenCount += messageTokens;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
return truncated;
|
|
51
|
-
}
|
|
52
|
-
// Find a suitable free model with the largest context window
|
|
53
|
-
async function findSuitableFreeModel(openai) {
|
|
54
|
-
try {
|
|
55
|
-
// Query available models with 'free' in their name
|
|
56
|
-
const modelsResponse = await openai.models.list();
|
|
57
|
-
if (!modelsResponse || !modelsResponse.data || modelsResponse.data.length === 0) {
|
|
58
|
-
return 'deepseek/deepseek-chat-v3-0324:free'; // Fallback to a known model
|
|
59
|
-
}
|
|
60
|
-
// Filter models with 'free' in ID
|
|
61
|
-
const freeModels = modelsResponse.data
|
|
62
|
-
.filter(model => model.id.includes('free'))
|
|
63
|
-
.map(model => {
|
|
64
|
-
// Try to extract context length from the model object
|
|
65
|
-
let contextLength = 0;
|
|
66
|
-
try {
|
|
67
|
-
const modelAny = model; // Cast to any to access non-standard properties
|
|
68
|
-
if (typeof modelAny.context_length === 'number') {
|
|
69
|
-
contextLength = modelAny.context_length;
|
|
70
|
-
}
|
|
71
|
-
else if (modelAny.context_window) {
|
|
72
|
-
contextLength = parseInt(modelAny.context_window, 10);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
catch (e) {
|
|
76
|
-
console.error(`Error parsing context length for model ${model.id}:`, e);
|
|
77
|
-
}
|
|
78
|
-
return {
|
|
79
|
-
id: model.id,
|
|
80
|
-
contextLength: contextLength || 0
|
|
81
|
-
};
|
|
82
|
-
});
|
|
83
|
-
if (freeModels.length === 0) {
|
|
84
|
-
return 'deepseek/deepseek-chat-v3-0324:free'; // Fallback if no free models found
|
|
85
|
-
}
|
|
86
|
-
// Sort by context length and pick the one with the largest context window
|
|
87
|
-
freeModels.sort((a, b) => b.contextLength - a.contextLength);
|
|
88
|
-
console.error(`Selected free model: ${freeModels[0].id} with context length: ${freeModels[0].contextLength}`);
|
|
89
|
-
return freeModels[0].id;
|
|
90
|
-
}
|
|
91
|
-
catch (error) {
|
|
92
|
-
console.error('Error finding suitable free model:', error);
|
|
93
|
-
return 'deepseek/deepseek-chat-v3-0324:free'; // Fallback to a known model
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
1
|
export async function handleChatCompletion(request, openai, defaultModel) {
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
return {
|
|
101
|
-
content: [
|
|
102
|
-
{
|
|
103
|
-
type: 'text',
|
|
104
|
-
text: 'Messages array cannot be empty. At least one message is required.',
|
|
105
|
-
},
|
|
106
|
-
],
|
|
107
|
-
isError: true,
|
|
108
|
-
};
|
|
2
|
+
const { messages, model, temperature, max_tokens } = request.params.arguments;
|
|
3
|
+
if (!messages?.length) {
|
|
4
|
+
return { content: [{ type: 'text', text: 'Messages array cannot be empty.' }], isError: true };
|
|
109
5
|
}
|
|
110
6
|
try {
|
|
111
|
-
// Select model with priority:
|
|
112
|
-
// 1. User-specified model
|
|
113
|
-
// 2. Default model from environment
|
|
114
|
-
// 3. Free model with the largest context window (selected automatically)
|
|
115
|
-
let model = args.model || defaultModel;
|
|
116
|
-
if (!model) {
|
|
117
|
-
model = await findSuitableFreeModel(openai);
|
|
118
|
-
console.error(`Using auto-selected model: ${model}`);
|
|
119
|
-
}
|
|
120
|
-
// Truncate messages to fit within context window
|
|
121
|
-
const truncatedMessages = truncateMessagesToFit(args.messages, MAX_CONTEXT_TOKENS);
|
|
122
|
-
console.error(`Making API call with model: ${model}`);
|
|
123
7
|
const completion = await openai.chat.completions.create({
|
|
124
|
-
model,
|
|
125
|
-
messages
|
|
126
|
-
temperature:
|
|
8
|
+
model: model || defaultModel || 'nvidia/nemotron-nano-12b-v2-vl:free',
|
|
9
|
+
messages,
|
|
10
|
+
temperature: temperature ?? 1,
|
|
11
|
+
...(max_tokens && { max_tokens }),
|
|
127
12
|
});
|
|
128
|
-
return {
|
|
129
|
-
content: [
|
|
130
|
-
{
|
|
131
|
-
type: 'text',
|
|
132
|
-
text: completion.choices[0].message.content || '',
|
|
133
|
-
},
|
|
134
|
-
],
|
|
135
|
-
};
|
|
13
|
+
return { content: [{ type: 'text', text: completion.choices[0].message.content || '' }] };
|
|
136
14
|
}
|
|
137
15
|
catch (error) {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
content: [
|
|
141
|
-
{
|
|
142
|
-
type: 'text',
|
|
143
|
-
text: `OpenRouter API error: ${error.message}`,
|
|
144
|
-
},
|
|
145
|
-
],
|
|
146
|
-
isError: true,
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
throw error;
|
|
16
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
17
|
+
return { content: [{ type: 'text', text: `API error: ${msg}` }], isError: true };
|
|
150
18
|
}
|
|
151
19
|
}
|
|
152
|
-
//# sourceMappingURL=chat-completion.js.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import OpenAI from 'openai';
|
|
2
|
+
export interface GenerateImageToolRequest {
|
|
3
|
+
prompt: string;
|
|
4
|
+
model?: string;
|
|
5
|
+
save_path?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function handleGenerateImage(request: {
|
|
8
|
+
params: {
|
|
9
|
+
arguments: GenerateImageToolRequest;
|
|
10
|
+
};
|
|
11
|
+
}, openai: OpenAI): Promise<{
|
|
12
|
+
content: {
|
|
13
|
+
type: string;
|
|
14
|
+
text: string;
|
|
15
|
+
}[];
|
|
16
|
+
isError: boolean;
|
|
17
|
+
} | {
|
|
18
|
+
content: ({
|
|
19
|
+
type: string;
|
|
20
|
+
text: string;
|
|
21
|
+
mimeType?: undefined;
|
|
22
|
+
data?: undefined;
|
|
23
|
+
} | {
|
|
24
|
+
type: string;
|
|
25
|
+
mimeType: string;
|
|
26
|
+
data: string;
|
|
27
|
+
text?: undefined;
|
|
28
|
+
})[];
|
|
29
|
+
isError?: undefined;
|
|
30
|
+
}>;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import { dirname } from 'path';
|
|
3
|
+
const DEFAULT_MODEL = 'google/gemini-2.5-flash-image';
|
|
4
|
+
export async function handleGenerateImage(request, openai) {
|
|
5
|
+
const { prompt, model, save_path } = request.params.arguments;
|
|
6
|
+
if (!prompt?.trim()) {
|
|
7
|
+
return { content: [{ type: 'text', text: 'Prompt is required.' }], isError: true };
|
|
8
|
+
}
|
|
9
|
+
try {
|
|
10
|
+
const completion = await openai.chat.completions.create({
|
|
11
|
+
model: model || DEFAULT_MODEL,
|
|
12
|
+
messages: [{ role: 'user', content: `Generate an image: ${prompt}` }],
|
|
13
|
+
});
|
|
14
|
+
const message = completion.choices[0]?.message;
|
|
15
|
+
if (!message) {
|
|
16
|
+
return { content: [{ type: 'text', text: 'No response from model.' }], isError: true };
|
|
17
|
+
}
|
|
18
|
+
const base64 = extractBase64(message);
|
|
19
|
+
if (base64) {
|
|
20
|
+
if (save_path) {
|
|
21
|
+
const dir = dirname(save_path);
|
|
22
|
+
await fs.mkdir(dir, { recursive: true });
|
|
23
|
+
await fs.writeFile(save_path, Buffer.from(base64.data, 'base64'));
|
|
24
|
+
return {
|
|
25
|
+
content: [
|
|
26
|
+
{ type: 'text', text: `Image saved to: ${save_path}` },
|
|
27
|
+
{ type: 'image', mimeType: base64.mime, data: base64.data },
|
|
28
|
+
],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
return { content: [{ type: 'image', mimeType: base64.mime, data: base64.data }] };
|
|
32
|
+
}
|
|
33
|
+
const content = message.content;
|
|
34
|
+
const text = typeof content === 'string' ? content : JSON.stringify(content);
|
|
35
|
+
return { content: [{ type: 'text', text }] };
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
39
|
+
return { content: [{ type: 'text', text: `Error: ${msg}` }], isError: true };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function extractBase64(message) {
|
|
43
|
+
const images = message.images;
|
|
44
|
+
if (Array.isArray(images) && images.length) {
|
|
45
|
+
for (const img of images) {
|
|
46
|
+
const imageUrl = img.image_url;
|
|
47
|
+
const result = parseDataUrl(imageUrl?.url || img.url);
|
|
48
|
+
if (result)
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (Array.isArray(message.content)) {
|
|
53
|
+
for (const part of message.content) {
|
|
54
|
+
const iu = part.image_url;
|
|
55
|
+
const url = iu?.url || part.url;
|
|
56
|
+
if (url) {
|
|
57
|
+
const r = parseDataUrl(url);
|
|
58
|
+
if (r)
|
|
59
|
+
return r;
|
|
60
|
+
}
|
|
61
|
+
const inline = part.inline_data;
|
|
62
|
+
if (inline?.data) {
|
|
63
|
+
return { data: inline.data, mime: inline.mime_type || 'image/png' };
|
|
64
|
+
}
|
|
65
|
+
if (part.type === 'image' && typeof part.data === 'string') {
|
|
66
|
+
return { data: part.data, mime: part.mime_type || 'image/png' };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (typeof message.content === 'string') {
|
|
71
|
+
const match = message.content.match(/data:image\/([^;]+);base64,([A-Za-z0-9+/=]+)/);
|
|
72
|
+
if (match)
|
|
73
|
+
return { data: match[2], mime: `image/${match[1]}` };
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
function parseDataUrl(url) {
|
|
78
|
+
if (!url?.startsWith('data:'))
|
|
79
|
+
return null;
|
|
80
|
+
const match = url.match(/^data:([^;]+);base64,(.+)$/);
|
|
81
|
+
return match ? { data: match[2], mime: match[1] } : null;
|
|
82
|
+
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { ModelCache } from '../model-cache.js';
|
|
2
|
-
|
|
3
|
-
model: string;
|
|
4
|
-
}
|
|
2
|
+
import { OpenRouterAPIClient } from '../openrouter-api.js';
|
|
5
3
|
export declare function handleGetModelInfo(request: {
|
|
6
4
|
params: {
|
|
7
|
-
arguments:
|
|
5
|
+
arguments: {
|
|
6
|
+
model: string;
|
|
7
|
+
};
|
|
8
8
|
};
|
|
9
|
-
}, modelCache: ModelCache): Promise<{
|
|
9
|
+
}, modelCache: ModelCache, apiClient?: OpenRouterAPIClient): Promise<{
|
|
10
10
|
content: {
|
|
11
11
|
type: string;
|
|
12
12
|
text: string;
|
|
@@ -1,44 +1,14 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
if (!modelCache.isCacheValid()) {
|
|
6
|
-
return {
|
|
7
|
-
content: [
|
|
8
|
-
{
|
|
9
|
-
type: 'text',
|
|
10
|
-
text: 'Model cache is empty or expired. Please call search_models first to populate the cache.',
|
|
11
|
-
},
|
|
12
|
-
],
|
|
13
|
-
isError: true,
|
|
14
|
-
};
|
|
15
|
-
}
|
|
16
|
-
const model = modelCache.getModel(args.model);
|
|
17
|
-
if (!model) {
|
|
18
|
-
throw new McpError(ErrorCode.InvalidParams, `Model '${args.model}' not found`);
|
|
19
|
-
}
|
|
20
|
-
return {
|
|
21
|
-
content: [
|
|
22
|
-
{
|
|
23
|
-
type: 'text',
|
|
24
|
-
text: JSON.stringify(model, null, 2),
|
|
25
|
-
},
|
|
26
|
-
],
|
|
27
|
-
};
|
|
1
|
+
export async function handleGetModelInfo(request, modelCache, apiClient) {
|
|
2
|
+
const { model } = request.params.arguments;
|
|
3
|
+
if (!modelCache.isValid() && apiClient) {
|
|
4
|
+
modelCache.setModels(await apiClient.getModels());
|
|
28
5
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
return {
|
|
32
|
-
content: [
|
|
33
|
-
{
|
|
34
|
-
type: 'text',
|
|
35
|
-
text: `Error retrieving model info: ${error.message}`,
|
|
36
|
-
},
|
|
37
|
-
],
|
|
38
|
-
isError: true,
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
throw error;
|
|
6
|
+
if (!modelCache.isValid()) {
|
|
7
|
+
return { content: [{ type: 'text', text: 'No model data available.' }], isError: true };
|
|
42
8
|
}
|
|
9
|
+
const info = modelCache.get(model);
|
|
10
|
+
if (!info) {
|
|
11
|
+
return { content: [{ type: 'text', text: `Model '${model}' not found.` }], isError: true };
|
|
12
|
+
}
|
|
13
|
+
return { content: [{ type: 'text', text: JSON.stringify(info, null, 2) }] };
|
|
43
14
|
}
|
|
44
|
-
//# sourceMappingURL=get-model-info.js.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare function getMaxImageDimension(): number;
|
|
2
|
+
export declare function getImageJpegQuality(): number;
|
|
3
|
+
export declare function getMimeType(filePath: string): string;
|
|
4
|
+
/** Blocks RFC1918, loopback, link-local, CGNAT, metadata (e.g. 169.254.169.254). */
|
|
5
|
+
export declare function isBlockedIPv4(ip: string): boolean;
|
|
6
|
+
/** Resolve hostname and ensure the resolved address is not private/link-local. */
|
|
7
|
+
export declare function assertUrlSafeForFetch(urlString: string): Promise<URL>;
|
|
8
|
+
export declare function fetchHttpImage(urlString: string): Promise<Buffer>;
|
|
9
|
+
export declare function fetchImage(source: string): Promise<Buffer>;
|
|
10
|
+
export declare function optimizeImage(buffer: Buffer): Promise<string>;
|
|
11
|
+
export declare function prepareImageUrl(source: string): Promise<string>;
|