@stabgan/openrouter-mcp-multimodal 4.6.2 → 4.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +108 -37
  2. package/dist/errors.d.ts +5 -20
  3. package/dist/errors.js +1 -10
  4. package/dist/index.js +7 -1
  5. package/dist/logger.js +54 -24
  6. package/dist/model-cache.d.ts +13 -0
  7. package/dist/model-cache.js +61 -5
  8. package/dist/openrouter-api.d.ts +14 -15
  9. package/dist/openrouter-api.js +68 -22
  10. package/dist/openrouter-openai-client.d.ts +9 -0
  11. package/dist/openrouter-openai-client.js +15 -0
  12. package/dist/tool-definitions.d.ts +24 -0
  13. package/dist/tool-definitions.js +283 -170
  14. package/dist/tool-descriptions.js +23 -15
  15. package/dist/tool-handlers/analyze-audio.js +4 -1
  16. package/dist/tool-handlers/analyze-image.js +10 -5
  17. package/dist/tool-handlers/analyze-video.js +9 -5
  18. package/dist/tool-handlers/async-chat.d.ts +17 -0
  19. package/dist/tool-handlers/async-chat.js +112 -31
  20. package/dist/tool-handlers/audio-utils.d.ts +19 -4
  21. package/dist/tool-handlers/audio-utils.js +170 -16
  22. package/dist/tool-handlers/cache.d.ts +3 -3
  23. package/dist/tool-handlers/cache.js +56 -4
  24. package/dist/tool-handlers/chat-completion.js +16 -7
  25. package/dist/tool-handlers/chat-request.d.ts +3 -0
  26. package/dist/tool-handlers/chat-request.js +28 -0
  27. package/dist/tool-handlers/completion-utils.d.ts +5 -11
  28. package/dist/tool-handlers/completion-utils.js +76 -47
  29. package/dist/tool-handlers/fetch-utils.d.ts +14 -0
  30. package/dist/tool-handlers/fetch-utils.js +331 -68
  31. package/dist/tool-handlers/generate-audio.d.ts +6 -51
  32. package/dist/tool-handlers/generate-audio.js +30 -76
  33. package/dist/tool-handlers/generate-image-dedicated.d.ts +2 -12
  34. package/dist/tool-handlers/generate-image-dedicated.js +79 -28
  35. package/dist/tool-handlers/generate-image.d.ts +3 -46
  36. package/dist/tool-handlers/generate-image.js +26 -43
  37. package/dist/tool-handlers/generate-video.d.ts +4 -3
  38. package/dist/tool-handlers/generate-video.js +51 -50
  39. package/dist/tool-handlers/get-model-info.js +1 -1
  40. package/dist/tool-handlers/health-check.js +39 -15
  41. package/dist/tool-handlers/image-utils.js +2 -2
  42. package/dist/tool-handlers/openrouter-errors.d.ts +2 -0
  43. package/dist/tool-handlers/openrouter-errors.js +138 -31
  44. package/dist/tool-handlers/path-safety.d.ts +6 -0
  45. package/dist/tool-handlers/path-safety.js +77 -14
  46. package/dist/tool-handlers/path-utils.d.ts +4 -0
  47. package/dist/tool-handlers/path-utils.js +20 -0
  48. package/dist/tool-handlers/provider-routing.d.ts +2 -0
  49. package/dist/tool-handlers/provider-routing.js +10 -0
  50. package/dist/tool-handlers/rerank.d.ts +1 -4
  51. package/dist/tool-handlers/rerank.js +43 -14
  52. package/dist/tool-handlers/search-models.js +3 -3
  53. package/dist/tool-handlers/speech-to-text.d.ts +1 -0
  54. package/dist/tool-handlers/speech-to-text.js +23 -56
  55. package/dist/tool-handlers/text-to-speech.d.ts +2 -12
  56. package/dist/tool-handlers/text-to-speech.js +29 -22
  57. package/dist/tool-handlers/tool-result-payload.d.ts +47 -0
  58. package/dist/tool-handlers/tool-result-payload.js +98 -0
  59. package/dist/tool-handlers/validate-model.js +1 -1
  60. package/dist/tool-handlers.d.ts +9 -0
  61. package/dist/tool-handlers.js +19 -10
  62. package/dist/version.d.ts +1 -1
  63. package/dist/version.js +1 -1
  64. package/package.json +1 -3
