@stabgan/openrouter-mcp-multimodal 2.0.0 → 3.1.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 +198 -145
- 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 +136 -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 +32 -0
- package/dist/tool-handlers/fetch-utils.js +216 -12
- 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 +48 -10
- package/dist/tool-handlers/generate-image.js +148 -33
- 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.d.ts +2 -9
- package/dist/tool-handlers/get-model-info.js +15 -5
- 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.d.ts +2 -9
- package/dist/tool-handlers/search-models.js +10 -6
- package/dist/tool-handlers/validate-model.d.ts +2 -9
- package/dist/tool-handlers/validate-model.js +15 -4
- package/dist/tool-handlers/video-utils.d.ts +29 -0
- package/dist/tool-handlers/video-utils.js +174 -0
- package/dist/tool-handlers.js +229 -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
|
@@ -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 };
|
|
@@ -6,16 +6,9 @@ export declare function handleGetModelInfo(request: {
|
|
|
6
6
|
model: string;
|
|
7
7
|
};
|
|
8
8
|
};
|
|
9
|
-
}, modelCache: ModelCache, apiClient?: OpenRouterAPIClient): Promise<{
|
|
9
|
+
}, modelCache: ModelCache, apiClient?: OpenRouterAPIClient): Promise<import("../errors.js").ToolErrorResult | {
|
|
10
10
|
content: {
|
|
11
|
-
type:
|
|
11
|
+
type: "text";
|
|
12
12
|
text: string;
|
|
13
13
|
}[];
|
|
14
|
-
isError: boolean;
|
|
15
|
-
} | {
|
|
16
|
-
content: {
|
|
17
|
-
type: string;
|
|
18
|
-
text: string;
|
|
19
|
-
}[];
|
|
20
|
-
isError?: undefined;
|
|
21
14
|
}>;
|
|
@@ -1,14 +1,24 @@
|
|
|
1
|
+
import { ErrorCode, toolError } from '../errors.js';
|
|
2
|
+
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
1
3
|
export async function handleGetModelInfo(request, modelCache, apiClient) {
|
|
2
|
-
const { model } = request.params.arguments;
|
|
3
|
-
if (!
|
|
4
|
-
|
|
4
|
+
const { model } = request.params.arguments ?? { model: '' };
|
|
5
|
+
if (!model || typeof model !== 'string') {
|
|
6
|
+
return toolError(ErrorCode.INVALID_INPUT, 'model is required.');
|
|
7
|
+
}
|
|
8
|
+
if (apiClient) {
|
|
9
|
+
try {
|
|
10
|
+
await modelCache.ensureFresh(() => apiClient.getModels());
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
return classifyUpstreamError(error, 'get_model_info');
|
|
14
|
+
}
|
|
5
15
|
}
|
|
6
16
|
if (!modelCache.isValid()) {
|
|
7
|
-
return
|
|
17
|
+
return toolError(ErrorCode.INTERNAL, 'No model data available.');
|
|
8
18
|
}
|
|
9
19
|
const info = modelCache.get(model);
|
|
10
20
|
if (!info) {
|
|
11
|
-
return
|
|
21
|
+
return toolError(ErrorCode.MODEL_NOT_FOUND, `Model '${model}' not found.`);
|
|
12
22
|
}
|
|
13
23
|
return { content: [{ type: 'text', text: JSON.stringify(info, null, 2) }] };
|
|
14
24
|
}
|
|
@@ -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>;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from 'path';
|
|
2
2
|
import { promises as fs } from 'fs';
|
|
3
|
-
import { readEnvInt, isBlockedIPv4 as _isBlockedIPv4, assertUrlSafeForFetch as _assertUrlSafeForFetch, fetchHttpResource, } from './fetch-utils.js';
|
|
3
|
+
import { readEnvInt, isBlockedIPv4 as _isBlockedIPv4, assertUrlSafeForFetch as _assertUrlSafeForFetch, fetchHttpResource, parseBase64DataUrl, } from './fetch-utils.js';
|
|
4
4
|
// Re-export for backward compatibility (tests import from image-utils)
|
|
5
5
|
export const isBlockedIPv4 = _isBlockedIPv4;
|
|
6
6
|
export const assertUrlSafeForFetch = _assertUrlSafeForFetch;
|
|
@@ -66,24 +66,71 @@ export async function fetchHttpImage(urlString) {
|
|
|
66
66
|
}
|
|
67
67
|
export async function fetchImage(source) {
|
|
68
68
|
if (source.startsWith('data:')) {
|
|
69
|
-
const
|
|
70
|
-
if (!
|
|
69
|
+
const parsed = parseBase64DataUrl(source);
|
|
70
|
+
if (!parsed)
|
|
71
71
|
throw new Error('Invalid data URL');
|
|
72
|
-
const
|
|
73
|
-
const approxBytes = Math.ceil((b64.length * 3) / 4);
|
|
72
|
+
const approxBytes = Math.ceil((parsed.base64.length * 3) / 4);
|
|
74
73
|
if (approxBytes > getMaxDataUrlBytes())
|
|
75
74
|
throw new Error('Data URL too large');
|
|
76
|
-
return Buffer.from(
|
|
75
|
+
return Buffer.from(parsed.base64, 'base64');
|
|
77
76
|
}
|
|
78
77
|
if (source.startsWith('http://') || source.startsWith('https://')) {
|
|
79
78
|
return fetchHttpImage(source);
|
|
80
79
|
}
|
|
81
80
|
return fs.readFile(source);
|
|
82
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Sniff image MIME type from magic bytes. Used to label the output of a
|
|
84
|
+
* failed `sharp` optimization (where we return original bytes but don't
|
|
85
|
+
* know the source MIME yet) and HTTP image responses whose Content-Type
|
|
86
|
+
* header is missing or wrong.
|
|
87
|
+
*/
|
|
88
|
+
export function sniffImageMime(buffer) {
|
|
89
|
+
if (buffer.length < 4)
|
|
90
|
+
return null;
|
|
91
|
+
// PNG: 89 50 4E 47 0D 0A 1A 0A
|
|
92
|
+
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) {
|
|
93
|
+
return 'image/png';
|
|
94
|
+
}
|
|
95
|
+
// JPEG: FF D8 FF
|
|
96
|
+
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
|
97
|
+
return 'image/jpeg';
|
|
98
|
+
}
|
|
99
|
+
// GIF: 47 49 46 38
|
|
100
|
+
if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x38) {
|
|
101
|
+
return 'image/gif';
|
|
102
|
+
}
|
|
103
|
+
// WebP: RIFF....WEBP
|
|
104
|
+
if (buffer.length >= 12 &&
|
|
105
|
+
buffer[0] === 0x52 &&
|
|
106
|
+
buffer[1] === 0x49 &&
|
|
107
|
+
buffer[2] === 0x46 &&
|
|
108
|
+
buffer[3] === 0x46 &&
|
|
109
|
+
buffer[8] === 0x57 &&
|
|
110
|
+
buffer[9] === 0x45 &&
|
|
111
|
+
buffer[10] === 0x42 &&
|
|
112
|
+
buffer[11] === 0x50) {
|
|
113
|
+
return 'image/webp';
|
|
114
|
+
}
|
|
115
|
+
// BMP
|
|
116
|
+
if (buffer[0] === 0x42 && buffer[1] === 0x4d)
|
|
117
|
+
return 'image/bmp';
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Optimize an image buffer and return both the base64 payload AND the MIME
|
|
122
|
+
* type that matches that payload. Callers should NOT assume JPEG — the
|
|
123
|
+
* pipeline falls back to the original bytes (with its detected MIME) when
|
|
124
|
+
* sharp is unavailable or fails. This closes BUG-012.
|
|
125
|
+
*/
|
|
83
126
|
export async function optimizeImage(buffer) {
|
|
84
127
|
const sharp = await loadSharp();
|
|
85
|
-
if (!sharp)
|
|
86
|
-
return
|
|
128
|
+
if (!sharp) {
|
|
129
|
+
return {
|
|
130
|
+
base64: buffer.toString('base64'),
|
|
131
|
+
mime: sniffImageMime(buffer) ?? 'application/octet-stream',
|
|
132
|
+
};
|
|
133
|
+
}
|
|
87
134
|
const maxDim = getMaxImageDimension();
|
|
88
135
|
const quality = getImageJpegQuality();
|
|
89
136
|
try {
|
|
@@ -94,17 +141,23 @@ export async function optimizeImage(buffer) {
|
|
|
94
141
|
pipeline = pipeline.resize(opts);
|
|
95
142
|
}
|
|
96
143
|
const out = await pipeline.jpeg({ quality }).toBuffer();
|
|
97
|
-
return out.toString('base64');
|
|
144
|
+
return { base64: out.toString('base64'), mime: 'image/jpeg' };
|
|
98
145
|
}
|
|
99
146
|
catch {
|
|
100
|
-
return
|
|
147
|
+
return {
|
|
148
|
+
base64: buffer.toString('base64'),
|
|
149
|
+
mime: sniffImageMime(buffer) ?? 'application/octet-stream',
|
|
150
|
+
};
|
|
101
151
|
}
|
|
102
152
|
}
|
|
103
153
|
export async function prepareImageUrl(source) {
|
|
104
154
|
if (source.startsWith('data:'))
|
|
105
155
|
return source;
|
|
106
156
|
const buffer = await fetchImage(source);
|
|
107
|
-
const base64 = await optimizeImage(buffer);
|
|
108
|
-
|
|
109
|
-
|
|
157
|
+
const { base64, mime } = await optimizeImage(buffer);
|
|
158
|
+
// When optimization succeeded, mime is 'image/jpeg'. When it failed, we
|
|
159
|
+
// use the sniffed mime. For local files we prefer the extension-derived
|
|
160
|
+
// mime (more specific) when optimization fell back.
|
|
161
|
+
const finalMime = mime === 'image/jpeg' || source.startsWith('http') ? mime : getMimeType(source);
|
|
162
|
+
return `data:${finalMime};base64,${base64}`;
|
|
110
163
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared mapping from OpenRouter / OpenAI SDK error shapes to our closed
|
|
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.
|
|
6
|
+
*/
|
|
7
|
+
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
|
+
export declare function classifyUpstreamError(err: unknown, _contextMessage?: string): ToolErrorResult;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared mapping from OpenRouter / OpenAI SDK error shapes to our closed
|
|
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.
|
|
6
|
+
*/
|
|
7
|
+
import { ErrorCode, toolError } from '../errors.js';
|
|
8
|
+
function extractStatus(err) {
|
|
9
|
+
if (typeof err !== 'object' || err === null)
|
|
10
|
+
return undefined;
|
|
11
|
+
const s = err.status;
|
|
12
|
+
if (typeof s === 'number')
|
|
13
|
+
return s;
|
|
14
|
+
// openai-node sometimes puts the status in `code` for `APIError`.
|
|
15
|
+
const c = err.code;
|
|
16
|
+
if (typeof c === 'number')
|
|
17
|
+
return c;
|
|
18
|
+
if (typeof c === 'string' && /^\d{3}$/.test(c))
|
|
19
|
+
return parseInt(c, 10);
|
|
20
|
+
// Fall back: parse the message for `HTTP NNN` — our internal client wraps
|
|
21
|
+
// fetch failures as `POST /videos failed: HTTP 400 — <detail>`.
|
|
22
|
+
if (err instanceof Error) {
|
|
23
|
+
const m = err.message.match(/\bHTTP (\d{3})\b/);
|
|
24
|
+
if (m)
|
|
25
|
+
return parseInt(m[1], 10);
|
|
26
|
+
}
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
function extractMessage(err) {
|
|
30
|
+
if (err instanceof Error) {
|
|
31
|
+
const nested = err.error;
|
|
32
|
+
if (nested && typeof nested === 'object' && typeof nested.message === 'string') {
|
|
33
|
+
return `${err.message} — ${nested.message}`;
|
|
34
|
+
}
|
|
35
|
+
if (typeof nested === 'string')
|
|
36
|
+
return `${err.message} — ${nested}`;
|
|
37
|
+
return err.message;
|
|
38
|
+
}
|
|
39
|
+
if (typeof err === 'string')
|
|
40
|
+
return err;
|
|
41
|
+
return 'unknown error';
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Classify a caught error from `openai.*` or a raw `fetch` to the
|
|
45
|
+
* OpenRouter REST API into the closed `ErrorCode` set.
|
|
46
|
+
*
|
|
47
|
+
* Matching strategy:
|
|
48
|
+
* 1. HTTP status first (when available).
|
|
49
|
+
* 2. Message heuristics for common OpenRouter strings (credits, ZDR,
|
|
50
|
+
* "model does not exist", content policy, etc.).
|
|
51
|
+
* 3. Default to INTERNAL to avoid leaking raw shapes.
|
|
52
|
+
*/
|
|
53
|
+
export function classifyUpstreamError(err, _contextMessage) {
|
|
54
|
+
const msg = extractMessage(err);
|
|
55
|
+
const status = extractStatus(err);
|
|
56
|
+
const lower = msg.toLowerCase();
|
|
57
|
+
const fullMsg = msg;
|
|
58
|
+
// Explicit credit / balance signals.
|
|
59
|
+
if (lower.includes('insufficient balance') ||
|
|
60
|
+
lower.includes('insufficient credits') ||
|
|
61
|
+
lower.includes('requires more credits') ||
|
|
62
|
+
lower.includes('requires at least') ||
|
|
63
|
+
status === 402) {
|
|
64
|
+
return toolError(ErrorCode.UPSTREAM_REFUSED, fullMsg, { status, reason: 'credits' });
|
|
65
|
+
}
|
|
66
|
+
// Zero Data Retention.
|
|
67
|
+
if (lower.includes('zdr') || lower.includes('zero data retention')) {
|
|
68
|
+
return toolError(ErrorCode.ZDR_INCOMPATIBLE, fullMsg, { status });
|
|
69
|
+
}
|
|
70
|
+
// Model lookup failures.
|
|
71
|
+
if (lower.includes('model') &&
|
|
72
|
+
(lower.includes('does not exist') || lower.includes('not found') || lower.includes('invalid model'))) {
|
|
73
|
+
return toolError(ErrorCode.MODEL_NOT_FOUND, fullMsg, { status });
|
|
74
|
+
}
|
|
75
|
+
// Content policy / moderation — surface as UPSTREAM_REFUSED so callers can distinguish from 5xx.
|
|
76
|
+
if (lower.includes('content policy') || lower.includes('moderation') || lower.includes('refused')) {
|
|
77
|
+
return toolError(ErrorCode.UPSTREAM_REFUSED, fullMsg, { status, reason: 'policy' });
|
|
78
|
+
}
|
|
79
|
+
// Rate-limit specific.
|
|
80
|
+
if (status === 429 || lower.includes('rate limit')) {
|
|
81
|
+
return toolError(ErrorCode.UPSTREAM_REFUSED, fullMsg, { status, reason: 'rate_limit' });
|
|
82
|
+
}
|
|
83
|
+
// Timeouts (AbortError from `AbortSignal.timeout`).
|
|
84
|
+
if (lower.includes('timed out') ||
|
|
85
|
+
lower.includes('timeout') ||
|
|
86
|
+
lower.includes('aborted') ||
|
|
87
|
+
(err instanceof Error && err.name === 'AbortError')) {
|
|
88
|
+
return toolError(ErrorCode.UPSTREAM_TIMEOUT, fullMsg, { status });
|
|
89
|
+
}
|
|
90
|
+
// Anything in the 4xx band that isn't covered above — user supplied a bad request.
|
|
91
|
+
if (typeof status === 'number' && status >= 400 && status < 500) {
|
|
92
|
+
return toolError(ErrorCode.INVALID_INPUT, fullMsg, { status });
|
|
93
|
+
}
|
|
94
|
+
// 5xx / network errors.
|
|
95
|
+
if (typeof status === 'number' && status >= 500) {
|
|
96
|
+
return toolError(ErrorCode.UPSTREAM_HTTP, fullMsg, { status });
|
|
97
|
+
}
|
|
98
|
+
return toolError(ErrorCode.UPSTREAM_HTTP, fullMsg);
|
|
99
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare class UnsafeOutputPathError extends Error {
|
|
2
|
+
constructor(message: string);
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* Resolve and validate a caller-supplied output path. Creates the parent
|
|
6
|
+
* directory if needed. Returns the absolute path that is safe to write.
|
|
7
|
+
*
|
|
8
|
+
* Throws `UnsafeOutputPathError` when the resolved path escapes the root
|
|
9
|
+
* (traversal attempt) and the sandbox is enabled.
|
|
10
|
+
*/
|
|
11
|
+
export declare function resolveSafeOutputPath(savePath: string): Promise<string>;
|