@stabgan/openrouter-mcp-multimodal 4.6.2 → 4.8.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 (64) hide show
  1. package/README.md +108 -37
  2. package/dist/errors.d.ts +5 -20
  3. package/dist/errors.js +1 -10
  4. package/dist/index.js +7 -1
  5. package/dist/logger.js +54 -24
  6. package/dist/model-cache.d.ts +13 -0
  7. package/dist/model-cache.js +61 -5
  8. package/dist/openrouter-api.d.ts +14 -15
  9. package/dist/openrouter-api.js +68 -22
  10. package/dist/openrouter-openai-client.d.ts +9 -0
  11. package/dist/openrouter-openai-client.js +15 -0
  12. package/dist/tool-definitions.d.ts +24 -0
  13. package/dist/tool-definitions.js +283 -170
  14. package/dist/tool-descriptions.js +23 -15
  15. package/dist/tool-handlers/analyze-audio.js +4 -1
  16. package/dist/tool-handlers/analyze-image.js +10 -5
  17. package/dist/tool-handlers/analyze-video.js +9 -5
  18. package/dist/tool-handlers/async-chat.d.ts +17 -0
  19. package/dist/tool-handlers/async-chat.js +112 -31
  20. package/dist/tool-handlers/audio-utils.d.ts +19 -4
  21. package/dist/tool-handlers/audio-utils.js +170 -16
  22. package/dist/tool-handlers/cache.d.ts +3 -3
  23. package/dist/tool-handlers/cache.js +56 -4
  24. package/dist/tool-handlers/chat-completion.js +16 -7
  25. package/dist/tool-handlers/chat-request.d.ts +3 -0
  26. package/dist/tool-handlers/chat-request.js +28 -0
  27. package/dist/tool-handlers/completion-utils.d.ts +5 -11
  28. package/dist/tool-handlers/completion-utils.js +76 -47
  29. package/dist/tool-handlers/fetch-utils.d.ts +14 -0
  30. package/dist/tool-handlers/fetch-utils.js +331 -68
  31. package/dist/tool-handlers/generate-audio.d.ts +6 -51
  32. package/dist/tool-handlers/generate-audio.js +30 -76
  33. package/dist/tool-handlers/generate-image-dedicated.d.ts +2 -12
  34. package/dist/tool-handlers/generate-image-dedicated.js +79 -28
  35. package/dist/tool-handlers/generate-image.d.ts +3 -46
  36. package/dist/tool-handlers/generate-image.js +26 -43
  37. package/dist/tool-handlers/generate-video.d.ts +4 -3
  38. package/dist/tool-handlers/generate-video.js +51 -50
  39. package/dist/tool-handlers/get-model-info.js +1 -1
  40. package/dist/tool-handlers/health-check.js +39 -15
  41. package/dist/tool-handlers/image-utils.js +2 -2
  42. package/dist/tool-handlers/openrouter-errors.d.ts +2 -0
  43. package/dist/tool-handlers/openrouter-errors.js +138 -31
  44. package/dist/tool-handlers/path-safety.d.ts +6 -0
  45. package/dist/tool-handlers/path-safety.js +77 -14
  46. package/dist/tool-handlers/path-utils.d.ts +4 -0
  47. package/dist/tool-handlers/path-utils.js +20 -0
  48. package/dist/tool-handlers/provider-routing.d.ts +2 -0
  49. package/dist/tool-handlers/provider-routing.js +10 -0
  50. package/dist/tool-handlers/rerank.d.ts +1 -4
  51. package/dist/tool-handlers/rerank.js +43 -14
  52. package/dist/tool-handlers/search-models.js +3 -3
  53. package/dist/tool-handlers/speech-to-text.d.ts +1 -0
  54. package/dist/tool-handlers/speech-to-text.js +23 -56
  55. package/dist/tool-handlers/text-to-speech.d.ts +2 -12
  56. package/dist/tool-handlers/text-to-speech.js +29 -22
  57. package/dist/tool-handlers/tool-result-payload.d.ts +47 -0
  58. package/dist/tool-handlers/tool-result-payload.js +98 -0
  59. package/dist/tool-handlers/validate-model.js +1 -1
  60. package/dist/tool-handlers.d.ts +9 -0
  61. package/dist/tool-handlers.js +19 -10
  62. package/dist/version.d.ts +1 -1
  63. package/dist/version.js +1 -1
  64. package/package.json +1 -3