@@ -1,4 +1,3 @@
1
- import { promises as fs } from 'node:fs';
2
1
  import { extname } from 'node:path';
3
2
  import { ErrorCode, toolError, toolErrorFrom } from '../errors.js';
4
3
  import { SERVER_VERSION } from '../version.js';
@@ -7,11 +6,12 @@ import { resolveOptionalOutputPath, isToolErrorResult, UnsafeOutputPathError, }
7
6
  import { resolveImageBase64 } from './image-source.js';
8
7
  import { readEnvInt } from './fetch-utils.js';
9
8
  import { classifyUpstreamError } from './openrouter-errors.js';
9
+ import { buildBinaryToolResult } from './tool-result-payload.js';
10
+ import { replaceExtension, writeOutputFile } from './path-utils.js';
10
11
  const FALLBACK_MODEL = 'google/veo-3.1';
11
12
  const DEFAULT_POLL_INTERVAL_MS = 15_000;
12
13
  const DEFAULT_MAX_WAIT_MS = 10 * 60_000;
13
14
  const MIN_POLL_INTERVAL_MS = 50; // just to avoid a 0ms busy-loop if a caller omits
14
- const INLINE_RETURN_CEILING_BYTES = 10 * 1024 * 1024;
15
15
  /** Models deprecated by OpenAI — removal date: 2026-09-24. */
