@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.
Files changed (54) hide show
  1. package/README.md +137 -43
  2. package/dist/errors.d.ts +42 -0
  3. package/dist/errors.js +46 -0
  4. package/dist/index.js +1 -1
  5. package/dist/logger.d.ts +22 -0
  6. package/dist/logger.js +47 -0
  7. package/dist/model-cache.d.ts +10 -0
  8. package/dist/model-cache.js +31 -1
  9. package/dist/openrouter-api.d.ts +54 -0
  10. package/dist/openrouter-api.js +128 -12
  11. package/dist/tool-handlers/analyze-audio.d.ts +5 -9
  12. package/dist/tool-handlers/analyze-audio.js +41 -8
  13. package/dist/tool-handlers/analyze-image.d.ts +5 -9
  14. package/dist/tool-handlers/analyze-image.js +38 -8
  15. package/dist/tool-handlers/analyze-video.d.ts +19 -0
  16. package/dist/tool-handlers/analyze-video.js +93 -0
  17. package/dist/tool-handlers/audio-utils.js +7 -9
  18. package/dist/tool-handlers/chat-completion.d.ts +6 -10
  19. package/dist/tool-handlers/chat-completion.js +27 -7
  20. package/dist/tool-handlers/completion-utils.d.ts +27 -0
  21. package/dist/tool-handlers/completion-utils.js +69 -0
  22. package/dist/tool-handlers/fetch-utils.d.ts +21 -0
  23. package/dist/tool-handlers/fetch-utils.js +166 -11
  24. package/dist/tool-handlers/generate-audio.d.ts +32 -12
  25. package/dist/tool-handlers/generate-audio.js +77 -46
  26. package/dist/tool-handlers/generate-image.d.ts +26 -10
  27. package/dist/tool-handlers/generate-image.js +79 -27
  28. package/dist/tool-handlers/generate-video.d.ts +78 -0
  29. package/dist/tool-handlers/generate-video.js +353 -0
  30. package/dist/tool-handlers/get-model-info.js +8 -2
  31. package/dist/tool-handlers/image-utils.d.ts +17 -1
  32. package/dist/tool-handlers/image-utils.js +66 -13
  33. package/dist/tool-handlers/openrouter-errors.d.ts +18 -0
  34. package/dist/tool-handlers/openrouter-errors.js +99 -0
  35. package/dist/tool-handlers/path-safety.d.ts +11 -0
  36. package/dist/tool-handlers/path-safety.js +88 -0
  37. package/dist/tool-handlers/search-models.js +1 -3
  38. package/dist/tool-handlers/validate-model.js +8 -2
  39. package/dist/tool-handlers/video-utils.d.ts +29 -0
  40. package/dist/tool-handlers/video-utils.js +174 -0
  41. package/dist/tool-handlers.js +199 -21
  42. package/package.json +3 -3
  43. package/dist/__tests__/audio-utils.test.d.ts +0 -1
  44. package/dist/__tests__/audio-utils.test.js +0 -120
  45. package/dist/__tests__/fetch-utils.test.d.ts +0 -1
  46. package/dist/__tests__/fetch-utils.test.js +0 -76
  47. package/dist/__tests__/generate-audio.test.d.ts +0 -1
  48. package/dist/__tests__/generate-audio.test.js +0 -90
  49. package/dist/__tests__/image-utils.test.d.ts +0 -1
  50. package/dist/__tests__/image-utils.test.js +0 -75
  51. package/dist/__tests__/integration.test.d.ts +0 -1
  52. package/dist/__tests__/integration.test.js +0 -219
  53. package/dist/__tests__/model-cache.test.d.ts +0 -1
  54. package/dist/__tests__/model-cache.test.js +0 -96
@@ -1,43 +1,95 @@
1
1
  import { promises as fs } from 'fs';
2
- import { dirname } from 'path';
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 { content: [{ type: 'text', text: 'Prompt is required.' }], isError: true };
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
- const completion = await openai.chat.completions.create({
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
- const message = completion.choices[0]?.message;
15
- if (!message) {
16
- return { content: [{ type: 'text', text: 'No response from model.' }], isError: true };
17
- }
18
- const base64 = extractBase64(message);
19
- if (base64) {
20
- if (save_path) {
21
- const dir = dirname(save_path);
22
- await fs.mkdir(dir, { recursive: true });
23
- await fs.writeFile(save_path, Buffer.from(base64.data, 'base64'));
24
- return {
25
- content: [
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 { content: [{ type: 'text', text }] };
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
- catch (error) {
38
- const msg = error instanceof Error ? error.message : String(error);
39
- return { content: [{ type: 'text', text: `Error: ${msg}` }], isError: true };
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 (!modelCache.isValid() && apiClient) {
4
- modelCache.setModels(await apiClient.getModels());
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
- export declare function optimizeImage(buffer: Buffer): Promise<string>;
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>;