@stabgan/openrouter-mcp-multimodal 4.6.1 → 4.6.2
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 +60 -52
- package/dist/errors.d.ts +3 -12
- package/dist/errors.js +2 -6
- package/dist/index.js +2 -10
- package/dist/logger.d.ts +1 -18
- package/dist/logger.js +0 -7
- package/dist/model-cache.d.ts +1 -20
- package/dist/model-cache.js +1 -20
- package/dist/openrouter-api.d.ts +4 -18
- package/dist/openrouter-api.js +4 -20
- package/dist/tool-definitions.d.ts +1 -0
- package/dist/tool-definitions.js +708 -0
- package/dist/tool-handlers/analyze-audio.d.ts +0 -5
- package/dist/tool-handlers/analyze-image.d.ts +0 -6
- package/dist/tool-handlers/analyze-image.js +1 -8
- package/dist/tool-handlers/analyze-video.d.ts +0 -5
- package/dist/tool-handlers/analyze-video.js +0 -7
- package/dist/tool-handlers/async-chat.d.ts +17 -13
- package/dist/tool-handlers/async-chat.js +42 -65
- package/dist/tool-handlers/audio-utils.d.ts +1 -4
- package/dist/tool-handlers/audio-utils.js +4 -14
- package/dist/tool-handlers/cache.d.ts +2 -18
- package/dist/tool-handlers/cache.js +1 -19
- package/dist/tool-handlers/chat-completion.d.ts +2 -30
- package/dist/tool-handlers/chat-completion.js +13 -33
- package/dist/tool-handlers/chat-request.d.ts +25 -0
- package/dist/tool-handlers/chat-request.js +38 -0
- package/dist/tool-handlers/completion-utils.d.ts +1 -18
- package/dist/tool-handlers/completion-utils.js +0 -2
- package/dist/tool-handlers/fetch-utils.d.ts +2 -18
- package/dist/tool-handlers/fetch-utils.js +3 -51
- package/dist/tool-handlers/generate-audio.js +11 -21
- package/dist/tool-handlers/generate-image-dedicated.js +16 -70
- package/dist/tool-handlers/generate-image-input.d.ts +0 -1
- package/dist/tool-handlers/generate-image-input.js +2 -18
- package/dist/tool-handlers/generate-image.js +8 -15
- package/dist/tool-handlers/generate-video.d.ts +1 -7
- package/dist/tool-handlers/generate-video.js +24 -109
- package/dist/tool-handlers/health-check.d.ts +1 -9
- package/dist/tool-handlers/health-check.js +1 -12
- package/dist/tool-handlers/image-source.d.ts +14 -0
- package/dist/tool-handlers/image-source.js +23 -0
- package/dist/tool-handlers/image-utils.d.ts +5 -0
- package/dist/tool-handlers/image-utils.js +23 -0
- package/dist/tool-handlers/openai-withresponse.d.ts +1 -10
- package/dist/tool-handlers/openai-withresponse.js +0 -7
- package/dist/tool-handlers/openrouter-errors.d.ts +2 -18
- package/dist/tool-handlers/openrouter-errors.js +2 -34
- package/dist/tool-handlers/path-safety.d.ts +10 -15
- package/dist/tool-handlers/path-safety.js +30 -55
- package/dist/tool-handlers/provider-routing.d.ts +0 -9
- package/dist/tool-handlers/provider-routing.js +1 -14
- package/dist/tool-handlers/rerank.js +0 -2
- package/dist/tool-handlers/search-models.d.ts +0 -6
- package/dist/tool-handlers/speech-to-text.js +3 -21
- package/dist/tool-handlers/structured-output.d.ts +1 -4
- package/dist/tool-handlers/structured-output.js +2 -16
- package/dist/tool-handlers/text-to-speech.js +8 -26
- package/dist/tool-handlers/video-utils.d.ts +1 -6
- package/dist/tool-handlers/video-utils.js +2 -17
- package/dist/tool-handlers.js +4 -734
- package/dist/tool-icons.d.ts +0 -6
- package/dist/tool-icons.js +1 -8
- package/dist/version.d.ts +1 -15
- package/dist/version.js +1 -15
- package/package.json +3 -2
|
@@ -1,27 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* User-Agent
|
|
3
|
-
* CDNs/WAFs (notably Wikimedia/Varnish) reject requests without a UA, so
|
|
4
|
-
* we identify ourselves with the package name + current version + a repo
|
|
5
|
-
* URL so origin operators can contact us if our traffic misbehaves.
|
|
6
|
-
*
|
|
7
|
-
* Version is read from package.json at module load so version bumps don't
|
|
8
|
-
* require hand-edits here. Falls back to `dev` if package.json can't be
|
|
9
|
-
* located (e.g. in certain bundled environments).
|
|
2
|
+
* User-Agent for outbound fetches — some CDNs reject requests without one.
|
|
10
3
|
*/
|
|
11
4
|
export declare const FETCH_USER_AGENT: string;
|
|
12
5
|
export declare function readEnvInt(name: string, fallback: number, min?: number): number;
|
|
13
6
|
/** Blocks RFC1918, loopback, link-local, CGNAT, metadata. */
|
|
14
7
|
export declare function isBlockedIPv4(ip: string): boolean;
|
|
15
|
-
/**
|
|
16
|
-
* Comprehensive IPv6 SSRF block list. Covers loopback, unspecified,
|
|
17
|
-
* IPv4-mapped, IPv4-compatible, link-local, site-local, ULA, multicast,
|
|
18
|
-
* discard, documentation, Teredo, 6to4 (re-validates the embedded IPv4
|
|
19
|
-
* against `isBlockedIPv4`), and ORCHID. Returns `true` for any input that
|
|
20
|
-
* is a valid IPv6 literal in a reserved or private range.
|
|
21
|
-
*
|
|
22
|
-
* For non-IPv6 input returns `false` (the caller is expected to also run
|
|
23
|
-
* `isBlockedIPv4` for IPv4 input).
|
|
24
|
-
*/
|
|
8
|
+
/** SSRF block list for IPv6 literals (private/reserved ranges). */
|
|
25
9
|
export declare function isBlockedIPv6(ip: string): boolean;
|
|
26
10
|
/** Resolve hostname and ensure the resolved address is not private/link-local. */
|
|
27
11
|
export declare function assertUrlSafeForFetch(urlString: string): Promise<URL>;
|
|
@@ -8,21 +8,12 @@ import { readFileSync } from 'node:fs';
|
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
/**
|
|
11
|
-
* User-Agent
|
|
12
|
-
* CDNs/WAFs (notably Wikimedia/Varnish) reject requests without a UA, so
|
|
13
|
-
* we identify ourselves with the package name + current version + a repo
|
|
14
|
-
* URL so origin operators can contact us if our traffic misbehaves.
|
|
15
|
-
*
|
|
16
|
-
* Version is read from package.json at module load so version bumps don't
|
|
17
|
-
* require hand-edits here. Falls back to `dev` if package.json can't be
|
|
18
|
-
* located (e.g. in certain bundled environments).
|
|
11
|
+
* User-Agent for outbound fetches — some CDNs reject requests without one.
|
|
19
12
|
*/
|
|
20
13
|
export const FETCH_USER_AGENT = (() => {
|
|
21
14
|
const fallback = 'openrouter-mcp-multimodal/dev (+https://github.com/stabgan/openrouter-mcp-multimodal)';
|
|
22
15
|
try {
|
|
23
16
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
24
|
-
// Walk up a few levels looking for package.json (handles both
|
|
25
|
-
// dist/tool-handlers/… and src/tool-handlers/… layouts).
|
|
26
17
|
for (let hop = 0; hop < 5; hop++) {
|
|
27
18
|
const candidate = path.resolve(here, '../'.repeat(hop), 'package.json');
|
|
28
19
|
try {
|
|
@@ -75,22 +66,13 @@ export function isBlockedIPv4(ip) {
|
|
|
75
66
|
return true;
|
|
76
67
|
return false;
|
|
77
68
|
}
|
|
78
|
-
/**
|
|
79
|
-
* Expand an IPv6 literal to eight 16-bit groups as lowercase hex without
|
|
80
|
-
* separators. Accepts compressed forms (::), zone ids (%eth0), and IPv4-mapped
|
|
81
|
-
* / IPv4-compatible tails. Returns null if the input is not a valid IPv6
|
|
82
|
-
* literal.
|
|
83
|
-
*/
|
|
69
|
+
/** Expand an IPv6 literal to eight 16-bit groups, or null if invalid. */
|
|
84
70
|
function expandIPv6(ip) {
|
|
85
|
-
// Strip optional brackets (URL host form) and zone id before validation.
|
|
86
71
|
const noZone = ip.includes('%') ? ip.split('%')[0] : ip;
|
|
87
72
|
const noBrackets = noZone.replace(/^\[|\]$/g, '');
|
|
88
73
|
if (!net.isIPv6(noBrackets))
|
|
89
74
|
return null;
|
|
90
75
|
let addr = noBrackets.toLowerCase();
|
|
91
|
-
// Pull out any IPv4 tail (`::ffff:a.b.c.d`, `::a.b.c.d`, `x:y::a.b.c.d`)
|
|
92
|
-
// and substitute two 16-bit zero groups in its place. This way the rest
|
|
93
|
-
// of the parser only needs to handle pure-hex 8-group form.
|
|
94
76
|
let v4Tail = null;
|
|
95
77
|
const dotIndex = addr.indexOf('.');
|
|
96
78
|
if (dotIndex >= 0) {
|
|
@@ -103,12 +85,10 @@ function expandIPv6(ip) {
|
|
|
103
85
|
return null;
|
|
104
86
|
}
|
|
105
87
|
v4Tail = [((parts[0] << 8) | parts[1]) & 0xffff, ((parts[2] << 8) | parts[3]) & 0xffff];
|
|
106
|
-
// Substitute "g6:g7" in hex. E.g. "::ffff:127.0.0.1" -> "::ffff:7f00:0001"
|
|
107
88
|
const hex6 = v4Tail[0].toString(16);
|
|
108
89
|
const hex7 = v4Tail[1].toString(16);
|
|
109
90
|
addr = addr.slice(0, lastColon) + ':' + hex6 + ':' + hex7;
|
|
110
91
|
}
|
|
111
|
-
// Split on "::" at most once; fill the gap with zero groups.
|
|
112
92
|
const halves = addr.split('::');
|
|
113
93
|
if (halves.length > 2)
|
|
114
94
|
return null;
|
|
@@ -135,22 +115,12 @@ function expandIPv6(ip) {
|
|
|
135
115
|
}
|
|
136
116
|
return out.length === 8 ? out : null;
|
|
137
117
|
}
|
|
138
|
-
/**
|
|
139
|
-
* Comprehensive IPv6 SSRF block list. Covers loopback, unspecified,
|
|
140
|
-
* IPv4-mapped, IPv4-compatible, link-local, site-local, ULA, multicast,
|
|
141
|
-
* discard, documentation, Teredo, 6to4 (re-validates the embedded IPv4
|
|
142
|
-
* against `isBlockedIPv4`), and ORCHID. Returns `true` for any input that
|
|
143
|
-
* is a valid IPv6 literal in a reserved or private range.
|
|
144
|
-
*
|
|
145
|
-
* For non-IPv6 input returns `false` (the caller is expected to also run
|
|
146
|
-
* `isBlockedIPv4` for IPv4 input).
|
|
147
|
-
*/
|
|
118
|
+
/** SSRF block list for IPv6 literals (private/reserved ranges). */
|
|
148
119
|
export function isBlockedIPv6(ip) {
|
|
149
120
|
const groups = expandIPv6(ip);
|
|
150
121
|
if (!groups)
|
|
151
122
|
return false;
|
|
152
123
|
const [g0, g1, g2, g3, g4, g5, g6, g7] = groups;
|
|
153
|
-
// :: (unspecified)
|
|
154
124
|
if (g0 === 0 &&
|
|
155
125
|
g1 === 0 &&
|
|
156
126
|
g2 === 0 &&
|
|
@@ -161,7 +131,6 @@ export function isBlockedIPv6(ip) {
|
|
|
161
131
|
g7 === 0) {
|
|
162
132
|
return true;
|
|
163
133
|
}
|
|
164
|
-
// ::1 (loopback)
|
|
165
134
|
if (g0 === 0 &&
|
|
166
135
|
g1 === 0 &&
|
|
167
136
|
g2 === 0 &&
|
|
@@ -172,49 +141,36 @@ export function isBlockedIPv6(ip) {
|
|
|
172
141
|
g7 === 1) {
|
|
173
142
|
return true;
|
|
174
143
|
}
|
|
175
|
-
// ::ffff:0:0/96 — IPv4-mapped. Re-check the embedded IPv4.
|
|
176
144
|
if (g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0xffff) {
|
|
177
145
|
const v4 = ((g6 << 16) >>> 0) | g7;
|
|
178
146
|
const dotted = `${(v4 >>> 24) & 0xff}.${(v4 >>> 16) & 0xff}.${(v4 >>> 8) & 0xff}.${v4 & 0xff}`;
|
|
179
147
|
return isBlockedIPv4(dotted);
|
|
180
148
|
}
|
|
181
|
-
// ::/96 IPv4-compatible (deprecated but still routable in places).
|
|
182
149
|
if (g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0) {
|
|
183
|
-
// Only treat as IPv4-compat if g6/g7 actually look like an IPv4 (both
|
|
184
|
-
// are nonzero or this is the all-zeros case handled above).
|
|
185
150
|
if (g6 !== 0 || g7 !== 0) {
|
|
186
151
|
const v4 = ((g6 << 16) >>> 0) | g7;
|
|
187
152
|
const dotted = `${(v4 >>> 24) & 0xff}.${(v4 >>> 16) & 0xff}.${(v4 >>> 8) & 0xff}.${v4 & 0xff}`;
|
|
188
153
|
return isBlockedIPv4(dotted);
|
|
189
154
|
}
|
|
190
155
|
}
|
|
191
|
-
// fc00::/7 — ULA
|
|
192
156
|
if ((g0 & 0xfe00) === 0xfc00)
|
|
193
157
|
return true;
|
|
194
|
-
// fe80::/10 — link-local
|
|
195
158
|
if ((g0 & 0xffc0) === 0xfe80)
|
|
196
159
|
return true;
|
|
197
|
-
// fec0::/10 — deprecated site-local
|
|
198
160
|
if ((g0 & 0xffc0) === 0xfec0)
|
|
199
161
|
return true;
|
|
200
|
-
// ff00::/8 — multicast (all forms)
|
|
201
162
|
if ((g0 & 0xff00) === 0xff00)
|
|
202
163
|
return true;
|
|
203
|
-
// 100::/64 — discard prefix (RFC 6666)
|
|
204
164
|
if (g0 === 0x0100 && g1 === 0 && g2 === 0 && g3 === 0)
|
|
205
165
|
return true;
|
|
206
|
-
// 2001:db8::/32 — documentation
|
|
207
166
|
if (g0 === 0x2001 && g1 === 0x0db8)
|
|
208
167
|
return true;
|
|
209
|
-
// 2001::/32 — Teredo
|
|
210
168
|
if (g0 === 0x2001 && g1 === 0x0000)
|
|
211
169
|
return true;
|
|
212
|
-
// 2001:10::/28, 2001:20::/28 — ORCHID / deprecated
|
|
213
170
|
if (g0 === 0x2001 && (g1 & 0xfff0) === 0x0010)
|
|
214
171
|
return true;
|
|
215
172
|
if (g0 === 0x2001 && (g1 & 0xfff0) === 0x0020)
|
|
216
173
|
return true;
|
|
217
|
-
// 2002::/16 — 6to4; re-check embedded IPv4 for private/reserved use.
|
|
218
174
|
if (g0 === 0x2002) {
|
|
219
175
|
const v4 = ((g1 << 16) >>> 0) | g2;
|
|
220
176
|
const dotted = `${(v4 >>> 24) & 0xff}.${(v4 >>> 16) & 0xff}.${(v4 >>> 8) & 0xff}.${v4 & 0xff}`;
|
|
@@ -351,10 +307,6 @@ export async function fetchHttpResource(urlString, opts) {
|
|
|
351
307
|
redirect: 'manual',
|
|
352
308
|
signal: controller.signal,
|
|
353
309
|
headers: {
|
|
354
|
-
// Some CDNs/WAFs (notably Wikimedia/Varnish) reject requests
|
|
355
|
-
// without a User-Agent with HTTP 400. Identify ourselves so
|
|
356
|
-
// analyze_image / analyze_audio / analyze_video work against
|
|
357
|
-
// those origins. See https://github.com/stabgan/openrouter-mcp-multimodal/issues/13
|
|
358
310
|
'User-Agent': FETCH_USER_AGENT,
|
|
359
311
|
Accept: 'image/*, audio/*, video/*, */*;q=0.8',
|
|
360
312
|
},
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { promises as fs } from 'fs';
|
|
2
|
-
import { extname } from 'path';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { extname } from 'node:path';
|
|
3
|
+
import { resolveOptionalOutputPath, isToolErrorResult } from './path-safety.js';
|
|
4
|
+
import { asOpenAIChatBody } from './chat-request.js';
|
|
5
|
+
import { ErrorCode, toolError } from '../errors.js';
|
|
5
6
|
import { SERVER_VERSION } from '../version.js';
|
|
6
7
|
import { logger } from '../logger.js';
|
|
7
8
|
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
@@ -91,7 +92,6 @@ export async function handleGenerateAudio(request, openai) {
|
|
|
91
92
|
if (!prompt?.trim()) {
|
|
92
93
|
return toolError(ErrorCode.INVALID_INPUT, 'prompt is required.');
|
|
93
94
|
}
|
|
94
|
-
// Audit entry. See generate_image for rationale.
|
|
95
95
|
logger.audit('generate_audio.start', {
|
|
96
96
|
model: model || DEFAULT_MODEL,
|
|
97
97
|
voice: voice?.trim() || DEFAULT_VOICE,
|
|
@@ -99,32 +99,23 @@ export async function handleGenerateAudio(request, openai) {
|
|
|
99
99
|
prompt_preview: prompt.slice(0, 80),
|
|
100
100
|
save_path: save_path ? 'provided' : 'none',
|
|
101
101
|
});
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
safeBase = await resolveSafeOutputPath(save_path);
|
|
107
|
-
}
|
|
108
|
-
catch (e) {
|
|
109
|
-
if (e instanceof UnsafeOutputPathError)
|
|
110
|
-
return toolErrorFrom(ErrorCode.UNSAFE_PATH, e);
|
|
111
|
-
return toolErrorFrom(ErrorCode.INTERNAL, e);
|
|
112
|
-
}
|
|
113
|
-
}
|
|
102
|
+
const savePathResult = await resolveOptionalOutputPath(save_path);
|
|
103
|
+
if (isToolErrorResult(savePathResult))
|
|
104
|
+
return savePathResult;
|
|
105
|
+
const safeBase = savePathResult.path;
|
|
114
106
|
const selectedFormat = VALID_FORMATS.includes(format ?? '')
|
|
115
107
|
? format
|
|
116
108
|
: DEFAULT_FORMAT;
|
|
117
109
|
const selectedVoice = voice?.trim() || DEFAULT_VOICE;
|
|
118
110
|
let stream;
|
|
119
111
|
try {
|
|
120
|
-
stream = (await openai.chat.completions.create({
|
|
112
|
+
stream = (await openai.chat.completions.create(asOpenAIChatBody({
|
|
121
113
|
model: model || DEFAULT_MODEL,
|
|
122
114
|
messages: [{ role: 'user', content: prompt }],
|
|
123
115
|
modalities: ['text', 'audio'],
|
|
124
116
|
audio: { voice: selectedVoice, format: selectedFormat },
|
|
125
117
|
stream: true,
|
|
126
|
-
|
|
127
|
-
}));
|
|
118
|
+
})));
|
|
128
119
|
}
|
|
129
120
|
catch (err) {
|
|
130
121
|
return classifyUpstreamError(err, 'generate_audio');
|
|
@@ -152,7 +143,6 @@ export async function handleGenerateAudio(request, openai) {
|
|
|
152
143
|
}
|
|
153
144
|
let audioBuffer = Buffer.from(fullAudioBase64, 'base64');
|
|
154
145
|
const detected = detectAudioFormat(audioBuffer);
|
|
155
|
-
// Always wrap raw PCM in WAV so it's playable
|
|
156
146
|
if (detected.ext === 'pcm') {
|
|
157
147
|
audioBuffer = Buffer.from(wrapPcmInWav(audioBuffer));
|
|
158
148
|
detected.ext = 'wav';
|
|
@@ -1,16 +1,7 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
* input_references for image-to-image workflows.
|
|
6
|
-
*
|
|
7
|
-
* This is distinct from the original `generate_image` tool which uses chat
|
|
8
|
-
* completions with `modalities: ['image', 'text']`. New image models are
|
|
9
|
-
* added exclusively to this dedicated endpoint.
|
|
10
|
-
*/
|
|
11
|
-
import { promises as fs } from 'fs';
|
|
12
|
-
import path from 'node:path';
|
|
13
|
-
import { resolveSafeOutputPath, resolveSafeInputPath, UnsafeOutputPathError, } from './path-safety.js';
|
|
1
|
+
/** Dedicated POST /api/v1/images — distinct from chat-completions `generate_image`. */
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import { resolveOptionalOutputPath, isToolErrorResult, UnsafeOutputPathError, } from './path-safety.js';
|
|
4
|
+
import { toOpenRouterImageReference } from './image-source.js';
|
|
14
5
|
import { ErrorCode, toolError, toolErrorFrom } from '../errors.js';
|
|
15
6
|
import { SERVER_VERSION } from '../version.js';
|
|
16
7
|
import { logger } from '../logger.js';
|
|
@@ -20,34 +11,12 @@ const DEFAULT_MODEL = 'google/gemini-2.5-flash-image';
|
|
|
20
11
|
const VALID_RESOLUTIONS = new Set(['512', '0.5K', '1K', '2K', '4K']);
|
|
21
12
|
const VALID_QUALITIES = new Set(['auto', 'low', 'medium', 'high']);
|
|
22
13
|
const VALID_OUTPUT_FORMATS = new Set(['png', 'jpeg', 'webp', 'svg']);
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
if (!trimmed)
|
|
30
|
-
throw new Error('Empty input_references entry');
|
|
31
|
-
// Data URLs and HTTP URLs pass through directly
|
|
32
|
-
if (trimmed.startsWith('data:') || /^https?:\/\//i.test(trimmed)) {
|
|
33
|
-
return { type: 'image_url', image_url: { url: trimmed } };
|
|
34
|
-
}
|
|
35
|
-
// Local file: sandbox, read, and convert to data URL
|
|
36
|
-
const abs = await resolveSafeInputPath(trimmed);
|
|
37
|
-
const buf = await fs.readFile(abs);
|
|
38
|
-
const ext = path.extname(abs).toLowerCase();
|
|
39
|
-
const mime = ext === '.png'
|
|
40
|
-
? 'image/png'
|
|
41
|
-
: ext === '.webp'
|
|
42
|
-
? 'image/webp'
|
|
43
|
-
: ext === '.gif'
|
|
44
|
-
? 'image/gif'
|
|
45
|
-
: ext === '.svg'
|
|
46
|
-
? 'image/svg+xml'
|
|
47
|
-
: 'image/jpeg';
|
|
48
|
-
const dataUrl = `data:${mime};base64,${buf.toString('base64')}`;
|
|
49
|
-
return { type: 'image_url', image_url: { url: dataUrl } };
|
|
50
|
-
}
|
|
14
|
+
const MIME_BY_FORMAT = {
|
|
15
|
+
png: 'image/png',
|
|
16
|
+
webp: 'image/webp',
|
|
17
|
+
svg: 'image/svg+xml',
|
|
18
|
+
jpeg: 'image/jpeg',
|
|
19
|
+
};
|
|
51
20
|
export async function handleGenerateImageDedicated(request, apiClient) {
|
|
52
21
|
const args = request.params.arguments ?? {};
|
|
53
22
|
const { prompt, model, resolution, aspect_ratio, quality, output_format, n, input_references, save_path, provider, cache, cache_ttl, cache_clear, } = args;
|
|
@@ -64,7 +33,6 @@ export async function handleGenerateImageDedicated(request, apiClient) {
|
|
|
64
33
|
input_references_count: input_references?.length ?? 0,
|
|
65
34
|
save_path: save_path ? 'provided' : 'none',
|
|
66
35
|
});
|
|
67
|
-
// Validate enums
|
|
68
36
|
if (resolution && !VALID_RESOLUTIONS.has(resolution)) {
|
|
69
37
|
return toolError(ErrorCode.INVALID_INPUT, `resolution '${resolution}' is not supported. Valid: ${[...VALID_RESOLUTIONS].join(', ')}.`);
|
|
70
38
|
}
|
|
@@ -74,19 +42,10 @@ export async function handleGenerateImageDedicated(request, apiClient) {
|
|
|
74
42
|
if (output_format && !VALID_OUTPUT_FORMATS.has(output_format)) {
|
|
75
43
|
return toolError(ErrorCode.INVALID_INPUT, `output_format '${output_format}' is not supported. Valid: ${[...VALID_OUTPUT_FORMATS].join(', ')}.`);
|
|
76
44
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
safeSavePath = await resolveSafeOutputPath(save_path);
|
|
82
|
-
}
|
|
83
|
-
catch (err) {
|
|
84
|
-
if (err instanceof UnsafeOutputPathError)
|
|
85
|
-
return toolErrorFrom(ErrorCode.UNSAFE_PATH, err);
|
|
86
|
-
return toolErrorFrom(ErrorCode.INTERNAL, err);
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
// Build request body
|
|
45
|
+
const savePathResult = await resolveOptionalOutputPath(save_path);
|
|
46
|
+
if (isToolErrorResult(savePathResult))
|
|
47
|
+
return savePathResult;
|
|
48
|
+
const safeSavePath = savePathResult.path;
|
|
90
49
|
const body = {
|
|
91
50
|
model: model || DEFAULT_MODEL,
|
|
92
51
|
prompt,
|
|
@@ -103,10 +62,9 @@ export async function handleGenerateImageDedicated(request, apiClient) {
|
|
|
103
62
|
body.n = n;
|
|
104
63
|
if (provider && typeof provider === 'object')
|
|
105
64
|
body.provider = provider;
|
|
106
|
-
// Resolve input references
|
|
107
65
|
if (input_references?.length) {
|
|
108
66
|
try {
|
|
109
|
-
const refs = await Promise.all(input_references.map(
|
|
67
|
+
const refs = await Promise.all(input_references.map(toOpenRouterImageReference));
|
|
110
68
|
body.input_references = refs;
|
|
111
69
|
}
|
|
112
70
|
catch (err) {
|
|
@@ -115,7 +73,6 @@ export async function handleGenerateImageDedicated(request, apiClient) {
|
|
|
115
73
|
return toolErrorFrom(ErrorCode.INVALID_INPUT, err, 'input_references');
|
|
116
74
|
}
|
|
117
75
|
}
|
|
118
|
-
// Build cache headers
|
|
119
76
|
const headers = buildCacheHeaders({ cache, cache_ttl, cache_clear });
|
|
120
77
|
let response;
|
|
121
78
|
try {
|
|
@@ -132,15 +89,7 @@ export async function handleGenerateImageDedicated(request, apiClient) {
|
|
|
132
89
|
}
|
|
133
90
|
const firstImage = images[0];
|
|
134
91
|
const imageData = firstImage.b64_json;
|
|
135
|
-
const mimeType = output_format
|
|
136
|
-
? 'image/png'
|
|
137
|
-
: output_format === 'webp'
|
|
138
|
-
? 'image/webp'
|
|
139
|
-
: output_format === 'svg'
|
|
140
|
-
? 'image/svg+xml'
|
|
141
|
-
: output_format === 'jpeg'
|
|
142
|
-
? 'image/jpeg'
|
|
143
|
-
: 'image/png'; // default
|
|
92
|
+
const mimeType = MIME_BY_FORMAT[output_format ?? ''] ?? 'image/png';
|
|
144
93
|
const baseMeta = {
|
|
145
94
|
server_version: SERVER_VERSION,
|
|
146
95
|
model: model || DEFAULT_MODEL,
|
|
@@ -150,7 +99,6 @@ export async function handleGenerateImageDedicated(request, apiClient) {
|
|
|
150
99
|
baseMeta.usage = response.usage;
|
|
151
100
|
if (firstImage.revised_prompt)
|
|
152
101
|
baseMeta.revised_prompt = firstImage.revised_prompt;
|
|
153
|
-
// Save to file if requested
|
|
154
102
|
if (safeSavePath && imageData) {
|
|
155
103
|
try {
|
|
156
104
|
await fs.writeFile(safeSavePath, imageData, { encoding: 'base64' });
|
|
@@ -167,14 +115,12 @@ export async function handleGenerateImageDedicated(request, apiClient) {
|
|
|
167
115
|
_meta: baseMeta,
|
|
168
116
|
};
|
|
169
117
|
}
|
|
170
|
-
// Return inline
|
|
171
118
|
if (imageData) {
|
|
172
119
|
return {
|
|
173
120
|
content: [{ type: 'image', mimeType, data: imageData }],
|
|
174
121
|
_meta: baseMeta,
|
|
175
122
|
};
|
|
176
123
|
}
|
|
177
|
-
// URL-only response (some models return URLs instead of base64)
|
|
178
124
|
return {
|
|
179
125
|
content: [{ type: 'text', text: `Image generated. URL: ${firstImage.url}` }],
|
|
180
126
|
_meta: { ...baseMeta, image_url: firstImage.url },
|
|
@@ -1,20 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { resolveSafeInputPath } from './path-safety.js';
|
|
4
|
-
import { mimeFromExtension } from './image-utils.js';
|
|
5
|
-
export async function resolveInputImage(ref) {
|
|
6
|
-
const trimmed = ref.trim();
|
|
7
|
-
if (!trimmed)
|
|
8
|
-
throw new Error('empty input_images entry');
|
|
9
|
-
if (trimmed.startsWith('data:'))
|
|
10
|
-
return trimmed;
|
|
11
|
-
if (/^https?:\/\//i.test(trimmed))
|
|
12
|
-
return trimmed;
|
|
13
|
-
const abs = await resolveSafeInputPath(trimmed);
|
|
14
|
-
const buf = await fs.readFile(abs);
|
|
15
|
-
const mime = mimeFromExtension(path.extname(abs)) || 'image/png';
|
|
16
|
-
return `data:${mime};base64,${buf.toString('base64')}`;
|
|
17
|
-
}
|
|
1
|
+
import { resolveImageUrl } from './image-source.js';
|
|
18
2
|
export async function buildUserContent(prompt, inputImages) {
|
|
19
3
|
if (!inputImages?.length) {
|
|
20
4
|
return `Generate an image: ${prompt}`;
|
|
@@ -27,7 +11,7 @@ export async function buildUserContent(prompt, inputImages) {
|
|
|
27
11
|
`closely; do not alter them.\n\nPrompt: ${prompt}`,
|
|
28
12
|
},
|
|
29
13
|
];
|
|
30
|
-
const urls = await Promise.all(inputImages.map((ref) =>
|
|
14
|
+
const urls = await Promise.all(inputImages.map((ref) => resolveImageUrl(ref)));
|
|
31
15
|
for (const url of urls) {
|
|
32
16
|
parts.push({
|
|
33
17
|
type: 'image_url',
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { promises as fs } from 'fs';
|
|
2
|
-
import {
|
|
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';
|
|
@@ -43,18 +44,10 @@ export async function handleGenerateImage(request, openai) {
|
|
|
43
44
|
if (image_size !== undefined && !VALID_IMAGE_SIZES.has(image_size)) {
|
|
44
45
|
return invalidEnumError('image_size', image_size, VALID_IMAGE_SIZES);
|
|
45
46
|
}
|
|
46
|
-
|
|
47
|
-
if (
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
}
|
|
47
|
+
const savePathResult = await resolveOptionalOutputPath(save_path);
|
|
48
|
+
if (isToolErrorResult(savePathResult))
|
|
49
|
+
return savePathResult;
|
|
50
|
+
const safePathResolved = savePathResult.path;
|
|
58
51
|
let content;
|
|
59
52
|
try {
|
|
60
53
|
content = await buildUserContent(prompt, input_images);
|
|
@@ -81,7 +74,7 @@ export async function handleGenerateImage(request, openai) {
|
|
|
81
74
|
body.max_tokens = max_tokens;
|
|
82
75
|
let completion;
|
|
83
76
|
try {
|
|
84
|
-
completion = (await openai.chat.completions.create(body));
|
|
77
|
+
completion = (await openai.chat.completions.create(asOpenAIChatBody(body)));
|
|
85
78
|
}
|
|
86
79
|
catch (err) {
|
|
87
80
|
return classifyUpstreamError(err, 'generate_image');
|
|
@@ -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;
|