@stabgan/openrouter-mcp-multimodal 2.0.0 → 3.0.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 +137 -43
- package/dist/errors.d.ts +42 -0
- package/dist/errors.js +46 -0
- package/dist/index.js +1 -1
- package/dist/logger.d.ts +22 -0
- package/dist/logger.js +47 -0
- package/dist/model-cache.d.ts +10 -0
- package/dist/model-cache.js +31 -1
- package/dist/openrouter-api.d.ts +54 -0
- package/dist/openrouter-api.js +128 -12
- package/dist/tool-handlers/analyze-audio.d.ts +5 -9
- package/dist/tool-handlers/analyze-audio.js +41 -8
- package/dist/tool-handlers/analyze-image.d.ts +5 -9
- package/dist/tool-handlers/analyze-image.js +38 -8
- package/dist/tool-handlers/analyze-video.d.ts +19 -0
- package/dist/tool-handlers/analyze-video.js +93 -0
- package/dist/tool-handlers/audio-utils.js +7 -9
- package/dist/tool-handlers/chat-completion.d.ts +6 -10
- package/dist/tool-handlers/chat-completion.js +27 -7
- package/dist/tool-handlers/completion-utils.d.ts +27 -0
- package/dist/tool-handlers/completion-utils.js +69 -0
- package/dist/tool-handlers/fetch-utils.d.ts +21 -0
- package/dist/tool-handlers/fetch-utils.js +166 -11
- package/dist/tool-handlers/generate-audio.d.ts +32 -12
- package/dist/tool-handlers/generate-audio.js +77 -46
- package/dist/tool-handlers/generate-image.d.ts +26 -10
- package/dist/tool-handlers/generate-image.js +79 -27
- package/dist/tool-handlers/generate-video.d.ts +78 -0
- package/dist/tool-handlers/generate-video.js +353 -0
- package/dist/tool-handlers/get-model-info.js +8 -2
- package/dist/tool-handlers/image-utils.d.ts +17 -1
- package/dist/tool-handlers/image-utils.js +66 -13
- package/dist/tool-handlers/openrouter-errors.d.ts +18 -0
- package/dist/tool-handlers/openrouter-errors.js +99 -0
- package/dist/tool-handlers/path-safety.d.ts +11 -0
- package/dist/tool-handlers/path-safety.js +88 -0
- package/dist/tool-handlers/search-models.js +1 -3
- package/dist/tool-handlers/validate-model.js +8 -2
- package/dist/tool-handlers/video-utils.d.ts +29 -0
- package/dist/tool-handlers/video-utils.js +174 -0
- package/dist/tool-handlers.js +199 -21
- package/package.json +3 -3
- package/dist/__tests__/audio-utils.test.d.ts +0 -1
- package/dist/__tests__/audio-utils.test.js +0 -120
- package/dist/__tests__/fetch-utils.test.d.ts +0 -1
- package/dist/__tests__/fetch-utils.test.js +0 -76
- package/dist/__tests__/generate-audio.test.d.ts +0 -1
- package/dist/__tests__/generate-audio.test.js +0 -90
- package/dist/__tests__/image-utils.test.d.ts +0 -1
- package/dist/__tests__/image-utils.test.js +0 -75
- package/dist/__tests__/integration.test.d.ts +0 -1
- package/dist/__tests__/integration.test.js +0 -219
- package/dist/__tests__/model-cache.test.d.ts +0 -1
- package/dist/__tests__/model-cache.test.js +0 -96
|
@@ -1,43 +1,95 @@
|
|
|
1
1
|
import { promises as fs } from 'fs';
|
|
2
|
-
import {
|
|
2
|
+
import { resolveSafeOutputPath, UnsafeOutputPathError } from './path-safety.js';
|
|
3
|
+
import { ErrorCode, toolError, toolErrorFrom } from '../errors.js';
|
|
4
|
+
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
3
5
|
const DEFAULT_MODEL = 'google/gemini-2.5-flash-image';
|
|
4
6
|
export async function handleGenerateImage(request, openai) {
|
|
5
|
-
const { prompt, model, save_path } = request.params.arguments;
|
|
7
|
+
const { prompt, model, save_path } = request.params.arguments ?? { prompt: '' };
|
|
6
8
|
if (!prompt?.trim()) {
|
|
7
|
-
return
|
|
9
|
+
return toolError(ErrorCode.INVALID_INPUT, 'prompt is required.');
|
|
8
10
|
}
|
|
11
|
+
// Fail-fast on unsafe paths BEFORE spending tokens.
|
|
12
|
+
let safePathResolved = null;
|
|
13
|
+
if (save_path) {
|
|
14
|
+
try {
|
|
15
|
+
safePathResolved = await resolveSafeOutputPath(save_path);
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
if (err instanceof UnsafeOutputPathError) {
|
|
19
|
+
return toolErrorFrom(ErrorCode.UNSAFE_PATH, err);
|
|
20
|
+
}
|
|
21
|
+
return toolErrorFrom(ErrorCode.INTERNAL, err);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
let completion;
|
|
9
25
|
try {
|
|
10
|
-
|
|
26
|
+
completion = await openai.chat.completions.create({
|
|
11
27
|
model: model || DEFAULT_MODEL,
|
|
12
28
|
messages: [{ role: 'user', content: `Generate an image: ${prompt}` }],
|
|
13
29
|
});
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
{ type: 'text', text: `Image saved to: ${save_path}` },
|
|
27
|
-
{ type: 'image', mimeType: base64.mime, data: base64.data },
|
|
28
|
-
],
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
return { content: [{ type: 'image', mimeType: base64.mime, data: base64.data }] };
|
|
32
|
-
}
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
return classifyUpstreamError(err, 'generate_image');
|
|
33
|
+
}
|
|
34
|
+
const message = completion.choices[0]?.message;
|
|
35
|
+
if (!message) {
|
|
36
|
+
return toolError(ErrorCode.INTERNAL, 'No response from model.');
|
|
37
|
+
}
|
|
38
|
+
const base64 = extractBase64(message);
|
|
39
|
+
if (!base64) {
|
|
40
|
+
// Model talked but did not emit an image. Surface this as a distinct
|
|
41
|
+
// condition so callers don't treat chatter as a successful image.
|
|
33
42
|
const content = message.content;
|
|
34
43
|
const text = typeof content === 'string' ? content : JSON.stringify(content);
|
|
35
|
-
return
|
|
44
|
+
return toolError(ErrorCode.UPSTREAM_REFUSED, `Model returned no image. Text response: ${text.slice(0, 300)}`, {
|
|
45
|
+
reason: 'no_image_in_response',
|
|
46
|
+
finish_reason: completion.choices[0]?.finish_reason,
|
|
47
|
+
});
|
|
36
48
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
49
|
+
if (safePathResolved) {
|
|
50
|
+
try {
|
|
51
|
+
await fs.writeFile(safePathResolved, Buffer.from(base64.data, 'base64'));
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
return toolErrorFrom(ErrorCode.INTERNAL, err, 'Write');
|
|
55
|
+
}
|
|
56
|
+
const usage = completion.usage;
|
|
57
|
+
return {
|
|
58
|
+
content: [
|
|
59
|
+
{ type: 'text', text: `Image saved to: ${safePathResolved}` },
|
|
60
|
+
{ type: 'image', mimeType: base64.mime, data: base64.data },
|
|
61
|
+
],
|
|
62
|
+
_meta: {
|
|
63
|
+
save_path: safePathResolved,
|
|
64
|
+
mime: base64.mime,
|
|
65
|
+
...(usage
|
|
66
|
+
? {
|
|
67
|
+
usage: {
|
|
68
|
+
prompt_tokens: usage.prompt_tokens,
|
|
69
|
+
completion_tokens: usage.completion_tokens,
|
|
70
|
+
total_tokens: usage.total_tokens,
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
: {}),
|
|
74
|
+
},
|
|
75
|
+
};
|
|
40
76
|
}
|
|
77
|
+
const usage = completion.usage;
|
|
78
|
+
return {
|
|
79
|
+
content: [{ type: 'image', mimeType: base64.mime, data: base64.data }],
|
|
80
|
+
_meta: {
|
|
81
|
+
mime: base64.mime,
|
|
82
|
+
...(usage
|
|
83
|
+
? {
|
|
84
|
+
usage: {
|
|
85
|
+
prompt_tokens: usage.prompt_tokens,
|
|
86
|
+
completion_tokens: usage.completion_tokens,
|
|
87
|
+
total_tokens: usage.total_tokens,
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
: {}),
|
|
91
|
+
},
|
|
92
|
+
};
|
|
41
93
|
}
|
|
42
94
|
function extractBase64(message) {
|
|
43
95
|
const images = message.images;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { OpenRouterAPIClient, VideoJobStatus } from '../openrouter-api.js';
|
|
2
|
+
export interface GenerateVideoToolRequest {
|
|
3
|
+
prompt: string;
|
|
4
|
+
model?: string;
|
|
5
|
+
resolution?: string;
|
|
6
|
+
aspect_ratio?: string;
|
|
7
|
+
duration?: number;
|
|
8
|
+
seed?: number;
|
|
9
|
+
first_frame_image?: string;
|
|
10
|
+
last_frame_image?: string;
|
|
11
|
+
reference_images?: string[];
|
|
12
|
+
provider?: Record<string, unknown>;
|
|
13
|
+
save_path?: string;
|
|
14
|
+
max_wait_ms?: number;
|
|
15
|
+
poll_interval_ms?: number;
|
|
16
|
+
}
|
|
17
|
+
export interface GetVideoStatusToolRequest {
|
|
18
|
+
video_id: string;
|
|
19
|
+
save_path?: string;
|
|
20
|
+
polling_url?: string;
|
|
21
|
+
}
|
|
22
|
+
type ProgressHook = (update: {
|
|
23
|
+
status: string;
|
|
24
|
+
progress?: number;
|
|
25
|
+
attempt: number;
|
|
26
|
+
video_id: string;
|
|
27
|
+
}) => void | Promise<void>;
|
|
28
|
+
declare function buildRequestBody(args: GenerateVideoToolRequest, model: string): Record<string, unknown>;
|
|
29
|
+
declare function extractJobError(status: VideoJobStatus): string;
|
|
30
|
+
declare function stripAndReplaceExt(p: string, newExt: string): string;
|
|
31
|
+
export declare function handleGenerateVideo(request: {
|
|
32
|
+
params: {
|
|
33
|
+
arguments: GenerateVideoToolRequest;
|
|
34
|
+
};
|
|
35
|
+
}, apiClient: OpenRouterAPIClient, progress?: ProgressHook): Promise<import("../errors.js").ToolErrorResult | {
|
|
36
|
+
content: {
|
|
37
|
+
type: "text";
|
|
38
|
+
text: string;
|
|
39
|
+
}[];
|
|
40
|
+
isError: false;
|
|
41
|
+
_meta: {
|
|
42
|
+
code: "JOB_STILL_RUNNING";
|
|
43
|
+
video_id: string;
|
|
44
|
+
polling_url: string;
|
|
45
|
+
last_status: string | undefined;
|
|
46
|
+
};
|
|
47
|
+
} | {
|
|
48
|
+
content: Record<string, unknown>[];
|
|
49
|
+
_meta: Record<string, unknown>;
|
|
50
|
+
isError?: undefined;
|
|
51
|
+
}>;
|
|
52
|
+
export declare function handleGetVideoStatus(request: {
|
|
53
|
+
params: {
|
|
54
|
+
arguments: GetVideoStatusToolRequest;
|
|
55
|
+
};
|
|
56
|
+
}, apiClient: OpenRouterAPIClient): Promise<import("../errors.js").ToolErrorResult | {
|
|
57
|
+
content: Record<string, unknown>[];
|
|
58
|
+
_meta: Record<string, unknown>;
|
|
59
|
+
isError?: undefined;
|
|
60
|
+
} | {
|
|
61
|
+
content: {
|
|
62
|
+
type: "text";
|
|
63
|
+
text: string;
|
|
64
|
+
}[];
|
|
65
|
+
isError: false;
|
|
66
|
+
_meta: {
|
|
67
|
+
code: "JOB_STILL_RUNNING";
|
|
68
|
+
video_id: string;
|
|
69
|
+
last_status: string;
|
|
70
|
+
progress: number | undefined;
|
|
71
|
+
};
|
|
72
|
+
}>;
|
|
73
|
+
export declare const _internals: {
|
|
74
|
+
buildRequestBody: typeof buildRequestBody;
|
|
75
|
+
stripAndReplaceExt: typeof stripAndReplaceExt;
|
|
76
|
+
extractJobError: typeof extractJobError;
|
|
77
|
+
};
|
|
78
|
+
export {};
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { extname } from 'node:path';
|
|
3
|
+
import { ErrorCode, toolError, toolErrorFrom } from '../errors.js';
|
|
4
|
+
import { logger } from '../logger.js';
|
|
5
|
+
import { resolveSafeOutputPath, UnsafeOutputPathError, } from './path-safety.js';
|
|
6
|
+
import { readEnvInt } from './fetch-utils.js';
|
|
7
|
+
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
8
|
+
const FALLBACK_MODEL = 'google/veo-3.1';
|
|
9
|
+
const DEFAULT_POLL_INTERVAL_MS = 15_000;
|
|
10
|
+
const DEFAULT_MAX_WAIT_MS = 10 * 60_000;
|
|
11
|
+
const MIN_POLL_INTERVAL_MS = 50; // just to avoid a 0ms busy-loop if a caller omits
|
|
12
|
+
const INLINE_RETURN_CEILING_BYTES = 10 * 1024 * 1024;
|
|
13
|
+
function getMaxInlineBytes() {
|
|
14
|
+
return readEnvInt('OPENROUTER_VIDEO_INLINE_MAX_BYTES', INLINE_RETURN_CEILING_BYTES, 4096);
|
|
15
|
+
}
|
|
16
|
+
function getDefaultPollInterval() {
|
|
17
|
+
return readEnvInt('OPENROUTER_VIDEO_POLL_INTERVAL_MS', DEFAULT_POLL_INTERVAL_MS, MIN_POLL_INTERVAL_MS);
|
|
18
|
+
}
|
|
19
|
+
function getDefaultMaxWait() {
|
|
20
|
+
return readEnvInt('OPENROUTER_VIDEO_MAX_WAIT_MS', DEFAULT_MAX_WAIT_MS, 10_000);
|
|
21
|
+
}
|
|
22
|
+
function getMaxDownloadBytes() {
|
|
23
|
+
// Generation output can be bigger than the input cap since it's our own
|
|
24
|
+
// content. Default 256 MB, override via env.
|
|
25
|
+
return readEnvInt('OPENROUTER_VIDEO_GEN_MAX_BYTES', 256 * 1024 * 1024, 1024 * 1024);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Fold a caller-supplied image source (local path, http URL, or data URL)
|
|
29
|
+
* into the `{ url: "data:video|image/...base64,..." }` shape OpenRouter
|
|
30
|
+
* expects inside `frame_images[].image` / `input_references[]`.
|
|
31
|
+
*
|
|
32
|
+
* We reuse `prepareVideoData` for videos but images live in `image-utils`.
|
|
33
|
+
* Since generate_video's references are images, not videos, we do a small
|
|
34
|
+
* image-specific fetch here (data URL pass-through, HTTP via fetch-utils,
|
|
35
|
+
* local via fs). We deliberately do NOT run them through sharp — the model
|
|
36
|
+
* wants the pristine frame.
|
|
37
|
+
*/
|
|
38
|
+
async function prepareImageInput(source) {
|
|
39
|
+
if (!source)
|
|
40
|
+
return null;
|
|
41
|
+
if (source.startsWith('data:')) {
|
|
42
|
+
const match = source.match(/^data:([^;,]+)(?:;[^,]*)*;base64,(.+)$/);
|
|
43
|
+
if (!match)
|
|
44
|
+
throw new Error(`Invalid image data URL: ${source.slice(0, 40)}…`);
|
|
45
|
+
return { mime: match[1], data: match[2] };
|
|
46
|
+
}
|
|
47
|
+
if (source.startsWith('http://') || source.startsWith('https://')) {
|
|
48
|
+
const { fetchHttpResource } = await import('./fetch-utils.js');
|
|
49
|
+
const { buffer, contentType } = await fetchHttpResource(source, {
|
|
50
|
+
timeoutMs: 30_000,
|
|
51
|
+
maxBytes: 25 * 1024 * 1024,
|
|
52
|
+
maxRedirects: 8,
|
|
53
|
+
});
|
|
54
|
+
const mime = (contentType?.split(';')[0]?.trim() || 'image/jpeg').toLowerCase();
|
|
55
|
+
return { mime, data: buffer.toString('base64') };
|
|
56
|
+
}
|
|
57
|
+
const buf = await fs.readFile(source);
|
|
58
|
+
const ext = extname(source).toLowerCase();
|
|
59
|
+
const mime = ext === '.png'
|
|
60
|
+
? 'image/png'
|
|
61
|
+
: ext === '.webp'
|
|
62
|
+
? 'image/webp'
|
|
63
|
+
: ext === '.gif'
|
|
64
|
+
? 'image/gif'
|
|
65
|
+
: 'image/jpeg';
|
|
66
|
+
return { mime, data: buf.toString('base64') };
|
|
67
|
+
}
|
|
68
|
+
function buildRequestBody(args, model) {
|
|
69
|
+
const body = { model, prompt: args.prompt };
|
|
70
|
+
if (args.resolution)
|
|
71
|
+
body.resolution = args.resolution;
|
|
72
|
+
if (args.aspect_ratio)
|
|
73
|
+
body.aspect_ratio = args.aspect_ratio;
|
|
74
|
+
if (typeof args.duration === 'number')
|
|
75
|
+
body.duration = args.duration;
|
|
76
|
+
if (typeof args.seed === 'number')
|
|
77
|
+
body.seed = args.seed;
|
|
78
|
+
if (args.provider && typeof args.provider === 'object')
|
|
79
|
+
body.provider = args.provider;
|
|
80
|
+
return body;
|
|
81
|
+
}
|
|
82
|
+
async function attachFrameImages(args, body) {
|
|
83
|
+
const frameImages = [];
|
|
84
|
+
if (args.first_frame_image) {
|
|
85
|
+
const img = await prepareImageInput(args.first_frame_image);
|
|
86
|
+
if (img) {
|
|
87
|
+
frameImages.push({
|
|
88
|
+
frame_type: 'first_frame',
|
|
89
|
+
image: { url: `data:${img.mime};base64,${img.data}` },
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (args.last_frame_image) {
|
|
94
|
+
const img = await prepareImageInput(args.last_frame_image);
|
|
95
|
+
if (img) {
|
|
96
|
+
frameImages.push({
|
|
97
|
+
frame_type: 'last_frame',
|
|
98
|
+
image: { url: `data:${img.mime};base64,${img.data}` },
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (frameImages.length)
|
|
103
|
+
body.frame_images = frameImages;
|
|
104
|
+
if (args.reference_images?.length) {
|
|
105
|
+
const refs = [];
|
|
106
|
+
for (const src of args.reference_images) {
|
|
107
|
+
const img = await prepareImageInput(src);
|
|
108
|
+
if (img)
|
|
109
|
+
refs.push({ image: { url: `data:${img.mime};base64,${img.data}` } });
|
|
110
|
+
}
|
|
111
|
+
if (refs.length)
|
|
112
|
+
body.input_references = refs;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async function pollUntilTerminal(apiClient, envelope, opts) {
|
|
116
|
+
let attempt = 0;
|
|
117
|
+
let last = null;
|
|
118
|
+
const initialStatus = (envelope.status ?? 'pending');
|
|
119
|
+
await opts.onProgress?.({ status: initialStatus, attempt: 0, video_id: envelope.id });
|
|
120
|
+
while (Date.now() < opts.deadlineAt) {
|
|
121
|
+
attempt += 1;
|
|
122
|
+
await sleep(Math.min(opts.pollIntervalMs, Math.max(0, opts.deadlineAt - Date.now())));
|
|
123
|
+
try {
|
|
124
|
+
last = await apiClient.pollVideoJob(envelope.id);
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
logger.warn('generate_video.poll_error', {
|
|
128
|
+
id: envelope.id,
|
|
129
|
+
err: err instanceof Error ? err.message : String(err),
|
|
130
|
+
});
|
|
131
|
+
continue; // transient; try again until deadline
|
|
132
|
+
}
|
|
133
|
+
await opts.onProgress?.({
|
|
134
|
+
status: last.status,
|
|
135
|
+
progress: typeof last.progress === 'number' ? last.progress : undefined,
|
|
136
|
+
attempt,
|
|
137
|
+
video_id: envelope.id,
|
|
138
|
+
});
|
|
139
|
+
if (last.status === 'completed')
|
|
140
|
+
return { kind: 'completed', status: last };
|
|
141
|
+
if (last.status === 'failed')
|
|
142
|
+
return { kind: 'failed', status: last };
|
|
143
|
+
}
|
|
144
|
+
return { kind: 'timeout', last };
|
|
145
|
+
}
|
|
146
|
+
function sleep(ms) {
|
|
147
|
+
if (ms <= 0)
|
|
148
|
+
return Promise.resolve();
|
|
149
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
150
|
+
}
|
|
151
|
+
function extractJobError(status) {
|
|
152
|
+
if (!status.error)
|
|
153
|
+
return 'Upstream marked the job failed.';
|
|
154
|
+
if (typeof status.error === 'string')
|
|
155
|
+
return status.error;
|
|
156
|
+
return status.error.message ?? 'Upstream marked the job failed.';
|
|
157
|
+
}
|
|
158
|
+
async function finalizeCompletedJob(apiClient, status, savePath) {
|
|
159
|
+
const url = status.unsigned_urls?.[0];
|
|
160
|
+
if (!url) {
|
|
161
|
+
throw new Error('Completed job returned no content URLs.');
|
|
162
|
+
}
|
|
163
|
+
const { buffer, contentType } = await apiClient.downloadVideoContent(status.id, 0, getMaxDownloadBytes());
|
|
164
|
+
const mime = (contentType?.split(';')[0]?.trim() || 'video/mp4').toLowerCase();
|
|
165
|
+
const ext = mime.includes('webm')
|
|
166
|
+
? '.webm'
|
|
167
|
+
: mime.includes('mov')
|
|
168
|
+
? '.mov'
|
|
169
|
+
: mime.includes('mpeg')
|
|
170
|
+
? '.mpeg'
|
|
171
|
+
: '.mp4';
|
|
172
|
+
const baseMeta = {
|
|
173
|
+
video_id: status.id,
|
|
174
|
+
mime,
|
|
175
|
+
size_bytes: buffer.length,
|
|
176
|
+
};
|
|
177
|
+
if (status.usage)
|
|
178
|
+
baseMeta.usage = status.usage;
|
|
179
|
+
if (status.unsigned_urls)
|
|
180
|
+
baseMeta.unsigned_urls = status.unsigned_urls;
|
|
181
|
+
if (savePath) {
|
|
182
|
+
const finalPath = extname(savePath) === ext ? savePath : stripAndReplaceExt(savePath, ext);
|
|
183
|
+
await fs.writeFile(finalPath, buffer);
|
|
184
|
+
baseMeta.save_path = finalPath;
|
|
185
|
+
const summaryNote = finalPath !== savePath ? ` (detected ${mime}, saved as ${finalPath})` : '';
|
|
186
|
+
const content = [
|
|
187
|
+
{ type: 'text', text: `Video saved to: ${finalPath}${summaryNote}` },
|
|
188
|
+
];
|
|
189
|
+
if (buffer.length <= getMaxInlineBytes()) {
|
|
190
|
+
content.push({
|
|
191
|
+
type: 'video',
|
|
192
|
+
mimeType: mime,
|
|
193
|
+
data: buffer.toString('base64'),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return { content, _meta: baseMeta };
|
|
197
|
+
}
|
|
198
|
+
// No save_path — return inline if small enough, otherwise just the URL.
|
|
199
|
+
if (buffer.length <= getMaxInlineBytes()) {
|
|
200
|
+
return {
|
|
201
|
+
content: [
|
|
202
|
+
{ type: 'text', text: `Video generated (${buffer.length} bytes, ${mime}).` },
|
|
203
|
+
{ type: 'video', mimeType: mime, data: buffer.toString('base64') },
|
|
204
|
+
],
|
|
205
|
+
_meta: baseMeta,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
content: [
|
|
210
|
+
{
|
|
211
|
+
type: 'text',
|
|
212
|
+
text: `Video generated (${buffer.length} bytes, ${mime}). Too large to inline; pass save_path to persist. URL: ${url}`,
|
|
213
|
+
},
|
|
214
|
+
],
|
|
215
|
+
_meta: baseMeta,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
function stripAndReplaceExt(p, newExt) {
|
|
219
|
+
const cur = extname(p);
|
|
220
|
+
const base = cur ? p.slice(0, -cur.length) : p;
|
|
221
|
+
return base + newExt;
|
|
222
|
+
}
|
|
223
|
+
export async function handleGenerateVideo(request, apiClient, progress) {
|
|
224
|
+
const args = request.params.arguments ?? {};
|
|
225
|
+
if (!args.prompt || !args.prompt.trim()) {
|
|
226
|
+
return toolError(ErrorCode.INVALID_INPUT, 'prompt is required.');
|
|
227
|
+
}
|
|
228
|
+
// Fail-fast on unsafe save_path BEFORE spending credits on the job.
|
|
229
|
+
let safeSavePath = null;
|
|
230
|
+
if (args.save_path) {
|
|
231
|
+
try {
|
|
232
|
+
safeSavePath = await resolveSafeOutputPath(args.save_path);
|
|
233
|
+
}
|
|
234
|
+
catch (err) {
|
|
235
|
+
if (err instanceof UnsafeOutputPathError)
|
|
236
|
+
return toolErrorFrom(ErrorCode.UNSAFE_PATH, err);
|
|
237
|
+
return toolErrorFrom(ErrorCode.INTERNAL, err);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const model = args.model ||
|
|
241
|
+
process.env.OPENROUTER_DEFAULT_VIDEO_GEN_MODEL ||
|
|
242
|
+
FALLBACK_MODEL;
|
|
243
|
+
const body = buildRequestBody(args, model);
|
|
244
|
+
try {
|
|
245
|
+
await attachFrameImages(args, body);
|
|
246
|
+
}
|
|
247
|
+
catch (err) {
|
|
248
|
+
return toolErrorFrom(ErrorCode.UNSUPPORTED_FORMAT, err, 'Reference/frame image');
|
|
249
|
+
}
|
|
250
|
+
let envelope;
|
|
251
|
+
try {
|
|
252
|
+
logger.info('generate_video.submit', { model, keys: Object.keys(body) });
|
|
253
|
+
envelope = await apiClient.submitVideoJob(body);
|
|
254
|
+
}
|
|
255
|
+
catch (err) {
|
|
256
|
+
return classifyUpstreamError(err, 'generate_video.submit');
|
|
257
|
+
}
|
|
258
|
+
const pollIntervalMs = Math.max(MIN_POLL_INTERVAL_MS, args.poll_interval_ms ?? getDefaultPollInterval());
|
|
259
|
+
const maxWaitMs = Math.max(100, args.max_wait_ms ?? getDefaultMaxWait());
|
|
260
|
+
const deadlineAt = Date.now() + maxWaitMs;
|
|
261
|
+
const outcome = await pollUntilTerminal(apiClient, envelope, {
|
|
262
|
+
pollIntervalMs,
|
|
263
|
+
deadlineAt,
|
|
264
|
+
onProgress: progress,
|
|
265
|
+
});
|
|
266
|
+
if (outcome.kind === 'failed') {
|
|
267
|
+
return toolError(ErrorCode.JOB_FAILED, extractJobError(outcome.status), {
|
|
268
|
+
video_id: outcome.status.id,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
if (outcome.kind === 'timeout') {
|
|
272
|
+
return {
|
|
273
|
+
content: [
|
|
274
|
+
{
|
|
275
|
+
type: 'text',
|
|
276
|
+
text: `Video still generating after ${maxWaitMs}ms. Use get_video_status with video_id=${envelope.id} to resume.`,
|
|
277
|
+
},
|
|
278
|
+
],
|
|
279
|
+
isError: false,
|
|
280
|
+
_meta: {
|
|
281
|
+
code: ErrorCode.JOB_STILL_RUNNING,
|
|
282
|
+
video_id: envelope.id,
|
|
283
|
+
polling_url: envelope.polling_url ?? `https://openrouter.ai/api/v1/videos/${envelope.id}`,
|
|
284
|
+
last_status: outcome.last?.status,
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
try {
|
|
289
|
+
const { content, _meta } = await finalizeCompletedJob(apiClient, outcome.status, safeSavePath);
|
|
290
|
+
return { content, _meta };
|
|
291
|
+
}
|
|
292
|
+
catch (err) {
|
|
293
|
+
if (err instanceof UnsafeOutputPathError) {
|
|
294
|
+
return toolErrorFrom(ErrorCode.UNSAFE_PATH, err);
|
|
295
|
+
}
|
|
296
|
+
return toolErrorFrom(ErrorCode.UPSTREAM_HTTP, err, 'Download');
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
export async function handleGetVideoStatus(request, apiClient) {
|
|
300
|
+
const args = request.params.arguments ?? {};
|
|
301
|
+
const id = args.video_id?.trim();
|
|
302
|
+
if (!id)
|
|
303
|
+
return toolError(ErrorCode.INVALID_INPUT, 'video_id is required.');
|
|
304
|
+
// Pre-resolve save_path so the poll surfaces a fast error before hitting OpenRouter.
|
|
305
|
+
let safeSavePath = null;
|
|
306
|
+
if (args.save_path) {
|
|
307
|
+
try {
|
|
308
|
+
safeSavePath = await resolveSafeOutputPath(args.save_path);
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
if (err instanceof UnsafeOutputPathError)
|
|
312
|
+
return toolErrorFrom(ErrorCode.UNSAFE_PATH, err);
|
|
313
|
+
return toolErrorFrom(ErrorCode.INTERNAL, err);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
let status;
|
|
317
|
+
try {
|
|
318
|
+
status = await apiClient.pollVideoJob(id);
|
|
319
|
+
}
|
|
320
|
+
catch (err) {
|
|
321
|
+
return classifyUpstreamError(err, 'get_video_status.poll');
|
|
322
|
+
}
|
|
323
|
+
if (status.status === 'failed') {
|
|
324
|
+
return toolError(ErrorCode.JOB_FAILED, extractJobError(status), { video_id: id });
|
|
325
|
+
}
|
|
326
|
+
if (status.status === 'completed') {
|
|
327
|
+
try {
|
|
328
|
+
const { content, _meta } = await finalizeCompletedJob(apiClient, status, safeSavePath);
|
|
329
|
+
return { content, _meta };
|
|
330
|
+
}
|
|
331
|
+
catch (err) {
|
|
332
|
+
if (err instanceof UnsafeOutputPathError)
|
|
333
|
+
return toolErrorFrom(ErrorCode.UNSAFE_PATH, err);
|
|
334
|
+
return toolErrorFrom(ErrorCode.UPSTREAM_HTTP, err, 'Download');
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return {
|
|
338
|
+
content: [
|
|
339
|
+
{
|
|
340
|
+
type: 'text',
|
|
341
|
+
text: `Video ${id} status: ${status.status}${typeof status.progress === 'number' ? ` (progress=${status.progress})` : ''}`,
|
|
342
|
+
},
|
|
343
|
+
],
|
|
344
|
+
isError: false,
|
|
345
|
+
_meta: {
|
|
346
|
+
code: ErrorCode.JOB_STILL_RUNNING,
|
|
347
|
+
video_id: id,
|
|
348
|
+
last_status: status.status,
|
|
349
|
+
progress: status.progress,
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
export const _internals = { buildRequestBody, stripAndReplaceExt, extractJobError };
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
export async function handleGetModelInfo(request, modelCache, apiClient) {
|
|
2
2
|
const { model } = request.params.arguments;
|
|
3
|
-
if (
|
|
4
|
-
|
|
3
|
+
if (apiClient) {
|
|
4
|
+
try {
|
|
5
|
+
await modelCache.ensureFresh(() => apiClient.getModels());
|
|
6
|
+
}
|
|
7
|
+
catch (error) {
|
|
8
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
9
|
+
return { content: [{ type: 'text', text: `Error: ${msg}` }], isError: true };
|
|
10
|
+
}
|
|
5
11
|
}
|
|
6
12
|
if (!modelCache.isValid()) {
|
|
7
13
|
return { content: [{ type: 'text', text: 'No model data available.' }], isError: true };
|
|
@@ -6,5 +6,21 @@ export declare function getImageJpegQuality(): number;
|
|
|
6
6
|
export declare function getMimeType(filePath: string): string;
|
|
7
7
|
export declare function fetchHttpImage(urlString: string): Promise<Buffer>;
|
|
8
8
|
export declare function fetchImage(source: string): Promise<Buffer>;
|
|
9
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Sniff image MIME type from magic bytes. Used to label the output of a
|
|
11
|
+
* failed `sharp` optimization (where we return original bytes but don't
|
|
12
|
+
* know the source MIME yet) and HTTP image responses whose Content-Type
|
|
13
|
+
* header is missing or wrong.
|
|
14
|
+
*/
|
|
15
|
+
export declare function sniffImageMime(buffer: Buffer): string | null;
|
|
16
|
+
/**
|
|
17
|
+
* Optimize an image buffer and return both the base64 payload AND the MIME
|
|
18
|
+
* type that matches that payload. Callers should NOT assume JPEG — the
|
|
19
|
+
* pipeline falls back to the original bytes (with its detected MIME) when
|
|
20
|
+
* sharp is unavailable or fails. This closes BUG-012.
|
|
21
|
+
*/
|
|
22
|
+
export declare function optimizeImage(buffer: Buffer): Promise<{
|
|
23
|
+
base64: string;
|
|
24
|
+
mime: string;
|
|
25
|
+
}>;
|
|
10
26
|
export declare function prepareImageUrl(source: string): Promise<string>;
|