@stabgan/openrouter-mcp-multimodal 4.7.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +95 -41
  2. package/dist/errors.d.ts +5 -20
  3. package/dist/errors.js +1 -10
  4. package/dist/index.js +8 -2
  5. package/dist/logger.js +54 -24
  6. package/dist/model-cache.d.ts +13 -0
  7. package/dist/model-cache.js +62 -8
  8. package/dist/openrouter-api.d.ts +14 -15
  9. package/dist/openrouter-api.js +68 -22
  10. package/dist/tool-definitions.d.ts +24 -0
  11. package/dist/tool-definitions.js +280 -177
  12. package/dist/tool-descriptions.d.ts +0 -4
  13. package/dist/tool-descriptions.js +30 -21
  14. package/dist/tool-handlers/analyze-audio.js +4 -1
  15. package/dist/tool-handlers/analyze-image.js +11 -6
  16. package/dist/tool-handlers/analyze-video.js +9 -5
  17. package/dist/tool-handlers/async-chat.d.ts +17 -0
  18. package/dist/tool-handlers/async-chat.js +104 -30
  19. package/dist/tool-handlers/audio-utils.d.ts +19 -4
  20. package/dist/tool-handlers/audio-utils.js +170 -16
  21. package/dist/tool-handlers/cache.d.ts +3 -3
  22. package/dist/tool-handlers/cache.js +56 -4
  23. package/dist/tool-handlers/chat-completion.js +16 -7
  24. package/dist/tool-handlers/chat-request.d.ts +4 -1
  25. package/dist/tool-handlers/chat-request.js +29 -1
  26. package/dist/tool-handlers/completion-utils.d.ts +5 -11
  27. package/dist/tool-handlers/completion-utils.js +76 -47
  28. package/dist/tool-handlers/fetch-utils.d.ts +14 -0
  29. package/dist/tool-handlers/fetch-utils.js +329 -77
  30. package/dist/tool-handlers/generate-audio.d.ts +4 -15
  31. package/dist/tool-handlers/generate-audio.js +21 -53
  32. package/dist/tool-handlers/generate-image-dedicated.d.ts +1 -1
  33. package/dist/tool-handlers/generate-image-dedicated.js +50 -31
  34. package/dist/tool-handlers/generate-image.d.ts +1 -1
  35. package/dist/tool-handlers/generate-image.js +19 -22
  36. package/dist/tool-handlers/generate-video.d.ts +4 -3
  37. package/dist/tool-handlers/generate-video.js +42 -18
  38. package/dist/tool-handlers/get-model-info.js +1 -1
  39. package/dist/tool-handlers/health-check.js +39 -15
  40. package/dist/tool-handlers/image-utils.js +2 -2
  41. package/dist/tool-handlers/openrouter-errors.d.ts +2 -0
  42. package/dist/tool-handlers/openrouter-errors.js +138 -31
  43. package/dist/tool-handlers/path-safety.js +49 -17
  44. package/dist/tool-handlers/path-utils.d.ts +2 -0
  45. package/dist/tool-handlers/path-utils.js +13 -0
  46. package/dist/tool-handlers/provider-routing.d.ts +2 -0
  47. package/dist/tool-handlers/provider-routing.js +11 -1
  48. package/dist/tool-handlers/rerank.d.ts +1 -4
  49. package/dist/tool-handlers/rerank.js +44 -15
  50. package/dist/tool-handlers/search-models.js +3 -3
  51. package/dist/tool-handlers/speech-to-text.d.ts +1 -0
  52. package/dist/tool-handlers/speech-to-text.js +23 -56
  53. package/dist/tool-handlers/text-to-speech.d.ts +1 -1
  54. package/dist/tool-handlers/text-to-speech.js +33 -20
  55. package/dist/tool-handlers/tool-result-payload.js +11 -9
  56. package/dist/tool-handlers/validate-model.js +1 -1
  57. package/dist/tool-handlers.d.ts +9 -0
  58. package/dist/tool-handlers.js +17 -5
  59. package/dist/tts-defaults.d.ts +4 -0
  60. package/dist/tts-defaults.js +4 -0
  61. package/dist/version.d.ts +3 -2
  62. package/dist/version.js +4 -2
  63. package/package.json +11 -13
@@ -3,7 +3,10 @@
3
3
  * Used by both image-utils and audio-utils to avoid duplication.
4
4
  */
