@stabgan/openrouter-mcp-multimodal 4.7.0 → 5.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 (63) hide show
  1. package/README.md +95 -41
  2. package/dist/errors.d.ts +5 -20
  3. package/dist/errors.js +1 -10
  4. package/dist/index.js +8 -2
  5. package/dist/logger.js +54 -24
  6. package/dist/model-cache.d.ts +13 -0
  7. package/dist/model-cache.js +62 -8
  8. package/dist/openrouter-api.d.ts +14 -15
  9. package/dist/openrouter-api.js +68 -22
  10. package/dist/tool-definitions.d.ts +24 -0
  11. package/dist/tool-definitions.js +280 -177
  12. package/dist/tool-descriptions.d.ts +0 -4
  13. package/dist/tool-descriptions.js +30 -21
  14. package/dist/tool-handlers/analyze-audio.js +4 -1
  15. package/dist/tool-handlers/analyze-image.js +11 -6
  16. package/dist/tool-handlers/analyze-video.js +9 -5
  17. package/dist/tool-handlers/async-chat.d.ts +17 -0
  18. package/dist/tool-handlers/async-chat.js +104 -30
  19. package/dist/tool-handlers/audio-utils.d.ts +19 -4
  20. package/dist/tool-handlers/audio-utils.js +170 -16
  21. package/dist/tool-handlers/cache.d.ts +3 -3
  22. package/dist/tool-handlers/cache.js +56 -4
  23. package/dist/tool-handlers/chat-completion.js +16 -7
  24. package/dist/tool-handlers/chat-request.d.ts +4 -1
  25. package/dist/tool-handlers/chat-request.js +29 -1
  26. package/dist/tool-handlers/completion-utils.d.ts +5 -11
  27. package/dist/tool-handlers/completion-utils.js +76 -47
  28. package/dist/tool-handlers/fetch-utils.d.ts +14 -0
  29. package/dist/tool-handlers/fetch-utils.js +329 -77
  30. package/dist/tool-handlers/generate-audio.d.ts +4 -15
  31. package/dist/tool-handlers/generate-audio.js +21 -53
  32. package/dist/tool-handlers/generate-image-dedicated.d.ts +1 -1
  33. package/dist/tool-handlers/generate-image-dedicated.js +50 -31
  34. package/dist/tool-handlers/generate-image.d.ts +1 -1
  35. package/dist/tool-handlers/generate-image.js +19 -22
  36. package/dist/tool-handlers/generate-video.d.ts +4 -3
  37. package/dist/tool-handlers/generate-video.js +42 -18
  38. package/dist/tool-handlers/get-model-info.js +1 -1
  39. package/dist/tool-handlers/health-check.js +39 -15
  40. package/dist/tool-handlers/image-utils.js +2 -2
  41. package/dist/tool-handlers/openrouter-errors.d.ts +2 -0
  42. package/dist/tool-handlers/openrouter-errors.js +138 -31
  43. package/dist/tool-handlers/path-safety.js +49 -17
  44. package/dist/tool-handlers/path-utils.d.ts +2 -0
  45. package/dist/tool-handlers/path-utils.js +13 -0
  46. package/dist/tool-handlers/provider-routing.d.ts +2 -0
  47. package/dist/tool-handlers/provider-routing.js +11 -1
  48. package/dist/tool-handlers/rerank.d.ts +1 -4
  49. package/dist/tool-handlers/rerank.js +44 -15
  50. package/dist/tool-handlers/search-models.js +3 -3
  51. package/dist/tool-handlers/speech-to-text.d.ts +1 -0
  52. package/dist/tool-handlers/speech-to-text.js +23 -56
  53. package/dist/tool-handlers/text-to-speech.d.ts +1 -1
  54. package/dist/tool-handlers/text-to-speech.js +33 -20
  55. package/dist/tool-handlers/tool-result-payload.js +11 -9
  56. package/dist/tool-handlers/validate-model.js +1 -1
  57. package/dist/tool-handlers.d.ts +9 -0
  58. package/dist/tool-handlers.js +17 -5
  59. package/dist/tts-defaults.d.ts +4 -0
  60. package/dist/tts-defaults.js +4 -0
  61. package/dist/version.d.ts +3 -2
  62. package/dist/version.js +4 -2
  63. package/package.json +11 -13
