@stabgan/openrouter-mcp-multimodal 4.6.1 → 4.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -53
- 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/openrouter-openai-client.d.ts +9 -0
- package/dist/openrouter-openai-client.js +15 -0
- package/dist/tool-definitions.d.ts +1 -0
- package/dist/tool-definitions.js +719 -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 +49 -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.d.ts +3 -37
- package/dist/tool-handlers/generate-audio.js +23 -47
- package/dist/tool-handlers/generate-image-dedicated.d.ts +1 -11
- package/dist/tool-handlers/generate-image-dedicated.js +65 -87
- 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.d.ts +2 -45
- package/dist/tool-handlers/generate-image.js +16 -37
- package/dist/tool-handlers/generate-video.d.ts +1 -7
- package/dist/tool-handlers/generate-video.js +34 -142
- 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 +15 -14
- package/dist/tool-handlers/path-safety.js +61 -55
- package/dist/tool-handlers/path-utils.d.ts +2 -0
- package/dist/tool-handlers/path-utils.js +7 -0
- 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.d.ts +1 -11
- package/dist/tool-handlers/text-to-speech.js +20 -41
- package/dist/tool-handlers/tool-result-payload.d.ts +47 -0
- package/dist/tool-handlers/tool-result-payload.js +96 -0
- package/dist/tool-handlers/video-utils.d.ts +1 -6
- package/dist/tool-handlers/video-utils.js +2 -17
- package/dist/tool-handlers.js +6 -739
- 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
|
},
|
|
@@ -22,46 +22,12 @@ export declare function detectAudioFormat(data: Buffer): {
|
|
|
22
22
|
mimeType: string;
|
|
23
23
|
};
|
|
24
24
|
export declare function wrapPcmInWav(pcmData: Buffer, sampleRate?: number): Buffer;
|
|
25
|
-
|
|
26
|
-
export declare function replaceExtension(filePath: string, newExt: string): string;
|
|
25
|
+
export { replaceExtension } from './path-utils.js';
|
|
27
26
|
export declare function handleGenerateAudio(request: {
|
|
28
27
|
params: {
|
|
29
28
|
arguments: GenerateAudioToolRequest;
|
|
30
29
|
};
|
|
31
30
|
}, openai: OpenAI): Promise<import("../errors.js").ToolErrorResult | {
|
|
32
|
-
content: (
|
|
33
|
-
|
|
34
|
-
text: string;
|
|
35
|
-
mimeType?: undefined;
|
|
36
|
-
data?: undefined;
|
|
37
|
-
} | {
|
|
38
|
-
type: "audio";
|
|
39
|
-
mimeType: string;
|
|
40
|
-
data: string;
|
|
41
|
-
text?: undefined;
|
|
42
|
-
})[];
|
|
43
|
-
_meta: {
|
|
44
|
-
server_version: string;
|
|
45
|
-
save_path: string;
|
|
46
|
-
mime: string;
|
|
47
|
-
size_bytes: number;
|
|
48
|
-
};
|
|
49
|
-
} | {
|
|
50
|
-
content: ({
|
|
51
|
-
type: "text";
|
|
52
|
-
text: string;
|
|
53
|
-
mimeType?: undefined;
|
|
54
|
-
data?: undefined;
|
|
55
|
-
} | {
|
|
56
|
-
type: "audio";
|
|
57
|
-
mimeType: string;
|
|
58
|
-
data: string;
|
|
59
|
-
text?: undefined;
|
|
60
|
-
})[];
|
|
61
|
-
_meta: {
|
|
62
|
-
server_version: string;
|
|
63
|
-
mime: string;
|
|
64
|
-
size_bytes: number;
|
|
65
|
-
save_path?: undefined;
|
|
66
|
-
};
|
|
31
|
+
content: import("./tool-result-payload.js").BinaryToolContent[];
|
|
32
|
+
_meta: Record<string, unknown>;
|
|
67
33
|
}>;
|
|
@@ -1,10 +1,13 @@
|
|
|
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';
|
|
9
|
+
import { buildBinaryToolResult } from './tool-result-payload.js';
|
|
10
|
+
import { replaceExtension } from './path-utils.js';
|
|
8
11
|
const DEFAULT_MODEL = 'openai/gpt-audio';
|
|
9
12
|
const DEFAULT_VOICE = 'alloy';
|
|
10
13
|
const DEFAULT_FORMAT = 'pcm16';
|
|
@@ -78,12 +81,7 @@ export function detectAudioFormat(data) {
|
|
|
78
81
|
export function wrapPcmInWav(pcmData, sampleRate = DEFAULT_PCM_SAMPLE_RATE) {
|
|
79
82
|
return Buffer.concat([createWavHeader(pcmData.length, sampleRate), pcmData]);
|
|
80
83
|
}
|
|
81
|
-
|
|
82
|
-
export function replaceExtension(filePath, newExt) {
|
|
83
|
-
const current = extname(filePath);
|
|
84
|
-
const base = current ? filePath.slice(0, -current.length) : filePath;
|
|
85
|
-
return `${base}.${newExt}`;
|
|
86
|
-
}
|
|
84
|
+
export { replaceExtension } from './path-utils.js';
|
|
87
85
|
export async function handleGenerateAudio(request, openai) {
|
|
88
86
|
const { prompt, model, voice, format, save_path } = request.params.arguments ?? {
|
|
89
87
|
prompt: '',
|
|
@@ -91,7 +89,6 @@ export async function handleGenerateAudio(request, openai) {
|
|
|
91
89
|
if (!prompt?.trim()) {
|
|
92
90
|
return toolError(ErrorCode.INVALID_INPUT, 'prompt is required.');
|
|
93
91
|
}
|
|
94
|
-
// Audit entry. See generate_image for rationale.
|
|
95
92
|
logger.audit('generate_audio.start', {
|
|
96
93
|
model: model || DEFAULT_MODEL,
|
|
97
94
|
voice: voice?.trim() || DEFAULT_VOICE,
|
|
@@ -99,32 +96,23 @@ export async function handleGenerateAudio(request, openai) {
|
|
|
99
96
|
prompt_preview: prompt.slice(0, 80),
|
|
100
97
|
save_path: save_path ? 'provided' : 'none',
|
|
101
98
|
});
|
|
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
|
-
}
|
|
99
|
+
const savePathResult = await resolveOptionalOutputPath(save_path);
|
|
100
|
+
if (isToolErrorResult(savePathResult))
|
|
101
|
+
return savePathResult;
|
|
102
|
+
const safeBase = savePathResult.path;
|
|
114
103
|
const selectedFormat = VALID_FORMATS.includes(format ?? '')
|
|
115
104
|
? format
|
|
116
105
|
: DEFAULT_FORMAT;
|
|
117
106
|
const selectedVoice = voice?.trim() || DEFAULT_VOICE;
|
|
118
107
|
let stream;
|
|
119
108
|
try {
|
|
120
|
-
stream = (await openai.chat.completions.create({
|
|
109
|
+
stream = (await openai.chat.completions.create(asOpenAIChatBody({
|
|
121
110
|
model: model || DEFAULT_MODEL,
|
|
122
111
|
messages: [{ role: 'user', content: prompt }],
|
|
123
112
|
modalities: ['text', 'audio'],
|
|
124
113
|
audio: { voice: selectedVoice, format: selectedFormat },
|
|
125
114
|
stream: true,
|
|
126
|
-
|
|
127
|
-
}));
|
|
115
|
+
})));
|
|
128
116
|
}
|
|
129
117
|
catch (err) {
|
|
130
118
|
return classifyUpstreamError(err, 'generate_audio');
|
|
@@ -152,13 +140,11 @@ export async function handleGenerateAudio(request, openai) {
|
|
|
152
140
|
}
|
|
153
141
|
let audioBuffer = Buffer.from(fullAudioBase64, 'base64');
|
|
154
142
|
const detected = detectAudioFormat(audioBuffer);
|
|
155
|
-
// Always wrap raw PCM in WAV so it's playable
|
|
156
143
|
if (detected.ext === 'pcm') {
|
|
157
144
|
audioBuffer = Buffer.from(wrapPcmInWav(audioBuffer));
|
|
158
145
|
detected.ext = 'wav';
|
|
159
146
|
detected.mimeType = 'audio/wav';
|
|
160
147
|
}
|
|
161
|
-
const returnBase64 = audioBuffer.toString('base64');
|
|
162
148
|
if (safeBase) {
|
|
163
149
|
const fileExt = extname(safeBase).toLowerCase().slice(1);
|
|
164
150
|
const actualSavePath = fileExt === detected.ext ? safeBase : replaceExtension(safeBase, detected.ext);
|
|
@@ -169,30 +155,20 @@ export async function handleGenerateAudio(request, openai) {
|
|
|
169
155
|
const result = transcript
|
|
170
156
|
? `Audio saved to: ${actualSavePath}${formatNote}\nTranscript: ${transcript}`
|
|
171
157
|
: `Audio saved to: ${actualSavePath}${formatNote}`;
|
|
172
|
-
return {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
],
|
|
177
|
-
_meta: {
|
|
158
|
+
return buildBinaryToolResult({ kind: 'audio', buffer: audioBuffer, mimeType: detected.mimeType }, {
|
|
159
|
+
savedPath: actualSavePath,
|
|
160
|
+
summaryText: result,
|
|
161
|
+
meta: {
|
|
178
162
|
server_version: SERVER_VERSION,
|
|
179
|
-
save_path: actualSavePath,
|
|
180
|
-
mime: detected.mimeType,
|
|
181
|
-
size_bytes: audioBuffer.length,
|
|
182
163
|
},
|
|
183
|
-
};
|
|
164
|
+
});
|
|
184
165
|
}
|
|
185
|
-
return {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
{ type: 'audio', mimeType: detected.mimeType, data: returnBase64 },
|
|
189
|
-
],
|
|
190
|
-
_meta: {
|
|
166
|
+
return buildBinaryToolResult({ kind: 'audio', buffer: audioBuffer, mimeType: detected.mimeType }, {
|
|
167
|
+
prefixText: transcript || 'Audio generated successfully.',
|
|
168
|
+
meta: {
|
|
191
169
|
server_version: SERVER_VERSION,
|
|
192
|
-
mime: detected.mimeType,
|
|
193
|
-
size_bytes: audioBuffer.length,
|
|
194
170
|
},
|
|
195
|
-
};
|
|
171
|
+
});
|
|
196
172
|
}
|
|
197
173
|
catch (err) {
|
|
198
174
|
return classifyUpstreamError(err, 'generate_audio (stream)');
|
|
@@ -17,16 +17,6 @@ export declare function handleGenerateImageDedicated(request: {
|
|
|
17
17
|
arguments: GenerateImageDedicatedRequest;
|
|
18
18
|
};
|
|
19
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
|
-
})[];
|
|
20
|
+
content: import("./tool-result-payload.js").BinaryToolContent[];
|
|
31
21
|
_meta: Record<string, unknown>;
|
|
32
22
|
}>;
|
|
@@ -1,53 +1,24 @@
|
|
|
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';
|
|
17
8
|
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
9
|
+
import { buildBinaryToolResult } from './tool-result-payload.js';
|
|
10
|
+
import { fetchHttpResource, readEnvInt } from './fetch-utils.js';
|
|
18
11
|
import { buildCacheHeaders } from './cache.js';
|
|
19
12
|
const DEFAULT_MODEL = 'google/gemini-2.5-flash-image';
|
|
20
13
|
const VALID_RESOLUTIONS = new Set(['512', '0.5K', '1K', '2K', '4K']);
|
|
21
14
|
const VALID_QUALITIES = new Set(['auto', 'low', 'medium', 'high']);
|
|
22
15
|
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
|
-
}
|
|
16
|
+
const MIME_BY_FORMAT = {
|
|
17
|
+
png: 'image/png',
|
|
18
|
+
webp: 'image/webp',
|
|
19
|
+
svg: 'image/svg+xml',
|
|
20
|
+
jpeg: 'image/jpeg',
|
|
21
|
+
};
|
|
51
22
|
export async function handleGenerateImageDedicated(request, apiClient) {
|
|
52
23
|
const args = request.params.arguments ?? {};
|
|
53
24
|
const { prompt, model, resolution, aspect_ratio, quality, output_format, n, input_references, save_path, provider, cache, cache_ttl, cache_clear, } = args;
|
|
@@ -64,7 +35,6 @@ export async function handleGenerateImageDedicated(request, apiClient) {
|
|
|
64
35
|
input_references_count: input_references?.length ?? 0,
|
|
65
36
|
save_path: save_path ? 'provided' : 'none',
|
|
66
37
|
});
|
|
67
|
-
// Validate enums
|
|
68
38
|
if (resolution && !VALID_RESOLUTIONS.has(resolution)) {
|
|
69
39
|
return toolError(ErrorCode.INVALID_INPUT, `resolution '${resolution}' is not supported. Valid: ${[...VALID_RESOLUTIONS].join(', ')}.`);
|
|
70
40
|
}
|
|
@@ -74,19 +44,10 @@ export async function handleGenerateImageDedicated(request, apiClient) {
|
|
|
74
44
|
if (output_format && !VALID_OUTPUT_FORMATS.has(output_format)) {
|
|
75
45
|
return toolError(ErrorCode.INVALID_INPUT, `output_format '${output_format}' is not supported. Valid: ${[...VALID_OUTPUT_FORMATS].join(', ')}.`);
|
|
76
46
|
}
|
|
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
|
|
47
|
+
const savePathResult = await resolveOptionalOutputPath(save_path);
|
|
48
|
+
if (isToolErrorResult(savePathResult))
|
|
49
|
+
return savePathResult;
|
|
50
|
+
const safeSavePath = savePathResult.path;
|
|
90
51
|
const body = {
|
|
91
52
|
model: model || DEFAULT_MODEL,
|
|
92
53
|
prompt,
|
|
@@ -103,10 +64,9 @@ export async function handleGenerateImageDedicated(request, apiClient) {
|
|
|
103
64
|
body.n = n;
|
|
104
65
|
if (provider && typeof provider === 'object')
|
|
105
66
|
body.provider = provider;
|
|
106
|
-
// Resolve input references
|
|
107
67
|
if (input_references?.length) {
|
|
108
68
|
try {
|
|
109
|
-
const refs = await Promise.all(input_references.map(
|
|
69
|
+
const refs = await Promise.all(input_references.map(toOpenRouterImageReference));
|
|
110
70
|
body.input_references = refs;
|
|
111
71
|
}
|
|
112
72
|
catch (err) {
|
|
@@ -115,7 +75,6 @@ export async function handleGenerateImageDedicated(request, apiClient) {
|
|
|
115
75
|
return toolErrorFrom(ErrorCode.INVALID_INPUT, err, 'input_references');
|
|
116
76
|
}
|
|
117
77
|
}
|
|
118
|
-
// Build cache headers
|
|
119
78
|
const headers = buildCacheHeaders({ cache, cache_ttl, cache_clear });
|
|
120
79
|
let response;
|
|
121
80
|
try {
|
|
@@ -132,15 +91,7 @@ export async function handleGenerateImageDedicated(request, apiClient) {
|
|
|
132
91
|
}
|
|
133
92
|
const firstImage = images[0];
|
|
134
93
|
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
|
|
94
|
+
const mimeType = MIME_BY_FORMAT[output_format ?? ''] ?? 'image/png';
|
|
144
95
|
const baseMeta = {
|
|
145
96
|
server_version: SERVER_VERSION,
|
|
146
97
|
model: model || DEFAULT_MODEL,
|
|
@@ -150,31 +101,58 @@ export async function handleGenerateImageDedicated(request, apiClient) {
|
|
|
150
101
|
baseMeta.usage = response.usage;
|
|
151
102
|
if (firstImage.revised_prompt)
|
|
152
103
|
baseMeta.revised_prompt = firstImage.revised_prompt;
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
104
|
+
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
|
+
}
|
|
157
115
|
}
|
|
158
|
-
|
|
159
|
-
|
|
116
|
+
if (buffer) {
|
|
117
|
+
try {
|
|
118
|
+
await fs.writeFile(safeSavePath, buffer);
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
return toolErrorFrom(ErrorCode.INTERNAL, err, 'Write');
|
|
122
|
+
}
|
|
123
|
+
baseMeta.save_path = safeSavePath;
|
|
124
|
+
return buildBinaryToolResult({ kind: 'image', buffer, mimeType }, {
|
|
125
|
+
savedPath: safeSavePath,
|
|
126
|
+
summaryText: `Image saved to: ${safeSavePath}`,
|
|
127
|
+
meta: baseMeta,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
if (firstImage.url) {
|
|
131
|
+
try {
|
|
132
|
+
const maxBytes = readEnvInt('OPENROUTER_IMAGE_MAX_DOWNLOAD_BYTES', 20 * 1024 * 1024, 1024);
|
|
133
|
+
const { buffer: fetched, contentType } = await fetchHttpResource(firstImage.url, {
|
|
134
|
+
maxBytes,
|
|
135
|
+
maxRedirects: 3,
|
|
136
|
+
timeoutMs: 30_000,
|
|
137
|
+
});
|
|
138
|
+
const resolvedMime = contentType?.split(';')[0]?.trim() || mimeType;
|
|
139
|
+
await fs.writeFile(safeSavePath, fetched);
|
|
140
|
+
baseMeta.save_path = safeSavePath;
|
|
141
|
+
return buildBinaryToolResult({ kind: 'image', buffer: fetched, mimeType: resolvedMime }, {
|
|
142
|
+
savedPath: safeSavePath,
|
|
143
|
+
summaryText: `Image saved to: ${safeSavePath}`,
|
|
144
|
+
meta: { ...baseMeta, mime: resolvedMime, image_url: firstImage.url },
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
return toolErrorFrom(ErrorCode.UPSTREAM_HTTP, err, 'Download image URL for save_path');
|
|
149
|
+
}
|
|
160
150
|
}
|
|
161
|
-
|
|
162
|
-
return {
|
|
163
|
-
content: [
|
|
164
|
-
{ type: 'text', text: `Image saved to: ${safeSavePath}` },
|
|
165
|
-
...(imageData ? [{ type: 'image', mimeType, data: imageData }] : []),
|
|
166
|
-
],
|
|
167
|
-
_meta: baseMeta,
|
|
168
|
-
};
|
|
151
|
+
return toolError(ErrorCode.UPSTREAM_REFUSED, 'Model returned no usable image data for save_path (empty b64_json and URL download unavailable).');
|
|
169
152
|
}
|
|
170
|
-
// Return inline
|
|
171
153
|
if (imageData) {
|
|
172
|
-
return {
|
|
173
|
-
content: [{ type: 'image', mimeType, data: imageData }],
|
|
174
|
-
_meta: baseMeta,
|
|
175
|
-
};
|
|
154
|
+
return buildBinaryToolResult({ kind: 'image', buffer: Buffer.from(imageData, 'base64'), mimeType }, { inlineOnly: true, meta: baseMeta });
|
|
176
155
|
}
|
|
177
|
-
// URL-only response (some models return URLs instead of base64)
|
|
178
156
|
return {
|
|
179
157
|
content: [{ type: 'text', text: `Image generated. URL: ${firstImage.url}` }],
|
|
180
158
|
_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',
|