@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.
Files changed (66) hide show
  1. package/README.md +60 -52
  2. package/dist/errors.d.ts +3 -12
  3. package/dist/errors.js +2 -6
  4. package/dist/index.js +2 -10
  5. package/dist/logger.d.ts +1 -18
  6. package/dist/logger.js +0 -7
  7. package/dist/model-cache.d.ts +1 -20
  8. package/dist/model-cache.js +1 -20
  9. package/dist/openrouter-api.d.ts +4 -18
  10. package/dist/openrouter-api.js +4 -20
  11. package/dist/tool-definitions.d.ts +1 -0
  12. package/dist/tool-definitions.js +708 -0
  13. package/dist/tool-handlers/analyze-audio.d.ts +0 -5
  14. package/dist/tool-handlers/analyze-image.d.ts +0 -6
  15. package/dist/tool-handlers/analyze-image.js +1 -8
  16. package/dist/tool-handlers/analyze-video.d.ts +0 -5
  17. package/dist/tool-handlers/analyze-video.js +0 -7
  18. package/dist/tool-handlers/async-chat.d.ts +17 -13
  19. package/dist/tool-handlers/async-chat.js +42 -65
  20. package/dist/tool-handlers/audio-utils.d.ts +1 -4
  21. package/dist/tool-handlers/audio-utils.js +4 -14
  22. package/dist/tool-handlers/cache.d.ts +2 -18
  23. package/dist/tool-handlers/cache.js +1 -19
  24. package/dist/tool-handlers/chat-completion.d.ts +2 -30
  25. package/dist/tool-handlers/chat-completion.js +13 -33
  26. package/dist/tool-handlers/chat-request.d.ts +25 -0
  27. package/dist/tool-handlers/chat-request.js +38 -0
  28. package/dist/tool-handlers/completion-utils.d.ts +1 -18
  29. package/dist/tool-handlers/completion-utils.js +0 -2
  30. package/dist/tool-handlers/fetch-utils.d.ts +2 -18
  31. package/dist/tool-handlers/fetch-utils.js +3 -51
  32. package/dist/tool-handlers/generate-audio.js +11 -21
  33. package/dist/tool-handlers/generate-image-dedicated.js +16 -70
  34. package/dist/tool-handlers/generate-image-input.d.ts +0 -1
  35. package/dist/tool-handlers/generate-image-input.js +2 -18
  36. package/dist/tool-handlers/generate-image.js +8 -15
  37. package/dist/tool-handlers/generate-video.d.ts +1 -7
  38. package/dist/tool-handlers/generate-video.js +24 -109
  39. package/dist/tool-handlers/health-check.d.ts +1 -9
  40. package/dist/tool-handlers/health-check.js +1 -12
  41. package/dist/tool-handlers/image-source.d.ts +14 -0
  42. package/dist/tool-handlers/image-source.js +23 -0
  43. package/dist/tool-handlers/image-utils.d.ts +5 -0
  44. package/dist/tool-handlers/image-utils.js +23 -0
  45. package/dist/tool-handlers/openai-withresponse.d.ts +1 -10
  46. package/dist/tool-handlers/openai-withresponse.js +0 -7
  47. package/dist/tool-handlers/openrouter-errors.d.ts +2 -18
  48. package/dist/tool-handlers/openrouter-errors.js +2 -34
  49. package/dist/tool-handlers/path-safety.d.ts +10 -15
  50. package/dist/tool-handlers/path-safety.js +30 -55
  51. package/dist/tool-handlers/provider-routing.d.ts +0 -9
  52. package/dist/tool-handlers/provider-routing.js +1 -14
  53. package/dist/tool-handlers/rerank.js +0 -2
  54. package/dist/tool-handlers/search-models.d.ts +0 -6
  55. package/dist/tool-handlers/speech-to-text.js +3 -21
  56. package/dist/tool-handlers/structured-output.d.ts +1 -4
  57. package/dist/tool-handlers/structured-output.js +2 -16
  58. package/dist/tool-handlers/text-to-speech.js +8 -26
  59. package/dist/tool-handlers/video-utils.d.ts +1 -6
  60. package/dist/tool-handlers/video-utils.js +2 -17
  61. package/dist/tool-handlers.js +4 -734
  62. package/dist/tool-icons.d.ts +0 -6
  63. package/dist/tool-icons.js +1 -8
  64. package/dist/version.d.ts +1 -15
  65. package/dist/version.js +1 -15
  66. package/package.json +3 -2
@@ -1,27 +1,11 @@
1
1
  /**
2
- * User-Agent we send on outbound fetches from `fetchHttpResource`. Some
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 we send on outbound fetches from `fetchHttpResource`. Some
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 { resolveSafeOutputPath, UnsafeOutputPathError } from './path-safety.js';
4
- import { ErrorCode, toolError, toolErrorFrom } from '../errors.js';
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
- // Fail-fast on unsafe paths BEFORE spending tokens.
103
- let safeBase = null;
104
- if (save_path) {
105
- try {
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
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
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
- * generate_image_dedicated — uses OpenRouter's dedicated POST /api/v1/images
3
- * endpoint (launched June 2026) for image generation. Supports normalized
4
- * resolution tiers, aspect ratios, quality levels, output formats, and
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
- * Resolve an input image reference (local path, URL, or data URL) into the
25
- * OpenRouter `input_references` shape: `{ type: "image_url", image_url: { url } }`.
26
- */
27
- async function resolveReference(source) {
28
- const trimmed = source.trim();
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
- // Resolve save path early
78
- let safeSavePath = null;
79
- if (save_path) {
80
- try {
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(resolveReference));
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 === 'png'
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,3 +1,2 @@
1
1
  import OpenAI from 'openai';
2
- export declare function resolveInputImage(ref: string): Promise<string>;
3
2
  export declare function buildUserContent(prompt: string, inputImages?: string[]): Promise<string | OpenAI.Chat.Completions.ChatCompletionContentPart[]>;
@@ -1,20 +1,4 @@
1
- import { promises as fs } from 'fs';
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) => resolveInputImage(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 { resolveSafeOutputPath, UnsafeOutputPathError } from './path-safety.js';
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
- let safePathResolved = null;
47
- if (save_path) {
48
- try {
49
- safePathResolved = await resolveSafeOutputPath(save_path);
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;