@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.
- package/README.md +78 -53
- package/dist/errors.d.ts +3 -12
- package/dist/errors.js +2 -6
- package/dist/index.js +2 -10
- package/dist/logger.d.ts +1 -18
- package/dist/logger.js +0 -7
- package/dist/model-cache.d.ts +1 -20
- package/dist/model-cache.js +1 -20
- package/dist/openrouter-api.d.ts +4 -18
- package/dist/openrouter-api.js +4 -20
- package/dist/openrouter-openai-client.d.ts +9 -0
- package/dist/openrouter-openai-client.js +15 -0
- package/dist/tool-definitions.d.ts +1 -0
- package/dist/tool-definitions.js +719 -0
- package/dist/tool-handlers/analyze-audio.d.ts +0 -5
- package/dist/tool-handlers/analyze-image.d.ts +0 -6
- package/dist/tool-handlers/analyze-image.js +1 -8
- package/dist/tool-handlers/analyze-video.d.ts +0 -5
- package/dist/tool-handlers/analyze-video.js +0 -7
- package/dist/tool-handlers/async-chat.d.ts +17 -13
- package/dist/tool-handlers/async-chat.js +49 -65
- package/dist/tool-handlers/audio-utils.d.ts +1 -4
- package/dist/tool-handlers/audio-utils.js +4 -14
- package/dist/tool-handlers/cache.d.ts +2 -18
- package/dist/tool-handlers/cache.js +1 -19
- package/dist/tool-handlers/chat-completion.d.ts +2 -30
- package/dist/tool-handlers/chat-completion.js +13 -33
- package/dist/tool-handlers/chat-request.d.ts +25 -0
- package/dist/tool-handlers/chat-request.js +38 -0
- package/dist/tool-handlers/completion-utils.d.ts +1 -18
- package/dist/tool-handlers/completion-utils.js +0 -2
- package/dist/tool-handlers/fetch-utils.d.ts +2 -18
- package/dist/tool-handlers/fetch-utils.js +3 -51
- package/dist/tool-handlers/generate-audio.d.ts +3 -37
- package/dist/tool-handlers/generate-audio.js +23 -47
- package/dist/tool-handlers/generate-image-dedicated.d.ts +1 -11
- package/dist/tool-handlers/generate-image-dedicated.js +65 -87
- package/dist/tool-handlers/generate-image-input.d.ts +0 -1
- package/dist/tool-handlers/generate-image-input.js +2 -18
- package/dist/tool-handlers/generate-image.d.ts +2 -45
- package/dist/tool-handlers/generate-image.js +16 -37
- package/dist/tool-handlers/generate-video.d.ts +1 -7
- package/dist/tool-handlers/generate-video.js +34 -142
- package/dist/tool-handlers/health-check.d.ts +1 -9
- package/dist/tool-handlers/health-check.js +1 -12
- package/dist/tool-handlers/image-source.d.ts +14 -0
- package/dist/tool-handlers/image-source.js +23 -0
- package/dist/tool-handlers/image-utils.d.ts +5 -0
- package/dist/tool-handlers/image-utils.js +23 -0
- package/dist/tool-handlers/openai-withresponse.d.ts +1 -10
- package/dist/tool-handlers/openai-withresponse.js +0 -7
- package/dist/tool-handlers/openrouter-errors.d.ts +2 -18
- package/dist/tool-handlers/openrouter-errors.js +2 -34
- package/dist/tool-handlers/path-safety.d.ts +15 -14
- package/dist/tool-handlers/path-safety.js +61 -55
- package/dist/tool-handlers/path-utils.d.ts +2 -0
- package/dist/tool-handlers/path-utils.js +7 -0
- package/dist/tool-handlers/provider-routing.d.ts +0 -9
- package/dist/tool-handlers/provider-routing.js +1 -14
- package/dist/tool-handlers/rerank.js +0 -2
- package/dist/tool-handlers/search-models.d.ts +0 -6
- package/dist/tool-handlers/speech-to-text.js +3 -21
- package/dist/tool-handlers/structured-output.d.ts +1 -4
- package/dist/tool-handlers/structured-output.js +2 -16
- package/dist/tool-handlers/text-to-speech.d.ts +1 -11
- package/dist/tool-handlers/text-to-speech.js +20 -41
- package/dist/tool-handlers/tool-result-payload.d.ts +47 -0
- package/dist/tool-handlers/tool-result-payload.js +96 -0
- package/dist/tool-handlers/video-utils.d.ts +1 -6
- package/dist/tool-handlers/video-utils.js +2 -17
- package/dist/tool-handlers.js +6 -739
- package/dist/tool-icons.d.ts +0 -6
- package/dist/tool-icons.js +1 -8
- package/dist/version.d.ts +1 -15
- package/dist/version.js +1 -15
- package/package.json +3 -2
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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.
|
|
6
|
-
*
|
|
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
|
|
14
|
-
*
|
|
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
|
|
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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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.
|
|
55
|
-
*
|
|
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,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
|
|
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
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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 :
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
}
|