@stabgan/openrouter-mcp-multimodal 4.7.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +95 -41
  2. package/dist/errors.d.ts +5 -20
  3. package/dist/errors.js +1 -10
  4. package/dist/index.js +8 -2
  5. package/dist/logger.js +54 -24
  6. package/dist/model-cache.d.ts +13 -0
  7. package/dist/model-cache.js +62 -8
  8. package/dist/openrouter-api.d.ts +14 -15
  9. package/dist/openrouter-api.js +68 -22
  10. package/dist/tool-definitions.d.ts +24 -0
  11. package/dist/tool-definitions.js +280 -177
  12. package/dist/tool-descriptions.d.ts +0 -4
  13. package/dist/tool-descriptions.js +30 -21
  14. package/dist/tool-handlers/analyze-audio.js +4 -1
  15. package/dist/tool-handlers/analyze-image.js +11 -6
  16. package/dist/tool-handlers/analyze-video.js +9 -5
  17. package/dist/tool-handlers/async-chat.d.ts +17 -0
  18. package/dist/tool-handlers/async-chat.js +104 -30
  19. package/dist/tool-handlers/audio-utils.d.ts +19 -4
  20. package/dist/tool-handlers/audio-utils.js +170 -16
  21. package/dist/tool-handlers/cache.d.ts +3 -3
  22. package/dist/tool-handlers/cache.js +56 -4
  23. package/dist/tool-handlers/chat-completion.js +16 -7
  24. package/dist/tool-handlers/chat-request.d.ts +4 -1
  25. package/dist/tool-handlers/chat-request.js +29 -1
  26. package/dist/tool-handlers/completion-utils.d.ts +5 -11
  27. package/dist/tool-handlers/completion-utils.js +76 -47
  28. package/dist/tool-handlers/fetch-utils.d.ts +14 -0
  29. package/dist/tool-handlers/fetch-utils.js +329 -77
  30. package/dist/tool-handlers/generate-audio.d.ts +4 -15
  31. package/dist/tool-handlers/generate-audio.js +21 -53
  32. package/dist/tool-handlers/generate-image-dedicated.d.ts +1 -1
  33. package/dist/tool-handlers/generate-image-dedicated.js +50 -31
  34. package/dist/tool-handlers/generate-image.d.ts +1 -1
  35. package/dist/tool-handlers/generate-image.js +19 -22
  36. package/dist/tool-handlers/generate-video.d.ts +4 -3
  37. package/dist/tool-handlers/generate-video.js +42 -18
  38. package/dist/tool-handlers/get-model-info.js +1 -1
  39. package/dist/tool-handlers/health-check.js +39 -15
  40. package/dist/tool-handlers/image-utils.js +2 -2
  41. package/dist/tool-handlers/openrouter-errors.d.ts +2 -0
  42. package/dist/tool-handlers/openrouter-errors.js +138 -31
  43. package/dist/tool-handlers/path-safety.js +49 -17
  44. package/dist/tool-handlers/path-utils.d.ts +2 -0
  45. package/dist/tool-handlers/path-utils.js +13 -0
  46. package/dist/tool-handlers/provider-routing.d.ts +2 -0
  47. package/dist/tool-handlers/provider-routing.js +11 -1
  48. package/dist/tool-handlers/rerank.d.ts +1 -4
  49. package/dist/tool-handlers/rerank.js +44 -15
  50. package/dist/tool-handlers/search-models.js +3 -3
  51. package/dist/tool-handlers/speech-to-text.d.ts +1 -0
  52. package/dist/tool-handlers/speech-to-text.js +23 -56
  53. package/dist/tool-handlers/text-to-speech.d.ts +1 -1
  54. package/dist/tool-handlers/text-to-speech.js +33 -20
  55. package/dist/tool-handlers/tool-result-payload.js +11 -9
  56. package/dist/tool-handlers/validate-model.js +1 -1
  57. package/dist/tool-handlers.d.ts +9 -0
  58. package/dist/tool-handlers.js +17 -5
  59. package/dist/tts-defaults.d.ts +4 -0
  60. package/dist/tts-defaults.js +4 -0
  61. package/dist/version.d.ts +3 -2
  62. package/dist/version.js +4 -2
  63. package/package.json +11 -13