@@ -1,5 +1,4 @@
1
- /** Dedicated POST /api/v1/images — distinct from chat-completions `generate_image`. */
2
- import { promises as fs } from 'node:fs';
1
+ import { IMAGE_ASPECT_RATIOS, IMAGE_DEDICATED_QUALITIES, IMAGE_DEDICATED_RESOLUTIONS, IMAGE_OUTPUT_FORMATS, } from '../tool-definitions.js';
3
2
  import { resolveOptionalOutputPath, isToolErrorResult, UnsafeOutputPathError, } from './path-safety.js';
4
3
  import { toOpenRouterImageReference } from './image-source.js';
5
4
  import { ErrorCode, toolError, toolErrorFrom } from '../errors.js';
@@ -8,11 +7,14 @@ import { logger } from '../logger.js';
8
7
  import { classifyUpstreamError } from './openrouter-errors.js';
9
8
  import { buildBinaryToolResult } from './tool-result-payload.js';
10
9
  import { fetchHttpResource, readEnvInt } from './fetch-utils.js';
11
- import { buildCacheHeaders } from './cache.js';
10
+ import { buildCacheHeaders, validateCacheOptions } from './cache.js';
11
+ import { writeOutputFile } from './path-utils.js';
12
12
  const DEFAULT_MODEL = 'google/gemini-2.5-flash-image';
13
- const VALID_RESOLUTIONS = new Set(['512', '0.5K', '1K', '2K', '4K']);
14
- const VALID_QUALITIES = new Set(['auto', 'low', 'medium', 'high']);
15
- const VALID_OUTPUT_FORMATS = new Set(['png', 'jpeg', 'webp', 'svg']);
13
+ const MAX_IMAGES = 10;
14
+ const VALID_ASPECT_RATIOS = new Set(IMAGE_ASPECT_RATIOS);
15
+ const VALID_RESOLUTIONS = new Set(IMAGE_DEDICATED_RESOLUTIONS);
16
+ const VALID_QUALITIES = new Set(IMAGE_DEDICATED_QUALITIES);
17
+ const VALID_OUTPUT_FORMATS = new Set(IMAGE_OUTPUT_FORMATS);
16
18
  const MIME_BY_FORMAT = {
17
19
  png: 'image/png',
18
20
  webp: 'image/webp',
@@ -35,6 +37,9 @@ export async function handleGenerateImageDedicated(request, apiClient) {
35
37
  input_references_count: input_references?.length ?? 0,
36
38
  save_path: save_path ? 'provided' : 'none',
37
39
  });
40
+ if (aspect_ratio && !VALID_ASPECT_RATIOS.has(aspect_ratio)) {
41
+ return toolError(ErrorCode.INVALID_INPUT, `aspect_ratio '${aspect_ratio}' is not supported. Valid: ${[...VALID_ASPECT_RATIOS].join(', ')}.`);
42
+ }
38
43
  if (resolution && !VALID_RESOLUTIONS.has(resolution)) {
39
44
  return toolError(ErrorCode.INVALID_INPUT, `resolution '${resolution}' is not supported. Valid: ${[...VALID_RESOLUTIONS].join(', ')}.`);
40
45
  }
