@stabgan/openrouter-mcp-multimodal 1.5.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +164 -169
  3. package/dist/__tests__/image-utils.test.d.ts +1 -0
  4. package/dist/__tests__/image-utils.test.js +75 -0
  5. package/dist/__tests__/integration.test.d.ts +1 -0
  6. package/dist/__tests__/integration.test.js +111 -0
  7. package/dist/__tests__/model-cache.test.d.ts +1 -0
  8. package/dist/__tests__/model-cache.test.js +96 -0
  9. package/dist/index.js +47 -85
  10. package/dist/model-cache.d.ts +18 -46
  11. package/dist/model-cache.js +28 -125
  12. package/dist/openrouter-api.d.ts +2 -22
  13. package/dist/openrouter-api.js +34 -111
  14. package/dist/tool-handlers/analyze-image.d.ts +2 -17
  15. package/dist/tool-handlers/analyze-image.js +18 -325
  16. package/dist/tool-handlers/chat-completion.d.ts +1 -0
  17. package/dist/tool-handlers/chat-completion.js +10 -143
  18. package/dist/tool-handlers/generate-image.d.ts +30 -0
  19. package/dist/tool-handlers/generate-image.js +82 -0
  20. package/dist/tool-handlers/get-model-info.d.ts +5 -5
  21. package/dist/tool-handlers/get-model-info.js +11 -41
  22. package/dist/tool-handlers/image-utils.d.ts +11 -0
  23. package/dist/tool-handlers/image-utils.js +253 -0
  24. package/dist/tool-handlers/search-models.d.ts +3 -10
  25. package/dist/tool-handlers/search-models.js +6 -37
  26. package/dist/tool-handlers/validate-model.d.ts +5 -5
  27. package/dist/tool-handlers/validate-model.js +13 -37
  28. package/dist/tool-handlers.d.ts +1 -6
  29. package/dist/tool-handlers.js +55 -240
  30. package/package.json +20 -21
  31. package/dist/index.js.map +0 -1
  32. package/dist/model-cache.js.map +0 -1
  33. package/dist/openrouter-api.js.map +0 -1
  34. package/dist/tool-handlers/analyze-image.js.map +0 -1
  35. package/dist/tool-handlers/chat-completion.js.map +0 -1
  36. package/dist/tool-handlers/get-model-info.js.map +0 -1
  37. package/dist/tool-handlers/multi-image-analysis.d.ts +0 -46
  38. package/dist/tool-handlers/multi-image-analysis.js +0 -429
  39. package/dist/tool-handlers/multi-image-analysis.js.map +0 -1
  40. package/dist/tool-handlers/search-models.js.map +0 -1
  41. package/dist/tool-handlers/validate-model.js.map +0 -1
  42. package/dist/tool-handlers.js.map +0 -1
@@ -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
- metadata: {
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: boolean;
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 path from 'path';
2
- import { promises as fs } from 'fs';
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 args = request.params.arguments;
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
- // Validate inputs
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
- type: 'text',
306
- text: completion.choices[0].message.content || '',
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
- metadata: {
310
- model: completion.model,
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
- console.error('Error in image analysis:', error);
317
- if (error instanceof McpError) {
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
@@ -4,6 +4,7 @@ export interface ChatCompletionToolRequest {
4
4
  model?: string;
5
5
  messages: ChatCompletionMessageParam[];
6
6
  temperature?: number;
7
+ max_tokens?: number;
7
8
  }
8
9
  export declare function handleChatCompletion(request: {
9
10
  params: {
@@ -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 args = request.params.arguments;
98
- // Validate message array
99
- if (args.messages.length === 0) {
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: truncatedMessages,
126
- temperature: args.temperature ?? 1,
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
- if (error instanceof Error) {
139
- return {
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
+ }