@@ -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 n = Number(raw);
22
- if (Number.isFinite(n) && n >= 0)
23
- return n;
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 s = err.status;
30
- if (typeof s === 'number')
31
- return s;
32
- const c = err.code;
33
- if (typeof c === 'number')
34
- return c;
35
- if (typeof c === 'string' && /^\d{3}$/.test(c))
36
- return parseInt(c, 10);
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.error;
73
+ const nested = extractNestedError(err);
47
74
  if (nested && typeof nested === 'object' && typeof nested.message === 'string') {
48
- return `${err.message} — ${nested.message}`;
75
+ msg = `${err.message} — ${nested.message}`;
76
+ }
77
+ else if (typeof nested === 'string') {
78
+ msg = `${err.message} — ${nested}`;
49
79
  }
50
- if (typeof nested === 'string')
51
- return `${err.message} — ${nested}`;
52
- return err.message;
80
+ else {
81
+ msg = err.message;
82
+ }
83
+ }
84
+ else if (typeof err === 'string') {
85
+ msg = err;
53
86
  }
54
- if (typeof err === 'string')
55
- return err;
56
- return 'unknown error';
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.includes('model') &&
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 === rootReal || ancestorReal.startsWith(withSep))) {
62
- throw new UnsafeOutputPathError(`save_path resolves outside OPENROUTER_OUTPUT_DIR (${rootReal}). ` +
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 === rootReal || parentReal.startsWith(withSep))) {
68
- throw new UnsafeOutputPathError(`save_path escapes OPENROUTER_OUTPUT_DIR via symlink (${rootReal}).`);
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
- let canonical;
118
- try {
119
- canonical = await fs.realpath(abs);
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
- catch {
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
- if (!(canonical === rootReal || canonical.startsWith(withSep))) {
125
- throw new UnsafeOutputPathError(`input path resolves outside OPENROUTER_INPUT_DIR (${rootReal}): ${inputPath}`);
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 === rootReal || jobDirCandidate.startsWith(withSep)))
200
+ if (!isInsideRoot(jobDirCandidate, rootReal))
169
201
  return null;
170
202
  return path.join(jobDirCandidate, 'status.json');
171
203
  }
172
- if (!(jobDirReal === rootReal || jobDirReal.startsWith(withSep)))
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
- const DEFAULT_MODEL = 'cohere/rerank-english-v3.0';
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: model || DEFAULT_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 buildStructuredResult({
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 = Math.min(Math.max(1, args.limit ?? DEFAULT_LIMIT), MAX_LIMIT);
16
- const offset = Math.max(0, args.offset ?? 0);
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,3 +1,4 @@
1
+ /** Dedicated POST /api/v1/audio/transcriptions — Whisper, GPT-4o Transcribe, Voxtral. */
1
2
  import type { OpenRouterAPIClient } from '../openrouter-api.js';
2
3
  import { type CacheOptions } from './cache.js';
3
4
  export interface SpeechToTextRequest extends CacheOptions {
@@ -1,63 +1,18 @@
1
- /** Dedicated POST /api/v1/audio/transcriptions — Whisper, GPT-4o Transcribe, Voxtral. */
2
- import { promises as fs } from 'node:fs';
3
- import path from 'node:path';
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(['json', 'text', 'srt', 'verbose_json', 'vtt']);
13
- /** Infer audio format from file extension. */
14
- function audioFormatFromExt(ext) {
15
- const normalized = ext.toLowerCase().replace('.', '');
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 resolveAudioInput(audio_path);
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.text;
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<import("../errors.js").ToolErrorResult | {
16
+ }, apiClient: OpenRouterAPIClient): Promise<{
17
17
  content: import("./tool-result-payload.js").BinaryToolContent[];
18
18
  _meta: Record<string, unknown>;
19
19
  }>;