@@ -44,6 +49,12 @@ export async function handleGenerateImageDedicated(request, apiClient) {
44
49
  if (output_format && !VALID_OUTPUT_FORMATS.has(output_format)) {
45
50
  return toolError(ErrorCode.INVALID_INPUT, `output_format '${output_format}' is not supported. Valid: ${[...VALID_OUTPUT_FORMATS].join(', ')}.`);
46
51
  }
52
+ if (typeof n === 'number' && (n < 1 || n > MAX_IMAGES)) {
53
+ return toolError(ErrorCode.INVALID_INPUT, `n must be between 1 and ${MAX_IMAGES} (inclusive).`);
54
+ }
55
+ const cacheError = validateCacheOptions({ cache, cache_ttl, cache_clear });
56
+ if (cacheError)
57
+ return cacheError;
47
58
  const savePathResult = await resolveOptionalOutputPath(save_path);
48
59
  if (isToolErrorResult(savePathResult))
49
60
  return savePathResult;
@@ -60,7 +71,7 @@ export async function handleGenerateImageDedicated(request, apiClient) {
60
71
  body.quality = quality;
61
72
  if (output_format)
62
73
  body.output_format = output_format;
63
- if (typeof n === 'number' && n > 0)
74
+ if (typeof n === 'number')
64
75
  body.n = n;
65
76
  if (provider && typeof provider === 'object')
66
77
  body.provider = provider;
@@ -90,38 +101,30 @@ export async function handleGenerateImageDedicated(request, apiClient) {
90
101
  });
91
102
  }
92
103
  const firstImage = images[0];
93
- const imageData = firstImage.b64_json;
94
104
  const mimeType = MIME_BY_FORMAT[output_format ?? ''] ?? 'image/png';
95
105
  const baseMeta = {
96
106
  server_version: SERVER_VERSION,
97
107
  model: model || DEFAULT_MODEL,
98
108
  images_count: images.length,
109
+ saved_image_index: 0,
99
110
  };
111
+ if (images.length > 1) {
112
+ baseMeta.images_note = 'Only images[0] is saved or inlined; request n=1 for a single image.';
113
+ }
100
114
  if (response.usage)
101
115
  baseMeta.usage = response.usage;
102
116
  if (firstImage.revised_prompt)
103
117
  baseMeta.revised_prompt = firstImage.revised_prompt;