@@ -1,15 +1,19 @@
1
- import { promises as fs } from 'node:fs';
2
1
  import { extname } from 'node:path';
2
+ import { GENERATE_AUDIO_FORMATS } from '../tool-definitions.js';
3
3
  import { resolveOptionalOutputPath, isToolErrorResult } from './path-safety.js';
4
4
  import { asOpenAIChatBody } from './chat-request.js';
5
5
  import { ErrorCode, toolError } from '../errors.js';
6
6
  import { SERVER_VERSION } from '../version.js';
7
7
  import { logger } from '../logger.js';
8
8
  import { classifyUpstreamError } from './openrouter-errors.js';
9
+ import { buildBinaryToolResult } from './tool-result-payload.js';
10
+ import { replaceExtension, writeOutputFile } from './path-utils.js';
11
+ import { detectAudioFormat } from './audio-utils.js';
12
+ export { detectAudioFormat } from './audio-utils.js';
9
13
  const DEFAULT_MODEL = 'openai/gpt-audio';
10
14
  const DEFAULT_VOICE = 'alloy';
11
15
  const DEFAULT_FORMAT = 'pcm16';
12
- const VALID_FORMATS = ['wav', 'mp3', 'flac', 'opus', 'pcm16'];
16
+ const VALID_FORMATS = GENERATE_AUDIO_FORMATS;
13
17
  const DEFAULT_PCM_SAMPLE_RATE = 24000;
14
18
  const PCM_BITS_PER_SAMPLE = 16;
15
19
  const PCM_NUM_CHANNELS = 1;
@@ -33,57 +37,16 @@ export function createWavHeader(dataLength, sampleRate = DEFAULT_PCM_SAMPLE_RATE
33
37
  header.writeUInt32LE(dataLength, 40);
34
38
  return header;
35
39
  }
36
- /**
37
- * Detect audio container format from magic bytes. Uses `Buffer.subarray()`
38
- * (not deprecated `slice()`). MP3 detection is intentionally strict:
39
- * - Accept ID3v2 tags (`'ID3'`) as unambiguous MP3.
40
- * - Accept raw frame sync only when every MPEG header field falls in a
41
- * non-reserved range: version != 0b01, layer != 0b00, bitrate != 0b1111,
42
- * sample rate index != 0b11. This removes the false positives that a
43
- * sync-word-only check produces on random binary.
44
- */
45
- export function detectAudioFormat(data) {
46
- if (data.length >= 3 && data[0] === 0x49 && data[1] === 0x44 && data[2] === 0x33) {
47
- return { ext: 'mp3', mimeType: 'audio/mpeg' };
48
- }
49
- if (data.length >= 4 && data[0] === 0xff && (data[1] & 0xe0) === 0xe0) {
50
- const b1 = data[1];
51
- const b2 = data[2];
52
- const versionBits = (b1 >> 3) & 0x03; // 01 = reserved
53
- const layerBits = (b1 >> 1) & 0x03; // 00 = reserved
54
- const bitrateIndex = (b2 >> 4) & 0x0f; // 1111 = bad
55
- const sampleRateIndex = (b2 >> 2) & 0x03; // 11 = reserved
56
- if (versionBits !== 0x01 &&
57
- layerBits !== 0x00 &&
58
- bitrateIndex !== 0x0f &&
59
- sampleRateIndex !== 0x03) {
60
- return { ext: 'mp3', mimeType: 'audio/mpeg' };
61
- }
62
- }
63
- if (data.length >= 12) {
64
- const riff = data.subarray(0, 4).toString('ascii');
65
- const wave = data.subarray(8, 12).toString('ascii');
66
- if (riff === 'RIFF' && wave === 'WAVE') {
67
- return { ext: 'wav', mimeType: 'audio/wav' };
68
- }
69
- }
70
- if (data.length >= 4) {
71
- const magic = data.subarray(0, 4).toString('ascii');
72
- if (magic === 'fLaC')
73
- return { ext: 'flac', mimeType: 'audio/flac' };
74
- if (magic === 'OggS')
75
- return { ext: 'ogg', mimeType: 'audio/ogg' };
76
- }
77
- return { ext: 'pcm', mimeType: 'audio/pcm' };
78
- }
79
40
  export function wrapPcmInWav(pcmData, sampleRate = DEFAULT_PCM_SAMPLE_RATE) {
80
41
  return Buffer.concat([createWavHeader(pcmData.length, sampleRate), pcmData]);
81
42
  }
