@stabgan/openrouter-mcp-multimodal 2.0.0 → 3.1.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 +198 -145
- package/dist/errors.d.ts +42 -0
- package/dist/errors.js +46 -0
- package/dist/index.js +1 -1
- package/dist/logger.d.ts +22 -0
- package/dist/logger.js +47 -0
- package/dist/model-cache.d.ts +10 -0
- package/dist/model-cache.js +31 -1
- package/dist/openrouter-api.d.ts +54 -0
- package/dist/openrouter-api.js +136 -12
- package/dist/tool-handlers/analyze-audio.d.ts +5 -9
- package/dist/tool-handlers/analyze-audio.js +41 -8
- package/dist/tool-handlers/analyze-image.d.ts +5 -9
- package/dist/tool-handlers/analyze-image.js +38 -8
- package/dist/tool-handlers/analyze-video.d.ts +19 -0
- package/dist/tool-handlers/analyze-video.js +93 -0
- package/dist/tool-handlers/audio-utils.js +7 -9
- package/dist/tool-handlers/chat-completion.d.ts +6 -10
- package/dist/tool-handlers/chat-completion.js +27 -7
- package/dist/tool-handlers/completion-utils.d.ts +27 -0
- package/dist/tool-handlers/completion-utils.js +69 -0
- package/dist/tool-handlers/fetch-utils.d.ts +32 -0
- package/dist/tool-handlers/fetch-utils.js +216 -12
- package/dist/tool-handlers/generate-audio.d.ts +32 -12
- package/dist/tool-handlers/generate-audio.js +77 -46
- package/dist/tool-handlers/generate-image.d.ts +48 -10
- package/dist/tool-handlers/generate-image.js +148 -33
- package/dist/tool-handlers/generate-video.d.ts +78 -0
- package/dist/tool-handlers/generate-video.js +353 -0
- package/dist/tool-handlers/get-model-info.d.ts +2 -9
- package/dist/tool-handlers/get-model-info.js +15 -5
- package/dist/tool-handlers/image-utils.d.ts +17 -1
- package/dist/tool-handlers/image-utils.js +66 -13
- package/dist/tool-handlers/openrouter-errors.d.ts +18 -0
- package/dist/tool-handlers/openrouter-errors.js +99 -0
- package/dist/tool-handlers/path-safety.d.ts +11 -0
- package/dist/tool-handlers/path-safety.js +88 -0
- package/dist/tool-handlers/search-models.d.ts +2 -9
- package/dist/tool-handlers/search-models.js +10 -6
- package/dist/tool-handlers/validate-model.d.ts +2 -9
- package/dist/tool-handlers/validate-model.js +15 -4
- package/dist/tool-handlers/video-utils.d.ts +29 -0
- package/dist/tool-handlers/video-utils.js +174 -0
- package/dist/tool-handlers.js +229 -21
- package/package.json +3 -3
- package/dist/__tests__/audio-utils.test.d.ts +0 -1
- package/dist/__tests__/audio-utils.test.js +0 -120
- package/dist/__tests__/fetch-utils.test.d.ts +0 -1
- package/dist/__tests__/fetch-utils.test.js +0 -76
- package/dist/__tests__/generate-audio.test.d.ts +0 -1
- package/dist/__tests__/generate-audio.test.js +0 -90
- package/dist/__tests__/image-utils.test.d.ts +0 -1
- package/dist/__tests__/image-utils.test.js +0 -75
- package/dist/__tests__/integration.test.d.ts +0 -1
- package/dist/__tests__/integration.test.js +0 -219
- package/dist/__tests__/model-cache.test.d.ts +0 -1
- package/dist/__tests__/model-cache.test.js +0 -96
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import OpenAI from 'openai';
|
|
2
|
-
import { ChatCompletionMessageParam } from 'openai/resources/chat/completions.js';
|
|
2
|
+
import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions.js';
|
|
3
3
|
export interface ChatCompletionToolRequest {
|
|
4
4
|
model?: string;
|
|
5
5
|
messages: ChatCompletionMessageParam[];
|
|
@@ -10,16 +10,12 @@ export declare function handleChatCompletion(request: {
|
|
|
10
10
|
params: {
|
|
11
11
|
arguments: ChatCompletionToolRequest;
|
|
12
12
|
};
|
|
13
|
-
}, openai: OpenAI, defaultModel?: string): Promise<{
|
|
13
|
+
}, openai: OpenAI, defaultModel?: string): Promise<import("../errors.js").ToolErrorResult | {
|
|
14
14
|
content: {
|
|
15
|
-
type:
|
|
15
|
+
type: "text";
|
|
16
16
|
text: string;
|
|
17
17
|
}[];
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
type: string;
|
|
22
|
-
text: string;
|
|
23
|
-
}[];
|
|
24
|
-
isError?: undefined;
|
|
18
|
+
_meta: {
|
|
19
|
+
finish_reason: "length" | "stop" | "tool_calls" | "content_filter" | "function_call" | undefined;
|
|
20
|
+
};
|
|
25
21
|
}>;
|
|
@@ -1,19 +1,39 @@
|
|
|
1
|
+
import { ErrorCode, toolError } from '../errors.js';
|
|
2
|
+
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
3
|
+
import { extractCompletionText, detectReasoningCutoff, toUsageMeta, } from './completion-utils.js';
|
|
1
4
|
export async function handleChatCompletion(request, openai, defaultModel) {
|
|
2
|
-
const { messages, model, temperature, max_tokens } = request.params.arguments
|
|
5
|
+
const { messages, model, temperature, max_tokens } = request.params.arguments ?? {
|
|
6
|
+
messages: [],
|
|
7
|
+
};
|
|
3
8
|
if (!messages?.length) {
|
|
4
|
-
return
|
|
9
|
+
return toolError(ErrorCode.INVALID_INPUT, 'Messages array cannot be empty.');
|
|
5
10
|
}
|
|
11
|
+
let completion;
|
|
6
12
|
try {
|
|
7
|
-
|
|
13
|
+
completion = await openai.chat.completions.create({
|
|
8
14
|
model: model || defaultModel || 'nvidia/nemotron-nano-12b-v2-vl:free',
|
|
9
15
|
messages,
|
|
10
16
|
temperature: temperature ?? 1,
|
|
11
17
|
...(max_tokens && { max_tokens }),
|
|
12
18
|
});
|
|
13
|
-
return { content: [{ type: 'text', text: completion.choices[0].message.content || '' }] };
|
|
14
19
|
}
|
|
15
|
-
catch (
|
|
16
|
-
|
|
17
|
-
return { content: [{ type: 'text', text: `API error: ${msg}` }], isError: true };
|
|
20
|
+
catch (err) {
|
|
21
|
+
return classifyUpstreamError(err);
|
|
18
22
|
}
|
|
23
|
+
const extracted = extractCompletionText(completion);
|
|
24
|
+
const cutoff = detectReasoningCutoff(extracted);
|
|
25
|
+
if (cutoff)
|
|
26
|
+
return cutoff;
|
|
27
|
+
if (!extracted.text) {
|
|
28
|
+
return toolError(ErrorCode.INTERNAL, 'Model returned no textual content.', {
|
|
29
|
+
finish_reason: extracted.finishReason,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
content: [{ type: 'text', text: extracted.text }],
|
|
34
|
+
_meta: {
|
|
35
|
+
finish_reason: extracted.finishReason,
|
|
36
|
+
...(toUsageMeta(extracted.usage) ?? {}),
|
|
37
|
+
},
|
|
38
|
+
};
|
|
19
39
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for tools that call `openai.chat.completions.create` and
|
|
3
|
+
* return the assistant's message as text. Handles:
|
|
4
|
+
* - plain string content (the common case)
|
|
5
|
+
* - multimodal array content (concatenate text parts)
|
|
6
|
+
* - reasoning-only responses (`content: null` + `reasoning`/`reasoning_details`)
|
|
7
|
+
* - `finish_reason === 'length'` — warn the caller so they know to raise
|
|
8
|
+
* `max_tokens` instead of silently getting nothing back.
|
|
9
|
+
*/
|
|
10
|
+
import type { ChatCompletion } from 'openai/resources/chat/completions.js';
|
|
11
|
+
import { type ToolErrorResult } from '../errors.js';
|
|
12
|
+
export interface ExtractedText {
|
|
13
|
+
text: string;
|
|
14
|
+
/** True when `text` came from the reasoning trace (not a final answer). */
|
|
15
|
+
reasonedOnly: boolean;
|
|
16
|
+
finishReason: ChatCompletion.Choice['finish_reason'] | undefined;
|
|
17
|
+
usage?: ChatCompletion['usage'];
|
|
18
|
+
}
|
|
19
|
+
export declare function extractCompletionText(completion: ChatCompletion): ExtractedText;
|
|
20
|
+
/**
|
|
21
|
+
* If the extracted response is reasoning-only and was cut off by
|
|
22
|
+
* `max_tokens`, return a structured INVALID_INPUT suggesting the caller
|
|
23
|
+
* raise the budget. Otherwise return `null` (let the caller format the
|
|
24
|
+
* success response).
|
|
25
|
+
*/
|
|
26
|
+
export declare function detectReasoningCutoff(extracted: ExtractedText): ToolErrorResult | null;
|
|
27
|
+
export declare function toUsageMeta(usage: ChatCompletion['usage'] | undefined): Record<string, unknown> | undefined;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { ErrorCode, toolError } from '../errors.js';
|
|
2
|
+
export function extractCompletionText(completion) {
|
|
3
|
+
const choice = completion.choices?.[0];
|
|
4
|
+
const msg = choice?.message;
|
|
5
|
+
const finishReason = choice?.finish_reason;
|
|
6
|
+
const usage = completion.usage ?? undefined;
|
|
7
|
+
if (!msg)
|
|
8
|
+
return { text: '', reasonedOnly: false, finishReason, usage };
|
|
9
|
+
const { content, reasoning, reasoning_details } = msg;
|
|
10
|
+
if (typeof content === 'string' && content.length > 0) {
|
|
11
|
+
return { text: content, reasonedOnly: false, finishReason, usage };
|
|
12
|
+
}
|
|
13
|
+
if (Array.isArray(content)) {
|
|
14
|
+
const parts = content
|
|
15
|
+
.filter((p) => p.type === 'text' && typeof p.text === 'string')
|
|
16
|
+
.map((p) => p.text ?? '');
|
|
17
|
+
const joined = parts.join('');
|
|
18
|
+
if (joined.length > 0) {
|
|
19
|
+
return { text: joined, reasonedOnly: false, finishReason, usage };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (typeof reasoning === 'string' && reasoning.length > 0) {
|
|
23
|
+
return { text: reasoning, reasonedOnly: true, finishReason, usage };
|
|
24
|
+
}
|
|
25
|
+
if (Array.isArray(reasoning_details) && reasoning_details.length > 0) {
|
|
26
|
+
const joined = reasoning_details
|
|
27
|
+
.filter((d) => typeof d.text === 'string')
|
|
28
|
+
.map((d) => d.text)
|
|
29
|
+
.join('\n');
|
|
30
|
+
if (joined.length > 0) {
|
|
31
|
+
return { text: joined, reasonedOnly: true, finishReason, usage };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return { text: '', reasonedOnly: false, finishReason, usage };
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* If the extracted response is reasoning-only and was cut off by
|
|
38
|
+
* `max_tokens`, return a structured INVALID_INPUT suggesting the caller
|
|
39
|
+
* raise the budget. Otherwise return `null` (let the caller format the
|
|
40
|
+
* success response).
|
|
41
|
+
*/
|
|
42
|
+
export function detectReasoningCutoff(extracted) {
|
|
43
|
+
if (extracted.reasonedOnly && extracted.finishReason === 'length') {
|
|
44
|
+
return toolError(ErrorCode.INVALID_INPUT, 'Model exhausted max_tokens during internal reasoning without emitting a final answer. ' +
|
|
45
|
+
'Raise max_tokens or choose a non-reasoning model.', {
|
|
46
|
+
finish_reason: extracted.finishReason,
|
|
47
|
+
reasoning_preview: extracted.text.slice(0, 200),
|
|
48
|
+
usage: extracted.usage
|
|
49
|
+
? {
|
|
50
|
+
prompt_tokens: extracted.usage.prompt_tokens,
|
|
51
|
+
completion_tokens: extracted.usage.completion_tokens,
|
|
52
|
+
total_tokens: extracted.usage.total_tokens,
|
|
53
|
+
}
|
|
54
|
+
: undefined,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
export function toUsageMeta(usage) {
|
|
60
|
+
if (!usage)
|
|
61
|
+
return undefined;
|
|
62
|
+
return {
|
|
63
|
+
usage: {
|
|
64
|
+
prompt_tokens: usage.prompt_tokens,
|
|
65
|
+
completion_tokens: usage.completion_tokens,
|
|
66
|
+
total_tokens: usage.total_tokens,
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -1,8 +1,40 @@
|
|
|
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).
|
|
10
|
+
*/
|
|
11
|
+
export declare const FETCH_USER_AGENT: string;
|
|
1
12
|
export declare function readEnvInt(name: string, fallback: number, min?: number): number;
|
|
2
13
|
/** Blocks RFC1918, loopback, link-local, CGNAT, metadata. */
|
|
3
14
|
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
|
+
*/
|
|
25
|
+
export declare function isBlockedIPv6(ip: string): boolean;
|
|
4
26
|
/** Resolve hostname and ensure the resolved address is not private/link-local. */
|
|
5
27
|
export declare function assertUrlSafeForFetch(urlString: string): Promise<URL>;
|
|
28
|
+
/**
|
|
29
|
+
* Parse an RFC 2397 data URL into `{ mediaType, base64 }`. Accepts MIME
|
|
30
|
+
* parameters (`data:audio/wav;charset=binary;base64,...`) and the bare
|
|
31
|
+
* `data:;base64,...` form. Returns `null` for anything that is not a
|
|
32
|
+
* base64-encoded data URL.
|
|
33
|
+
*/
|
|
34
|
+
export declare function parseBase64DataUrl(source: string): {
|
|
35
|
+
mediaType: string;
|
|
36
|
+
base64: string;
|
|
37
|
+
} | null;
|
|
6
38
|
export interface FetchOptions {
|
|
7
39
|
timeoutMs: number;
|
|
8
40
|
maxBytes: number;
|
|
@@ -3,6 +3,45 @@
|
|
|
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 net from 'node:net';
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import path from 'node:path';
|
|
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).
|
|
19
|
+
*/
|
|
20
|
+
export const FETCH_USER_AGENT = (() => {
|
|
21
|
+
const fallback = 'openrouter-mcp-multimodal/dev (+https://github.com/stabgan/openrouter-mcp-multimodal)';
|
|
22
|
+
try {
|
|
23
|
+
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
|
+
for (let hop = 0; hop < 5; hop++) {
|
|
27
|
+
const candidate = path.resolve(here, '../'.repeat(hop), 'package.json');
|
|
28
|
+
try {
|
|
29
|
+
const raw = readFileSync(candidate, 'utf8');
|
|
30
|
+
const pkg = JSON.parse(raw);
|
|
31
|
+
if (pkg?.version && pkg?.name?.includes('openrouter-mcp-multimodal')) {
|
|
32
|
+
return `openrouter-mcp-multimodal/${pkg.version} (+https://github.com/stabgan/openrouter-mcp-multimodal)`;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
/* keep walking */
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
/* fall through */
|
|
42
|
+
}
|
|
43
|
+
return fallback;
|
|
44
|
+
})();
|
|
6
45
|
export function readEnvInt(name, fallback, min = 1) {
|
|
7
46
|
const raw = process.env[name];
|
|
8
47
|
if (raw === undefined || raw === '')
|
|
@@ -36,18 +75,136 @@ export function isBlockedIPv4(ip) {
|
|
|
36
75
|
return true;
|
|
37
76
|
return false;
|
|
38
77
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
+
*/
|
|
84
|
+
function expandIPv6(ip) {
|
|
85
|
+
// Strip optional brackets (URL host form) and zone id before validation.
|
|
86
|
+
const noZone = ip.includes('%') ? ip.split('%')[0] : ip;
|
|
87
|
+
const noBrackets = noZone.replace(/^\[|\]$/g, '');
|
|
88
|
+
if (!net.isIPv6(noBrackets))
|
|
89
|
+
return null;
|
|
90
|
+
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
|
+
let v4Tail = null;
|
|
95
|
+
const dotIndex = addr.indexOf('.');
|
|
96
|
+
if (dotIndex >= 0) {
|
|
97
|
+
const lastColon = addr.lastIndexOf(':', dotIndex);
|
|
98
|
+
if (lastColon < 0)
|
|
99
|
+
return null;
|
|
100
|
+
const tail = addr.slice(lastColon + 1);
|
|
101
|
+
const parts = tail.split('.').map((p) => parseInt(p, 10));
|
|
102
|
+
if (parts.length !== 4 || parts.some((p) => !Number.isInteger(p) || p < 0 || p > 255)) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
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
|
+
const hex6 = v4Tail[0].toString(16);
|
|
108
|
+
const hex7 = v4Tail[1].toString(16);
|
|
109
|
+
addr = addr.slice(0, lastColon) + ':' + hex6 + ':' + hex7;
|
|
110
|
+
}
|
|
111
|
+
// Split on "::" at most once; fill the gap with zero groups.
|
|
112
|
+
const halves = addr.split('::');
|
|
113
|
+
if (halves.length > 2)
|
|
114
|
+
return null;
|
|
115
|
+
const left = halves[0] ? halves[0].split(':') : [];
|
|
116
|
+
const right = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
|
|
117
|
+
const missing = 8 - left.length - right.length;
|
|
118
|
+
if (halves.length === 2) {
|
|
119
|
+
if (missing < 0)
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
if (missing !== 0)
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
const zeros = Array(Math.max(0, missing)).fill('0');
|
|
127
|
+
const hexGroups = [...left, ...zeros, ...right];
|
|
128
|
+
if (hexGroups.length !== 8)
|
|
129
|
+
return null;
|
|
130
|
+
const out = [];
|
|
131
|
+
for (const g of hexGroups) {
|
|
132
|
+
if (g.length === 0 || g.length > 4 || !/^[0-9a-f]+$/.test(g))
|
|
133
|
+
return null;
|
|
134
|
+
out.push(parseInt(g, 16));
|
|
135
|
+
}
|
|
136
|
+
return out.length === 8 ? out : null;
|
|
137
|
+
}
|
|
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
|
+
*/
|
|
148
|
+
export function isBlockedIPv6(ip) {
|
|
149
|
+
const groups = expandIPv6(ip);
|
|
150
|
+
if (!groups)
|
|
151
|
+
return false;
|
|
152
|
+
const [g0, g1, g2, g3, g4, g5, g6, g7] = groups;
|
|
153
|
+
// :: (unspecified)
|
|
154
|
+
if (g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0 && g6 === 0 && g7 === 0) {
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
// ::1 (loopback)
|
|
158
|
+
if (g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0 && g6 === 0 && g7 === 1) {
|
|
43
159
|
return true;
|
|
44
|
-
|
|
160
|
+
}
|
|
161
|
+
// ::ffff:0:0/96 — IPv4-mapped. Re-check the embedded IPv4.
|
|
162
|
+
if (g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0xffff) {
|
|
163
|
+
const v4 = ((g6 << 16) >>> 0) | g7;
|
|
164
|
+
const dotted = `${(v4 >>> 24) & 0xff}.${(v4 >>> 16) & 0xff}.${(v4 >>> 8) & 0xff}.${v4 & 0xff}`;
|
|
165
|
+
return isBlockedIPv4(dotted);
|
|
166
|
+
}
|
|
167
|
+
// ::/96 IPv4-compatible (deprecated but still routable in places).
|
|
168
|
+
if (g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0) {
|
|
169
|
+
// Only treat as IPv4-compat if g6/g7 actually look like an IPv4 (both
|
|
170
|
+
// are nonzero or this is the all-zeros case handled above).
|
|
171
|
+
if (g6 !== 0 || g7 !== 0) {
|
|
172
|
+
const v4 = ((g6 << 16) >>> 0) | g7;
|
|
173
|
+
const dotted = `${(v4 >>> 24) & 0xff}.${(v4 >>> 16) & 0xff}.${(v4 >>> 8) & 0xff}.${v4 & 0xff}`;
|
|
174
|
+
return isBlockedIPv4(dotted);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// fc00::/7 — ULA
|
|
178
|
+
if ((g0 & 0xfe00) === 0xfc00)
|
|
179
|
+
return true;
|
|
180
|
+
// fe80::/10 — link-local
|
|
181
|
+
if ((g0 & 0xffc0) === 0xfe80)
|
|
182
|
+
return true;
|
|
183
|
+
// fec0::/10 — deprecated site-local
|
|
184
|
+
if ((g0 & 0xffc0) === 0xfec0)
|
|
185
|
+
return true;
|
|
186
|
+
// ff00::/8 — multicast (all forms)
|
|
187
|
+
if ((g0 & 0xff00) === 0xff00)
|
|
188
|
+
return true;
|
|
189
|
+
// 100::/64 — discard prefix (RFC 6666)
|
|
190
|
+
if (g0 === 0x0100 && g1 === 0 && g2 === 0 && g3 === 0)
|
|
191
|
+
return true;
|
|
192
|
+
// 2001:db8::/32 — documentation
|
|
193
|
+
if (g0 === 0x2001 && g1 === 0x0db8)
|
|
194
|
+
return true;
|
|
195
|
+
// 2001::/32 — Teredo
|
|
196
|
+
if (g0 === 0x2001 && g1 === 0x0000)
|
|
45
197
|
return true;
|
|
46
|
-
|
|
47
|
-
if (
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
198
|
+
// 2001:10::/28, 2001:20::/28 — ORCHID / deprecated
|
|
199
|
+
if (g0 === 0x2001 && (g1 & 0xfff0) === 0x0010)
|
|
200
|
+
return true;
|
|
201
|
+
if (g0 === 0x2001 && (g1 & 0xfff0) === 0x0020)
|
|
202
|
+
return true;
|
|
203
|
+
// 2002::/16 — 6to4; re-check embedded IPv4 for private/reserved use.
|
|
204
|
+
if (g0 === 0x2002) {
|
|
205
|
+
const v4 = ((g1 << 16) >>> 0) | g2;
|
|
206
|
+
const dotted = `${(v4 >>> 24) & 0xff}.${(v4 >>> 16) & 0xff}.${(v4 >>> 8) & 0xff}.${v4 & 0xff}`;
|
|
207
|
+
return isBlockedIPv4(dotted);
|
|
51
208
|
}
|
|
52
209
|
return false;
|
|
53
210
|
}
|
|
@@ -107,6 +264,13 @@ export async function assertUrlSafeForFetch(urlString) {
|
|
|
107
264
|
return url;
|
|
108
265
|
}
|
|
109
266
|
async function readResponseBodyWithLimit(res, maxBytes) {
|
|
267
|
+
const declared = res.headers.get('content-length');
|
|
268
|
+
if (declared) {
|
|
269
|
+
const n = parseInt(declared, 10);
|
|
270
|
+
if (Number.isFinite(n) && n > maxBytes) {
|
|
271
|
+
throw new Error('Response too large');
|
|
272
|
+
}
|
|
273
|
+
}
|
|
110
274
|
const reader = res.body?.getReader();
|
|
111
275
|
if (!reader) {
|
|
112
276
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
@@ -121,12 +285,41 @@ async function readResponseBodyWithLimit(res, maxBytes) {
|
|
|
121
285
|
if (done)
|
|
122
286
|
break;
|
|
123
287
|
total += value.byteLength;
|
|
124
|
-
if (total > maxBytes)
|
|
288
|
+
if (total > maxBytes) {
|
|
289
|
+
// Cancel the underlying body so the server connection can be released.
|
|
290
|
+
try {
|
|
291
|
+
await reader.cancel();
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
/* ignore */
|
|
295
|
+
}
|
|
125
296
|
throw new Error('Response too large');
|
|
297
|
+
}
|
|
126
298
|
chunks.push(Buffer.from(value));
|
|
127
299
|
}
|
|
128
300
|
return Buffer.concat(chunks);
|
|
129
301
|
}
|
|
302
|
+
/**
|
|
303
|
+
* Parse an RFC 2397 data URL into `{ mediaType, base64 }`. Accepts MIME
|
|
304
|
+
* parameters (`data:audio/wav;charset=binary;base64,...`) and the bare
|
|
305
|
+
* `data:;base64,...` form. Returns `null` for anything that is not a
|
|
306
|
+
* base64-encoded data URL.
|
|
307
|
+
*/
|
|
308
|
+
export function parseBase64DataUrl(source) {
|
|
309
|
+
if (!source.startsWith('data:'))
|
|
310
|
+
return null;
|
|
311
|
+
const comma = source.indexOf(',');
|
|
312
|
+
if (comma < 0)
|
|
313
|
+
return null;
|
|
314
|
+
const prefix = source.slice(5, comma); // between "data:" and ","
|
|
315
|
+
const payload = source.slice(comma + 1);
|
|
316
|
+
const parts = prefix.split(';').map((p) => p.trim());
|
|
317
|
+
const hasBase64 = parts[parts.length - 1]?.toLowerCase() === 'base64';
|
|
318
|
+
if (!hasBase64)
|
|
319
|
+
return null;
|
|
320
|
+
const mediaType = (parts[0] && parts[0].includes('/') ? parts[0] : 'application/octet-stream').toLowerCase();
|
|
321
|
+
return { mediaType, base64: payload };
|
|
322
|
+
}
|
|
130
323
|
/**
|
|
131
324
|
* Fetch a remote HTTP(S) resource with SSRF protection, size limits,
|
|
132
325
|
* redirect cap, and timeout. Returns body Buffer + Content-Type header.
|
|
@@ -140,7 +333,18 @@ export async function fetchHttpResource(urlString, opts) {
|
|
|
140
333
|
const t = setTimeout(() => controller.abort(), opts.timeoutMs);
|
|
141
334
|
let res;
|
|
142
335
|
try {
|
|
143
|
-
res = await fetch(target, {
|
|
336
|
+
res = await fetch(target, {
|
|
337
|
+
redirect: 'manual',
|
|
338
|
+
signal: controller.signal,
|
|
339
|
+
headers: {
|
|
340
|
+
// Some CDNs/WAFs (notably Wikimedia/Varnish) reject requests
|
|
341
|
+
// without a User-Agent with HTTP 400. Identify ourselves so
|
|
342
|
+
// analyze_image / analyze_audio / analyze_video work against
|
|
343
|
+
// those origins. See https://github.com/stabgan/openrouter-mcp-multimodal/issues/13
|
|
344
|
+
'User-Agent': FETCH_USER_AGENT,
|
|
345
|
+
Accept: 'image/*, audio/*, video/*, */*;q=0.8',
|
|
346
|
+
},
|
|
347
|
+
});
|
|
144
348
|
}
|
|
145
349
|
finally {
|
|
146
350
|
clearTimeout(t);
|
|
@@ -6,40 +6,60 @@ export interface GenerateAudioToolRequest {
|
|
|
6
6
|
format?: string;
|
|
7
7
|
save_path?: string;
|
|
8
8
|
}
|
|
9
|
-
/** Create a 44-byte WAV header for raw PCM16 data. */
|
|
9
|
+
/** Create a 44-byte WAV header for raw PCM16 data at `sampleRate` Hz. */
|
|
10
10
|
export declare function createWavHeader(dataLength: number, sampleRate?: number): Buffer;
|
|
11
11
|
/**
|
|
12
|
-
* Detect audio container format from magic bytes.
|
|
13
|
-
*
|
|
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.
|
|
14
19
|
*/
|
|
15
20
|
export declare function detectAudioFormat(data: Buffer): {
|
|
16
21
|
ext: string;
|
|
17
22
|
mimeType: string;
|
|
18
23
|
};
|
|
19
|
-
export declare function wrapPcmInWav(pcmData: Buffer): Buffer;
|
|
24
|
+
export declare function wrapPcmInWav(pcmData: Buffer, sampleRate?: number): Buffer;
|
|
20
25
|
/** Strip existing extension (if any) and append a new one. */
|
|
21
26
|
export declare function replaceExtension(filePath: string, newExt: string): string;
|
|
22
27
|
export declare function handleGenerateAudio(request: {
|
|
23
28
|
params: {
|
|
24
29
|
arguments: GenerateAudioToolRequest;
|
|
25
30
|
};
|
|
26
|
-
}, openai: OpenAI): Promise<{
|
|
27
|
-
content: {
|
|
28
|
-
type:
|
|
31
|
+
}, openai: OpenAI): Promise<import("../errors.js").ToolErrorResult | {
|
|
32
|
+
content: ({
|
|
33
|
+
type: "text";
|
|
29
34
|
text: string;
|
|
30
|
-
|
|
31
|
-
|
|
35
|
+
mimeType?: undefined;
|
|
36
|
+
data?: undefined;
|
|
37
|
+
} | {
|
|
38
|
+
type: "audio";
|
|
39
|
+
mimeType: string;
|
|
40
|
+
data: string;
|
|
41
|
+
text?: undefined;
|
|
42
|
+
})[];
|
|
43
|
+
_meta: {
|
|
44
|
+
save_path: string;
|
|
45
|
+
mime: string;
|
|
46
|
+
size_bytes: number;
|
|
47
|
+
};
|
|
32
48
|
} | {
|
|
33
49
|
content: ({
|
|
34
|
-
type:
|
|
50
|
+
type: "text";
|
|
35
51
|
text: string;
|
|
36
52
|
mimeType?: undefined;
|
|
37
53
|
data?: undefined;
|
|
38
54
|
} | {
|
|
39
|
-
type:
|
|
55
|
+
type: "audio";
|
|
40
56
|
mimeType: string;
|
|
41
57
|
data: string;
|
|
42
58
|
text?: undefined;
|
|
43
59
|
})[];
|
|
44
|
-
|
|
60
|
+
_meta: {
|
|
61
|
+
mime: string;
|
|
62
|
+
size_bytes: number;
|
|
63
|
+
save_path?: undefined;
|
|
64
|
+
};
|
|
45
65
|
}>;
|