@stabgan/openrouter-mcp-multimodal 4.6.1 → 4.7.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 (76) hide show
  1. package/README.md +78 -53
  2. package/dist/errors.d.ts +3 -12
  3. package/dist/errors.js +2 -6
  4. package/dist/index.js +2 -10
  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/openrouter-openai-client.d.ts +9 -0
  12. package/dist/openrouter-openai-client.js +15 -0
  13. package/dist/tool-definitions.d.ts +1 -0
  14. package/dist/tool-definitions.js +719 -0
  15. package/dist/tool-handlers/analyze-audio.d.ts +0 -5
  16. package/dist/tool-handlers/analyze-image.d.ts +0 -6
  17. package/dist/tool-handlers/analyze-image.js +1 -8
  18. package/dist/tool-handlers/analyze-video.d.ts +0 -5
  19. package/dist/tool-handlers/analyze-video.js +0 -7
  20. package/dist/tool-handlers/async-chat.d.ts +17 -13
  21. package/dist/tool-handlers/async-chat.js +49 -65
  22. package/dist/tool-handlers/audio-utils.d.ts +1 -4
  23. package/dist/tool-handlers/audio-utils.js +4 -14
  24. package/dist/tool-handlers/cache.d.ts +2 -18
  25. package/dist/tool-handlers/cache.js +1 -19
  26. package/dist/tool-handlers/chat-completion.d.ts +2 -30
  27. package/dist/tool-handlers/chat-completion.js +13 -33
  28. package/dist/tool-handlers/chat-request.d.ts +25 -0
  29. package/dist/tool-handlers/chat-request.js +38 -0
  30. package/dist/tool-handlers/completion-utils.d.ts +1 -18
  31. package/dist/tool-handlers/completion-utils.js +0 -2
  32. package/dist/tool-handlers/fetch-utils.d.ts +2 -18
  33. package/dist/tool-handlers/fetch-utils.js +3 -51
  34. package/dist/tool-handlers/generate-audio.d.ts +3 -37
  35. package/dist/tool-handlers/generate-audio.js +23 -47
  36. package/dist/tool-handlers/generate-image-dedicated.d.ts +1 -11
  37. package/dist/tool-handlers/generate-image-dedicated.js +65 -87
  38. package/dist/tool-handlers/generate-image-input.d.ts +0 -1
  39. package/dist/tool-handlers/generate-image-input.js +2 -18
  40. package/dist/tool-handlers/generate-image.d.ts +2 -45
  41. package/dist/tool-handlers/generate-image.js +16 -37
  42. package/dist/tool-handlers/generate-video.d.ts +1 -7
  43. package/dist/tool-handlers/generate-video.js +34 -142
  44. package/dist/tool-handlers/health-check.d.ts +1 -9
  45. package/dist/tool-handlers/health-check.js +1 -12
  46. package/dist/tool-handlers/image-source.d.ts +14 -0
  47. package/dist/tool-handlers/image-source.js +23 -0
  48. package/dist/tool-handlers/image-utils.d.ts +5 -0
  49. package/dist/tool-handlers/image-utils.js +23 -0
  50. package/dist/tool-handlers/openai-withresponse.d.ts +1 -10
  51. package/dist/tool-handlers/openai-withresponse.js +0 -7
  52. package/dist/tool-handlers/openrouter-errors.d.ts +2 -18
  53. package/dist/tool-handlers/openrouter-errors.js +2 -34
  54. package/dist/tool-handlers/path-safety.d.ts +15 -14
  55. package/dist/tool-handlers/path-safety.js +61 -55
  56. package/dist/tool-handlers/path-utils.d.ts +2 -0
  57. package/dist/tool-handlers/path-utils.js +7 -0
  58. package/dist/tool-handlers/provider-routing.d.ts +0 -9
  59. package/dist/tool-handlers/provider-routing.js +1 -14
  60. package/dist/tool-handlers/rerank.js +0 -2
  61. package/dist/tool-handlers/search-models.d.ts +0 -6
  62. package/dist/tool-handlers/speech-to-text.js +3 -21
  63. package/dist/tool-handlers/structured-output.d.ts +1 -4
  64. package/dist/tool-handlers/structured-output.js +2 -16
  65. package/dist/tool-handlers/text-to-speech.d.ts +1 -11
  66. package/dist/tool-handlers/text-to-speech.js +20 -41
  67. package/dist/tool-handlers/tool-result-payload.d.ts +47 -0
  68. package/dist/tool-handlers/tool-result-payload.js +96 -0
  69. package/dist/tool-handlers/video-utils.d.ts +1 -6
  70. package/dist/tool-handlers/video-utils.js +2 -17
  71. package/dist/tool-handlers.js +6 -739
  72. package/dist/tool-icons.d.ts +0 -6
  73. package/dist/tool-icons.js +1 -8
  74. package/dist/version.d.ts +1 -15
  75. package/dist/version.js +1 -15
  76. package/package.json +3 -2