16
16
  const SORA_DEPRECATED_MODELS = new Set([
17
17
  'openai/sora-2',
@@ -37,8 +37,22 @@ function checkSoraDeprecation(model) {
37
37
  `Your request will still be attempted, but may fail. Recommended alternatives:\n` +
38
38
  SORA_ALTERNATIVES.map((a) => ` • ${a}`).join('\n'));
39
39
  }
40
- function getMaxInlineBytes() {
41
- return readEnvInt('OPENROUTER_VIDEO_INLINE_MAX_BYTES', INLINE_RETURN_CEILING_BYTES, 4096);
40
+ function isTerminalFailureStatus(status) {
41
+ const normalized = status.toLowerCase();
42
+ return normalized === 'failed' || normalized === 'cancelled' || normalized === 'canceled';
43
+ }
44
+ async function invokeProgressHook(hook, update) {
45
+ if (!hook)
46
+ return;
47
+ try {
48
+ await hook(update);
49
+ }
50
+ catch (err) {
51
+ logger.warn('generate_video.progress_hook_error', {
52
+ video_id: update.video_id,
53
+ err: err instanceof Error ? err.message : String(err),
54
+ });
55
+ }
42
56
  }
43
57
  function getDefaultPollInterval() {
44
58
  return readEnvInt('OPENROUTER_VIDEO_POLL_INTERVAL_MS', DEFAULT_POLL_INTERVAL_MS, MIN_POLL_INTERVAL_MS);
@@ -103,7 +117,11 @@ async function pollUntilTerminal(apiClient, envelope, opts) {
103
117
  let attempt = 0;
104
118
  let last = null;
105
119
  const initialStatus = (envelope.status ?? 'pending');
106
- await opts.onProgress?.({ status: initialStatus, attempt: 0, video_id: envelope.id });
120
+ await invokeProgressHook(opts.onProgress, {
121
+ status: initialStatus,
122
+ attempt: 0,
123
+ video_id: envelope.id,
124
+ });
107
125
  while (Date.now() < opts.deadlineAt) {
108
126
  attempt += 1;
109
127
  await sleep(Math.min(opts.pollIntervalMs, Math.max(0, opts.deadlineAt - Date.now())));
@@ -115,9 +133,9 @@ async function pollUntilTerminal(apiClient, envelope, opts) {
115
133
  id: envelope.id,
116
134
  err: err instanceof Error ? err.message : String(err),
117
135
  });
118
- continue; // transient; try again until deadline
136
+ continue;
119
137
  }
120
- await opts.onProgress?.({
138
+ await invokeProgressHook(opts.onProgress, {
121
139
  status: last.status,
122
140
  progress: typeof last.progress === 'number' ? last.progress : undefined,
123
141
  attempt,
@@ -125,7 +143,7 @@ async function pollUntilTerminal(apiClient, envelope, opts) {
125
143
  });
126
144
  if (last.status === 'completed')
127
145
  return { kind: 'completed', status: last };
128
- if (last.status === 'failed')
146
+ if (isTerminalFailureStatus(last.status))
129
147
  return { kind: 'failed', status: last };
130
148
  }
131
149
  return { kind: 'timeout', last };
@@ -148,14 +166,17 @@ async function finalizeCompletedJob(apiClient, status, savePath) {
148
166
  throw new Error('Completed job returned no content URLs.');
149
167
  }
150
168
  const { buffer, contentType } = await apiClient.downloadVideoContent(status.id, 0, getMaxDownloadBytes());
169
+ if (buffer.length === 0) {
170
+ throw new Error('Completed job returned empty video content.');
171
+ }
151
172
  const mime = (contentType?.split(';')[0]?.trim() || 'video/mp4').toLowerCase();
152
173
  const ext = mime.includes('webm')
153
- ? '.webm'
174
+ ? 'webm'
154
175
  : mime.includes('mov')
155
- ? '.mov'
176
+ ? 'mov'
156
177
  : mime.includes('mpeg')
157
- ? '.mpeg'
158
- : '.mp4';
178
+ ? 'mpeg'
179
+ : 'mp4';
159
180
  const baseMeta = {
160
181
  server_version: SERVER_VERSION,
161
182
  video_id: status.id,
@@ -167,45 +188,20 @@ async function finalizeCompletedJob(apiClient, status, savePath) {
167
188
  if (status.unsigned_urls)
168
189
  baseMeta.unsigned_urls = status.unsigned_urls;
169
190
  if (savePath) {
170
- const finalPath = extname(savePath) === ext ? savePath : stripAndReplaceExt(savePath, ext);
171
- await fs.writeFile(finalPath, buffer);
191
+ const finalPath = extname(savePath) === `.${ext}` ? savePath : replaceExtension(savePath, ext);
192
+ await writeOutputFile(finalPath, buffer);
172
193
  baseMeta.save_path = finalPath;
173
194
  const summaryNote = finalPath !== savePath ? ` (detected ${mime}, saved as ${finalPath})` : '';
174
- const content = [
175
- { type: 'text', text: `Video saved to: ${finalPath}${summaryNote}` },
176
- ];
177
- if (buffer.length <= getMaxInlineBytes()) {
178
- content.push({
179
- type: 'video',
180
- mimeType: mime,
181
- data: buffer.toString('base64'),
182
- });
183
- }
184
- return { content, _meta: baseMeta };
185
- }
186
- if (buffer.length <= getMaxInlineBytes()) {
187
- return {
188
- content: [
189
- { type: 'text', text: `Video generated (${buffer.length} bytes, ${mime}).` },
190
- { type: 'video', mimeType: mime, data: buffer.toString('base64') },
191
- ],
192
- _meta: baseMeta,
193
- };
195
+ return buildBinaryToolResult({ kind: 'video', buffer, mimeType: mime }, {
196
+ savedPath: finalPath,
197
+ summaryText: `Video saved to: ${finalPath}${summaryNote}`,
198
+ meta: baseMeta,
199
+ });
194
200
  }
195
- return {
196
- content: [
197
- {
198
- type: 'text',
199
- text: `Video generated (${buffer.length} bytes, ${mime}). Too large to inline; pass save_path to persist. URL: ${url}`,
200
- },
201
- ],
202
- _meta: baseMeta,
203
- };
204
- }
205
- function stripAndReplaceExt(p, newExt) {
206
- const cur = extname(p);
207
- const base = cur ? p.slice(0, -cur.length) : p;
208
- return base + newExt;
201
+ return buildBinaryToolResult({ kind: 'video', buffer, mimeType: mime }, {
202
+ remoteUrl: url,
203
+ meta: baseMeta,
204
+ });
209
205
  }
210
206
  export async function handleGenerateVideo(request, apiClient, progress) {
211
207
  const args = request.params.arguments ?? {};
@@ -312,7 +308,7 @@ export async function handleGetVideoStatus(request, apiClient) {
312
308
  catch (err) {
313
309
  return classifyUpstreamError(err, 'get_video_status.poll');
314
310
  }
315
- if (status.status === 'failed') {
311
+ if (status.status === 'failed' || isTerminalFailureStatus(status.status)) {
316
312
  return toolError(ErrorCode.JOB_FAILED, extractJobError(status), { video_id: id });
317
313
  }
318
314
  if (status.status === 'completed') {
@@ -368,4 +364,9 @@ export async function handleGenerateVideoFromImage(request, apiClient, progress)
368
364
  },
369
365
  }, apiClient, progress);
370
366
  }
371
- export const _internals = { buildRequestBody, stripAndReplaceExt, extractJobError };
367
+ export const _internals = {
368
+ buildRequestBody,
369
+ extractJobError,
370
+ isTerminalFailureStatus,
371
+ invokeProgressHook,
372
+ };
@@ -17,7 +17,7 @@ export async function handleGetModelInfo(request, modelCache, apiClient) {
17
17
  if (!modelCache.isValid()) {
18
18
  return toolError(ErrorCode.INTERNAL, 'No model data available.');
19
19
  }
20
- const info = modelCache.get(model);
20
+ const info = modelCache.lookup(model);
21
21
  if (!info) {
22
22
  return toolError(ErrorCode.MODEL_NOT_FOUND, `Model '${model}' not found.`);
23
23
  }
@@ -1,24 +1,48 @@
1
1
  import { SERVER_VERSION, MCP_PROTOCOL_VERSION } from '../version.js';
2
2
  import { buildStructuredResult } from './structured-output.js';
3
+ import { classifyUpstreamError } from './openrouter-errors.js';
3
4
  /** Liveness probe — validates API key, reachability, and cached model count. */
4
5
  export async function handleHealthCheck(_request, apiClient, modelCache) {
5
- let apiKeyValid = false;
6
- let errorMessage;
7
6
  try {
8
- await modelCache.ensureFresh(() => apiClient.getModels());
9
- apiKeyValid = true;
7
+ let apiKeyValid = false;
8
+ let errorMessage;
9
+ let errorMeta = {};
10
+ try {
11
+ await modelCache.ensureFresh(() => apiClient.getModels());
12
+ apiKeyValid = true;
13
+ }
14
+ catch (err) {
15
+ const classified = classifyUpstreamError(err, 'health_check');
16
+ errorMessage = classified.content[0]?.text;
17
+ errorMeta = {
18
+ code: classified._meta.code,
19
+ ...(classified._meta.suggestions ? { suggestions: classified._meta.suggestions } : {}),
20
+ ...(classified._meta.details ? { details: classified._meta.details } : {}),
21
+ };
22
+ }
23
+ const modelsCached = modelCache.isValid() ? modelCache.size() : 0;
24
+ const ok = apiKeyValid;
25
+ return buildStructuredResult({
26
+ ok,
27
+ server_version: SERVER_VERSION,
28
+ protocol_version: MCP_PROTOCOL_VERSION,
29
+ api_key_valid: apiKeyValid,
30
+ models_cached: modelsCached,
31
+ ...(errorMessage ? { error: errorMessage } : {}),
32
+ }, errorMeta);
10
33
  }
11
34
  catch (err) {
12
- errorMessage = err instanceof Error ? err.message : String(err);
35
+ const classified = classifyUpstreamError(err, 'health_check');
36
+ return buildStructuredResult({
37
+ ok: false,
38
+ server_version: SERVER_VERSION,
39
+ protocol_version: MCP_PROTOCOL_VERSION,
40
+ api_key_valid: false,
41
+ models_cached: 0,
42
+ error: classified.content[0]?.text,
43
+ }, {
44
+ code: classified._meta.code,
45
+ ...(classified._meta.suggestions ? { suggestions: classified._meta.suggestions } : {}),
46
+ });
13
47
  }
14
- const modelsCached = modelCache.isValid() ? modelCache.size() : 0;
15
- const ok = apiKeyValid;
16
- return buildStructuredResult({
17
- ok,
18
- server_version: SERVER_VERSION,
19
- protocol_version: MCP_PROTOCOL_VERSION,
20
- api_key_valid: apiKeyValid,
21
- models_cached: modelsCached,
22
- ...(errorMessage ? { error: errorMessage } : {}),
23
- });
24
48
  }
@@ -1,5 +1,5 @@
1
- import path from 'path';
2
- import { promises as fs } from 'fs';
1
+ import path from 'node:path';
2
+ import { promises as fs } from 'node:fs';
3
3
  import { readEnvInt, isBlockedIPv4 as _isBlockedIPv4, assertUrlSafeForFetch as _assertUrlSafeForFetch, fetchHttpResource, parseBase64DataUrl, } from './fetch-utils.js';
4
4
  import { resolveSafeInputPath } from './path-safety.js';
5
5
  // Re-export for backward compatibility (tests import from image-utils)
@@ -2,5 +2,7 @@
2
2
  * Map OpenRouter / OpenAI SDK errors to our closed `ErrorCode` enum.
3
3
  */
4
4
  import { type ToolErrorResult } from '../errors.js';
5
+ /** Strip bearer tokens and OpenRouter key material from user-visible messages. */
6
+ export declare function sanitizeErrorMessage(msg: string): string;
5
7
  /** Classify upstream errors into the closed `ErrorCode` set. */
6
8
  export declare function classifyUpstreamError(err: unknown, contextMessage?: string): ToolErrorResult;
@@ -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 }, {
@@ -15,3 +15,9 @@ export type OptionalOutputPath = {
15
15
  export declare function isToolErrorResult(result: OptionalOutputPath | ToolErrorResult): result is ToolErrorResult;
16
16
  /** Resolve optional save_path; returns a tool error result on sandbox violation. */
17
17
  export declare function resolveOptionalOutputPath(savePath: string | undefined): Promise<OptionalOutputPath | ToolErrorResult>;
18
+ export declare function isValidJobId(jobId: string): boolean;
19
+ /**
20
+ * Resolve async-chat job status.json under OPENROUTER_OUTPUT_DIR/openrouter-jobs/.
21
+ * Uses realpath when the job directory exists to block symlink escapes.
22
+ */
23
+ export declare function resolveSafeJobStatusPath(jobsDir: string, jobId: string): Promise<string | null>;
@@ -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;
@@ -142,3 +175,33 @@ export async function resolveOptionalOutputPath(savePath) {
142
175
  return toolErrorFrom(ErrorCode.INTERNAL, err);
143
176
  }
144
177
  }
178
+ const JOB_ID_PATTERN = /^chat_[a-zA-Z0-9_-]{1,128}$/;
179
+ export function isValidJobId(jobId) {
180
+ if (!JOB_ID_PATTERN.test(jobId))
181
+ return false;
182
+ if (jobId.includes('..') || jobId.includes('/') || jobId.includes('\\'))
183
+ return false;
184
+ return true;
185
+ }
186
+ /**
187
+ * Resolve async-chat job status.json under OPENROUTER_OUTPUT_DIR/openrouter-jobs/.
188
+ * Uses realpath when the job directory exists to block symlink escapes.
189
+ */
190
+ export async function resolveSafeJobStatusPath(jobsDir, jobId) {
191
+ if (!isValidJobId(jobId))
192
+ return null;
193
+ const rootReal = await fs.realpath(jobsDir).catch(() => path.resolve(jobsDir));
194
+ const jobDirCandidate = path.resolve(jobsDir, jobId);
195
+ let jobDirReal;
196
+ try {
197
+ jobDirReal = await fs.realpath(jobDirCandidate);
198
+ }
199
+ catch {
200
+ if (!isInsideRoot(jobDirCandidate, rootReal))
201
+ return null;
202
+ return path.join(jobDirCandidate, 'status.json');
203
+ }
204
+ if (!isInsideRoot(jobDirReal, rootReal))
205
+ return null;
206
+ return path.join(jobDirReal, 'status.json');
207
+ }
@@ -0,0 +1,4 @@
1
+ /** Strip existing extension (if any) and append a new one. */
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>;
@@ -0,0 +1,20 @@
1
+ import { extname } from 'node:path';
2
+ import { promises as fs } from 'node:fs';
3
+ /** Strip existing extension (if any) and append a new one. */
4
+ export function replaceExtension(filePath, newExt) {
5
+ const current = extname(filePath);
6
+ const base = current ? filePath.slice(0, -current.length) : filePath;
7
+ return `${base}.${newExt}`;
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
+ }