@stabgan/openrouter-mcp-multimodal 4.7.0 → 5.0.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 +95 -41
- package/dist/errors.d.ts +5 -20
- package/dist/errors.js +1 -10
- package/dist/index.js +8 -2
- package/dist/logger.js +54 -24
- package/dist/model-cache.d.ts +13 -0
- package/dist/model-cache.js +62 -8
- package/dist/openrouter-api.d.ts +14 -15
- package/dist/openrouter-api.js +68 -22
- package/dist/tool-definitions.d.ts +24 -0
- package/dist/tool-definitions.js +280 -177
- package/dist/tool-descriptions.d.ts +0 -4
- package/dist/tool-descriptions.js +30 -21
- package/dist/tool-handlers/analyze-audio.js +4 -1
- package/dist/tool-handlers/analyze-image.js +11 -6
- package/dist/tool-handlers/analyze-video.js +9 -5
- package/dist/tool-handlers/async-chat.d.ts +17 -0
- package/dist/tool-handlers/async-chat.js +104 -30
- package/dist/tool-handlers/audio-utils.d.ts +19 -4
- package/dist/tool-handlers/audio-utils.js +170 -16
- package/dist/tool-handlers/cache.d.ts +3 -3
- package/dist/tool-handlers/cache.js +56 -4
- package/dist/tool-handlers/chat-completion.js +16 -7
- package/dist/tool-handlers/chat-request.d.ts +4 -1
- package/dist/tool-handlers/chat-request.js +29 -1
- package/dist/tool-handlers/completion-utils.d.ts +5 -11
- package/dist/tool-handlers/completion-utils.js +76 -47
- package/dist/tool-handlers/fetch-utils.d.ts +14 -0
- package/dist/tool-handlers/fetch-utils.js +329 -77
- package/dist/tool-handlers/generate-audio.d.ts +4 -15
- package/dist/tool-handlers/generate-audio.js +21 -53
- package/dist/tool-handlers/generate-image-dedicated.d.ts +1 -1
- package/dist/tool-handlers/generate-image-dedicated.js +50 -31
- package/dist/tool-handlers/generate-image.d.ts +1 -1
- package/dist/tool-handlers/generate-image.js +19 -22
- package/dist/tool-handlers/generate-video.d.ts +4 -3
- package/dist/tool-handlers/generate-video.js +42 -18
- package/dist/tool-handlers/get-model-info.js +1 -1
- package/dist/tool-handlers/health-check.js +39 -15
- package/dist/tool-handlers/image-utils.js +2 -2
- package/dist/tool-handlers/openrouter-errors.d.ts +2 -0
- package/dist/tool-handlers/openrouter-errors.js +138 -31
- package/dist/tool-handlers/path-safety.js +49 -17
- package/dist/tool-handlers/path-utils.d.ts +2 -0
- package/dist/tool-handlers/path-utils.js +13 -0
- package/dist/tool-handlers/provider-routing.d.ts +2 -0
- package/dist/tool-handlers/provider-routing.js +11 -1
- package/dist/tool-handlers/rerank.d.ts +1 -4
- package/dist/tool-handlers/rerank.js +44 -15
- package/dist/tool-handlers/search-models.js +3 -3
- package/dist/tool-handlers/speech-to-text.d.ts +1 -0
- package/dist/tool-handlers/speech-to-text.js +23 -56
- package/dist/tool-handlers/text-to-speech.d.ts +1 -1
- package/dist/tool-handlers/text-to-speech.js +33 -20
- package/dist/tool-handlers/tool-result-payload.js +11 -9
- package/dist/tool-handlers/validate-model.js +1 -1
- package/dist/tool-handlers.d.ts +9 -0
- package/dist/tool-handlers.js +17 -5
- package/dist/tts-defaults.d.ts +4 -0
- package/dist/tts-defaults.js +4 -0
- package/dist/version.d.ts +3 -2
- package/dist/version.js +4 -2
- package/package.json +11 -13
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
import { promises as fs } from 'node:fs';
|
|
1
|
+
import { IMAGE_ASPECT_RATIOS, IMAGE_DEDICATED_QUALITIES, IMAGE_DEDICATED_RESOLUTIONS, IMAGE_OUTPUT_FORMATS, } from '../tool-definitions.js';
|
|
3
2
|
import { resolveOptionalOutputPath, isToolErrorResult, UnsafeOutputPathError, } from './path-safety.js';
|
|
4
3
|
import { toOpenRouterImageReference } from './image-source.js';
|
|
5
4
|
import { ErrorCode, toolError, toolErrorFrom } from '../errors.js';
|
|
@@ -8,11 +7,14 @@ import { logger } from '../logger.js';
|
|
|
8
7
|
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
9
8
|
import { buildBinaryToolResult } from './tool-result-payload.js';
|
|
10
9
|
import { fetchHttpResource, readEnvInt } from './fetch-utils.js';
|
|
11
|
-
import { buildCacheHeaders } from './cache.js';
|
|
10
|
+
import { buildCacheHeaders, validateCacheOptions } from './cache.js';
|
|
11
|
+
import { writeOutputFile } from './path-utils.js';
|
|
12
12
|
const DEFAULT_MODEL = 'google/gemini-2.5-flash-image';
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
const
|
|
13
|
+
const MAX_IMAGES = 10;
|
|
14
|
+
const VALID_ASPECT_RATIOS = new Set(IMAGE_ASPECT_RATIOS);
|
|
15
|
+
const VALID_RESOLUTIONS = new Set(IMAGE_DEDICATED_RESOLUTIONS);
|
|
16
|
+
const VALID_QUALITIES = new Set(IMAGE_DEDICATED_QUALITIES);
|
|
17
|
+
const VALID_OUTPUT_FORMATS = new Set(IMAGE_OUTPUT_FORMATS);
|
|
16
18
|
const MIME_BY_FORMAT = {
|
|
17
19
|
png: 'image/png',
|
|
18
20
|
webp: 'image/webp',
|
|
@@ -35,6 +37,9 @@ export async function handleGenerateImageDedicated(request, apiClient) {
|
|
|
35
37
|
input_references_count: input_references?.length ?? 0,
|
|
36
38
|
save_path: save_path ? 'provided' : 'none',
|
|
37
39
|
});
|
|
40
|
+
if (aspect_ratio && !VALID_ASPECT_RATIOS.has(aspect_ratio)) {
|
|
41
|
+
return toolError(ErrorCode.INVALID_INPUT, `aspect_ratio '${aspect_ratio}' is not supported. Valid: ${[...VALID_ASPECT_RATIOS].join(', ')}.`);
|
|
42
|
+
}
|
|
38
43
|
if (resolution && !VALID_RESOLUTIONS.has(resolution)) {
|
|
39
44
|
return toolError(ErrorCode.INVALID_INPUT, `resolution '${resolution}' is not supported. Valid: ${[...VALID_RESOLUTIONS].join(', ')}.`);
|
|
40
45
|
}
|
|
@@ -44,6 +49,12 @@ export async function handleGenerateImageDedicated(request, apiClient) {
|
|
|
44
49
|
if (output_format && !VALID_OUTPUT_FORMATS.has(output_format)) {
|
|
45
50
|
return toolError(ErrorCode.INVALID_INPUT, `output_format '${output_format}' is not supported. Valid: ${[...VALID_OUTPUT_FORMATS].join(', ')}.`);
|
|
46
51
|
}
|
|
52
|
+
if (typeof n === 'number' && (n < 1 || n > MAX_IMAGES)) {
|
|
53
|
+
return toolError(ErrorCode.INVALID_INPUT, `n must be between 1 and ${MAX_IMAGES} (inclusive).`);
|
|
54
|
+
}
|
|
55
|
+
const cacheError = validateCacheOptions({ cache, cache_ttl, cache_clear });
|
|
56
|
+
if (cacheError)
|
|
57
|
+
return cacheError;
|
|
47
58
|
const savePathResult = await resolveOptionalOutputPath(save_path);
|
|
48
59
|
if (isToolErrorResult(savePathResult))
|
|
49
60
|
return savePathResult;
|
|
@@ -60,7 +71,7 @@ export async function handleGenerateImageDedicated(request, apiClient) {
|
|
|
60
71
|
body.quality = quality;
|
|
61
72
|
if (output_format)
|
|
62
73
|
body.output_format = output_format;
|
|
63
|
-
if (typeof n === 'number'
|
|
74
|
+
if (typeof n === 'number')
|
|
64
75
|
body.n = n;
|
|
65
76
|
if (provider && typeof provider === 'object')
|
|
66
77
|
body.provider = provider;
|
|
@@ -90,38 +101,30 @@ export async function handleGenerateImageDedicated(request, apiClient) {
|
|
|
90
101
|
});
|
|
91
102
|
}
|
|
92
103
|
const firstImage = images[0];
|
|
93
|
-
const imageData = firstImage.b64_json;
|
|
94
104
|
const mimeType = MIME_BY_FORMAT[output_format ?? ''] ?? 'image/png';
|
|
95
105
|
const baseMeta = {
|
|
96
106
|
server_version: SERVER_VERSION,
|
|
97
107
|
model: model || DEFAULT_MODEL,
|
|
98
108
|
images_count: images.length,
|
|
109
|
+
saved_image_index: 0,
|
|
99
110
|
};
|
|
111
|
+
if (images.length > 1) {
|
|
112
|
+
baseMeta.images_note = 'Only images[0] is saved or inlined; request n=1 for a single image.';
|
|
113
|
+
}
|
|
100
114
|
if (response.usage)
|
|
101
115
|
baseMeta.usage = response.usage;
|
|
102
116
|
if (firstImage.revised_prompt)
|
|
103
117
|
baseMeta.revised_prompt = firstImage.revised_prompt;
|
|
118
|
+
const decoded = decodeImageBuffer(firstImage.b64_json);
|
|
104
119
|
if (safeSavePath) {
|
|
105
|
-
|
|
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
|
-
}
|
|
115
|
-
}
|
|
116
|
-
if (buffer) {
|
|
120
|
+
if (decoded) {
|
|
117
121
|
try {
|
|
118
|
-
await
|
|
122
|
+
await writeOutputFile(safeSavePath, decoded);
|
|
119
123
|
}
|
|
120
124
|
catch (err) {
|
|
121
125
|
return toolErrorFrom(ErrorCode.INTERNAL, err, 'Write');
|
|
122
126
|
}
|
|
123
|
-
|
|
124
|
-
return buildBinaryToolResult({ kind: 'image', buffer, mimeType }, {
|
|
127
|
+
return buildBinaryToolResult({ kind: 'image', buffer: decoded, mimeType }, {
|
|
125
128
|
savedPath: safeSavePath,
|
|
126
129
|
summaryText: `Image saved to: ${safeSavePath}`,
|
|
127
130
|
meta: baseMeta,
|
|
@@ -135,9 +138,11 @@ export async function handleGenerateImageDedicated(request, apiClient) {
|
|
|
135
138
|
maxRedirects: 3,
|
|
136
139
|
timeoutMs: 30_000,
|
|
137
140
|
});
|
|
141
|
+
if (fetched.length === 0) {
|
|
142
|
+
return toolError(ErrorCode.UPSTREAM_REFUSED, 'Downloaded image URL returned empty body.');
|
|
143
|
+
}
|
|
138
144
|
const resolvedMime = contentType?.split(';')[0]?.trim() || mimeType;
|
|
139
|
-
await
|
|
140
|
-
baseMeta.save_path = safeSavePath;
|
|
145
|
+
await writeOutputFile(safeSavePath, fetched);
|
|
141
146
|
return buildBinaryToolResult({ kind: 'image', buffer: fetched, mimeType: resolvedMime }, {
|
|
142
147
|
savedPath: safeSavePath,
|
|
143
148
|
summaryText: `Image saved to: ${safeSavePath}`,
|
|
@@ -150,11 +155,25 @@ export async function handleGenerateImageDedicated(request, apiClient) {
|
|
|
150
155
|
}
|
|
151
156
|
return toolError(ErrorCode.UPSTREAM_REFUSED, 'Model returned no usable image data for save_path (empty b64_json and URL download unavailable).');
|
|
152
157
|
}
|
|
153
|
-
if (
|
|
154
|
-
return buildBinaryToolResult({ kind: 'image', buffer:
|
|
158
|
+
if (decoded) {
|
|
159
|
+
return buildBinaryToolResult({ kind: 'image', buffer: decoded, mimeType }, { inlineOnly: true, meta: baseMeta });
|
|
160
|
+
}
|
|
161
|
+
if (firstImage.url) {
|
|
162
|
+
return {
|
|
163
|
+
content: [{ type: 'text', text: `Image generated. URL: ${firstImage.url}` }],
|
|
164
|
+
_meta: { ...baseMeta, image_url: firstImage.url },
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
return toolError(ErrorCode.UPSTREAM_REFUSED, 'Model returned no usable image data.');
|
|
168
|
+
}
|
|
169
|
+
function decodeImageBuffer(b64) {
|
|
170
|
+
if (!b64)
|
|
171
|
+
return null;
|
|
172
|
+
try {
|
|
173
|
+
const buffer = Buffer.from(b64, 'base64');
|
|
174
|
+
return buffer.length > 0 ? buffer : null;
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return null;
|
|
155
178
|
}
|
|
156
|
-
return {
|
|
157
|
-
content: [{ type: 'text', text: `Image generated. URL: ${firstImage.url}` }],
|
|
158
|
-
_meta: { ...baseMeta, image_url: firstImage.url },
|
|
159
|
-
};
|
|
160
179
|
}
|
|
@@ -13,7 +13,7 @@ export declare function handleGenerateImage(request: {
|
|
|
13
13
|
params: {
|
|
14
14
|
arguments: GenerateImageToolRequest;
|
|
15
15
|
};
|
|
16
|
-
}, openai: OpenAI): Promise<
|
|
16
|
+
}, openai: OpenAI): Promise<{
|
|
17
17
|
content: import("./tool-result-payload.js").BinaryToolContent[];
|
|
18
18
|
_meta: Record<string, unknown>;
|
|
19
19
|
}>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { IMAGE_ASPECT_RATIOS } from '../tool-definitions.js';
|
|
2
2
|
import { resolveOptionalOutputPath, isToolErrorResult, UnsafeOutputPathError, } from './path-safety.js';
|
|
3
3
|
import { parseBase64DataUrl } from './fetch-utils.js';
|
|
4
4
|
import { buildUserContent } from './generate-image-input.js';
|
|
@@ -8,23 +8,9 @@ import { SERVER_VERSION } from '../version.js';
|
|
|
8
8
|
import { logger } from '../logger.js';
|
|
9
9
|
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
10
10
|
import { buildBinaryToolResult } from './tool-result-payload.js';
|
|
11
|
+
import { writeOutputFile } from './path-utils.js';
|
|
11
12
|
const DEFAULT_MODEL = 'google/gemini-2.5-flash-image';
|
|
12
|
-
const VALID_ASPECT_RATIOS = new Set(
|
|
13
|
-
'1:1',
|
|
14
|
-
'2:3',
|
|
15
|
-
'3:2',
|
|
16
|
-
'3:4',
|
|
17
|
-
'4:3',
|
|
18
|
-
'4:5',
|
|
19
|
-
'5:4',
|
|
20
|
-
'9:16',
|
|
21
|
-
'16:9',
|
|
22
|
-
'21:9',
|
|
23
|
-
'1:4',
|
|
24
|
-
'4:1',
|
|
25
|
-
'1:8',
|
|
26
|
-
'8:1',
|
|
27
|
-
]);
|
|
13
|
+
const VALID_ASPECT_RATIOS = new Set(IMAGE_ASPECT_RATIOS);
|
|
28
14
|
const VALID_IMAGE_SIZES = new Set(['0.5K', '1K', '2K', '4K']);
|
|
29
15
|
export async function handleGenerateImage(request, openai) {
|
|
30
16
|
const { prompt, model, save_path, aspect_ratio, image_size, max_tokens, input_images, modalities, } = request.params.arguments ?? { prompt: '' };
|
|
@@ -45,6 +31,14 @@ export async function handleGenerateImage(request, openai) {
|
|
|
45
31
|
if (image_size !== undefined && !VALID_IMAGE_SIZES.has(image_size)) {
|
|
46
32
|
return invalidEnumError('image_size', image_size, VALID_IMAGE_SIZES);
|
|
47
33
|
}
|
|
34
|
+
if (max_tokens !== undefined) {
|
|
35
|
+
if (typeof max_tokens !== 'number' ||
|
|
36
|
+
!Number.isFinite(max_tokens) ||
|
|
37
|
+
max_tokens <= 0 ||
|
|
38
|
+
!Number.isInteger(max_tokens)) {
|
|
39
|
+
return toolError(ErrorCode.INVALID_INPUT, 'max_tokens must be a positive integer.');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
48
42
|
const savePathResult = await resolveOptionalOutputPath(save_path);
|
|
49
43
|
if (isToolErrorResult(savePathResult))
|
|
50
44
|
return savePathResult;
|
|
@@ -71,7 +65,7 @@ export async function handleGenerateImage(request, openai) {
|
|
|
71
65
|
};
|
|
72
66
|
if (Object.keys(imageConfig).length > 0)
|
|
73
67
|
body.image_config = imageConfig;
|
|
74
|
-
if (typeof max_tokens === 'number'
|
|
68
|
+
if (typeof max_tokens === 'number')
|
|
75
69
|
body.max_tokens = max_tokens;
|
|
76
70
|
let completion;
|
|
77
71
|
try {
|
|
@@ -93,20 +87,24 @@ export async function handleGenerateImage(request, openai) {
|
|
|
93
87
|
finish_reason: completion.choices[0]?.finish_reason,
|
|
94
88
|
});
|
|
95
89
|
}
|
|
90
|
+
const buffer = Buffer.from(base64.data, 'base64');
|
|
91
|
+
if (buffer.length === 0) {
|
|
92
|
+
return toolError(ErrorCode.UPSTREAM_REFUSED, 'Model returned an empty image payload.');
|
|
93
|
+
}
|
|
96
94
|
if (safePathResolved) {
|
|
97
95
|
try {
|
|
98
|
-
await
|
|
96
|
+
await writeOutputFile(safePathResolved, buffer);
|
|
99
97
|
}
|
|
100
98
|
catch (err) {
|
|
101
99
|
return toolErrorFrom(ErrorCode.INTERNAL, err, 'Write');
|
|
102
100
|
}
|
|
103
101
|
}
|
|
104
|
-
return buildImageSuccessResult(base64, completion.usage, safePathResolved ?? undefined);
|
|
102
|
+
return buildImageSuccessResult(base64, buffer, completion.usage, safePathResolved ?? undefined);
|
|
105
103
|
}
|
|
106
104
|
function invalidEnumError(field, value, allowed) {
|
|
107
105
|
return toolError(ErrorCode.INVALID_INPUT, `${field} '${value}' is not supported. Valid values: ${[...allowed].join(', ')}.`);
|
|
108
106
|
}
|
|
109
|
-
function buildImageSuccessResult(base64, usage, savePath) {
|
|
107
|
+
function buildImageSuccessResult(base64, buffer, usage, savePath) {
|
|
110
108
|
const usageMeta = usage
|
|
111
109
|
? {
|
|
112
110
|
usage: {
|
|
@@ -116,7 +114,6 @@ function buildImageSuccessResult(base64, usage, savePath) {
|
|
|
116
114
|
},
|
|
117
115
|
}
|
|
118
116
|
: {};
|
|
119
|
-
const buffer = Buffer.from(base64.data, 'base64');
|
|
120
117
|
return buildBinaryToolResult({ kind: 'image', buffer, mimeType: base64.mime }, {
|
|
121
118
|
savedPath: savePath ?? null,
|
|
122
119
|
inlineOnly: !savePath,
|
|
@@ -17,7 +17,6 @@ export interface GenerateVideoToolRequest {
|
|
|
17
17
|
export interface GetVideoStatusToolRequest {
|
|
18
18
|
video_id: string;
|
|
19
19
|
save_path?: string;
|
|
20
|
-
polling_url?: string;
|
|
21
20
|
}
|
|
22
21
|
type ProgressHook = (update: {
|
|
23
22
|
status: string;
|
|
@@ -25,9 +24,10 @@ type ProgressHook = (update: {
|
|
|
25
24
|
attempt: number;
|
|
26
25
|
video_id: string;
|
|
27
26
|
}) => void | Promise<void>;
|
|
27
|
+
declare function isTerminalFailureStatus(status: string): boolean;
|
|
28
|
+
declare function invokeProgressHook(hook: ProgressHook | undefined, update: Parameters<ProgressHook>[0]): Promise<void>;
|
|
28
29
|
declare function buildRequestBody(args: GenerateVideoToolRequest, model: string): Record<string, unknown>;
|
|
29
30
|
declare function extractJobError(status: VideoJobStatus): string;
|
|
30
|
-
declare function stripAndReplaceExt(p: string, newExt: string): string;
|
|
31
31
|
export declare function handleGenerateVideo(request: {
|
|
32
32
|
params: {
|
|
33
33
|
arguments: GenerateVideoToolRequest;
|
|
@@ -109,7 +109,8 @@ export declare function handleGenerateVideoFromImage(request: {
|
|
|
109
109
|
}>;
|
|
110
110
|
export declare const _internals: {
|
|
111
111
|
buildRequestBody: typeof buildRequestBody;
|
|
112
|
-
stripAndReplaceExt: typeof stripAndReplaceExt;
|
|
113
112
|
extractJobError: typeof extractJobError;
|
|
113
|
+
isTerminalFailureStatus: typeof isTerminalFailureStatus;
|
|
114
|
+
invokeProgressHook: typeof invokeProgressHook;
|
|
114
115
|
};
|
|
115
116
|
export {};
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { promises as fs } from 'node:fs';
|
|
2
1
|
import { extname } from 'node:path';
|
|
3
2
|
import { ErrorCode, toolError, toolErrorFrom } from '../errors.js';
|
|
4
3
|
import { SERVER_VERSION } from '../version.js';
|
|
@@ -8,6 +7,7 @@ import { resolveImageBase64 } from './image-source.js';
|
|
|
8
7
|
import { readEnvInt } from './fetch-utils.js';
|
|
9
8
|
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
10
9
|
import { buildBinaryToolResult } from './tool-result-payload.js';
|
|
10
|
+
import { replaceExtension, writeOutputFile } from './path-utils.js';
|
|
11
11
|
const FALLBACK_MODEL = 'google/veo-3.1';
|
|
12
12
|
const DEFAULT_POLL_INTERVAL_MS = 15_000;
|
|
13
13
|
const DEFAULT_MAX_WAIT_MS = 10 * 60_000;
|
|
@@ -37,6 +37,23 @@ 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 isTerminalFailureStatus(status) {
|
|
41
|
+
const normalized = status.toLowerCase();
|
|
42
|
+
return normalized === 'failed' || normalized === 'cancelled' || normalized === 'canceled';
|
|
43
|
+
}
|
|
44
|
+
async function invokeProgressHook(hook, update) {
|
|
45
|
+
if (!hook)
|
|
46
|
+
return;
|
|
47
|
+
try {
|
|
48
|
+
await hook(update);
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
logger.warn('generate_video.progress_hook_error', {
|
|
52
|
+
video_id: update.video_id,
|
|
53
|
+
err: err instanceof Error ? err.message : String(err),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
40
57
|
function getDefaultPollInterval() {
|
|
41
58
|
return readEnvInt('OPENROUTER_VIDEO_POLL_INTERVAL_MS', DEFAULT_POLL_INTERVAL_MS, MIN_POLL_INTERVAL_MS);
|
|
42
59
|
}
|
|
@@ -100,7 +117,11 @@ async function pollUntilTerminal(apiClient, envelope, opts) {
|
|
|
100
117
|
let attempt = 0;
|
|
101
118
|
let last = null;
|
|
102
119
|
const initialStatus = (envelope.status ?? 'pending');
|
|
103
|
-
await opts.onProgress
|
|
120
|
+
await invokeProgressHook(opts.onProgress, {
|
|
121
|
+
status: initialStatus,
|
|
122
|
+
attempt: 0,
|
|
123
|
+
video_id: envelope.id,
|
|
124
|
+
});
|
|
104
125
|
while (Date.now() < opts.deadlineAt) {
|
|
105
126
|
attempt += 1;
|
|
106
127
|
await sleep(Math.min(opts.pollIntervalMs, Math.max(0, opts.deadlineAt - Date.now())));
|
|
@@ -112,9 +133,9 @@ async function pollUntilTerminal(apiClient, envelope, opts) {
|
|
|
112
133
|
id: envelope.id,
|
|
113
134
|
err: err instanceof Error ? err.message : String(err),
|
|
114
135
|
});
|
|
115
|
-
continue;
|
|
136
|
+
continue;
|
|
116
137
|
}
|
|
117
|
-
await opts.onProgress
|
|
138
|
+
await invokeProgressHook(opts.onProgress, {
|
|
118
139
|
status: last.status,
|
|
119
140
|
progress: typeof last.progress === 'number' ? last.progress : undefined,
|
|
120
141
|
attempt,
|
|
@@ -122,7 +143,7 @@ async function pollUntilTerminal(apiClient, envelope, opts) {
|
|
|
122
143
|
});
|
|
123
144
|
if (last.status === 'completed')
|
|
124
145
|
return { kind: 'completed', status: last };
|
|
125
|
-
if (last.status
|
|
146
|
+
if (isTerminalFailureStatus(last.status))
|
|
126
147
|
return { kind: 'failed', status: last };
|
|
127
148
|
}
|
|
128
149
|
return { kind: 'timeout', last };
|
|
@@ -145,14 +166,17 @@ async function finalizeCompletedJob(apiClient, status, savePath) {
|
|
|
145
166
|
throw new Error('Completed job returned no content URLs.');
|
|
146
167
|
}
|
|
147
168
|
const { buffer, contentType } = await apiClient.downloadVideoContent(status.id, 0, getMaxDownloadBytes());
|
|
169
|
+
if (buffer.length === 0) {
|
|
170
|
+
throw new Error('Completed job returned empty video content.');
|
|
171
|
+
}
|
|
148
172
|
const mime = (contentType?.split(';')[0]?.trim() || 'video/mp4').toLowerCase();
|
|
149
173
|
const ext = mime.includes('webm')
|
|
150
|
-
? '
|
|
174
|
+
? 'webm'
|
|
151
175
|
: mime.includes('mov')
|
|
152
|
-
? '
|
|
176
|
+
? 'mov'
|
|
153
177
|
: mime.includes('mpeg')
|
|
154
|
-
? '
|
|
155
|
-
: '
|
|
178
|
+
? 'mpeg'
|
|
179
|
+
: 'mp4';
|
|
156
180
|
const baseMeta = {
|
|
157
181
|
server_version: SERVER_VERSION,
|
|
158
182
|
video_id: status.id,
|
|
@@ -164,8 +188,8 @@ async function finalizeCompletedJob(apiClient, status, savePath) {
|
|
|
164
188
|
if (status.unsigned_urls)
|
|
165
189
|
baseMeta.unsigned_urls = status.unsigned_urls;
|
|
166
190
|
if (savePath) {
|
|
167
|
-
const finalPath = extname(savePath) === ext ? savePath :
|
|
168
|
-
await
|
|
191
|
+
const finalPath = extname(savePath) === `.${ext}` ? savePath : replaceExtension(savePath, ext);
|
|
192
|
+
await writeOutputFile(finalPath, buffer);
|
|
169
193
|
baseMeta.save_path = finalPath;
|
|
170
194
|
const summaryNote = finalPath !== savePath ? ` (detected ${mime}, saved as ${finalPath})` : '';
|
|
171
195
|
return buildBinaryToolResult({ kind: 'video', buffer, mimeType: mime }, {
|
|
@@ -179,11 +203,6 @@ async function finalizeCompletedJob(apiClient, status, savePath) {
|
|
|
179
203
|
meta: baseMeta,
|
|
180
204
|
});
|
|
181
205
|
}
|
|
182
|
-
function stripAndReplaceExt(p, newExt) {
|
|
183
|
-
const cur = extname(p);
|
|
184
|
-
const base = cur ? p.slice(0, -cur.length) : p;
|
|
185
|
-
return base + newExt;
|
|
186
|
-
}
|
|
187
206
|
export async function handleGenerateVideo(request, apiClient, progress) {
|
|
188
207
|
const args = request.params.arguments ?? {};
|
|
189
208
|
if (!args.prompt || !args.prompt.trim()) {
|
|
@@ -289,7 +308,7 @@ export async function handleGetVideoStatus(request, apiClient) {
|
|
|
289
308
|
catch (err) {
|
|
290
309
|
return classifyUpstreamError(err, 'get_video_status.poll');
|
|
291
310
|
}
|
|
292
|
-
if (status.status === 'failed') {
|
|
311
|
+
if (status.status === 'failed' || isTerminalFailureStatus(status.status)) {
|
|
293
312
|
return toolError(ErrorCode.JOB_FAILED, extractJobError(status), { video_id: id });
|
|
294
313
|
}
|
|
295
314
|
if (status.status === 'completed') {
|
|
@@ -345,4 +364,9 @@ export async function handleGenerateVideoFromImage(request, apiClient, progress)
|
|
|
345
364
|
},
|
|
346
365
|
}, apiClient, progress);
|
|
347
366
|
}
|
|
348
|
-
export const _internals = {
|
|
367
|
+
export const _internals = {
|
|
368
|
+
buildRequestBody,
|
|
369
|
+
extractJobError,
|
|
370
|
+
isTerminalFailureStatus,
|
|
371
|
+
invokeProgressHook,
|
|
372
|
+
};
|
|
@@ -17,7 +17,7 @@ export async function handleGetModelInfo(request, modelCache, apiClient) {
|
|
|
17
17
|
if (!modelCache.isValid()) {
|
|
18
18
|
return toolError(ErrorCode.INTERNAL, 'No model data available.');
|
|
19
19
|
}
|
|
20
|
-
const info = modelCache.
|
|
20
|
+
const info = modelCache.lookup(model);
|
|
21
21
|
if (!info) {
|
|
22
22
|
return toolError(ErrorCode.MODEL_NOT_FOUND, `Model '${model}' not found.`);
|
|
23
23
|
}
|
|
@@ -1,24 +1,48 @@
|
|
|
1
1
|
import { SERVER_VERSION, MCP_PROTOCOL_VERSION } from '../version.js';
|
|
2
2
|
import { buildStructuredResult } from './structured-output.js';
|
|
3
|
+
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
3
4
|
/** Liveness probe — validates API key, reachability, and cached model count. */
|
|
4
5
|
export async function handleHealthCheck(_request, apiClient, modelCache) {
|
|
5
|
-
let apiKeyValid = false;
|
|
6
|
-
let errorMessage;
|
|
7
6
|
try {
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
let apiKeyValid = false;
|
|
8
|
+
let errorMessage;
|
|
9
|
+
let errorMeta = {};
|
|
10
|
+
try {
|
|
11
|
+
await modelCache.ensureFresh(() => apiClient.getModels());
|
|
12
|
+
apiKeyValid = true;
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
const classified = classifyUpstreamError(err, 'health_check');
|
|
16
|
+
errorMessage = classified.content[0]?.text;
|
|
17
|
+
errorMeta = {
|
|
18
|
+
code: classified._meta.code,
|
|
19
|
+
...(classified._meta.suggestions ? { suggestions: classified._meta.suggestions } : {}),
|
|
20
|
+
...(classified._meta.details ? { details: classified._meta.details } : {}),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const modelsCached = modelCache.isValid() ? modelCache.size() : 0;
|
|
24
|
+
const ok = apiKeyValid;
|
|
25
|
+
return buildStructuredResult({
|
|
26
|
+
ok,
|
|
27
|
+
server_version: SERVER_VERSION,
|
|
28
|
+
protocol_version: MCP_PROTOCOL_VERSION,
|
|
29
|
+
api_key_valid: apiKeyValid,
|
|
30
|
+
models_cached: modelsCached,
|
|
31
|
+
...(errorMessage ? { error: errorMessage } : {}),
|
|
32
|
+
}, errorMeta);
|
|
10
33
|
}
|
|
11
34
|
catch (err) {
|
|
12
|
-
|
|
35
|
+
const classified = classifyUpstreamError(err, 'health_check');
|
|
36
|
+
return buildStructuredResult({
|
|
37
|
+
ok: false,
|
|
38
|
+
server_version: SERVER_VERSION,
|
|
39
|
+
protocol_version: MCP_PROTOCOL_VERSION,
|
|
40
|
+
api_key_valid: false,
|
|
41
|
+
models_cached: 0,
|
|
42
|
+
error: classified.content[0]?.text,
|
|
43
|
+
}, {
|
|
44
|
+
code: classified._meta.code,
|
|
45
|
+
...(classified._meta.suggestions ? { suggestions: classified._meta.suggestions } : {}),
|
|
46
|
+
});
|
|
13
47
|
}
|
|
14
|
-
const modelsCached = modelCache.isValid() ? modelCache.size() : 0;
|
|
15
|
-
const ok = apiKeyValid;
|
|
16
|
-
return buildStructuredResult({
|
|
17
|
-
ok,
|
|
18
|
-
server_version: SERVER_VERSION,
|
|
19
|
-
protocol_version: MCP_PROTOCOL_VERSION,
|
|
20
|
-
api_key_valid: apiKeyValid,
|
|
21
|
-
models_cached: modelsCached,
|
|
22
|
-
...(errorMessage ? { error: errorMessage } : {}),
|
|
23
|
-
});
|
|
24
48
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import path from 'path';
|
|
2
|
-
import { promises as fs } from 'fs';
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
3
|
import { readEnvInt, isBlockedIPv4 as _isBlockedIPv4, assertUrlSafeForFetch as _assertUrlSafeForFetch, fetchHttpResource, parseBase64DataUrl, } from './fetch-utils.js';
|
|
4
4
|
import { resolveSafeInputPath } from './path-safety.js';
|
|
5
5
|
// Re-export for backward compatibility (tests import from image-utils)
|
|
@@ -2,5 +2,7 @@
|
|
|
2
2
|
* Map OpenRouter / OpenAI SDK errors to our closed `ErrorCode` enum.
|
|
3
3
|
*/
|
|
4
4
|
import { type ToolErrorResult } from '../errors.js';
|
|
5
|
+
/** Strip bearer tokens and OpenRouter key material from user-visible messages. */
|
|
6
|
+
export declare function sanitizeErrorMessage(msg: string): string;
|
|
5
7
|
/** Classify upstream errors into the closed `ErrorCode` set. */
|
|
6
8
|
export declare function classifyUpstreamError(err: unknown, contextMessage?: string): ToolErrorResult;
|