@stabgan/openrouter-mcp-multimodal 4.6.2 → 4.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +108 -37
- package/dist/errors.d.ts +5 -20
- package/dist/errors.js +1 -10
- package/dist/index.js +7 -1
- package/dist/logger.js +54 -24
- package/dist/model-cache.d.ts +13 -0
- package/dist/model-cache.js +61 -5
- package/dist/openrouter-api.d.ts +14 -15
- package/dist/openrouter-api.js +68 -22
- package/dist/openrouter-openai-client.d.ts +9 -0
- package/dist/openrouter-openai-client.js +15 -0
- package/dist/tool-definitions.d.ts +24 -0
- package/dist/tool-definitions.js +283 -170
- package/dist/tool-descriptions.js +23 -15
- package/dist/tool-handlers/analyze-audio.js +4 -1
- package/dist/tool-handlers/analyze-image.js +10 -5
- package/dist/tool-handlers/analyze-video.js +9 -5
- package/dist/tool-handlers/async-chat.d.ts +17 -0
- package/dist/tool-handlers/async-chat.js +112 -31
- package/dist/tool-handlers/audio-utils.d.ts +19 -4
- package/dist/tool-handlers/audio-utils.js +170 -16
- package/dist/tool-handlers/cache.d.ts +3 -3
- package/dist/tool-handlers/cache.js +56 -4
- package/dist/tool-handlers/chat-completion.js +16 -7
- package/dist/tool-handlers/chat-request.d.ts +3 -0
- package/dist/tool-handlers/chat-request.js +28 -0
- package/dist/tool-handlers/completion-utils.d.ts +5 -11
- package/dist/tool-handlers/completion-utils.js +76 -47
- package/dist/tool-handlers/fetch-utils.d.ts +14 -0
- package/dist/tool-handlers/fetch-utils.js +331 -68
- package/dist/tool-handlers/generate-audio.d.ts +6 -51
- package/dist/tool-handlers/generate-audio.js +30 -76
- package/dist/tool-handlers/generate-image-dedicated.d.ts +2 -12
- package/dist/tool-handlers/generate-image-dedicated.js +79 -28
- package/dist/tool-handlers/generate-image.d.ts +3 -46
- package/dist/tool-handlers/generate-image.js +26 -43
- package/dist/tool-handlers/generate-video.d.ts +4 -3
- package/dist/tool-handlers/generate-video.js +51 -50
- package/dist/tool-handlers/get-model-info.js +1 -1
- package/dist/tool-handlers/health-check.js +39 -15
- package/dist/tool-handlers/image-utils.js +2 -2
- package/dist/tool-handlers/openrouter-errors.d.ts +2 -0
- package/dist/tool-handlers/openrouter-errors.js +138 -31
- package/dist/tool-handlers/path-safety.d.ts +6 -0
- package/dist/tool-handlers/path-safety.js +77 -14
- package/dist/tool-handlers/path-utils.d.ts +4 -0
- package/dist/tool-handlers/path-utils.js +20 -0
- package/dist/tool-handlers/provider-routing.d.ts +2 -0
- package/dist/tool-handlers/provider-routing.js +10 -0
- package/dist/tool-handlers/rerank.d.ts +1 -4
- package/dist/tool-handlers/rerank.js +43 -14
- package/dist/tool-handlers/search-models.js +3 -3
- package/dist/tool-handlers/speech-to-text.d.ts +1 -0
- package/dist/tool-handlers/speech-to-text.js +23 -56
- package/dist/tool-handlers/text-to-speech.d.ts +2 -12
- package/dist/tool-handlers/text-to-speech.js +29 -22
- package/dist/tool-handlers/tool-result-payload.d.ts +47 -0
- package/dist/tool-handlers/tool-result-payload.js +98 -0
- package/dist/tool-handlers/validate-model.js +1 -1
- package/dist/tool-handlers.d.ts +9 -0
- package/dist/tool-handlers.js +19 -10
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -3
|
@@ -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
|
-
|
|
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
|
|
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)
|
|
@@ -179,10 +201,74 @@ export function isBlockedIPv6(ip) {
|
|
|
179
201
|
return false;
|
|
180
202
|
}
|
|
181
203
|
function isIPv4Literal(host) {
|
|
182
|
-
return
|
|
204
|
+
return normalizeIPv4Literal(host) !== null;
|
|
183
205
|
}
|
|
184
|
-
/**
|
|
185
|
-
|
|
206
|
+
/** Vitest-only hooks; inert outside `process.env.VITEST === 'true'`. */
|
|
207
|
+
let testDnsLookup = null;
|
|
208
|
+
let testTrustedCa;
|
|
209
|
+
let testAllowLoopbackResolution = false;
|
|
210
|
+
/** @internal Test seam — never active in production. */
|
|
211
|
+
export function __setFetchUtilsTestHooks(hooks) {
|
|
212
|
+
if (process.env.VITEST !== 'true')
|
|
213
|
+
return;
|
|
214
|
+
if ('dnsLookup' in hooks)
|
|
215
|
+
testDnsLookup = hooks.dnsLookup ?? null;
|
|
216
|
+
if ('trustedCa' in hooks)
|
|
217
|
+
testTrustedCa = hooks.trustedCa;
|
|
218
|
+
if ('allowLoopbackResolution' in hooks) {
|
|
219
|
+
testAllowLoopbackResolution = hooks.allowLoopbackResolution ?? false;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/** @internal Test seam — never active in production. */
|
|
223
|
+
export function __resetFetchUtilsTestHooks() {
|
|
224
|
+
testDnsLookup = null;
|
|
225
|
+
testTrustedCa = undefined;
|
|
226
|
+
testAllowLoopbackResolution = false;
|
|
227
|
+
}
|
|
228
|
+
function isAddressBlocked(address, family) {
|
|
229
|
+
if (family === 4)
|
|
230
|
+
return isBlockedIPv4(address);
|
|
231
|
+
return isBlockedIPv6(address);
|
|
232
|
+
}
|
|
233
|
+
function isLoopbackForTest(address, family) {
|
|
234
|
+
if (family === 4) {
|
|
235
|
+
const normalized = normalizeIPv4Literal(address);
|
|
236
|
+
if (!normalized)
|
|
237
|
+
return false;
|
|
238
|
+
return ipv4ToUint(normalized) >>> 24 === 127;
|
|
239
|
+
}
|
|
240
|
+
const groups = expandIPv6(address);
|
|
241
|
+
if (!groups)
|
|
242
|
+
return false;
|
|
243
|
+
return (groups[0] === 0 &&
|
|
244
|
+
groups[1] === 0 &&
|
|
245
|
+
groups[2] === 0 &&
|
|
246
|
+
groups[3] === 0 &&
|
|
247
|
+
groups[4] === 0 &&
|
|
248
|
+
groups[5] === 0 &&
|
|
249
|
+
groups[6] === 0 &&
|
|
250
|
+
groups[7] === 1);
|
|
251
|
+
}
|
|
252
|
+
function assertAddressAllowed(address, family) {
|
|
253
|
+
if (testAllowLoopbackResolution &&
|
|
254
|
+
process.env.VITEST === 'true' &&
|
|
255
|
+
isLoopbackForTest(address, family)) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (isAddressBlocked(address, family))
|
|
259
|
+
throw new Error('Blocked host');
|
|
260
|
+
}
|
|
261
|
+
async function lookupHostAddresses(host) {
|
|
262
|
+
if (testDnsLookup && process.env.VITEST === 'true') {
|
|
263
|
+
return testDnsLookup(host);
|
|
264
|
+
}
|
|
265
|
+
const records = await dns.lookup(host, { all: true, verbatim: true });
|
|
266
|
+
return records.map((r) => ({
|
|
267
|
+
address: r.address,
|
|
268
|
+
family: r.family === 6 ? 6 : 4,
|
|
269
|
+
}));
|
|
270
|
+
}
|
|
271
|
+
async function validateUrlAndResolveAddresses(urlString) {
|
|
186
272
|
let url;
|
|
187
273
|
try {
|
|
188
274
|
url = new URL(urlString);
|
|
@@ -201,74 +287,253 @@ export async function assertUrlSafeForFetch(urlString) {
|
|
|
201
287
|
throw new Error('Blocked host');
|
|
202
288
|
}
|
|
203
289
|
if (isIPv4Literal(host)) {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
return url;
|
|
290
|
+
const normalized = normalizeIPv4Literal(host);
|
|
291
|
+
assertAddressAllowed(normalized, 4);
|
|
292
|
+
return { url, addresses: [{ address: normalized, family: 4 }] };
|
|
207
293
|
}
|
|
208
294
|
if (host.includes(':') && !host.startsWith('[')) {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
return url;
|
|
295
|
+
assertAddressAllowed(host, 6);
|
|
296
|
+
return { url, addresses: [{ address: host, family: 6 }] };
|
|
212
297
|
}
|
|
213
298
|
let lookupHost = host;
|
|
214
299
|
if (host.startsWith('[') && host.endsWith(']')) {
|
|
215
300
|
lookupHost = host.slice(1, -1);
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
return url;
|
|
301
|
+
assertAddressAllowed(lookupHost, 6);
|
|
302
|
+
return { url, addresses: [{ address: lookupHost, family: 6 }] };
|
|
219
303
|
}
|
|
220
|
-
const records = await
|
|
304
|
+
const records = await lookupHostAddresses(lookupHost);
|
|
221
305
|
if (!records.length)
|
|
222
306
|
throw new Error('Could not resolve host');
|
|
307
|
+
const addresses = [];
|
|
223
308
|
for (const r of records) {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
309
|
+
assertAddressAllowed(r.address, r.family);
|
|
310
|
+
addresses.push(r);
|
|
311
|
+
}
|
|
312
|
+
return { url, addresses };
|
|
313
|
+
}
|
|
314
|
+
/** Resolve hostname and ensure the resolved address is not private/link-local. */
|
|
315
|
+
export async function assertUrlSafeForFetch(urlString) {
|
|
316
|
+
const { url } = await validateUrlAndResolveAddresses(urlString);
|
|
317
|
+
return url;
|
|
318
|
+
}
|
|
319
|
+
// Pin the socket to a pre-validated IP so connect-time DNS cannot rebind to private space.
|
|
320
|
+
function pinnedLookup(address, family) {
|
|
321
|
+
const deliver = (cb) => {
|
|
322
|
+
cb(null, address, family);
|
|
323
|
+
};
|
|
324
|
+
return (hostname, options, callback) => {
|
|
325
|
+
if (typeof options === 'function') {
|
|
326
|
+
deliver(options);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (!callback)
|
|
330
|
+
return;
|
|
331
|
+
const opts = typeof options === 'number' ? { family: options } : (options ?? {});
|
|
332
|
+
if (opts.all) {
|
|
333
|
+
callback(null, [{ address, family }]);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
deliver(callback);
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
function isConnectError(err) {
|
|
340
|
+
if (!(err instanceof Error))
|
|
341
|
+
return false;
|
|
342
|
+
const code = err.code;
|
|
343
|
+
return (code === 'ECONNREFUSED' ||
|
|
344
|
+
code === 'ECONNRESET' ||
|
|
345
|
+
code === 'ETIMEDOUT' ||
|
|
346
|
+
code === 'EHOSTUNREACH' ||
|
|
347
|
+
code === 'ENETUNREACH' ||
|
|
348
|
+
code === 'EPIPE');
|
|
349
|
+
}
|
|
350
|
+
function requestPort(url) {
|
|
351
|
+
if (url.port)
|
|
352
|
+
return Number(url.port);
|
|
353
|
+
return url.protocol === 'https:' ? 443 : 80;
|
|
354
|
+
}
|
|
355
|
+
function buildRequestOptions(url, pinned, timeoutMs) {
|
|
356
|
+
return {
|
|
357
|
+
hostname: url.hostname,
|
|
358
|
+
host: url.host,
|
|
359
|
+
port: requestPort(url),
|
|
360
|
+
path: `${url.pathname}${url.search}`,
|
|
361
|
+
method: 'GET',
|
|
362
|
+
headers: {
|
|
363
|
+
'User-Agent': FETCH_USER_AGENT,
|
|
364
|
+
Accept: 'image/*, audio/*, video/*, */*;q=0.8',
|
|
365
|
+
// Prefer identity; decompress defensively if the origin ignores this.
|
|
366
|
+
'Accept-Encoding': 'identity',
|
|
367
|
+
Host: url.host,
|
|
368
|
+
},
|
|
369
|
+
lookup: pinnedLookup(pinned.address, pinned.family),
|
|
370
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
function pinnedHttpRequest(url, pinned, timeoutMs) {
|
|
374
|
+
return new Promise((resolve, reject) => {
|
|
375
|
+
const options = buildRequestOptions(url, pinned, timeoutMs);
|
|
376
|
+
const req = http.request(options, (res) => resolve(res));
|
|
377
|
+
req.on('error', (err) => {
|
|
378
|
+
if (options.signal?.aborted) {
|
|
379
|
+
reject(new Error('Fetch timed out'));
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
reject(err);
|
|
383
|
+
});
|
|
384
|
+
req.end();
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
function pinnedHttpsRequest(url, pinned, timeoutMs, ca) {
|
|
388
|
+
return new Promise((resolve, reject) => {
|
|
389
|
+
const options = buildRequestOptions(url, pinned, timeoutMs);
|
|
390
|
+
const req = https.request({ ...options, ca }, (res) => resolve(res));
|
|
391
|
+
req.on('error', (err) => {
|
|
392
|
+
if (options.signal?.aborted) {
|
|
393
|
+
reject(new Error('Fetch timed out'));
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
reject(err);
|
|
397
|
+
});
|
|
398
|
+
req.end();
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
async function pinnedRequest(url, pinned, timeoutMs) {
|
|
402
|
+
const ca = testTrustedCa && process.env.VITEST === 'true' ? testTrustedCa : undefined;
|
|
403
|
+
if (url.protocol === 'https:') {
|
|
404
|
+
return pinnedHttpsRequest(url, pinned, timeoutMs, ca);
|
|
405
|
+
}
|
|
406
|
+
if (url.protocol === 'http:') {
|
|
407
|
+
return pinnedHttpRequest(url, pinned, timeoutMs);
|
|
408
|
+
}
|
|
409
|
+
throw new Error('Only HTTP(S) URLs are allowed');
|
|
410
|
+
}
|
|
411
|
+
async function pinnedRequestWithFallback(url, addresses, timeoutMs) {
|
|
412
|
+
if (!addresses.length)
|
|
413
|
+
throw new Error('Could not resolve host');
|
|
414
|
+
let lastError;
|
|
415
|
+
for (const pinned of addresses) {
|
|
416
|
+
try {
|
|
417
|
+
return await pinnedRequest(url, pinned, timeoutMs);
|
|
228
418
|
}
|
|
229
|
-
|
|
230
|
-
if (
|
|
231
|
-
throw
|
|
419
|
+
catch (err) {
|
|
420
|
+
if (err instanceof Error && err.message === 'Fetch timed out')
|
|
421
|
+
throw err;
|
|
422
|
+
lastError = err;
|
|
423
|
+
if (!isConnectError(err))
|
|
424
|
+
throw err;
|
|
232
425
|
}
|
|
233
426
|
}
|
|
234
|
-
|
|
427
|
+
throw lastError instanceof Error ? lastError : new Error('Connection failed');
|
|
428
|
+
}
|
|
429
|
+
function drainIncomingMessage(res) {
|
|
430
|
+
return new Promise((resolve) => {
|
|
431
|
+
const finish = () => resolve();
|
|
432
|
+
res.on('end', finish);
|
|
433
|
+
res.on('close', finish);
|
|
434
|
+
res.on('error', finish);
|
|
435
|
+
res.resume();
|
|
436
|
+
});
|
|
235
437
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
438
|
+
function parseContentEncodings(raw) {
|
|
439
|
+
if (!raw)
|
|
440
|
+
return [];
|
|
441
|
+
const out = [];
|
|
442
|
+
for (const part of raw.split(',')) {
|
|
443
|
+
const enc = part.trim().toLowerCase();
|
|
444
|
+
if (!enc || enc === 'identity')
|
|
445
|
+
continue;
|
|
446
|
+
switch (enc) {
|
|
447
|
+
case 'gzip':
|
|
448
|
+
case 'x-gzip':
|
|
449
|
+
case 'deflate':
|
|
450
|
+
case 'br':
|
|
451
|
+
out.push(enc);
|
|
452
|
+
break;
|
|
453
|
+
default:
|
|
454
|
+
throw new Error(`Unsupported Content-Encoding: ${enc}`);
|
|
242
455
|
}
|
|
243
456
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
457
|
+
return out;
|
|
458
|
+
}
|
|
459
|
+
function createDecompressTransform(encoding) {
|
|
460
|
+
switch (encoding) {
|
|
461
|
+
case 'gzip':
|
|
462
|
+
case 'x-gzip':
|
|
463
|
+
return createGunzip();
|
|
464
|
+
case 'deflate':
|
|
465
|
+
return createInflate();
|
|
466
|
+
case 'br':
|
|
467
|
+
return createBrotliDecompress();
|
|
468
|
+
default: {
|
|
469
|
+
const _exhaustive = encoding;
|
|
470
|
+
throw new Error(`Unsupported Content-Encoding: ${String(_exhaustive)}`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
function bodyStreamForResponse(res, encodings) {
|
|
475
|
+
let stream = res;
|
|
476
|
+
for (const enc of [...encodings].reverse()) {
|
|
477
|
+
stream = stream.pipe(createDecompressTransform(enc));
|
|
478
|
+
}
|
|
479
|
+
return stream;
|
|
480
|
+
}
|
|
481
|
+
async function readIncomingMessageWithLimit(res, maxBytes) {
|
|
482
|
+
let encodings;
|
|
483
|
+
try {
|
|
484
|
+
encodings = parseContentEncodings(headerValue(res.headers, 'content-encoding'));
|
|
485
|
+
}
|
|
486
|
+
catch (err) {
|
|
487
|
+
await releaseIncomingMessage(res);
|
|
488
|
+
throw err;
|
|
250
489
|
}
|
|
490
|
+
if (!encodings.length) {
|
|
491
|
+
const declared = res.headers['content-length'];
|
|
492
|
+
if (declared) {
|
|
493
|
+
const n = parseInt(String(declared), 10);
|
|
494
|
+
if (Number.isFinite(n) && n > maxBytes) {
|
|
495
|
+
res.destroy();
|
|
496
|
+
throw new Error('Response too large');
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
const stream = bodyStreamForResponse(res, encodings);
|
|
251
501
|
const chunks = [];
|
|
252
502
|
let total = 0;
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
catch {
|
|
264
|
-
/* ignore */
|
|
503
|
+
try {
|
|
504
|
+
for await (const chunk of stream) {
|
|
505
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
506
|
+
total += buf.length;
|
|
507
|
+
if (total > maxBytes) {
|
|
508
|
+
res.destroy();
|
|
509
|
+
if ('destroy' in stream && typeof stream.destroy === 'function') {
|
|
510
|
+
stream.destroy();
|
|
511
|
+
}
|
|
512
|
+
throw new Error('Response too large');
|
|
265
513
|
}
|
|
266
|
-
|
|
514
|
+
chunks.push(buf);
|
|
267
515
|
}
|
|
268
|
-
|
|
516
|
+
}
|
|
517
|
+
catch (err) {
|
|
518
|
+
if (err instanceof Error && err.message === 'Response too large')
|
|
519
|
+
throw err;
|
|
520
|
+
res.destroy();
|
|
521
|
+
if ('destroy' in stream && typeof stream.destroy === 'function') {
|
|
522
|
+
stream.destroy();
|
|
523
|
+
}
|
|
524
|
+
throw err;
|
|
269
525
|
}
|
|
270
526
|
return Buffer.concat(chunks);
|
|
271
527
|
}
|
|
528
|
+
async function releaseIncomingMessage(res) {
|
|
529
|
+
await drainIncomingMessage(res);
|
|
530
|
+
}
|
|
531
|
+
function headerValue(headers, name) {
|
|
532
|
+
const raw = headers[name.toLowerCase()];
|
|
533
|
+
if (raw === undefined)
|
|
534
|
+
return null;
|
|
535
|
+
return Array.isArray(raw) ? (raw[0] ?? null) : raw;
|
|
536
|
+
}
|
|
272
537
|
/**
|
|
273
538
|
* Parse an RFC 2397 data URL into `{ mediaType, base64 }`. Accepts MIME
|
|
274
539
|
* parameters (`data:audio/wav;charset=binary;base64,...`) and the bare
|
|
@@ -297,35 +562,33 @@ export function parseBase64DataUrl(source) {
|
|
|
297
562
|
export async function fetchHttpResource(urlString, opts) {
|
|
298
563
|
let current = urlString;
|
|
299
564
|
for (let hop = 0; hop <= opts.maxRedirects; hop++) {
|
|
300
|
-
const validated = await
|
|
565
|
+
const { url: validated, addresses } = await validateUrlAndResolveAddresses(current);
|
|
301
566
|
const target = validated.href;
|
|
302
|
-
const controller = new AbortController();
|
|
303
|
-
const t = setTimeout(() => controller.abort(), opts.timeoutMs);
|
|
304
567
|
let res;
|
|
305
568
|
try {
|
|
306
|
-
res = await
|
|
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
|
-
});
|
|
569
|
+
res = await pinnedRequestWithFallback(validated, addresses, opts.timeoutMs);
|
|
314
570
|
}
|
|
315
|
-
|
|
316
|
-
|
|
571
|
+
catch (err) {
|
|
572
|
+
if (err instanceof Error && err.message === 'Fetch timed out') {
|
|
573
|
+
throw new Error('Fetch timed out');
|
|
574
|
+
}
|
|
575
|
+
throw err;
|
|
317
576
|
}
|
|
318
|
-
|
|
319
|
-
|
|
577
|
+
const statusCode = res.statusCode ?? 0;
|
|
578
|
+
if (statusCode >= 300 && statusCode < 400) {
|
|
579
|
+
const loc = headerValue(res.headers, 'location');
|
|
580
|
+
await releaseIncomingMessage(res);
|
|
320
581
|
if (!loc)
|
|
321
582
|
throw new Error('Redirect without Location header');
|
|
322
583
|
current = new URL(loc, target).href;
|
|
323
584
|
continue;
|
|
324
585
|
}
|
|
325
|
-
if (
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
586
|
+
if (statusCode < 200 || statusCode >= 300) {
|
|
587
|
+
await releaseIncomingMessage(res);
|
|
588
|
+
throw new Error(`HTTP ${statusCode}`);
|
|
589
|
+
}
|
|
590
|
+
const buffer = await readIncomingMessageWithLimit(res, opts.maxBytes);
|
|
591
|
+
return { buffer, contentType: headerValue(res.headers, 'content-type') };
|
|
329
592
|
}
|
|
330
593
|
throw new Error('Too many redirects');
|
|
331
594
|
}
|
|
@@ -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,60 +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
|
-
/**
|
|
26
|
-
export declare function
|
|
13
|
+
/** Decode each streamed base64 fragment and concatenate binary (joining strings corrupts padding). */
|
|
14
|
+
export declare function assembleBase64AudioChunks(chunks: string[]): Buffer;
|
|
27
15
|
export declare function handleGenerateAudio(request: {
|
|
28
16
|
params: {
|
|
29
17
|
arguments: GenerateAudioToolRequest;
|
|
30
18
|
};
|
|
31
|
-
}, openai: OpenAI): Promise<
|
|
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
|
-
};
|
|
19
|
+
}, openai: OpenAI): Promise<{
|
|
20
|
+
content: import("./tool-result-payload.js").BinaryToolContent[];
|
|
21
|
+
_meta: Record<string, unknown>;
|
|
67
22
|
}>;
|