82
- /** Strip existing extension (if any) and append a new one. */
83
- export function replaceExtension(filePath, newExt) {
84
- const current = extname(filePath);
85
- const base = current ? filePath.slice(0, -current.length) : filePath;
86
- return `${base}.${newExt}`;
43
+ /** Decode each streamed base64 fragment and concatenate binary (joining strings corrupts padding). */
44
+ export function assembleBase64AudioChunks(chunks) {
45
+ if (chunks.length === 0)
46
+ return Buffer.alloc(0);
47
+ if (chunks.length === 1)
48
+ return Buffer.from(chunks[0], 'base64');
49
+ return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk, 'base64')));
87
50
  }
88
51
  export async function handleGenerateAudio(request, openai) {
89
52
  const { prompt, model, voice, format, save_path } = request.params.arguments ?? {
@@ -92,10 +55,13 @@ export async function handleGenerateAudio(request, openai) {
92
55
  if (!prompt?.trim()) {
93
56
  return toolError(ErrorCode.INVALID_INPUT, 'prompt is required.');
94
57
  }
58
+ if (format && !VALID_FORMATS.includes(format)) {
59
+ return toolError(ErrorCode.INVALID_INPUT, `format '${format}' is not supported. Valid: ${VALID_FORMATS.join(', ')}.`);
60
+ }
95
61
  logger.audit('generate_audio.start', {
96
62
  model: model || DEFAULT_MODEL,
97
63
  voice: voice?.trim() || DEFAULT_VOICE,
98
- format: VALID_FORMATS.includes(format ?? '') ? format : DEFAULT_FORMAT,
64
+ format: format || DEFAULT_FORMAT,
99
65
  prompt_preview: prompt.slice(0, 80),
100
66
  save_path: save_path ? 'provided' : 'none',
101
67
  });
@@ -134,55 +100,43 @@ export async function handleGenerateAudio(request, openai) {
134
100
  transcriptChunks.push(a.transcript);
135
101
  }
136
102
  }
137
- const fullAudioBase64 = audioChunks.join('');
138
103
  const transcript = transcriptChunks.join('');
139
- if (!fullAudioBase64) {
104
+ if (audioChunks.length === 0) {
140
105
  return toolError(ErrorCode.INTERNAL, transcript
141
106
  ? `No audio returned (model emitted transcript only): ${transcript.slice(0, 300)}`
142
107
  : 'No audio returned.', { reason: 'no_audio_in_stream' });
143
108
  }
144
- let audioBuffer = Buffer.from(fullAudioBase64, 'base64');
109
+ let audioBuffer = assembleBase64AudioChunks(audioChunks);
145
110
  const detected = detectAudioFormat(audioBuffer);
146
111
  if (detected.ext === 'pcm') {
147
- audioBuffer = Buffer.from(wrapPcmInWav(audioBuffer));
112
+ audioBuffer = wrapPcmInWav(audioBuffer);
148
113
  detected.ext = 'wav';
149
114
  detected.mimeType = 'audio/wav';
150
115
  }
151
- const returnBase64 = audioBuffer.toString('base64');
152
116
  if (safeBase) {
153
117
  const fileExt = extname(safeBase).toLowerCase().slice(1);
154
118
  const actualSavePath = fileExt === detected.ext ? safeBase : replaceExtension(safeBase, detected.ext);
155
- await fs.writeFile(actualSavePath, audioBuffer);
119
+ await writeOutputFile(actualSavePath, audioBuffer);
156
120
  const formatNote = actualSavePath !== safeBase
157
121
  ? ` (detected ${detected.ext.toUpperCase()}, saved as ${actualSavePath})`
158
122
  : '';
159
123
  const result = transcript
160
124
  ? `Audio saved to: ${actualSavePath}${formatNote}\nTranscript: ${transcript}`
161
125
  : `Audio saved to: ${actualSavePath}${formatNote}`;
162
- return {
163
- content: [
164
- { type: 'text', text: result },
165
- { type: 'audio', mimeType: detected.mimeType, data: returnBase64 },
166
- ],
167
- _meta: {
126
+ return buildBinaryToolResult({ kind: 'audio', buffer: audioBuffer, mimeType: detected.mimeType }, {
127
+ savedPath: actualSavePath,
128
+ summaryText: result,
129
+ meta: {
168
130
  server_version: SERVER_VERSION,
169
- save_path: actualSavePath,
170
- mime: detected.mimeType,
171
- size_bytes: audioBuffer.length,
172
131
  },
173
- };
132
+ });
174
133
  }
175
- return {
176
- content: [
177
- { type: 'text', text: transcript || 'Audio generated successfully.' },
178
- { type: 'audio', mimeType: detected.mimeType, data: returnBase64 },
179
- ],
180
- _meta: {
134
+ return buildBinaryToolResult({ kind: 'audio', buffer: audioBuffer, mimeType: detected.mimeType }, {
135
+ prefixText: transcript || 'Audio generated successfully.',
136
+ meta: {
181
137
  server_version: SERVER_VERSION,
182
- mime: detected.mimeType,
183
- size_bytes: audioBuffer.length,
184
138
  },
185
- };
139
+ });
186
140
  }
187
141
  catch (err) {
188
142
  return classifyUpstreamError(err, 'generate_audio (stream)');
@@ -16,17 +16,7 @@ export declare function handleGenerateImageDedicated(request: {
16
16
  params: {
17
17
  arguments: GenerateImageDedicatedRequest;
18
18
  };
19
- }, apiClient: OpenRouterAPIClient): Promise<import("../errors.js").ToolErrorResult | {
20
- content: ({
21
- type: "text";
22
- text: string;
23
- mimeType?: undefined;
24
- data?: undefined;
25
- } | {
26
- type: "image";
27
- mimeType: string;
28
- data: string;
29
- text?: undefined;
30
- })[];
19
+ }, apiClient: OpenRouterAPIClient): Promise<{
20
+ content: import("./tool-result-payload.js").BinaryToolContent[];
31
21
  _meta: Record<string, unknown>;
32
22
  }>;
@@ -1,16 +1,20 @@
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';
6
5
  import { SERVER_VERSION } from '../version.js';
7
6
  import { logger } from '../logger.js';
8
7
  import { classifyUpstreamError } from './openrouter-errors.js';
9
- import { buildCacheHeaders } from './cache.js';
8
+ import { buildBinaryToolResult } from './tool-result-payload.js';
9
+ import { fetchHttpResource, readEnvInt } from './fetch-utils.js';
10
+ import { buildCacheHeaders, validateCacheOptions } from './cache.js';
11
+ import { writeOutputFile } from './path-utils.js';
10
12
  const DEFAULT_MODEL = 'google/gemini-2.5-flash-image';
11
- const VALID_RESOLUTIONS = new Set(['512', '0.5K', '1K', '2K', '4K']);
12
- const VALID_QUALITIES = new Set(['auto', 'low', 'medium', 'high']);
13
- 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);
14
18
  const MIME_BY_FORMAT = {
15
19
  png: 'image/png',
16
20
  webp: 'image/webp',
@@ -33,6 +37,9 @@ export async function handleGenerateImageDedicated(request, apiClient) {
33
37
  input_references_count: input_references?.length ?? 0,
34
38
  save_path: save_path ? 'provided' : 'none',
35
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
+ }
36
43
  if (resolution && !VALID_RESOLUTIONS.has(resolution)) {
37
44
  return toolError(ErrorCode.INVALID_INPUT, `resolution '${resolution}' is not supported. Valid: ${[...VALID_RESOLUTIONS].join(', ')}.`);
38
45
  }
@@ -42,6 +49,12 @@ export async function handleGenerateImageDedicated(request, apiClient) {
42
49
  if (output_format && !VALID_OUTPUT_FORMATS.has(output_format)) {
43
50
  return toolError(ErrorCode.INVALID_INPUT, `output_format '${output_format}' is not supported. Valid: ${[...VALID_OUTPUT_FORMATS].join(', ')}.`);
44
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;
45
58
  const savePathResult = await resolveOptionalOutputPath(save_path);
46
59
  if (isToolErrorResult(savePathResult))
47
60
  return savePathResult;
@@ -58,7 +71,7 @@ export async function handleGenerateImageDedicated(request, apiClient) {
58
71
  body.quality = quality;
59
72
  if (output_format)
60
73
  body.output_format = output_format;
61
- if (typeof n === 'number' && n > 0)
74
+ if (typeof n === 'number')
62
75
  body.n = n;
63
76
  if (provider && typeof provider === 'object')
64
77
  body.provider = provider;
@@ -88,41 +101,79 @@ export async function handleGenerateImageDedicated(request, apiClient) {
88
101
  });
89
102
  }
90
103
  const firstImage = images[0];
91
- const imageData = firstImage.b64_json;
92
104
  const mimeType = MIME_BY_FORMAT[output_format ?? ''] ?? 'image/png';
93
105
  const baseMeta = {
94
106
  server_version: SERVER_VERSION,
95
107
  model: model || DEFAULT_MODEL,
96
108
  images_count: images.length,
109
+ saved_image_index: 0,
97
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
+ }
98
114
  if (response.usage)
99
115
  baseMeta.usage = response.usage;
100
116
  if (firstImage.revised_prompt)
101
117
  baseMeta.revised_prompt = firstImage.revised_prompt;
102
- if (safeSavePath && imageData) {
103
- try {
104
- await fs.writeFile(safeSavePath, imageData, { encoding: 'base64' });
118
+ const decoded = decodeImageBuffer(firstImage.b64_json);
119
+ if (safeSavePath) {
120
+ if (decoded) {
121
+ try {
122
+ await writeOutputFile(safeSavePath, decoded);
123
+ }
124
+ catch (err) {
125
+ return toolErrorFrom(ErrorCode.INTERNAL, err, 'Write');
126
+ }
127
+ return buildBinaryToolResult({ kind: 'image', buffer: decoded, mimeType }, {
128
+ savedPath: safeSavePath,
129
+ summaryText: `Image saved to: ${safeSavePath}`,
130
+ meta: baseMeta,
131
+ });
105
132
  }
106
- catch (err) {
107
- return toolErrorFrom(ErrorCode.INTERNAL, err, 'Write');
133
+ if (firstImage.url) {
134
+ try {
135
+ const maxBytes = readEnvInt('OPENROUTER_IMAGE_MAX_DOWNLOAD_BYTES', 20 * 1024 * 1024, 1024);
136
+ const { buffer: fetched, contentType } = await fetchHttpResource(firstImage.url, {
137
+ maxBytes,
138
+ maxRedirects: 3,
139
+ timeoutMs: 30_000,
140
+ });
141
+ if (fetched.length === 0) {
142
+ return toolError(ErrorCode.UPSTREAM_REFUSED, 'Downloaded image URL returned empty body.');
143
+ }
144
+ const resolvedMime = contentType?.split(';')[0]?.trim() || mimeType;
145
+ await writeOutputFile(safeSavePath, fetched);
146
+ return buildBinaryToolResult({ kind: 'image', buffer: fetched, mimeType: resolvedMime }, {
147
+ savedPath: safeSavePath,
148
+ summaryText: `Image saved to: ${safeSavePath}`,
149
+ meta: { ...baseMeta, mime: resolvedMime, image_url: firstImage.url },
150
+ });
151
+ }
152
+ catch (err) {
153
+ return toolErrorFrom(ErrorCode.UPSTREAM_HTTP, err, 'Download image URL for save_path');
154
+ }
108
155
  }
109
- baseMeta.save_path = safeSavePath;
110
- return {
111
- content: [
112
- { type: 'text', text: `Image saved to: ${safeSavePath}` },
113
- ...(imageData ? [{ type: 'image', mimeType, data: imageData }] : []),
114
- ],
115
- _meta: baseMeta,
116
- };
156
+ return toolError(ErrorCode.UPSTREAM_REFUSED, 'Model returned no usable image data for save_path (empty b64_json and URL download unavailable).');
117
157
  }
118
- if (imageData) {
158
+ if (decoded) {
159
+ return buildBinaryToolResult({ kind: 'image', buffer: decoded, mimeType }, { inlineOnly: true, meta: baseMeta });
160
+ }
161
+ if (firstImage.url) {
119
162
  return {
120
- content: [{ type: 'image', mimeType, data: imageData }],
121
- _meta: baseMeta,
163
+ content: [{ type: 'text', text: `Image generated. URL: ${firstImage.url}` }],
164
+ _meta: { ...baseMeta, image_url: firstImage.url },
122
165
  };
123
166
  }
124
- return {
125
- content: [{ type: 'text', text: `Image generated. URL: ${firstImage.url}` }],
126
- _meta: { ...baseMeta, image_url: firstImage.url },
127
- };
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;
178
+ }
128
179
  }
@@ -13,50 +13,7 @@ export declare function handleGenerateImage(request: {
13
13
  params: {
14
14
  arguments: GenerateImageToolRequest;
15
15
  };
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
- };
16
+ }, openai: OpenAI): Promise<{
17
+ content: import("./tool-result-payload.js").BinaryToolContent[];
18
+ _meta: Record<string, unknown>;
62
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';
@@ -7,23 +7,10 @@ import { ErrorCode, toolError, toolErrorFrom } from '../errors.js';
7
7
  import { SERVER_VERSION } from '../version.js';
8
8
  import { logger } from '../logger.js';
9
9
  import { classifyUpstreamError } from './openrouter-errors.js';
10
+ import { buildBinaryToolResult } from './tool-result-payload.js';
11
+ import { writeOutputFile } from './path-utils.js';
10
12
  const DEFAULT_MODEL = 'google/gemini-2.5-flash-image';
11
- const VALID_ASPECT_RATIOS = new Set([
12
- '1:1',
13
- '2:3',
14
- '3:2',
15
- '3:4',
16
- '4:3',
17
- '4:5',
18
- '5:4',
19
- '9:16',
20
- '16:9',
21
- '21:9',
22
- '1:4',
23
- '4:1',
24
- '1:8',
25
- '8:1',
26
- ]);
13
+ const VALID_ASPECT_RATIOS = new Set(IMAGE_ASPECT_RATIOS);
27
14
  const VALID_IMAGE_SIZES = new Set(['0.5K', '1K', '2K', '4K']);
28
15
  export async function handleGenerateImage(request, openai) {
29
16
  const { prompt, model, save_path, aspect_ratio, image_size, max_tokens, input_images, modalities, } = request.params.arguments ?? { prompt: '' };
@@ -44,6 +31,14 @@ export async function handleGenerateImage(request, openai) {
44
31
  if (image_size !== undefined && !VALID_IMAGE_SIZES.has(image_size)) {
45
32
  return invalidEnumError('image_size', image_size, VALID_IMAGE_SIZES);
46
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
+ }
47
42
  const savePathResult = await resolveOptionalOutputPath(save_path);
48
43
  if (isToolErrorResult(savePathResult))
49
44
  return savePathResult;
@@ -70,7 +65,7 @@ export async function handleGenerateImage(request, openai) {
70
65
  };
71
66
  if (Object.keys(imageConfig).length > 0)
72
67
  body.image_config = imageConfig;
73
- if (typeof max_tokens === 'number' && max_tokens > 0)
68
+ if (typeof max_tokens === 'number')
74
69
  body.max_tokens = max_tokens;
75
70
  let completion;
76
71
  try {
@@ -92,20 +87,24 @@ export async function handleGenerateImage(request, openai) {
92
87
  finish_reason: completion.choices[0]?.finish_reason,
93
88
  });
94
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
+ }
95
94
  if (safePathResolved) {
96
95
  try {
97
- await fs.writeFile(safePathResolved, base64.data, { encoding: 'base64' });
96
+ await writeOutputFile(safePathResolved, buffer);
98
97
  }
99
98
  catch (err) {
100
99
  return toolErrorFrom(ErrorCode.INTERNAL, err, 'Write');
101
100
  }
102
101
  }
103
- return buildImageSuccessResult(base64, completion.usage, safePathResolved ?? undefined);
102
+ return buildImageSuccessResult(base64, buffer, completion.usage, safePathResolved ?? undefined);
104
103
  }
105
104
  function invalidEnumError(field, value, allowed) {
106
105
  return toolError(ErrorCode.INVALID_INPUT, `${field} '${value}' is not supported. Valid values: ${[...allowed].join(', ')}.`);
107
106
  }
108
- function buildImageSuccessResult(base64, usage, savePath) {
107
+ function buildImageSuccessResult(base64, buffer, usage, savePath) {
109
108
  const usageMeta = usage
110
109
  ? {
111
110
  usage: {
@@ -115,28 +114,12 @@ function buildImageSuccessResult(base64, usage, savePath) {
115
114
  },
116
115
  }
117
116
  : {};
118
- if (savePath) {
119
- return {
120
- content: [
121
- { type: 'text', text: `Image saved to: ${savePath}` },
122
- { type: 'image', mimeType: base64.mime, data: base64.data },
123
- ],
124
- _meta: {
125
- server_version: SERVER_VERSION,
126
- save_path: savePath,
127
- mime: base64.mime,
128
- ...usageMeta,
129
- },
130
- };
131
- }
132
- return {
133
- content: [{ type: 'image', mimeType: base64.mime, data: base64.data }],
134
- _meta: {
135
- server_version: SERVER_VERSION,
136
- mime: base64.mime,
137
- ...usageMeta,
138
- },
139
- };
117
+ return buildBinaryToolResult({ kind: 'image', buffer, mimeType: base64.mime }, {
118
+ savedPath: savePath ?? null,
119
+ inlineOnly: !savePath,
120
+ summaryText: savePath ? `Image saved to: ${savePath}` : undefined,
121
+ meta: { server_version: SERVER_VERSION, ...usageMeta },
122
+ });
140
123
  }
141
124
  function extractBase64(message) {
142
125
  const images = message.images;
@@ -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 {};