@stabgan/openrouter-mcp-multimodal 4.0.0 → 4.5.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/README.md +40 -7
- package/dist/errors.d.ts +26 -8
- package/dist/errors.js +17 -12
- package/dist/index.js +1 -1
- package/dist/logger.d.ts +11 -0
- package/dist/logger.js +26 -0
- package/dist/model-cache.d.ts +4 -0
- package/dist/model-cache.js +6 -0
- package/dist/openrouter-api.d.ts +22 -2
- package/dist/openrouter-api.js +21 -2
- package/dist/tool-handlers/analyze-audio.d.ts +9 -4
- package/dist/tool-handlers/analyze-audio.js +29 -15
- package/dist/tool-handlers/analyze-image.d.ts +10 -4
- package/dist/tool-handlers/analyze-image.js +37 -9
- package/dist/tool-handlers/analyze-video.d.ts +9 -4
- package/dist/tool-handlers/analyze-video.js +31 -19
- package/dist/tool-handlers/cache.d.ts +33 -0
- package/dist/tool-handlers/cache.js +54 -0
- package/dist/tool-handlers/chat-completion.d.ts +18 -4
- package/dist/tool-handlers/chat-completion.js +39 -11
- package/dist/tool-handlers/completion-utils.d.ts +29 -0
- package/dist/tool-handlers/completion-utils.js +76 -17
- package/dist/tool-handlers/generate-audio.d.ts +2 -0
- package/dist/tool-handlers/generate-audio.js +18 -1
- package/dist/tool-handlers/generate-image.d.ts +5 -3
- package/dist/tool-handlers/generate-image.js +20 -34
- package/dist/tool-handlers/generate-video.d.ts +43 -0
- package/dist/tool-handlers/generate-video.js +61 -6
- package/dist/tool-handlers/get-model-info.d.ts +1 -6
- package/dist/tool-handlers/get-model-info.js +2 -1
- package/dist/tool-handlers/health-check.d.ts +23 -0
- package/dist/tool-handlers/health-check.js +32 -0
- package/dist/tool-handlers/openai-withresponse.d.ts +16 -0
- package/dist/tool-handlers/openai-withresponse.js +16 -0
- package/dist/tool-handlers/path-safety.d.ts +11 -0
- package/dist/tool-handlers/path-safety.js +54 -0
- package/dist/tool-handlers/provider-routing.js +6 -2
- package/dist/tool-handlers/rerank.d.ts +17 -0
- package/dist/tool-handlers/rerank.js +52 -0
- package/dist/tool-handlers/search-models.d.ts +18 -7
- package/dist/tool-handlers/search-models.js +25 -2
- package/dist/tool-handlers/structured-output.d.ts +13 -0
- package/dist/tool-handlers/structured-output.js +24 -0
- package/dist/tool-handlers/validate-model.d.ts +4 -6
- package/dist/tool-handlers/validate-model.js +3 -8
- package/dist/tool-handlers.d.ts +1 -0
- package/dist/tool-handlers.js +417 -165
- package/dist/version.d.ts +16 -0
- package/dist/version.js +16 -0
- package/package.json +1 -1
|
@@ -39,6 +39,7 @@ export declare function handleGenerateVideo(request: {
|
|
|
39
39
|
}[];
|
|
40
40
|
isError: false;
|
|
41
41
|
_meta: {
|
|
42
|
+
server_version: string;
|
|
42
43
|
code: "JOB_STILL_RUNNING";
|
|
43
44
|
video_id: string;
|
|
44
45
|
polling_url: string;
|
|
@@ -64,12 +65,54 @@ export declare function handleGetVideoStatus(request: {
|
|
|
64
65
|
}[];
|
|
65
66
|
isError: false;
|
|
66
67
|
_meta: {
|
|
68
|
+
server_version: string;
|
|
67
69
|
code: "JOB_STILL_RUNNING";
|
|
68
70
|
video_id: string;
|
|
69
71
|
last_status: string;
|
|
70
72
|
progress: number | undefined;
|
|
71
73
|
};
|
|
72
74
|
}>;
|
|
75
|
+
/**
|
|
76
|
+
* Image-to-video convenience wrapper. Takes a single `image` argument
|
|
77
|
+
* (first frame) and delegates to `handleGenerateVideo` with the broader
|
|
78
|
+
* parameter surface hidden. Based on arxiv 2511.03497's finding that
|
|
79
|
+
* tool-calling success degrades with parameter count — a narrower tool
|
|
80
|
+
* gives the model a cleaner decision path.
|
|
81
|
+
*/
|
|
82
|
+
export interface GenerateVideoFromImageRequest {
|
|
83
|
+
image: string;
|
|
84
|
+
prompt: string;
|
|
85
|
+
model?: string;
|
|
86
|
+
resolution?: string;
|
|
87
|
+
aspect_ratio?: string;
|
|
88
|
+
duration?: number;
|
|
89
|
+
seed?: number;
|
|
90
|
+
save_path?: string;
|
|
91
|
+
max_wait_ms?: number;
|
|
92
|
+
poll_interval_ms?: number;
|
|
93
|
+
}
|
|
94
|
+
export declare function handleGenerateVideoFromImage(request: {
|
|
95
|
+
params: {
|
|
96
|
+
arguments: GenerateVideoFromImageRequest;
|
|
97
|
+
};
|
|
98
|
+
}, apiClient: OpenRouterAPIClient, progress?: ProgressHook): Promise<import("../errors.js").ToolErrorResult | {
|
|
99
|
+
content: {
|
|
100
|
+
type: "text";
|
|
101
|
+
text: string;
|
|
102
|
+
}[];
|
|
103
|
+
isError: false;
|
|
104
|
+
_meta: {
|
|
105
|
+
server_version: string;
|
|
106
|
+
code: "JOB_STILL_RUNNING";
|
|
107
|
+
video_id: string;
|
|
108
|
+
polling_url: string;
|
|
109
|
+
last_status: string | undefined;
|
|
110
|
+
};
|
|
111
|
+
} | {
|
|
112
|
+
content: Record<string, unknown>[];
|
|
113
|
+
_meta: Record<string, unknown>;
|
|
114
|
+
isError?: undefined;
|
|
115
|
+
}>;
|
|
73
116
|
export declare const _internals: {
|
|
74
117
|
buildRequestBody: typeof buildRequestBody;
|
|
75
118
|
stripAndReplaceExt: typeof stripAndReplaceExt;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { promises as fs } from 'node:fs';
|
|
2
2
|
import { extname } from 'node:path';
|
|
3
3
|
import { ErrorCode, toolError, toolErrorFrom } from '../errors.js';
|
|
4
|
+
import { SERVER_VERSION } from '../version.js';
|
|
4
5
|
import { logger } from '../logger.js';
|
|
5
|
-
import { resolveSafeOutputPath, UnsafeOutputPathError, } from './path-safety.js';
|
|
6
|
+
import { resolveSafeOutputPath, resolveSafeInputPath, UnsafeOutputPathError, } from './path-safety.js';
|
|
6
7
|
import { readEnvInt } from './fetch-utils.js';
|
|
7
8
|
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
8
9
|
const FALLBACK_MODEL = 'google/veo-3.1';
|
|
@@ -54,8 +55,15 @@ async function prepareImageInput(source) {
|
|
|
54
55
|
const mime = (contentType?.split(';')[0]?.trim() || 'image/jpeg').toLowerCase();
|
|
55
56
|
return { mime, data: buffer.toString('base64') };
|
|
56
57
|
}
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
// Local file: sandbox via path-safety's resolveSafeInputPath so
|
|
59
|
+
// generate_video's first_frame_image / last_frame_image /
|
|
60
|
+
// reference_images fields enforce the same OPENROUTER_INPUT_DIR
|
|
61
|
+
// / OPENROUTER_OUTPUT_DIR / cwd scope that generate_image's
|
|
62
|
+
// input_images already uses. Callers can still bypass with
|
|
63
|
+
// OPENROUTER_ALLOW_UNSAFE_PATHS=1 for legacy scripts.
|
|
64
|
+
const abs = await resolveSafeInputPath(source);
|
|
65
|
+
const buf = await fs.readFile(abs);
|
|
66
|
+
const ext = extname(abs).toLowerCase();
|
|
59
67
|
const mime = ext === '.png'
|
|
60
68
|
? 'image/png'
|
|
61
69
|
: ext === '.webp'
|
|
@@ -170,6 +178,7 @@ async function finalizeCompletedJob(apiClient, status, savePath) {
|
|
|
170
178
|
? '.mpeg'
|
|
171
179
|
: '.mp4';
|
|
172
180
|
const baseMeta = {
|
|
181
|
+
server_version: SERVER_VERSION,
|
|
173
182
|
video_id: status.id,
|
|
174
183
|
mime,
|
|
175
184
|
size_bytes: buffer.length,
|
|
@@ -225,6 +234,23 @@ export async function handleGenerateVideo(request, apiClient, progress) {
|
|
|
225
234
|
if (!args.prompt || !args.prompt.trim()) {
|
|
226
235
|
return toolError(ErrorCode.INVALID_INPUT, 'prompt is required.');
|
|
227
236
|
}
|
|
237
|
+
const model = args.model ||
|
|
238
|
+
process.env.OPENROUTER_DEFAULT_VIDEO_GEN_MODEL ||
|
|
239
|
+
FALLBACK_MODEL;
|
|
240
|
+
// Audit entry — video is the most expensive tool we have. Always log
|
|
241
|
+
// model, resolution, duration, and a safe prompt preview so unintended
|
|
242
|
+
// spend can be traced.
|
|
243
|
+
logger.audit('generate_video.start', {
|
|
244
|
+
model,
|
|
245
|
+
prompt_preview: args.prompt.slice(0, 80),
|
|
246
|
+
resolution: args.resolution,
|
|
247
|
+
duration: args.duration,
|
|
248
|
+
aspect_ratio: args.aspect_ratio,
|
|
249
|
+
first_frame: args.first_frame_image ? 'provided' : 'none',
|
|
250
|
+
last_frame: args.last_frame_image ? 'provided' : 'none',
|
|
251
|
+
reference_images: args.reference_images?.length ?? 0,
|
|
252
|
+
save_path: args.save_path ? 'provided' : 'none',
|
|
253
|
+
});
|
|
228
254
|
// Fail-fast on unsafe save_path BEFORE spending credits on the job.
|
|
229
255
|
let safeSavePath = null;
|
|
230
256
|
if (args.save_path) {
|
|
@@ -237,14 +263,16 @@ export async function handleGenerateVideo(request, apiClient, progress) {
|
|
|
237
263
|
return toolErrorFrom(ErrorCode.INTERNAL, err);
|
|
238
264
|
}
|
|
239
265
|
}
|
|
240
|
-
const model = args.model ||
|
|
241
|
-
process.env.OPENROUTER_DEFAULT_VIDEO_GEN_MODEL ||
|
|
242
|
-
FALLBACK_MODEL;
|
|
243
266
|
const body = buildRequestBody(args, model);
|
|
244
267
|
try {
|
|
245
268
|
await attachFrameImages(args, body);
|
|
246
269
|
}
|
|
247
270
|
catch (err) {
|
|
271
|
+
// Sandbox violation → UNSAFE_PATH; all other decode failures stay
|
|
272
|
+
// as UNSUPPORTED_FORMAT (couldn't read, invalid data URL, etc.).
|
|
273
|
+
if (err instanceof UnsafeOutputPathError) {
|
|
274
|
+
return toolErrorFrom(ErrorCode.UNSAFE_PATH, err, 'Reference/frame image');
|
|
275
|
+
}
|
|
248
276
|
return toolErrorFrom(ErrorCode.UNSUPPORTED_FORMAT, err, 'Reference/frame image');
|
|
249
277
|
}
|
|
250
278
|
let envelope;
|
|
@@ -278,6 +306,7 @@ export async function handleGenerateVideo(request, apiClient, progress) {
|
|
|
278
306
|
],
|
|
279
307
|
isError: false,
|
|
280
308
|
_meta: {
|
|
309
|
+
server_version: SERVER_VERSION,
|
|
281
310
|
code: ErrorCode.JOB_STILL_RUNNING,
|
|
282
311
|
video_id: envelope.id,
|
|
283
312
|
polling_url: envelope.polling_url ?? `https://openrouter.ai/api/v1/videos/${envelope.id}`,
|
|
@@ -343,6 +372,7 @@ export async function handleGetVideoStatus(request, apiClient) {
|
|
|
343
372
|
],
|
|
344
373
|
isError: false,
|
|
345
374
|
_meta: {
|
|
375
|
+
server_version: SERVER_VERSION,
|
|
346
376
|
code: ErrorCode.JOB_STILL_RUNNING,
|
|
347
377
|
video_id: id,
|
|
348
378
|
last_status: status.status,
|
|
@@ -350,4 +380,29 @@ export async function handleGetVideoStatus(request, apiClient) {
|
|
|
350
380
|
},
|
|
351
381
|
};
|
|
352
382
|
}
|
|
383
|
+
export async function handleGenerateVideoFromImage(request, apiClient, progress) {
|
|
384
|
+
const args = request.params.arguments ?? {};
|
|
385
|
+
if (!args.image) {
|
|
386
|
+
return toolError(ErrorCode.INVALID_INPUT, 'image is required.');
|
|
387
|
+
}
|
|
388
|
+
if (!args.prompt || !args.prompt.trim()) {
|
|
389
|
+
return toolError(ErrorCode.INVALID_INPUT, 'prompt is required.');
|
|
390
|
+
}
|
|
391
|
+
return handleGenerateVideo({
|
|
392
|
+
params: {
|
|
393
|
+
arguments: {
|
|
394
|
+
prompt: args.prompt,
|
|
395
|
+
first_frame_image: args.image,
|
|
396
|
+
model: args.model,
|
|
397
|
+
resolution: args.resolution,
|
|
398
|
+
aspect_ratio: args.aspect_ratio,
|
|
399
|
+
duration: args.duration,
|
|
400
|
+
seed: args.seed,
|
|
401
|
+
save_path: args.save_path,
|
|
402
|
+
max_wait_ms: args.max_wait_ms,
|
|
403
|
+
poll_interval_ms: args.poll_interval_ms,
|
|
404
|
+
},
|
|
405
|
+
},
|
|
406
|
+
}, apiClient, progress);
|
|
407
|
+
}
|
|
353
408
|
export const _internals = { buildRequestBody, stripAndReplaceExt, extractJobError };
|
|
@@ -6,9 +6,4 @@ export declare function handleGetModelInfo(request: {
|
|
|
6
6
|
model: string;
|
|
7
7
|
};
|
|
8
8
|
};
|
|
9
|
-
}, modelCache: ModelCache, apiClient?: OpenRouterAPIClient): Promise<import("../errors.js").ToolErrorResult |
|
|
10
|
-
content: {
|
|
11
|
-
type: "text";
|
|
12
|
-
text: string;
|
|
13
|
-
}[];
|
|
14
|
-
}>;
|
|
9
|
+
}, modelCache: ModelCache, apiClient?: OpenRouterAPIClient): Promise<import("../errors.js").ToolErrorResult | import("./structured-output.js").StructuredResult<import("../model-cache.js").OpenRouterModelRecord>>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ErrorCode, toolError } from '../errors.js';
|
|
2
2
|
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
3
|
+
import { buildStructuredResult } from './structured-output.js';
|
|
3
4
|
export async function handleGetModelInfo(request, modelCache, apiClient) {
|
|
4
5
|
const { model } = request.params.arguments ?? { model: '' };
|
|
5
6
|
if (!model || typeof model !== 'string') {
|
|
@@ -20,5 +21,5 @@ export async function handleGetModelInfo(request, modelCache, apiClient) {
|
|
|
20
21
|
if (!info) {
|
|
21
22
|
return toolError(ErrorCode.MODEL_NOT_FOUND, `Model '${model}' not found.`);
|
|
22
23
|
}
|
|
23
|
-
return
|
|
24
|
+
return buildStructuredResult(info);
|
|
24
25
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { OpenRouterAPIClient } from '../openrouter-api.js';
|
|
2
|
+
import { ModelCache } from '../model-cache.js';
|
|
3
|
+
/**
|
|
4
|
+
* Lightweight liveness probe. Runs the following checks:
|
|
5
|
+
* - Hit `/models` via the API client (indirectly validates API key +
|
|
6
|
+
* OpenRouter reachability)
|
|
7
|
+
* - Read cached model count
|
|
8
|
+
* - Report server + protocol versions
|
|
9
|
+
*
|
|
10
|
+
* Returns `{ ok, ... }` so ops can use it as a readiness signal.
|
|
11
|
+
*/
|
|
12
|
+
export declare function handleHealthCheck(_request: {
|
|
13
|
+
params: {
|
|
14
|
+
arguments: Record<string, unknown>;
|
|
15
|
+
};
|
|
16
|
+
}, apiClient: OpenRouterAPIClient, modelCache: ModelCache): Promise<import("./structured-output.js").StructuredResult<{
|
|
17
|
+
error?: string | undefined;
|
|
18
|
+
ok: boolean;
|
|
19
|
+
server_version: string;
|
|
20
|
+
protocol_version: string;
|
|
21
|
+
api_key_valid: boolean;
|
|
22
|
+
models_cached: number;
|
|
23
|
+
}>>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { SERVER_VERSION, MCP_PROTOCOL_VERSION } from '../version.js';
|
|
2
|
+
import { buildStructuredResult } from './structured-output.js';
|
|
3
|
+
/**
|
|
4
|
+
* Lightweight liveness probe. Runs the following checks:
|
|
5
|
+
* - Hit `/models` via the API client (indirectly validates API key +
|
|
6
|
+
* OpenRouter reachability)
|
|
7
|
+
* - Read cached model count
|
|
8
|
+
* - Report server + protocol versions
|
|
9
|
+
*
|
|
10
|
+
* Returns `{ ok, ... }` so ops can use it as a readiness signal.
|
|
11
|
+
*/
|
|
12
|
+
export async function handleHealthCheck(_request, apiClient, modelCache) {
|
|
13
|
+
let apiKeyValid = false;
|
|
14
|
+
let errorMessage;
|
|
15
|
+
try {
|
|
16
|
+
await modelCache.ensureFresh(() => apiClient.getModels());
|
|
17
|
+
apiKeyValid = true;
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
errorMessage = err instanceof Error ? err.message : String(err);
|
|
21
|
+
}
|
|
22
|
+
const modelsCached = modelCache.isValid() ? modelCache.size() : 0;
|
|
23
|
+
const ok = apiKeyValid && modelsCached > 0;
|
|
24
|
+
return buildStructuredResult({
|
|
25
|
+
ok,
|
|
26
|
+
server_version: SERVER_VERSION,
|
|
27
|
+
protocol_version: MCP_PROTOCOL_VERSION,
|
|
28
|
+
api_key_valid: apiKeyValid,
|
|
29
|
+
models_cached: modelsCached,
|
|
30
|
+
...(errorMessage ? { error: errorMessage } : {}),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helper for calling `openai.chat.completions.create()` and getting back
|
|
3
|
+
* BOTH the typed body and the raw fetch `Response` (so we can read the
|
|
4
|
+
* X-OpenRouter-Cache-* headers).
|
|
5
|
+
*
|
|
6
|
+
* The real openai SDK returns an `APIPromise` that exposes `.withResponse()`.
|
|
7
|
+
* Vitest tests typically stub `create()` to return a plain `ChatCompletion`
|
|
8
|
+
* object. This helper handles both cases so tests don't need to mock the
|
|
9
|
+
* chainable.
|
|
10
|
+
*/
|
|
11
|
+
import type { ChatCompletion } from 'openai/resources/chat/completions.js';
|
|
12
|
+
export interface ChatCompletionWithHeaders {
|
|
13
|
+
data: ChatCompletion;
|
|
14
|
+
response: Response | undefined;
|
|
15
|
+
}
|
|
16
|
+
export declare function awaitCompletionWithHeaders(call: unknown): Promise<ChatCompletionWithHeaders>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export async function awaitCompletionWithHeaders(call) {
|
|
2
|
+
// Prefer the APIPromise .withResponse() chainable, which returns
|
|
3
|
+
// `{ data, response }` — but only if the object looks like an
|
|
4
|
+
// APIPromise. Mocks that return plain objects get unwrapped via a
|
|
5
|
+
// direct await.
|
|
6
|
+
const maybeChainable = call;
|
|
7
|
+
if (typeof maybeChainable?.withResponse === 'function') {
|
|
8
|
+
const { data, response } = await maybeChainable.withResponse();
|
|
9
|
+
return { data, response };
|
|
10
|
+
}
|
|
11
|
+
// Fallback: await the value directly. Covers both test mocks
|
|
12
|
+
// (which return a plain ChatCompletion via vi.fn().mockResolvedValue())
|
|
13
|
+
// and any exotic SDK shape we don't recognize.
|
|
14
|
+
const data = (await call);
|
|
15
|
+
return { data, response: undefined };
|
|
16
|
+
}
|
|
@@ -9,3 +9,14 @@ export declare class UnsafeOutputPathError extends Error {
|
|
|
9
9
|
* (traversal attempt) and the sandbox is enabled.
|
|
10
10
|
*/
|
|
11
11
|
export declare function resolveSafeOutputPath(savePath: string): Promise<string>;
|
|
12
|
+
/**
|
|
13
|
+
* Resolve and validate a caller-supplied INPUT path. Unlike
|
|
14
|
+
* `resolveSafeOutputPath`, this never creates directories — it only
|
|
15
|
+
* confirms the path lives inside the input sandbox and returns the
|
|
16
|
+
* absolute path the caller can `fs.readFile` from.
|
|
17
|
+
*
|
|
18
|
+
* Accepts the same `OPENROUTER_ALLOW_UNSAFE_PATHS=1` legacy bypass.
|
|
19
|
+
* Throws `UnsafeOutputPathError` on traversal attempts (re-used type so
|
|
20
|
+
* handlers map errors uniformly to `ErrorCode.UNSAFE_PATH`).
|
|
21
|
+
*/
|
|
22
|
+
export declare function resolveSafeInputPath(inputPath: string): Promise<string>;
|
|
@@ -86,3 +86,57 @@ async function findExistingAncestor(dir) {
|
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Root-resolution for caller-supplied INPUT paths. Prefers
|
|
91
|
+
* `OPENROUTER_INPUT_DIR`, then `OPENROUTER_OUTPUT_DIR`, then `process.cwd()`.
|
|
92
|
+
* This mirrors the semantics `generate_image`'s `input_images` originally
|
|
93
|
+
* shipped with; exposing it here lets `generate_video`'s frame and
|
|
94
|
+
* reference images use the same sandbox.
|
|
95
|
+
*/
|
|
96
|
+
function getInputRoot() {
|
|
97
|
+
const inputDir = process.env.OPENROUTER_INPUT_DIR;
|
|
98
|
+
if (inputDir && inputDir.length > 0)
|
|
99
|
+
return path.resolve(inputDir);
|
|
100
|
+
const outputDir = process.env.OPENROUTER_OUTPUT_DIR;
|
|
101
|
+
if (outputDir && outputDir.length > 0)
|
|
102
|
+
return path.resolve(outputDir);
|
|
103
|
+
return process.cwd();
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Resolve and validate a caller-supplied INPUT path. Unlike
|
|
107
|
+
* `resolveSafeOutputPath`, this never creates directories — it only
|
|
108
|
+
* confirms the path lives inside the input sandbox and returns the
|
|
109
|
+
* absolute path the caller can `fs.readFile` from.
|
|
110
|
+
*
|
|
111
|
+
* Accepts the same `OPENROUTER_ALLOW_UNSAFE_PATHS=1` legacy bypass.
|
|
112
|
+
* Throws `UnsafeOutputPathError` on traversal attempts (re-used type so
|
|
113
|
+
* handlers map errors uniformly to `ErrorCode.UNSAFE_PATH`).
|
|
114
|
+
*/
|
|
115
|
+
export async function resolveSafeInputPath(inputPath) {
|
|
116
|
+
if (isUnsafeMode()) {
|
|
117
|
+
return path.resolve(inputPath);
|
|
118
|
+
}
|
|
119
|
+
const root = getInputRoot();
|
|
120
|
+
const rootReal = await fs.realpath(root).catch(() => path.resolve(root));
|
|
121
|
+
const withSep = rootReal.endsWith(path.sep) ? rootReal : rootReal + path.sep;
|
|
122
|
+
const abs = path.isAbsolute(inputPath)
|
|
123
|
+
? path.resolve(inputPath)
|
|
124
|
+
: path.resolve(rootReal, inputPath);
|
|
125
|
+
// Prefer realpath for the prefix check so callers can pass paths
|
|
126
|
+
// through symlinks (e.g. macOS `/var/...` → `/private/var/...`)
|
|
127
|
+
// without us rejecting them. If the file doesn't exist yet, fall
|
|
128
|
+
// back to a textual check on the resolved path so traversal
|
|
129
|
+
// (`../escape.png`) is still rejected with the right error type
|
|
130
|
+
// instead of leaking an ENOENT to the caller.
|
|
131
|
+
let canonical;
|
|
132
|
+
try {
|
|
133
|
+
canonical = await fs.realpath(abs);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
canonical = abs;
|
|
137
|
+
}
|
|
138
|
+
if (!(canonical === rootReal || canonical.startsWith(withSep))) {
|
|
139
|
+
throw new UnsafeOutputPathError(`input path resolves outside OPENROUTER_INPUT_DIR (${rootReal}): ${inputPath}`);
|
|
140
|
+
}
|
|
141
|
+
return abs;
|
|
142
|
+
}
|
|
@@ -80,8 +80,12 @@ export function readProviderDefaults() {
|
|
|
80
80
|
if (order)
|
|
81
81
|
out.order = order;
|
|
82
82
|
}
|
|
83
|
-
catch {
|
|
84
|
-
|
|
83
|
+
catch (err) {
|
|
84
|
+
// Don't crash the server on a malformed env var — log once so an
|
|
85
|
+
// operator notices instead of wondering why their ordering is being
|
|
86
|
+
// ignored. All other OPENROUTER_PROVIDER_* fields follow the same
|
|
87
|
+
// "silent drop" policy for consistency.
|
|
88
|
+
console.error(`[openrouter-mcp] OPENROUTER_PROVIDER_ORDER ignored: ${err instanceof Error ? err.message : String(err)}`);
|
|
85
89
|
}
|
|
86
90
|
const requireParams = parseBool(env.OPENROUTER_PROVIDER_REQUIRE_PARAMETERS);
|
|
87
91
|
if (requireParams !== undefined)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { OpenRouterAPIClient } from '../openrouter-api.js';
|
|
2
|
+
export interface RerankDocumentsRequest {
|
|
3
|
+
query: string;
|
|
4
|
+
documents: string[];
|
|
5
|
+
model?: string;
|
|
6
|
+
top_n?: number;
|
|
7
|
+
/** When true, include the original document text in each result. */
|
|
8
|
+
return_documents?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export declare function handleRerankDocuments(request: {
|
|
11
|
+
params: {
|
|
12
|
+
arguments: RerankDocumentsRequest;
|
|
13
|
+
};
|
|
14
|
+
}, apiClient: OpenRouterAPIClient): Promise<import("../errors.js").ToolErrorResult | import("./structured-output.js").StructuredResult<{
|
|
15
|
+
model: string;
|
|
16
|
+
results: Record<string, unknown>[];
|
|
17
|
+
}>>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { ErrorCode, toolError, toolErrorFrom } from '../errors.js';
|
|
2
|
+
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
3
|
+
import { buildStructuredResult } from './structured-output.js';
|
|
4
|
+
const DEFAULT_MODEL = 'cohere/rerank-english-v3.0';
|
|
5
|
+
export async function handleRerankDocuments(request, apiClient) {
|
|
6
|
+
const args = request.params.arguments ??
|
|
7
|
+
{ query: '', documents: [] };
|
|
8
|
+
const { query, documents, model, top_n, return_documents } = args;
|
|
9
|
+
if (!query?.trim()) {
|
|
10
|
+
return toolError(ErrorCode.INVALID_INPUT, 'query is required.');
|
|
11
|
+
}
|
|
12
|
+
if (!Array.isArray(documents) || documents.length === 0) {
|
|
13
|
+
return toolError(ErrorCode.INVALID_INPUT, 'documents must be a non-empty array of strings.');
|
|
14
|
+
}
|
|
15
|
+
if (documents.some((d) => typeof d !== 'string')) {
|
|
16
|
+
return toolError(ErrorCode.INVALID_INPUT, 'every document must be a string.');
|
|
17
|
+
}
|
|
18
|
+
let response;
|
|
19
|
+
try {
|
|
20
|
+
response = await apiClient.rerank({
|
|
21
|
+
model: model || DEFAULT_MODEL,
|
|
22
|
+
query,
|
|
23
|
+
documents,
|
|
24
|
+
top_n,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
return classifyUpstreamError(err, 'rerank');
|
|
29
|
+
}
|
|
30
|
+
// Normalize to a stable shape: always expose `score` (OpenRouter
|
|
31
|
+
// providers sometimes return `relevance_score`, sometimes `score`).
|
|
32
|
+
const normalized = (response.results ?? []).map((r) => {
|
|
33
|
+
const score = typeof r.score === 'number' ? r.score : r.relevance_score;
|
|
34
|
+
const out = { index: r.index, score };
|
|
35
|
+
if (return_documents) {
|
|
36
|
+
const doc = typeof r.document === 'string'
|
|
37
|
+
? r.document
|
|
38
|
+
: r.document?.text ?? documents[r.index];
|
|
39
|
+
out.document = doc;
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
});
|
|
43
|
+
try {
|
|
44
|
+
return buildStructuredResult({
|
|
45
|
+
model: response.model ?? model ?? DEFAULT_MODEL,
|
|
46
|
+
results: normalized,
|
|
47
|
+
}, response.usage ? { usage: response.usage } : {});
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
return toolErrorFrom(ErrorCode.INTERNAL, err, 'rerank');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -1,20 +1,31 @@
|
|
|
1
|
-
import { ModelCache } from '../model-cache.js';
|
|
1
|
+
import { ModelCache, type OpenRouterModelRecord } from '../model-cache.js';
|
|
2
2
|
import { OpenRouterAPIClient } from '../openrouter-api.js';
|
|
3
3
|
export interface SearchModelsArgs {
|
|
4
4
|
query?: string;
|
|
5
5
|
provider?: string;
|
|
6
6
|
capabilities?: {
|
|
7
7
|
vision?: boolean;
|
|
8
|
+
audio?: boolean;
|
|
9
|
+
video?: boolean;
|
|
8
10
|
};
|
|
9
11
|
limit?: number;
|
|
12
|
+
/**
|
|
13
|
+
* Skip this many matching results before returning `limit`. Paired with
|
|
14
|
+
* `limit` and the returned `next_offset` to let large model lists be
|
|
15
|
+
* paged safely. Follows Phil Schmid's "paginate large results" best
|
|
16
|
+
* practice.
|
|
17
|
+
*/
|
|
18
|
+
offset?: number;
|
|
10
19
|
}
|
|
11
20
|
export declare function handleSearchModels(request: {
|
|
12
21
|
params: {
|
|
13
22
|
arguments: SearchModelsArgs;
|
|
14
23
|
};
|
|
15
|
-
}, apiClient: OpenRouterAPIClient, modelCache: ModelCache): Promise<import("../errors.js").ToolErrorResult | {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
24
|
+
}, apiClient: OpenRouterAPIClient, modelCache: ModelCache): Promise<import("../errors.js").ToolErrorResult | import("./structured-output.js").StructuredResult<{
|
|
25
|
+
results: OpenRouterModelRecord[];
|
|
26
|
+
offset: number;
|
|
27
|
+
limit: number;
|
|
28
|
+
total: number;
|
|
29
|
+
has_more: boolean;
|
|
30
|
+
next_offset: number | null;
|
|
31
|
+
}>>;
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { ErrorCode, toolErrorFrom } from '../errors.js';
|
|
2
2
|
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
3
|
+
import { buildStructuredResult } from './structured-output.js';
|
|
4
|
+
const DEFAULT_LIMIT = 20;
|
|
5
|
+
const MAX_LIMIT = 50;
|
|
3
6
|
export async function handleSearchModels(request, apiClient, modelCache) {
|
|
4
7
|
try {
|
|
5
8
|
await modelCache.ensureFresh(() => apiClient.getModels());
|
|
@@ -8,8 +11,28 @@ export async function handleSearchModels(request, apiClient, modelCache) {
|
|
|
8
11
|
return classifyUpstreamError(error, 'search_models');
|
|
9
12
|
}
|
|
10
13
|
try {
|
|
11
|
-
const
|
|
12
|
-
|
|
14
|
+
const args = request.params.arguments ?? {};
|
|
15
|
+
const limit = Math.min(Math.max(1, args.limit ?? DEFAULT_LIMIT), MAX_LIMIT);
|
|
16
|
+
const offset = Math.max(0, args.offset ?? 0);
|
|
17
|
+
// Get the full filtered set, then slice for pagination.
|
|
18
|
+
const all = modelCache.search({
|
|
19
|
+
query: args.query,
|
|
20
|
+
provider: args.provider,
|
|
21
|
+
capabilities: args.capabilities,
|
|
22
|
+
all: true,
|
|
23
|
+
});
|
|
24
|
+
const total = all.length;
|
|
25
|
+
const page = all.slice(offset, offset + limit);
|
|
26
|
+
const nextOffset = offset + limit;
|
|
27
|
+
const hasMore = nextOffset < total;
|
|
28
|
+
return buildStructuredResult({
|
|
29
|
+
results: page,
|
|
30
|
+
offset,
|
|
31
|
+
limit,
|
|
32
|
+
total,
|
|
33
|
+
has_more: hasMore,
|
|
34
|
+
next_offset: hasMore ? nextOffset : null,
|
|
35
|
+
});
|
|
13
36
|
}
|
|
14
37
|
catch (error) {
|
|
15
38
|
return toolErrorFrom(ErrorCode.INTERNAL, error, 'search_models');
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface StructuredResult<T = unknown> {
|
|
2
|
+
content: Array<{
|
|
3
|
+
type: 'text';
|
|
4
|
+
text: string;
|
|
5
|
+
}>;
|
|
6
|
+
structuredContent: T;
|
|
7
|
+
_meta: Record<string, unknown>;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Wrap a JSON-serializable object in the MCP-spec dual-representation
|
|
11
|
+
* format. `meta` is merged on top of the default `server_version` stamp.
|
|
12
|
+
*/
|
|
13
|
+
export declare function buildStructuredResult<T>(data: T, meta?: Record<string, unknown>): StructuredResult<T>;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helper for building MCP tool responses that carry structured data
|
|
3
|
+
* alongside the legacy text representation.
|
|
4
|
+
*
|
|
5
|
+
* Per MCP spec 2025-06-18 §5.2.6-7, when a tool has an `outputSchema`
|
|
6
|
+
* the response SHOULD include `structuredContent` (the typed object)
|
|
7
|
+
* AND, for backwards compatibility with clients that don't parse that
|
|
8
|
+
* field, `content` with a serialized JSON text block.
|
|
9
|
+
*
|
|
10
|
+
* Consumers use `buildStructuredResult(data, meta?)` and get back the
|
|
11
|
+
* full `{ content, structuredContent, _meta }` shape.
|
|
12
|
+
*/
|
|
13
|
+
import { SERVER_VERSION } from '../version.js';
|
|
14
|
+
/**
|
|
15
|
+
* Wrap a JSON-serializable object in the MCP-spec dual-representation
|
|
16
|
+
* format. `meta` is merged on top of the default `server_version` stamp.
|
|
17
|
+
*/
|
|
18
|
+
export function buildStructuredResult(data, meta = {}) {
|
|
19
|
+
return {
|
|
20
|
+
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
|
|
21
|
+
structuredContent: data,
|
|
22
|
+
_meta: { server_version: SERVER_VERSION, ...meta },
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -6,9 +6,7 @@ export declare function handleValidateModel(request: {
|
|
|
6
6
|
model: string;
|
|
7
7
|
};
|
|
8
8
|
};
|
|
9
|
-
}, modelCache: ModelCache, apiClient?: OpenRouterAPIClient): Promise<import("../errors.js").ToolErrorResult | {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}[];
|
|
14
|
-
}>;
|
|
9
|
+
}, modelCache: ModelCache, apiClient?: OpenRouterAPIClient): Promise<import("../errors.js").ToolErrorResult | import("./structured-output.js").StructuredResult<{
|
|
10
|
+
valid: boolean;
|
|
11
|
+
model: string;
|
|
12
|
+
}>>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ErrorCode, toolError } from '../errors.js';
|
|
2
2
|
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
3
|
+
import { buildStructuredResult } from './structured-output.js';
|
|
3
4
|
export async function handleValidateModel(request, modelCache, apiClient) {
|
|
4
5
|
const { model } = request.params.arguments ?? { model: '' };
|
|
5
6
|
if (!model || typeof model !== 'string') {
|
|
@@ -16,12 +17,6 @@ export async function handleValidateModel(request, modelCache, apiClient) {
|
|
|
16
17
|
if (!modelCache.isValid()) {
|
|
17
18
|
return toolError(ErrorCode.INTERNAL, 'No model data available.');
|
|
18
19
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
{
|
|
22
|
-
type: 'text',
|
|
23
|
-
text: JSON.stringify({ valid: modelCache.has(model) }),
|
|
24
|
-
},
|
|
25
|
-
],
|
|
26
|
-
};
|
|
20
|
+
const valid = modelCache.has(model);
|
|
21
|
+
return buildStructuredResult({ valid, model });
|
|
27
22
|
}
|