@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.
Files changed (76) hide show
  1. package/README.md +78 -53
  2. package/dist/errors.d.ts +3 -12
  3. package/dist/errors.js +2 -6
  4. package/dist/index.js +2 -10
  5. package/dist/logger.d.ts +1 -18
  6. package/dist/logger.js +0 -7
  7. package/dist/model-cache.d.ts +1 -20
  8. package/dist/model-cache.js +1 -20
  9. package/dist/openrouter-api.d.ts +4 -18
  10. package/dist/openrouter-api.js +4 -20
  11. package/dist/openrouter-openai-client.d.ts +9 -0
  12. package/dist/openrouter-openai-client.js +15 -0
  13. package/dist/tool-definitions.d.ts +1 -0
  14. package/dist/tool-definitions.js +719 -0
  15. package/dist/tool-handlers/analyze-audio.d.ts +0 -5
  16. package/dist/tool-handlers/analyze-image.d.ts +0 -6
  17. package/dist/tool-handlers/analyze-image.js +1 -8
  18. package/dist/tool-handlers/analyze-video.d.ts +0 -5
  19. package/dist/tool-handlers/analyze-video.js +0 -7
  20. package/dist/tool-handlers/async-chat.d.ts +17 -13
  21. package/dist/tool-handlers/async-chat.js +49 -65
  22. package/dist/tool-handlers/audio-utils.d.ts +1 -4
  23. package/dist/tool-handlers/audio-utils.js +4 -14
  24. package/dist/tool-handlers/cache.d.ts +2 -18
  25. package/dist/tool-handlers/cache.js +1 -19
  26. package/dist/tool-handlers/chat-completion.d.ts +2 -30
  27. package/dist/tool-handlers/chat-completion.js +13 -33
  28. package/dist/tool-handlers/chat-request.d.ts +25 -0
  29. package/dist/tool-handlers/chat-request.js +38 -0
  30. package/dist/tool-handlers/completion-utils.d.ts +1 -18
  31. package/dist/tool-handlers/completion-utils.js +0 -2
  32. package/dist/tool-handlers/fetch-utils.d.ts +2 -18
  33. package/dist/tool-handlers/fetch-utils.js +3 -51
  34. package/dist/tool-handlers/generate-audio.d.ts +3 -37
  35. package/dist/tool-handlers/generate-audio.js +23 -47
  36. package/dist/tool-handlers/generate-image-dedicated.d.ts +1 -11
  37. package/dist/tool-handlers/generate-image-dedicated.js +65 -87
  38. package/dist/tool-handlers/generate-image-input.d.ts +0 -1
  39. package/dist/tool-handlers/generate-image-input.js +2 -18
  40. package/dist/tool-handlers/generate-image.d.ts +2 -45
  41. package/dist/tool-handlers/generate-image.js +16 -37
  42. package/dist/tool-handlers/generate-video.d.ts +1 -7
  43. package/dist/tool-handlers/generate-video.js +34 -142
  44. package/dist/tool-handlers/health-check.d.ts +1 -9
  45. package/dist/tool-handlers/health-check.js +1 -12
  46. package/dist/tool-handlers/image-source.d.ts +14 -0
  47. package/dist/tool-handlers/image-source.js +23 -0
  48. package/dist/tool-handlers/image-utils.d.ts +5 -0
  49. package/dist/tool-handlers/image-utils.js +23 -0
  50. package/dist/tool-handlers/openai-withresponse.d.ts +1 -10
  51. package/dist/tool-handlers/openai-withresponse.js +0 -7
  52. package/dist/tool-handlers/openrouter-errors.d.ts +2 -18
  53. package/dist/tool-handlers/openrouter-errors.js +2 -34
  54. package/dist/tool-handlers/path-safety.d.ts +15 -14
  55. package/dist/tool-handlers/path-safety.js +61 -55
  56. package/dist/tool-handlers/path-utils.d.ts +2 -0
  57. package/dist/tool-handlers/path-utils.js +7 -0
  58. package/dist/tool-handlers/provider-routing.d.ts +0 -9
  59. package/dist/tool-handlers/provider-routing.js +1 -14
  60. package/dist/tool-handlers/rerank.js +0 -2
  61. package/dist/tool-handlers/search-models.d.ts +0 -6
  62. package/dist/tool-handlers/speech-to-text.js +3 -21
  63. package/dist/tool-handlers/structured-output.d.ts +1 -4
  64. package/dist/tool-handlers/structured-output.js +2 -16
  65. package/dist/tool-handlers/text-to-speech.d.ts +1 -11
  66. package/dist/tool-handlers/text-to-speech.js +20 -41
  67. package/dist/tool-handlers/tool-result-payload.d.ts +47 -0
  68. package/dist/tool-handlers/tool-result-payload.js +96 -0
  69. package/dist/tool-handlers/video-utils.d.ts +1 -6
  70. package/dist/tool-handlers/video-utils.js +2 -17
  71. package/dist/tool-handlers.js +6 -739
  72. package/dist/tool-icons.d.ts +0 -6
  73. package/dist/tool-icons.js +1 -8
  74. package/dist/version.d.ts +1 -15
  75. package/dist/version.js +1 -15
  76. 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
- type: "text";
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 { resolveSafeOutputPath, UnsafeOutputPathError } from './path-safety.js';
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
- let safePathResolved = null;
47
- if (save_path) {
48
- try {
49
- safePathResolved = await resolveSafeOutputPath(save_path);
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
- if (savePath) {
126
- return {
127
- content: [
128
- { type: 'text', text: `Image saved to: ${savePath}` },
129
- { type: 'image', mimeType: base64.mime, data: base64.data },
130
- ],
131
- _meta: {
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 { resolveSafeOutputPath, resolveSafeInputPath, UnsafeOutputPathError, } from './path-safety.js';
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
- if (source.startsWith('data:')) {
71
- const match = source.match(/^data:([^;,]+)(?:;[^,]*)*;base64,(.+)$/);
72
- if (!match)
73
- throw new Error(`Invalid image data URL: ${source.slice(0, 40)}…`);
74
- return { mime: match[1], data: match[2] };
75
- }
76
- if (source.startsWith('http://') || source.startsWith('https://')) {
77
- const { fetchHttpResource } = await import('./fetch-utils.js');
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(prepareImageInput(args.first_frame_image).then((img) => img
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(prepareImageInput(args.last_frame_image).then((img) => img
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) => prepareImageInput(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
- const content = [
235
- { type: 'text', text: `Video saved to: ${finalPath}${summaryNote}` },
236
- ];
237
- if (buffer.length <= getMaxInlineBytes()) {
238
- content.push({
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
- content: [
258
- {
259
- type: 'text',
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
- // Fail-fast on unsafe save_path BEFORE spending credits on the job.
295
- let safeSavePath = null;
296
- if (args.save_path) {
297
- try {
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
- // Pre-resolve save_path so the poll surfaces a fast error before hitting OpenRouter.
382
- let safeSavePath = null;
383
- if (args.save_path) {
384
- try {
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
- * 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.
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;