@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.
- package/README.md +95 -41
- package/dist/errors.d.ts +5 -20
- package/dist/errors.js +1 -10
- package/dist/index.js +8 -2
- package/dist/logger.js +54 -24
- package/dist/model-cache.d.ts +13 -0
- package/dist/model-cache.js +62 -8
- package/dist/openrouter-api.d.ts +14 -15
- package/dist/openrouter-api.js +68 -22
- package/dist/tool-definitions.d.ts +24 -0
- package/dist/tool-definitions.js +280 -177
- package/dist/tool-descriptions.d.ts +0 -4
- package/dist/tool-descriptions.js +30 -21
- package/dist/tool-handlers/analyze-audio.js +4 -1
- package/dist/tool-handlers/analyze-image.js +11 -6
- 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 +104 -30
- 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 +4 -1
- package/dist/tool-handlers/chat-request.js +29 -1
- 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 +329 -77
- package/dist/tool-handlers/generate-audio.d.ts +4 -15
- package/dist/tool-handlers/generate-audio.js +21 -53
- package/dist/tool-handlers/generate-image-dedicated.d.ts +1 -1
- package/dist/tool-handlers/generate-image-dedicated.js +50 -31
- package/dist/tool-handlers/generate-image.d.ts +1 -1
- package/dist/tool-handlers/generate-image.js +19 -22
- package/dist/tool-handlers/generate-video.d.ts +4 -3
- package/dist/tool-handlers/generate-video.js +42 -18
- 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.js +49 -17
- package/dist/tool-handlers/path-utils.d.ts +2 -0
- package/dist/tool-handlers/path-utils.js +13 -0
- package/dist/tool-handlers/provider-routing.d.ts +2 -0
- package/dist/tool-handlers/provider-routing.js +11 -1
- package/dist/tool-handlers/rerank.d.ts +1 -4
- package/dist/tool-handlers/rerank.js +44 -15
- 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 +1 -1
- package/dist/tool-handlers/text-to-speech.js +33 -20
- package/dist/tool-handlers/tool-result-payload.js +11 -9
- package/dist/tool-handlers/validate-model.js +1 -1
- package/dist/tool-handlers.d.ts +9 -0
- package/dist/tool-handlers.js +17 -5
- package/dist/tts-defaults.d.ts +4 -0
- package/dist/tts-defaults.js +4 -0
- package/dist/version.d.ts +3 -2
- package/dist/version.js +4 -2
- package/package.json +11 -13
|
@@ -3,37 +3,78 @@ import path from 'node:path';
|
|
|
3
3
|
import { promises as fs } from 'node:fs';
|
|
4
4
|
import { readEnvInt, fetchHttpResource, parseBase64DataUrl } from './fetch-utils.js';
|
|
5
5
|
import { resolveSafeInputPath } from './path-safety.js';
|
|
6
|
-
// Re-export for tests
|
|
7
6
|
export { isBlockedIPv4, assertUrlSafeForFetch } from './fetch-utils.js';
|
|
8
7
|
const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
|
|
9
8
|
const DEFAULT_MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024;
|
|
10
9
|
const DEFAULT_MAX_REDIRECTS = 8;
|
|
11
|
-
const DEFAULT_MAX_DATA_URL_BYTES = 20 * 1024 * 1024;
|
|
12
10
|
function getFetchTimeoutMs() {
|
|
13
11
|
return readEnvInt('OPENROUTER_AUDIO_FETCH_TIMEOUT_MS', DEFAULT_FETCH_TIMEOUT_MS, 1000);
|
|
14
12
|
}
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
/** Shared ceiling for speech_to_text local, HTTP, and data-URL inputs. */
|
|
14
|
+
export function getMaxAudioInputBytes() {
|
|
15
|
+
return readEnvInt('OPENROUTER_AUDIO_MAX_DOWNLOAD_BYTES', DEFAULT_MAX_DOWNLOAD_BYTES, 1);
|
|
17
16
|
}
|
|
18
17
|
function getMaxRedirects() {
|
|
19
18
|
return readEnvInt('OPENROUTER_AUDIO_MAX_REDIRECTS', DEFAULT_MAX_REDIRECTS, 0);
|
|
20
19
|
}
|
|
21
|
-
function getMaxDataUrlBytes() {
|
|
22
|
-
return readEnvInt('OPENROUTER_AUDIO_MAX_DATA_URL_BYTES', DEFAULT_MAX_DATA_URL_BYTES, 1024);
|
|
23
|
-
}
|
|
24
|
-
/** File-extension formats (matchable by .ext) */
|
|
25
20
|
const FILE_AUDIO_FORMATS = ['wav', 'mp3', 'aiff', 'aac', 'ogg', 'flac', 'm4a'];
|
|
26
|
-
/** API-only formats (no real file extension) */
|
|
27
21
|
const API_AUDIO_FORMATS = ['pcm16', 'pcm24'];
|
|
28
22
|
export const SUPPORTED_AUDIO_FORMATS = [...FILE_AUDIO_FORMATS, ...API_AUDIO_FORMATS];
|
|
29
|
-
|
|
23
|
+
export const STT_FILE_EXTENSIONS = [
|
|
24
|
+
'mp3',
|
|
25
|
+
'mp4',
|
|
26
|
+
'm4a',
|
|
27
|
+
'wav',
|
|
28
|
+
'flac',
|
|
29
|
+
'ogg',
|
|
30
|
+
'oga',
|
|
31
|
+
'webm',
|
|
32
|
+
'opus',
|
|
33
|
+
];
|
|
34
|
+
/**
|
|
35
|
+
* Detect audio container format from magic bytes. MP3 detection is strict:
|
|
36
|
+
* ID3 tags or frame sync with valid MPEG header fields.
|
|
37
|
+
*/
|
|
38
|
+
export function detectAudioFormat(data) {
|
|
39
|
+
if (data.length >= 3 && data[0] === 0x49 && data[1] === 0x44 && data[2] === 0x33) {
|
|
40
|
+
return { ext: 'mp3', mimeType: 'audio/mpeg' };
|
|
41
|
+
}
|
|
42
|
+
if (data.length >= 4 && data[0] === 0xff && (data[1] & 0xe0) === 0xe0) {
|
|
43
|
+
const b1 = data[1];
|
|
44
|
+
const b2 = data[2];
|
|
45
|
+
const versionBits = (b1 >> 3) & 0x03;
|
|
46
|
+
const layerBits = (b1 >> 1) & 0x03;
|
|
47
|
+
const bitrateIndex = (b2 >> 4) & 0x0f;
|
|
48
|
+
const sampleRateIndex = (b2 >> 2) & 0x03;
|
|
49
|
+
if (versionBits !== 0x01 &&
|
|
50
|
+
layerBits !== 0x00 &&
|
|
51
|
+
bitrateIndex !== 0x0f &&
|
|
52
|
+
sampleRateIndex !== 0x03) {
|
|
53
|
+
return { ext: 'mp3', mimeType: 'audio/mpeg' };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (data.length >= 12) {
|
|
57
|
+
const riff = data.subarray(0, 4).toString('ascii');
|
|
58
|
+
const wave = data.subarray(8, 12).toString('ascii');
|
|
59
|
+
if (riff === 'RIFF' && wave === 'WAVE') {
|
|
60
|
+
return { ext: 'wav', mimeType: 'audio/wav' };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (data.length >= 4) {
|
|
64
|
+
const magic = data.subarray(0, 4).toString('ascii');
|
|
65
|
+
if (magic === 'fLaC')
|
|
66
|
+
return { ext: 'flac', mimeType: 'audio/flac' };
|
|
67
|
+
if (magic === 'OggS')
|
|
68
|
+
return { ext: 'ogg', mimeType: 'audio/ogg' };
|
|
69
|
+
}
|
|
70
|
+
return { ext: 'pcm', mimeType: 'audio/pcm' };
|
|
71
|
+
}
|
|
30
72
|
export function getAudioFormat(filePath) {
|
|
31
73
|
const ext = path.extname(filePath).toLowerCase().slice(1);
|
|
32
74
|
return FILE_AUDIO_FORMATS.includes(ext)
|
|
33
75
|
? ext
|
|
34
76
|
: undefined;
|
|
35
77
|
}
|
|
36
|
-
/** Get MIME type for an audio format. */
|
|
37
78
|
export function getAudioMimeType(format) {
|
|
38
79
|
const map = {
|
|
39
80
|
wav: 'audio/wav',
|
|
@@ -48,7 +89,6 @@ export function getAudioMimeType(format) {
|
|
|
48
89
|
};
|
|
49
90
|
return map[format] || 'audio/wav';
|
|
50
91
|
}
|
|
51
|
-
/** Map a MIME subtype (e.g. "mpeg", "wave") to an AudioFormat. */
|
|
52
92
|
function mimeSubtypeToFormat(subtype) {
|
|
53
93
|
const aliasMap = {
|
|
54
94
|
mpeg: 'mp3',
|
|
@@ -71,7 +111,6 @@ function mimeSubtypeToFormat(subtype) {
|
|
|
71
111
|
? lower
|
|
72
112
|
: undefined));
|
|
73
113
|
}
|
|
74
|
-
/** Derive AudioFormat from a Content-Type header value. */
|
|
75
114
|
function formatFromContentType(ct) {
|
|
76
115
|
if (!ct)
|
|
77
116
|
return undefined;
|
|
@@ -80,7 +119,6 @@ function formatFromContentType(ct) {
|
|
|
80
119
|
return undefined;
|
|
81
120
|
return mimeSubtypeToFormat(mime.slice(6));
|
|
82
121
|
}
|
|
83
|
-
/** Prepare audio from data URL, HTTP URL, or sandboxed local file. */
|
|
84
122
|
export async function prepareAudioData(source) {
|
|
85
123
|
if (source.startsWith('data:')) {
|
|
86
124
|
const parsed = parseBase64DataUrl(source);
|
|
@@ -91,14 +129,14 @@ export async function prepareAudioData(source) {
|
|
|
91
129
|
throw new Error(`Unsupported audio format from MIME: ${parsed.mediaType}. Supported: ${SUPPORTED_AUDIO_FORMATS.join(', ')}`);
|
|
92
130
|
}
|
|
93
131
|
const approxBytes = Math.ceil((parsed.base64.length * 3) / 4);
|
|
94
|
-
if (approxBytes >
|
|
132
|
+
if (approxBytes > getMaxAudioInputBytes())
|
|
95
133
|
throw new Error('Data URL too large');
|
|
96
134
|
return { data: parsed.base64, format };
|
|
97
135
|
}
|
|
98
136
|
if (source.startsWith('http://') || source.startsWith('https://')) {
|
|
99
137
|
const { buffer, contentType } = await fetchHttpResource(source, {
|
|
100
138
|
timeoutMs: getFetchTimeoutMs(),
|
|
101
|
-
maxBytes:
|
|
139
|
+
maxBytes: getMaxAudioInputBytes(),
|
|
102
140
|
maxRedirects: getMaxRedirects(),
|
|
103
141
|
});
|
|
104
142
|
const urlPath = new URL(source).pathname;
|
|
@@ -113,6 +151,122 @@ export async function prepareAudioData(source) {
|
|
|
113
151
|
if (!format) {
|
|
114
152
|
throw new Error(`Unsupported audio format for file: ${source}. Supported: ${SUPPORTED_AUDIO_FORMATS.join(', ')}`);
|
|
115
153
|
}
|
|
154
|
+
const { size } = await fs.stat(safe);
|
|
155
|
+
assertWithinAudioInputLimit(size, 'Audio file');
|
|
116
156
|
const buffer = await fs.readFile(safe);
|
|
117
157
|
return { data: buffer.toString('base64'), format };
|
|
118
158
|
}
|
|
159
|
+
/** Map a speech_to_text file extension to the upstream format slug. */
|
|
160
|
+
export function sttFormatFromExtension(ext) {
|
|
161
|
+
const normalized = ext.toLowerCase().replace(/^\./, '');
|
|
162
|
+
switch (normalized) {
|
|
163
|
+
case 'mp3':
|
|
164
|
+
return 'mp3';
|
|
165
|
+
case 'mp4':
|
|
166
|
+
case 'm4a':
|
|
167
|
+
return 'mp4';
|
|
168
|
+
case 'wav':
|
|
169
|
+
return 'wav';
|
|
170
|
+
case 'flac':
|
|
171
|
+
return 'flac';
|
|
172
|
+
case 'ogg':
|
|
173
|
+
case 'oga':
|
|
174
|
+
return 'ogg';
|
|
175
|
+
case 'webm':
|
|
176
|
+
return 'webm';
|
|
177
|
+
case 'opus':
|
|
178
|
+
return 'opus';
|
|
179
|
+
default:
|
|
180
|
+
throw new Error(`Unsupported audio format for file extension '.${normalized}'. Supported: ${STT_FILE_EXTENSIONS.join(', ')}.`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function sttFormatFromMimeSubtype(subtype) {
|
|
184
|
+
const lower = subtype.toLowerCase();
|
|
185
|
+
switch (lower) {
|
|
186
|
+
case 'mpeg':
|
|
187
|
+
case 'mp3':
|
|
188
|
+
return 'mp3';
|
|
189
|
+
case 'wav':
|
|
190
|
+
case 'wave':
|
|
191
|
+
return 'wav';
|
|
192
|
+
case 'flac':
|
|
193
|
+
return 'flac';
|
|
194
|
+
case 'ogg':
|
|
195
|
+
return 'ogg';
|
|
196
|
+
case 'webm':
|
|
197
|
+
return 'webm';
|
|
198
|
+
case 'opus':
|
|
199
|
+
return 'opus';
|
|
200
|
+
case 'mp4':
|
|
201
|
+
case 'm4a':
|
|
202
|
+
case 'x-m4a':
|
|
203
|
+
return 'mp4';
|
|
204
|
+
default:
|
|
205
|
+
if (STT_FILE_EXTENSIONS.includes(lower)) {
|
|
206
|
+
return sttFormatFromExtension(lower);
|
|
207
|
+
}
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
function assertWithinAudioInputLimit(byteCount, label) {
|
|
212
|
+
const maxBytes = getMaxAudioInputBytes();
|
|
213
|
+
if (byteCount > maxBytes) {
|
|
214
|
+
throw new Error(`${label} too large (${byteCount} bytes, max ${maxBytes})`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/** Resolve speech_to_text audio from data URL, HTTP URL, or sandboxed local file. */
|
|
218
|
+
export async function resolveSpeechToTextAudio(audioPath) {
|
|
219
|
+
const trimmed = audioPath.trim();
|
|
220
|
+
if (!trimmed)
|
|
221
|
+
throw new Error('audio_path is empty');
|
|
222
|
+
if (trimmed.startsWith('data:')) {
|
|
223
|
+
const parsed = parseBase64DataUrl(trimmed);
|
|
224
|
+
if (!parsed || !parsed.mediaType.startsWith('audio/')) {
|
|
225
|
+
throw new Error('Invalid audio data URL format');
|
|
226
|
+
}
|
|
227
|
+
const approxBytes = Math.ceil((parsed.base64.length * 3) / 4);
|
|
228
|
+
assertWithinAudioInputLimit(approxBytes, 'Data URL');
|
|
229
|
+
const format = sttFormatFromMimeSubtype(parsed.mediaType.split('/')[1] ?? '');
|
|
230
|
+
if (!format) {
|
|
231
|
+
throw new Error(`Unsupported audio format from MIME: ${parsed.mediaType}. Supported: ${STT_FILE_EXTENSIONS.join(', ')}.`);
|
|
232
|
+
}
|
|
233
|
+
return { data: parsed.base64, format };
|
|
234
|
+
}
|
|
235
|
+
if (/^https?:\/\//i.test(trimmed)) {
|
|
236
|
+
const { buffer, contentType } = await fetchHttpResource(trimmed, {
|
|
237
|
+
timeoutMs: getFetchTimeoutMs(),
|
|
238
|
+
maxBytes: getMaxAudioInputBytes(),
|
|
239
|
+
maxRedirects: getMaxRedirects(),
|
|
240
|
+
});
|
|
241
|
+
assertWithinAudioInputLimit(buffer.length, 'Audio download');
|
|
242
|
+
const ext = path.extname(new URL(trimmed).pathname);
|
|
243
|
+
let format;
|
|
244
|
+
if (ext) {
|
|
245
|
+
try {
|
|
246
|
+
format = sttFormatFromExtension(ext);
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
format = undefined;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
format =
|
|
253
|
+
format ??
|
|
254
|
+
(contentType
|
|
255
|
+
? sttFormatFromMimeSubtype(contentType
|
|
256
|
+
.split(';')[0]
|
|
257
|
+
.trim()
|
|
258
|
+
.toLowerCase()
|
|
259
|
+
.replace(/^audio\//, ''))
|
|
260
|
+
: undefined);
|
|
261
|
+
if (!format) {
|
|
262
|
+
throw new Error(`Unsupported audio format from URL: ${trimmed}. Supported: ${STT_FILE_EXTENSIONS.join(', ')}.`);
|
|
263
|
+
}
|
|
264
|
+
return { data: buffer.toString('base64'), format };
|
|
265
|
+
}
|
|
266
|
+
const abs = await resolveSafeInputPath(trimmed);
|
|
267
|
+
const { size } = await fs.stat(abs);
|
|
268
|
+
assertWithinAudioInputLimit(size, 'Audio file');
|
|
269
|
+
const buf = await fs.readFile(abs);
|
|
270
|
+
const format = sttFormatFromExtension(path.extname(abs));
|
|
271
|
+
return { data: buf.toString('base64'), format };
|
|
272
|
+
}
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/** OpenRouter response caching via X-OpenRouter-Cache headers. */
|
|
2
|
+
import { type ToolErrorResult } from '../errors.js';
|
|
2
3
|
export interface CacheOptions {
|
|
3
4
|
cache?: boolean;
|
|
4
5
|
cache_ttl?: string;
|
|
5
6
|
cache_clear?: boolean;
|
|
6
7
|
}
|
|
7
|
-
/** Parse the env-default and return `true` when caching should be on by default. */
|
|
8
8
|
export declare function readCacheDefault(): boolean;
|
|
9
|
-
|
|
9
|
+
export declare function resolveCacheTtl(cacheTtl: string): string | ToolErrorResult;
|
|
10
|
+
export declare function validateCacheOptions(opts: CacheOptions | undefined): ToolErrorResult | null;
|
|
10
11
|
export declare function buildCacheHeaders(opts: CacheOptions | undefined): Record<string, string>;
|
|
11
|
-
/** Extract cache metadata from response headers, null when not present. */
|
|
12
12
|
export interface CacheMeta {
|
|
13
13
|
status: 'HIT' | 'MISS' | string;
|
|
14
14
|
age?: number;
|
|
@@ -1,17 +1,69 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** OpenRouter response caching via X-OpenRouter-Cache headers. */
|
|
2
|
+
import { ErrorCode, toolError } from '../errors.js';
|
|
3
|
+
const MIN_CACHE_TTL_SECONDS = 1;
|
|
4
|
+
const MAX_CACHE_TTL_SECONDS = 86_400;
|
|
5
|
+
const INTEGER_TTL_RE = /^\d+$/;
|
|
6
|
+
const DURATION_TTL_RE = /^(\d+)([smh])$/i;
|
|
7
|
+
const CACHE_TTL_INVALID_MSG = `cache_ttl must be an integer seconds value (${MIN_CACHE_TTL_SECONDS}–${MAX_CACHE_TTL_SECONDS}) ` +
|
|
8
|
+
'or a duration string like "30s", "5m", or "1h".';
|
|
2
9
|
export function readCacheDefault() {
|
|
3
10
|
const raw = (process.env.OPENROUTER_CACHE_RESPONSES ?? '').trim().toLowerCase();
|
|
4
11
|
return raw === '1' || raw === 'true' || raw === 'yes';
|
|
5
12
|
}
|
|
6
|
-
|
|
13
|
+
export function resolveCacheTtl(cacheTtl) {
|
|
14
|
+
const trimmed = cacheTtl.trim();
|
|
15
|
+
if (!trimmed) {
|
|
16
|
+
return toolError(ErrorCode.INVALID_INPUT, CACHE_TTL_INVALID_MSG);
|
|
17
|
+
}
|
|
18
|
+
let seconds;
|
|
19
|
+
const durationMatch = DURATION_TTL_RE.exec(trimmed);
|
|
20
|
+
if (durationMatch) {
|
|
21
|
+
const value = Number.parseInt(durationMatch[1], 10);
|
|
22
|
+
const unit = durationMatch[2].toLowerCase();
|
|
23
|
+
switch (unit) {
|
|
24
|
+
case 's':
|
|
25
|
+
seconds = value;
|
|
26
|
+
break;
|
|
27
|
+
case 'm':
|
|
28
|
+
seconds = value * 60;
|
|
29
|
+
break;
|
|
30
|
+
case 'h':
|
|
31
|
+
seconds = value * 3600;
|
|
32
|
+
break;
|
|
33
|
+
default: {
|
|
34
|
+
const _exhaustive = unit;
|
|
35
|
+
return toolError(ErrorCode.INVALID_INPUT, CACHE_TTL_INVALID_MSG);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
else if (INTEGER_TTL_RE.test(trimmed)) {
|
|
40
|
+
seconds = Number.parseInt(trimmed, 10);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
return toolError(ErrorCode.INVALID_INPUT, CACHE_TTL_INVALID_MSG);
|
|
44
|
+
}
|
|
45
|
+
if (seconds < MIN_CACHE_TTL_SECONDS || seconds > MAX_CACHE_TTL_SECONDS) {
|
|
46
|
+
return toolError(ErrorCode.INVALID_INPUT, `cache_ttl must be between ${MIN_CACHE_TTL_SECONDS} and ${MAX_CACHE_TTL_SECONDS} seconds (inclusive).`);
|
|
47
|
+
}
|
|
48
|
+
return String(seconds);
|
|
49
|
+
}
|
|
50
|
+
export function validateCacheOptions(opts) {
|
|
51
|
+
if (!opts?.cache_ttl)
|
|
52
|
+
return null;
|
|
53
|
+
const resolved = resolveCacheTtl(opts.cache_ttl);
|
|
54
|
+
return typeof resolved === 'string' ? null : resolved;
|
|
55
|
+
}
|
|
7
56
|
export function buildCacheHeaders(opts) {
|
|
8
57
|
const headers = {};
|
|
9
58
|
const defaultOn = readCacheDefault();
|
|
10
59
|
const enabled = opts?.cache ?? defaultOn;
|
|
11
60
|
if (enabled)
|
|
12
61
|
headers['X-OpenRouter-Cache'] = 'true';
|
|
13
|
-
if (opts?.cache_ttl)
|
|
14
|
-
|
|
62
|
+
if (opts?.cache_ttl) {
|
|
63
|
+
const resolved = resolveCacheTtl(opts.cache_ttl);
|
|
64
|
+
if (typeof resolved === 'string')
|
|
65
|
+
headers['X-OpenRouter-Cache-TTL'] = resolved;
|
|
66
|
+
}
|
|
15
67
|
if (opts?.cache_clear)
|
|
16
68
|
headers['X-OpenRouter-Cache-Clear'] = 'true';
|
|
17
69
|
return headers;
|
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
import { ErrorCode, toolError } from '../errors.js';
|
|
2
2
|
import { SERVER_VERSION } from '../version.js';
|
|
3
3
|
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
4
|
-
import { extractCompletionText, detectReasoningCutoff, buildCompletionMeta, } from './completion-utils.js';
|
|
5
|
-
import { extractCacheMeta } from './cache.js';
|
|
4
|
+
import { extractCompletionText, detectReasoningCutoff, buildCompletionMeta, capResultText, } from './completion-utils.js';
|
|
5
|
+
import { extractCacheMeta, validateCacheOptions } from './cache.js';
|
|
6
6
|
import { awaitCompletionWithHeaders } from './openai-withresponse.js';
|
|
7
|
-
import { DEFAULT_CHAT_MODEL, buildChatCompletionBody, buildChatCompletionRequestOpts, asOpenAIChatBody, readIncludeReasoningDefault, } from './chat-request.js';
|
|
7
|
+
import { DEFAULT_CHAT_MODEL, buildChatCompletionBody, buildChatCompletionRequestOpts, asOpenAIChatBody, readIncludeReasoningDefault, validateChatMessages, validateMaxTokens, } from './chat-request.js';
|
|
8
8
|
export async function handleChatCompletion(request, openai, defaultModel) {
|
|
9
9
|
const args = request.params.arguments ?? { messages: [] };
|
|
10
10
|
const { messages, model, temperature, max_tokens, provider, include_reasoning, online, web_max_results, cache, cache_ttl, cache_clear, } = args;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
const messagesError = validateChatMessages(messages);
|
|
12
|
+
if (messagesError)
|
|
13
|
+
return messagesError;
|
|
14
|
+
const cacheError = validateCacheOptions({ cache, cache_ttl, cache_clear });
|
|
15
|
+
if (cacheError)
|
|
16
|
+
return cacheError;
|
|
17
|
+
const maxTokensError = validateMaxTokens(max_tokens);
|
|
18
|
+
if (maxTokensError)
|
|
19
|
+
return maxTokensError;
|
|
14
20
|
const wantsReasoning = include_reasoning ?? readIncludeReasoningDefault();
|
|
15
21
|
const body = buildChatCompletionBody({
|
|
16
22
|
messages,
|
|
@@ -48,8 +54,11 @@ export async function handleChatCompletion(request, openai, defaultModel) {
|
|
|
48
54
|
const extra = { server_version: SERVER_VERSION };
|
|
49
55
|
if (cacheMeta)
|
|
50
56
|
extra.cache = cacheMeta;
|
|
57
|
+
const capped = capResultText(extracted.text);
|
|
58
|
+
if (capped.truncated)
|
|
59
|
+
extra.result_truncated = true;
|
|
51
60
|
return {
|
|
52
|
-
content: [{ type: 'text', text:
|
|
61
|
+
content: [{ type: 'text', text: capped.text }],
|
|
53
62
|
_meta: buildCompletionMeta(extracted, {
|
|
54
63
|
includeReasoning: wantsReasoning,
|
|
55
64
|
extra,
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type OpenAI from 'openai';
|
|
2
2
|
import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions.js';
|
|
3
|
+
import { type ToolErrorResult } from '../errors.js';
|
|
3
4
|
import { type ProviderRoutingOptions } from './provider-routing.js';
|
|
4
5
|
import { type CacheOptions } from './cache.js';
|
|
5
|
-
export declare const DEFAULT_CHAT_MODEL = "
|
|
6
|
+
export declare const DEFAULT_CHAT_MODEL = "google/gemma-4-26b-a4b-it:free";
|
|
6
7
|
/** Shared request shape for sync and async chat completion tools. */
|
|
7
8
|
export interface ChatToolRequest extends CacheOptions {
|
|
8
9
|
model?: string;
|
|
@@ -15,6 +16,8 @@ export interface ChatToolRequest extends CacheOptions {
|
|
|
15
16
|
web_max_results?: number;
|
|
16
17
|
}
|
|
17
18
|
export declare function readIncludeReasoningDefault(): boolean;
|
|
19
|
+
export declare function validateChatMessages(messages: ChatCompletionMessageParam[] | undefined): ToolErrorResult | null;
|
|
20
|
+
export declare function validateMaxTokens(max_tokens: number | undefined): ToolErrorResult | null;
|
|
18
21
|
export declare function buildChatCompletionBody(input: ChatToolRequest & {
|
|
19
22
|
model: string;
|
|
20
23
|
}): Record<string, unknown>;
|
|
@@ -1,10 +1,38 @@
|
|
|
1
|
+
import { ErrorCode, toolError } from '../errors.js';
|
|
1
2
|
import { readProviderDefaults, mergeProviderOptions, buildProviderBody, resolveMaxTokens, } from './provider-routing.js';
|
|
2
3
|
import { buildCacheHeaders } from './cache.js';
|
|
3
|
-
export const DEFAULT_CHAT_MODEL = '
|
|
4
|
+
export const DEFAULT_CHAT_MODEL = 'google/gemma-4-26b-a4b-it:free';
|
|
4
5
|
export function readIncludeReasoningDefault() {
|
|
5
6
|
const raw = (process.env.OPENROUTER_INCLUDE_REASONING ?? '').trim().toLowerCase();
|
|
6
7
|
return raw === '1' || raw === 'true' || raw === 'yes';
|
|
7
8
|
}
|
|
9
|
+
export function validateChatMessages(messages) {
|
|
10
|
+
if (!messages?.length) {
|
|
11
|
+
return toolError(ErrorCode.INVALID_INPUT, 'Messages array cannot be empty.');
|
|
12
|
+
}
|
|
13
|
+
for (let i = 0; i < messages.length; i++) {
|
|
14
|
+
const msg = messages[i];
|
|
15
|
+
const role = msg.role;
|
|
16
|
+
if (typeof role !== 'string' || role.trim().length === 0) {
|
|
17
|
+
return toolError(ErrorCode.INVALID_INPUT, `Message at index ${i} has an empty or missing role.`);
|
|
18
|
+
}
|
|
19
|
+
if ('content' in msg && msg.content === null) {
|
|
20
|
+
return toolError(ErrorCode.INVALID_INPUT, `Message at index ${i} has null content.`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
export function validateMaxTokens(max_tokens) {
|
|
26
|
+
if (max_tokens === undefined)
|
|
27
|
+
return null;
|
|
28
|
+
if (typeof max_tokens !== 'number' ||
|
|
29
|
+
!Number.isFinite(max_tokens) ||
|
|
30
|
+
max_tokens <= 0 ||
|
|
31
|
+
!Number.isInteger(max_tokens)) {
|
|
32
|
+
return toolError(ErrorCode.INVALID_INPUT, 'max_tokens must be a positive integer.');
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
8
36
|
export function buildChatCompletionBody(input) {
|
|
9
37
|
const providerBody = buildProviderBody(mergeProviderOptions(readProviderDefaults(), input.provider));
|
|
10
38
|
const effectiveMaxTokens = resolveMaxTokens(input.max_tokens);
|
|
@@ -12,6 +12,11 @@ export interface ExtractedText {
|
|
|
12
12
|
reasoning?: string;
|
|
13
13
|
usage?: ChatCompletion['usage'];
|
|
14
14
|
}
|
|
15
|
+
export declare function readMaxResultTextChars(): number;
|
|
16
|
+
export declare function capResultText(text: string): {
|
|
17
|
+
text: string;
|
|
18
|
+
truncated: boolean;
|
|
19
|
+
};
|
|
15
20
|
export declare function extractCompletionText(completion: ChatCompletion): ExtractedText;
|
|
16
21
|
/**
|
|
17
22
|
* If the extracted response is reasoning-only and was cut off by
|
|
@@ -21,17 +26,6 @@ export declare function extractCompletionText(completion: ChatCompletion): Extra
|
|
|
21
26
|
*/
|
|
22
27
|
export declare function detectReasoningCutoff(extracted: ExtractedText): ToolErrorResult | null;
|
|
23
28
|
export declare function toUsageMeta(usage: ChatCompletion['usage'] | undefined): Record<string, unknown> | undefined;
|
|
24
|
-
/**
|
|
25
|
-
* Build the common `_meta` shape for chat-completion-derived tools.
|
|
26
|
-
* Folds in:
|
|
27
|
-
* - normalized and native finish reasons (from the choice)
|
|
28
|
-
* - optional `reasoning` trace (when the caller opted in)
|
|
29
|
-
* - token usage (prompt / completion / total)
|
|
30
|
-
* - server version stamp
|
|
31
|
-
*
|
|
32
|
-
* Caller can pass `extra` to merge additional keys (cache metadata,
|
|
33
|
-
* content_is_untrusted, etc.) without repeating this boilerplate.
|
|
34
|
-
*/
|
|
35
29
|
export interface BuildMetaOptions {
|
|
36
30
|
includeReasoning?: boolean;
|
|
37
31
|
extra?: Record<string, unknown>;
|
|
@@ -1,4 +1,23 @@
|
|
|
1
1
|
import { ErrorCode, toolError } from '../errors.js';
|
|
2
|
+
/** Default cap for text returned in tool content / _meta (chars). Set OPENROUTER_MAX_RESULT_TEXT_CHARS=0 to disable. */
|
|
3
|
+
const DEFAULT_MAX_RESULT_TEXT_CHARS = 512_000;
|
|
4
|
+
export function readMaxResultTextChars() {
|
|
5
|
+
const raw = process.env.OPENROUTER_MAX_RESULT_TEXT_CHARS;
|
|
6
|
+
if (raw === undefined || raw === '')
|
|
7
|
+
return DEFAULT_MAX_RESULT_TEXT_CHARS;
|
|
8
|
+
if (raw === '0')
|
|
9
|
+
return 0;
|
|
10
|
+
const n = parseInt(raw, 10);
|
|
11
|
+
return Number.isFinite(n) && n > 0 ? n : DEFAULT_MAX_RESULT_TEXT_CHARS;
|
|
12
|
+
}
|
|
13
|
+
export function capResultText(text) {
|
|
14
|
+
const max = readMaxResultTextChars();
|
|
15
|
+
if (max <= 0 || text.length <= max)
|
|
16
|
+
return { text, truncated: false };
|
|
17
|
+
const omitted = text.length - max;
|
|
18
|
+
const marker = `\n\n[… truncated — ${omitted} chars omitted; set OPENROUTER_MAX_RESULT_TEXT_CHARS=0 to disable]`;
|
|
19
|
+
return { text: text.slice(0, max) + marker, truncated: true };
|
|
20
|
+
}
|
|
2
21
|
function extractReasoning(msg) {
|
|
3
22
|
if (typeof msg.reasoning === 'string' && msg.reasoning.length > 0)
|
|
4
23
|
return msg.reasoning;
|
|
@@ -12,41 +31,40 @@ function extractReasoning(msg) {
|
|
|
12
31
|
}
|
|
13
32
|
return undefined;
|
|
14
33
|
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const finishReason = choice?.finish_reason;
|
|
19
|
-
const nativeFinishReason = choice?.native_finish_reason ?? undefined;
|
|
20
|
-
const usage = completion.usage ?? undefined;
|
|
21
|
-
if (!msg) {
|
|
22
|
-
return {
|
|
23
|
-
text: '',
|
|
24
|
-
reasonedOnly: false,
|
|
25
|
-
finishReason,
|
|
26
|
-
nativeFinishReason: nativeFinishReason ?? undefined,
|
|
27
|
-
usage,
|
|
28
|
-
};
|
|
29
|
-
}
|
|
30
|
-
const { content } = msg;
|
|
31
|
-
const reasoning = extractReasoning(msg);
|
|
32
|
-
if (typeof content === 'string' && content.length > 0) {
|
|
33
|
-
return {
|
|
34
|
-
text: content,
|
|
35
|
-
reasonedOnly: false,
|
|
36
|
-
finishReason,
|
|
37
|
-
nativeFinishReason: nativeFinishReason ?? undefined,
|
|
38
|
-
reasoning,
|
|
39
|
-
usage,
|
|
40
|
-
};
|
|
41
|
-
}
|
|
34
|
+
function extractContentText(content) {
|
|
35
|
+
if (typeof content === 'string' && content.length > 0)
|
|
36
|
+
return content;
|
|
42
37
|
if (Array.isArray(content)) {
|
|
43
38
|
const parts = content
|
|
44
39
|
.filter((p) => p.type === 'text' && typeof p.text === 'string')
|
|
45
40
|
.map((p) => p.text ?? '');
|
|
46
|
-
|
|
47
|
-
|
|
41
|
+
return parts.join('');
|
|
42
|
+
}
|
|
43
|
+
return '';
|
|
44
|
+
}
|
|
45
|
+
function emptyExtracted(finishReason, nativeFinishReason, usage) {
|
|
46
|
+
return {
|
|
47
|
+
text: '',
|
|
48
|
+
reasonedOnly: false,
|
|
49
|
+
finishReason,
|
|
50
|
+
nativeFinishReason,
|
|
51
|
+
usage,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
export function extractCompletionText(completion) {
|
|
55
|
+
try {
|
|
56
|
+
const choice = completion.choices?.[0];
|
|
57
|
+
const msg = choice?.message;
|
|
58
|
+
const finishReason = choice?.finish_reason;
|
|
59
|
+
const nativeFinishReason = choice?.native_finish_reason ?? undefined;
|
|
60
|
+
const usage = completion.usage ?? undefined;
|
|
61
|
+
if (!msg) {
|
|
62
|
+
return emptyExtracted(finishReason, nativeFinishReason ?? undefined, usage);
|
|
63
|
+
}
|
|
64
|
+
const reasoning = extractReasoning(msg);
|
|
65
|
+
if (typeof msg.refusal === 'string' && msg.refusal.length > 0) {
|
|
48
66
|
return {
|
|
49
|
-
text:
|
|
67
|
+
text: msg.refusal,
|
|
50
68
|
reasonedOnly: false,
|
|
51
69
|
finishReason,
|
|
52
70
|
nativeFinishReason: nativeFinishReason ?? undefined,
|
|
@@ -54,24 +72,32 @@ export function extractCompletionText(completion) {
|
|
|
54
72
|
usage,
|
|
55
73
|
};
|
|
56
74
|
}
|
|
75
|
+
const contentText = extractContentText(msg.content);
|
|
76
|
+
if (contentText.length > 0) {
|
|
77
|
+
return {
|
|
78
|
+
text: contentText,
|
|
79
|
+
reasonedOnly: false,
|
|
80
|
+
finishReason,
|
|
81
|
+
nativeFinishReason: nativeFinishReason ?? undefined,
|
|
82
|
+
reasoning,
|
|
83
|
+
usage,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
if (reasoning && reasoning.length > 0) {
|
|
87
|
+
return {
|
|
88
|
+
text: reasoning,
|
|
89
|
+
reasonedOnly: true,
|
|
90
|
+
finishReason,
|
|
91
|
+
nativeFinishReason: nativeFinishReason ?? undefined,
|
|
92
|
+
reasoning,
|
|
93
|
+
usage,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return emptyExtracted(finishReason, nativeFinishReason ?? undefined, usage);
|
|
57
97
|
}
|
|
58
|
-
|
|
59
|
-
return
|
|
60
|
-
text: reasoning,
|
|
61
|
-
reasonedOnly: true,
|
|
62
|
-
finishReason,
|
|
63
|
-
nativeFinishReason: nativeFinishReason ?? undefined,
|
|
64
|
-
reasoning,
|
|
65
|
-
usage,
|
|
66
|
-
};
|
|
98
|
+
catch {
|
|
99
|
+
return emptyExtracted(undefined, undefined, undefined);
|
|
67
100
|
}
|
|
68
|
-
return {
|
|
69
|
-
text: '',
|
|
70
|
-
reasonedOnly: false,
|
|
71
|
-
finishReason,
|
|
72
|
-
nativeFinishReason: nativeFinishReason ?? undefined,
|
|
73
|
-
usage,
|
|
74
|
-
};
|
|
75
101
|
}
|
|
76
102
|
/**
|
|
77
103
|
* If the extracted response is reasoning-only and was cut off by
|
|
@@ -115,7 +141,10 @@ export function buildCompletionMeta(extracted, opts = {}) {
|
|
|
115
141
|
meta.native_finish_reason = extracted.nativeFinishReason;
|
|
116
142
|
}
|
|
117
143
|
if (opts.includeReasoning && extracted.reasoning && !extracted.reasonedOnly) {
|
|
118
|
-
|
|
144
|
+
const capped = capResultText(extracted.reasoning);
|
|
145
|
+
meta.reasoning = capped.text;
|
|
146
|
+
if (capped.truncated)
|
|
147
|
+
meta.reasoning_truncated = true;
|
|
119
148
|
}
|
|
120
149
|
const usageMeta = toUsageMeta(extracted.usage);
|
|
121
150
|
if (usageMeta)
|
|
@@ -7,6 +7,19 @@ export declare function readEnvInt(name: string, fallback: number, min?: number)
|
|
|
7
7
|
export declare function isBlockedIPv4(ip: string): boolean;
|
|
8
8
|
/** SSRF block list for IPv6 literals (private/reserved ranges). */
|
|
9
9
|
export declare function isBlockedIPv6(ip: string): boolean;
|
|
10
|
+
export interface PinnedAddress {
|
|
11
|
+
address: string;
|
|
12
|
+
family: 4 | 6;
|
|
13
|
+
}
|
|
14
|
+
type DnsLookupHook = (host: string) => Promise<PinnedAddress[]>;
|
|
15
|
+
/** @internal Test seam — never active in production. */
|
|
16
|
+
export declare function __setFetchUtilsTestHooks(hooks: {
|
|
17
|
+
dnsLookup?: DnsLookupHook | null;
|
|
18
|
+
trustedCa?: string;
|
|
19
|
+
allowLoopbackResolution?: boolean;
|
|
20
|
+
}): void;
|
|
21
|
+
/** @internal Test seam — never active in production. */
|
|
22
|
+
export declare function __resetFetchUtilsTestHooks(): void;
|
|
10
23
|
/** Resolve hostname and ensure the resolved address is not private/link-local. */
|
|
11
24
|
export declare function assertUrlSafeForFetch(urlString: string): Promise<URL>;
|
|
12
25
|
/**
|
|
@@ -32,3 +45,4 @@ export declare function fetchHttpResource(urlString: string, opts: FetchOptions)
|
|
|
32
45
|
buffer: Buffer;
|
|
33
46
|
contentType: string | null;
|
|
34
47
|
}>;
|
|
48
|
+
export {};
|