@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.
- package/README.md +60 -52
- package/dist/errors.d.ts +3 -12
- package/dist/errors.js +2 -6
- package/dist/index.js +11 -11
- 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/tool-definitions.d.ts +1 -0
- package/dist/tool-definitions.js +708 -0
- package/dist/tool-descriptions.js +6 -8
- 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 +43 -66
- 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.js +11 -21
- package/dist/tool-handlers/generate-image-dedicated.js +17 -65
- 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.js +8 -15
- package/dist/tool-handlers/generate-video.d.ts +1 -7
- package/dist/tool-handlers/generate-video.js +24 -109
- 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 +10 -15
- package/dist/tool-handlers/path-safety.js +30 -55
- 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 +19 -29
- 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.js +8 -26
- package/dist/tool-handlers/video-utils.d.ts +1 -6
- package/dist/tool-handlers/video-utils.js +2 -17
- package/dist/tool-handlers.js +9 -730
- package/dist/tool-icons.d.ts +9 -0
- package/dist/tool-icons.js +52 -0
- package/dist/version.d.ts +1 -15
- package/dist/version.js +1 -15
- package/package.json +3 -2
|
@@ -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,19 @@ 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
|
+
}
|
|
@@ -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';
|
|
@@ -20,38 +14,37 @@ const VALID_RESPONSE_FORMATS = new Set(['json', 'text', 'srt', 'verbose_json', '
|
|
|
20
14
|
function audioFormatFromExt(ext) {
|
|
21
15
|
const normalized = ext.toLowerCase().replace('.', '');
|
|
22
16
|
switch (normalized) {
|
|
23
|
-
case 'mp3':
|
|
17
|
+
case 'mp3':
|
|
18
|
+
return 'mp3';
|
|
24
19
|
case 'mp4':
|
|
25
|
-
case 'm4a':
|
|
26
|
-
|
|
27
|
-
case '
|
|
20
|
+
case 'm4a':
|
|
21
|
+
return 'mp4';
|
|
22
|
+
case 'wav':
|
|
23
|
+
return 'wav';
|
|
24
|
+
case 'flac':
|
|
25
|
+
return 'flac';
|
|
28
26
|
case 'ogg':
|
|
29
|
-
case 'oga':
|
|
30
|
-
|
|
31
|
-
case '
|
|
32
|
-
|
|
27
|
+
case 'oga':
|
|
28
|
+
return 'ogg';
|
|
29
|
+
case 'webm':
|
|
30
|
+
return 'webm';
|
|
31
|
+
case 'opus':
|
|
32
|
+
return 'opus';
|
|
33
|
+
default:
|
|
34
|
+
return 'mp3';
|
|
33
35
|
}
|
|
34
36
|
}
|
|
35
|
-
/**
|
|
36
|
-
* Resolve audio input to base64 + format, supporting:
|
|
37
|
-
* - data: URLs (pass through)
|
|
38
|
-
* - http(s) URLs (fetch)
|
|
39
|
-
* - local file paths (sandboxed read)
|
|
40
|
-
*/
|
|
41
37
|
async function resolveAudioInput(audioPath) {
|
|
42
38
|
const trimmed = audioPath.trim();
|
|
43
39
|
if (!trimmed)
|
|
44
40
|
throw new Error('audio_path is empty');
|
|
45
|
-
// Data URL
|
|
46
41
|
if (trimmed.startsWith('data:')) {
|
|
47
42
|
const match = trimmed.match(/^data:audio\/([^;,]+)(?:;[^,]*)*;base64,(.+)$/);
|
|
48
43
|
if (!match)
|
|
49
44
|
throw new Error('Invalid audio data URL format');
|
|
50
45
|
return { data: match[2], format: match[1] };
|
|
51
46
|
}
|
|
52
|
-
// HTTP URL
|
|
53
47
|
if (/^https?:\/\//i.test(trimmed)) {
|
|
54
|
-
const { fetchHttpResource } = await import('./fetch-utils.js');
|
|
55
48
|
const { buffer, contentType } = await fetchHttpResource(trimmed, {
|
|
56
49
|
timeoutMs: 60_000,
|
|
57
50
|
maxBytes: 100 * 1024 * 1024,
|
|
@@ -60,7 +53,6 @@ async function resolveAudioInput(audioPath) {
|
|
|
60
53
|
const format = contentType?.match(/audio\/(\w+)/)?.[1] || 'mp3';
|
|
61
54
|
return { data: buffer.toString('base64'), format };
|
|
62
55
|
}
|
|
63
|
-
// Local file
|
|
64
56
|
const abs = await resolveSafeInputPath(trimmed);
|
|
65
57
|
const buf = await fs.readFile(abs);
|
|
66
58
|
const ext = path.extname(abs);
|
|
@@ -82,7 +74,6 @@ export async function handleSpeechToText(request, apiClient) {
|
|
|
82
74
|
language,
|
|
83
75
|
response_format,
|
|
84
76
|
});
|
|
85
|
-
// Resolve audio input
|
|
86
77
|
let audioInput;
|
|
87
78
|
try {
|
|
88
79
|
audioInput = await resolveAudioInput(audio_path);
|
|
@@ -95,7 +86,6 @@ export async function handleSpeechToText(request, apiClient) {
|
|
|
95
86
|
return toolErrorFrom(ErrorCode.UPSTREAM_REFUSED, err);
|
|
96
87
|
return toolErrorFrom(ErrorCode.INVALID_INPUT, err);
|
|
97
88
|
}
|
|
98
|
-
// Build request body
|
|
99
89
|
const body = {
|
|
100
90
|
model: model || DEFAULT_MODEL,
|
|
101
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) }],
|
|
@@ -1,14 +1,7 @@
|
|
|
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';
|
|
@@ -33,19 +26,10 @@ export async function handleTextToSpeech(request, apiClient) {
|
|
|
33
26
|
input_preview: input.slice(0, 80),
|
|
34
27
|
save_path: save_path ? 'provided' : 'none',
|
|
35
28
|
});
|
|
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
|
|
29
|
+
const savePathResult = await resolveOptionalOutputPath(save_path);
|
|
30
|
+
if (isToolErrorResult(savePathResult))
|
|
31
|
+
return savePathResult;
|
|
32
|
+
const safeSavePath = savePathResult.path;
|
|
49
33
|
const body = {
|
|
50
34
|
model: model || DEFAULT_MODEL,
|
|
51
35
|
input,
|
|
@@ -67,7 +51,6 @@ export async function handleTextToSpeech(request, apiClient) {
|
|
|
67
51
|
}
|
|
68
52
|
const { buffer, contentType } = result;
|
|
69
53
|
const mimeType = contentType.split(';')[0]?.trim() || 'audio/mpeg';
|
|
70
|
-
// Determine file extension from format
|
|
71
54
|
const ext = response_format || 'mp3';
|
|
72
55
|
const baseMeta = {
|
|
73
56
|
server_version: SERVER_VERSION,
|
|
@@ -77,7 +60,6 @@ export async function handleTextToSpeech(request, apiClient) {
|
|
|
77
60
|
voice: voice || DEFAULT_VOICE,
|
|
78
61
|
};
|
|
79
62
|
if (safeSavePath) {
|
|
80
|
-
// Ensure extension matches
|
|
81
63
|
const currentExt = extname(safeSavePath).toLowerCase().slice(1);
|
|
82
64
|
const actualPath = currentExt === ext ? safeSavePath : `${safeSavePath}.${ext}`;
|
|
83
65
|
try {
|
|
@@ -20,10 +20,5 @@ export interface VideoData {
|
|
|
20
20
|
mediaType: string;
|
|
21
21
|
sizeBytes: number;
|
|
22
22
|
}
|
|
23
|
-
/**
|
|
24
|
-
* Prepare a video from any source (data URL / HTTP URL / local file) as
|
|
25
|
-
* base64 + MIME. OpenRouter requires the client to send video as either a
|
|
26
|
-
* URL or a data URL; we always base64 it so the tool works regardless of
|
|
27
|
-
* provider quirks.
|
|
28
|
-
*/
|
|
23
|
+
/** Prepare video from data URL, HTTP URL, or sandboxed local file. */
|
|
29
24
|
export declare function prepareVideoData(source: string): Promise<VideoData>;
|
|
@@ -1,11 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Video format detection and fetch utilities. Mirrors the structure of
|
|
3
|
-
* `audio-utils.ts`: all network/security logic comes from `fetch-utils.ts`,
|
|
4
|
-
* this module owns format detection, base64 encoding, and MIME mapping.
|
|
5
|
-
*
|
|
6
|
-
* OpenRouter's video-understanding docs (accessed 2026-04-20) list four
|
|
7
|
-
* supported container formats: mp4, mpeg, mov, webm.
|
|
8
|
-
*/
|
|
1
|
+
/** Video format detection and fetch utilities. */
|
|
9
2
|
import path from 'node:path';
|
|
10
3
|
import { promises as fs } from 'node:fs';
|
|
11
4
|
import { readEnvInt, fetchHttpResource, parseBase64DataUrl } from './fetch-utils.js';
|
|
@@ -107,14 +100,8 @@ export function detectVideoFormat(buffer) {
|
|
|
107
100
|
}
|
|
108
101
|
return undefined;
|
|
109
102
|
}
|
|
110
|
-
/**
|
|
111
|
-
* Prepare a video from any source (data URL / HTTP URL / local file) as
|
|
112
|
-
* base64 + MIME. OpenRouter requires the client to send video as either a
|
|
113
|
-
* URL or a data URL; we always base64 it so the tool works regardless of
|
|
114
|
-
* provider quirks.
|
|
115
|
-
*/
|
|
103
|
+
/** Prepare video from data URL, HTTP URL, or sandboxed local file. */
|
|
116
104
|
export async function prepareVideoData(source) {
|
|
117
|
-
// --- data URL ---
|
|
118
105
|
if (source.startsWith('data:')) {
|
|
119
106
|
const parsed = parseBase64DataUrl(source);
|
|
120
107
|
if (!parsed)
|
|
@@ -136,7 +123,6 @@ export async function prepareVideoData(source) {
|
|
|
136
123
|
sizeBytes: approxBytes,
|
|
137
124
|
};
|
|
138
125
|
}
|
|
139
|
-
// --- HTTP(S) URL ---
|
|
140
126
|
if (source.startsWith('http://') || source.startsWith('https://')) {
|
|
141
127
|
const { buffer, contentType } = await fetchHttpResource(source, {
|
|
142
128
|
timeoutMs: getFetchTimeoutMs(),
|
|
@@ -155,7 +141,6 @@ export async function prepareVideoData(source) {
|
|
|
155
141
|
sizeBytes: buffer.length,
|
|
156
142
|
};
|
|
157
143
|
}
|
|
158
|
-
// --- local file ---
|
|
159
144
|
const safe = await resolveSafeInputPath(source);
|
|
160
145
|
const buffer = await fs.readFile(safe);
|
|
161
146
|
const format = detectVideoFormat(buffer) ?? getVideoFormat(safe);
|