@@ -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,23 @@
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>;
10
+ /** Resolve and validate a caller-supplied input path (read-only, no mkdir). */
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>;
18
+ export declare function isValidJobId(jobId: string): boolean;
12
19
  /**
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`).
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.
21
22
  */
22
- export declare function resolveSafeInputPath(inputPath: string): Promise<string>;
23
+ export declare function resolveSafeJobStatusPath(jobsDir: string, jobId: string): Promise<string | null>;
@@ -1,34 +1,23 @@
1
- /**
2
- * Output-path sandbox. Tools that write files (`generate_image`,
3
- * `generate_audio`, future `generate_video`) route their `save_path`
4
- * through `resolveSafeOutputPath` so an untrusted MCP caller cannot
5
- * traverse outside the configured output root.
6
- *
7
- * Root resolution order:
8
- * 1. `OPENROUTER_OUTPUT_DIR` env var (if set and non-empty).
9
- * 2. `process.cwd()`.
10
- *
11
- * Set `OPENROUTER_ALLOW_UNSAFE_PATHS=1` to disable the sandbox entirely
12
- * (legacy v2 behavior). This is discouraged — document the trade-off
13
- * where it appears in user configs.
14
- */
15
1
  import path from 'node:path';
16
2
  import { promises as fs } from 'node:fs';
17
3
  import os from 'node:os';
4
+ import { ErrorCode, toolErrorFrom } from '../errors.js';
18
5
  /**
19
- * Resolve the output root directory. On Windows, `process.cwd()` can be
20
- * a system directory (e.g. `C:\Windows\System32`) when spawned by MCP
21
- * clients without a working directory override. We detect that case and
22
- * fall back to a writable temp directory to avoid EPERM errors.
6
+ * Path sandboxes for MCP caller-supplied input/output paths.
7
+ * Set `OPENROUTER_ALLOW_UNSAFE_PATHS=1` to disable (legacy v2 behavior).
8
+ *
9
+ * Output paths: walk to the first existing ancestor, realpath it, and reject
10
+ * escapes before mkdir. Re-realpath the parent after mkdir to catch symlink traversal.
11
+ *
12
+ * Input paths: realpath when the file exists; otherwise reject via resolved prefix
13
+ * so `../escape` fails without leaking ENOENT.
23
14
  */
