@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
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
2
|
+
import { ModelCache } from '../model-cache.js';
|
|
3
|
+
describe('ModelCache', () => {
|
|
4
|
+
let cache;
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
vi.unstubAllEnvs();
|
|
7
|
+
cache = ModelCache.getInstance();
|
|
8
|
+
cache.setModels([]);
|
|
9
|
+
});
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.unstubAllEnvs();
|
|
12
|
+
});
|
|
13
|
+
const sampleModels = [
|
|
14
|
+
{
|
|
15
|
+
id: 'openai/gpt-4',
|
|
16
|
+
name: 'OpenAI: GPT-4',
|
|
17
|
+
architecture: { input_modalities: ['text', 'image'] },
|
|
18
|
+
context_length: 128000,
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
id: 'anthropic/claude-3',
|
|
22
|
+
name: 'Anthropic: Claude 3',
|
|
23
|
+
architecture: { input_modalities: ['text', 'image'] },
|
|
24
|
+
context_length: 200000,
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: 'meta/llama-3',
|
|
28
|
+
name: 'Meta: Llama 3',
|
|
29
|
+
architecture: { input_modalities: ['text'] },
|
|
30
|
+
context_length: 8192,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: 'qwen/qwen-vl:free',
|
|
34
|
+
name: 'Qwen: VL (free)',
|
|
35
|
+
architecture: { input_modalities: ['text', 'image'] },
|
|
36
|
+
context_length: 32000,
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
it('should be a singleton', () => {
|
|
40
|
+
const a = ModelCache.getInstance();
|
|
41
|
+
const b = ModelCache.getInstance();
|
|
42
|
+
expect(a).toBe(b);
|
|
43
|
+
});
|
|
44
|
+
it('should store and retrieve models', () => {
|
|
45
|
+
cache.setModels(sampleModels);
|
|
46
|
+
expect(cache.getAll()).toHaveLength(4);
|
|
47
|
+
expect(cache.get('openai/gpt-4')).toEqual(sampleModels[0]);
|
|
48
|
+
expect(cache.get('nonexistent')).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
it('should check model existence', () => {
|
|
51
|
+
cache.setModels(sampleModels);
|
|
52
|
+
expect(cache.has('openai/gpt-4')).toBe(true);
|
|
53
|
+
expect(cache.has('nonexistent')).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
it('should report valid cache after setModels', () => {
|
|
56
|
+
expect(cache.isValid()).toBe(false);
|
|
57
|
+
cache.setModels(sampleModels);
|
|
58
|
+
expect(cache.isValid()).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
it('should search by query', () => {
|
|
61
|
+
cache.setModels(sampleModels);
|
|
62
|
+
const results = cache.search({ query: 'gpt' });
|
|
63
|
+
expect(results).toHaveLength(1);
|
|
64
|
+
expect(results[0].id).toBe('openai/gpt-4');
|
|
65
|
+
});
|
|
66
|
+
it('should search by provider', () => {
|
|
67
|
+
cache.setModels(sampleModels);
|
|
68
|
+
const results = cache.search({ provider: 'anthropic' });
|
|
69
|
+
expect(results).toHaveLength(1);
|
|
70
|
+
expect(results[0].id).toBe('anthropic/claude-3');
|
|
71
|
+
});
|
|
72
|
+
it('should filter by vision capability', () => {
|
|
73
|
+
cache.setModels(sampleModels);
|
|
74
|
+
const results = cache.search({ capabilities: { vision: true } });
|
|
75
|
+
expect(results).toHaveLength(3);
|
|
76
|
+
expect(results.every((m) => m.architecture?.input_modalities?.includes('image'))).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
it('should respect limit', () => {
|
|
79
|
+
cache.setModels(sampleModels);
|
|
80
|
+
const results = cache.search({ limit: 2 });
|
|
81
|
+
expect(results).toHaveLength(2);
|
|
82
|
+
});
|
|
83
|
+
it('should combine filters', () => {
|
|
84
|
+
cache.setModels(sampleModels);
|
|
85
|
+
const results = cache.search({ query: 'free', capabilities: { vision: true } });
|
|
86
|
+
expect(results).toHaveLength(1);
|
|
87
|
+
expect(results[0].id).toBe('qwen/qwen-vl:free');
|
|
88
|
+
});
|
|
89
|
+
it('should expire cache after TTL from env', async () => {
|
|
90
|
+
vi.stubEnv('OPENROUTER_MODEL_CACHE_TTL_MS', '25');
|
|
91
|
+
cache.setModels(sampleModels);
|
|
92
|
+
expect(cache.isValid()).toBe(true);
|
|
93
|
+
await new Promise((r) => setTimeout(r, 60));
|
|
94
|
+
expect(cache.isValid()).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
});
|
package/dist/index.js
CHANGED
|
@@ -1,51 +1,52 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
import { Readable } from 'node:stream';
|
|
3
3
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
4
4
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
5
|
import { ToolHandlers } from './tool-handlers.js';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
this.server = new Server({
|
|
21
|
-
name: 'openrouter-multimodal-server',
|
|
22
|
-
version: '1.0.0',
|
|
23
|
-
}, {
|
|
24
|
-
capabilities: {
|
|
25
|
-
tools: {},
|
|
26
|
-
},
|
|
27
|
-
});
|
|
28
|
-
// Set up error handling
|
|
29
|
-
this.server.onerror = (error) => console.error('[MCP Error]', error);
|
|
30
|
-
// Initialize tool handlers
|
|
31
|
-
this.toolHandlers = new ToolHandlers(this.server, apiKey, defaultModel);
|
|
32
|
-
process.on('SIGINT', async () => {
|
|
33
|
-
await this.server.close();
|
|
34
|
-
process.exit(0);
|
|
35
|
-
});
|
|
36
|
-
}
|
|
37
|
-
async run() {
|
|
38
|
-
const transport = new StdioServerTransport();
|
|
39
|
-
await this.server.connect(transport);
|
|
40
|
-
console.error('OpenRouter Multimodal MCP server running on stdio');
|
|
41
|
-
console.error('Using API key from environment variable');
|
|
42
|
-
console.error('Note: To use OpenRouter Multimodal, add the API key to your environment variables:');
|
|
43
|
-
console.error(' OPENROUTER_API_KEY=your-api-key');
|
|
44
|
-
const modelDisplay = process.env.OPENROUTER_DEFAULT_MODEL || DEFAULT_MODEL;
|
|
45
|
-
console.error(` Using default model: ${modelDisplay}`);
|
|
46
|
-
console.error('Server is ready to process tool calls. Waiting for input...');
|
|
47
|
-
}
|
|
6
|
+
const DEFAULT_MODEL = 'nvidia/nemotron-nano-12b-v2-vl:free';
|
|
7
|
+
// Exit on fatal errors to prevent silent zombie processes (issue #5)
|
|
8
|
+
process.on('uncaughtException', (err) => {
|
|
9
|
+
console.error('[Fatal]', err);
|
|
10
|
+
process.exit(1);
|
|
11
|
+
});
|
|
12
|
+
process.on('unhandledRejection', (err) => {
|
|
13
|
+
console.error('[Fatal]', err);
|
|
14
|
+
process.exit(1);
|
|
15
|
+
});
|
|
16
|
+
const apiKey = process.env.OPENROUTER_API_KEY;
|
|
17
|
+
if (!apiKey) {
|
|
18
|
+
console.error('OPENROUTER_API_KEY is required');
|
|
19
|
+
process.exit(1);
|
|
48
20
|
}
|
|
49
|
-
const
|
|
50
|
-
server
|
|
51
|
-
|
|
21
|
+
const defaultModel = process.env.OPENROUTER_DEFAULT_MODEL || process.env.DEFAULT_MODEL || DEFAULT_MODEL;
|
|
22
|
+
const server = new Server({ name: 'openrouter-multimodal-server', version: '1.8.0' }, { capabilities: { tools: {} } });
|
|
23
|
+
server.onerror = (error) => console.error('[MCP Error]', error);
|
|
24
|
+
new ToolHandlers(server, apiKey, defaultModel);
|
|
25
|
+
process.on('SIGINT', async () => {
|
|
26
|
+
await server.close();
|
|
27
|
+
process.exit(0);
|
|
28
|
+
});
|
|
29
|
+
// Ensure stdin emits raw Buffers — some hosts (e.g. Claude Desktop) may set
|
|
30
|
+
// encoding on the stdin pipe, which causes the MCP SDK's ReadBuffer to receive
|
|
31
|
+
// strings instead of Buffers. ReadBuffer.readMessage() calls subarray() which
|
|
32
|
+
// doesn't exist on strings, triggering an infinite error loop.
|
|
33
|
+
const stdinStream = process.stdin;
|
|
34
|
+
stdinStream.setEncoding?.(undefined);
|
|
35
|
+
const safeStdin = new Readable({
|
|
36
|
+
read() { },
|
|
37
|
+
});
|
|
38
|
+
process.stdin.on('data', (chunk) => {
|
|
39
|
+
safeStdin.push(typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk);
|
|
40
|
+
});
|
|
41
|
+
process.stdin.on('end', () => safeStdin.push(null));
|
|
42
|
+
process.stdin.on('error', (err) => safeStdin.destroy(err));
|
|
43
|
+
const transport = new StdioServerTransport(safeStdin, process.stdout);
|
|
44
|
+
server
|
|
45
|
+
.connect(transport)
|
|
46
|
+
.then(() => {
|
|
47
|
+
console.error(`OpenRouter MCP server running (model: ${defaultModel})`);
|
|
48
|
+
})
|
|
49
|
+
.catch((err) => {
|
|
50
|
+
console.error('[Fatal] Server failed to start:', err);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
});
|
package/dist/model-cache.d.ts
CHANGED
|
@@ -1,56 +1,28 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
export interface OpenRouterModelRecord {
|
|
2
|
+
id: string;
|
|
3
|
+
name?: string;
|
|
4
|
+
architecture?: {
|
|
5
|
+
input_modalities?: string[];
|
|
6
|
+
};
|
|
7
|
+
context_length?: number;
|
|
8
|
+
[key: string]: unknown;
|
|
9
|
+
}
|
|
4
10
|
export declare class ModelCache {
|
|
5
11
|
private static instance;
|
|
6
12
|
private models;
|
|
7
|
-
private
|
|
8
|
-
private cacheExpiryTime;
|
|
9
|
-
private constructor();
|
|
10
|
-
/**
|
|
11
|
-
* Get singleton instance
|
|
12
|
-
*/
|
|
13
|
+
private fetchedAt;
|
|
13
14
|
static getInstance(): ModelCache;
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
*/
|
|
21
|
-
setModels(models: any[]): void;
|
|
22
|
-
/**
|
|
23
|
-
* Get all cached models
|
|
24
|
-
*/
|
|
25
|
-
getAllModels(): any[];
|
|
26
|
-
/**
|
|
27
|
-
* Get a specific model by ID
|
|
28
|
-
*/
|
|
29
|
-
getModel(modelId: string): any | null;
|
|
30
|
-
/**
|
|
31
|
-
* Check if a model exists
|
|
32
|
-
*/
|
|
33
|
-
hasModel(modelId: string): boolean;
|
|
34
|
-
/**
|
|
35
|
-
* Search models based on criteria
|
|
36
|
-
*/
|
|
37
|
-
searchModels(params: {
|
|
15
|
+
isValid(): boolean;
|
|
16
|
+
setModels(models: OpenRouterModelRecord[]): void;
|
|
17
|
+
getAll(): OpenRouterModelRecord[];
|
|
18
|
+
get(id: string): OpenRouterModelRecord | null;
|
|
19
|
+
has(id: string): boolean;
|
|
20
|
+
search(params: {
|
|
38
21
|
query?: string;
|
|
39
22
|
provider?: string;
|
|
40
|
-
minContextLength?: number | string;
|
|
41
|
-
maxContextLength?: number | string;
|
|
42
|
-
maxPromptPrice?: number | string;
|
|
43
|
-
maxCompletionPrice?: number | string;
|
|
44
23
|
capabilities?: {
|
|
45
|
-
functions?: boolean;
|
|
46
|
-
tools?: boolean;
|
|
47
24
|
vision?: boolean;
|
|
48
|
-
json_mode?: boolean;
|
|
49
25
|
};
|
|
50
|
-
limit?: number
|
|
51
|
-
}):
|
|
52
|
-
/**
|
|
53
|
-
* Reset the cache
|
|
54
|
-
*/
|
|
55
|
-
resetCache(): void;
|
|
26
|
+
limit?: number;
|
|
27
|
+
}): OpenRouterModelRecord[];
|
|
56
28
|
}
|
package/dist/model-cache.js
CHANGED
|
@@ -1,143 +1,46 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
function getCacheTtlMs() {
|
|
2
|
+
const raw = process.env.OPENROUTER_MODEL_CACHE_TTL_MS;
|
|
3
|
+
if (raw === undefined || raw === '')
|
|
4
|
+
return 3600000;
|
|
5
|
+
const n = parseInt(raw, 10);
|
|
6
|
+
return Number.isFinite(n) && n > 0 ? n : 3600000;
|
|
7
|
+
}
|
|
4
8
|
export class ModelCache {
|
|
5
9
|
static instance;
|
|
6
|
-
models;
|
|
7
|
-
|
|
8
|
-
cacheExpiryTime; // in milliseconds (1 hour = 3600000)
|
|
9
|
-
constructor() {
|
|
10
|
-
this.models = {};
|
|
11
|
-
this.lastFetchTime = 0;
|
|
12
|
-
this.cacheExpiryTime = 3600000; // 1 hour
|
|
13
|
-
}
|
|
14
|
-
/**
|
|
15
|
-
* Get singleton instance
|
|
16
|
-
*/
|
|
10
|
+
models = {};
|
|
11
|
+
fetchedAt = 0;
|
|
17
12
|
static getInstance() {
|
|
18
|
-
|
|
19
|
-
ModelCache.instance = new ModelCache();
|
|
20
|
-
}
|
|
21
|
-
return ModelCache.instance;
|
|
13
|
+
return (ModelCache.instance ??= new ModelCache());
|
|
22
14
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
*/
|
|
26
|
-
isCacheValid() {
|
|
27
|
-
return (Object.keys(this.models).length > 0 &&
|
|
28
|
-
Date.now() - this.lastFetchTime < this.cacheExpiryTime);
|
|
15
|
+
isValid() {
|
|
16
|
+
return Object.keys(this.models).length > 0 && Date.now() - this.fetchedAt < getCacheTtlMs();
|
|
29
17
|
}
|
|
30
|
-
/**
|
|
31
|
-
* Store all models
|
|
32
|
-
*/
|
|
33
18
|
setModels(models) {
|
|
34
|
-
this.models =
|
|
35
|
-
|
|
36
|
-
this.models[model.id] = model;
|
|
37
|
-
}
|
|
38
|
-
this.lastFetchTime = Date.now();
|
|
19
|
+
this.models = Object.fromEntries(models.map((m) => [m.id, m]));
|
|
20
|
+
this.fetchedAt = Date.now();
|
|
39
21
|
}
|
|
40
|
-
|
|
41
|
-
* Get all cached models
|
|
42
|
-
*/
|
|
43
|
-
getAllModels() {
|
|
22
|
+
getAll() {
|
|
44
23
|
return Object.values(this.models);
|
|
45
24
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
*/
|
|
49
|
-
getModel(modelId) {
|
|
50
|
-
return this.models[modelId] || null;
|
|
25
|
+
get(id) {
|
|
26
|
+
return this.models[id] ?? null;
|
|
51
27
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
*/
|
|
55
|
-
hasModel(modelId) {
|
|
56
|
-
return !!this.models[modelId];
|
|
28
|
+
has(id) {
|
|
29
|
+
return id in this.models;
|
|
57
30
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
*/
|
|
61
|
-
searchModels(params) {
|
|
62
|
-
let results = this.getAllModels();
|
|
63
|
-
// Apply text search
|
|
31
|
+
search(params) {
|
|
32
|
+
let results = this.getAll();
|
|
64
33
|
if (params.query) {
|
|
65
|
-
const
|
|
66
|
-
results = results.filter((
|
|
67
|
-
(model.description && model.description.toLowerCase().includes(query)) ||
|
|
68
|
-
(model.provider && model.provider.toLowerCase().includes(query)));
|
|
34
|
+
const q = params.query.toLowerCase();
|
|
35
|
+
results = results.filter((m) => m.id.toLowerCase().includes(q) || m.name?.toLowerCase().includes(q));
|
|
69
36
|
}
|
|
70
|
-
// Filter by provider
|
|
71
37
|
if (params.provider) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
// Filter by context length
|
|
75
|
-
if (params.minContextLength !== undefined) {
|
|
76
|
-
const minContextLength = typeof params.minContextLength === 'string'
|
|
77
|
-
? parseInt(params.minContextLength, 10)
|
|
78
|
-
: params.minContextLength;
|
|
79
|
-
if (!isNaN(minContextLength)) {
|
|
80
|
-
results = results.filter((model) => model.context_length >= minContextLength);
|
|
81
|
-
}
|
|
38
|
+
const p = params.provider.toLowerCase();
|
|
39
|
+
results = results.filter((m) => m.id.toLowerCase().startsWith(p + '/'));
|
|
82
40
|
}
|
|
83
|
-
if (params.
|
|
84
|
-
|
|
85
|
-
? parseInt(params.maxContextLength, 10)
|
|
86
|
-
: params.maxContextLength;
|
|
87
|
-
if (!isNaN(maxContextLength)) {
|
|
88
|
-
results = results.filter((model) => model.context_length <= maxContextLength);
|
|
89
|
-
}
|
|
41
|
+
if (params.capabilities?.vision) {
|
|
42
|
+
results = results.filter((m) => m.architecture?.input_modalities?.includes('image'));
|
|
90
43
|
}
|
|
91
|
-
|
|
92
|
-
if (params.maxPromptPrice !== undefined) {
|
|
93
|
-
const maxPromptPrice = typeof params.maxPromptPrice === 'string'
|
|
94
|
-
? parseFloat(params.maxPromptPrice)
|
|
95
|
-
: params.maxPromptPrice;
|
|
96
|
-
if (!isNaN(maxPromptPrice)) {
|
|
97
|
-
results = results.filter((model) => !model.pricing?.prompt || model.pricing.prompt <= maxPromptPrice);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
if (params.maxCompletionPrice !== undefined) {
|
|
101
|
-
const maxCompletionPrice = typeof params.maxCompletionPrice === 'string'
|
|
102
|
-
? parseFloat(params.maxCompletionPrice)
|
|
103
|
-
: params.maxCompletionPrice;
|
|
104
|
-
if (!isNaN(maxCompletionPrice)) {
|
|
105
|
-
results = results.filter((model) => !model.pricing?.completion ||
|
|
106
|
-
model.pricing.completion <= maxCompletionPrice);
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
// Filter by capabilities
|
|
110
|
-
if (params.capabilities) {
|
|
111
|
-
if (params.capabilities.functions) {
|
|
112
|
-
results = results.filter((model) => model.capabilities?.function_calling);
|
|
113
|
-
}
|
|
114
|
-
if (params.capabilities.tools) {
|
|
115
|
-
results = results.filter((model) => model.capabilities?.tools);
|
|
116
|
-
}
|
|
117
|
-
if (params.capabilities.vision) {
|
|
118
|
-
results = results.filter((model) => model.capabilities?.vision);
|
|
119
|
-
}
|
|
120
|
-
if (params.capabilities.json_mode) {
|
|
121
|
-
results = results.filter((model) => model.capabilities?.json_mode);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
// Apply limit
|
|
125
|
-
if (params.limit !== undefined) {
|
|
126
|
-
const limit = typeof params.limit === 'string'
|
|
127
|
-
? parseInt(params.limit, 10)
|
|
128
|
-
: params.limit;
|
|
129
|
-
if (!isNaN(limit) && limit > 0) {
|
|
130
|
-
results = results.slice(0, limit);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
return results;
|
|
134
|
-
}
|
|
135
|
-
/**
|
|
136
|
-
* Reset the cache
|
|
137
|
-
*/
|
|
138
|
-
resetCache() {
|
|
139
|
-
this.models = {};
|
|
140
|
-
this.lastFetchTime = 0;
|
|
44
|
+
return results.slice(0, params.limit ?? 10);
|
|
141
45
|
}
|
|
142
46
|
}
|
|
143
|
-
//# sourceMappingURL=model-cache.js.map
|
package/dist/openrouter-api.d.ts
CHANGED
|
@@ -1,26 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
* Client for interacting with the OpenRouter API
|
|
3
|
-
*/
|
|
1
|
+
import type { OpenRouterModelRecord } from './model-cache.js';
|
|
4
2
|
export declare class OpenRouterAPIClient {
|
|
5
3
|
private apiKey;
|
|
6
|
-
private axiosInstance;
|
|
7
|
-
private retryCount;
|
|
8
|
-
private retryDelay;
|
|
9
4
|
constructor(apiKey: string);
|
|
10
|
-
|
|
11
|
-
* Get all available models from OpenRouter
|
|
12
|
-
*/
|
|
13
|
-
getModels(): Promise<any[]>;
|
|
14
|
-
/**
|
|
15
|
-
* Send a request to the OpenRouter API with retry functionality
|
|
16
|
-
*/
|
|
17
|
-
request(endpoint: string, method: string, data?: any): Promise<any>;
|
|
18
|
-
/**
|
|
19
|
-
* Handle retryable errors
|
|
20
|
-
*/
|
|
21
|
-
private handleRetryableError;
|
|
22
|
-
/**
|
|
23
|
-
* Handle request errors
|
|
24
|
-
*/
|
|
25
|
-
private handleRequestError;
|
|
5
|
+
getModels(): Promise<OpenRouterModelRecord[]>;
|
|
26
6
|
}
|
package/dist/openrouter-api.js
CHANGED
|
@@ -1,119 +1,42 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
const BASE_URL = 'https://openrouter.ai/api/v1';
|
|
2
|
+
async function sleep(ms) {
|
|
3
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
4
|
+
}
|
|
5
|
+
async function fetchWithRetry(url, init, retries = 2) {
|
|
6
|
+
let lastErr;
|
|
7
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
8
|
+
try {
|
|
9
|
+
const res = await fetch(url, init);
|
|
10
|
+
if (res.status === 429 || res.status >= 500) {
|
|
11
|
+
if (attempt < retries)
|
|
12
|
+
await sleep(400 * (attempt + 1));
|
|
13
|
+
else
|
|
14
|
+
return res;
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
return res;
|
|
18
|
+
}
|
|
19
|
+
catch (e) {
|
|
20
|
+
lastErr = e;
|
|
21
|
+
if (attempt < retries)
|
|
22
|
+
await sleep(400 * (attempt + 1));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
|
|
26
|
+
}
|
|
6
27
|
export class OpenRouterAPIClient {
|
|
7
28
|
apiKey;
|
|
8
|
-
axiosInstance;
|
|
9
|
-
retryCount = 3;
|
|
10
|
-
retryDelay = 1000; // Initial delay in ms
|
|
11
29
|
constructor(apiKey) {
|
|
12
30
|
this.apiKey = apiKey;
|
|
13
|
-
this.axiosInstance = axios.create({
|
|
14
|
-
baseURL: 'https://openrouter.ai/api/v1',
|
|
15
|
-
headers: {
|
|
16
|
-
'Authorization': `Bearer ${this.apiKey}`,
|
|
17
|
-
'Content-Type': 'application/json',
|
|
18
|
-
'HTTP-Referer': 'https://github.com/stabgan/openrouter-mcp-multimodal',
|
|
19
|
-
'X-Title': 'OpenRouter MCP Multimodal Server'
|
|
20
|
-
},
|
|
21
|
-
timeout: 60000 // 60 seconds timeout
|
|
22
|
-
});
|
|
23
31
|
}
|
|
24
|
-
/**
|
|
25
|
-
* Get all available models from OpenRouter
|
|
26
|
-
*/
|
|
27
32
|
async getModels() {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
/**
|
|
38
|
-
* Send a request to the OpenRouter API with retry functionality
|
|
39
|
-
*/
|
|
40
|
-
async request(endpoint, method, data) {
|
|
41
|
-
let lastError = null;
|
|
42
|
-
let retries = 0;
|
|
43
|
-
while (retries <= this.retryCount) {
|
|
44
|
-
try {
|
|
45
|
-
const response = await this.axiosInstance.request({
|
|
46
|
-
url: endpoint,
|
|
47
|
-
method,
|
|
48
|
-
data
|
|
49
|
-
});
|
|
50
|
-
return response.data;
|
|
51
|
-
}
|
|
52
|
-
catch (error) {
|
|
53
|
-
lastError = this.handleRetryableError(error, retries);
|
|
54
|
-
retries++;
|
|
55
|
-
if (retries <= this.retryCount) {
|
|
56
|
-
// Exponential backoff with jitter
|
|
57
|
-
const delay = this.retryDelay * Math.pow(2, retries - 1) * (0.5 + Math.random() * 0.5);
|
|
58
|
-
console.error(`Retrying in ${Math.round(delay)}ms (${retries}/${this.retryCount})`);
|
|
59
|
-
await new Promise(resolve => setTimeout(resolve, delay));
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
// If we get here, all retries failed
|
|
64
|
-
throw lastError || new Error('Request failed after multiple retries');
|
|
65
|
-
}
|
|
66
|
-
/**
|
|
67
|
-
* Handle retryable errors
|
|
68
|
-
*/
|
|
69
|
-
handleRetryableError(error, retryCount) {
|
|
70
|
-
if (axios.isAxiosError(error)) {
|
|
71
|
-
const axiosError = error;
|
|
72
|
-
// Rate limiting (429) or server errors (5xx)
|
|
73
|
-
if (axiosError.response?.status === 429 || (axiosError.response?.status && axiosError.response.status >= 500)) {
|
|
74
|
-
console.error(`Request error (retry ${retryCount}): ${axiosError.message}`);
|
|
75
|
-
if (axiosError.response?.status === 429) {
|
|
76
|
-
console.error('Rate limit exceeded. Retrying with backoff...');
|
|
77
|
-
}
|
|
78
|
-
return new Error(`OpenRouter API error: ${axiosError.response?.status} ${axiosError.message}`);
|
|
79
|
-
}
|
|
80
|
-
// For other status codes, don't retry
|
|
81
|
-
if (axiosError.response) {
|
|
82
|
-
const responseData = axiosError.response.data;
|
|
83
|
-
const message = responseData?.error?.message || axiosError.message;
|
|
84
|
-
throw new McpError(ErrorCode.InternalError, `OpenRouter API error: ${message}`);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
// Network errors should be retried
|
|
88
|
-
console.error(`Network error (retry ${retryCount}): ${error.message}`);
|
|
89
|
-
return new Error(`Network error: ${error.message}`);
|
|
90
|
-
}
|
|
91
|
-
/**
|
|
92
|
-
* Handle request errors
|
|
93
|
-
*/
|
|
94
|
-
handleRequestError(error) {
|
|
95
|
-
console.error('Error in OpenRouter API request:', error);
|
|
96
|
-
if (axios.isAxiosError(error)) {
|
|
97
|
-
const axiosError = error;
|
|
98
|
-
if (axiosError.response) {
|
|
99
|
-
const status = axiosError.response.status;
|
|
100
|
-
const responseData = axiosError.response.data;
|
|
101
|
-
const message = responseData?.error?.message || axiosError.message;
|
|
102
|
-
if (status === 401 || status === 403) {
|
|
103
|
-
throw new McpError(ErrorCode.InvalidRequest, `Authentication error: ${message}`);
|
|
104
|
-
}
|
|
105
|
-
else if (status === 429) {
|
|
106
|
-
throw new McpError(ErrorCode.InternalError, `Rate limit exceeded: ${message}`);
|
|
107
|
-
}
|
|
108
|
-
else {
|
|
109
|
-
throw new McpError(ErrorCode.InternalError, `OpenRouter API error (${status}): ${message}`);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
else if (axiosError.request) {
|
|
113
|
-
throw new McpError(ErrorCode.ConnectionClosed, `Network error: ${axiosError.message}`);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
throw new McpError(ErrorCode.InternalError, `Unknown error: ${error.message || 'No error message'}`);
|
|
33
|
+
const res = await fetchWithRetry(`${BASE_URL}/models`, {
|
|
34
|
+
headers: { Authorization: `Bearer ${this.apiKey}` },
|
|
35
|
+
signal: AbortSignal.timeout(30000),
|
|
36
|
+
}, 2);
|
|
37
|
+
if (!res.ok)
|
|
38
|
+
throw new Error(`Failed to fetch models: HTTP ${res.status}`);
|
|
39
|
+
const data = (await res.json());
|
|
40
|
+
return data.data ?? [];
|
|
117
41
|
}
|
|
118
42
|
}
|
|
119
|
-
//# sourceMappingURL=openrouter-api.js.map
|
|
@@ -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
|
}>;
|