118
+ const decoded = decodeImageBuffer(firstImage.b64_json);
104
119
  if (safeSavePath) {
105
- let buffer = null;
106
- if (imageData) {
107
- try {
108
- buffer = Buffer.from(imageData, 'base64');
109
- if (buffer.length === 0)
110
- buffer = null;
111
- }
112
- catch {
113
- buffer = null;
114
- }
115
- }
116
- if (buffer) {
120
+ if (decoded) {
117
121
  try {
118
- await fs.writeFile(safeSavePath, buffer);
122
+ await writeOutputFile(safeSavePath, decoded);
119
123
  }
120
124
  catch (err) {
121
125
  return toolErrorFrom(ErrorCode.INTERNAL, err, 'Write');
122
126
  }
123
- baseMeta.save_path = safeSavePath;
124
- return buildBinaryToolResult({ kind: 'image', buffer, mimeType }, {
127
+ return buildBinaryToolResult({ kind: 'image', buffer: decoded, mimeType }, {
125
128
  savedPath: safeSavePath,
126
129
  summaryText: `Image saved to: ${safeSavePath}`,
127
130
  meta: baseMeta,
@@ -135,9 +138,11 @@ export async function handleGenerateImageDedicated(request, apiClient) {
135
138
  maxRedirects: 3,
136
139
  timeoutMs: 30_000,
137
140
  });
141
+ if (fetched.length === 0) {
142
+ return toolError(ErrorCode.UPSTREAM_REFUSED, 'Downloaded image URL returned empty body.');
143
+ }
138
144
  const resolvedMime = contentType?.split(';')[0]?.trim() || mimeType;
139
- await fs.writeFile(safeSavePath, fetched);
140
- baseMeta.save_path = safeSavePath;
145
+ await writeOutputFile(safeSavePath, fetched);
141
146
  return buildBinaryToolResult({ kind: 'image', buffer: fetched, mimeType: resolvedMime }, {
142
147
  savedPath: safeSavePath,
143
148
  summaryText: `Image saved to: ${safeSavePath}`,
@@ -150,11 +155,25 @@ export async function handleGenerateImageDedicated(request, apiClient) {
150
155
  }
151
156
  return toolError(ErrorCode.UPSTREAM_REFUSED, 'Model returned no usable image data for save_path (empty b64_json and URL download unavailable).');
152
157
  }
153
- if (imageData) {
154
- return buildBinaryToolResult({ kind: 'image', buffer: Buffer.from(imageData, 'base64'), mimeType }, { inlineOnly: true, meta: baseMeta });
158
+ if (decoded) {
159
+ return buildBinaryToolResult({ kind: 'image', buffer: decoded, mimeType }, { inlineOnly: true, meta: baseMeta });
160
+ }
161
+ if (firstImage.url) {
162
+ return {
163
+ content: [{ type: 'text', text: `Image generated. URL: ${firstImage.url}` }],
164
+ _meta: { ...baseMeta, image_url: firstImage.url },
165
+ };
166
+ }
167
+ return toolError(ErrorCode.UPSTREAM_REFUSED, 'Model returned no usable image data.');
168
+ }
169
+ function decodeImageBuffer(b64) {
170
+ if (!b64)
171
+ return null;
172
+ try {
173
+ const buffer = Buffer.from(b64, 'base64');
174
+ return buffer.length > 0 ? buffer : null;
175
+ }
176
+ catch {
177
+ return null;
155
178
  }
156
- return {
157
- content: [{ type: 'text', text: `Image generated. URL: ${firstImage.url}` }],
158
- _meta: { ...baseMeta, image_url: firstImage.url },
159
- };
160
179
  }
@@ -13,7 +13,7 @@ export declare function handleGenerateImage(request: {
13
13
  params: {
14
14
  arguments: GenerateImageToolRequest;
15
15
  };
16
- }, openai: OpenAI): Promise<import("../errors.js").ToolErrorResult | {
16
+ }, openai: OpenAI): Promise<{
17
17
  content: import("./tool-result-payload.js").BinaryToolContent[];
18
18
  _meta: Record<string, unknown>;
19
19
  }>;
@@ -1,4 +1,4 @@
1
- import { promises as fs } from 'node:fs';
1
+ import { IMAGE_ASPECT_RATIOS } from '../tool-definitions.js';
2
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';
@@ -8,23 +8,9 @@ import { SERVER_VERSION } from '../version.js';
8
8
  import { logger } from '../logger.js';
9
9
  import { classifyUpstreamError } from './openrouter-errors.js';
10
10
  import { buildBinaryToolResult } from './tool-result-payload.js';
11
+ import { writeOutputFile } from './path-utils.js';
11
12
  const DEFAULT_MODEL = 'google/gemini-2.5-flash-image';
12
- const VALID_ASPECT_RATIOS = new Set([
13
- '1:1',
14
- '2:3',
15
- '3:2',
16
- '3:4',
17
- '4:3',
18
- '4:5',
19
- '5:4',
20
- '9:16',
21
- '16:9',
22
- '21:9',
23
- '1:4',
24
- '4:1',
25
- '1:8',
26
- '8:1',
27
- ]);
13
+ const VALID_ASPECT_RATIOS = new Set(IMAGE_ASPECT_RATIOS);
28
14
  const VALID_IMAGE_SIZES = new Set(['0.5K', '1K', '2K', '4K']);
29
15
  export async function handleGenerateImage(request, openai) {
30
16
  const { prompt, model, save_path, aspect_ratio, image_size, max_tokens, input_images, modalities, } = request.params.arguments ?? { prompt: '' };
@@ -45,6 +31,14 @@ export async function handleGenerateImage(request, openai) {
45
31
  if (image_size !== undefined && !VALID_IMAGE_SIZES.has(image_size)) {
46
32
  return invalidEnumError('image_size', image_size, VALID_IMAGE_SIZES);
47
33
  }
34
+ if (max_tokens !== undefined) {
35
+ if (typeof max_tokens !== 'number' ||
36
+ !Number.isFinite(max_tokens) ||
37
+ max_tokens <= 0 ||
38
+ !Number.isInteger(max_tokens)) {
39
+ return toolError(ErrorCode.INVALID_INPUT, 'max_tokens must be a positive integer.');
40
+ }
41
+ }
48
42
  const savePathResult = await resolveOptionalOutputPath(save_path);
49
43
  if (isToolErrorResult(savePathResult))
50
44
  return savePathResult;
@@ -71,7 +65,7 @@ export async function handleGenerateImage(request, openai) {
71
65
  };
72
66
  if (Object.keys(imageConfig).length > 0)
73
67
  body.image_config = imageConfig;
74
- if (typeof max_tokens === 'number' && max_tokens > 0)
68
+ if (typeof max_tokens === 'number')
75
69
  body.max_tokens = max_tokens;
76
70
  let completion;
77
71
  try {
@@ -93,20 +87,24 @@ export async function handleGenerateImage(request, openai) {
93
87
  finish_reason: completion.choices[0]?.finish_reason,
94
88
  });
95
89
  }
90
+ const buffer = Buffer.from(base64.data, 'base64');
91
+ if (buffer.length === 0) {
92
+ return toolError(ErrorCode.UPSTREAM_REFUSED, 'Model returned an empty image payload.');
93
+ }
96
94
  if (safePathResolved) {
97
95
  try {
98
- await fs.writeFile(safePathResolved, base64.data, { encoding: 'base64' });
96
+ await writeOutputFile(safePathResolved, buffer);
99
97
  }
100
98
  catch (err) {
101
99
  return toolErrorFrom(ErrorCode.INTERNAL, err, 'Write');
102
100
  }
103
101
  }
104
- return buildImageSuccessResult(base64, completion.usage, safePathResolved ?? undefined);
102
+ return buildImageSuccessResult(base64, buffer, completion.usage, safePathResolved ?? undefined);
105
103
  }
106
104
  function invalidEnumError(field, value, allowed) {
107
105
  return toolError(ErrorCode.INVALID_INPUT, `${field} '${value}' is not supported. Valid values: ${[...allowed].join(', ')}.`);
108
106
  }
109
- function buildImageSuccessResult(base64, usage, savePath) {
107
+ function buildImageSuccessResult(base64, buffer, usage, savePath) {
110
108
  const usageMeta = usage
111
109
  ? {
112
110
  usage: {
@@ -116,7 +114,6 @@ function buildImageSuccessResult(base64, usage, savePath) {
116
114
  },
117
115
  }
118
116
  : {};
119
- const buffer = Buffer.from(base64.data, 'base64');
120
117
  return buildBinaryToolResult({ kind: 'image', buffer, mimeType: base64.mime }, {
121
118
  savedPath: savePath ?? null,
122
119
  inlineOnly: !savePath,
@@ -17,7 +17,6 @@ export interface GenerateVideoToolRequest {
17
17
  export interface GetVideoStatusToolRequest {
18
18
  video_id: string;
19
19
  save_path?: string;
20
- polling_url?: string;
21
20
  }
22
21
  type ProgressHook = (update: {
23
22
  status: string;
@@ -25,9 +24,10 @@ type ProgressHook = (update: {
25
24
  attempt: number;
26
25
  video_id: string;
27
26
  }) => void | Promise<void>;
27
+ declare function isTerminalFailureStatus(status: string): boolean;
28
+ declare function invokeProgressHook(hook: ProgressHook | undefined, update: Parameters<ProgressHook>[0]): Promise<void>;
28
29
  declare function buildRequestBody(args: GenerateVideoToolRequest, model: string): Record<string, unknown>;
29
30
  declare function extractJobError(status: VideoJobStatus): string;
30
- declare function stripAndReplaceExt(p: string, newExt: string): string;
31
31
  export declare function handleGenerateVideo(request: {
32
32
  params: {
33
33
  arguments: GenerateVideoToolRequest;
@@ -109,7 +109,8 @@ export declare function handleGenerateVideoFromImage(request: {
109
109
  }>;
110
110
  export declare const _internals: {
111
111
  buildRequestBody: typeof buildRequestBody;
112
- stripAndReplaceExt: typeof stripAndReplaceExt;
113
112
  extractJobError: typeof extractJobError;
113
+ isTerminalFailureStatus: typeof isTerminalFailureStatus;
114
+ invokeProgressHook: typeof invokeProgressHook;
114
115
  };
115
116
  export {};
@@ -1,4 +1,3 @@
1
- import { promises as fs } from 'node:fs';
2
1
  import { extname } from 'node:path';
3
2
  import { ErrorCode, toolError, toolErrorFrom } from '../errors.js';
4
3
  import { SERVER_VERSION } from '../version.js';
@@ -8,6 +7,7 @@ import { resolveImageBase64 } from './image-source.js';
8
7
  import { readEnvInt } from './fetch-utils.js';
9
8
  import { classifyUpstreamError } from './openrouter-errors.js';
10
9
  import { buildBinaryToolResult } from './tool-result-payload.js';
10
+ import { replaceExtension, writeOutputFile } from './path-utils.js';
11
11
  const FALLBACK_MODEL = 'google/veo-3.1';
12
12
  const DEFAULT_POLL_INTERVAL_MS = 15_000;
13
13
  const DEFAULT_MAX_WAIT_MS = 10 * 60_000;
@@ -37,6 +37,23 @@ function checkSoraDeprecation(model) {
37
37
  `Your request will still be attempted, but may fail. Recommended alternatives:\n` +
38
38
  SORA_ALTERNATIVES.map((a) => ` • ${a}`).join('\n'));
39
39
  }
40
+ function isTerminalFailureStatus(status) {
41
+ const normalized = status.toLowerCase();
42
+ return normalized === 'failed' || normalized === 'cancelled' || normalized === 'canceled';
43
+ }
44
+ async function invokeProgressHook(hook, update) {
45
+ if (!hook)
46
+ return;
47
+ try {
48
+ await hook(update);
49
+ }
50
+ catch (err) {
51
+ logger.warn('generate_video.progress_hook_error', {
52
+ video_id: update.video_id,
53
+ err: err instanceof Error ? err.message : String(err),
54
+ });
55
+ }
56
+ }
40
57
  function getDefaultPollInterval() {
41
58
  return readEnvInt('OPENROUTER_VIDEO_POLL_INTERVAL_MS', DEFAULT_POLL_INTERVAL_MS, MIN_POLL_INTERVAL_MS);
42
59
  }
@@ -100,7 +117,11 @@ async function pollUntilTerminal(apiClient, envelope, opts) {
100
117
  let attempt = 0;
101
118
  let last = null;
102
119
  const initialStatus = (envelope.status ?? 'pending');
103
- await opts.onProgress?.({ status: initialStatus, attempt: 0, video_id: envelope.id });
120
+ await invokeProgressHook(opts.onProgress, {
121
+ status: initialStatus,
122
+ attempt: 0,
123
+ video_id: envelope.id,
124
+ });
104
125
  while (Date.now() < opts.deadlineAt) {
105
126
  attempt += 1;
106
127
  await sleep(Math.min(opts.pollIntervalMs, Math.max(0, opts.deadlineAt - Date.now())));
@@ -112,9 +133,9 @@ async function pollUntilTerminal(apiClient, envelope, opts) {
112
133
  id: envelope.id,
113
134
  err: err instanceof Error ? err.message : String(err),
114
135
  });
115
- continue; // transient; try again until deadline
136
+ continue;
116
137
  }
117
- await opts.onProgress?.({
138
+ await invokeProgressHook(opts.onProgress, {
118
139
  status: last.status,
119
140
  progress: typeof last.progress === 'number' ? last.progress : undefined,
120
141
  attempt,
@@ -122,7 +143,7 @@ async function pollUntilTerminal(apiClient, envelope, opts) {
122
143
  });
123
144
  if (last.status === 'completed')
124
145
  return { kind: 'completed', status: last };
125
- if (last.status === 'failed')
146
+ if (isTerminalFailureStatus(last.status))
126
147
  return { kind: 'failed', status: last };
127
148
  }
128
149
  return { kind: 'timeout', last };
@@ -145,14 +166,17 @@ async function finalizeCompletedJob(apiClient, status, savePath) {
145
166
  throw new Error('Completed job returned no content URLs.');
146
167
  }
147
168
  const { buffer, contentType } = await apiClient.downloadVideoContent(status.id, 0, getMaxDownloadBytes());
169
+ if (buffer.length === 0) {
170
+ throw new Error('Completed job returned empty video content.');
171
+ }
148
172
  const mime = (contentType?.split(';')[0]?.trim() || 'video/mp4').toLowerCase();
149
173
  const ext = mime.includes('webm')
150
- ? '.webm'
174
+ ? 'webm'
151
175
  : mime.includes('mov')
152
- ? '.mov'
176
+ ? 'mov'
153
177
  : mime.includes('mpeg')
154
- ? '.mpeg'
155
- : '.mp4';
178
+ ? 'mpeg'
179
+ : 'mp4';
156
180
  const baseMeta = {
157
181
  server_version: SERVER_VERSION,
158
182
  video_id: status.id,
@@ -164,8 +188,8 @@ async function finalizeCompletedJob(apiClient, status, savePath) {
164
188
  if (status.unsigned_urls)
165
189
  baseMeta.unsigned_urls = status.unsigned_urls;
166
190
  if (savePath) {
167
- const finalPath = extname(savePath) === ext ? savePath : stripAndReplaceExt(savePath, ext);
168
- await fs.writeFile(finalPath, buffer);
191
+ const finalPath = extname(savePath) === `.${ext}` ? savePath : replaceExtension(savePath, ext);
192
+ await writeOutputFile(finalPath, buffer);
169
193
  baseMeta.save_path = finalPath;
170
194
  const summaryNote = finalPath !== savePath ? ` (detected ${mime}, saved as ${finalPath})` : '';
171
195
  return buildBinaryToolResult({ kind: 'video', buffer, mimeType: mime }, {
@@ -179,11 +203,6 @@ async function finalizeCompletedJob(apiClient, status, savePath) {
179
203
  meta: baseMeta,
180
204
  });
181
205
  }
182
- function stripAndReplaceExt(p, newExt) {
183
- const cur = extname(p);
184
- const base = cur ? p.slice(0, -cur.length) : p;
185
- return base + newExt;
186
- }
187
206
  export async function handleGenerateVideo(request, apiClient, progress) {
188
207
  const args = request.params.arguments ?? {};
189
208
  if (!args.prompt || !args.prompt.trim()) {
@@ -289,7 +308,7 @@ export async function handleGetVideoStatus(request, apiClient) {
289
308
  catch (err) {
290
309
  return classifyUpstreamError(err, 'get_video_status.poll');
291
310
  }
292
- if (status.status === 'failed') {
311
+ if (status.status === 'failed' || isTerminalFailureStatus(status.status)) {
293
312
  return toolError(ErrorCode.JOB_FAILED, extractJobError(status), { video_id: id });
294
313
  }
295
314
  if (status.status === 'completed') {
@@ -345,4 +364,9 @@ export async function handleGenerateVideoFromImage(request, apiClient, progress)
345
364
  },
346
365
  }, apiClient, progress);
347
366
  }
348
- export const _internals = { buildRequestBody, stripAndReplaceExt, extractJobError };
367
+ export const _internals = {
368
+ buildRequestBody,
369
+ extractJobError,
370
+ isTerminalFailureStatus,
371
+ invokeProgressHook,
372
+ };
@@ -17,7 +17,7 @@ export async function handleGetModelInfo(request, modelCache, apiClient) {
17
17
  if (!modelCache.isValid()) {
18
18
  return toolError(ErrorCode.INTERNAL, 'No model data available.');
19
19
  }
20
- const info = modelCache.get(model);
20
+ const info = modelCache.lookup(model);
21
21
  if (!info) {
22
22
  return toolError(ErrorCode.MODEL_NOT_FOUND, `Model '${model}' not found.`);
23
23
  }
@@ -1,24 +1,48 @@
1
1
  import { SERVER_VERSION, MCP_PROTOCOL_VERSION } from '../version.js';
2
2
  import { buildStructuredResult } from './structured-output.js';
3
+ import { classifyUpstreamError } from './openrouter-errors.js';
3
4
  /** Liveness probe — validates API key, reachability, and cached model count. */
4
5
  export async function handleHealthCheck(_request, apiClient, modelCache) {
5
- let apiKeyValid = false;
6
- let errorMessage;
7
6
  try {
8
- await modelCache.ensureFresh(() => apiClient.getModels());
9
- apiKeyValid = true;
7
+ let apiKeyValid = false;
8
+ let errorMessage;
9
+ let errorMeta = {};
10
+ try {
11
+ await modelCache.ensureFresh(() => apiClient.getModels());
12
+ apiKeyValid = true;
13
+ }
14
+ catch (err) {
15
+ const classified = classifyUpstreamError(err, 'health_check');
16
+ errorMessage = classified.content[0]?.text;
17
+ errorMeta = {
18
+ code: classified._meta.code,
19
+ ...(classified._meta.suggestions ? { suggestions: classified._meta.suggestions } : {}),
20
+ ...(classified._meta.details ? { details: classified._meta.details } : {}),
21
+ };
22
+ }
23
+ const modelsCached = modelCache.isValid() ? modelCache.size() : 0;
24
+ const ok = apiKeyValid;
25
+ return buildStructuredResult({
26
+ ok,
27
+ server_version: SERVER_VERSION,
28
+ protocol_version: MCP_PROTOCOL_VERSION,
29
+ api_key_valid: apiKeyValid,
30
+ models_cached: modelsCached,
31
+ ...(errorMessage ? { error: errorMessage } : {}),
32
+ }, errorMeta);
10
33
  }
11
34
  catch (err) {
12
- errorMessage = err instanceof Error ? err.message : String(err);
35
+ const classified = classifyUpstreamError(err, 'health_check');
36
+ return buildStructuredResult({
37
+ ok: false,
38
+ server_version: SERVER_VERSION,
39
+ protocol_version: MCP_PROTOCOL_VERSION,
40
+ api_key_valid: false,
41
+ models_cached: 0,
42
+ error: classified.content[0]?.text,
43
+ }, {
44
+ code: classified._meta.code,
45
+ ...(classified._meta.suggestions ? { suggestions: classified._meta.suggestions } : {}),
46
+ });
13
47
  }
14
- const modelsCached = modelCache.isValid() ? modelCache.size() : 0;
15
- const ok = apiKeyValid;
16
- return buildStructuredResult({
17
- ok,
18
- server_version: SERVER_VERSION,
19
- protocol_version: MCP_PROTOCOL_VERSION,
20
- api_key_valid: apiKeyValid,
21
- models_cached: modelsCached,
22
- ...(errorMessage ? { error: errorMessage } : {}),
23
- });
24
48
  }
@@ -1,5 +1,5 @@
1
- import path from 'path';
2
- import { promises as fs } from 'fs';
1
+ import path from 'node:path';
2
+ import { promises as fs } from 'node:fs';
3
3
  import { readEnvInt, isBlockedIPv4 as _isBlockedIPv4, assertUrlSafeForFetch as _assertUrlSafeForFetch, fetchHttpResource, parseBase64DataUrl, } from './fetch-utils.js';
4
4
  import { resolveSafeInputPath } from './path-safety.js';
5
5
  // Re-export for backward compatibility (tests import from image-utils)
@@ -2,5 +2,7 @@
2
2
  * Map OpenRouter / OpenAI SDK errors to our closed `ErrorCode` enum.
3
3
  */
4
4
  import { type ToolErrorResult } from '../errors.js';
5
+ /** Strip bearer tokens and OpenRouter key material from user-visible messages. */
6
+ export declare function sanitizeErrorMessage(msg: string): string;
5
7
  /** Classify upstream errors into the closed `ErrorCode` set. */
6
8
  export declare function classifyUpstreamError(err: unknown, contextMessage?: string): ToolErrorResult;