15
+ /** Output root: OPENROUTER_OUTPUT_DIR, or cwd (with Windows system-dir fallback). */
24
16
  function getOutputRoot() {
25
17
  const override = process.env.OPENROUTER_OUTPUT_DIR;
26
18
  if (override && override.length > 0)
27
19
  return path.resolve(override);
28
20
  const cwd = process.cwd();
29
- // On Windows, avoid using system directories as the default output root.
30
- // Common non-writable defaults when MCP clients spawn without a cwd:
31
- // C:\Windows\System32, C:\Windows, C:\Program Files\...
32
21
  if (process.platform === 'win32') {
33
22
  const cwdLower = cwd.toLowerCase().replace(/\\/g, '/');
34
23
  if (cwdLower.startsWith('c:/windows') ||
@@ -51,11 +40,8 @@ export class UnsafeOutputPathError extends Error {
51
40
  }
52
41
  }
53
42
  /**
54
- * Resolve and validate a caller-supplied output path. Creates the parent
55
- * directory if needed. Returns the absolute path that is safe to write.
56
- *
57
- * Throws `UnsafeOutputPathError` when the resolved path escapes the root
58
- * (traversal attempt) and the sandbox is enabled.
43
+ * Resolve and validate a caller-supplied output path.
44
+ * Throws `UnsafeOutputPathError` on traversal attempts.
59
45
  */
60
46
  export async function resolveSafeOutputPath(savePath) {
61
47
  if (isUnsafeMode()) {
@@ -65,26 +51,18 @@ export async function resolveSafeOutputPath(savePath) {
65
51
  }
66
52
  const root = getOutputRoot();
67
53
  const rootReal = await fs.realpath(root).catch(() => path.resolve(root));
68
- // Resolve relative paths against the real root; absolute paths stay as
69
- // given so we can check them against the root prefix below.
70
54
  const candidate = path.isAbsolute(savePath)
71
55
  ? path.resolve(savePath)
72
56
  : path.resolve(rootReal, savePath);
73
- // Walk up from the candidate dir to find the first component that exists
74
- // so we can realpath it. This lets us create new subdirectories under the
75
- // root while still catching symlink-based traversal.
76
57
  const withSep = rootReal.endsWith(path.sep) ? rootReal : rootReal + path.sep;
77
58
  const candidateDir = path.dirname(candidate);
78
59
  const existingAncestor = await findExistingAncestor(candidateDir);
79
60
  const ancestorReal = await fs.realpath(existingAncestor);
80
- // The realpath of the first-existing ancestor MUST be within the root.
81
61
  if (!(ancestorReal === rootReal || ancestorReal.startsWith(withSep))) {
82
62
  throw new UnsafeOutputPathError(`save_path resolves outside OPENROUTER_OUTPUT_DIR (${rootReal}). ` +
83
63
  `Set OPENROUTER_OUTPUT_DIR to a wider root or OPENROUTER_ALLOW_UNSAFE_PATHS=1 to disable this check.`);
84
64
  }
85
- // Safe to create missing intermediate directories now.
86
65
  await fs.mkdir(candidateDir, { recursive: true });
87
- // Re-realpath the final parent in case mkdir traversed a symlink.
88
66
  const parentReal = await fs.realpath(candidateDir);
89
67
  if (!(parentReal === rootReal || parentReal.startsWith(withSep))) {
90
68
  throw new UnsafeOutputPathError(`save_path escapes OPENROUTER_OUTPUT_DIR via symlink (${rootReal}).`);
@@ -106,11 +84,7 @@ async function findExistingAncestor(dir) {
106
84
  }
107
85
  }
108
86
  }
109
- /**
110
- * Root-resolution for caller-supplied INPUT paths. Prefers
111
- * `OPENROUTER_INPUT_DIR`, then `OPENROUTER_OUTPUT_DIR`, then `process.cwd()`.
112
- * On Windows, applies the same system-directory detection as getOutputRoot.
113
- */
87
+ /** Input root: OPENROUTER_INPUT_DIR, then OPENROUTER_OUTPUT_DIR, then cwd. */
114
88
  function getInputRoot() {
115
89
  const inputDir = process.env.OPENROUTER_INPUT_DIR;
116
90
  if (inputDir && inputDir.length > 0)
@@ -129,16 +103,7 @@ function getInputRoot() {
129
103
  }
130
104
  return cwd;
131
105
  }
132
- /**
133
- * Resolve and validate a caller-supplied INPUT path. Unlike
134
- * `resolveSafeOutputPath`, this never creates directories — it only
135
- * confirms the path lives inside the input sandbox and returns the
136
- * absolute path the caller can `fs.readFile` from.
137
- *
138
- * Accepts the same `OPENROUTER_ALLOW_UNSAFE_PATHS=1` legacy bypass.
139
- * Throws `UnsafeOutputPathError` on traversal attempts (re-used type so
140
- * handlers map errors uniformly to `ErrorCode.UNSAFE_PATH`).
141
- */
106
+ /** Resolve and validate a caller-supplied input path (read-only, no mkdir). */
142
107
  export async function resolveSafeInputPath(inputPath) {
143
108
  if (isUnsafeMode()) {
144
109
  return path.resolve(inputPath);
@@ -149,12 +114,6 @@ export async function resolveSafeInputPath(inputPath) {
149
114
  const abs = path.isAbsolute(inputPath)
150
115
  ? path.resolve(inputPath)
151
116
  : path.resolve(rootReal, inputPath);
152
- // Prefer realpath for the prefix check so callers can pass paths
153
- // through symlinks (e.g. macOS `/var/...` → `/private/var/...`)
154
- // without us rejecting them. If the file doesn't exist yet, fall
155
- // back to a textual check on the resolved path so traversal
156
- // (`../escape.png`) is still rejected with the right error type
157
- // instead of leaking an ENOENT to the caller.
158
117
  let canonical;
159
118
  try {
160
119
  canonical = await fs.realpath(abs);
@@ -167,3 +126,50 @@ export async function resolveSafeInputPath(inputPath) {
167
126
  }
168
127
  return abs;
169
128
  }
129
+ export function isToolErrorResult(result) {
130
+ return 'isError' in result;
131
+ }
132
+ /** Resolve optional save_path; returns a tool error result on sandbox violation. */
133
+ export async function resolveOptionalOutputPath(savePath) {
134
+ if (!savePath)
135
+ return { path: null };
136
+ try {
137
+ return { path: await resolveSafeOutputPath(savePath) };
138
+ }
139
+ catch (err) {
140
+ if (err instanceof UnsafeOutputPathError)
141
+ return toolErrorFrom(ErrorCode.UNSAFE_PATH, err);
142
+ return toolErrorFrom(ErrorCode.INTERNAL, err);
143
+ }
144
+ }
145
+ const JOB_ID_PATTERN = /^chat_[a-zA-Z0-9_-]{1,128}$/;
146
+ export function isValidJobId(jobId) {
147
+ if (!JOB_ID_PATTERN.test(jobId))
148
+ return false;
149
+ if (jobId.includes('..') || jobId.includes('/') || jobId.includes('\\'))
150
+ return false;
151
+ return true;
152
+ }
153
+ /**
154
+ * Resolve async-chat job status.json under OPENROUTER_OUTPUT_DIR/openrouter-jobs/.
155
+ * Uses realpath when the job directory exists to block symlink escapes.
156
+ */
157
+ export async function resolveSafeJobStatusPath(jobsDir, jobId) {
158
+ if (!isValidJobId(jobId))
159
+ return null;
160
+ const rootReal = await fs.realpath(jobsDir).catch(() => path.resolve(jobsDir));
161
+ const withSep = rootReal.endsWith(path.sep) ? rootReal : rootReal + path.sep;
162
+ const jobDirCandidate = path.resolve(jobsDir, jobId);
163
+ let jobDirReal;
164
+ try {
165
+ jobDirReal = await fs.realpath(jobDirCandidate);
166
+ }
167
+ catch {
168
+ if (!(jobDirCandidate === rootReal || jobDirCandidate.startsWith(withSep)))
169
+ return null;
170
+ return path.join(jobDirCandidate, 'status.json');
171
+ }
172
+ if (!(jobDirReal === rootReal || jobDirReal.startsWith(withSep)))
173
+ return null;
174
+ return path.join(jobDirReal, 'status.json');
175
+ }
@@ -0,0 +1,2 @@
1
+ /** Strip existing extension (if any) and append a new one. */
2
+ export declare function replaceExtension(filePath: string, newExt: string): string;
@@ -0,0 +1,7 @@
1
+ import { extname } from 'node:path';
2
+ /** Strip existing extension (if any) and append a new one. */
3
+ export function replaceExtension(filePath, newExt) {
4
+ const current = extname(filePath);
5
+ const base = current ? filePath.slice(0, -current.length) : filePath;
6
+ return `${base}.${newExt}`;
7
+ }
@@ -1,12 +1,3 @@
1
- /**
2
- * OpenRouter provider routing. Merges caller-supplied `provider` args on
3
- * top of env-var defaults and emits the `provider` object that goes into
4
- * `POST /chat/completions`. See
5
- * https://openrouter.ai/docs/features/provider-routing for the spec.
6
- *
7
- * Precedence: explicit tool arg > env var > unset. Empty arrays / empty
8
- * objects are dropped so we don't send noise to the API.
9
- */
10
1
  export type ProviderSort = 'price' | 'throughput' | 'latency';
11
2
  export type DataCollectionPolicy = 'allow' | 'deny';
12
3
  export interface ProviderRoutingOptions {
@@ -1,11 +1,5 @@
1
1
  /**
2
- * OpenRouter provider routing. Merges caller-supplied `provider` args on
3
- * top of env-var defaults and emits the `provider` object that goes into
4
- * `POST /chat/completions`. See
5
- * https://openrouter.ai/docs/features/provider-routing for the spec.
6
- *
7
- * Precedence: explicit tool arg > env var > unset. Empty arrays / empty
8
- * objects are dropped so we don't send noise to the API.
2
+ * OpenRouter provider routing — merges tool args over OPENROUTER_PROVIDER_* env defaults.
9
3
  */
10
4
  import { logger } from '../logger.js';
11
5
  function parseCsv(raw) {
@@ -21,9 +15,6 @@ function parseJsonArray(raw, name) {
21
15
  if (!raw)
22
16
  return undefined;
23
17
  const trimmed = raw.trim();
24
- // If the value looks like a JSON array (`[...]`), require it to BE valid JSON.
25
- // Otherwise fall back to CSV parsing — that way `a,b,c` works too, but a
26
- // malformed `[bogus]` doesn't silently become a single-element string array.
27
18
  if (trimmed.startsWith('[')) {
28
19
  try {
29
20
  const parsed = JSON.parse(trimmed);
@@ -84,10 +75,6 @@ export function readProviderDefaults() {
84
75
  out.order = order;
85
76
  }
86
77
  catch (err) {
87
- // Don't crash the server on a malformed env var — log once so an
88
- // operator notices instead of wondering why their ordering is being
89
- // ignored. All other OPENROUTER_PROVIDER_* fields follow the same
90
- // "silent drop" policy for consistency.
91
78
  logger.warn('OPENROUTER_PROVIDER_ORDER ignored', {
92
79
  err: err instanceof Error ? err.message : String(err),
93
80
  });
@@ -26,8 +26,6 @@ export async function handleRerankDocuments(request, apiClient) {
26
26
  catch (err) {
27
27
  return classifyUpstreamError(err, 'rerank');
28
28
  }
29
- // Normalize to a stable shape: always expose `score` (OpenRouter
30
- // providers sometimes return `relevance_score`, sometimes `score`).
31
29
  const normalized = (response.results ?? []).map((r) => {
32
30
  const score = typeof r.score === 'number' ? r.score : r.relevance_score;
33
31
  const out = { index: r.index, score };
@@ -9,12 +9,6 @@ export interface SearchModelsArgs {
9
9
  video?: boolean;
10
10
  };
11
11
  limit?: number;
12
- /**
13
- * Skip this many matching results before returning `limit`. Paired with
14
- * `limit` and the returned `next_offset` to let large model lists be
15
- * paged safely. Follows Phil Schmid's "paginate large results" best
16
- * practice.
17
- */
18
12
  offset?: number;
19
13
  }
20
14
  export declare function handleSearchModels(request: {
@@ -1,14 +1,8 @@
1
- /**
2
- * speech_to_text — uses OpenRouter's dedicated POST /api/v1/audio/transcriptions
3
- * endpoint (launched May 2026) for speech-to-text transcription. Faster and more
4
- * cost-efficient than routing through chat completions for pure transcription.
5
- *
6
- * Supported models: OpenAI Whisper-1, GPT-4o Transcribe, GPT-4o Mini Transcribe,
7
- * Mistral Voxtral Mini Transcribe.
8
- */
9
- import { promises as fs } from 'fs';
1
+ /** Dedicated POST /api/v1/audio/transcriptions — Whisper, GPT-4o Transcribe, Voxtral. */
2
+ import { promises as fs } from 'node:fs';
10
3
  import path from 'node:path';
11
4
  import { resolveSafeInputPath, UnsafeOutputPathError } from './path-safety.js';
5
+ import { fetchHttpResource } from './fetch-utils.js';
12
6
  import { ErrorCode, toolError, toolErrorFrom } from '../errors.js';
13
7
  import { SERVER_VERSION } from '../version.js';
14
8
  import { logger } from '../logger.js';
@@ -40,26 +34,17 @@ function audioFormatFromExt(ext) {
40
34
  return 'mp3';
41
35
  }
42
36
  }
43
- /**
44
- * Resolve audio input to base64 + format, supporting:
45
- * - data: URLs (pass through)
46
- * - http(s) URLs (fetch)
47
- * - local file paths (sandboxed read)
48
- */
49
37
  async function resolveAudioInput(audioPath) {
50
38
  const trimmed = audioPath.trim();
51
39
  if (!trimmed)
52
40
  throw new Error('audio_path is empty');
53
- // Data URL
54
41
  if (trimmed.startsWith('data:')) {
55
42
  const match = trimmed.match(/^data:audio\/([^;,]+)(?:;[^,]*)*;base64,(.+)$/);
56
43
  if (!match)
57
44
  throw new Error('Invalid audio data URL format');
58
45
  return { data: match[2], format: match[1] };
59
46
  }
60
- // HTTP URL
61
47
  if (/^https?:\/\//i.test(trimmed)) {
62
- const { fetchHttpResource } = await import('./fetch-utils.js');
63
48
  const { buffer, contentType } = await fetchHttpResource(trimmed, {
64
49
  timeoutMs: 60_000,
65
50
  maxBytes: 100 * 1024 * 1024,
@@ -68,7 +53,6 @@ async function resolveAudioInput(audioPath) {
68
53
  const format = contentType?.match(/audio\/(\w+)/)?.[1] || 'mp3';
69
54
  return { data: buffer.toString('base64'), format };
70
55
  }
71
- // Local file
72
56
  const abs = await resolveSafeInputPath(trimmed);
73
57
  const buf = await fs.readFile(abs);
74
58
  const ext = path.extname(abs);
@@ -90,7 +74,6 @@ export async function handleSpeechToText(request, apiClient) {
90
74
  language,
91
75
  response_format,
92
76
  });
93
- // Resolve audio input
94
77
  let audioInput;
95
78
  try {
96
79
  audioInput = await resolveAudioInput(audio_path);
@@ -103,7 +86,6 @@ export async function handleSpeechToText(request, apiClient) {
103
86
  return toolErrorFrom(ErrorCode.UPSTREAM_REFUSED, err);
104
87
  return toolErrorFrom(ErrorCode.INVALID_INPUT, err);
105
88
  }
106
- // Build request body
107
89
  const body = {
108
90
  model: model || DEFAULT_MODEL,
109
91
  input_audio: {
@@ -6,10 +6,7 @@ export interface StructuredResult<T = unknown> {
6
6
  structuredContent: T;
7
7
  _meta: Record<string, unknown>;
8
8
  }
9
- /**
10
- * Wrap a JSON-serializable object in the MCP-spec dual-representation
11
- * format. `meta` is merged on top of the default `server_version` stamp.
12
- */
9
+ /** Wrap JSON-serializable data in MCP dual-representation format. */
13
10
  export declare function buildStructuredResult<T>(data: T, meta?: Record<string, unknown>): StructuredResult<T>;
14
11
  /** Read typed JSON from an MCP tool result (structuredContent or legacy text). */
15
12
  export declare function readToolPayload<T = unknown>(result: {
@@ -1,20 +1,6 @@
1
- /**
2
- * Helper for building MCP tool responses that carry structured data
3
- * alongside the legacy text representation.
4
- *
5
- * Per MCP spec 2025-06-18 §5.2.6-7, when a tool has an `outputSchema`
6
- * the response SHOULD include `structuredContent` (the typed object)
7
- * AND, for backwards compatibility with clients that don't parse that
8
- * field, `content` with a serialized JSON text block.
9
- *
10
- * Consumers use `buildStructuredResult(data, meta?)` and get back the
11
- * full `{ content, structuredContent, _meta }` shape.
12
- */
1
+ /** Build MCP tool responses with `structuredContent` plus legacy JSON text. */
13
2
  import { SERVER_VERSION } from '../version.js';
14
- /**
15
- * Wrap a JSON-serializable object in the MCP-spec dual-representation
16
- * format. `meta` is merged on top of the default `server_version` stamp.
17
- */
3
+ /** Wrap JSON-serializable data in MCP dual-representation format. */
18
4
  export function buildStructuredResult(data, meta = {}) {
19
5
  return {
20
6
  content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
@@ -14,16 +14,6 @@ export declare function handleTextToSpeech(request: {
14
14
  arguments: TextToSpeechRequest;
15
15
  };
16
16
  }, apiClient: OpenRouterAPIClient): Promise<import("../errors.js").ToolErrorResult | {
17
- content: ({
18
- type: "text";
19
- text: string;
20
- mimeType?: undefined;
21
- data?: undefined;
22
- } | {
23
- type: "audio";
24
- mimeType: string;
25
- data: string;
26
- text?: undefined;
27
- })[];
17
+ content: import("./tool-result-payload.js").BinaryToolContent[];
28
18
  _meta: Record<string, unknown>;
29
19
  }>;
@@ -1,18 +1,13 @@
1
- /**
2
- * text_to_speech — uses OpenRouter's dedicated POST /api/v1/audio/speech
3
- * endpoint (launched May 2026) for text-to-speech. Faster and more cost-efficient
4
- * than routing through chat completions with audio modality.
5
- *
6
- * Supported providers: OpenAI (GPT-4o Mini TTS), Google (Gemini Flash TTS),
7
- * Mistral (Voxtral Mini TTS).
8
- */
9
- import { promises as fs } from 'fs';
10
- import { extname } from 'path';
11
- import { resolveSafeOutputPath, UnsafeOutputPathError } from './path-safety.js';
1
+ /** Dedicated POST /api/v1/audio/speech — OpenAI, Gemini Flash TTS, Voxtral. */
2
+ import { promises as fs } from 'node:fs';
3
+ import { extname } from 'node:path';
4
+ import { resolveOptionalOutputPath, isToolErrorResult } from './path-safety.js';
12
5
  import { ErrorCode, toolError, toolErrorFrom } from '../errors.js';
13
6
  import { SERVER_VERSION } from '../version.js';
14
7
  import { logger } from '../logger.js';
15
8
  import { classifyUpstreamError } from './openrouter-errors.js';
9
+ import { buildBinaryToolResult } from './tool-result-payload.js';
10
+ import { replaceExtension } from './path-utils.js';
16
11
  import { buildCacheHeaders } from './cache.js';
17
12
  const DEFAULT_MODEL = 'openai/gpt-4o-mini-tts-2025-12-15';
18
13
  const DEFAULT_VOICE = 'alloy';
@@ -33,19 +28,10 @@ export async function handleTextToSpeech(request, apiClient) {
33
28
  input_preview: input.slice(0, 80),
34
29
  save_path: save_path ? 'provided' : 'none',
35
30
  });
36
- // Resolve save path early
37
- let safeSavePath = null;
38
- if (save_path) {
39
- try {
40
- safeSavePath = await resolveSafeOutputPath(save_path);
41
- }
42
- catch (err) {
43
- if (err instanceof UnsafeOutputPathError)
44
- return toolErrorFrom(ErrorCode.UNSAFE_PATH, err);
45
- return toolErrorFrom(ErrorCode.INTERNAL, err);
46
- }
47
- }
48
- // Build request body
31
+ const savePathResult = await resolveOptionalOutputPath(save_path);
32
+ if (isToolErrorResult(savePathResult))
33
+ return savePathResult;
34
+ const safeSavePath = savePathResult.path;
49
35
  const body = {
50
36
  model: model || DEFAULT_MODEL,
51
37
  input,
@@ -67,7 +53,6 @@ export async function handleTextToSpeech(request, apiClient) {
67
53
  }
68
54
  const { buffer, contentType } = result;
69
55
  const mimeType = contentType.split(';')[0]?.trim() || 'audio/mpeg';
70
- // Determine file extension from format
71
56
  const ext = response_format || 'mp3';
72
57
  const baseMeta = {
73
58
  server_version: SERVER_VERSION,
@@ -77,9 +62,8 @@ export async function handleTextToSpeech(request, apiClient) {
77
62
  voice: voice || DEFAULT_VOICE,
78
63
  };
79
64
  if (safeSavePath) {
80
- // Ensure extension matches
81
65
  const currentExt = extname(safeSavePath).toLowerCase().slice(1);
82
- const actualPath = currentExt === ext ? safeSavePath : `${safeSavePath}.${ext}`;
66
+ const actualPath = currentExt === ext ? safeSavePath : replaceExtension(safeSavePath, ext);
83
67
  try {
84
68
  await fs.writeFile(actualPath, buffer);
85
69
  }
@@ -87,19 +71,14 @@ export async function handleTextToSpeech(request, apiClient) {
87
71
  return toolErrorFrom(ErrorCode.INTERNAL, err, 'Write');
88
72
  }
89
73
  baseMeta.save_path = actualPath;
90
- return {
91
- content: [
92
- { type: 'text', text: `Speech saved to: ${actualPath}` },
93
- { type: 'audio', mimeType, data: buffer.toString('base64') },
94
- ],
95
- _meta: baseMeta,
96
- };
74
+ return buildBinaryToolResult({ kind: 'audio', buffer, mimeType }, {
75
+ savedPath: actualPath,
76
+ summaryText: `Speech saved to: ${actualPath}`,
77
+ meta: baseMeta,
78
+ });
97
79
  }
98
- return {
99
- content: [
100
- { type: 'text', text: `Speech generated (${buffer.length} bytes, ${mimeType}).` },
101
- { type: 'audio', mimeType, data: buffer.toString('base64') },
102
- ],
103
- _meta: baseMeta,
104
- };
80
+ return buildBinaryToolResult({ kind: 'audio', buffer, mimeType }, {
81
+ prefixText: `Speech generated (${buffer.length} bytes, ${mimeType}).`,
82
+ meta: baseMeta,
83
+ });
105
84
  }