@stabgan/openrouter-mcp-multimodal 4.6.2 → 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 +22 -5
- package/dist/openrouter-openai-client.d.ts +9 -0
- package/dist/openrouter-openai-client.js +15 -0
- package/dist/tool-definitions.js +18 -7
- package/dist/tool-handlers/async-chat.js +8 -1
- package/dist/tool-handlers/generate-audio.d.ts +3 -37
- package/dist/tool-handlers/generate-audio.js +12 -26
- package/dist/tool-handlers/generate-image-dedicated.d.ts +1 -11
- package/dist/tool-handlers/generate-image-dedicated.js +49 -17
- package/dist/tool-handlers/generate-image.d.ts +2 -45
- package/dist/tool-handlers/generate-image.js +8 -22
- package/dist/tool-handlers/generate-video.js +10 -33
- package/dist/tool-handlers/path-safety.d.ts +6 -0
- package/dist/tool-handlers/path-safety.js +31 -0
- package/dist/tool-handlers/path-utils.d.ts +2 -0
- package/dist/tool-handlers/path-utils.js +7 -0
- package/dist/tool-handlers/text-to-speech.d.ts +1 -11
- package/dist/tool-handlers/text-to-speech.js +12 -15
- 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.js +2 -5
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
<a href="#examples">Examples</a> ·
|
|
33
33
|
<a href="#security">Security</a> ·
|
|
34
34
|
<a href="#development">Development</a> ·
|
|
35
|
+
<a href="#releasing">Releasing</a> ·
|
|
35
36
|
<a href="#faq">FAQ</a>
|
|
36
37
|
</p>
|
|
37
38
|
|
|
@@ -129,7 +130,7 @@ npx -y @stabgan/openrouter-mcp-multimodal
|
|
|
129
130
|
}
|
|
130
131
|
```
|
|
131
132
|
|
|
132
|
-
Pin a release: `"args": ["-y", "@stabgan/openrouter-mcp-multimodal@4.
|
|
133
|
+
Pin a release: `"args": ["-y", "@stabgan/openrouter-mcp-multimodal@4.7.0"]`
|
|
133
134
|
|
|
134
135
|
</details>
|
|
135
136
|
|
|
@@ -141,7 +142,7 @@ Install [uv](https://docs.astral.sh/uv/getting-started/installation/) (includes
|
|
|
141
142
|
```bash
|
|
142
143
|
export OPENROUTER_API_KEY=sk-or-v1-...
|
|
143
144
|
uvx mcp-server-openrouter-multimodal
|
|
144
|
-
# pin npm version: OPENROUTER_MCP_NPM_VERSION=4.
|
|
145
|
+
# pin npm version: OPENROUTER_MCP_NPM_VERSION=4.7.0 uvx mcp-server-openrouter-multimodal
|
|
145
146
|
```
|
|
146
147
|
|
|
147
148
|
```json
|
|
@@ -160,7 +161,7 @@ uvx mcp-server-openrouter-multimodal
|
|
|
160
161
|
|
|
161
162
|
**pipx equivalent:** `pipx run mcp-server-openrouter-multimodal`
|
|
162
163
|
|
|
163
|
-
Optional: `OPENROUTER_MCP_NPM_VERSION=4.
|
|
164
|
+
Optional: `OPENROUTER_MCP_NPM_VERSION=4.7.0` pins the underlying npm package.
|
|
164
165
|
|
|
165
166
|
</details>
|
|
166
167
|
|
|
@@ -241,7 +242,7 @@ Use `-i` (interactive stdio). Avoid `-t` (TTY corrupts MCP framing on some hosts
|
|
|
241
242
|
|
|
242
243
|
```bash
|
|
243
244
|
docker run --rm -i -e OPENROUTER_API_KEY=sk-or-v1-... \
|
|
244
|
-
ghcr.io/stabgan/openrouter-mcp-multimodal:4.
|
|
245
|
+
ghcr.io/stabgan/openrouter-mcp-multimodal:4.7.0
|
|
245
246
|
```
|
|
246
247
|
|
|
247
248
|
```json
|
|
@@ -492,8 +493,22 @@ Mock tests live under `src/__tests__/mock/` and cover handlers, path sandboxes,
|
|
|
492
493
|
```bash
|
|
493
494
|
npm run lint
|
|
494
495
|
npm run format:check
|
|
496
|
+
npm run version:check # package.json vs src/version.ts, server.json, pyproject.toml
|
|
495
497
|
```
|
|
496
498
|
|
|
499
|
+
## Releasing
|
|
500
|
+
|
|
501
|
+
Published artifacts (**npm**, **PyPI/uvx**, **Docker**, **GHCR**) all ship from the **same semver** on a git tag (`vX.Y.Z`). Pushing to `main` runs tests but does **not** publish to npm or PyPI.
|
|
502
|
+
|
|
503
|
+
**Normal flow:** merge conventional commits to `main` → [Release Please](https://github.com/googleapis/release-please) opens a Release PR → merge it → tag is created → CI publishes everywhere.
|
|
504
|
+
|
|
505
|
+
**Manual flow:** bump all version files → `npm run version:check` → `npm run ci` + smoke tests → commit → `git tag vX.Y.Z` → `git push origin vX.Y.Z`.
|
|
506
|
+
|
|
507
|
+
Full checklist, file list, CI secrets, and agent instructions:
|
|
508
|
+
|
|
509
|
+
- **[`docs/RELEASING.md`](docs/RELEASING.md)** — maintainer release guide
|
|
510
|
+
- **[`AGENTS.md`](AGENTS.md)** — quick reference for AI agents
|
|
511
|
+
|
|
497
512
|
## FAQ
|
|
498
513
|
|
|
499
514
|
### Do I need paid OpenRouter credits?
|
|
@@ -522,4 +537,6 @@ Apache 2.0 — see [LICENSE](./LICENSE).
|
|
|
522
537
|
|
|
523
538
|
## Contributing
|
|
524
539
|
|
|
525
|
-
Issues and PRs welcome. For large changes, open an issue first.
|
|
540
|
+
Issues and PRs welcome. For large changes, open an issue first.
|
|
541
|
+
|
|
542
|
+
Before submitting: run **`npm run ci`**. Use [Conventional Commits](https://www.conventionalcommits.org/) (`fix:`, `feat:`, etc.) so [Release Please](docs/RELEASING.md) can cut the next release. See **[`docs/RELEASING.md`](docs/RELEASING.md)** if you need to ship a version.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import OpenAI from 'openai';
|
|
2
|
+
declare const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
|
|
3
|
+
declare const OPENROUTER_ATTRIBUTION_HEADERS: {
|
|
4
|
+
readonly 'HTTP-Referer': "https://github.com/stabgan/openrouter-mcp-multimodal";
|
|
5
|
+
readonly 'X-Title': "openrouter-mcp-multimodal";
|
|
6
|
+
};
|
|
7
|
+
/** OpenAI SDK client configured for OpenRouter chat/completions endpoints. */
|
|
8
|
+
export declare function createOpenRouterOpenAIClient(apiKey: string): OpenAI;
|
|
9
|
+
export { OPENROUTER_BASE_URL, OPENROUTER_ATTRIBUTION_HEADERS };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import OpenAI from 'openai';
|
|
2
|
+
const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
|
|
3
|
+
const OPENROUTER_ATTRIBUTION_HEADERS = {
|
|
4
|
+
'HTTP-Referer': 'https://github.com/stabgan/openrouter-mcp-multimodal',
|
|
5
|
+
'X-Title': 'openrouter-mcp-multimodal',
|
|
6
|
+
};
|
|
7
|
+
/** OpenAI SDK client configured for OpenRouter chat/completions endpoints. */
|
|
8
|
+
export function createOpenRouterOpenAIClient(apiKey) {
|
|
9
|
+
return new OpenAI({
|
|
10
|
+
apiKey,
|
|
11
|
+
baseURL: OPENROUTER_BASE_URL,
|
|
12
|
+
defaultHeaders: { ...OPENROUTER_ATTRIBUTION_HEADERS },
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
export { OPENROUTER_BASE_URL, OPENROUTER_ATTRIBUTION_HEADERS };
|
package/dist/tool-definitions.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { TOOL_DESCRIPTIONS } from './tool-descriptions.js';
|
|
2
|
+
/** Shared JSON-schema fragment for save_path on generate/write tools. */
|
|
3
|
+
const SAVE_PATH_PROPERTY = {
|
|
4
|
+
type: 'string',
|
|
5
|
+
description: 'Write the artifact under OPENROUTER_OUTPUT_DIR (path-sandboxed). When set, the tool result is text-only with _meta.save_path — no inline media block. Without save_path, inline image/audio/video is returned only if under OPENROUTER_*_INLINE_MAX_BYTES (see .env.example).',
|
|
6
|
+
};
|
|
2
7
|
export const TOOL_DEFINITIONS = [
|
|
3
8
|
{
|
|
4
9
|
name: 'chat_completion',
|
|
@@ -378,7 +383,7 @@ export const TOOL_DEFINITIONS = [
|
|
|
378
383
|
},
|
|
379
384
|
image_size: { type: 'string', enum: ['0.5K', '1K', '2K', '4K'] },
|
|
380
385
|
max_tokens: { type: 'number', minimum: 1 },
|
|
381
|
-
save_path:
|
|
386
|
+
save_path: SAVE_PATH_PROPERTY,
|
|
382
387
|
input_images: { type: 'array', items: { type: 'string' } },
|
|
383
388
|
modalities: { type: 'array', items: { type: 'string' } },
|
|
384
389
|
},
|
|
@@ -436,7 +441,10 @@ export const TOOL_DEFINITIONS = [
|
|
|
436
441
|
items: { type: 'string' },
|
|
437
442
|
description: 'Reference images for image-to-image workflows. Each entry: local path, http(s) URL, or data URL.',
|
|
438
443
|
},
|
|
439
|
-
save_path: {
|
|
444
|
+
save_path: {
|
|
445
|
+
...SAVE_PATH_PROPERTY,
|
|
446
|
+
description: 'Save generated image to this path. ' + SAVE_PATH_PROPERTY.description,
|
|
447
|
+
},
|
|
440
448
|
provider: {
|
|
441
449
|
type: 'object',
|
|
442
450
|
description: 'Provider routing overrides (order, sort, allow_fallbacks, etc.).',
|
|
@@ -465,7 +473,7 @@ export const TOOL_DEFINITIONS = [
|
|
|
465
473
|
model: { type: 'string' },
|
|
466
474
|
voice: { type: 'string' },
|
|
467
475
|
format: { type: 'string' },
|
|
468
|
-
save_path:
|
|
476
|
+
save_path: SAVE_PATH_PROPERTY,
|
|
469
477
|
},
|
|
470
478
|
required: ['prompt'],
|
|
471
479
|
},
|
|
@@ -510,7 +518,10 @@ export const TOOL_DEFINITIONS = [
|
|
|
510
518
|
type: 'string',
|
|
511
519
|
description: 'Tone/style instructions (e.g. "speak in a warm, friendly tone"). OpenAI models only.',
|
|
512
520
|
},
|
|
513
|
-
save_path: {
|
|
521
|
+
save_path: {
|
|
522
|
+
...SAVE_PATH_PROPERTY,
|
|
523
|
+
description: 'Save audio to this path. ' + SAVE_PATH_PROPERTY.description,
|
|
524
|
+
},
|
|
514
525
|
cache: { type: 'boolean' },
|
|
515
526
|
cache_ttl: { type: 'string' },
|
|
516
527
|
cache_clear: { type: 'boolean' },
|
|
@@ -584,7 +595,7 @@ export const TOOL_DEFINITIONS = [
|
|
|
584
595
|
last_frame_image: { type: 'string' },
|
|
585
596
|
reference_images: { type: 'array', items: { type: 'string' } },
|
|
586
597
|
provider: { type: 'object' },
|
|
587
|
-
save_path:
|
|
598
|
+
save_path: SAVE_PATH_PROPERTY,
|
|
588
599
|
max_wait_ms: { type: 'number', minimum: 10000 },
|
|
589
600
|
poll_interval_ms: { type: 'number', minimum: 2000 },
|
|
590
601
|
},
|
|
@@ -614,7 +625,7 @@ export const TOOL_DEFINITIONS = [
|
|
|
614
625
|
aspect_ratio: { type: 'string' },
|
|
615
626
|
duration: { type: 'number', minimum: 1 },
|
|
616
627
|
seed: { type: 'number' },
|
|
617
|
-
save_path:
|
|
628
|
+
save_path: SAVE_PATH_PROPERTY,
|
|
618
629
|
max_wait_ms: { type: 'number', minimum: 10000 },
|
|
619
630
|
poll_interval_ms: { type: 'number', minimum: 2000 },
|
|
620
631
|
},
|
|
@@ -635,7 +646,7 @@ export const TOOL_DEFINITIONS = [
|
|
|
635
646
|
type: 'object',
|
|
636
647
|
properties: {
|
|
637
648
|
video_id: { type: 'string' },
|
|
638
|
-
save_path:
|
|
649
|
+
save_path: SAVE_PATH_PROPERTY,
|
|
639
650
|
},
|
|
640
651
|
required: ['video_id'],
|
|
641
652
|
},
|
|
@@ -5,6 +5,7 @@ import { ErrorCode, toolError } from '../errors.js';
|
|
|
5
5
|
import { SERVER_VERSION } from '../version.js';
|
|
6
6
|
import { logger } from '../logger.js';
|
|
7
7
|
import { extractCompletionText, buildCompletionMeta } from './completion-utils.js';
|
|
8
|
+
import { resolveSafeJobStatusPath, isValidJobId } from './path-safety.js';
|
|
8
9
|
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
9
10
|
import { DEFAULT_CHAT_MODEL, buildChatCompletionBody, buildChatCompletionRequestOpts, asOpenAIChatBody, readIncludeReasoningDefault, } from './chat-request.js';
|
|
10
11
|
const jobs = new Map();
|
|
@@ -44,8 +45,11 @@ export async function loadJobFromDisk(jobId) {
|
|
|
44
45
|
const dir = getJobsDir();
|
|
45
46
|
if (!dir)
|
|
46
47
|
return null;
|
|
48
|
+
const statusPath = await resolveSafeJobStatusPath(dir, jobId);
|
|
49
|
+
if (!statusPath)
|
|
50
|
+
return null;
|
|
47
51
|
try {
|
|
48
|
-
const raw = await fs.readFile(
|
|
52
|
+
const raw = await fs.readFile(statusPath, 'utf8');
|
|
49
53
|
return JSON.parse(raw);
|
|
50
54
|
}
|
|
51
55
|
catch {
|
|
@@ -148,6 +152,9 @@ export async function handleGetChatCompletionStatus(request) {
|
|
|
148
152
|
if (!jobId) {
|
|
149
153
|
return toolError(ErrorCode.INVALID_INPUT, 'job_id is required.');
|
|
150
154
|
}
|
|
155
|
+
if (!isValidJobId(jobId)) {
|
|
156
|
+
return toolError(ErrorCode.INVALID_INPUT, `Invalid job_id "${jobId}". Must start with chat_ and must not contain path separators.`);
|
|
157
|
+
}
|
|
151
158
|
const job = await resolveJob(jobId);
|
|
152
159
|
if (!job) {
|
|
153
160
|
const hint = getJobsDir()
|
|
@@ -22,46 +22,12 @@ export declare function detectAudioFormat(data: Buffer): {
|
|
|
22
22
|
mimeType: string;
|
|
23
23
|
};
|
|
24
24
|
export declare function wrapPcmInWav(pcmData: Buffer, sampleRate?: number): Buffer;
|
|
25
|
-
|
|
26
|
-
export declare function replaceExtension(filePath: string, newExt: string): string;
|
|
25
|
+
export { replaceExtension } from './path-utils.js';
|
|
27
26
|
export declare function handleGenerateAudio(request: {
|
|
28
27
|
params: {
|
|
29
28
|
arguments: GenerateAudioToolRequest;
|
|
30
29
|
};
|
|
31
30
|
}, openai: OpenAI): Promise<import("../errors.js").ToolErrorResult | {
|
|
32
|
-
content: (
|
|
33
|
-
|
|
34
|
-
text: string;
|
|
35
|
-
mimeType?: undefined;
|
|
36
|
-
data?: undefined;
|
|
37
|
-
} | {
|
|
38
|
-
type: "audio";
|
|
39
|
-
mimeType: string;
|
|
40
|
-
data: string;
|
|
41
|
-
text?: undefined;
|
|
42
|
-
})[];
|
|
43
|
-
_meta: {
|
|
44
|
-
server_version: string;
|
|
45
|
-
save_path: string;
|
|
46
|
-
mime: string;
|
|
47
|
-
size_bytes: number;
|
|
48
|
-
};
|
|
49
|
-
} | {
|
|
50
|
-
content: ({
|
|
51
|
-
type: "text";
|
|
52
|
-
text: string;
|
|
53
|
-
mimeType?: undefined;
|
|
54
|
-
data?: undefined;
|
|
55
|
-
} | {
|
|
56
|
-
type: "audio";
|
|
57
|
-
mimeType: string;
|
|
58
|
-
data: string;
|
|
59
|
-
text?: undefined;
|
|
60
|
-
})[];
|
|
61
|
-
_meta: {
|
|
62
|
-
server_version: string;
|
|
63
|
-
mime: string;
|
|
64
|
-
size_bytes: number;
|
|
65
|
-
save_path?: undefined;
|
|
66
|
-
};
|
|
31
|
+
content: import("./tool-result-payload.js").BinaryToolContent[];
|
|
32
|
+
_meta: Record<string, unknown>;
|
|
67
33
|
}>;
|
|
@@ -6,6 +6,8 @@ import { ErrorCode, toolError } from '../errors.js';
|
|
|
6
6
|
import { SERVER_VERSION } from '../version.js';
|
|
7
7
|
import { logger } from '../logger.js';
|
|
8
8
|
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
9
|
+
import { buildBinaryToolResult } from './tool-result-payload.js';
|
|
10
|
+
import { replaceExtension } from './path-utils.js';
|
|
9
11
|
const DEFAULT_MODEL = 'openai/gpt-audio';
|
|
10
12
|
const DEFAULT_VOICE = 'alloy';
|
|
11
13
|
const DEFAULT_FORMAT = 'pcm16';
|
|
@@ -79,12 +81,7 @@ export function detectAudioFormat(data) {
|
|
|
79
81
|
export function wrapPcmInWav(pcmData, sampleRate = DEFAULT_PCM_SAMPLE_RATE) {
|
|
80
82
|
return Buffer.concat([createWavHeader(pcmData.length, sampleRate), pcmData]);
|
|
81
83
|
}
|
|
82
|
-
|
|
83
|
-
export function replaceExtension(filePath, newExt) {
|
|
84
|
-
const current = extname(filePath);
|
|
85
|
-
const base = current ? filePath.slice(0, -current.length) : filePath;
|
|
86
|
-
return `${base}.${newExt}`;
|
|
87
|
-
}
|
|
84
|
+
export { replaceExtension } from './path-utils.js';
|
|
88
85
|
export async function handleGenerateAudio(request, openai) {
|
|
89
86
|
const { prompt, model, voice, format, save_path } = request.params.arguments ?? {
|
|
90
87
|
prompt: '',
|
|
@@ -148,7 +145,6 @@ export async function handleGenerateAudio(request, openai) {
|
|
|
148
145
|
detected.ext = 'wav';
|
|
149
146
|
detected.mimeType = 'audio/wav';
|
|
150
147
|
}
|
|
151
|
-
const returnBase64 = audioBuffer.toString('base64');
|
|
152
148
|
if (safeBase) {
|
|
153
149
|
const fileExt = extname(safeBase).toLowerCase().slice(1);
|
|
154
150
|
const actualSavePath = fileExt === detected.ext ? safeBase : replaceExtension(safeBase, detected.ext);
|
|
@@ -159,30 +155,20 @@ export async function handleGenerateAudio(request, openai) {
|
|
|
159
155
|
const result = transcript
|
|
160
156
|
? `Audio saved to: ${actualSavePath}${formatNote}\nTranscript: ${transcript}`
|
|
161
157
|
: `Audio saved to: ${actualSavePath}${formatNote}`;
|
|
162
|
-
return {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
],
|
|
167
|
-
_meta: {
|
|
158
|
+
return buildBinaryToolResult({ kind: 'audio', buffer: audioBuffer, mimeType: detected.mimeType }, {
|
|
159
|
+
savedPath: actualSavePath,
|
|
160
|
+
summaryText: result,
|
|
161
|
+
meta: {
|
|
168
162
|
server_version: SERVER_VERSION,
|
|
169
|
-
save_path: actualSavePath,
|
|
170
|
-
mime: detected.mimeType,
|
|
171
|
-
size_bytes: audioBuffer.length,
|
|
172
163
|
},
|
|
173
|
-
};
|
|
164
|
+
});
|
|
174
165
|
}
|
|
175
|
-
return {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
{ type: 'audio', mimeType: detected.mimeType, data: returnBase64 },
|
|
179
|
-
],
|
|
180
|
-
_meta: {
|
|
166
|
+
return buildBinaryToolResult({ kind: 'audio', buffer: audioBuffer, mimeType: detected.mimeType }, {
|
|
167
|
+
prefixText: transcript || 'Audio generated successfully.',
|
|
168
|
+
meta: {
|
|
181
169
|
server_version: SERVER_VERSION,
|
|
182
|
-
mime: detected.mimeType,
|
|
183
|
-
size_bytes: audioBuffer.length,
|
|
184
170
|
},
|
|
185
|
-
};
|
|
171
|
+
});
|
|
186
172
|
}
|
|
187
173
|
catch (err) {
|
|
188
174
|
return classifyUpstreamError(err, 'generate_audio (stream)');
|
|
@@ -17,16 +17,6 @@ export declare function handleGenerateImageDedicated(request: {
|
|
|
17
17
|
arguments: GenerateImageDedicatedRequest;
|
|
18
18
|
};
|
|
19
19
|
}, apiClient: OpenRouterAPIClient): Promise<import("../errors.js").ToolErrorResult | {
|
|
20
|
-
content: (
|
|
21
|
-
type: "text";
|
|
22
|
-
text: string;
|
|
23
|
-
mimeType?: undefined;
|
|
24
|
-
data?: undefined;
|
|
25
|
-
} | {
|
|
26
|
-
type: "image";
|
|
27
|
-
mimeType: string;
|
|
28
|
-
data: string;
|
|
29
|
-
text?: undefined;
|
|
30
|
-
})[];
|
|
20
|
+
content: import("./tool-result-payload.js").BinaryToolContent[];
|
|
31
21
|
_meta: Record<string, unknown>;
|
|
32
22
|
}>;
|
|
@@ -6,6 +6,8 @@ import { ErrorCode, toolError, toolErrorFrom } from '../errors.js';
|
|
|
6
6
|
import { SERVER_VERSION } from '../version.js';
|
|
7
7
|
import { logger } from '../logger.js';
|
|
8
8
|
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
9
|
+
import { buildBinaryToolResult } from './tool-result-payload.js';
|
|
10
|
+
import { fetchHttpResource, readEnvInt } from './fetch-utils.js';
|
|
9
11
|
import { buildCacheHeaders } from './cache.js';
|
|
10
12
|
const DEFAULT_MODEL = 'google/gemini-2.5-flash-image';
|
|
11
13
|
const VALID_RESOLUTIONS = new Set(['512', '0.5K', '1K', '2K', '4K']);
|
|
@@ -99,27 +101,57 @@ export async function handleGenerateImageDedicated(request, apiClient) {
|
|
|
99
101
|
baseMeta.usage = response.usage;
|
|
100
102
|
if (firstImage.revised_prompt)
|
|
101
103
|
baseMeta.revised_prompt = firstImage.revised_prompt;
|
|
102
|
-
if (safeSavePath
|
|
103
|
-
|
|
104
|
-
|
|
104
|
+
if (safeSavePath) {
|
|
105
|
+
let buffer = null;
|
|
106
|
+
if (imageData) {
|
|
107
|
+
try {
|
|
108
|
+
buffer = Buffer.from(imageData, 'base64');
|
|
109
|
+
if (buffer.length === 0)
|
|
110
|
+
buffer = null;
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
buffer = null;
|
|
114
|
+
}
|
|
105
115
|
}
|
|
106
|
-
|
|
107
|
-
|
|
116
|
+
if (buffer) {
|
|
117
|
+
try {
|
|
118
|
+
await fs.writeFile(safeSavePath, buffer);
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
return toolErrorFrom(ErrorCode.INTERNAL, err, 'Write');
|
|
122
|
+
}
|
|
123
|
+
baseMeta.save_path = safeSavePath;
|
|
124
|
+
return buildBinaryToolResult({ kind: 'image', buffer, mimeType }, {
|
|
125
|
+
savedPath: safeSavePath,
|
|
126
|
+
summaryText: `Image saved to: ${safeSavePath}`,
|
|
127
|
+
meta: baseMeta,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
if (firstImage.url) {
|
|
131
|
+
try {
|
|
132
|
+
const maxBytes = readEnvInt('OPENROUTER_IMAGE_MAX_DOWNLOAD_BYTES', 20 * 1024 * 1024, 1024);
|
|
133
|
+
const { buffer: fetched, contentType } = await fetchHttpResource(firstImage.url, {
|
|
134
|
+
maxBytes,
|
|
135
|
+
maxRedirects: 3,
|
|
136
|
+
timeoutMs: 30_000,
|
|
137
|
+
});
|
|
138
|
+
const resolvedMime = contentType?.split(';')[0]?.trim() || mimeType;
|
|
139
|
+
await fs.writeFile(safeSavePath, fetched);
|
|
140
|
+
baseMeta.save_path = safeSavePath;
|
|
141
|
+
return buildBinaryToolResult({ kind: 'image', buffer: fetched, mimeType: resolvedMime }, {
|
|
142
|
+
savedPath: safeSavePath,
|
|
143
|
+
summaryText: `Image saved to: ${safeSavePath}`,
|
|
144
|
+
meta: { ...baseMeta, mime: resolvedMime, image_url: firstImage.url },
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
return toolErrorFrom(ErrorCode.UPSTREAM_HTTP, err, 'Download image URL for save_path');
|
|
149
|
+
}
|
|
108
150
|
}
|
|
109
|
-
|
|
110
|
-
return {
|
|
111
|
-
content: [
|
|
112
|
-
{ type: 'text', text: `Image saved to: ${safeSavePath}` },
|
|
113
|
-
...(imageData ? [{ type: 'image', mimeType, data: imageData }] : []),
|
|
114
|
-
],
|
|
115
|
-
_meta: baseMeta,
|
|
116
|
-
};
|
|
151
|
+
return toolError(ErrorCode.UPSTREAM_REFUSED, 'Model returned no usable image data for save_path (empty b64_json and URL download unavailable).');
|
|
117
152
|
}
|
|
118
153
|
if (imageData) {
|
|
119
|
-
return {
|
|
120
|
-
content: [{ type: 'image', mimeType, data: imageData }],
|
|
121
|
-
_meta: baseMeta,
|
|
122
|
-
};
|
|
154
|
+
return buildBinaryToolResult({ kind: 'image', buffer: Buffer.from(imageData, 'base64'), mimeType }, { inlineOnly: true, meta: baseMeta });
|
|
123
155
|
}
|
|
124
156
|
return {
|
|
125
157
|
content: [{ type: 'text', text: `Image generated. URL: ${firstImage.url}` }],
|
|
@@ -14,49 +14,6 @@ export declare function handleGenerateImage(request: {
|
|
|
14
14
|
arguments: GenerateImageToolRequest;
|
|
15
15
|
};
|
|
16
16
|
}, openai: OpenAI): Promise<import("../errors.js").ToolErrorResult | {
|
|
17
|
-
content: (
|
|
18
|
-
|
|
19
|
-
text: string;
|
|
20
|
-
mimeType?: undefined;
|
|
21
|
-
data?: undefined;
|
|
22
|
-
} | {
|
|
23
|
-
type: "image";
|
|
24
|
-
mimeType: string;
|
|
25
|
-
data: string;
|
|
26
|
-
text?: undefined;
|
|
27
|
-
})[];
|
|
28
|
-
_meta: {
|
|
29
|
-
usage: {
|
|
30
|
-
prompt_tokens: number;
|
|
31
|
-
completion_tokens: number;
|
|
32
|
-
total_tokens: number;
|
|
33
|
-
};
|
|
34
|
-
server_version: string;
|
|
35
|
-
save_path: string;
|
|
36
|
-
mime: string;
|
|
37
|
-
} | {
|
|
38
|
-
usage?: undefined;
|
|
39
|
-
server_version: string;
|
|
40
|
-
save_path: string;
|
|
41
|
-
mime: string;
|
|
42
|
-
};
|
|
43
|
-
} | {
|
|
44
|
-
content: {
|
|
45
|
-
type: "image";
|
|
46
|
-
mimeType: string;
|
|
47
|
-
data: string;
|
|
48
|
-
}[];
|
|
49
|
-
_meta: {
|
|
50
|
-
usage: {
|
|
51
|
-
prompt_tokens: number;
|
|
52
|
-
completion_tokens: number;
|
|
53
|
-
total_tokens: number;
|
|
54
|
-
};
|
|
55
|
-
server_version: string;
|
|
56
|
-
mime: string;
|
|
57
|
-
} | {
|
|
58
|
-
usage?: undefined;
|
|
59
|
-
server_version: string;
|
|
60
|
-
mime: string;
|
|
61
|
-
};
|
|
17
|
+
content: import("./tool-result-payload.js").BinaryToolContent[];
|
|
18
|
+
_meta: Record<string, unknown>;
|
|
62
19
|
}>;
|
|
@@ -7,6 +7,7 @@ import { ErrorCode, toolError, toolErrorFrom } from '../errors.js';
|
|
|
7
7
|
import { SERVER_VERSION } from '../version.js';
|
|
8
8
|
import { logger } from '../logger.js';
|
|
9
9
|
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
10
|
+
import { buildBinaryToolResult } from './tool-result-payload.js';
|
|
10
11
|
const DEFAULT_MODEL = 'google/gemini-2.5-flash-image';
|
|
11
12
|
const VALID_ASPECT_RATIOS = new Set([
|
|
12
13
|
'1:1',
|
|
@@ -115,28 +116,13 @@ function buildImageSuccessResult(base64, usage, savePath) {
|
|
|
115
116
|
},
|
|
116
117
|
}
|
|
117
118
|
: {};
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
server_version: SERVER_VERSION,
|
|
126
|
-
save_path: savePath,
|
|
127
|
-
mime: base64.mime,
|
|
128
|
-
...usageMeta,
|
|
129
|
-
},
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
return {
|
|
133
|
-
content: [{ type: 'image', mimeType: base64.mime, data: base64.data }],
|
|
134
|
-
_meta: {
|
|
135
|
-
server_version: SERVER_VERSION,
|
|
136
|
-
mime: base64.mime,
|
|
137
|
-
...usageMeta,
|
|
138
|
-
},
|
|
139
|
-
};
|
|
119
|
+
const buffer = Buffer.from(base64.data, 'base64');
|
|
120
|
+
return buildBinaryToolResult({ kind: 'image', buffer, mimeType: base64.mime }, {
|
|
121
|
+
savedPath: savePath ?? null,
|
|
122
|
+
inlineOnly: !savePath,
|
|
123
|
+
summaryText: savePath ? `Image saved to: ${savePath}` : undefined,
|
|
124
|
+
meta: { server_version: SERVER_VERSION, ...usageMeta },
|
|
125
|
+
});
|
|
140
126
|
}
|
|
141
127
|
function extractBase64(message) {
|
|
142
128
|
const images = message.images;
|
|
@@ -7,11 +7,11 @@ import { resolveOptionalOutputPath, isToolErrorResult, UnsafeOutputPathError, }
|
|
|
7
7
|
import { resolveImageBase64 } from './image-source.js';
|
|
8
8
|
import { readEnvInt } from './fetch-utils.js';
|
|
9
9
|
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
10
|
+
import { buildBinaryToolResult } from './tool-result-payload.js';
|
|
10
11
|
const FALLBACK_MODEL = 'google/veo-3.1';
|
|
11
12
|
const DEFAULT_POLL_INTERVAL_MS = 15_000;
|
|
12
13
|
const DEFAULT_MAX_WAIT_MS = 10 * 60_000;
|
|
13
14
|
const MIN_POLL_INTERVAL_MS = 50; // just to avoid a 0ms busy-loop if a caller omits
|
|
14
|
-
const INLINE_RETURN_CEILING_BYTES = 10 * 1024 * 1024;
|
|
15
15
|
/** Models deprecated by OpenAI — removal date: 2026-09-24. */
|
|
16
16
|
const SORA_DEPRECATED_MODELS = new Set([
|
|
17
17
|
'openai/sora-2',
|
|
@@ -37,9 +37,6 @@ function checkSoraDeprecation(model) {
|
|
|
37
37
|
`Your request will still be attempted, but may fail. Recommended alternatives:\n` +
|
|
38
38
|
SORA_ALTERNATIVES.map((a) => ` • ${a}`).join('\n'));
|
|
39
39
|
}
|
|
40
|
-
function getMaxInlineBytes() {
|
|
41
|
-
return readEnvInt('OPENROUTER_VIDEO_INLINE_MAX_BYTES', INLINE_RETURN_CEILING_BYTES, 4096);
|
|
42
|
-
}
|
|
43
40
|
function getDefaultPollInterval() {
|
|
44
41
|
return readEnvInt('OPENROUTER_VIDEO_POLL_INTERVAL_MS', DEFAULT_POLL_INTERVAL_MS, MIN_POLL_INTERVAL_MS);
|
|
45
42
|
}
|
|
@@ -171,36 +168,16 @@ async function finalizeCompletedJob(apiClient, status, savePath) {
|
|
|
171
168
|
await fs.writeFile(finalPath, buffer);
|
|
172
169
|
baseMeta.save_path = finalPath;
|
|
173
170
|
const summaryNote = finalPath !== savePath ? ` (detected ${mime}, saved as ${finalPath})` : '';
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
type: 'video',
|
|
180
|
-
mimeType: mime,
|
|
181
|
-
data: buffer.toString('base64'),
|
|
182
|
-
});
|
|
183
|
-
}
|
|
184
|
-
return { content, _meta: baseMeta };
|
|
185
|
-
}
|
|
186
|
-
if (buffer.length <= getMaxInlineBytes()) {
|
|
187
|
-
return {
|
|
188
|
-
content: [
|
|
189
|
-
{ type: 'text', text: `Video generated (${buffer.length} bytes, ${mime}).` },
|
|
190
|
-
{ type: 'video', mimeType: mime, data: buffer.toString('base64') },
|
|
191
|
-
],
|
|
192
|
-
_meta: baseMeta,
|
|
193
|
-
};
|
|
171
|
+
return buildBinaryToolResult({ kind: 'video', buffer, mimeType: mime }, {
|
|
172
|
+
savedPath: finalPath,
|
|
173
|
+
summaryText: `Video saved to: ${finalPath}${summaryNote}`,
|
|
174
|
+
meta: baseMeta,
|
|
175
|
+
});
|
|
194
176
|
}
|
|
195
|
-
return {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
text: `Video generated (${buffer.length} bytes, ${mime}). Too large to inline; pass save_path to persist. URL: ${url}`,
|
|
200
|
-
},
|
|
201
|
-
],
|
|
202
|
-
_meta: baseMeta,
|
|
203
|
-
};
|
|
177
|
+
return buildBinaryToolResult({ kind: 'video', buffer, mimeType: mime }, {
|
|
178
|
+
remoteUrl: url,
|
|
179
|
+
meta: baseMeta,
|
|
180
|
+
});
|
|
204
181
|
}
|
|
205
182
|
function stripAndReplaceExt(p, newExt) {
|
|
206
183
|
const cur = extname(p);
|
|
@@ -15,3 +15,9 @@ export type OptionalOutputPath = {
|
|
|
15
15
|
export declare function isToolErrorResult(result: OptionalOutputPath | ToolErrorResult): result is ToolErrorResult;
|
|
16
16
|
/** Resolve optional save_path; returns a tool error result on sandbox violation. */
|
|
17
17
|
export declare function resolveOptionalOutputPath(savePath: string | undefined): Promise<OptionalOutputPath | ToolErrorResult>;
|
|
18
|
+
export declare function isValidJobId(jobId: string): boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Resolve async-chat job status.json under OPENROUTER_OUTPUT_DIR/openrouter-jobs/.
|
|
21
|
+
* Uses realpath when the job directory exists to block symlink escapes.
|
|
22
|
+
*/
|
|
23
|
+
export declare function resolveSafeJobStatusPath(jobsDir: string, jobId: string): Promise<string | null>;
|
|
@@ -142,3 +142,34 @@ export async function resolveOptionalOutputPath(savePath) {
|
|
|
142
142
|
return toolErrorFrom(ErrorCode.INTERNAL, err);
|
|
143
143
|
}
|
|
144
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
|
+
}
|
|
@@ -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
|
}>;
|
|
@@ -6,6 +6,8 @@ import { ErrorCode, toolError, toolErrorFrom } from '../errors.js';
|
|
|
6
6
|
import { SERVER_VERSION } from '../version.js';
|
|
7
7
|
import { logger } from '../logger.js';
|
|
8
8
|
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
9
|
+
import { buildBinaryToolResult } from './tool-result-payload.js';
|
|
10
|
+
import { replaceExtension } from './path-utils.js';
|
|
9
11
|
import { buildCacheHeaders } from './cache.js';
|
|
10
12
|
const DEFAULT_MODEL = 'openai/gpt-4o-mini-tts-2025-12-15';
|
|
11
13
|
const DEFAULT_VOICE = 'alloy';
|
|
@@ -61,7 +63,7 @@ export async function handleTextToSpeech(request, apiClient) {
|
|
|
61
63
|
};
|
|
62
64
|
if (safeSavePath) {
|
|
63
65
|
const currentExt = extname(safeSavePath).toLowerCase().slice(1);
|
|
64
|
-
const actualPath = currentExt === ext ? safeSavePath :
|
|
66
|
+
const actualPath = currentExt === ext ? safeSavePath : replaceExtension(safeSavePath, ext);
|
|
65
67
|
try {
|
|
66
68
|
await fs.writeFile(actualPath, buffer);
|
|
67
69
|
}
|
|
@@ -69,19 +71,14 @@ export async function handleTextToSpeech(request, apiClient) {
|
|
|
69
71
|
return toolErrorFrom(ErrorCode.INTERNAL, err, 'Write');
|
|
70
72
|
}
|
|
71
73
|
baseMeta.save_path = actualPath;
|
|
72
|
-
return {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
_meta: baseMeta,
|
|
78
|
-
};
|
|
74
|
+
return buildBinaryToolResult({ kind: 'audio', buffer, mimeType }, {
|
|
75
|
+
savedPath: actualPath,
|
|
76
|
+
summaryText: `Speech saved to: ${actualPath}`,
|
|
77
|
+
meta: baseMeta,
|
|
78
|
+
});
|
|
79
79
|
}
|
|
80
|
-
return {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
],
|
|
85
|
-
_meta: baseMeta,
|
|
86
|
-
};
|
|
80
|
+
return buildBinaryToolResult({ kind: 'audio', buffer, mimeType }, {
|
|
81
|
+
prefixText: `Speech generated (${buffer.length} bytes, ${mimeType}).`,
|
|
82
|
+
meta: baseMeta,
|
|
83
|
+
});
|
|
87
84
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export type InlineMediaKind = 'image' | 'audio' | 'video';
|
|
2
|
+
type TextContent = {
|
|
3
|
+
type: 'text';
|
|
4
|
+
text: string;
|
|
5
|
+
};
|
|
6
|
+
type ImageContent = {
|
|
7
|
+
type: 'image';
|
|
8
|
+
mimeType: string;
|
|
9
|
+
data: string;
|
|
10
|
+
};
|
|
11
|
+
type AudioContent = {
|
|
12
|
+
type: 'audio';
|
|
13
|
+
mimeType: string;
|
|
14
|
+
data: string;
|
|
15
|
+
};
|
|
16
|
+
type ResourceContent = {
|
|
17
|
+
type: 'resource';
|
|
18
|
+
resource: {
|
|
19
|
+
uri: string;
|
|
20
|
+
mimeType?: string;
|
|
21
|
+
blob: string;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
export type BinaryToolContent = TextContent | ImageContent | AudioContent | ResourceContent;
|
|
25
|
+
export interface BinaryArtifact {
|
|
26
|
+
kind: InlineMediaKind;
|
|
27
|
+
buffer: Buffer;
|
|
28
|
+
mimeType: string;
|
|
29
|
+
}
|
|
30
|
+
export interface BuildBinaryToolResultOptions {
|
|
31
|
+
savedPath?: string | null;
|
|
32
|
+
/** Overrides default saved/too-large message */
|
|
33
|
+
summaryText?: string;
|
|
34
|
+
/** Shown alongside inline media when not using inlineOnly */
|
|
35
|
+
prefixText?: string;
|
|
36
|
+
/** When inline fits and no save_path: return media block only (image UX) */
|
|
37
|
+
inlineOnly?: boolean;
|
|
38
|
+
remoteUrl?: string;
|
|
39
|
+
meta?: Record<string, unknown>;
|
|
40
|
+
maxInlineBytes?: number;
|
|
41
|
+
}
|
|
42
|
+
export declare function getMaxInlineBytes(kind: InlineMediaKind): number;
|
|
43
|
+
export declare function buildBinaryToolResult(artifact: BinaryArtifact, opts?: BuildBinaryToolResultOptions): {
|
|
44
|
+
content: BinaryToolContent[];
|
|
45
|
+
_meta: Record<string, unknown>;
|
|
46
|
+
};
|
|
47
|
+
export {};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical MCP tool-result policy for binary artifacts:
|
|
3
|
+
* - saved to disk -> text pointer only (never duplicate inline media)
|
|
4
|
+
* - not saved -> inline only when under byte ceiling, else text + save_path hint
|
|
5
|
+
* - video uses MCP `resource` blocks (spec has no `video` content type)
|
|
6
|
+
*/
|
|
7
|
+
import { readEnvInt } from './fetch-utils.js';
|
|
8
|
+
const DEFAULT_INLINE_MAX_BYTES = 1024 * 1024;
|
|
9
|
+
const DEFAULT_VIDEO_INLINE_MAX_BYTES = 10 * 1024 * 1024;
|
|
10
|
+
const INLINE_VIDEO_URI = 'inline://openrouter-mcp-multimodal/video';
|
|
11
|
+
const KIND_ENV_KEYS = {
|
|
12
|
+
image: 'OPENROUTER_IMAGE_INLINE_MAX_BYTES',
|
|
13
|
+
audio: 'OPENROUTER_AUDIO_INLINE_MAX_BYTES',
|
|
14
|
+
video: 'OPENROUTER_VIDEO_INLINE_MAX_BYTES',
|
|
15
|
+
};
|
|
16
|
+
const KIND_DEFAULT_BYTES = {
|
|
17
|
+
image: DEFAULT_INLINE_MAX_BYTES,
|
|
18
|
+
audio: DEFAULT_INLINE_MAX_BYTES,
|
|
19
|
+
video: DEFAULT_VIDEO_INLINE_MAX_BYTES,
|
|
20
|
+
};
|
|
21
|
+
export function getMaxInlineBytes(kind) {
|
|
22
|
+
const globalFallback = readEnvInt('OPENROUTER_INLINE_MAX_BYTES', KIND_DEFAULT_BYTES[kind], 4096);
|
|
23
|
+
return readEnvInt(KIND_ENV_KEYS[kind], globalFallback, 4096);
|
|
24
|
+
}
|
|
25
|
+
function kindLabel(kind) {
|
|
26
|
+
switch (kind) {
|
|
27
|
+
case 'image':
|
|
28
|
+
return 'Image';
|
|
29
|
+
case 'audio':
|
|
30
|
+
return 'Audio';
|
|
31
|
+
case 'video':
|
|
32
|
+
return 'Video';
|
|
33
|
+
default: {
|
|
34
|
+
const _exhaustive = kind;
|
|
35
|
+
return _exhaustive;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function buildInlineBlock(kind, mimeType, data, remoteUrl) {
|
|
40
|
+
if (kind === 'video') {
|
|
41
|
+
return {
|
|
42
|
+
type: 'resource',
|
|
43
|
+
resource: {
|
|
44
|
+
uri: remoteUrl ?? INLINE_VIDEO_URI,
|
|
45
|
+
mimeType,
|
|
46
|
+
blob: data,
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return { type: kind, mimeType, data };
|
|
51
|
+
}
|
|
52
|
+
export function buildBinaryToolResult(artifact, opts = {}) {
|
|
53
|
+
const { kind, buffer, mimeType } = artifact;
|
|
54
|
+
const maxInline = opts.maxInlineBytes ?? getMaxInlineBytes(kind);
|
|
55
|
+
const meta = {
|
|
56
|
+
...opts.meta,
|
|
57
|
+
mime: mimeType,
|
|
58
|
+
size_bytes: buffer.length,
|
|
59
|
+
};
|
|
60
|
+
if (opts.savedPath) {
|
|
61
|
+
const text = opts.summaryText ??
|
|
62
|
+
`${kindLabel(kind)} saved to: ${opts.savedPath} (${buffer.length} bytes, ${mimeType})`;
|
|
63
|
+
return {
|
|
64
|
+
content: [{ type: 'text', text }],
|
|
65
|
+
_meta: { ...meta, save_path: opts.savedPath },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (buffer.length <= maxInline) {
|
|
69
|
+
const data = buffer.toString('base64');
|
|
70
|
+
if (opts.inlineOnly) {
|
|
71
|
+
return {
|
|
72
|
+
content: [buildInlineBlock(kind, mimeType, data, opts.remoteUrl)],
|
|
73
|
+
_meta: meta,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const text = opts.prefixText ?? `${kindLabel(kind)} generated (${buffer.length} bytes, ${mimeType}).`;
|
|
77
|
+
return {
|
|
78
|
+
content: [textBlock(text), buildInlineBlock(kind, mimeType, data, opts.remoteUrl)],
|
|
79
|
+
_meta: meta,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const urlHint = opts.remoteUrl ? ` URL: ${opts.remoteUrl}` : '';
|
|
83
|
+
return {
|
|
84
|
+
content: [
|
|
85
|
+
{
|
|
86
|
+
type: 'text',
|
|
87
|
+
text: opts.summaryText ??
|
|
88
|
+
`${kindLabel(kind)} generated (${buffer.length} bytes, ${mimeType}). Too large to inline; pass save_path to persist.${urlHint}`,
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
_meta: meta,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function textBlock(text) {
|
|
95
|
+
return { type: 'text', text };
|
|
96
|
+
}
|
package/dist/tool-handlers.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CallToolRequestSchema, ErrorCode as McpErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
-
import
|
|
2
|
+
import { createOpenRouterOpenAIClient } from './openrouter-openai-client.js';
|
|
3
3
|
import { ModelCache } from './model-cache.js';
|
|
4
4
|
import { OpenRouterAPIClient } from './openrouter-api.js';
|
|
5
5
|
import { handleChatCompletion } from './tool-handlers/chat-completion.js';
|
|
@@ -55,10 +55,7 @@ export class ToolHandlers {
|
|
|
55
55
|
constructor(server, apiKey, defaultModel) {
|
|
56
56
|
this.defaultModel = defaultModel;
|
|
57
57
|
this.apiClient = new OpenRouterAPIClient(apiKey);
|
|
58
|
-
this.openai =
|
|
59
|
-
apiKey,
|
|
60
|
-
baseURL: 'https://openrouter.ai/api/v1',
|
|
61
|
-
});
|
|
58
|
+
this.openai = createOpenRouterOpenAIClient(apiKey);
|
|
62
59
|
this.server = server;
|
|
63
60
|
this.register(server);
|
|
64
61
|
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const SERVER_VERSION = "4.
|
|
1
|
+
export declare const SERVER_VERSION = "4.7.0";
|
|
2
2
|
export declare const MCP_PROTOCOL_VERSION = "2025-06-18";
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export const SERVER_VERSION = '4.
|
|
1
|
+
export const SERVER_VERSION = '4.7.0';
|
|
2
2
|
export const MCP_PROTOCOL_VERSION = '2025-06-18';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stabgan/openrouter-mcp-multimodal",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.7.0",
|
|
4
4
|
"mcpName": "io.github.stabgan/openrouter-multimodal",
|
|
5
5
|
"description": "MCP server for OpenRouter — chat with 300+ LLMs, analyze images/audio/video, generate images (dedicated API), TTS/STT, video generation (Veo 3.1 / Seedance / Wan), async completions, response caching",
|
|
6
6
|
"type": "module",
|