@stabgan/openrouter-mcp-multimodal 4.5.0 → 4.5.3
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/README.md +380 -242
- package/dist/index.js +22 -6
- package/dist/model-cache.d.ts +35 -12
- package/dist/model-cache.js +79 -22
- package/dist/tool-descriptions.d.ts +19 -0
- package/dist/tool-descriptions.js +423 -0
- package/dist/tool-handlers/analyze-audio.js +5 -1
- package/dist/tool-handlers/analyze-image.js +6 -5
- package/dist/tool-handlers/analyze-video.js +6 -5
- package/dist/tool-handlers/audio-utils.js +4 -2
- package/dist/tool-handlers/chat-completion.js +1 -1
- package/dist/tool-handlers/fetch-utils.js +16 -2
- package/dist/tool-handlers/generate-audio.js +2 -4
- package/dist/tool-handlers/generate-image-input.d.ts +3 -0
- package/dist/tool-handlers/generate-image-input.js +38 -0
- package/dist/tool-handlers/generate-image.d.ts +13 -51
- package/dist/tool-handlers/generate-image.js +32 -119
- package/dist/tool-handlers/generate-video.js +28 -24
- package/dist/tool-handlers/health-check.js +4 -1
- package/dist/tool-handlers/image-utils.d.ts +1 -0
- package/dist/tool-handlers/image-utils.js +26 -16
- package/dist/tool-handlers/openrouter-errors.d.ts +5 -1
- package/dist/tool-handlers/openrouter-errors.js +78 -13
- package/dist/tool-handlers/provider-routing.js +7 -2
- package/dist/tool-handlers/rerank.js +2 -5
- package/dist/tool-handlers/search-models.d.ts +2 -2
- package/dist/tool-handlers/search-models.js +2 -6
- package/dist/tool-handlers/structured-output.d.ts +8 -0
- package/dist/tool-handlers/structured-output.js +11 -0
- package/dist/tool-handlers/video-utils.js +6 -9
- package/dist/tool-handlers.js +43 -123
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +26 -14
|
@@ -5,6 +5,29 @@
|
|
|
5
5
|
* don't drift.
|
|
6
6
|
*/
|
|
7
7
|
import { ErrorCode, toolError } from '../errors.js';
|
|
8
|
+
function extractRetryAfterSeconds(err) {
|
|
9
|
+
if (typeof err !== 'object' || err === null)
|
|
10
|
+
return undefined;
|
|
11
|
+
const e = err;
|
|
12
|
+
const getHeader = (h) => {
|
|
13
|
+
if (!h)
|
|
14
|
+
return null;
|
|
15
|
+
if (typeof h === 'object' && typeof h.get === 'function') {
|
|
16
|
+
return h.get('retry-after') ?? null;
|
|
17
|
+
}
|
|
18
|
+
const rec = h;
|
|
19
|
+
return rec['retry-after'] ?? rec['Retry-After'] ?? null;
|
|
20
|
+
};
|
|
21
|
+
const raw = getHeader(e.headers) ?? getHeader(e.response?.headers);
|
|
22
|
+
if (!raw)
|
|
23
|
+
return undefined;
|
|
24
|
+
const n = Number(raw);
|
|
25
|
+
if (Number.isFinite(n) && n >= 0)
|
|
26
|
+
return n;
|
|
27
|
+
// Retry-After can also be an HTTP-date; return undefined for those (caller
|
|
28
|
+
// can still retry on its own backoff schedule).
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
8
31
|
function extractStatus(err) {
|
|
9
32
|
if (typeof err !== 'object' || err === null)
|
|
10
33
|
return undefined;
|
|
@@ -49,43 +72,82 @@ function extractMessage(err) {
|
|
|
49
72
|
* 2. Message heuristics for common OpenRouter strings (credits, ZDR,
|
|
50
73
|
* "model does not exist", content policy, etc.).
|
|
51
74
|
* 3. Default to INTERNAL to avoid leaking raw shapes.
|
|
75
|
+
*
|
|
76
|
+
* When the error carries a `Retry-After` header (on 429 / 503) we populate
|
|
77
|
+
* `_meta.retry_after_seconds` so agents can back off intelligently. We
|
|
78
|
+
* also attach canonical `suggestions[]` for common cases.
|
|
52
79
|
*/
|
|
53
|
-
export function classifyUpstreamError(err,
|
|
54
|
-
const
|
|
80
|
+
export function classifyUpstreamError(err, contextMessage) {
|
|
81
|
+
const rawMsg = extractMessage(err);
|
|
55
82
|
const status = extractStatus(err);
|
|
56
|
-
const lower =
|
|
57
|
-
|
|
83
|
+
const lower = rawMsg.toLowerCase();
|
|
84
|
+
// Prefix every user-visible message with the handler context when the
|
|
85
|
+
// caller supplied one (e.g. `rerank`, `generate_video.submit`). Makes
|
|
86
|
+
// server-side triage possible without digging through logs.
|
|
87
|
+
const fullMsg = contextMessage ? `${contextMessage}: ${rawMsg}` : rawMsg;
|
|
88
|
+
const retryAfterSeconds = extractRetryAfterSeconds(err);
|
|
58
89
|
// Explicit credit / balance signals.
|
|
59
90
|
if (lower.includes('insufficient balance') ||
|
|
60
91
|
lower.includes('insufficient credits') ||
|
|
61
92
|
lower.includes('requires more credits') ||
|
|
62
93
|
lower.includes('requires at least') ||
|
|
63
94
|
status === 402) {
|
|
64
|
-
return toolError(ErrorCode.UPSTREAM_REFUSED, fullMsg, { status, reason: 'credits' }
|
|
95
|
+
return toolError(ErrorCode.UPSTREAM_REFUSED, fullMsg, { status, reason: 'credits' }, {
|
|
96
|
+
suggestions: [
|
|
97
|
+
'Top up credits at https://openrouter.ai/settings/credits',
|
|
98
|
+
'Switch to a free-tier model (append :free to the slug)',
|
|
99
|
+
],
|
|
100
|
+
});
|
|
65
101
|
}
|
|
66
102
|
// Zero Data Retention.
|
|
67
103
|
if (lower.includes('zdr') || lower.includes('zero data retention')) {
|
|
68
|
-
return toolError(ErrorCode.ZDR_INCOMPATIBLE, fullMsg, { status }
|
|
104
|
+
return toolError(ErrorCode.ZDR_INCOMPATIBLE, fullMsg, { status }, {
|
|
105
|
+
suggestions: [
|
|
106
|
+
'Pick a provider that supports your ZDR policy',
|
|
107
|
+
'Set provider.data_collection: "allow" to bypass the restriction',
|
|
108
|
+
],
|
|
109
|
+
});
|
|
69
110
|
}
|
|
70
111
|
// Model lookup failures.
|
|
71
112
|
if (lower.includes('model') &&
|
|
72
|
-
(lower.includes('does not exist') ||
|
|
73
|
-
|
|
113
|
+
(lower.includes('does not exist') ||
|
|
114
|
+
lower.includes('not found') ||
|
|
115
|
+
lower.includes('invalid model'))) {
|
|
116
|
+
return toolError(ErrorCode.MODEL_NOT_FOUND, fullMsg, { status }, {
|
|
117
|
+
suggestions: [
|
|
118
|
+
'Use search_models to discover valid model ids',
|
|
119
|
+
'Use validate_model to pre-flight a model id',
|
|
120
|
+
],
|
|
121
|
+
});
|
|
74
122
|
}
|
|
75
123
|
// Content policy / moderation — surface as UPSTREAM_REFUSED so callers can distinguish from 5xx.
|
|
76
|
-
if (lower.includes('content policy') ||
|
|
77
|
-
|
|
124
|
+
if (lower.includes('content policy') ||
|
|
125
|
+
lower.includes('moderation') ||
|
|
126
|
+
lower.includes('refused')) {
|
|
127
|
+
return toolError(ErrorCode.UPSTREAM_REFUSED, fullMsg, { status, reason: 'policy' }, {
|
|
128
|
+
suggestions: ['Rephrase the prompt', 'Try a different provider via provider.order'],
|
|
129
|
+
});
|
|
78
130
|
}
|
|
79
131
|
// Rate-limit specific.
|
|
80
132
|
if (status === 429 || lower.includes('rate limit')) {
|
|
81
|
-
return toolError(ErrorCode.UPSTREAM_REFUSED, fullMsg, { status, reason: 'rate_limit' }
|
|
133
|
+
return toolError(ErrorCode.UPSTREAM_REFUSED, fullMsg, { status, reason: 'rate_limit' }, {
|
|
134
|
+
suggestions: [
|
|
135
|
+
retryAfterSeconds !== undefined
|
|
136
|
+
? `Wait ${retryAfterSeconds}s and retry`
|
|
137
|
+
: 'Wait and retry with exponential backoff',
|
|
138
|
+
'Append :nitro to the model slug to route to a faster provider',
|
|
139
|
+
],
|
|
140
|
+
retry_after_seconds: retryAfterSeconds,
|
|
141
|
+
});
|
|
82
142
|
}
|
|
83
143
|
// Timeouts (AbortError from `AbortSignal.timeout`).
|
|
84
144
|
if (lower.includes('timed out') ||
|
|
85
145
|
lower.includes('timeout') ||
|
|
86
146
|
lower.includes('aborted') ||
|
|
87
147
|
(err instanceof Error && err.name === 'AbortError')) {
|
|
88
|
-
return toolError(ErrorCode.UPSTREAM_TIMEOUT, fullMsg, { status }
|
|
148
|
+
return toolError(ErrorCode.UPSTREAM_TIMEOUT, fullMsg, { status }, {
|
|
149
|
+
suggestions: ['Retry', 'Raise max_wait_ms or max_tokens'],
|
|
150
|
+
});
|
|
89
151
|
}
|
|
90
152
|
// Anything in the 4xx band that isn't covered above — user supplied a bad request.
|
|
91
153
|
if (typeof status === 'number' && status >= 400 && status < 500) {
|
|
@@ -93,7 +155,10 @@ export function classifyUpstreamError(err, _contextMessage) {
|
|
|
93
155
|
}
|
|
94
156
|
// 5xx / network errors.
|
|
95
157
|
if (typeof status === 'number' && status >= 500) {
|
|
96
|
-
return toolError(ErrorCode.UPSTREAM_HTTP, fullMsg, { status }
|
|
158
|
+
return toolError(ErrorCode.UPSTREAM_HTTP, fullMsg, { status }, {
|
|
159
|
+
suggestions: ['Retry after a brief delay', 'Check https://status.openrouter.ai'],
|
|
160
|
+
retry_after_seconds: retryAfterSeconds,
|
|
161
|
+
});
|
|
97
162
|
}
|
|
98
163
|
return toolError(ErrorCode.UPSTREAM_HTTP, fullMsg);
|
|
99
164
|
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* Precedence: explicit tool arg > env var > unset. Empty arrays / empty
|
|
8
8
|
* objects are dropped so we don't send noise to the API.
|
|
9
9
|
*/
|
|
10
|
+
import { logger } from '../logger.js';
|
|
10
11
|
function parseCsv(raw) {
|
|
11
12
|
if (!raw)
|
|
12
13
|
return undefined;
|
|
@@ -51,7 +52,9 @@ function parseSort(raw) {
|
|
|
51
52
|
if (!raw)
|
|
52
53
|
return undefined;
|
|
53
54
|
const lc = raw.trim().toLowerCase();
|
|
54
|
-
return lc === 'price' || lc === 'throughput' || lc === 'latency'
|
|
55
|
+
return lc === 'price' || lc === 'throughput' || lc === 'latency'
|
|
56
|
+
? lc
|
|
57
|
+
: undefined;
|
|
55
58
|
}
|
|
56
59
|
function parseDataCollection(raw) {
|
|
57
60
|
if (!raw)
|
|
@@ -85,7 +88,9 @@ export function readProviderDefaults() {
|
|
|
85
88
|
// operator notices instead of wondering why their ordering is being
|
|
86
89
|
// ignored. All other OPENROUTER_PROVIDER_* fields follow the same
|
|
87
90
|
// "silent drop" policy for consistency.
|
|
88
|
-
|
|
91
|
+
logger.warn('OPENROUTER_PROVIDER_ORDER ignored', {
|
|
92
|
+
err: err instanceof Error ? err.message : String(err),
|
|
93
|
+
});
|
|
89
94
|
}
|
|
90
95
|
const requireParams = parseBool(env.OPENROUTER_PROVIDER_REQUIRE_PARAMETERS);
|
|
91
96
|
if (requireParams !== undefined)
|
|
@@ -3,8 +3,7 @@ import { classifyUpstreamError } from './openrouter-errors.js';
|
|
|
3
3
|
import { buildStructuredResult } from './structured-output.js';
|
|
4
4
|
const DEFAULT_MODEL = 'cohere/rerank-english-v3.0';
|
|
5
5
|
export async function handleRerankDocuments(request, apiClient) {
|
|
6
|
-
const args = request.params.arguments ??
|
|
7
|
-
{ query: '', documents: [] };
|
|
6
|
+
const args = request.params.arguments ?? { query: '', documents: [] };
|
|
8
7
|
const { query, documents, model, top_n, return_documents } = args;
|
|
9
8
|
if (!query?.trim()) {
|
|
10
9
|
return toolError(ErrorCode.INVALID_INPUT, 'query is required.');
|
|
@@ -33,9 +32,7 @@ export async function handleRerankDocuments(request, apiClient) {
|
|
|
33
32
|
const score = typeof r.score === 'number' ? r.score : r.relevance_score;
|
|
34
33
|
const out = { index: r.index, score };
|
|
35
34
|
if (return_documents) {
|
|
36
|
-
const doc = typeof r.document === 'string'
|
|
37
|
-
? r.document
|
|
38
|
-
: r.document?.text ?? documents[r.index];
|
|
35
|
+
const doc = typeof r.document === 'string' ? r.document : (r.document?.text ?? documents[r.index]);
|
|
39
36
|
out.document = doc;
|
|
40
37
|
}
|
|
41
38
|
return out;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ModelCache
|
|
1
|
+
import { ModelCache } from '../model-cache.js';
|
|
2
2
|
import { OpenRouterAPIClient } from '../openrouter-api.js';
|
|
3
3
|
export interface SearchModelsArgs {
|
|
4
4
|
query?: string;
|
|
@@ -22,7 +22,7 @@ export declare function handleSearchModels(request: {
|
|
|
22
22
|
arguments: SearchModelsArgs;
|
|
23
23
|
};
|
|
24
24
|
}, apiClient: OpenRouterAPIClient, modelCache: ModelCache): Promise<import("../errors.js").ToolErrorResult | import("./structured-output.js").StructuredResult<{
|
|
25
|
-
results: OpenRouterModelRecord[];
|
|
25
|
+
results: import("../model-cache.js").OpenRouterModelRecord[];
|
|
26
26
|
offset: number;
|
|
27
27
|
limit: number;
|
|
28
28
|
total: number;
|
|
@@ -14,15 +14,11 @@ export async function handleSearchModels(request, apiClient, modelCache) {
|
|
|
14
14
|
const args = request.params.arguments ?? {};
|
|
15
15
|
const limit = Math.min(Math.max(1, args.limit ?? DEFAULT_LIMIT), MAX_LIMIT);
|
|
16
16
|
const offset = Math.max(0, args.offset ?? 0);
|
|
17
|
-
|
|
18
|
-
const all = modelCache.search({
|
|
17
|
+
const { page, total } = modelCache.searchPaginated({
|
|
19
18
|
query: args.query,
|
|
20
19
|
provider: args.provider,
|
|
21
20
|
capabilities: args.capabilities,
|
|
22
|
-
|
|
23
|
-
});
|
|
24
|
-
const total = all.length;
|
|
25
|
-
const page = all.slice(offset, offset + limit);
|
|
21
|
+
}, offset, limit);
|
|
26
22
|
const nextOffset = offset + limit;
|
|
27
23
|
const hasMore = nextOffset < total;
|
|
28
24
|
return buildStructuredResult({
|
|
@@ -11,3 +11,11 @@ export interface StructuredResult<T = unknown> {
|
|
|
11
11
|
* format. `meta` is merged on top of the default `server_version` stamp.
|
|
12
12
|
*/
|
|
13
13
|
export declare function buildStructuredResult<T>(data: T, meta?: Record<string, unknown>): StructuredResult<T>;
|
|
14
|
+
/** Read typed JSON from an MCP tool result (structuredContent or legacy text). */
|
|
15
|
+
export declare function readToolPayload<T = unknown>(result: {
|
|
16
|
+
structuredContent?: T;
|
|
17
|
+
content?: Array<{
|
|
18
|
+
type: string;
|
|
19
|
+
text?: string;
|
|
20
|
+
}>;
|
|
21
|
+
}): T;
|
|
@@ -22,3 +22,14 @@ export function buildStructuredResult(data, meta = {}) {
|
|
|
22
22
|
_meta: { server_version: SERVER_VERSION, ...meta },
|
|
23
23
|
};
|
|
24
24
|
}
|
|
25
|
+
/** Read typed JSON from an MCP tool result (structuredContent or legacy text). */
|
|
26
|
+
export function readToolPayload(result) {
|
|
27
|
+
if (result.structuredContent !== undefined) {
|
|
28
|
+
return result.structuredContent;
|
|
29
|
+
}
|
|
30
|
+
const text = result.content?.[0]?.text;
|
|
31
|
+
if (text === undefined) {
|
|
32
|
+
throw new Error('tool result has no structuredContent or content text');
|
|
33
|
+
}
|
|
34
|
+
return JSON.parse(text);
|
|
35
|
+
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import { promises as fs } from 'node:fs';
|
|
11
11
|
import { readEnvInt, fetchHttpResource, parseBase64DataUrl } from './fetch-utils.js';
|
|
12
|
+
import { resolveSafeInputPath } from './path-safety.js';
|
|
12
13
|
export { isBlockedIPv4, assertUrlSafeForFetch } from './fetch-utils.js';
|
|
13
14
|
const DEFAULT_FETCH_TIMEOUT_MS = 60_000;
|
|
14
15
|
const DEFAULT_MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024; // 100 MB
|
|
@@ -93,10 +94,7 @@ export function detectVideoFormat(buffer) {
|
|
|
93
94
|
}
|
|
94
95
|
if (buffer.length >= 4) {
|
|
95
96
|
// EBML header — WebM & Matroska.
|
|
96
|
-
if (buffer[0] === 0x1a &&
|
|
97
|
-
buffer[1] === 0x45 &&
|
|
98
|
-
buffer[2] === 0xdf &&
|
|
99
|
-
buffer[3] === 0xa3) {
|
|
97
|
+
if (buffer[0] === 0x1a && buffer[1] === 0x45 && buffer[2] === 0xdf && buffer[3] === 0xa3) {
|
|
100
98
|
return 'webm';
|
|
101
99
|
}
|
|
102
100
|
// MPEG-PS / MPEG-TS start codes.
|
|
@@ -146,9 +144,7 @@ export async function prepareVideoData(source) {
|
|
|
146
144
|
maxRedirects: getMaxRedirects(),
|
|
147
145
|
});
|
|
148
146
|
const urlPath = new URL(source).pathname;
|
|
149
|
-
const format = detectVideoFormat(buffer) ??
|
|
150
|
-
getVideoFormat(urlPath) ??
|
|
151
|
-
formatFromContentType(contentType);
|
|
147
|
+
const format = detectVideoFormat(buffer) ?? getVideoFormat(urlPath) ?? formatFromContentType(contentType);
|
|
152
148
|
if (!format) {
|
|
153
149
|
throw new Error(`Could not determine video format from ${source}. Supported: ${SUPPORTED_VIDEO_FORMATS.join(', ')}`);
|
|
154
150
|
}
|
|
@@ -160,8 +156,9 @@ export async function prepareVideoData(source) {
|
|
|
160
156
|
};
|
|
161
157
|
}
|
|
162
158
|
// --- local file ---
|
|
163
|
-
const
|
|
164
|
-
const
|
|
159
|
+
const safe = await resolveSafeInputPath(source);
|
|
160
|
+
const buffer = await fs.readFile(safe);
|
|
161
|
+
const format = detectVideoFormat(buffer) ?? getVideoFormat(safe);
|
|
165
162
|
if (!format) {
|
|
166
163
|
throw new Error(`Unsupported video format for file: ${source}. Supported: ${SUPPORTED_VIDEO_FORMATS.join(', ')}`);
|
|
167
164
|
}
|
package/dist/tool-handlers.js
CHANGED
|
@@ -14,141 +14,45 @@ import { handleAnalyzeVideo } from './tool-handlers/analyze-video.js';
|
|
|
14
14
|
import { handleGenerateVideo, handleGetVideoStatus, handleGenerateVideoFromImage, } from './tool-handlers/generate-video.js';
|
|
15
15
|
import { handleRerankDocuments } from './tool-handlers/rerank.js';
|
|
16
16
|
import { handleHealthCheck } from './tool-handlers/health-check.js';
|
|
17
|
+
import { TOOL_DESCRIPTIONS } from './tool-descriptions.js';
|
|
17
18
|
function wrapToolArgs(a) {
|
|
18
19
|
return { params: { arguments: a ?? {} } };
|
|
19
20
|
}
|
|
20
21
|
function buildProgressHook(server, progressToken) {
|
|
21
22
|
if (progressToken === undefined)
|
|
22
23
|
return undefined;
|
|
24
|
+
// MCP `notifications/progress` REQUIRES `progress` to be strictly
|
|
25
|
+
// monotonically increasing within a single progressToken. OpenRouter
|
|
26
|
+
// returns `progress: 0..100` on some ticks and omits it on others, so
|
|
27
|
+
// we anchor on a per-hook attempt counter and use the upstream number
|
|
28
|
+
// only as an informational `message`. This guarantees monotonicity
|
|
29
|
+
// regardless of what the upstream does (drops, duplicates, decreases).
|
|
30
|
+
//
|
|
31
|
+
// See MCP spec 2025-06-18 utilities/progress §Behavior Requirements:
|
|
32
|
+
// "The progress value MUST increase with each notification, even if
|
|
33
|
+
// the total is unknown."
|
|
34
|
+
let lastSent = -1;
|
|
23
35
|
return ({ status, progress, attempt, video_id }) => {
|
|
36
|
+
// Always monotonic: at least attempt+1 (so initial attempt=0 → 0 stays
|
|
37
|
+
// reserved for the 'submitted' ping). If upstream has a real numeric
|
|
38
|
+
// progress that's higher than our counter, adopt that.
|
|
39
|
+
const candidate = typeof progress === 'number' ? Math.max(attempt, progress) : attempt;
|
|
40
|
+
const next = Math.max(lastSent + 1, candidate);
|
|
41
|
+
lastSent = next;
|
|
24
42
|
void server.notification({
|
|
25
43
|
method: 'notifications/progress',
|
|
26
44
|
params: {
|
|
27
45
|
progressToken,
|
|
28
|
-
|
|
29
|
-
// monotonic counter when the upstream doesn't return a numeric
|
|
30
|
-
// progress value.
|
|
31
|
-
progress: typeof progress === 'number' ? progress : attempt,
|
|
32
|
-
...(typeof progress === 'number' ? { total: 100 } : {}),
|
|
46
|
+
progress: next,
|
|
33
47
|
message: `video ${video_id} — ${status}${typeof progress === 'number' ? ` (${progress}%)` : ''}`,
|
|
34
48
|
},
|
|
35
49
|
});
|
|
36
50
|
};
|
|
37
51
|
}
|
|
38
52
|
function extractProgressToken(req) {
|
|
39
|
-
const meta = req?.params
|
|
40
|
-
?._meta;
|
|
53
|
+
const meta = req?.params?._meta;
|
|
41
54
|
return meta?.progressToken;
|
|
42
55
|
}
|
|
43
|
-
// ---------------------------------------------------------------------------
|
|
44
|
-
// Tool descriptions include explicit "Fails when" and "Works with" sections
|
|
45
|
-
// per arxiv 2602.18764 (Schema-Guided Dialogue / MCP convergence). Explicit
|
|
46
|
-
// failure-mode documentation reduces misrouted calls and helps the model
|
|
47
|
-
// pick the right recovery path after an error.
|
|
48
|
-
const TOOL_DESCRIPTIONS = {
|
|
49
|
-
chat_completion: 'Send messages to an OpenRouter model and get a text response. Supports provider routing ' +
|
|
50
|
-
'(quantizations / ignore / sort / order / require_parameters / data_collection / allow_fallbacks), ' +
|
|
51
|
-
'model variant suffixes (`:nitro` fastest, `:floor` cheapest, `:exacto` tool-calling accuracy), ' +
|
|
52
|
-
'reasoning token passthrough, web search, and response caching.\n\n' +
|
|
53
|
-
'Fails when:\n' +
|
|
54
|
-
'- INVALID_INPUT: messages array is empty\n' +
|
|
55
|
-
'- UPSTREAM_REFUSED: provider rejected the request (credits, content policy, or rate limit)\n' +
|
|
56
|
-
'- UPSTREAM_TIMEOUT: upstream did not respond within the SDK timeout\n' +
|
|
57
|
-
'- MODEL_NOT_FOUND: model slug does not exist on OpenRouter\n\n' +
|
|
58
|
-
'Works with: validate_model (pre-flight model id check), search_models (discover models).',
|
|
59
|
-
analyze_image: 'Analyze an image with a vision model. Accepts local file paths, http(s) URLs, or base64 data URLs. ' +
|
|
60
|
-
'Output is model-generated and tagged `_meta.content_is_untrusted: true`.\n\n' +
|
|
61
|
-
'Fails when:\n' +
|
|
62
|
-
'- INVALID_INPUT: image_path missing or malformed\n' +
|
|
63
|
-
'- UNSAFE_PATH: local path escaped the sandbox\n' +
|
|
64
|
-
'- RESOURCE_TOO_LARGE: image exceeded configured fetch size cap\n' +
|
|
65
|
-
'- UPSTREAM_REFUSED: provider SSRF guard blocked the URL or content policy rejected\n\n' +
|
|
66
|
-
'Works with: search_models (find vision-capable models), generate_image (follow-up creation).',
|
|
67
|
-
analyze_audio: 'Transcribe or analyze an audio file (WAV / MP3 / FLAC / OGG / etc.) using a multimodal model. ' +
|
|
68
|
-
'Output is tagged `_meta.content_is_untrusted: true`.\n\n' +
|
|
69
|
-
'Fails when:\n' +
|
|
70
|
-
'- INVALID_INPUT: audio_path missing\n' +
|
|
71
|
-
'- UNSUPPORTED_FORMAT: decoder could not identify the file as audio\n' +
|
|
72
|
-
'- RESOURCE_TOO_LARGE: input exceeded size cap\n' +
|
|
73
|
-
'- UPSTREAM_REFUSED: blocked host or content policy\n\n' +
|
|
74
|
-
'Works with: generate_audio (text-to-speech follow-up).',
|
|
75
|
-
analyze_video: 'Describe or analyze a video (mp4 / mpeg / mov / webm) using a multimodal model. Default model: ' +
|
|
76
|
-
'google/gemini-2.5-flash. Output is tagged `_meta.content_is_untrusted: true`.\n\n' +
|
|
77
|
-
'Fails when:\n' +
|
|
78
|
-
'- INVALID_INPUT: video_path missing\n' +
|
|
79
|
-
'- UNSUPPORTED_FORMAT: not a recognized video container\n' +
|
|
80
|
-
'- RESOURCE_TOO_LARGE: exceeds fetch cap\n' +
|
|
81
|
-
'- UPSTREAM_REFUSED: SSRF block or provider refusal\n\n' +
|
|
82
|
-
'Works with: generate_video (text-to-video), get_video_status (poll async jobs).',
|
|
83
|
-
search_models: 'Search OpenRouter\'s model catalog by name, provider, or capability. Returns a paginated list; ' +
|
|
84
|
-
'use `offset` / `limit` / `next_offset` to page through.\n\n' +
|
|
85
|
-
'Fails when:\n' +
|
|
86
|
-
'- UPSTREAM_HTTP: /models endpoint returned an error\n' +
|
|
87
|
-
'- UPSTREAM_REFUSED: invalid API key\n\n' +
|
|
88
|
-
'Works with: validate_model, get_model_info.',
|
|
89
|
-
get_model_info: 'Get pricing / context-length / capability details for a specific model id.\n\n' +
|
|
90
|
-
'Fails when:\n' +
|
|
91
|
-
'- INVALID_INPUT: model not provided\n' +
|
|
92
|
-
'- MODEL_NOT_FOUND: model slug does not exist\n' +
|
|
93
|
-
'- UPSTREAM_HTTP: model list fetch failed\n\n' +
|
|
94
|
-
'Works with: search_models (discover ids), validate_model (cheap existence check).',
|
|
95
|
-
validate_model: 'Check whether a model id exists on OpenRouter. Cheap boolean lookup against the cached catalog.\n\n' +
|
|
96
|
-
'Fails when:\n' +
|
|
97
|
-
'- INVALID_INPUT: model not provided\n' +
|
|
98
|
-
'- UPSTREAM_HTTP: catalog refresh failed\n\n' +
|
|
99
|
-
'Works with: get_model_info (detailed lookup), chat_completion (pre-flight validation).',
|
|
100
|
-
generate_image: 'Generate an image from a text prompt. Optional reference images condition the output for style / ' +
|
|
101
|
-
'identity consistency. Default model: google/gemini-2.5-flash-image.\n\n' +
|
|
102
|
-
'Fails when:\n' +
|
|
103
|
-
'- INVALID_INPUT: prompt empty, bad aspect_ratio / image_size, unreadable reference image\n' +
|
|
104
|
-
'- UNSAFE_PATH: save_path or input_images path escaped the sandbox\n' +
|
|
105
|
-
'- UPSTREAM_REFUSED: provider content policy rejected or insufficient credits\n' +
|
|
106
|
-
'- MODEL_NOT_FOUND: model slug invalid\n\n' +
|
|
107
|
-
'Works with: analyze_image (verify the result), generate_video_from_image (next step in workflow).',
|
|
108
|
-
generate_audio: 'Generate audio (speech / music) from a text prompt. Format auto-detected, extension auto-corrected.\n\n' +
|
|
109
|
-
'Fails when:\n' +
|
|
110
|
-
'- INVALID_INPUT: prompt empty\n' +
|
|
111
|
-
'- UNSAFE_PATH: save_path escaped the sandbox\n' +
|
|
112
|
-
'- UPSTREAM_REFUSED: content policy or credit issues\n\n' +
|
|
113
|
-
'Works with: analyze_audio (verify the result).',
|
|
114
|
-
generate_video: 'Generate a video from a text prompt (optionally conditioned on first/last-frame or reference images). ' +
|
|
115
|
-
'Submits an async job, polls until completion or max_wait_ms, and downloads the result. Emits MCP ' +
|
|
116
|
-
'progress notifications when the client provides a `progressToken`. Default model: google/veo-3.1.\n\n' +
|
|
117
|
-
'Fails when:\n' +
|
|
118
|
-
'- INVALID_INPUT: prompt empty\n' +
|
|
119
|
-
'- UNSAFE_PATH: save_path or reference image paths escaped the sandbox\n' +
|
|
120
|
-
'- UPSTREAM_REFUSED: content policy, credits, or bad request\n' +
|
|
121
|
-
'- JOB_FAILED: provider marked the job as failed\n' +
|
|
122
|
-
'- JOB_STILL_RUNNING: exceeded max_wait_ms (response carries the video_id to resume)\n' +
|
|
123
|
-
'- UNSUPPORTED_FORMAT: reference/frame image could not be decoded\n\n' +
|
|
124
|
-
'Works with: get_video_status (resume timed-out jobs), generate_video_from_image (narrower image-to-video variant).',
|
|
125
|
-
generate_video_from_image: 'Narrower convenience wrapper around generate_video for image-to-video workflows. Takes a single ' +
|
|
126
|
-
'`image` argument (used as the first frame) and `prompt`. Per arxiv 2511.03497, narrower tools with ' +
|
|
127
|
-
'fewer parameters improve tool-call hit rate.\n\n' +
|
|
128
|
-
'Fails when:\n' +
|
|
129
|
-
'- INVALID_INPUT: image or prompt missing\n' +
|
|
130
|
-
'- UNSAFE_PATH: image path escaped the sandbox\n' +
|
|
131
|
-
'- UPSTREAM_REFUSED / JOB_FAILED / JOB_STILL_RUNNING: same as generate_video\n\n' +
|
|
132
|
-
'Works with: generate_video (full parameter surface), get_video_status.',
|
|
133
|
-
get_video_status: 'Poll an async video-generation job by id. Downloads the result when complete (and saves if save_path given).\n\n' +
|
|
134
|
-
'Fails when:\n' +
|
|
135
|
-
'- INVALID_INPUT: video_id missing\n' +
|
|
136
|
-
'- UNSAFE_PATH: save_path escaped the sandbox\n' +
|
|
137
|
-
'- JOB_FAILED: provider marked the job as failed\n' +
|
|
138
|
-
'- JOB_STILL_RUNNING: job not yet complete (carries `_meta.last_status` + `progress`)\n\n' +
|
|
139
|
-
'Works with: generate_video, generate_video_from_image.',
|
|
140
|
-
rerank_documents: 'Re-order a list of documents by relevance to a query using an OpenRouter reranker. Default model: ' +
|
|
141
|
-
'cohere/rerank-english-v3.0.\n\n' +
|
|
142
|
-
'Fails when:\n' +
|
|
143
|
-
'- INVALID_INPUT: query missing, documents empty, non-string document elements\n' +
|
|
144
|
-
'- MODEL_NOT_FOUND: reranker model slug does not exist\n' +
|
|
145
|
-
'- UPSTREAM_HTTP: provider returned an error\n\n' +
|
|
146
|
-
'Works with: search_models (discover rerankers), chat_completion (answer grounded in top-ranked docs).',
|
|
147
|
-
health_check: 'Verify API-key validity, OpenRouter reachability, and return server + protocol versions. No args.\n\n' +
|
|
148
|
-
'Fails when: never returns an error result — always returns `{ ok, api_key_valid, ... }` so ops can ' +
|
|
149
|
-
'programmatically branch on the payload.\n\n' +
|
|
150
|
-
'Works with: every other tool (run once at startup to confirm credentials).',
|
|
151
|
-
};
|
|
152
56
|
export class ToolHandlers {
|
|
153
57
|
openai;
|
|
154
58
|
modelCache = ModelCache.getInstance();
|
|
@@ -234,11 +138,11 @@ export class ToolHandlers {
|
|
|
234
138
|
},
|
|
235
139
|
include_reasoning: {
|
|
236
140
|
type: 'boolean',
|
|
237
|
-
description:
|
|
141
|
+
description: "Surface the model's chain-of-thought on `_meta.reasoning` for R1 / Opus 4.7 / Gemini Thinking.",
|
|
238
142
|
},
|
|
239
143
|
online: {
|
|
240
144
|
type: 'boolean',
|
|
241
|
-
description:
|
|
145
|
+
description: "Enable OpenRouter's web-search plugin (Exa-backed, $4 / 1000 results).",
|
|
242
146
|
},
|
|
243
147
|
web_max_results: {
|
|
244
148
|
type: 'number',
|
|
@@ -275,8 +179,16 @@ export class ToolHandlers {
|
|
|
275
179
|
inputSchema: {
|
|
276
180
|
type: 'object',
|
|
277
181
|
properties: {
|
|
278
|
-
image_path: {
|
|
279
|
-
|
|
182
|
+
image_path: {
|
|
183
|
+
type: 'string',
|
|
184
|
+
description: 'Required. Local path (inside OPENROUTER_INPUT_DIR sandbox), https URL, or data URL. ' +
|
|
185
|
+
'Good: `"photo.jpg"`. Bad: `"url": "..."` (wrong key), `"/etc/passwd"` (UNSAFE_PATH).',
|
|
186
|
+
},
|
|
187
|
+
question: {
|
|
188
|
+
type: 'string',
|
|
189
|
+
description: 'Optional question about the image. Defaults to "What\'s in this image?" if omitted. ' +
|
|
190
|
+
'Good: `"List all text"`. Bad: using `prompt` key (wrong name for this tool).',
|
|
191
|
+
},
|
|
280
192
|
model: { type: 'string' },
|
|
281
193
|
cache_input: {
|
|
282
194
|
type: 'boolean',
|
|
@@ -305,7 +217,8 @@ export class ToolHandlers {
|
|
|
305
217
|
properties: {
|
|
306
218
|
audio_path: {
|
|
307
219
|
type: 'string',
|
|
308
|
-
description: '
|
|
220
|
+
description: 'Local file path (sandboxed to OPENROUTER_INPUT_DIR / OPENROUTER_OUTPUT_DIR / cwd), ' +
|
|
221
|
+
'http(s) URL, or data URL (base64-encoded audio)',
|
|
309
222
|
},
|
|
310
223
|
question: {
|
|
311
224
|
type: 'string',
|
|
@@ -335,7 +248,8 @@ export class ToolHandlers {
|
|
|
335
248
|
properties: {
|
|
336
249
|
video_path: {
|
|
337
250
|
type: 'string',
|
|
338
|
-
description: '
|
|
251
|
+
description: 'Local file path (sandboxed to OPENROUTER_INPUT_DIR / OPENROUTER_OUTPUT_DIR / cwd), ' +
|
|
252
|
+
'http(s) URL, or base64 data URL. Supported: mp4 / mpeg / mov / webm.',
|
|
339
253
|
},
|
|
340
254
|
question: { type: 'string' },
|
|
341
255
|
model: { type: 'string' },
|
|
@@ -643,7 +557,13 @@ export class ToolHandlers {
|
|
|
643
557
|
models_cached: { type: 'number' },
|
|
644
558
|
error: { type: 'string' },
|
|
645
559
|
},
|
|
646
|
-
required: [
|
|
560
|
+
required: [
|
|
561
|
+
'ok',
|
|
562
|
+
'server_version',
|
|
563
|
+
'protocol_version',
|
|
564
|
+
'api_key_valid',
|
|
565
|
+
'models_cached',
|
|
566
|
+
],
|
|
647
567
|
},
|
|
648
568
|
},
|
|
649
569
|
],
|
package/dist/version.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Bumped in lockstep with package.json / server.json / smithery.yaml /
|
|
7
7
|
* scripts/build-manifest.mjs during release prep.
|
|
8
8
|
*/
|
|
9
|
-
export declare const SERVER_VERSION = "4.5.
|
|
9
|
+
export declare const SERVER_VERSION = "4.5.3";
|
|
10
10
|
/**
|
|
11
11
|
* MCP protocol version our SDK speaks. Hardcoded to match the version
|
|
12
12
|
* bundled with `@modelcontextprotocol/sdk`; update when upgrading the
|
package/dist/version.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Bumped in lockstep with package.json / server.json / smithery.yaml /
|
|
7
7
|
* scripts/build-manifest.mjs during release prep.
|
|
8
8
|
*/
|
|
9
|
-
export const SERVER_VERSION = '4.5.
|
|
9
|
+
export const SERVER_VERSION = '4.5.3';
|
|
10
10
|
/**
|
|
11
11
|
* MCP protocol version our SDK speaks. Hardcoded to match the version
|
|
12
12
|
* bundled with `@modelcontextprotocol/sdk`; update when upgrading the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stabgan/openrouter-mcp-multimodal",
|
|
3
|
-
"version": "4.5.
|
|
3
|
+
"version": "4.5.3",
|
|
4
4
|
"mcpName": "io.github.stabgan/openrouter-multimodal",
|
|
5
5
|
"description": "MCP server for OpenRouter with text chat, image analysis + generation, audio analysis + generation, video analysis, and video generation (Veo 3.1 / Sora 2 Pro / Seedance / Wan)",
|
|
6
6
|
"type": "module",
|
|
@@ -19,8 +19,18 @@
|
|
|
19
19
|
"lint": "eslint src",
|
|
20
20
|
"format": "prettier --write \"src/**/*.ts\" *.json \"*.md\"",
|
|
21
21
|
"format:check": "prettier --check \"src/**/*.ts\"",
|
|
22
|
-
"test": "
|
|
23
|
-
"test:
|
|
22
|
+
"test": "vitest run",
|
|
23
|
+
"test:regression": "vitest run --config vitest.regression.config.ts",
|
|
24
|
+
"test:integration": "vitest run --config vitest.integration.config.ts",
|
|
25
|
+
"test:e2e": "npm run build && node scripts/live-e2e.mjs",
|
|
26
|
+
"test:smoke:npm": "npm run build && npm pack --quiet && node scripts/smoke-npm-mcp.mjs",
|
|
27
|
+
"test:smoke:docker": "node scripts/smoke-docker-mcp.mjs",
|
|
28
|
+
"test:smoke:uvx": "node scripts/smoke-uvx-mcp.mjs",
|
|
29
|
+
"test:smoke:uvx:local": "npm run build && npm pack --quiet && MCP_UVX_LOCAL=1 node scripts/smoke-uvx-mcp.mjs",
|
|
30
|
+
"test:smoke:uvx:git": "MCP_UVX_FROM_GIT=1 node scripts/smoke-uvx-mcp.mjs",
|
|
31
|
+
"test:smoke": "npm run test:smoke:npm && npm run test:smoke:docker && npm run test:smoke:uvx:local",
|
|
32
|
+
"test:all": "npm test && npm run test:regression && npm run test:integration",
|
|
33
|
+
"ci": "npm run lint && npm run format:check && npm run build && npm run test:all"
|
|
24
34
|
},
|
|
25
35
|
"keywords": [
|
|
26
36
|
"mcp",
|
|
@@ -44,23 +54,25 @@
|
|
|
44
54
|
"homepage": "https://github.com/stabgan/openrouter-mcp-multimodal#readme",
|
|
45
55
|
"license": "Apache-2.0",
|
|
46
56
|
"engines": {
|
|
47
|
-
"node": ">=
|
|
57
|
+
"node": ">=20.0.0"
|
|
48
58
|
},
|
|
49
59
|
"dependencies": {
|
|
50
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
51
|
-
"dotenv": "^
|
|
52
|
-
"openai": "^4.
|
|
53
|
-
"sharp": "^0.
|
|
60
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
61
|
+
"dotenv": "^17.4.2",
|
|
62
|
+
"openai": "^4.104.0",
|
|
63
|
+
"sharp": "^0.35.3"
|
|
54
64
|
},
|
|
55
65
|
"devDependencies": {
|
|
56
66
|
"@eslint/js": "^9.39.2",
|
|
57
|
-
"@types/node": "^22.
|
|
67
|
+
"@types/node": "^22.20.0",
|
|
58
68
|
"eslint": "^9.39.2",
|
|
59
69
|
"eslint-config-prettier": "^10.1.8",
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"
|
|
70
|
+
"form-data": "^4.0.6",
|
|
71
|
+
"js-yaml": "^4.3.0",
|
|
72
|
+
"prettier": "^3.9.4",
|
|
73
|
+
"shx": "^0.4.0",
|
|
74
|
+
"typescript": "^5.9.3",
|
|
75
|
+
"typescript-eslint": "^8.62.1",
|
|
76
|
+
"vitest": "^4.1.9"
|
|
65
77
|
}
|
|
66
78
|
}
|