5
5
  import dns from 'node:dns/promises';
6
+ import http from 'node:http';
7
+ import https from 'node:https';
6
8
  import net from 'node:net';
9
+ import { createBrotliDecompress, createGunzip, createInflate } from 'node:zlib';
7
10
  import { readFileSync } from 'node:fs';
8
11
  import { fileURLToPath } from 'node:url';
9
12
  import path from 'node:path';
@@ -37,9 +40,25 @@ export function readEnvInt(name, fallback, min = 1) {
37
40
  const raw = process.env[name];
38
41
  if (raw === undefined || raw === '')
39
42
  return fallback;
40
- const n = parseInt(raw, 10);
43
+ if (!/^\d+$/.test(raw))
44
+ return fallback;
45
+ const n = Number(raw);
41
46
  return Number.isFinite(n) && n >= min ? n : fallback;
42
47
  }
48
+ /** Normalize dotted / decimal / octal / shorthand IPv4 literals via the URL parser. */
49
+ function normalizeIPv4Literal(host) {
50
+ const trimmed = host.trim().toLowerCase();
51
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(trimmed))
52
+ return trimmed;
53
+ try {
54
+ const parsed = new URL(`http://${trimmed}/`);
55
+ const h = parsed.hostname;
56
+ return /^\d{1,3}(\.\d{1,3}){3}$/.test(h) ? h : null;
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
43
62
  function ipv4ToUint(ip) {
44
63
  const parts = ip.split('.').map((p) => parseInt(p, 10));
45
64
  if (parts.length !== 4 || parts.some((p) => !Number.isInteger(p) || p < 0 || p > 255)) {
@@ -49,7 +68,10 @@ function ipv4ToUint(ip) {
49
68
  }
50
69
  /** Blocks RFC1918, loopback, link-local, CGNAT, metadata. */
51
70
  export function isBlockedIPv4(ip) {
52
- const n = ipv4ToUint(ip);
71
+ const normalized = normalizeIPv4Literal(ip);
72
+ if (!normalized)
73
+ return false;
74
+ const n = ipv4ToUint(normalized);
53
75
  if (n >>> 24 === 127)
54
76
  return true;
55
77
  if (n >>> 24 === 10)
@@ -73,7 +95,6 @@ function expandIPv6(ip) {
73
95
  if (!net.isIPv6(noBrackets))
74
96
  return null;
75
97
  let addr = noBrackets.toLowerCase();
76
- let v4Tail = null;
77
98
  const dotIndex = addr.indexOf('.');
78
99
  if (dotIndex >= 0) {
79
100
  const lastColon = addr.lastIndexOf(':', dotIndex);
@@ -84,10 +105,9 @@ function expandIPv6(ip) {
84
105
  if (parts.length !== 4 || parts.some((p) => !Number.isInteger(p) || p < 0 || p > 255)) {
85
106
  return null;
86
107
  }
87
- v4Tail = [((parts[0] << 8) | parts[1]) & 0xffff, ((parts[2] << 8) | parts[3]) & 0xffff];
88
- const hex6 = v4Tail[0].toString(16);
89
- const hex7 = v4Tail[1].toString(16);
90
- addr = addr.slice(0, lastColon) + ':' + hex6 + ':' + hex7;
108
+ const v4Group6 = ((parts[0] << 8) | parts[1]) & 0xffff;
109
+ const v4Group7 = ((parts[2] << 8) | parts[3]) & 0xffff;
110
+ addr = addr.slice(0, lastColon) + ':' + v4Group6.toString(16) + ':' + v4Group7.toString(16);
91
111
  }
92
112
  const halves = addr.split('::');
93
113
  if (halves.length > 2)
@@ -179,10 +199,74 @@ export function isBlockedIPv6(ip) {
179
199
  return false;
180
200
  }
181
201
  function isIPv4Literal(host) {
182
- return /^\d{1,3}(\.\d{1,3}){3}$/.test(host);
202
+ return normalizeIPv4Literal(host) !== null;
183
203
  }
184
- /** Resolve hostname and ensure the resolved address is not private/link-local. */
185
- export async function assertUrlSafeForFetch(urlString) {
204
+ /** Vitest-only hooks; inert outside `process.env.VITEST === 'true'`. */
205
+ let testDnsLookup = null;
206
+ let testTrustedCa;
207
+ let testAllowLoopbackResolution = false;
208
+ /** @internal Test seam — never active in production. */
209
+ export function __setFetchUtilsTestHooks(hooks) {
210
+ if (process.env.VITEST !== 'true')
211
+ return;
212
+ if ('dnsLookup' in hooks)
213
+ testDnsLookup = hooks.dnsLookup ?? null;
214
+ if ('trustedCa' in hooks)
215
+ testTrustedCa = hooks.trustedCa;
216
+ if ('allowLoopbackResolution' in hooks) {
217
+ testAllowLoopbackResolution = hooks.allowLoopbackResolution ?? false;
218
+ }
219
+ }
220
+ /** @internal Test seam — never active in production. */
221
+ export function __resetFetchUtilsTestHooks() {
222
+ testDnsLookup = null;
223
+ testTrustedCa = undefined;
224
+ testAllowLoopbackResolution = false;
225
+ }
226
+ function isAddressBlocked(address, family) {
227
+ if (family === 4)
228
+ return isBlockedIPv4(address);
229
+ return isBlockedIPv6(address);
230
+ }
231
+ function isLoopbackForTest(address, family) {
232
+ if (family === 4) {
233
+ const normalized = normalizeIPv4Literal(address);
234
+ if (!normalized)
235
+ return false;
236
+ return ipv4ToUint(normalized) >>> 24 === 127;
237
+ }
238
+ const groups = expandIPv6(address);
239
+ if (!groups)
240
+ return false;
241
+ return (groups[0] === 0 &&
242
+ groups[1] === 0 &&
243
+ groups[2] === 0 &&
244
+ groups[3] === 0 &&
245
+ groups[4] === 0 &&
246
+ groups[5] === 0 &&
247
+ groups[6] === 0 &&
248
+ groups[7] === 1);
249
+ }
250
+ function assertAddressAllowed(address, family) {
251
+ if (testAllowLoopbackResolution &&
252
+ process.env.VITEST === 'true' &&
253
+ isLoopbackForTest(address, family)) {
254
+ return;
255
+ }
256
+ if (isAddressBlocked(address, family))
257
+ throw new Error('Blocked host');
258
+ }
259
+ async function lookupHostAddresses(host) {
260
+ if (testDnsLookup && process.env.VITEST === 'true') {
261
+ return testDnsLookup(host);
262
+ }
263
+ const records = await dns.lookup(host, { all: true, verbatim: true });
264
+ return records.map((r) => ({
265
+ address: r.address,
266
+ family: r.family === 6 ? 6 : 4,
267
+ }));
268
+ }
269
+ async function validateUrlAndResolveAddresses(urlString) {
186
270
  let url;
187
271
  try {
188
272
  url = new URL(urlString);
@@ -201,74 +285,253 @@ export async function assertUrlSafeForFetch(urlString) {
201
285
  throw new Error('Blocked host');
202
286
  }
203
287
  if (isIPv4Literal(host)) {
204
- if (isBlockedIPv4(host))
205
- throw new Error('Blocked host');
206
- return url;
288
+ const normalized = normalizeIPv4Literal(host);
289
+ assertAddressAllowed(normalized, 4);
290
+ return { url, addresses: [{ address: normalized, family: 4 }] };
207
291
  }
208
292
  if (host.includes(':') && !host.startsWith('[')) {
209
- if (isBlockedIPv6(host))
210
- throw new Error('Blocked host');
211
- return url;
293
+ assertAddressAllowed(host, 6);
294
+ return { url, addresses: [{ address: host, family: 6 }] };
212
295
  }
213
296
  let lookupHost = host;
214
297
  if (host.startsWith('[') && host.endsWith(']')) {
215
298
  lookupHost = host.slice(1, -1);
216
- if (isBlockedIPv6(lookupHost))
217
- throw new Error('Blocked host');
218
- return url;
299
+ assertAddressAllowed(lookupHost, 6);
300
+ return { url, addresses: [{ address: lookupHost, family: 6 }] };
219
301
  }
220
- const records = await dns.lookup(lookupHost, { all: true, verbatim: true });
302
+ const records = await lookupHostAddresses(lookupHost);
221
303
  if (!records.length)
222
304
  throw new Error('Could not resolve host');
305
+ const addresses = [];
223
306
  for (const r of records) {
224
- const { address, family } = r;
225
- if (family === 4) {
226
- if (isBlockedIPv4(address))
227
- throw new Error('Blocked host');
307
+ assertAddressAllowed(r.address, r.family);
308
+ addresses.push(r);
309
+ }
310
+ return { url, addresses };
311
+ }
312
+ /** Resolve hostname and ensure the resolved address is not private/link-local. */
313
+ export async function assertUrlSafeForFetch(urlString) {
314
+ const { url } = await validateUrlAndResolveAddresses(urlString);
315
+ return url;
316
+ }
317
+ // Pin the socket to a pre-validated IP so connect-time DNS cannot rebind to private space.
318
+ function pinnedLookup(address, family) {
319
+ const deliver = (cb) => {
320
+ cb(null, address, family);
321
+ };
322
+ return (hostname, options, callback) => {
323
+ if (typeof options === 'function') {
324
+ deliver(options);
325
+ return;
326
+ }
327
+ if (!callback)
328
+ return;
329
+ const opts = typeof options === 'number' ? { family: options } : (options ?? {});
330
+ if (opts.all) {
331
+ callback(null, [{ address, family }]);
332
+ return;
333
+ }
334
+ deliver(callback);
335
+ };
336
+ }
337
+ function isConnectError(err) {
338
+ if (!(err instanceof Error))
339
+ return false;
340
+ const code = err.code;
341
+ return (code === 'ECONNREFUSED' ||
342
+ code === 'ECONNRESET' ||
343
+ code === 'ETIMEDOUT' ||
344
+ code === 'EHOSTUNREACH' ||
345
+ code === 'ENETUNREACH' ||
346
+ code === 'EPIPE');
347
+ }
348
+ function requestPort(url) {
349
+ if (url.port)
350
+ return Number(url.port);
351
+ return url.protocol === 'https:' ? 443 : 80;
352
+ }
353
+ function buildRequestOptions(url, pinned, timeoutMs) {
354
+ return {
355
+ hostname: url.hostname,
356
+ host: url.host,
357
+ port: requestPort(url),
358
+ path: `${url.pathname}${url.search}`,
359
+ method: 'GET',
360
+ headers: {
361
+ 'User-Agent': FETCH_USER_AGENT,
362
+ Accept: 'image/*, audio/*, video/*, */*;q=0.8',
363
+ // Prefer identity; decompress defensively if the origin ignores this.
364
+ 'Accept-Encoding': 'identity',
365
+ Host: url.host,
366
+ },
367
+ lookup: pinnedLookup(pinned.address, pinned.family),
368
+ signal: AbortSignal.timeout(timeoutMs),
369
+ };
370
+ }
371
+ function pinnedHttpRequest(url, pinned, timeoutMs) {
372
+ return new Promise((resolve, reject) => {
373
+ const options = buildRequestOptions(url, pinned, timeoutMs);
374
+ const req = http.request(options, (res) => resolve(res));
375
+ req.on('error', (err) => {
376
+ if (options.signal?.aborted) {
377
+ reject(new Error('Fetch timed out'));
378
+ return;
379
+ }
380
+ reject(err);
381
+ });
382
+ req.end();
383
+ });
384
+ }
385
+ function pinnedHttpsRequest(url, pinned, timeoutMs, ca) {
386
+ return new Promise((resolve, reject) => {
387
+ const options = buildRequestOptions(url, pinned, timeoutMs);
388
+ const req = https.request({ ...options, ca }, (res) => resolve(res));
389
+ req.on('error', (err) => {
390
+ if (options.signal?.aborted) {
391
+ reject(new Error('Fetch timed out'));
392
+ return;
393
+ }
394
+ reject(err);
395
+ });
396
+ req.end();
397
+ });
398
+ }
399
+ async function pinnedRequest(url, pinned, timeoutMs) {
400
+ const ca = testTrustedCa && process.env.VITEST === 'true' ? testTrustedCa : undefined;
401
+ if (url.protocol === 'https:') {
402
+ return pinnedHttpsRequest(url, pinned, timeoutMs, ca);
403
+ }
404
+ if (url.protocol === 'http:') {
405
+ return pinnedHttpRequest(url, pinned, timeoutMs);
406
+ }
407
+ throw new Error('Only HTTP(S) URLs are allowed');
408
+ }
409
+ async function pinnedRequestWithFallback(url, addresses, timeoutMs) {
410
+ if (!addresses.length)
411
+ throw new Error('Could not resolve host');
412
+ let lastError;
413
+ for (const pinned of addresses) {
414
+ try {
415
+ return await pinnedRequest(url, pinned, timeoutMs);
228
416
  }
229
- else if (family === 6) {
230
- if (isBlockedIPv6(address))
231
- throw new Error('Blocked host');
417
+ catch (err) {
418
+ if (err instanceof Error && err.message === 'Fetch timed out')
419
+ throw err;
420
+ lastError = err;
421
+ if (!isConnectError(err))
422
+ throw err;
232
423
  }
233
424
  }
234
- return url;
425
+ throw lastError instanceof Error ? lastError : new Error('Connection failed');
426
+ }
427
+ function drainIncomingMessage(res) {
428
+ return new Promise((resolve) => {
429
+ const finish = () => resolve();
430
+ res.on('end', finish);
431
+ res.on('close', finish);
432
+ res.on('error', finish);
433
+ res.resume();
434
+ });
235
435
  }
236
- async function readResponseBodyWithLimit(res, maxBytes) {
237
- const declared = res.headers.get('content-length');
238
- if (declared) {
239
- const n = parseInt(declared, 10);
240
- if (Number.isFinite(n) && n > maxBytes) {
241
- throw new Error('Response too large');
436
+ function parseContentEncodings(raw) {
437
+ if (!raw)
438
+ return [];
439
+ const out = [];
440
+ for (const part of raw.split(',')) {
441
+ const enc = part.trim().toLowerCase();
442
+ if (!enc || enc === 'identity')
443
+ continue;
444
+ switch (enc) {
445
+ case 'gzip':
446
+ case 'x-gzip':
447
+ case 'deflate':
448
+ case 'br':
449
+ out.push(enc);
450
+ break;
451
+ default:
452
+ throw new Error(`Unsupported Content-Encoding: ${enc}`);
453
+ }
454
+ }
455
+ return out;
456
+ }
457
+ function createDecompressTransform(encoding) {
458
+ switch (encoding) {
459
+ case 'gzip':
460
+ case 'x-gzip':
461
+ return createGunzip();
462
+ case 'deflate':
463
+ return createInflate();
464
+ case 'br':
465
+ return createBrotliDecompress();
466
+ default: {
467
+ const _exhaustive = encoding;
468
+ throw new Error(`Unsupported Content-Encoding: ${String(_exhaustive)}`);
242
469
  }
243
470
  }
244
- const reader = res.body?.getReader();
245
- if (!reader) {
246
- const buf = Buffer.from(await res.arrayBuffer());
247
- if (buf.length > maxBytes)
248
- throw new Error('Response too large');
249
- return buf;
471
+ }
472
+ function bodyStreamForResponse(res, encodings) {
473
+ let stream = res;
474
+ for (const enc of [...encodings].reverse()) {
475
+ stream = stream.pipe(createDecompressTransform(enc));
476
+ }
477
+ return stream;
478
+ }
479
+ async function readIncomingMessageWithLimit(res, maxBytes) {
480
+ let encodings;
481
+ try {
482
+ encodings = parseContentEncodings(headerValue(res.headers, 'content-encoding'));
250
483
  }
484
+ catch (err) {
485
+ await releaseIncomingMessage(res);
486
+ throw err;
487
+ }
488
+ if (!encodings.length) {
489
+ const declared = res.headers['content-length'];
490
+ if (declared) {
491
+ const n = parseInt(String(declared), 10);
492
+ if (Number.isFinite(n) && n > maxBytes) {
493
+ res.destroy();
494
+ throw new Error('Response too large');
495
+ }
496
+ }
497
+ }
498
+ const stream = bodyStreamForResponse(res, encodings);
251
499
  const chunks = [];
252
500
  let total = 0;
253
- for (;;) {
254
- const { done, value } = await reader.read();
255
- if (done)
256
- break;
257
- total += value.byteLength;
258
- if (total > maxBytes) {
259
- // Cancel the underlying body so the server connection can be released.
260
- try {
261
- await reader.cancel();
262
- }
263
- catch {
264
- /* ignore */
501
+ try {
502
+ for await (const chunk of stream) {
503
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
504
+ total += buf.length;
505
+ if (total > maxBytes) {
506
+ res.destroy();
507
+ if ('destroy' in stream && typeof stream.destroy === 'function') {
508
+ stream.destroy();
509
+ }
510
+ throw new Error('Response too large');
265
511
  }
266
- throw new Error('Response too large');
512
+ chunks.push(buf);
267
513
  }
268
- chunks.push(Buffer.from(value));
514
+ }
515
+ catch (err) {
516
+ if (err instanceof Error && err.message === 'Response too large')
517
+ throw err;
518
+ res.destroy();
519
+ if ('destroy' in stream && typeof stream.destroy === 'function') {
520
+ stream.destroy();
521
+ }
522
+ throw err;
269
523
  }
270
524
  return Buffer.concat(chunks);
271
525
  }
526
+ async function releaseIncomingMessage(res) {
527
+ await drainIncomingMessage(res);
528
+ }
529
+ function headerValue(headers, name) {
530
+ const raw = headers[name.toLowerCase()];
531
+ if (raw === undefined)
532
+ return null;
533
+ return Array.isArray(raw) ? (raw[0] ?? null) : raw;
534
+ }
272
535
  /**
273
536
  * Parse an RFC 2397 data URL into `{ mediaType, base64 }`. Accepts MIME
274
537
  * parameters (`data:audio/wav;charset=binary;base64,...`) and the bare
@@ -297,35 +560,24 @@ export function parseBase64DataUrl(source) {
297
560
  export async function fetchHttpResource(urlString, opts) {
298
561
  let current = urlString;
299
562
  for (let hop = 0; hop <= opts.maxRedirects; hop++) {
300
- const validated = await assertUrlSafeForFetch(current);
563
+ const { url: validated, addresses } = await validateUrlAndResolveAddresses(current);
301
564
  const target = validated.href;
302
- const controller = new AbortController();
303
- const t = setTimeout(() => controller.abort(), opts.timeoutMs);
304
- let res;
305
- try {
306
- res = await fetch(target, {
307
- redirect: 'manual',
308
- signal: controller.signal,
309
- headers: {
310
- 'User-Agent': FETCH_USER_AGENT,
311
- Accept: 'image/*, audio/*, video/*, */*;q=0.8',
312
- },
313
- });
314
- }
315
- finally {
316
- clearTimeout(t);
317
- }
318
- if (res.status >= 300 && res.status < 400) {
319
- const loc = res.headers.get('location');
565
+ const res = await pinnedRequestWithFallback(validated, addresses, opts.timeoutMs);
566
+ const statusCode = res.statusCode ?? 0;
567
+ if (statusCode >= 300 && statusCode < 400) {
568
+ const loc = headerValue(res.headers, 'location');
569
+ await releaseIncomingMessage(res);
320
570
  if (!loc)
321
571
  throw new Error('Redirect without Location header');
322
572
  current = new URL(loc, target).href;
323
573
  continue;
324
574
  }
325
- if (!res.ok)
326
- throw new Error(`HTTP ${res.status}`);
327
- const buffer = await readResponseBodyWithLimit(res, opts.maxBytes);
328
- return { buffer, contentType: res.headers.get('content-type') };
575
+ if (statusCode < 200 || statusCode >= 300) {
576
+ await releaseIncomingMessage(res);
577
+ throw new Error(`HTTP ${statusCode}`);
578
+ }
579
+ const buffer = await readIncomingMessageWithLimit(res, opts.maxBytes);
580
+ return { buffer, contentType: headerValue(res.headers, 'content-type') };
329
581
  }
330
582
  throw new Error('Too many redirects');
331
583
  }
@@ -1,4 +1,5 @@
1
1
  import OpenAI from 'openai';
2
+ export { detectAudioFormat } from './audio-utils.js';
2
3
  export interface GenerateAudioToolRequest {
3
4
  prompt: string;
4
5
  model?: string;
@@ -8,26 +9,14 @@ export interface GenerateAudioToolRequest {
8
9
  }
9
10
  /** Create a 44-byte WAV header for raw PCM16 data at `sampleRate` Hz. */
10
11
  export declare function createWavHeader(dataLength: number, sampleRate?: number): Buffer;
11
- /**
12
- * Detect audio container format from magic bytes. Uses `Buffer.subarray()`
13
- * (not deprecated `slice()`). MP3 detection is intentionally strict:
14
- * - Accept ID3v2 tags (`'ID3'`) as unambiguous MP3.
15
- * - Accept raw frame sync only when every MPEG header field falls in a
16
- * non-reserved range: version != 0b01, layer != 0b00, bitrate != 0b1111,
17
- * sample rate index != 0b11. This removes the false positives that a
18
- * sync-word-only check produces on random binary.
19
- */
20
- export declare function detectAudioFormat(data: Buffer): {
21
- ext: string;
22
- mimeType: string;
23
- };
24
12
  export declare function wrapPcmInWav(pcmData: Buffer, sampleRate?: number): Buffer;
25
- export { replaceExtension } from './path-utils.js';
13
+ /** Decode each streamed base64 fragment and concatenate binary (joining strings corrupts padding). */
14
+ export declare function assembleBase64AudioChunks(chunks: string[]): Buffer;
26
15
  export declare function handleGenerateAudio(request: {
27
16
  params: {
28
17
  arguments: GenerateAudioToolRequest;
29
18
  };
30
- }, openai: OpenAI): Promise<import("../errors.js").ToolErrorResult | {
19
+ }, openai: OpenAI): Promise<{
31
20
  content: import("./tool-result-payload.js").BinaryToolContent[];
32
21
  _meta: Record<string, unknown>;
33
22
  }>;
@@ -1,5 +1,5 @@
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';
@@ -7,11 +7,13 @@ import { SERVER_VERSION } from '../version.js';
7
7
  import { logger } from '../logger.js';
8
8
  import { classifyUpstreamError } from './openrouter-errors.js';
9
9
  import { buildBinaryToolResult } from './tool-result-payload.js';
10
- import { replaceExtension } from './path-utils.js';
10
+ import { replaceExtension, writeOutputFile } from './path-utils.js';
11
+ import { detectAudioFormat } from './audio-utils.js';
12
+ export { detectAudioFormat } from './audio-utils.js';
11
13
  const DEFAULT_MODEL = 'openai/gpt-audio';
12
14
  const DEFAULT_VOICE = 'alloy';
13
15
  const DEFAULT_FORMAT = 'pcm16';
14
- const VALID_FORMATS = ['wav', 'mp3', 'flac', 'opus', 'pcm16'];
16
+ const VALID_FORMATS = GENERATE_AUDIO_FORMATS;
15
17
  const DEFAULT_PCM_SAMPLE_RATE = 24000;
16
18
  const PCM_BITS_PER_SAMPLE = 16;
17
19
  const PCM_NUM_CHANNELS = 1;
@@ -35,53 +37,17 @@ export function createWavHeader(dataLength, sampleRate = DEFAULT_PCM_SAMPLE_RATE
35
37
  header.writeUInt32LE(dataLength, 40);
36
38
  return header;
37
39
  }
38
- /**
39
- * Detect audio container format from magic bytes. Uses `Buffer.subarray()`
40
- * (not deprecated `slice()`). MP3 detection is intentionally strict:
41
- * - Accept ID3v2 tags (`'ID3'`) as unambiguous MP3.
42
- * - Accept raw frame sync only when every MPEG header field falls in a
43
- * non-reserved range: version != 0b01, layer != 0b00, bitrate != 0b1111,
44
- * sample rate index != 0b11. This removes the false positives that a
45
- * sync-word-only check produces on random binary.
46
- */
47
- export function detectAudioFormat(data) {
48
- if (data.length >= 3 && data[0] === 0x49 && data[1] === 0x44 && data[2] === 0x33) {
49
- return { ext: 'mp3', mimeType: 'audio/mpeg' };
50
- }
51
- if (data.length >= 4 && data[0] === 0xff && (data[1] & 0xe0) === 0xe0) {
52
- const b1 = data[1];
53
- const b2 = data[2];
54
- const versionBits = (b1 >> 3) & 0x03; // 01 = reserved
55
- const layerBits = (b1 >> 1) & 0x03; // 00 = reserved
56
- const bitrateIndex = (b2 >> 4) & 0x0f; // 1111 = bad
57
- const sampleRateIndex = (b2 >> 2) & 0x03; // 11 = reserved
58
- if (versionBits !== 0x01 &&
59
- layerBits !== 0x00 &&
60
- bitrateIndex !== 0x0f &&
61
- sampleRateIndex !== 0x03) {
62
- return { ext: 'mp3', mimeType: 'audio/mpeg' };
63
- }
64
- }
65
- if (data.length >= 12) {
66
- const riff = data.subarray(0, 4).toString('ascii');
67
- const wave = data.subarray(8, 12).toString('ascii');
68
- if (riff === 'RIFF' && wave === 'WAVE') {
69
- return { ext: 'wav', mimeType: 'audio/wav' };
70
- }
71
- }
72
- if (data.length >= 4) {
73
- const magic = data.subarray(0, 4).toString('ascii');
74
- if (magic === 'fLaC')
75
- return { ext: 'flac', mimeType: 'audio/flac' };
76
- if (magic === 'OggS')
77
- return { ext: 'ogg', mimeType: 'audio/ogg' };
78
- }
79
- return { ext: 'pcm', mimeType: 'audio/pcm' };
80
- }
81
40
  export function wrapPcmInWav(pcmData, sampleRate = DEFAULT_PCM_SAMPLE_RATE) {
82
41
  return Buffer.concat([createWavHeader(pcmData.length, sampleRate), pcmData]);
83
42
  }
84
- export { replaceExtension } from './path-utils.js';
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')));
50
+ }
85
51
  export async function handleGenerateAudio(request, openai) {
86
52
  const { prompt, model, voice, format, save_path } = request.params.arguments ?? {
87
53
  prompt: '',
@@ -89,10 +55,13 @@ export async function handleGenerateAudio(request, openai) {
89
55
  if (!prompt?.trim()) {
90
56
  return toolError(ErrorCode.INVALID_INPUT, 'prompt is required.');
91
57
  }
58
+ if (format && !VALID_FORMATS.includes(format)) {
59
+ return toolError(ErrorCode.INVALID_INPUT, `format '${format}' is not supported. Valid: ${VALID_FORMATS.join(', ')}.`);
60
+ }
92
61
  logger.audit('generate_audio.start', {
93
62
  model: model || DEFAULT_MODEL,
94
63
  voice: voice?.trim() || DEFAULT_VOICE,
95
- format: VALID_FORMATS.includes(format ?? '') ? format : DEFAULT_FORMAT,
64
+ format: format || DEFAULT_FORMAT,
96
65
  prompt_preview: prompt.slice(0, 80),
97
66
  save_path: save_path ? 'provided' : 'none',
98
67
  });
@@ -131,24 +100,23 @@ export async function handleGenerateAudio(request, openai) {
131
100
  transcriptChunks.push(a.transcript);
132
101
  }
133
102
  }
134
- const fullAudioBase64 = audioChunks.join('');
135
103
  const transcript = transcriptChunks.join('');
136
- if (!fullAudioBase64) {
104
+ if (audioChunks.length === 0) {
137
105
  return toolError(ErrorCode.INTERNAL, transcript
138
106
  ? `No audio returned (model emitted transcript only): ${transcript.slice(0, 300)}`
139
107
  : 'No audio returned.', { reason: 'no_audio_in_stream' });
140
108
  }
141
- let audioBuffer = Buffer.from(fullAudioBase64, 'base64');
109
+ let audioBuffer = assembleBase64AudioChunks(audioChunks);
142
110
  const detected = detectAudioFormat(audioBuffer);
143
111
  if (detected.ext === 'pcm') {
144
- audioBuffer = Buffer.from(wrapPcmInWav(audioBuffer));
112
+ audioBuffer = wrapPcmInWav(audioBuffer);
145
113
  detected.ext = 'wav';
146
114
  detected.mimeType = 'audio/wav';
147
115
  }
148
116
  if (safeBase) {
149
117
  const fileExt = extname(safeBase).toLowerCase().slice(1);
150
118
  const actualSavePath = fileExt === detected.ext ? safeBase : replaceExtension(safeBase, detected.ext);
151
- await fs.writeFile(actualSavePath, audioBuffer);
119
+ await writeOutputFile(actualSavePath, audioBuffer);
152
120
  const formatNote = actualSavePath !== safeBase
153
121
  ? ` (detected ${detected.ext.toUpperCase()}, saved as ${actualSavePath})`
154
122
  : '';
@@ -16,7 +16,7 @@ export declare function handleGenerateImageDedicated(request: {
16
16
  params: {
17
17
  arguments: GenerateImageDedicatedRequest;
18
18
  };
19
- }, apiClient: OpenRouterAPIClient): Promise<import("../errors.js").ToolErrorResult | {
19
+ }, apiClient: OpenRouterAPIClient): Promise<{
20
20
  content: import("./tool-result-payload.js").BinaryToolContent[];
21
21
  _meta: Record<string, unknown>;
22
22
  }>;