@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.
- package/README.md +108 -37
- package/dist/errors.d.ts +5 -20
- package/dist/errors.js +1 -10
- package/dist/index.js +7 -1
- package/dist/logger.js +54 -24
- package/dist/model-cache.d.ts +13 -0
- package/dist/model-cache.js +61 -5
- package/dist/openrouter-api.d.ts +14 -15
- package/dist/openrouter-api.js +68 -22
- package/dist/openrouter-openai-client.d.ts +9 -0
- package/dist/openrouter-openai-client.js +15 -0
- package/dist/tool-definitions.d.ts +24 -0
- package/dist/tool-definitions.js +283 -170
- package/dist/tool-descriptions.js +23 -15
- package/dist/tool-handlers/analyze-audio.js +4 -1
- package/dist/tool-handlers/analyze-image.js +10 -5
- package/dist/tool-handlers/analyze-video.js +9 -5
- package/dist/tool-handlers/async-chat.d.ts +17 -0
- package/dist/tool-handlers/async-chat.js +112 -31
- package/dist/tool-handlers/audio-utils.d.ts +19 -4
- package/dist/tool-handlers/audio-utils.js +170 -16
- package/dist/tool-handlers/cache.d.ts +3 -3
- package/dist/tool-handlers/cache.js +56 -4
- package/dist/tool-handlers/chat-completion.js +16 -7
- package/dist/tool-handlers/chat-request.d.ts +3 -0
- package/dist/tool-handlers/chat-request.js +28 -0
- package/dist/tool-handlers/completion-utils.d.ts +5 -11
- package/dist/tool-handlers/completion-utils.js +76 -47
- package/dist/tool-handlers/fetch-utils.d.ts +14 -0
- package/dist/tool-handlers/fetch-utils.js +331 -68
- package/dist/tool-handlers/generate-audio.d.ts +6 -51
- package/dist/tool-handlers/generate-audio.js +30 -76
- package/dist/tool-handlers/generate-image-dedicated.d.ts +2 -12
- package/dist/tool-handlers/generate-image-dedicated.js +79 -28
- package/dist/tool-handlers/generate-image.d.ts +3 -46
- package/dist/tool-handlers/generate-image.js +26 -43
- package/dist/tool-handlers/generate-video.d.ts +4 -3
- package/dist/tool-handlers/generate-video.js +51 -50
- package/dist/tool-handlers/get-model-info.js +1 -1
- package/dist/tool-handlers/health-check.js +39 -15
- package/dist/tool-handlers/image-utils.js +2 -2
- package/dist/tool-handlers/openrouter-errors.d.ts +2 -0
- package/dist/tool-handlers/openrouter-errors.js +138 -31
- package/dist/tool-handlers/path-safety.d.ts +6 -0
- package/dist/tool-handlers/path-safety.js +77 -14
- package/dist/tool-handlers/path-utils.d.ts +4 -0
- package/dist/tool-handlers/path-utils.js +20 -0
- package/dist/tool-handlers/provider-routing.d.ts +2 -0
- package/dist/tool-handlers/provider-routing.js +10 -0
- package/dist/tool-handlers/rerank.d.ts +1 -4
- package/dist/tool-handlers/rerank.js +43 -14
- package/dist/tool-handlers/search-models.js +3 -3
- package/dist/tool-handlers/speech-to-text.d.ts +1 -0
- package/dist/tool-handlers/speech-to-text.js +23 -56
- package/dist/tool-handlers/text-to-speech.d.ts +2 -12
- package/dist/tool-handlers/text-to-speech.js +29 -22
- package/dist/tool-handlers/tool-result-payload.d.ts +47 -0
- package/dist/tool-handlers/tool-result-payload.js +98 -0
- package/dist/tool-handlers/validate-model.js +1 -1
- package/dist/tool-handlers.d.ts +9 -0
- package/dist/tool-handlers.js +19 -10
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- 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 =
|
|
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
|
-
/**
|
|
83
|
-
export function
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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:
|
|
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 (
|
|
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 =
|
|
109
|
+
let audioBuffer = assembleBase64AudioChunks(audioChunks);
|
|
145
110
|
const detected = detectAudioFormat(audioBuffer);
|
|
146
111
|
if (detected.ext === 'pcm') {
|
|
147
|
-
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
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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<
|
|
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
|
-
|
|
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 {
|
|
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
|
|
12
|
-
const
|
|
13
|
-
const
|
|
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'
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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 (
|
|
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: '
|
|
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
|
-
|
|
126
|
-
|
|
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<
|
|
17
|
-
content: (
|
|
18
|
-
|
|
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 {
|
|
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'
|
|
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
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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 {};
|