@stabgan/openrouter-mcp-multimodal 4.6.0 → 4.6.2

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 (67) hide show
  1. package/README.md +60 -52
  2. package/dist/errors.d.ts +3 -12
  3. package/dist/errors.js +2 -6
  4. package/dist/index.js +11 -11
  5. package/dist/logger.d.ts +1 -18
  6. package/dist/logger.js +0 -7
  7. package/dist/model-cache.d.ts +1 -20
  8. package/dist/model-cache.js +1 -20
  9. package/dist/openrouter-api.d.ts +4 -18
  10. package/dist/openrouter-api.js +4 -20
  11. package/dist/tool-definitions.d.ts +1 -0
  12. package/dist/tool-definitions.js +708 -0
  13. package/dist/tool-descriptions.js +6 -8
  14. package/dist/tool-handlers/analyze-audio.d.ts +0 -5
  15. package/dist/tool-handlers/analyze-image.d.ts +0 -6
  16. package/dist/tool-handlers/analyze-image.js +1 -8
  17. package/dist/tool-handlers/analyze-video.d.ts +0 -5
  18. package/dist/tool-handlers/analyze-video.js +0 -7
  19. package/dist/tool-handlers/async-chat.d.ts +17 -13
  20. package/dist/tool-handlers/async-chat.js +43 -66
  21. package/dist/tool-handlers/audio-utils.d.ts +1 -4
  22. package/dist/tool-handlers/audio-utils.js +4 -14
  23. package/dist/tool-handlers/cache.d.ts +2 -18
  24. package/dist/tool-handlers/cache.js +1 -19
  25. package/dist/tool-handlers/chat-completion.d.ts +2 -30
  26. package/dist/tool-handlers/chat-completion.js +13 -33
  27. package/dist/tool-handlers/chat-request.d.ts +25 -0
  28. package/dist/tool-handlers/chat-request.js +38 -0
  29. package/dist/tool-handlers/completion-utils.d.ts +1 -18
  30. package/dist/tool-handlers/completion-utils.js +0 -2
  31. package/dist/tool-handlers/fetch-utils.d.ts +2 -18
  32. package/dist/tool-handlers/fetch-utils.js +3 -51
  33. package/dist/tool-handlers/generate-audio.js +11 -21
  34. package/dist/tool-handlers/generate-image-dedicated.js +17 -65
  35. package/dist/tool-handlers/generate-image-input.d.ts +0 -1
  36. package/dist/tool-handlers/generate-image-input.js +2 -18
  37. package/dist/tool-handlers/generate-image.js +8 -15
  38. package/dist/tool-handlers/generate-video.d.ts +1 -7
  39. package/dist/tool-handlers/generate-video.js +24 -109
  40. package/dist/tool-handlers/health-check.d.ts +1 -9
  41. package/dist/tool-handlers/health-check.js +1 -12
  42. package/dist/tool-handlers/image-source.d.ts +14 -0
  43. package/dist/tool-handlers/image-source.js +23 -0
  44. package/dist/tool-handlers/image-utils.d.ts +5 -0
  45. package/dist/tool-handlers/image-utils.js +23 -0
  46. package/dist/tool-handlers/openai-withresponse.d.ts +1 -10
  47. package/dist/tool-handlers/openai-withresponse.js +0 -7
  48. package/dist/tool-handlers/openrouter-errors.d.ts +2 -18
  49. package/dist/tool-handlers/openrouter-errors.js +2 -34
  50. package/dist/tool-handlers/path-safety.d.ts +10 -15
  51. package/dist/tool-handlers/path-safety.js +30 -55
  52. package/dist/tool-handlers/provider-routing.d.ts +0 -9
  53. package/dist/tool-handlers/provider-routing.js +1 -14
  54. package/dist/tool-handlers/rerank.js +0 -2
  55. package/dist/tool-handlers/search-models.d.ts +0 -6
  56. package/dist/tool-handlers/speech-to-text.js +19 -29
  57. package/dist/tool-handlers/structured-output.d.ts +1 -4
  58. package/dist/tool-handlers/structured-output.js +2 -16
  59. package/dist/tool-handlers/text-to-speech.js +8 -26
  60. package/dist/tool-handlers/video-utils.d.ts +1 -6
  61. package/dist/tool-handlers/video-utils.js +2 -17
  62. package/dist/tool-handlers.js +9 -730
  63. package/dist/tool-icons.d.ts +9 -0
  64. package/dist/tool-icons.js +52 -0
  65. package/dist/version.d.ts +1 -15
  66. package/dist/version.js +1 -15
  67. package/package.json +3 -2
