@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
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
* Map OpenRouter / OpenAI SDK errors to our closed `ErrorCode` enum.
|
|
3
3
|
*/
|
|
4
4
|
import { ErrorCode, toolError } from '../errors.js';
|
|
5
|
+
const AUTH_SUGGESTIONS = [
|
|
6
|
+
'Verify OPENROUTER_API_KEY is set and matches https://openrouter.ai/keys',
|
|
7
|
+
'Ensure the key has not been revoked or expired',
|
|
8
|
+
];
|
|
9
|
+
/** Strip bearer tokens and OpenRouter key material from user-visible messages. */
|
|
10
|
+
export function sanitizeErrorMessage(msg) {
|
|
11
|
+
return msg
|
|
12
|
+
.replace(/Bearer\s+\S+/gi, 'Bearer [REDACTED]')
|
|
13
|
+
.replace(/sk-or-v\d+-[\w-]+/gi, '[REDACTED]')
|
|
14
|
+
.replace(/Authorization:\s*\S+/gi, 'Authorization: [REDACTED]');
|
|
15
|
+
}
|
|
5
16
|
function extractRetryAfterSeconds(err) {
|
|
6
17
|
if (typeof err !== 'object' || err === null)
|
|
7
18
|
return undefined;
|
|
@@ -18,22 +29,29 @@ function extractRetryAfterSeconds(err) {
|
|
|
18
29
|
const raw = getHeader(e.headers) ?? getHeader(e.response?.headers);
|
|
19
30
|
if (!raw)
|
|
20
31
|
return undefined;
|
|
21
|
-
const
|
|
22
|
-
if (Number.isFinite(
|
|
23
|
-
return
|
|
32
|
+
const asInt = parseInt(raw, 10);
|
|
33
|
+
if (Number.isFinite(asInt) && asInt >= 0)
|
|
34
|
+
return asInt;
|
|
35
|
+
const asDate = Date.parse(raw);
|
|
36
|
+
if (Number.isFinite(asDate)) {
|
|
37
|
+
const deltaSec = Math.ceil((asDate - Date.now()) / 1000);
|
|
38
|
+
return deltaSec > 0 ? deltaSec : 0;
|
|
39
|
+
}
|
|
24
40
|
return undefined;
|
|
25
41
|
}
|
|
26
42
|
function extractStatus(err) {
|
|
27
43
|
if (typeof err !== 'object' || err === null)
|
|
28
44
|
return undefined;
|
|
29
|
-
const
|
|
30
|
-
if (typeof
|
|
31
|
-
return
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
45
|
+
const e = err;
|
|
46
|
+
if (typeof e.status === 'number')
|
|
47
|
+
return e.status;
|
|
48
|
+
if (typeof e.code === 'number')
|
|
49
|
+
return e.code;
|
|
50
|
+
if (typeof e.code === 'string' && /^\d{3}$/.test(e.code))
|
|
51
|
+
return parseInt(e.code, 10);
|
|
52
|
+
const nested = e.error;
|
|
53
|
+
if (nested && typeof nested === 'object' && typeof nested.code === 'number')
|
|
54
|
+
return nested.code;
|
|
37
55
|
if (err instanceof Error) {
|
|
38
56
|
const m = err.message.match(/\bHTTP (\d{3})\b/);
|
|
39
57
|
if (m)
|
|
@@ -41,27 +59,116 @@ function extractStatus(err) {
|
|
|
41
59
|
}
|
|
42
60
|
return undefined;
|
|
43
61
|
}
|
|
62
|
+
function extractNestedError(err) {
|
|
63
|
+
if (typeof err !== 'object' || err === null)
|
|
64
|
+
return undefined;
|
|
65
|
+
const nested = err.error;
|
|
66
|
+
if (!nested)
|
|
67
|
+
return undefined;
|
|
68
|
+
return nested;
|
|
69
|
+
}
|
|
44
70
|
function extractMessage(err) {
|
|
71
|
+
let msg;
|
|
45
72
|
if (err instanceof Error) {
|
|
46
|
-
const nested = err
|
|
73
|
+
const nested = extractNestedError(err);
|
|
47
74
|
if (nested && typeof nested === 'object' && typeof nested.message === 'string') {
|
|
48
|
-
|
|
75
|
+
msg = `${err.message} — ${nested.message}`;
|
|
76
|
+
}
|
|
77
|
+
else if (typeof nested === 'string') {
|
|
78
|
+
msg = `${err.message} — ${nested}`;
|
|
49
79
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
80
|
+
else {
|
|
81
|
+
msg = err.message;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
else if (typeof err === 'string') {
|
|
85
|
+
msg = err;
|
|
53
86
|
}
|
|
54
|
-
if (typeof err === '
|
|
55
|
-
|
|
56
|
-
|
|
87
|
+
else if (typeof err === 'object' && err !== null) {
|
|
88
|
+
const e = err;
|
|
89
|
+
if (typeof e.message === 'string') {
|
|
90
|
+
msg = e.message;
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
const nested = extractNestedError(err);
|
|
94
|
+
if (nested && typeof nested === 'object' && typeof nested.message === 'string') {
|
|
95
|
+
msg = nested.message;
|
|
96
|
+
}
|
|
97
|
+
else if (typeof nested === 'string') {
|
|
98
|
+
msg = nested;
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
msg = 'unknown error';
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
msg = 'unknown error';
|
|
107
|
+
}
|
|
108
|
+
return sanitizeErrorMessage(msg);
|
|
109
|
+
}
|
|
110
|
+
function extractErrorType(err) {
|
|
111
|
+
const nested = extractNestedError(err);
|
|
112
|
+
if (nested && typeof nested === 'object') {
|
|
113
|
+
if (typeof nested.error_type === 'string')
|
|
114
|
+
return nested.error_type;
|
|
115
|
+
if (typeof nested.type === 'string')
|
|
116
|
+
return nested.type;
|
|
117
|
+
}
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
function isAuthFailure(status, lower, errorType) {
|
|
121
|
+
if (status === 401)
|
|
122
|
+
return true;
|
|
123
|
+
if (errorType === 'authentication' || errorType === 'authentication_error')
|
|
124
|
+
return true;
|
|
125
|
+
return (lower.includes('invalid api key') ||
|
|
126
|
+
lower.includes('invalid credentials') ||
|
|
127
|
+
lower.includes('invalid authentication') ||
|
|
128
|
+
lower.includes('no auth credentials') ||
|
|
129
|
+
lower.includes('missing api key') ||
|
|
130
|
+
lower.includes('unauthorized') ||
|
|
131
|
+
lower.includes('authentication failed') ||
|
|
132
|
+
lower.includes('user not found') ||
|
|
133
|
+
(status === 403 &&
|
|
134
|
+
(lower.includes('invalid api key') ||
|
|
135
|
+
lower.includes('invalid credentials') ||
|
|
136
|
+
lower.includes('authentication'))));
|
|
137
|
+
}
|
|
138
|
+
function isModelNotFound(status, lower) {
|
|
139
|
+
if (status === 404)
|
|
140
|
+
return true;
|
|
141
|
+
return (lower.includes('model') &&
|
|
142
|
+
(lower.includes('does not exist') ||
|
|
143
|
+
lower.includes('not found') ||
|
|
144
|
+
lower.includes('invalid model')));
|
|
145
|
+
}
|
|
146
|
+
function isGuardrailOrPolicy(lower) {
|
|
147
|
+
return (lower.includes('content policy') ||
|
|
148
|
+
lower.includes('moderation') ||
|
|
149
|
+
lower.includes('refused') ||
|
|
150
|
+
lower.includes('prompt injection') ||
|
|
151
|
+
lower.includes('guardrail') ||
|
|
152
|
+
lower.includes('request blocked') ||
|
|
153
|
+
lower.includes('blocked:'));
|
|
154
|
+
}
|
|
155
|
+
function looksLikeHtml(msg) {
|
|
156
|
+
const t = msg.trimStart().toLowerCase();
|
|
157
|
+
return t.startsWith('<!doctype') || t.startsWith('<html');
|
|
57
158
|
}
|
|
58
159
|
/** Classify upstream errors into the closed `ErrorCode` set. */
|
|
59
160
|
export function classifyUpstreamError(err, contextMessage) {
|
|
60
161
|
const rawMsg = extractMessage(err);
|
|
61
162
|
const status = extractStatus(err);
|
|
163
|
+
const errorType = extractErrorType(err);
|
|
62
164
|
const lower = rawMsg.toLowerCase();
|
|
63
165
|
const fullMsg = contextMessage ? `${contextMessage}: ${rawMsg}` : rawMsg;
|
|
64
166
|
const retryAfterSeconds = extractRetryAfterSeconds(err);
|
|
167
|
+
if (looksLikeHtml(rawMsg)) {
|
|
168
|
+
return toolError(ErrorCode.UPSTREAM_HTTP, contextMessage
|
|
169
|
+
? `${contextMessage}: upstream returned an HTML error page`
|
|
170
|
+
: 'upstream returned an HTML error page', { status }, { suggestions: ['Retry after a brief delay', 'Check https://status.openrouter.ai'] });
|
|
171
|
+
}
|
|
65
172
|
if (lower.includes('insufficient balance') ||
|
|
66
173
|
lower.includes('insufficient credits') ||
|
|
67
174
|
lower.includes('requires more credits') ||
|
|
@@ -82,10 +189,7 @@ export function classifyUpstreamError(err, contextMessage) {
|
|
|
82
189
|
],
|
|
83
190
|
});
|
|
84
191
|
}
|
|
85
|
-
if (lower
|
|
86
|
-
(lower.includes('does not exist') ||
|
|
87
|
-
lower.includes('not found') ||
|
|
88
|
-
lower.includes('invalid model'))) {
|
|
192
|
+
if (isModelNotFound(status, lower)) {
|
|
89
193
|
return toolError(ErrorCode.MODEL_NOT_FOUND, fullMsg, { status }, {
|
|
90
194
|
suggestions: [
|
|
91
195
|
'Use search_models to discover valid model ids',
|
|
@@ -93,13 +197,6 @@ export function classifyUpstreamError(err, contextMessage) {
|
|
|
93
197
|
],
|
|
94
198
|
});
|
|
95
199
|
}
|
|
96
|
-
if (lower.includes('content policy') ||
|
|
97
|
-
lower.includes('moderation') ||
|
|
98
|
-
lower.includes('refused')) {
|
|
99
|
-
return toolError(ErrorCode.UPSTREAM_REFUSED, fullMsg, { status, reason: 'policy' }, {
|
|
100
|
-
suggestions: ['Rephrase the prompt', 'Try a different provider via provider.order'],
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
200
|
if (status === 429 || lower.includes('rate limit')) {
|
|
104
201
|
return toolError(ErrorCode.UPSTREAM_REFUSED, fullMsg, { status, reason: 'rate_limit' }, {
|
|
105
202
|
suggestions: [
|
|
@@ -119,8 +216,18 @@ export function classifyUpstreamError(err, contextMessage) {
|
|
|
119
216
|
suggestions: ['Retry', 'Raise max_wait_ms or max_tokens'],
|
|
120
217
|
});
|
|
121
218
|
}
|
|
219
|
+
if (isAuthFailure(status, lower, errorType)) {
|
|
220
|
+
return toolError(ErrorCode.INVALID_CREDENTIALS, fullMsg, { status, reason: 'auth' }, { suggestions: [...AUTH_SUGGESTIONS] });
|
|
221
|
+
}
|
|
222
|
+
if (isGuardrailOrPolicy(lower) || status === 403) {
|
|
223
|
+
return toolError(ErrorCode.UPSTREAM_REFUSED, fullMsg, { status, reason: 'policy' }, {
|
|
224
|
+
suggestions: ['Rephrase the prompt', 'Try a different provider via provider.order'],
|
|
225
|
+
});
|
|
226
|
+
}
|
|
122
227
|
if (typeof status === 'number' && status >= 400 && status < 500) {
|
|
123
|
-
return toolError(ErrorCode.INVALID_INPUT, fullMsg, { status }
|
|
228
|
+
return toolError(ErrorCode.INVALID_INPUT, fullMsg, { status }, {
|
|
229
|
+
suggestions: ['Verify request parameters against OpenRouter docs'],
|
|
230
|
+
});
|
|
124
231
|
}
|
|
125
232
|
if (typeof status === 'number' && status >= 500) {
|
|
126
233
|
return toolError(ErrorCode.UPSTREAM_HTTP, fullMsg, { status }, {
|
|
@@ -33,6 +33,22 @@ function isUnsafeMode() {
|
|
|
33
33
|
const v = process.env.OPENROUTER_ALLOW_UNSAFE_PATHS;
|
|
34
34
|
return v === '1' || v?.toLowerCase() === 'true';
|
|
35
35
|
}
|
|
36
|
+
function pathHasNullByte(p) {
|
|
37
|
+
return p.includes('\0');
|
|
38
|
+
}
|
|
39
|
+
function withRootSep(rootReal) {
|
|
40
|
+
return rootReal.endsWith(path.sep) ? rootReal : rootReal + path.sep;
|
|
41
|
+
}
|
|
42
|
+
/** Prefix check; case-insensitive on Windows (drive letter / path casing). */
|
|
43
|
+
function isInsideRoot(resolved, rootReal) {
|
|
44
|
+
const withSep = withRootSep(rootReal);
|
|
45
|
+
if (process.platform === 'win32') {
|
|
46
|
+
const lower = resolved.toLowerCase();
|
|
47
|
+
const rootLower = rootReal.toLowerCase();
|
|
48
|
+
return lower === rootLower || lower.startsWith(withRootSep(rootLower));
|
|
49
|
+
}
|
|
50
|
+
return resolved === rootReal || resolved.startsWith(withSep);
|
|
51
|
+
}
|
|
36
52
|
export class UnsafeOutputPathError extends Error {
|
|
37
53
|
constructor(message) {
|
|
38
54
|
super(message);
|
|
@@ -49,23 +65,26 @@ export async function resolveSafeOutputPath(savePath) {
|
|
|
49
65
|
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
50
66
|
return abs;
|
|
51
67
|
}
|
|
68
|
+
if (pathHasNullByte(savePath)) {
|
|
69
|
+
throw new UnsafeOutputPathError('save_path contains a null byte');
|
|
70
|
+
}
|
|
52
71
|
const root = getOutputRoot();
|
|
53
72
|
const rootReal = await fs.realpath(root).catch(() => path.resolve(root));
|
|
54
73
|
const candidate = path.isAbsolute(savePath)
|
|
55
74
|
? path.resolve(savePath)
|
|
56
75
|
: path.resolve(rootReal, savePath);
|
|
57
|
-
const withSep = rootReal.endsWith(path.sep) ? rootReal : rootReal + path.sep;
|
|
58
76
|
const candidateDir = path.dirname(candidate);
|
|
59
77
|
const existingAncestor = await findExistingAncestor(candidateDir);
|
|
60
78
|
const ancestorReal = await fs.realpath(existingAncestor);
|
|
61
|
-
if (!(ancestorReal
|
|
62
|
-
throw new UnsafeOutputPathError(`save_path resolves outside OPENROUTER_OUTPUT_DIR
|
|
79
|
+
if (!isInsideRoot(ancestorReal, rootReal)) {
|
|
80
|
+
throw new UnsafeOutputPathError(`save_path resolves outside OPENROUTER_OUTPUT_DIR. ` +
|
|
63
81
|
`Set OPENROUTER_OUTPUT_DIR to a wider root or OPENROUTER_ALLOW_UNSAFE_PATHS=1 to disable this check.`);
|
|
64
82
|
}
|
|
65
83
|
await fs.mkdir(candidateDir, { recursive: true });
|
|
66
84
|
const parentReal = await fs.realpath(candidateDir);
|
|
67
|
-
if (!(parentReal
|
|
68
|
-
throw new UnsafeOutputPathError(
|
|
85
|
+
if (!isInsideRoot(parentReal, rootReal)) {
|
|
86
|
+
throw new UnsafeOutputPathError('save_path escapes OPENROUTER_OUTPUT_DIR via symlink. ' +
|
|
87
|
+
'Set OPENROUTER_OUTPUT_DIR to a wider root or OPENROUTER_ALLOW_UNSAFE_PATHS=1 to disable this check.');
|
|
69
88
|
}
|
|
70
89
|
return path.join(parentReal, path.basename(candidate));
|
|
71
90
|
}
|
|
@@ -108,23 +127,37 @@ export async function resolveSafeInputPath(inputPath) {
|
|
|
108
127
|
if (isUnsafeMode()) {
|
|
109
128
|
return path.resolve(inputPath);
|
|
110
129
|
}
|
|
130
|
+
if (pathHasNullByte(inputPath)) {
|
|
131
|
+
throw new UnsafeOutputPathError('input path contains a null byte');
|
|
132
|
+
}
|
|
111
133
|
const root = getInputRoot();
|
|
112
134
|
const rootReal = await fs.realpath(root).catch(() => path.resolve(root));
|
|
113
|
-
const withSep = rootReal.endsWith(path.sep) ? rootReal : rootReal + path.sep;
|
|
114
135
|
const abs = path.isAbsolute(inputPath)
|
|
115
136
|
? path.resolve(inputPath)
|
|
116
137
|
: path.resolve(rootReal, inputPath);
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
138
|
+
const existingAncestor = await findExistingAncestor(abs);
|
|
139
|
+
const ancestorReal = await fs.realpath(existingAncestor);
|
|
140
|
+
if (!isInsideRoot(ancestorReal, rootReal)) {
|
|
141
|
+
throw new UnsafeOutputPathError(`input path resolves outside OPENROUTER_INPUT_DIR: ${inputPath}. ` +
|
|
142
|
+
'Set OPENROUTER_INPUT_DIR to a wider root or OPENROUTER_ALLOW_UNSAFE_PATHS=1 to disable this check.');
|
|
120
143
|
}
|
|
121
|
-
|
|
122
|
-
canonical = abs;
|
|
144
|
+
try {
|
|
145
|
+
const canonical = await fs.realpath(abs);
|
|
146
|
+
if (!isInsideRoot(canonical, rootReal)) {
|
|
147
|
+
throw new UnsafeOutputPathError(`input path resolves outside OPENROUTER_INPUT_DIR: ${inputPath}. ` +
|
|
148
|
+
'Set OPENROUTER_INPUT_DIR to a wider root or OPENROUTER_ALLOW_UNSAFE_PATHS=1 to disable this check.');
|
|
149
|
+
}
|
|
150
|
+
return canonical;
|
|
123
151
|
}
|
|
124
|
-
|
|
125
|
-
|
|
152
|
+
catch (err) {
|
|
153
|
+
if (err instanceof UnsafeOutputPathError)
|
|
154
|
+
throw err;
|
|
155
|
+
if (!isInsideRoot(abs, rootReal)) {
|
|
156
|
+
throw new UnsafeOutputPathError(`input path resolves outside OPENROUTER_INPUT_DIR: ${inputPath}. ` +
|
|
157
|
+
'Set OPENROUTER_INPUT_DIR to a wider root or OPENROUTER_ALLOW_UNSAFE_PATHS=1 to disable this check.');
|
|
158
|
+
}
|
|
159
|
+
return abs;
|
|
126
160
|
}
|
|
127
|
-
return abs;
|
|
128
161
|
}
|
|
129
162
|
export function isToolErrorResult(result) {
|
|
130
163
|
return 'isError' in result;
|
|
@@ -158,18 +191,17 @@ export async function resolveSafeJobStatusPath(jobsDir, jobId) {
|
|
|
158
191
|
if (!isValidJobId(jobId))
|
|
159
192
|
return null;
|
|
160
193
|
const rootReal = await fs.realpath(jobsDir).catch(() => path.resolve(jobsDir));
|
|
161
|
-
const withSep = rootReal.endsWith(path.sep) ? rootReal : rootReal + path.sep;
|
|
162
194
|
const jobDirCandidate = path.resolve(jobsDir, jobId);
|
|
163
195
|
let jobDirReal;
|
|
164
196
|
try {
|
|
165
197
|
jobDirReal = await fs.realpath(jobDirCandidate);
|
|
166
198
|
}
|
|
167
199
|
catch {
|
|
168
|
-
if (!(jobDirCandidate
|
|
200
|
+
if (!isInsideRoot(jobDirCandidate, rootReal))
|
|
169
201
|
return null;
|
|
170
202
|
return path.join(jobDirCandidate, 'status.json');
|
|
171
203
|
}
|
|
172
|
-
if (!(jobDirReal
|
|
204
|
+
if (!isInsideRoot(jobDirReal, rootReal))
|
|
173
205
|
return null;
|
|
174
206
|
return path.join(jobDirReal, 'status.json');
|
|
175
207
|
}
|
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
/** Strip existing extension (if any) and append a new one. */
|
|
2
2
|
export declare function replaceExtension(filePath: string, newExt: string): string;
|
|
3
|
+
/** Write bytes atomically via a same-directory temp file and rename. */
|
|
4
|
+
export declare function writeOutputFile(target: string, data: Buffer): Promise<void>;
|
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
import { extname } from 'node:path';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
2
3
|
/** Strip existing extension (if any) and append a new one. */
|
|
3
4
|
export function replaceExtension(filePath, newExt) {
|
|
4
5
|
const current = extname(filePath);
|
|
5
6
|
const base = current ? filePath.slice(0, -current.length) : filePath;
|
|
6
7
|
return `${base}.${newExt}`;
|
|
7
8
|
}
|
|
9
|
+
/** Write bytes atomically via a same-directory temp file and rename. */
|
|
10
|
+
export async function writeOutputFile(target, data) {
|
|
11
|
+
const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
12
|
+
try {
|
|
13
|
+
await fs.writeFile(tmp, data);
|
|
14
|
+
await fs.rename(tmp, target);
|
|
15
|
+
}
|
|
16
|
+
catch (err) {
|
|
17
|
+
await fs.rm(tmp, { force: true }).catch(() => undefined);
|
|
18
|
+
throw err;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -5,6 +5,8 @@ export interface ProviderRoutingOptions {
|
|
|
5
5
|
quantizations?: string[];
|
|
6
6
|
/** Exclude these provider slugs (e.g. `['openai', 'anthropic']`). */
|
|
7
7
|
ignore?: string[];
|
|
8
|
+
/** Allow only these provider slugs for the request. */
|
|
9
|
+
only?: string[];
|
|
8
10
|
/** Sort providers by this criterion. */
|
|
9
11
|
sort?: ProviderSort;
|
|
10
12
|
/** Prioritized provider list (e.g. `['openai/gpt-4o', 'anthropic/claude-3-opus']`). */
|
|
@@ -24,7 +24,7 @@ function parseJsonArray(raw, name) {
|
|
|
24
24
|
return parsed.length > 0 ? parsed : undefined;
|
|
25
25
|
}
|
|
26
26
|
catch (err) {
|
|
27
|
-
throw new Error(`${name}: malformed JSON array (${err instanceof Error ? err.message : String(err)})
|
|
27
|
+
throw new Error(`${name}: malformed JSON array (${err instanceof Error ? err.message : String(err)})`, { cause: err });
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
return parseCsv(trimmed);
|
|
@@ -66,6 +66,16 @@ export function readProviderDefaults() {
|
|
|
66
66
|
const ignore = parseCsv(env.OPENROUTER_PROVIDER_IGNORE);
|
|
67
67
|
if (ignore)
|
|
68
68
|
out.ignore = ignore;
|
|
69
|
+
try {
|
|
70
|
+
const only = parseJsonArray(env.OPENROUTER_PROVIDER_ONLY, 'OPENROUTER_PROVIDER_ONLY');
|
|
71
|
+
if (only)
|
|
72
|
+
out.only = only;
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
logger.warn('OPENROUTER_PROVIDER_ONLY ignored', {
|
|
76
|
+
err: err instanceof Error ? err.message : String(err),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
69
79
|
const sort = parseSort(env.OPENROUTER_PROVIDER_SORT);
|
|
70
80
|
if (sort)
|
|
71
81
|
out.sort = sort;
|
|
@@ -11,7 +11,4 @@ export declare function handleRerankDocuments(request: {
|
|
|
11
11
|
params: {
|
|
12
12
|
arguments: RerankDocumentsRequest;
|
|
13
13
|
};
|
|
14
|
-
}, apiClient: OpenRouterAPIClient): Promise<import("../errors.js").ToolErrorResult | import("./structured-output.js").StructuredResult<
|
|
15
|
-
model: string;
|
|
16
|
-
results: Record<string, unknown>[];
|
|
17
|
-
}>>;
|
|
14
|
+
}, apiClient: OpenRouterAPIClient): Promise<import("../errors.js").ToolErrorResult | import("./structured-output.js").StructuredResult<unknown>>;
|
|
@@ -1,7 +1,39 @@
|
|
|
1
1
|
import { ErrorCode, toolError, toolErrorFrom } from '../errors.js';
|
|
2
2
|
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
3
3
|
import { buildStructuredResult } from './structured-output.js';
|
|
4
|
-
|
|
4
|
+
import { capResultText } from './completion-utils.js';
|
|
5
|
+
const DEFAULT_MODEL = 'cohere/rerank-v3.5';
|
|
6
|
+
function isValidDocumentIndex(index, documentCount) {
|
|
7
|
+
return (typeof index === 'number' && Number.isInteger(index) && index >= 0 && index < documentCount);
|
|
8
|
+
}
|
|
9
|
+
function normalizeRerankResults(response, documents, returnDocuments, modelFallback) {
|
|
10
|
+
const invalid = (response.results ?? []).find((r) => !isValidDocumentIndex(r.index, documents.length));
|
|
11
|
+
if (invalid) {
|
|
12
|
+
return toolError(ErrorCode.INTERNAL, `Rerank API returned invalid document index ${String(invalid.index)} (expected 0–${documents.length - 1}).`);
|
|
13
|
+
}
|
|
14
|
+
const normalized = (response.results ?? []).map((r) => {
|
|
15
|
+
const score = typeof r.score === 'number' ? r.score : r.relevance_score;
|
|
16
|
+
const out = { index: r.index, score };
|
|
17
|
+
if (returnDocuments) {
|
|
18
|
+
const rawDoc = typeof r.document === 'string' ? r.document : (r.document?.text ?? documents[r.index]);
|
|
19
|
+
const capped = capResultText(rawDoc);
|
|
20
|
+
out.document = capped.text;
|
|
21
|
+
if (capped.truncated)
|
|
22
|
+
out.document_truncated = true;
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
});
|
|
26
|
+
const payload = {
|
|
27
|
+
model: response.model ?? modelFallback,
|
|
28
|
+
results: normalized,
|
|
29
|
+
};
|
|
30
|
+
const jsonText = JSON.stringify(payload, null, 2);
|
|
31
|
+
const cappedJson = capResultText(jsonText);
|
|
32
|
+
if (cappedJson.truncated) {
|
|
33
|
+
return toolError(ErrorCode.RESOURCE_TOO_LARGE, 'Rerank result exceeds OPENROUTER_MAX_RESULT_TEXT_CHARS. Set it to 0 to disable or raise the limit.', { result_truncated: true });
|
|
34
|
+
}
|
|
35
|
+
return buildStructuredResult(payload, response.usage ? { usage: response.usage } : {});
|
|
36
|
+
}
|
|
5
37
|
export async function handleRerankDocuments(request, apiClient) {
|
|
6
38
|
const args = request.params.arguments ?? { query: '', documents: [] };
|
|
7
39
|
const { query, documents, model, top_n, return_documents } = args;
|
|
@@ -14,10 +46,19 @@ export async function handleRerankDocuments(request, apiClient) {
|
|
|
14
46
|
if (documents.some((d) => typeof d !== 'string')) {
|
|
15
47
|
return toolError(ErrorCode.INVALID_INPUT, 'every document must be a string.');
|
|
16
48
|
}
|
|
49
|
+
if (top_n !== undefined) {
|
|
50
|
+
if (typeof top_n !== 'number' || !Number.isFinite(top_n)) {
|
|
51
|
+
return toolError(ErrorCode.INVALID_INPUT, 'top_n must be a finite number.');
|
|
52
|
+
}
|
|
53
|
+
if (top_n < 1) {
|
|
54
|
+
return toolError(ErrorCode.INVALID_INPUT, 'top_n must be at least 1 when specified.');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const effectiveModel = model || DEFAULT_MODEL;
|
|
17
58
|
let response;
|
|
18
59
|
try {
|
|
19
60
|
response = await apiClient.rerank({
|
|
20
|
-
model:
|
|
61
|
+
model: effectiveModel,
|
|
21
62
|
query,
|
|
22
63
|
documents,
|
|
23
64
|
top_n,
|
|
@@ -26,20 +67,8 @@ export async function handleRerankDocuments(request, apiClient) {
|
|
|
26
67
|
catch (err) {
|
|
27
68
|
return classifyUpstreamError(err, 'rerank');
|
|
28
69
|
}
|
|
29
|
-
const normalized = (response.results ?? []).map((r) => {
|
|
30
|
-
const score = typeof r.score === 'number' ? r.score : r.relevance_score;
|
|
31
|
-
const out = { index: r.index, score };
|
|
32
|
-
if (return_documents) {
|
|
33
|
-
const doc = typeof r.document === 'string' ? r.document : (r.document?.text ?? documents[r.index]);
|
|
34
|
-
out.document = doc;
|
|
35
|
-
}
|
|
36
|
-
return out;
|
|
37
|
-
});
|
|
38
70
|
try {
|
|
39
|
-
return
|
|
40
|
-
model: response.model ?? model ?? DEFAULT_MODEL,
|
|
41
|
-
results: normalized,
|
|
42
|
-
}, response.usage ? { usage: response.usage } : {});
|
|
71
|
+
return normalizeRerankResults(response, documents, return_documents === true, effectiveModel);
|
|
43
72
|
}
|
|
44
73
|
catch (err) {
|
|
45
74
|
return toolErrorFrom(ErrorCode.INTERNAL, err, 'rerank');
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
+
import { clampLimit, clampOffset } from '../model-cache.js';
|
|
1
2
|
import { ErrorCode, toolErrorFrom } from '../errors.js';
|
|
2
3
|
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
3
4
|
import { buildStructuredResult } from './structured-output.js';
|
|
4
5
|
const DEFAULT_LIMIT = 20;
|
|
5
|
-
const MAX_LIMIT = 50;
|
|
6
6
|
export async function handleSearchModels(request, apiClient, modelCache) {
|
|
7
7
|
try {
|
|
8
8
|
await modelCache.ensureFresh(() => apiClient.getModels());
|
|
@@ -12,8 +12,8 @@ export async function handleSearchModels(request, apiClient, modelCache) {
|
|
|
12
12
|
}
|
|
13
13
|
try {
|
|
14
14
|
const args = request.params.arguments ?? {};
|
|
15
|
-
const limit =
|
|
16
|
-
const offset =
|
|
15
|
+
const limit = clampLimit(args.limit ?? DEFAULT_LIMIT, DEFAULT_LIMIT);
|
|
16
|
+
const offset = clampOffset(args.offset ?? 0);
|
|
17
17
|
const { page, total } = modelCache.searchPaginated({
|
|
18
18
|
query: args.query,
|
|
19
19
|
provider: args.provider,
|
|
@@ -1,63 +1,18 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
import { resolveSafeInputPath, UnsafeOutputPathError } from './path-safety.js';
|
|
5
|
-
import { fetchHttpResource } from './fetch-utils.js';
|
|
1
|
+
import { STT_RESPONSE_FORMATS } from '../tool-definitions.js';
|
|
2
|
+
import { UnsafeOutputPathError } from './path-safety.js';
|
|
3
|
+
import { resolveSpeechToTextAudio } from './audio-utils.js';
|
|
6
4
|
import { ErrorCode, toolError, toolErrorFrom } from '../errors.js';
|
|
7
5
|
import { SERVER_VERSION } from '../version.js';
|
|
8
6
|
import { logger } from '../logger.js';
|
|
9
7
|
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
10
|
-
import { buildCacheHeaders } from './cache.js';
|
|
8
|
+
import { buildCacheHeaders, validateCacheOptions } from './cache.js';
|
|
11
9
|
const DEFAULT_MODEL = 'openai/whisper-1';
|
|
12
|
-
const VALID_RESPONSE_FORMATS = new Set(
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
switch (normalized) {
|
|
17
|
-
case 'mp3':
|
|
18
|
-
return 'mp3';
|
|
19
|
-
case 'mp4':
|
|
20
|
-
case 'm4a':
|
|
21
|
-
return 'mp4';
|
|
22
|
-
case 'wav':
|
|
23
|
-
return 'wav';
|
|
24
|
-
case 'flac':
|
|
25
|
-
return 'flac';
|
|
26
|
-
case 'ogg':
|
|
27
|
-
case 'oga':
|
|
28
|
-
return 'ogg';
|
|
29
|
-
case 'webm':
|
|
30
|
-
return 'webm';
|
|
31
|
-
case 'opus':
|
|
32
|
-
return 'opus';
|
|
33
|
-
default:
|
|
34
|
-
return 'mp3';
|
|
10
|
+
const VALID_RESPONSE_FORMATS = new Set(STT_RESPONSE_FORMATS);
|
|
11
|
+
function formatTranscriptionContent(response, responseFormat) {
|
|
12
|
+
if (responseFormat === 'verbose_json') {
|
|
13
|
+
return JSON.stringify(response, null, 2);
|
|
35
14
|
}
|
|
36
|
-
|
|
37
|
-
async function resolveAudioInput(audioPath) {
|
|
38
|
-
const trimmed = audioPath.trim();
|
|
39
|
-
if (!trimmed)
|
|
40
|
-
throw new Error('audio_path is empty');
|
|
41
|
-
if (trimmed.startsWith('data:')) {
|
|
42
|
-
const match = trimmed.match(/^data:audio\/([^;,]+)(?:;[^,]*)*;base64,(.+)$/);
|
|
43
|
-
if (!match)
|
|
44
|
-
throw new Error('Invalid audio data URL format');
|
|
45
|
-
return { data: match[2], format: match[1] };
|
|
46
|
-
}
|
|
47
|
-
if (/^https?:\/\//i.test(trimmed)) {
|
|
48
|
-
const { buffer, contentType } = await fetchHttpResource(trimmed, {
|
|
49
|
-
timeoutMs: 60_000,
|
|
50
|
-
maxBytes: 100 * 1024 * 1024,
|
|
51
|
-
maxRedirects: 8,
|
|
52
|
-
});
|
|
53
|
-
const format = contentType?.match(/audio\/(\w+)/)?.[1] || 'mp3';
|
|
54
|
-
return { data: buffer.toString('base64'), format };
|
|
55
|
-
}
|
|
56
|
-
const abs = await resolveSafeInputPath(trimmed);
|
|
57
|
-
const buf = await fs.readFile(abs);
|
|
58
|
-
const ext = path.extname(abs);
|
|
59
|
-
const format = audioFormatFromExt(ext);
|
|
60
|
-
return { data: buf.toString('base64'), format };
|
|
15
|
+
return response.text ?? null;
|
|
61
16
|
}
|
|
62
17
|
export async function handleSpeechToText(request, apiClient) {
|
|
63
18
|
const args = request.params.arguments ?? {};
|
|
@@ -68,6 +23,12 @@ export async function handleSpeechToText(request, apiClient) {
|
|
|
68
23
|
if (response_format && !VALID_RESPONSE_FORMATS.has(response_format)) {
|
|
69
24
|
return toolError(ErrorCode.INVALID_INPUT, `response_format '${response_format}' is not supported. Valid: ${[...VALID_RESPONSE_FORMATS].join(', ')}.`);
|
|
70
25
|
}
|
|
26
|
+
if (typeof temperature === 'number' && (temperature < 0 || temperature > 1)) {
|
|
27
|
+
return toolError(ErrorCode.INVALID_INPUT, 'temperature must be between 0 and 1 (inclusive).');
|
|
28
|
+
}
|
|
29
|
+
const cacheError = validateCacheOptions({ cache, cache_ttl, cache_clear });
|
|
30
|
+
if (cacheError)
|
|
31
|
+
return cacheError;
|
|
71
32
|
logger.audit('speech_to_text.start', {
|
|
72
33
|
model: model || DEFAULT_MODEL,
|
|
73
34
|
audio_path: audio_path.startsWith('data:') ? 'data_url' : audio_path.slice(0, 80),
|
|
@@ -76,7 +37,7 @@ export async function handleSpeechToText(request, apiClient) {
|
|
|
76
37
|
});
|
|
77
38
|
let audioInput;
|
|
78
39
|
try {
|
|
79
|
-
audioInput = await
|
|
40
|
+
audioInput = await resolveSpeechToTextAudio(audio_path);
|
|
80
41
|
}
|
|
81
42
|
catch (err) {
|
|
82
43
|
if (err instanceof UnsafeOutputPathError)
|
|
@@ -84,6 +45,12 @@ export async function handleSpeechToText(request, apiClient) {
|
|
|
84
45
|
const msg = err instanceof Error ? err.message : String(err);
|
|
85
46
|
if (msg.includes('Blocked host'))
|
|
86
47
|
return toolErrorFrom(ErrorCode.UPSTREAM_REFUSED, err);
|
|
48
|
+
if (msg.toLowerCase().includes('too large')) {
|
|
49
|
+
return toolErrorFrom(ErrorCode.RESOURCE_TOO_LARGE, err);
|
|
50
|
+
}
|
|
51
|
+
if (msg.toLowerCase().includes('unsupported')) {
|
|
52
|
+
return toolErrorFrom(ErrorCode.UNSUPPORTED_FORMAT, err);
|
|
53
|
+
}
|
|
87
54
|
return toolErrorFrom(ErrorCode.INVALID_INPUT, err);
|
|
88
55
|
}
|
|
89
56
|
const body = {
|
|
@@ -107,7 +74,7 @@ export async function handleSpeechToText(request, apiClient) {
|
|
|
107
74
|
catch (err) {
|
|
108
75
|
return classifyUpstreamError(err, 'speech_to_text');
|
|
109
76
|
}
|
|
110
|
-
const text = response
|
|
77
|
+
const text = formatTranscriptionContent(response, response_format);
|
|
111
78
|
if (!text) {
|
|
112
79
|
return toolError(ErrorCode.INTERNAL, 'Transcription returned no text.', {
|
|
113
80
|
response_keys: Object.keys(response),
|
|
@@ -13,7 +13,7 @@ export declare function handleTextToSpeech(request: {
|
|
|
13
13
|
params: {
|
|
14
14
|
arguments: TextToSpeechRequest;
|
|
15
15
|
};
|
|
16
|
-
}, apiClient: OpenRouterAPIClient): Promise<
|
|
16
|
+
}, apiClient: OpenRouterAPIClient): Promise<{
|
|
17
17
|
content: import("./tool-result-payload.js").BinaryToolContent[];
|
|
18
18
|
_meta: Record<string, unknown>;
|
|
19
19
|
}>;
|