@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
|
@@ -4,9 +4,6 @@ export interface AnalyzeImageToolRequest {
|
|
|
4
4
|
question?: string;
|
|
5
5
|
model?: string;
|
|
6
6
|
}
|
|
7
|
-
/**
|
|
8
|
-
* Handler for analyzing a single image
|
|
9
|
-
*/
|
|
10
7
|
export declare function handleAnalyzeImage(request: {
|
|
11
8
|
params: {
|
|
12
9
|
arguments: AnalyzeImageToolRequest;
|
|
@@ -16,23 +13,11 @@ export declare function handleAnalyzeImage(request: {
|
|
|
16
13
|
type: string;
|
|
17
14
|
text: string;
|
|
18
15
|
}[];
|
|
19
|
-
|
|
20
|
-
model: string;
|
|
21
|
-
usage: OpenAI.Completions.CompletionUsage | undefined;
|
|
22
|
-
error_type?: undefined;
|
|
23
|
-
error_message?: undefined;
|
|
24
|
-
};
|
|
25
|
-
isError?: undefined;
|
|
16
|
+
isError: boolean;
|
|
26
17
|
} | {
|
|
27
18
|
content: {
|
|
28
19
|
type: string;
|
|
29
20
|
text: string;
|
|
30
21
|
}[];
|
|
31
|
-
isError
|
|
32
|
-
metadata: {
|
|
33
|
-
error_type: string;
|
|
34
|
-
error_message: string;
|
|
35
|
-
model?: undefined;
|
|
36
|
-
usage?: undefined;
|
|
37
|
-
};
|
|
22
|
+
isError?: undefined;
|
|
38
23
|
}>;
|
|
@@ -1,335 +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
|
-
/**
|
|
28
|
-
* Normalizes a file path to be OS-neutral
|
|
29
|
-
* Handles Windows backslashes, drive letters, etc.
|
|
30
|
-
*/
|
|
31
|
-
function normalizePath(filePath) {
|
|
32
|
-
// Skip normalization for URLs and data URLs
|
|
33
|
-
if (filePath.startsWith('http://') ||
|
|
34
|
-
filePath.startsWith('https://') ||
|
|
35
|
-
filePath.startsWith('data:')) {
|
|
36
|
-
return filePath;
|
|
37
|
-
}
|
|
38
|
-
// Handle Windows paths and convert them to a format that's usable
|
|
39
|
-
// First normalize the path according to the OS
|
|
40
|
-
let normalized = path.normalize(filePath);
|
|
41
|
-
// Make sure any Windows backslashes are handled
|
|
42
|
-
normalized = normalized.replace(/\\/g, '/');
|
|
43
|
-
return normalized;
|
|
44
|
-
}
|
|
45
|
-
async function fetchImageAsBuffer(url) {
|
|
46
|
-
try {
|
|
47
|
-
// Handle data URLs
|
|
48
|
-
if (url.startsWith('data:')) {
|
|
49
|
-
const matches = url.match(/^data:([A-Za-z-+\/]+);base64,(.+)$/);
|
|
50
|
-
if (!matches || matches.length !== 3) {
|
|
51
|
-
throw new Error('Invalid data URL');
|
|
52
|
-
}
|
|
53
|
-
return Buffer.from(matches[2], 'base64');
|
|
54
|
-
}
|
|
55
|
-
// Normalize the path before proceeding
|
|
56
|
-
const normalizedUrl = normalizePath(url);
|
|
57
|
-
// Handle file URLs
|
|
58
|
-
if (normalizedUrl.startsWith('file://')) {
|
|
59
|
-
const filePath = normalizedUrl.replace('file://', '');
|
|
60
|
-
return await fs.readFile(filePath);
|
|
61
|
-
}
|
|
62
|
-
// Handle http/https URLs
|
|
63
|
-
if (normalizedUrl.startsWith('http://') || normalizedUrl.startsWith('https://')) {
|
|
64
|
-
const response = await fetch(normalizedUrl);
|
|
65
|
-
if (!response.ok) {
|
|
66
|
-
throw new Error(`HTTP error! status: ${response.status}`);
|
|
67
|
-
}
|
|
68
|
-
return Buffer.from(await response.arrayBuffer());
|
|
69
|
-
}
|
|
70
|
-
// Handle regular file paths
|
|
71
|
-
try {
|
|
72
|
-
return await fs.readFile(normalizedUrl);
|
|
73
|
-
}
|
|
74
|
-
catch (error) {
|
|
75
|
-
// Try the original path as a fallback
|
|
76
|
-
if (normalizedUrl !== url) {
|
|
77
|
-
try {
|
|
78
|
-
return await fs.readFile(url);
|
|
79
|
-
}
|
|
80
|
-
catch (secondError) {
|
|
81
|
-
console.error(`Failed to read file with normalized path (${normalizedUrl}) and original path (${url})`);
|
|
82
|
-
throw error; // Throw the original error
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
throw error;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
catch (error) {
|
|
89
|
-
console.error(`Error fetching image from ${url}:`, error);
|
|
90
|
-
throw error;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
/**
|
|
94
|
-
* Processes an image with minimal processing when sharp isn't available
|
|
95
|
-
*/
|
|
96
|
-
async function processImageFallback(buffer) {
|
|
97
|
-
try {
|
|
98
|
-
// Just return the buffer as base64 without processing
|
|
99
|
-
return buffer.toString('base64');
|
|
100
|
-
}
|
|
101
|
-
catch (error) {
|
|
102
|
-
console.error('Error in fallback image processing:', error);
|
|
103
|
-
throw error;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
async function processImage(buffer) {
|
|
107
|
-
try {
|
|
108
|
-
if (typeof sharp !== 'function') {
|
|
109
|
-
console.warn('Using fallback image processing (sharp not available)');
|
|
110
|
-
return processImageFallback(buffer);
|
|
111
|
-
}
|
|
112
|
-
// Get image metadata
|
|
113
|
-
let metadata;
|
|
114
|
-
try {
|
|
115
|
-
metadata = await sharp(buffer).metadata();
|
|
116
|
-
}
|
|
117
|
-
catch (error) {
|
|
118
|
-
console.warn('Error getting image metadata, using fallback:', error);
|
|
119
|
-
return processImageFallback(buffer);
|
|
120
|
-
}
|
|
121
|
-
// Calculate dimensions to keep base64 size reasonable
|
|
122
|
-
const MAX_DIMENSION = 800;
|
|
123
|
-
const JPEG_QUALITY = 80;
|
|
124
|
-
if (metadata.width && metadata.height) {
|
|
125
|
-
const largerDimension = Math.max(metadata.width, metadata.height);
|
|
126
|
-
if (largerDimension > MAX_DIMENSION) {
|
|
127
|
-
const resizeOptions = metadata.width > metadata.height
|
|
128
|
-
? { width: MAX_DIMENSION }
|
|
129
|
-
: { height: MAX_DIMENSION };
|
|
130
|
-
const resizedBuffer = await sharp(buffer)
|
|
131
|
-
.resize(resizeOptions)
|
|
132
|
-
.jpeg({ quality: JPEG_QUALITY })
|
|
133
|
-
.toBuffer();
|
|
134
|
-
return resizedBuffer.toString('base64');
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
// If no resizing needed, just convert to JPEG
|
|
138
|
-
const jpegBuffer = await sharp(buffer)
|
|
139
|
-
.jpeg({ quality: JPEG_QUALITY })
|
|
140
|
-
.toBuffer();
|
|
141
|
-
return jpegBuffer.toString('base64');
|
|
142
|
-
}
|
|
143
|
-
catch (error) {
|
|
144
|
-
console.error('Error processing image, using fallback:', error);
|
|
145
|
-
return processImageFallback(buffer);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
/**
|
|
149
|
-
* Processes an image from a path or base64 string to a proper base64 format for APIs
|
|
150
|
-
*/
|
|
151
|
-
async function prepareImage(imagePath) {
|
|
152
|
-
try {
|
|
153
|
-
// Check if already a base64 data URL
|
|
154
|
-
if (imagePath.startsWith('data:')) {
|
|
155
|
-
const matches = imagePath.match(/^data:([A-Za-z-+\/]+);base64,(.+)$/);
|
|
156
|
-
if (!matches || matches.length !== 3) {
|
|
157
|
-
throw new McpError(ErrorCode.InvalidParams, 'Invalid base64 data URL format');
|
|
158
|
-
}
|
|
159
|
-
return { base64: matches[2], mimeType: matches[1] };
|
|
160
|
-
}
|
|
161
|
-
// Normalize the path first
|
|
162
|
-
const normalizedPath = normalizePath(imagePath);
|
|
163
|
-
// Check if image is a URL
|
|
164
|
-
if (normalizedPath.startsWith('http://') || normalizedPath.startsWith('https://')) {
|
|
165
|
-
try {
|
|
166
|
-
const buffer = await fetchImageAsBuffer(normalizedPath);
|
|
167
|
-
const processed = await processImage(buffer);
|
|
168
|
-
return { base64: processed, mimeType: 'image/jpeg' }; // We convert everything to JPEG
|
|
169
|
-
}
|
|
170
|
-
catch (error) {
|
|
171
|
-
throw new McpError(ErrorCode.InvalidParams, `Failed to fetch image from URL: ${error.message}`);
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
// Handle file paths
|
|
175
|
-
let absolutePath = normalizedPath;
|
|
176
|
-
// For local file paths, ensure they are absolute
|
|
177
|
-
// Don't check URLs or data URIs
|
|
178
|
-
if (!normalizedPath.startsWith('data:') &&
|
|
179
|
-
!normalizedPath.startsWith('http://') &&
|
|
180
|
-
!normalizedPath.startsWith('https://')) {
|
|
181
|
-
if (!path.isAbsolute(normalizedPath)) {
|
|
182
|
-
throw new McpError(ErrorCode.InvalidParams, 'Image path must be absolute');
|
|
183
|
-
}
|
|
184
|
-
// For Windows paths that include a drive letter but aren't recognized as absolute
|
|
185
|
-
// by path.isAbsolute in some environments
|
|
186
|
-
if (/^[A-Za-z]:/.test(normalizedPath) && !path.isAbsolute(normalizedPath)) {
|
|
187
|
-
absolutePath = path.resolve(normalizedPath);
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
try {
|
|
191
|
-
// Check if the file exists
|
|
192
|
-
await fs.access(absolutePath);
|
|
193
|
-
}
|
|
194
|
-
catch (error) {
|
|
195
|
-
// Try the original path as a fallback
|
|
196
|
-
try {
|
|
197
|
-
await fs.access(imagePath);
|
|
198
|
-
absolutePath = imagePath; // Use the original path if that works
|
|
199
|
-
}
|
|
200
|
-
catch (secondError) {
|
|
201
|
-
throw new McpError(ErrorCode.InvalidParams, `File not found: ${absolutePath}`);
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
// Read the file as a buffer
|
|
205
|
-
let buffer;
|
|
206
|
-
try {
|
|
207
|
-
buffer = await fs.readFile(absolutePath);
|
|
208
|
-
}
|
|
209
|
-
catch (error) {
|
|
210
|
-
// Try the original path as a fallback
|
|
211
|
-
try {
|
|
212
|
-
buffer = await fs.readFile(imagePath);
|
|
213
|
-
}
|
|
214
|
-
catch (secondError) {
|
|
215
|
-
throw new McpError(ErrorCode.InvalidParams, `Failed to read file: ${absolutePath}`);
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
// Determine MIME type from file extension
|
|
219
|
-
const extension = path.extname(absolutePath).toLowerCase();
|
|
220
|
-
let mimeType;
|
|
221
|
-
switch (extension) {
|
|
222
|
-
case '.png':
|
|
223
|
-
mimeType = 'image/png';
|
|
224
|
-
break;
|
|
225
|
-
case '.jpg':
|
|
226
|
-
case '.jpeg':
|
|
227
|
-
mimeType = 'image/jpeg';
|
|
228
|
-
break;
|
|
229
|
-
case '.webp':
|
|
230
|
-
mimeType = 'image/webp';
|
|
231
|
-
break;
|
|
232
|
-
case '.gif':
|
|
233
|
-
mimeType = 'image/gif';
|
|
234
|
-
break;
|
|
235
|
-
case '.bmp':
|
|
236
|
-
mimeType = 'image/bmp';
|
|
237
|
-
break;
|
|
238
|
-
default:
|
|
239
|
-
mimeType = 'application/octet-stream';
|
|
240
|
-
}
|
|
241
|
-
// Process and optimize the image
|
|
242
|
-
const processed = await processImage(buffer);
|
|
243
|
-
return { base64: processed, mimeType };
|
|
244
|
-
}
|
|
245
|
-
catch (error) {
|
|
246
|
-
console.error('Error preparing image:', error);
|
|
247
|
-
throw error;
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
/**
|
|
251
|
-
* Handler for analyzing a single image
|
|
252
|
-
*/
|
|
1
|
+
import { prepareImageUrl } from './image-utils.js';
|
|
2
|
+
const DEFAULT_MODEL = 'nvidia/nemotron-nano-12b-v2-vl:free';
|
|
253
3
|
export async function handleAnalyzeImage(request, openai, defaultModel) {
|
|
254
|
-
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
|
+
}
|
|
255
8
|
try {
|
|
256
|
-
|
|
257
|
-
if (!args.image_path) {
|
|
258
|
-
throw new McpError(ErrorCode.InvalidParams, 'An image path, URL, or base64 data is required');
|
|
259
|
-
}
|
|
260
|
-
const question = args.question || "What's in this image?";
|
|
261
|
-
console.error(`Processing image: ${args.image_path.substring(0, 100)}${args.image_path.length > 100 ? '...' : ''}`);
|
|
262
|
-
// Convert the image to base64
|
|
263
|
-
const { base64, mimeType } = await prepareImage(args.image_path);
|
|
264
|
-
// Create the content array for the OpenAI API
|
|
265
|
-
const content = [
|
|
266
|
-
{
|
|
267
|
-
type: 'text',
|
|
268
|
-
text: question
|
|
269
|
-
},
|
|
270
|
-
{
|
|
271
|
-
type: 'image_url',
|
|
272
|
-
image_url: {
|
|
273
|
-
url: `data:${mimeType};base64,${base64}`
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
];
|
|
277
|
-
// Select model with priority:
|
|
278
|
-
// 1. User-specified model
|
|
279
|
-
// 2. Default model from environment
|
|
280
|
-
// 3. Default free vision model (qwen/qwen2.5-vl-32b-instruct:free)
|
|
281
|
-
let model = args.model || defaultModel || DEFAULT_FREE_MODEL;
|
|
282
|
-
// If a model is specified but not our default free model, verify it exists
|
|
283
|
-
if (model !== DEFAULT_FREE_MODEL) {
|
|
284
|
-
try {
|
|
285
|
-
await openai.models.retrieve(model);
|
|
286
|
-
}
|
|
287
|
-
catch (error) {
|
|
288
|
-
console.error(`Specified model ${model} not found, falling back to auto-selection`);
|
|
289
|
-
model = await findSuitableFreeModel(openai);
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
console.error(`Making API call with model: ${model}`);
|
|
293
|
-
// Make the API call
|
|
9
|
+
const imageUrl = await prepareImageUrl(image_path);
|
|
294
10
|
const completion = await openai.chat.completions.create({
|
|
295
|
-
model,
|
|
296
|
-
messages: [
|
|
297
|
-
role: 'user',
|
|
298
|
-
content
|
|
299
|
-
}]
|
|
300
|
-
});
|
|
301
|
-
// Return the analysis result
|
|
302
|
-
return {
|
|
303
|
-
content: [
|
|
11
|
+
model: model || defaultModel || DEFAULT_MODEL,
|
|
12
|
+
messages: [
|
|
304
13
|
{
|
|
305
|
-
|
|
306
|
-
|
|
14
|
+
role: 'user',
|
|
15
|
+
content: [
|
|
16
|
+
{ type: 'text', text: question || "What's in this image?" },
|
|
17
|
+
{ type: 'image_url', image_url: { url: imageUrl } },
|
|
18
|
+
],
|
|
307
19
|
},
|
|
308
20
|
],
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
usage: completion.usage
|
|
312
|
-
}
|
|
313
|
-
};
|
|
21
|
+
});
|
|
22
|
+
return { content: [{ type: 'text', text: completion.choices[0].message.content || '' }] };
|
|
314
23
|
}
|
|
315
24
|
catch (error) {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
throw error;
|
|
319
|
-
}
|
|
320
|
-
return {
|
|
321
|
-
content: [
|
|
322
|
-
{
|
|
323
|
-
type: 'text',
|
|
324
|
-
text: `Error analyzing image: ${error instanceof Error ? error.message : String(error)}`,
|
|
325
|
-
},
|
|
326
|
-
],
|
|
327
|
-
isError: true,
|
|
328
|
-
metadata: {
|
|
329
|
-
error_type: error instanceof Error ? error.constructor.name : 'Unknown',
|
|
330
|
-
error_message: error instanceof Error ? error.message : String(error)
|
|
331
|
-
}
|
|
332
|
-
};
|
|
25
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
26
|
+
return { content: [{ type: 'text', text: `Error: ${msg}` }], isError: true };
|
|
333
27
|
}
|
|
334
28
|
}
|
|
335
|
-
//# 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
|
+
}
|