@@ -3,7 +3,8 @@ import { extname } from 'node:path';
3
3
  import { ErrorCode, toolError, toolErrorFrom } from '../errors.js';
4
4
  import { SERVER_VERSION } from '../version.js';
5
5
  import { logger } from '../logger.js';
6
- import { resolveSafeOutputPath, resolveSafeInputPath, UnsafeOutputPathError, } from './path-safety.js';
6
+ import { resolveOptionalOutputPath, isToolErrorResult, UnsafeOutputPathError, } from './path-safety.js';
7
+ import { resolveImageBase64 } from './image-source.js';
7
8
  import { readEnvInt } from './fetch-utils.js';
8
9
  import { classifyUpstreamError } from './openrouter-errors.js';
9
10
  const FALLBACK_MODEL = 'google/veo-3.1';
@@ -26,10 +27,7 @@ const SORA_ALTERNATIVES = [
26
27
  'bytedance/seedance-2.0-fast (fast turnaround)',
27
28
  'alibaba/wan-2.7 (good for artistic styles)',
28
29
  ];
29
- /**
30
- * Check if the model is a deprecated Sora model and return a warning string,
31
- * or null if no deprecation applies.
32
- */
30
+ /** Return a deprecation warning for Sora models, or null. */
33
31
  function checkSoraDeprecation(model) {
34
32
  const normalized = model.toLowerCase().trim();
35
33
  if (!SORA_DEPRECATED_MODELS.has(normalized) && !normalized.startsWith('openai/sora')) {
@@ -49,57 +47,19 @@ function getDefaultMaxWait() {
49
47
  return readEnvInt('OPENROUTER_VIDEO_MAX_WAIT_MS', DEFAULT_MAX_WAIT_MS, 10_000);
50
48
  }
51
49
  function getMaxDownloadBytes() {
52
- // Generation output can be bigger than the input cap since it's our own
53
- // content. Default 256 MB, override via env.
54
50
  return readEnvInt('OPENROUTER_VIDEO_GEN_MAX_BYTES', 256 * 1024 * 1024, 1024 * 1024);
55
51
  }
56
- /**
57
- * Fold a caller-supplied image source (local path, http URL, or data URL)
58
- * into the `{ url: "data:video|image/...base64,..." }` shape OpenRouter
59
- * expects inside `frame_images[].image` / `input_references[]`.
60
- *
61
- * We reuse `prepareVideoData` for videos but images live in `image-utils`.
62
- * Since generate_video's references are images, not videos, we do a small
63
- * image-specific fetch here (data URL pass-through, HTTP via fetch-utils,
64
- * local via fs). We deliberately do NOT run them through sharp — the model
65
- * wants the pristine frame.
66
- */
67
- async function prepareImageInput(source) {
52
+ async function imageFrameEntry(source, frameType) {
68
53
  if (!source)
69
54
  return null;
70
- if (source.startsWith('data:')) {
71
- const match = source.match(/^data:([^;,]+)(?:;[^,]*)*;base64,(.+)$/);
72
- if (!match)
73
- throw new Error(`Invalid image data URL: ${source.slice(0, 40)}…`);
74
- return { mime: match[1], data: match[2] };
75
- }
76
- if (source.startsWith('http://') || source.startsWith('https://')) {
77
- const { fetchHttpResource } = await import('./fetch-utils.js');
78
- const { buffer, contentType } = await fetchHttpResource(source, {
79
- timeoutMs: 30_000,
80
- maxBytes: 25 * 1024 * 1024,
81
- maxRedirects: 8,
82
- });
83
- const mime = (contentType?.split(';')[0]?.trim() || 'image/jpeg').toLowerCase();
84
- return { mime, data: buffer.toString('base64') };
85
- }
86
- // Local file: sandbox via path-safety's resolveSafeInputPath so
87
- // generate_video's first_frame_image / last_frame_image /
88
- // reference_images fields enforce the same OPENROUTER_INPUT_DIR
89
- // / OPENROUTER_OUTPUT_DIR / cwd scope that generate_image's
90
- // input_images already uses. Callers can still bypass with
91
- // OPENROUTER_ALLOW_UNSAFE_PATHS=1 for legacy scripts.
92
- const abs = await resolveSafeInputPath(source);
93
- const buf = await fs.readFile(abs);
94
- const ext = extname(abs).toLowerCase();
95
- const mime = ext === '.png'
96
- ? 'image/png'
97
- : ext === '.webp'
98
- ? 'image/webp'
99
- : ext === '.gif'
100
- ? 'image/gif'
101
- : 'image/jpeg';
102
- return { mime, data: buf.toString('base64') };
55
+ const img = await resolveImageBase64(source);
56
+ const entry = {
57
+ type: 'image_url',
58
+ image_url: { url: `data:${img.mime};base64,${img.data}` },
59
+ };
60
+ if (frameType)
61
+ entry.frame_type = frameType;
62
+ return { kind: 'frame', entry };
103
63
  }
104
64
  function buildRequestBody(args, model) {
105
65
  const body = { model, prompt: args.prompt };
@@ -118,28 +78,10 @@ function buildRequestBody(args, model) {
118
78
  async function attachFrameImages(args, body) {
119
79
  const frameTasks = [];
120
80
  if (args.first_frame_image) {
121
- frameTasks.push(prepareImageInput(args.first_frame_image).then((img) => img
122
- ? {
123
- kind: 'frame',
124
- entry: {
125
- type: 'image_url',
126
- image_url: { url: `data:${img.mime};base64,${img.data}` },
127
- frame_type: 'first_frame',
128
- },
129
- }
130
- : null));
81
+ frameTasks.push(imageFrameEntry(args.first_frame_image, 'first_frame'));
131
82
  }
132
83
  if (args.last_frame_image) {
133
- frameTasks.push(prepareImageInput(args.last_frame_image).then((img) => img
134
- ? {
135
- kind: 'frame',
136
- entry: {
137
- type: 'image_url',
138
- image_url: { url: `data:${img.mime};base64,${img.data}` },
139
- frame_type: 'last_frame',
140
- },
141
- }
142
- : null));
84
+ frameTasks.push(imageFrameEntry(args.last_frame_image, 'last_frame'));
143
85
  }
144
86
  const frameResults = await Promise.all(frameTasks);
145
87
  const frameImages = frameResults
@@ -148,10 +90,8 @@ async function attachFrameImages(args, body) {
148
90
  if (frameImages.length)
149
91
  body.frame_images = frameImages;
150
92
  if (args.reference_images?.length) {
151
- const refResults = await Promise.all(args.reference_images.map((src) => prepareImageInput(src)));
152
- const refs = refResults
153
- .filter((img) => img !== null)
154
- .map((img) => ({
93
+ const refResults = await Promise.all(args.reference_images.map((src) => resolveImageBase64(src)));
94
+ const refs = refResults.map((img) => ({
155
95
  type: 'image_url',
156
96
  image_url: { url: `data:${img.mime};base64,${img.data}` },
157
97
  }));
@@ -243,7 +183,6 @@ async function finalizeCompletedJob(apiClient, status, savePath) {
243
183
  }
244
184
  return { content, _meta: baseMeta };
245
185
  }
246
- // No save_path — return inline if small enough, otherwise just the URL.
247
186
  if (buffer.length <= getMaxInlineBytes()) {
248
187
  return {
249
188
  content: [
@@ -274,12 +213,7 @@ export async function handleGenerateVideo(request, apiClient, progress) {
274
213
  return toolError(ErrorCode.INVALID_INPUT, 'prompt is required.');
275
214
  }
276
215
  const model = args.model || process.env.OPENROUTER_DEFAULT_VIDEO_GEN_MODEL || FALLBACK_MODEL;
277
- // Sora deprecation warning — OpenAI is removing the Videos API and all
278
- // Sora 2 model aliases on September 24, 2026. Warn and suggest alternatives.
279
216
  const deprecationWarning = checkSoraDeprecation(model);
280
- // Audit entry — video is the most expensive tool we have. Always log
281
- // model, resolution, duration, and a safe prompt preview so unintended
282
- // spend can be traced.
283
217
  logger.audit('generate_video.start', {
284
218
  model,
285
219
  prompt_preview: args.prompt.slice(0, 80),
@@ -291,25 +225,15 @@ export async function handleGenerateVideo(request, apiClient, progress) {
291
225
  reference_images: args.reference_images?.length ?? 0,
292
226
  save_path: args.save_path ? 'provided' : 'none',
293
227
  });
294
- // Fail-fast on unsafe save_path BEFORE spending credits on the job.
295
- let safeSavePath = null;
296
- if (args.save_path) {
297
- try {
298
- safeSavePath = await resolveSafeOutputPath(args.save_path);
299
- }
300
- catch (err) {
301
- if (err instanceof UnsafeOutputPathError)
302
- return toolErrorFrom(ErrorCode.UNSAFE_PATH, err);
303
- return toolErrorFrom(ErrorCode.INTERNAL, err);
304
- }
305
- }
228
+ const savePathResult = await resolveOptionalOutputPath(args.save_path);
229
+ if (isToolErrorResult(savePathResult))
230
+ return savePathResult;
231
+ const safeSavePath = savePathResult.path;
306
232
  const body = buildRequestBody(args, model);
307
233
  try {
308
234
  await attachFrameImages(args, body);
309
235
  }
310
236
  catch (err) {
311
- // Sandbox violation → UNSAFE_PATH; all other decode failures stay
312
- // as UNSUPPORTED_FORMAT (couldn't read, invalid data URL, etc.).
313
237
  if (err instanceof UnsafeOutputPathError) {
314
238
  return toolErrorFrom(ErrorCode.UNSAFE_PATH, err, 'Reference/frame image');
315
239
  }
@@ -359,7 +283,6 @@ export async function handleGenerateVideo(request, apiClient, progress) {
359
283
  }
360
284
  try {
361
285
  const { content, _meta } = await finalizeCompletedJob(apiClient, outcome.status, safeSavePath);
362
- // Prepend deprecation warning if applicable
363
286
  if (deprecationWarning) {
364
287
  content.unshift({ type: 'text', text: deprecationWarning });
365
288
  _meta.deprecated_model = true;
@@ -378,18 +301,10 @@ export async function handleGetVideoStatus(request, apiClient) {
378
301
  const id = args.video_id?.trim();
379
302
  if (!id)
380
303
  return toolError(ErrorCode.INVALID_INPUT, 'video_id is required.');
381
- // Pre-resolve save_path so the poll surfaces a fast error before hitting OpenRouter.
382
- let safeSavePath = null;
383
- if (args.save_path) {
384
- try {
385
- safeSavePath = await resolveSafeOutputPath(args.save_path);
386
- }
387
- catch (err) {
388
- if (err instanceof UnsafeOutputPathError)
389
- return toolErrorFrom(ErrorCode.UNSAFE_PATH, err);
390
- return toolErrorFrom(ErrorCode.INTERNAL, err);
391
- }
392
- }
304
+ const savePathResult = await resolveOptionalOutputPath(args.save_path);
305
+ if (isToolErrorResult(savePathResult))
306
+ return savePathResult;
307
+ const safeSavePath = savePathResult.path;
393
308
  let status;
394
309
  try {
395
310
  status = await apiClient.pollVideoJob(id);
@@ -1,14 +1,6 @@
1
1
  import type { OpenRouterAPIClient } from '../openrouter-api.js';
2
2
  import { ModelCache } from '../model-cache.js';
3
- /**
4
- * Lightweight liveness probe. Runs the following checks:
5
- * - Hit `/models` via the API client (indirectly validates API key +
6
- * OpenRouter reachability)
7
- * - Read cached model count
8
- * - Report server + protocol versions
9
- *
10
- * Returns `{ ok, ... }` so ops can use it as a readiness signal.
11
- */
3
+ /** Liveness probe — validates API key, reachability, and cached model count. */
12
4
  export declare function handleHealthCheck(_request: {
13
5
  params: {
14
6
  arguments: Record<string, unknown>;
@@ -1,14 +1,6 @@
1
1
  import { SERVER_VERSION, MCP_PROTOCOL_VERSION } from '../version.js';
2
2
  import { buildStructuredResult } from './structured-output.js';
3
- /**
4
- * Lightweight liveness probe. Runs the following checks:
5
- * - Hit `/models` via the API client (indirectly validates API key +
6
- * OpenRouter reachability)
7
- * - Read cached model count
8
- * - Report server + protocol versions
9
- *
10
- * Returns `{ ok, ... }` so ops can use it as a readiness signal.
11
- */
3
+ /** Liveness probe — validates API key, reachability, and cached model count. */
12
4
  export async function handleHealthCheck(_request, apiClient, modelCache) {
13
5
  let apiKeyValid = false;
14
6
  let errorMessage;
@@ -20,9 +12,6 @@ export async function handleHealthCheck(_request, apiClient, modelCache) {
20
12
  errorMessage = err instanceof Error ? err.message : String(err);
21
13
  }
22
14
  const modelsCached = modelCache.isValid() ? modelCache.size() : 0;
23
- // `ok` means the API was reachable and the key was accepted. An empty
24
- // catalog counts as success (the API just returned no models) — callers
25
- // branch on `models_cached` if they care about the count.
26
15
  const ok = apiKeyValid;
27
16
  return buildStructuredResult({
28
17
  ok,
@@ -0,0 +1,14 @@
1
+ /** Resolve an image reference to a URL (data URL or http(s) passthrough). */
2
+ export declare function resolveImageUrl(ref: string): Promise<string>;
3
+ /** Resolve an image reference to base64 payload + mime (for video frame uploads). */
4
+ export declare function resolveImageBase64(source: string): Promise<{
5
+ mime: string;
6
+ data: string;
7
+ }>;
8
+ /** OpenRouter dedicated-image API reference shape. */
9
+ export declare function toOpenRouterImageReference(source: string): Promise<{
10
+ type: string;
11
+ image_url: {
12
+ url: string;
13
+ };
14
+ }>;
@@ -0,0 +1,23 @@
1
+ import { fetchImageWithMime } from './image-utils.js';
2
+ /** Resolve an image reference to a URL (data URL or http(s) passthrough). */
3
+ export async function resolveImageUrl(ref) {
4
+ const trimmed = ref.trim();
5
+ if (!trimmed)
6
+ throw new Error('empty image reference');
7
+ if (trimmed.startsWith('data:') || /^https?:\/\//i.test(trimmed))
8
+ return trimmed;
9
+ const { buffer, mime } = await fetchImageWithMime(trimmed);
10
+ return `data:${mime};base64,${buffer.toString('base64')}`;
11
+ }
12
+ /** Resolve an image reference to base64 payload + mime (for video frame uploads). */
13
+ export async function resolveImageBase64(source) {
14
+ if (!source.trim())
15
+ throw new Error('empty image source');
16
+ const { buffer, mime } = await fetchImageWithMime(source);
17
+ return { mime, data: buffer.toString('base64') };
18
+ }
19
+ /** OpenRouter dedicated-image API reference shape. */
20
+ export async function toOpenRouterImageReference(source) {
21
+ const url = await resolveImageUrl(source);
22
+ return { type: 'image_url', image_url: { url } };
23
+ }
@@ -24,4 +24,9 @@ export declare function optimizeImage(buffer: Buffer): Promise<{
24
24
  base64: string;
25
25
  mime: string;
26
26
  }>;
27
+ /** Load image bytes with a MIME type (respects OPENROUTER_IMAGE_* fetch limits). */
28
+ export declare function fetchImageWithMime(source: string): Promise<{
29
+ buffer: Buffer;
30
+ mime: string;
31
+ }>;
27
32
  export declare function prepareImageUrl(source: string): Promise<string>;
@@ -160,6 +160,29 @@ export async function optimizeImage(buffer) {
160
160
  };
161
161
  }
162
162
  }
163
+ /** Load image bytes with a MIME type (respects OPENROUTER_IMAGE_* fetch limits). */
164
+ export async function fetchImageWithMime(source) {
165
+ if (source.startsWith('data:')) {
166
+ const parsed = parseBase64DataUrl(source);
167
+ if (!parsed)
168
+ throw new Error('Invalid data URL');
169
+ return { buffer: Buffer.from(parsed.base64, 'base64'), mime: parsed.mediaType };
170
+ }
171
+ if (source.startsWith('http://') || source.startsWith('https://')) {
172
+ const { buffer, contentType } = await fetchHttpResource(source, {
173
+ timeoutMs: getFetchTimeoutMs(),
174
+ maxBytes: getMaxDownloadBytes(),
175
+ maxRedirects: getMaxRedirects(),
176
+ });
177
+ const mime = (contentType?.split(';')[0]?.trim() ||
178
+ sniffImageMime(buffer) ||
179
+ 'image/jpeg').toLowerCase();
180
+ return { buffer, mime };
181
+ }
182
+ const safe = await resolveSafeInputPath(source);
183
+ const buffer = await fs.readFile(safe);
184
+ return { buffer, mime: getMimeType(safe) };
185
+ }
163
186
  export async function prepareImageUrl(source) {
164
187
  if (source.startsWith('data:'))
165
188
  return source;
@@ -1,13 +1,4 @@
1
- /**
2
- * Helper for calling `openai.chat.completions.create()` and getting back
3
- * BOTH the typed body and the raw fetch `Response` (so we can read the
4
- * X-OpenRouter-Cache-* headers).
5
- *
6
- * The real openai SDK returns an `APIPromise` that exposes `.withResponse()`.
7
- * Vitest tests typically stub `create()` to return a plain `ChatCompletion`
8
- * object. This helper handles both cases so tests don't need to mock the
9
- * chainable.
10
- */
1
+ /** Call `openai.chat.completions.create()` and return body + raw Response headers. */
11
2
  import type { ChatCompletion } from 'openai/resources/chat/completions.js';
12
3
  export interface ChatCompletionWithHeaders {
13
4
  data: ChatCompletion;
@@ -1,16 +1,9 @@
1
1
  export async function awaitCompletionWithHeaders(call) {
2
- // Prefer the APIPromise .withResponse() chainable, which returns
3
- // `{ data, response }` — but only if the object looks like an
4
- // APIPromise. Mocks that return plain objects get unwrapped via a
5
- // direct await.
6
2
  const maybeChainable = call;
7
3
  if (typeof maybeChainable?.withResponse === 'function') {
8
4
  const { data, response } = await maybeChainable.withResponse();
9
5
  return { data, response };
10
6
  }
11
- // Fallback: await the value directly. Covers both test mocks
12
- // (which return a plain ChatCompletion via vi.fn().mockResolvedValue())
13
- // and any exotic SDK shape we don't recognize.
14
7
  const data = (await call);
15
8
  return { data, response: undefined };
16
9
  }
@@ -1,22 +1,6 @@
1
1
  /**
2
- * Shared mapping from OpenRouter / OpenAI SDK error shapes to our closed
3
- * `ErrorCode` enum. Every tool handler that calls the OpenAI client routes
4
- * its `catch` block through `classifyUpstreamError` so error taxonomies
5
- * don't drift.
2
+ * Map OpenRouter / OpenAI SDK errors to our closed `ErrorCode` enum.
6
3
  */
7
4
  import { type ToolErrorResult } from '../errors.js';
8
- /**
9
- * Classify a caught error from `openai.*` or a raw `fetch` to the
10
- * OpenRouter REST API into the closed `ErrorCode` set.
11
- *
12
- * Matching strategy:
13
- * 1. HTTP status first (when available).
14
- * 2. Message heuristics for common OpenRouter strings (credits, ZDR,
15
- * "model does not exist", content policy, etc.).
16
- * 3. Default to INTERNAL to avoid leaking raw shapes.
17
- *
18
- * When the error carries a `Retry-After` header (on 429 / 503) we populate
19
- * `_meta.retry_after_seconds` so agents can back off intelligently. We
20
- * also attach canonical `suggestions[]` for common cases.
21
- */
5
+ /** Classify upstream errors into the closed `ErrorCode` set. */
22
6
  export declare function classifyUpstreamError(err: unknown, contextMessage?: string): ToolErrorResult;
@@ -1,8 +1,5 @@
1
1
  /**
2
- * Shared mapping from OpenRouter / OpenAI SDK error shapes to our closed
3
- * `ErrorCode` enum. Every tool handler that calls the OpenAI client routes
4
- * its `catch` block through `classifyUpstreamError` so error taxonomies
5
- * don't drift.
2
+ * Map OpenRouter / OpenAI SDK errors to our closed `ErrorCode` enum.
6
3
  */
7
4
  import { ErrorCode, toolError } from '../errors.js';
8
5
  function extractRetryAfterSeconds(err) {
@@ -24,8 +21,6 @@ function extractRetryAfterSeconds(err) {
24
21
  const n = Number(raw);
25
22
  if (Number.isFinite(n) && n >= 0)
26
23
  return n;
27
- // Retry-After can also be an HTTP-date; return undefined for those (caller
28
- // can still retry on its own backoff schedule).
29
24
  return undefined;
30
25
  }
31
26
  function extractStatus(err) {
@@ -34,14 +29,11 @@ function extractStatus(err) {
34
29
  const s = err.status;
35
30
  if (typeof s === 'number')
36
31
  return s;
37
- // openai-node sometimes puts the status in `code` for `APIError`.
38
32
  const c = err.code;
39
33
  if (typeof c === 'number')
40
34
  return c;
41
35
  if (typeof c === 'string' && /^\d{3}$/.test(c))
42
36
  return parseInt(c, 10);
43
- // Fall back: parse the message for `HTTP NNN` — our internal client wraps
44
- // fetch failures as `POST /videos failed: HTTP 400 — <detail>`.
45
37
  if (err instanceof Error) {
46
38
  const m = err.message.match(/\bHTTP (\d{3})\b/);
47
39
  if (m)
@@ -63,30 +55,13 @@ function extractMessage(err) {
63
55
  return err;
64
56
  return 'unknown error';
65
57
  }
66
- /**
67
- * Classify a caught error from `openai.*` or a raw `fetch` to the
68
- * OpenRouter REST API into the closed `ErrorCode` set.
69
- *
70
- * Matching strategy:
71
- * 1. HTTP status first (when available).
72
- * 2. Message heuristics for common OpenRouter strings (credits, ZDR,
73
- * "model does not exist", content policy, etc.).
74
- * 3. Default to INTERNAL to avoid leaking raw shapes.
75
- *
76
- * When the error carries a `Retry-After` header (on 429 / 503) we populate
77
- * `_meta.retry_after_seconds` so agents can back off intelligently. We
78
- * also attach canonical `suggestions[]` for common cases.
79
- */
58
+ /** Classify upstream errors into the closed `ErrorCode` set. */
80
59
  export function classifyUpstreamError(err, contextMessage) {
81
60
  const rawMsg = extractMessage(err);
82
61
  const status = extractStatus(err);
83
62
  const lower = rawMsg.toLowerCase();
84
- // Prefix every user-visible message with the handler context when the
85
- // caller supplied one (e.g. `rerank`, `generate_video.submit`). Makes
86
- // server-side triage possible without digging through logs.
87
63
  const fullMsg = contextMessage ? `${contextMessage}: ${rawMsg}` : rawMsg;
88
64
  const retryAfterSeconds = extractRetryAfterSeconds(err);
89
- // Explicit credit / balance signals.
90
65
  if (lower.includes('insufficient balance') ||
91
66
  lower.includes('insufficient credits') ||
92
67
  lower.includes('requires more credits') ||
@@ -99,7 +74,6 @@ export function classifyUpstreamError(err, contextMessage) {
99
74
  ],
100
75
  });
101
76
  }
102
- // Zero Data Retention.
103
77
  if (lower.includes('zdr') || lower.includes('zero data retention')) {
104
78
  return toolError(ErrorCode.ZDR_INCOMPATIBLE, fullMsg, { status }, {
105
79
  suggestions: [
@@ -108,7 +82,6 @@ export function classifyUpstreamError(err, contextMessage) {
108
82
  ],
109
83
  });
110
84
  }
111
- // Model lookup failures.
112
85
  if (lower.includes('model') &&
113
86
  (lower.includes('does not exist') ||
114
87
  lower.includes('not found') ||
@@ -120,7 +93,6 @@ export function classifyUpstreamError(err, contextMessage) {
120
93
  ],
121
94
  });
122
95
  }
123
- // Content policy / moderation — surface as UPSTREAM_REFUSED so callers can distinguish from 5xx.
124
96
  if (lower.includes('content policy') ||
125
97
  lower.includes('moderation') ||
126
98
  lower.includes('refused')) {
@@ -128,7 +100,6 @@ export function classifyUpstreamError(err, contextMessage) {
128
100
  suggestions: ['Rephrase the prompt', 'Try a different provider via provider.order'],
129
101
  });
130
102
  }
131
- // Rate-limit specific.
132
103
  if (status === 429 || lower.includes('rate limit')) {
133
104
  return toolError(ErrorCode.UPSTREAM_REFUSED, fullMsg, { status, reason: 'rate_limit' }, {
134
105
  suggestions: [
@@ -140,7 +111,6 @@ export function classifyUpstreamError(err, contextMessage) {
140
111
  retry_after_seconds: retryAfterSeconds,
141
112
  });
142
113
  }
143
- // Timeouts (AbortError from `AbortSignal.timeout`).
144
114
  if (lower.includes('timed out') ||
145
115
  lower.includes('timeout') ||
146
116
  lower.includes('aborted') ||
@@ -149,11 +119,9 @@ export function classifyUpstreamError(err, contextMessage) {
149
119
  suggestions: ['Retry', 'Raise max_wait_ms or max_tokens'],
150
120
  });
151
121
  }
152
- // Anything in the 4xx band that isn't covered above — user supplied a bad request.
153
122
  if (typeof status === 'number' && status >= 400 && status < 500) {
154
123
  return toolError(ErrorCode.INVALID_INPUT, fullMsg, { status });
155
124
  }
156
- // 5xx / network errors.
157
125
  if (typeof status === 'number' && status >= 500) {
158
126
  return toolError(ErrorCode.UPSTREAM_HTTP, fullMsg, { status }, {
159
127
  suggestions: ['Retry after a brief delay', 'Check https://status.openrouter.ai'],
@@ -1,22 +1,17 @@
1
+ import { type ToolErrorResult } from '../errors.js';
1
2
  export declare class UnsafeOutputPathError extends Error {
2
3
  constructor(message: string);
3
4
  }
4
5
  /**
5
- * Resolve and validate a caller-supplied output path. Creates the parent
6
- * directory if needed. Returns the absolute path that is safe to write.
7
- *
8
- * Throws `UnsafeOutputPathError` when the resolved path escapes the root
9
- * (traversal attempt) and the sandbox is enabled.
6
+ * Resolve and validate a caller-supplied output path.
7
+ * Throws `UnsafeOutputPathError` on traversal attempts.
10
8
  */
11
9
  export declare function resolveSafeOutputPath(savePath: string): Promise<string>;
12
- /**
13
- * Resolve and validate a caller-supplied INPUT path. Unlike
14
- * `resolveSafeOutputPath`, this never creates directories — it only
15
- * confirms the path lives inside the input sandbox and returns the
16
- * absolute path the caller can `fs.readFile` from.
17
- *
18
- * Accepts the same `OPENROUTER_ALLOW_UNSAFE_PATHS=1` legacy bypass.
19
- * Throws `UnsafeOutputPathError` on traversal attempts (re-used type so
20
- * handlers map errors uniformly to `ErrorCode.UNSAFE_PATH`).
21
- */
10
+ /** Resolve and validate a caller-supplied input path (read-only, no mkdir). */
22
11
  export declare function resolveSafeInputPath(inputPath: string): Promise<string>;
12
+ export type OptionalOutputPath = {
13
+ path: string | null;
14
+ };
15
+ export declare function isToolErrorResult(result: OptionalOutputPath | ToolErrorResult): result is ToolErrorResult;
16
+ /** Resolve optional save_path; returns a tool error result on sandbox violation. */
17
+ export declare function resolveOptionalOutputPath(savePath: string | undefined): Promise<OptionalOutputPath | ToolErrorResult>;