@stabgan/openrouter-mcp-multimodal 4.6.1 → 4.7.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 +78 -53
- package/dist/errors.d.ts +3 -12
- package/dist/errors.js +2 -6
- package/dist/index.js +2 -10
- package/dist/logger.d.ts +1 -18
- package/dist/logger.js +0 -7
- package/dist/model-cache.d.ts +1 -20
- package/dist/model-cache.js +1 -20
- package/dist/openrouter-api.d.ts +4 -18
- package/dist/openrouter-api.js +4 -20
- package/dist/openrouter-openai-client.d.ts +9 -0
- package/dist/openrouter-openai-client.js +15 -0
- package/dist/tool-definitions.d.ts +1 -0
- package/dist/tool-definitions.js +719 -0
- package/dist/tool-handlers/analyze-audio.d.ts +0 -5
- package/dist/tool-handlers/analyze-image.d.ts +0 -6
- package/dist/tool-handlers/analyze-image.js +1 -8
- package/dist/tool-handlers/analyze-video.d.ts +0 -5
- package/dist/tool-handlers/analyze-video.js +0 -7
- package/dist/tool-handlers/async-chat.d.ts +17 -13
- package/dist/tool-handlers/async-chat.js +49 -65
- package/dist/tool-handlers/audio-utils.d.ts +1 -4
- package/dist/tool-handlers/audio-utils.js +4 -14
- package/dist/tool-handlers/cache.d.ts +2 -18
- package/dist/tool-handlers/cache.js +1 -19
- package/dist/tool-handlers/chat-completion.d.ts +2 -30
- package/dist/tool-handlers/chat-completion.js +13 -33
- package/dist/tool-handlers/chat-request.d.ts +25 -0
- package/dist/tool-handlers/chat-request.js +38 -0
- package/dist/tool-handlers/completion-utils.d.ts +1 -18
- package/dist/tool-handlers/completion-utils.js +0 -2
- package/dist/tool-handlers/fetch-utils.d.ts +2 -18
- package/dist/tool-handlers/fetch-utils.js +3 -51
- package/dist/tool-handlers/generate-audio.d.ts +3 -37
- package/dist/tool-handlers/generate-audio.js +23 -47
- package/dist/tool-handlers/generate-image-dedicated.d.ts +1 -11
- package/dist/tool-handlers/generate-image-dedicated.js +65 -87
- package/dist/tool-handlers/generate-image-input.d.ts +0 -1
- package/dist/tool-handlers/generate-image-input.js +2 -18
- package/dist/tool-handlers/generate-image.d.ts +2 -45
- package/dist/tool-handlers/generate-image.js +16 -37
- package/dist/tool-handlers/generate-video.d.ts +1 -7
- package/dist/tool-handlers/generate-video.js +34 -142
- package/dist/tool-handlers/health-check.d.ts +1 -9
- package/dist/tool-handlers/health-check.js +1 -12
- package/dist/tool-handlers/image-source.d.ts +14 -0
- package/dist/tool-handlers/image-source.js +23 -0
- package/dist/tool-handlers/image-utils.d.ts +5 -0
- package/dist/tool-handlers/image-utils.js +23 -0
- package/dist/tool-handlers/openai-withresponse.d.ts +1 -10
- package/dist/tool-handlers/openai-withresponse.js +0 -7
- package/dist/tool-handlers/openrouter-errors.d.ts +2 -18
- package/dist/tool-handlers/openrouter-errors.js +2 -34
- package/dist/tool-handlers/path-safety.d.ts +15 -14
- package/dist/tool-handlers/path-safety.js +61 -55
- package/dist/tool-handlers/path-utils.d.ts +2 -0
- package/dist/tool-handlers/path-utils.js +7 -0
- package/dist/tool-handlers/provider-routing.d.ts +0 -9
- package/dist/tool-handlers/provider-routing.js +1 -14
- package/dist/tool-handlers/rerank.js +0 -2
- package/dist/tool-handlers/search-models.d.ts +0 -6
- package/dist/tool-handlers/speech-to-text.js +3 -21
- package/dist/tool-handlers/structured-output.d.ts +1 -4
- package/dist/tool-handlers/structured-output.js +2 -16
- package/dist/tool-handlers/text-to-speech.d.ts +1 -11
- package/dist/tool-handlers/text-to-speech.js +20 -41
- package/dist/tool-handlers/tool-result-payload.d.ts +47 -0
- package/dist/tool-handlers/tool-result-payload.js +96 -0
- package/dist/tool-handlers/video-utils.d.ts +1 -6
- package/dist/tool-handlers/video-utils.js +2 -17
- package/dist/tool-handlers.js +6 -739
- package/dist/tool-icons.d.ts +0 -6
- package/dist/tool-icons.js +1 -8
- package/dist/version.d.ts +1 -15
- package/dist/version.js +1 -15
- package/package.json +3 -2
|
@@ -14,49 +14,6 @@ export declare function handleGenerateImage(request: {
|
|
|
14
14
|
arguments: GenerateImageToolRequest;
|
|
15
15
|
};
|
|
16
16
|
}, openai: OpenAI): Promise<import("../errors.js").ToolErrorResult | {
|
|
17
|
-
content: (
|
|
18
|
-
|
|
19
|
-
text: string;
|
|
20
|
-
mimeType?: undefined;
|
|
21
|
-
data?: undefined;
|
|
22
|
-
} | {
|
|
23
|
-
type: "image";
|
|
24
|
-
mimeType: string;
|
|
25
|
-
data: string;
|
|
26
|
-
text?: undefined;
|
|
27
|
-
})[];
|
|
28
|
-
_meta: {
|
|
29
|
-
usage: {
|
|
30
|
-
prompt_tokens: number;
|
|
31
|
-
completion_tokens: number;
|
|
32
|
-
total_tokens: number;
|
|
33
|
-
};
|
|
34
|
-
server_version: string;
|
|
35
|
-
save_path: string;
|
|
36
|
-
mime: string;
|
|
37
|
-
} | {
|
|
38
|
-
usage?: undefined;
|
|
39
|
-
server_version: string;
|
|
40
|
-
save_path: string;
|
|
41
|
-
mime: string;
|
|
42
|
-
};
|
|
43
|
-
} | {
|
|
44
|
-
content: {
|
|
45
|
-
type: "image";
|
|
46
|
-
mimeType: string;
|
|
47
|
-
data: string;
|
|
48
|
-
}[];
|
|
49
|
-
_meta: {
|
|
50
|
-
usage: {
|
|
51
|
-
prompt_tokens: number;
|
|
52
|
-
completion_tokens: number;
|
|
53
|
-
total_tokens: number;
|
|
54
|
-
};
|
|
55
|
-
server_version: string;
|
|
56
|
-
mime: string;
|
|
57
|
-
} | {
|
|
58
|
-
usage?: undefined;
|
|
59
|
-
server_version: string;
|
|
60
|
-
mime: string;
|
|
61
|
-
};
|
|
17
|
+
content: import("./tool-result-payload.js").BinaryToolContent[];
|
|
18
|
+
_meta: Record<string, unknown>;
|
|
62
19
|
}>;
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { promises as fs } from 'fs';
|
|
2
|
-
import {
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { resolveOptionalOutputPath, isToolErrorResult, UnsafeOutputPathError, } from './path-safety.js';
|
|
3
3
|
import { parseBase64DataUrl } from './fetch-utils.js';
|
|
4
4
|
import { buildUserContent } from './generate-image-input.js';
|
|
5
|
+
import { asOpenAIChatBody } from './chat-request.js';
|
|
5
6
|
import { ErrorCode, toolError, toolErrorFrom } from '../errors.js';
|
|
6
7
|
import { SERVER_VERSION } from '../version.js';
|
|
7
8
|
import { logger } from '../logger.js';
|
|
8
9
|
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
10
|
+
import { buildBinaryToolResult } from './tool-result-payload.js';
|
|
9
11
|
const DEFAULT_MODEL = 'google/gemini-2.5-flash-image';
|
|
10
12
|
const VALID_ASPECT_RATIOS = new Set([
|
|
11
13
|
'1:1',
|
|
@@ -43,18 +45,10 @@ export async function handleGenerateImage(request, openai) {
|
|
|
43
45
|
if (image_size !== undefined && !VALID_IMAGE_SIZES.has(image_size)) {
|
|
44
46
|
return invalidEnumError('image_size', image_size, VALID_IMAGE_SIZES);
|
|
45
47
|
}
|
|
46
|
-
|
|
47
|
-
if (
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
catch (err) {
|
|
52
|
-
if (err instanceof UnsafeOutputPathError) {
|
|
53
|
-
return toolErrorFrom(ErrorCode.UNSAFE_PATH, err);
|
|
54
|
-
}
|
|
55
|
-
return toolErrorFrom(ErrorCode.INTERNAL, err);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
48
|
+
const savePathResult = await resolveOptionalOutputPath(save_path);
|
|
49
|
+
if (isToolErrorResult(savePathResult))
|
|
50
|
+
return savePathResult;
|
|
51
|
+
const safePathResolved = savePathResult.path;
|
|
58
52
|
let content;
|
|
59
53
|
try {
|
|
60
54
|
content = await buildUserContent(prompt, input_images);
|
|
@@ -81,7 +75,7 @@ export async function handleGenerateImage(request, openai) {
|
|
|
81
75
|
body.max_tokens = max_tokens;
|
|
82
76
|
let completion;
|
|
83
77
|
try {
|
|
84
|
-
completion = (await openai.chat.completions.create(body));
|
|
78
|
+
completion = (await openai.chat.completions.create(asOpenAIChatBody(body)));
|
|
85
79
|
}
|
|
86
80
|
catch (err) {
|
|
87
81
|
return classifyUpstreamError(err, 'generate_image');
|
|
@@ -122,28 +116,13 @@ function buildImageSuccessResult(base64, usage, savePath) {
|
|
|
122
116
|
},
|
|
123
117
|
}
|
|
124
118
|
: {};
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
server_version: SERVER_VERSION,
|
|
133
|
-
save_path: savePath,
|
|
134
|
-
mime: base64.mime,
|
|
135
|
-
...usageMeta,
|
|
136
|
-
},
|
|
137
|
-
};
|
|
138
|
-
}
|
|
139
|
-
return {
|
|
140
|
-
content: [{ type: 'image', mimeType: base64.mime, data: base64.data }],
|
|
141
|
-
_meta: {
|
|
142
|
-
server_version: SERVER_VERSION,
|
|
143
|
-
mime: base64.mime,
|
|
144
|
-
...usageMeta,
|
|
145
|
-
},
|
|
146
|
-
};
|
|
119
|
+
const buffer = Buffer.from(base64.data, 'base64');
|
|
120
|
+
return buildBinaryToolResult({ kind: 'image', buffer, mimeType: base64.mime }, {
|
|
121
|
+
savedPath: savePath ?? null,
|
|
122
|
+
inlineOnly: !savePath,
|
|
123
|
+
summaryText: savePath ? `Image saved to: ${savePath}` : undefined,
|
|
124
|
+
meta: { server_version: SERVER_VERSION, ...usageMeta },
|
|
125
|
+
});
|
|
147
126
|
}
|
|
148
127
|
function extractBase64(message) {
|
|
149
128
|
const images = message.images;
|
|
@@ -72,13 +72,7 @@ export declare function handleGetVideoStatus(request: {
|
|
|
72
72
|
progress: number | undefined;
|
|
73
73
|
};
|
|
74
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
|
-
*/
|
|
75
|
+
/** Image-to-video wrapper — delegates to `handleGenerateVideo` with a narrower schema. */
|
|
82
76
|
export interface GenerateVideoFromImageRequest {
|
|
83
77
|
image: string;
|
|
84
78
|
prompt: string;
|
|
@@ -3,14 +3,15 @@ import { extname } from 'node:path';
|
|
|
3
3
|
import { ErrorCode, toolError, toolErrorFrom } from '../errors.js';
|
|
4
4
|
import { SERVER_VERSION } from '../version.js';
|
|
5
5
|
import { logger } from '../logger.js';
|
|
6
|
-
import {
|
|
6
|
+
import { resolveOptionalOutputPath, isToolErrorResult, UnsafeOutputPathError, } from './path-safety.js';
|
|
7
|
+
import { resolveImageBase64 } from './image-source.js';
|
|
7
8
|
import { readEnvInt } from './fetch-utils.js';
|
|
8
9
|
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
10
|
+
import { buildBinaryToolResult } from './tool-result-payload.js';
|
|
9
11
|
const FALLBACK_MODEL = 'google/veo-3.1';
|
|
10
12
|
const DEFAULT_POLL_INTERVAL_MS = 15_000;
|
|
11
13
|
const DEFAULT_MAX_WAIT_MS = 10 * 60_000;
|
|
12
14
|
const MIN_POLL_INTERVAL_MS = 50; // just to avoid a 0ms busy-loop if a caller omits
|
|
13
|
-
const INLINE_RETURN_CEILING_BYTES = 10 * 1024 * 1024;
|
|
14
15
|
/** Models deprecated by OpenAI — removal date: 2026-09-24. */
|
|
15
16
|
const SORA_DEPRECATED_MODELS = new Set([
|
|
16
17
|
'openai/sora-2',
|
|
@@ -26,10 +27,7 @@ const SORA_ALTERNATIVES = [
|
|
|
26
27
|
'bytedance/seedance-2.0-fast (fast turnaround)',
|
|
27
28
|
'alibaba/wan-2.7 (good for artistic styles)',
|
|
28
29
|
];
|
|
29
|
-
/**
|
|
30
|
-
* Check if the model is a deprecated Sora model and return a warning string,
|
|
31
|
-
* or null if no deprecation applies.
|
|
32
|
-
*/
|
|
30
|
+
/** Return a deprecation warning for Sora models, or null. */
|
|
33
31
|
function checkSoraDeprecation(model) {
|
|
34
32
|
const normalized = model.toLowerCase().trim();
|
|
35
33
|
if (!SORA_DEPRECATED_MODELS.has(normalized) && !normalized.startsWith('openai/sora')) {
|
|
@@ -39,9 +37,6 @@ function checkSoraDeprecation(model) {
|
|
|
39
37
|
`Your request will still be attempted, but may fail. Recommended alternatives:\n` +
|
|
40
38
|
SORA_ALTERNATIVES.map((a) => ` • ${a}`).join('\n'));
|
|
41
39
|
}
|
|
42
|
-
function getMaxInlineBytes() {
|
|
43
|
-
return readEnvInt('OPENROUTER_VIDEO_INLINE_MAX_BYTES', INLINE_RETURN_CEILING_BYTES, 4096);
|
|
44
|
-
}
|
|
45
40
|
function getDefaultPollInterval() {
|
|
46
41
|
return readEnvInt('OPENROUTER_VIDEO_POLL_INTERVAL_MS', DEFAULT_POLL_INTERVAL_MS, MIN_POLL_INTERVAL_MS);
|
|
47
42
|
}
|
|
@@ -49,57 +44,19 @@ function getDefaultMaxWait() {
|
|
|
49
44
|
return readEnvInt('OPENROUTER_VIDEO_MAX_WAIT_MS', DEFAULT_MAX_WAIT_MS, 10_000);
|
|
50
45
|
}
|
|
51
46
|
function getMaxDownloadBytes() {
|
|
52
|
-
// Generation output can be bigger than the input cap since it's our own
|
|
53
|
-
// content. Default 256 MB, override via env.
|
|
54
47
|
return readEnvInt('OPENROUTER_VIDEO_GEN_MAX_BYTES', 256 * 1024 * 1024, 1024 * 1024);
|
|
55
48
|
}
|
|
56
|
-
|
|
57
|
-
* Fold a caller-supplied image source (local path, http URL, or data URL)
|
|
58
|
-
* into the `{ url: "data:video|image/...base64,..." }` shape OpenRouter
|
|
59
|
-
* expects inside `frame_images[].image` / `input_references[]`.
|
|
60
|
-
*
|
|
61
|
-
* We reuse `prepareVideoData` for videos but images live in `image-utils`.
|
|
62
|
-
* Since generate_video's references are images, not videos, we do a small
|
|
63
|
-
* image-specific fetch here (data URL pass-through, HTTP via fetch-utils,
|
|
64
|
-
* local via fs). We deliberately do NOT run them through sharp — the model
|
|
65
|
-
* wants the pristine frame.
|
|
66
|
-
*/
|
|
67
|
-
async function prepareImageInput(source) {
|
|
49
|
+
async function imageFrameEntry(source, frameType) {
|
|
68
50
|
if (!source)
|
|
69
51
|
return null;
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const { buffer, contentType } = await fetchHttpResource(source, {
|
|
79
|
-
timeoutMs: 30_000,
|
|
80
|
-
maxBytes: 25 * 1024 * 1024,
|
|
81
|
-
maxRedirects: 8,
|
|
82
|
-
});
|
|
83
|
-
const mime = (contentType?.split(';')[0]?.trim() || 'image/jpeg').toLowerCase();
|
|
84
|
-
return { mime, data: buffer.toString('base64') };
|
|
85
|
-
}
|
|
86
|
-
// Local file: sandbox via path-safety's resolveSafeInputPath so
|
|
87
|
-
// generate_video's first_frame_image / last_frame_image /
|
|
88
|
-
// reference_images fields enforce the same OPENROUTER_INPUT_DIR
|
|
89
|
-
// / OPENROUTER_OUTPUT_DIR / cwd scope that generate_image's
|
|
90
|
-
// input_images already uses. Callers can still bypass with
|
|
91
|
-
// OPENROUTER_ALLOW_UNSAFE_PATHS=1 for legacy scripts.
|
|
92
|
-
const abs = await resolveSafeInputPath(source);
|
|
93
|
-
const buf = await fs.readFile(abs);
|
|
94
|
-
const ext = extname(abs).toLowerCase();
|
|
95
|
-
const mime = ext === '.png'
|
|
96
|
-
? 'image/png'
|
|
97
|
-
: ext === '.webp'
|
|
98
|
-
? 'image/webp'
|
|
99
|
-
: ext === '.gif'
|
|
100
|
-
? 'image/gif'
|
|
101
|
-
: 'image/jpeg';
|
|
102
|
-
return { mime, data: buf.toString('base64') };
|
|
52
|
+
const img = await resolveImageBase64(source);
|
|
53
|
+
const entry = {
|
|
54
|
+
type: 'image_url',
|
|
55
|
+
image_url: { url: `data:${img.mime};base64,${img.data}` },
|
|
56
|
+
};
|
|
57
|
+
if (frameType)
|
|
58
|
+
entry.frame_type = frameType;
|
|
59
|
+
return { kind: 'frame', entry };
|
|
103
60
|
}
|
|
104
61
|
function buildRequestBody(args, model) {
|
|
105
62
|
const body = { model, prompt: args.prompt };
|
|
@@ -118,28 +75,10 @@ function buildRequestBody(args, model) {
|
|
|
118
75
|
async function attachFrameImages(args, body) {
|
|
119
76
|
const frameTasks = [];
|
|
120
77
|
if (args.first_frame_image) {
|
|
121
|
-
frameTasks.push(
|
|
122
|
-
? {
|
|
123
|
-
kind: 'frame',
|
|
124
|
-
entry: {
|
|
125
|
-
type: 'image_url',
|
|
126
|
-
image_url: { url: `data:${img.mime};base64,${img.data}` },
|
|
127
|
-
frame_type: 'first_frame',
|
|
128
|
-
},
|
|
129
|
-
}
|
|
130
|
-
: null));
|
|
78
|
+
frameTasks.push(imageFrameEntry(args.first_frame_image, 'first_frame'));
|
|
131
79
|
}
|
|
132
80
|
if (args.last_frame_image) {
|
|
133
|
-
frameTasks.push(
|
|
134
|
-
? {
|
|
135
|
-
kind: 'frame',
|
|
136
|
-
entry: {
|
|
137
|
-
type: 'image_url',
|
|
138
|
-
image_url: { url: `data:${img.mime};base64,${img.data}` },
|
|
139
|
-
frame_type: 'last_frame',
|
|
140
|
-
},
|
|
141
|
-
}
|
|
142
|
-
: null));
|
|
81
|
+
frameTasks.push(imageFrameEntry(args.last_frame_image, 'last_frame'));
|
|
143
82
|
}
|
|
144
83
|
const frameResults = await Promise.all(frameTasks);
|
|
145
84
|
const frameImages = frameResults
|
|
@@ -148,10 +87,8 @@ async function attachFrameImages(args, body) {
|
|
|
148
87
|
if (frameImages.length)
|
|
149
88
|
body.frame_images = frameImages;
|
|
150
89
|
if (args.reference_images?.length) {
|
|
151
|
-
const refResults = await Promise.all(args.reference_images.map((src) =>
|
|
152
|
-
const refs = refResults
|
|
153
|
-
.filter((img) => img !== null)
|
|
154
|
-
.map((img) => ({
|
|
90
|
+
const refResults = await Promise.all(args.reference_images.map((src) => resolveImageBase64(src)));
|
|
91
|
+
const refs = refResults.map((img) => ({
|
|
155
92
|
type: 'image_url',
|
|
156
93
|
image_url: { url: `data:${img.mime};base64,${img.data}` },
|
|
157
94
|
}));
|
|
@@ -231,37 +168,16 @@ async function finalizeCompletedJob(apiClient, status, savePath) {
|
|
|
231
168
|
await fs.writeFile(finalPath, buffer);
|
|
232
169
|
baseMeta.save_path = finalPath;
|
|
233
170
|
const summaryNote = finalPath !== savePath ? ` (detected ${mime}, saved as ${finalPath})` : '';
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
type: 'video',
|
|
240
|
-
mimeType: mime,
|
|
241
|
-
data: buffer.toString('base64'),
|
|
242
|
-
});
|
|
243
|
-
}
|
|
244
|
-
return { content, _meta: baseMeta };
|
|
245
|
-
}
|
|
246
|
-
// No save_path — return inline if small enough, otherwise just the URL.
|
|
247
|
-
if (buffer.length <= getMaxInlineBytes()) {
|
|
248
|
-
return {
|
|
249
|
-
content: [
|
|
250
|
-
{ type: 'text', text: `Video generated (${buffer.length} bytes, ${mime}).` },
|
|
251
|
-
{ type: 'video', mimeType: mime, data: buffer.toString('base64') },
|
|
252
|
-
],
|
|
253
|
-
_meta: baseMeta,
|
|
254
|
-
};
|
|
171
|
+
return buildBinaryToolResult({ kind: 'video', buffer, mimeType: mime }, {
|
|
172
|
+
savedPath: finalPath,
|
|
173
|
+
summaryText: `Video saved to: ${finalPath}${summaryNote}`,
|
|
174
|
+
meta: baseMeta,
|
|
175
|
+
});
|
|
255
176
|
}
|
|
256
|
-
return {
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
text: `Video generated (${buffer.length} bytes, ${mime}). Too large to inline; pass save_path to persist. URL: ${url}`,
|
|
261
|
-
},
|
|
262
|
-
],
|
|
263
|
-
_meta: baseMeta,
|
|
264
|
-
};
|
|
177
|
+
return buildBinaryToolResult({ kind: 'video', buffer, mimeType: mime }, {
|
|
178
|
+
remoteUrl: url,
|
|
179
|
+
meta: baseMeta,
|
|
180
|
+
});
|
|
265
181
|
}
|
|
266
182
|
function stripAndReplaceExt(p, newExt) {
|
|
267
183
|
const cur = extname(p);
|
|
@@ -274,12 +190,7 @@ export async function handleGenerateVideo(request, apiClient, progress) {
|
|
|
274
190
|
return toolError(ErrorCode.INVALID_INPUT, 'prompt is required.');
|
|
275
191
|
}
|
|
276
192
|
const model = args.model || process.env.OPENROUTER_DEFAULT_VIDEO_GEN_MODEL || FALLBACK_MODEL;
|
|
277
|
-
// Sora deprecation warning — OpenAI is removing the Videos API and all
|
|
278
|
-
// Sora 2 model aliases on September 24, 2026. Warn and suggest alternatives.
|
|
279
193
|
const deprecationWarning = checkSoraDeprecation(model);
|
|
280
|
-
// Audit entry — video is the most expensive tool we have. Always log
|
|
281
|
-
// model, resolution, duration, and a safe prompt preview so unintended
|
|
282
|
-
// spend can be traced.
|
|
283
194
|
logger.audit('generate_video.start', {
|
|
284
195
|
model,
|
|
285
196
|
prompt_preview: args.prompt.slice(0, 80),
|
|
@@ -291,25 +202,15 @@ export async function handleGenerateVideo(request, apiClient, progress) {
|
|
|
291
202
|
reference_images: args.reference_images?.length ?? 0,
|
|
292
203
|
save_path: args.save_path ? 'provided' : 'none',
|
|
293
204
|
});
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
safeSavePath = await resolveSafeOutputPath(args.save_path);
|
|
299
|
-
}
|
|
300
|
-
catch (err) {
|
|
301
|
-
if (err instanceof UnsafeOutputPathError)
|
|
302
|
-
return toolErrorFrom(ErrorCode.UNSAFE_PATH, err);
|
|
303
|
-
return toolErrorFrom(ErrorCode.INTERNAL, err);
|
|
304
|
-
}
|
|
305
|
-
}
|
|
205
|
+
const savePathResult = await resolveOptionalOutputPath(args.save_path);
|
|
206
|
+
if (isToolErrorResult(savePathResult))
|
|
207
|
+
return savePathResult;
|
|
208
|
+
const safeSavePath = savePathResult.path;
|
|
306
209
|
const body = buildRequestBody(args, model);
|
|
307
210
|
try {
|
|
308
211
|
await attachFrameImages(args, body);
|
|
309
212
|
}
|
|
310
213
|
catch (err) {
|
|
311
|
-
// Sandbox violation → UNSAFE_PATH; all other decode failures stay
|
|
312
|
-
// as UNSUPPORTED_FORMAT (couldn't read, invalid data URL, etc.).
|
|
313
214
|
if (err instanceof UnsafeOutputPathError) {
|
|
314
215
|
return toolErrorFrom(ErrorCode.UNSAFE_PATH, err, 'Reference/frame image');
|
|
315
216
|
}
|
|
@@ -359,7 +260,6 @@ export async function handleGenerateVideo(request, apiClient, progress) {
|
|
|
359
260
|
}
|
|
360
261
|
try {
|
|
361
262
|
const { content, _meta } = await finalizeCompletedJob(apiClient, outcome.status, safeSavePath);
|
|
362
|
-
// Prepend deprecation warning if applicable
|
|
363
263
|
if (deprecationWarning) {
|
|
364
264
|
content.unshift({ type: 'text', text: deprecationWarning });
|
|
365
265
|
_meta.deprecated_model = true;
|
|
@@ -378,18 +278,10 @@ export async function handleGetVideoStatus(request, apiClient) {
|
|
|
378
278
|
const id = args.video_id?.trim();
|
|
379
279
|
if (!id)
|
|
380
280
|
return toolError(ErrorCode.INVALID_INPUT, 'video_id is required.');
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
safeSavePath = await resolveSafeOutputPath(args.save_path);
|
|
386
|
-
}
|
|
387
|
-
catch (err) {
|
|
388
|
-
if (err instanceof UnsafeOutputPathError)
|
|
389
|
-
return toolErrorFrom(ErrorCode.UNSAFE_PATH, err);
|
|
390
|
-
return toolErrorFrom(ErrorCode.INTERNAL, err);
|
|
391
|
-
}
|
|
392
|
-
}
|
|
281
|
+
const savePathResult = await resolveOptionalOutputPath(args.save_path);
|
|
282
|
+
if (isToolErrorResult(savePathResult))
|
|
283
|
+
return savePathResult;
|
|
284
|
+
const safeSavePath = savePathResult.path;
|
|
393
285
|
let status;
|
|
394
286
|
try {
|
|
395
287
|
status = await apiClient.pollVideoJob(id);
|
|
@@ -1,14 +1,6 @@
|
|
|
1
1
|
import type { OpenRouterAPIClient } from '../openrouter-api.js';
|
|
2
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
|
-
*/
|
|
3
|
+
/** Liveness probe — validates API key, reachability, and cached model count. */
|
|
12
4
|
export declare function handleHealthCheck(_request: {
|
|
13
5
|
params: {
|
|
14
6
|
arguments: Record<string, unknown>;
|
|
@@ -1,14 +1,6 @@
|
|
|
1
1
|
import { SERVER_VERSION, MCP_PROTOCOL_VERSION } from '../version.js';
|
|
2
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
|
-
*/
|
|
3
|
+
/** Liveness probe — validates API key, reachability, and cached model count. */
|
|
12
4
|
export async function handleHealthCheck(_request, apiClient, modelCache) {
|
|
13
5
|
let apiKeyValid = false;
|
|
14
6
|
let errorMessage;
|
|
@@ -20,9 +12,6 @@ export async function handleHealthCheck(_request, apiClient, modelCache) {
|
|
|
20
12
|
errorMessage = err instanceof Error ? err.message : String(err);
|
|
21
13
|
}
|
|
22
14
|
const modelsCached = modelCache.isValid() ? modelCache.size() : 0;
|
|
23
|
-
// `ok` means the API was reachable and the key was accepted. An empty
|
|
24
|
-
// catalog counts as success (the API just returned no models) — callers
|
|
25
|
-
// branch on `models_cached` if they care about the count.
|
|
26
15
|
const ok = apiKeyValid;
|
|
27
16
|
return buildStructuredResult({
|
|
28
17
|
ok,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Resolve an image reference to a URL (data URL or http(s) passthrough). */
|
|
2
|
+
export declare function resolveImageUrl(ref: string): Promise<string>;
|
|
3
|
+
/** Resolve an image reference to base64 payload + mime (for video frame uploads). */
|
|
4
|
+
export declare function resolveImageBase64(source: string): Promise<{
|
|
5
|
+
mime: string;
|
|
6
|
+
data: string;
|
|
7
|
+
}>;
|
|
8
|
+
/** OpenRouter dedicated-image API reference shape. */
|
|
9
|
+
export declare function toOpenRouterImageReference(source: string): Promise<{
|
|
10
|
+
type: string;
|
|
11
|
+
image_url: {
|
|
12
|
+
url: string;
|
|
13
|
+
};
|
|
14
|
+
}>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { fetchImageWithMime } from './image-utils.js';
|
|
2
|
+
/** Resolve an image reference to a URL (data URL or http(s) passthrough). */
|
|
3
|
+
export async function resolveImageUrl(ref) {
|
|
4
|
+
const trimmed = ref.trim();
|
|
5
|
+
if (!trimmed)
|
|
6
|
+
throw new Error('empty image reference');
|
|
7
|
+
if (trimmed.startsWith('data:') || /^https?:\/\//i.test(trimmed))
|
|
8
|
+
return trimmed;
|
|
9
|
+
const { buffer, mime } = await fetchImageWithMime(trimmed);
|
|
10
|
+
return `data:${mime};base64,${buffer.toString('base64')}`;
|
|
11
|
+
}
|
|
12
|
+
/** Resolve an image reference to base64 payload + mime (for video frame uploads). */
|
|
13
|
+
export async function resolveImageBase64(source) {
|
|
14
|
+
if (!source.trim())
|
|
15
|
+
throw new Error('empty image source');
|
|
16
|
+
const { buffer, mime } = await fetchImageWithMime(source);
|
|
17
|
+
return { mime, data: buffer.toString('base64') };
|
|
18
|
+
}
|
|
19
|
+
/** OpenRouter dedicated-image API reference shape. */
|
|
20
|
+
export async function toOpenRouterImageReference(source) {
|
|
21
|
+
const url = await resolveImageUrl(source);
|
|
22
|
+
return { type: 'image_url', image_url: { url } };
|
|
23
|
+
}
|
|
@@ -24,4 +24,9 @@ export declare function optimizeImage(buffer: Buffer): Promise<{
|
|
|
24
24
|
base64: string;
|
|
25
25
|
mime: string;
|
|
26
26
|
}>;
|
|
27
|
+
/** Load image bytes with a MIME type (respects OPENROUTER_IMAGE_* fetch limits). */
|
|
28
|
+
export declare function fetchImageWithMime(source: string): Promise<{
|
|
29
|
+
buffer: Buffer;
|
|
30
|
+
mime: string;
|
|
31
|
+
}>;
|
|
27
32
|
export declare function prepareImageUrl(source: string): Promise<string>;
|
|
@@ -160,6 +160,29 @@ export async function optimizeImage(buffer) {
|
|
|
160
160
|
};
|
|
161
161
|
}
|
|
162
162
|
}
|
|
163
|
+
/** Load image bytes with a MIME type (respects OPENROUTER_IMAGE_* fetch limits). */
|
|
164
|
+
export async function fetchImageWithMime(source) {
|
|
165
|
+
if (source.startsWith('data:')) {
|
|
166
|
+
const parsed = parseBase64DataUrl(source);
|
|
167
|
+
if (!parsed)
|
|
168
|
+
throw new Error('Invalid data URL');
|
|
169
|
+
return { buffer: Buffer.from(parsed.base64, 'base64'), mime: parsed.mediaType };
|
|
170
|
+
}
|
|
171
|
+
if (source.startsWith('http://') || source.startsWith('https://')) {
|
|
172
|
+
const { buffer, contentType } = await fetchHttpResource(source, {
|
|
173
|
+
timeoutMs: getFetchTimeoutMs(),
|
|
174
|
+
maxBytes: getMaxDownloadBytes(),
|
|
175
|
+
maxRedirects: getMaxRedirects(),
|
|
176
|
+
});
|
|
177
|
+
const mime = (contentType?.split(';')[0]?.trim() ||
|
|
178
|
+
sniffImageMime(buffer) ||
|
|
179
|
+
'image/jpeg').toLowerCase();
|
|
180
|
+
return { buffer, mime };
|
|
181
|
+
}
|
|
182
|
+
const safe = await resolveSafeInputPath(source);
|
|
183
|
+
const buffer = await fs.readFile(safe);
|
|
184
|
+
return { buffer, mime: getMimeType(safe) };
|
|
185
|
+
}
|
|
163
186
|
export async function prepareImageUrl(source) {
|
|
164
187
|
if (source.startsWith('data:'))
|
|
165
188
|
return source;
|
|
@@ -1,13 +1,4 @@
|
|
|
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
|
-
*/
|
|
1
|
+
/** Call `openai.chat.completions.create()` and return body + raw Response headers. */
|
|
11
2
|
import type { ChatCompletion } from 'openai/resources/chat/completions.js';
|
|
12
3
|
export interface ChatCompletionWithHeaders {
|
|
13
4
|
data: ChatCompletion;
|
|
@@ -1,16 +1,9 @@
|
|
|
1
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
2
|
const maybeChainable = call;
|
|
7
3
|
if (typeof maybeChainable?.withResponse === 'function') {
|
|
8
4
|
const { data, response } = await maybeChainable.withResponse();
|
|
9
5
|
return { data, response };
|
|
10
6
|
}
|
|
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
7
|
const data = (await call);
|
|
15
8
|
return { data, response: undefined };
|
|
16
9
|
}
|
|
@@ -1,22 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* `ErrorCode` enum. Every tool handler that calls the OpenAI client routes
|
|
4
|
-
* its `catch` block through `classifyUpstreamError` so error taxonomies
|
|
5
|
-
* don't drift.
|
|
2
|
+
* Map OpenRouter / OpenAI SDK errors to our closed `ErrorCode` enum.
|
|
6
3
|
*/
|
|
7
4
|
import { type ToolErrorResult } from '../errors.js';
|
|
8
|
-
/**
|
|
9
|
-
* Classify a caught error from `openai.*` or a raw `fetch` to the
|
|
10
|
-
* OpenRouter REST API into the closed `ErrorCode` set.
|
|
11
|
-
*
|
|
12
|
-
* Matching strategy:
|
|
13
|
-
* 1. HTTP status first (when available).
|
|
14
|
-
* 2. Message heuristics for common OpenRouter strings (credits, ZDR,
|
|
15
|
-
* "model does not exist", content policy, etc.).
|
|
16
|
-
* 3. Default to INTERNAL to avoid leaking raw shapes.
|
|
17
|
-
*
|
|
18
|
-
* When the error carries a `Retry-After` header (on 429 / 503) we populate
|
|
19
|
-
* `_meta.retry_after_seconds` so agents can back off intelligently. We
|
|
20
|
-
* also attach canonical `suggestions[]` for common cases.
|
|
21
|
-
*/
|
|
5
|
+
/** Classify upstream errors into the closed `ErrorCode` set. */
|
|
22
6
|
export declare function classifyUpstreamError(err: unknown, contextMessage?: string): ToolErrorResult;
|