@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
|
@@ -3,7 +3,8 @@ import { extname } from 'node:path';
|
|
|
3
3
|
import { ErrorCode, toolError, toolErrorFrom } from '../errors.js';
|
|
4
4
|
import { SERVER_VERSION } from '../version.js';
|
|
5
5
|
import { logger } from '../logger.js';
|
|
6
|
-
import {
|
|
6
|
+
import { resolveOptionalOutputPath, isToolErrorResult, UnsafeOutputPathError, } from './path-safety.js';
|
|
7
|
+
import { resolveImageBase64 } from './image-source.js';
|
|
7
8
|
import { readEnvInt } from './fetch-utils.js';
|
|
8
9
|
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
9
10
|
const FALLBACK_MODEL = 'google/veo-3.1';
|
|
@@ -26,10 +27,7 @@ const SORA_ALTERNATIVES = [
|
|
|
26
27
|
'bytedance/seedance-2.0-fast (fast turnaround)',
|
|
27
28
|
'alibaba/wan-2.7 (good for artistic styles)',
|
|
28
29
|
];
|
|
29
|
-
/**
|
|
30
|
-
* Check if the model is a deprecated Sora model and return a warning string,
|
|
31
|
-
* or null if no deprecation applies.
|
|
32
|
-
*/
|
|
30
|
+
/** Return a deprecation warning for Sora models, or null. */
|
|
33
31
|
function checkSoraDeprecation(model) {
|
|
34
32
|
const normalized = model.toLowerCase().trim();
|
|
35
33
|
if (!SORA_DEPRECATED_MODELS.has(normalized) && !normalized.startsWith('openai/sora')) {
|
|
@@ -49,57 +47,19 @@ function getDefaultMaxWait() {
|
|
|
49
47
|
return readEnvInt('OPENROUTER_VIDEO_MAX_WAIT_MS', DEFAULT_MAX_WAIT_MS, 10_000);
|
|
50
48
|
}
|
|
51
49
|
function getMaxDownloadBytes() {
|
|
52
|
-
// Generation output can be bigger than the input cap since it's our own
|
|
53
|
-
// content. Default 256 MB, override via env.
|
|
54
50
|
return readEnvInt('OPENROUTER_VIDEO_GEN_MAX_BYTES', 256 * 1024 * 1024, 1024 * 1024);
|
|
55
51
|
}
|
|
56
|
-
|
|
57
|
-
* Fold a caller-supplied image source (local path, http URL, or data URL)
|
|
58
|
-
* into the `{ url: "data:video|image/...base64,..." }` shape OpenRouter
|
|
59
|
-
* expects inside `frame_images[].image` / `input_references[]`.
|
|
60
|
-
*
|
|
61
|
-
* We reuse `prepareVideoData` for videos but images live in `image-utils`.
|
|
62
|
-
* Since generate_video's references are images, not videos, we do a small
|
|
63
|
-
* image-specific fetch here (data URL pass-through, HTTP via fetch-utils,
|
|
64
|
-
* local via fs). We deliberately do NOT run them through sharp — the model
|
|
65
|
-
* wants the pristine frame.
|
|
66
|
-
*/
|
|
67
|
-
async function prepareImageInput(source) {
|
|
52
|
+
async function imageFrameEntry(source, frameType) {
|
|
68
53
|
if (!source)
|
|
69
54
|
return null;
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const { buffer, contentType } = await fetchHttpResource(source, {
|
|
79
|
-
timeoutMs: 30_000,
|
|
80
|
-
maxBytes: 25 * 1024 * 1024,
|
|
81
|
-
maxRedirects: 8,
|
|
82
|
-
});
|
|
83
|
-
const mime = (contentType?.split(';')[0]?.trim() || 'image/jpeg').toLowerCase();
|
|
84
|
-
return { mime, data: buffer.toString('base64') };
|
|
85
|
-
}
|
|
86
|
-
// Local file: sandbox via path-safety's resolveSafeInputPath so
|
|
87
|
-
// generate_video's first_frame_image / last_frame_image /
|
|
88
|
-
// reference_images fields enforce the same OPENROUTER_INPUT_DIR
|
|
89
|
-
// / OPENROUTER_OUTPUT_DIR / cwd scope that generate_image's
|
|
90
|
-
// input_images already uses. Callers can still bypass with
|
|
91
|
-
// OPENROUTER_ALLOW_UNSAFE_PATHS=1 for legacy scripts.
|
|
92
|
-
const abs = await resolveSafeInputPath(source);
|
|
93
|
-
const buf = await fs.readFile(abs);
|
|
94
|
-
const ext = extname(abs).toLowerCase();
|
|
95
|
-
const mime = ext === '.png'
|
|
96
|
-
? 'image/png'
|
|
97
|
-
: ext === '.webp'
|
|
98
|
-
? 'image/webp'
|
|
99
|
-
: ext === '.gif'
|
|
100
|
-
? 'image/gif'
|
|
101
|
-
: 'image/jpeg';
|
|
102
|
-
return { mime, data: buf.toString('base64') };
|
|
55
|
+
const img = await resolveImageBase64(source);
|
|
56
|
+
const entry = {
|
|
57
|
+
type: 'image_url',
|
|
58
|
+
image_url: { url: `data:${img.mime};base64,${img.data}` },
|
|
59
|
+
};
|
|
60
|
+
if (frameType)
|
|
61
|
+
entry.frame_type = frameType;
|
|
62
|
+
return { kind: 'frame', entry };
|
|
103
63
|
}
|
|
104
64
|
function buildRequestBody(args, model) {
|
|
105
65
|
const body = { model, prompt: args.prompt };
|
|
@@ -118,28 +78,10 @@ function buildRequestBody(args, model) {
|
|
|
118
78
|
async function attachFrameImages(args, body) {
|
|
119
79
|
const frameTasks = [];
|
|
120
80
|
if (args.first_frame_image) {
|
|
121
|
-
frameTasks.push(
|
|
122
|
-
? {
|
|
123
|
-
kind: 'frame',
|
|
124
|
-
entry: {
|
|
125
|
-
type: 'image_url',
|
|
126
|
-
image_url: { url: `data:${img.mime};base64,${img.data}` },
|
|
127
|
-
frame_type: 'first_frame',
|
|
128
|
-
},
|
|
129
|
-
}
|
|
130
|
-
: null));
|
|
81
|
+
frameTasks.push(imageFrameEntry(args.first_frame_image, 'first_frame'));
|
|
131
82
|
}
|
|
132
83
|
if (args.last_frame_image) {
|
|
133
|
-
frameTasks.push(
|
|
134
|
-
? {
|
|
135
|
-
kind: 'frame',
|
|
136
|
-
entry: {
|
|
137
|
-
type: 'image_url',
|
|
138
|
-
image_url: { url: `data:${img.mime};base64,${img.data}` },
|
|
139
|
-
frame_type: 'last_frame',
|
|
140
|
-
},
|
|
141
|
-
}
|
|
142
|
-
: null));
|
|
84
|
+
frameTasks.push(imageFrameEntry(args.last_frame_image, 'last_frame'));
|
|
143
85
|
}
|
|
144
86
|
const frameResults = await Promise.all(frameTasks);
|
|
145
87
|
const frameImages = frameResults
|
|
@@ -148,10 +90,8 @@ async function attachFrameImages(args, body) {
|
|
|
148
90
|
if (frameImages.length)
|
|
149
91
|
body.frame_images = frameImages;
|
|
150
92
|
if (args.reference_images?.length) {
|
|
151
|
-
const refResults = await Promise.all(args.reference_images.map((src) =>
|
|
152
|
-
const refs = refResults
|
|
153
|
-
.filter((img) => img !== null)
|
|
154
|
-
.map((img) => ({
|
|
93
|
+
const refResults = await Promise.all(args.reference_images.map((src) => resolveImageBase64(src)));
|
|
94
|
+
const refs = refResults.map((img) => ({
|
|
155
95
|
type: 'image_url',
|
|
156
96
|
image_url: { url: `data:${img.mime};base64,${img.data}` },
|
|
157
97
|
}));
|
|
@@ -243,7 +183,6 @@ async function finalizeCompletedJob(apiClient, status, savePath) {
|
|
|
243
183
|
}
|
|
244
184
|
return { content, _meta: baseMeta };
|
|
245
185
|
}
|
|
246
|
-
// No save_path — return inline if small enough, otherwise just the URL.
|
|
247
186
|
if (buffer.length <= getMaxInlineBytes()) {
|
|
248
187
|
return {
|
|
249
188
|
content: [
|
|
@@ -274,12 +213,7 @@ export async function handleGenerateVideo(request, apiClient, progress) {
|
|
|
274
213
|
return toolError(ErrorCode.INVALID_INPUT, 'prompt is required.');
|
|
275
214
|
}
|
|
276
215
|
const model = args.model || process.env.OPENROUTER_DEFAULT_VIDEO_GEN_MODEL || FALLBACK_MODEL;
|
|
277
|
-
// Sora deprecation warning — OpenAI is removing the Videos API and all
|
|
278
|
-
// Sora 2 model aliases on September 24, 2026. Warn and suggest alternatives.
|
|
279
216
|
const deprecationWarning = checkSoraDeprecation(model);
|
|
280
|
-
// Audit entry — video is the most expensive tool we have. Always log
|
|
281
|
-
// model, resolution, duration, and a safe prompt preview so unintended
|
|
282
|
-
// spend can be traced.
|
|
283
217
|
logger.audit('generate_video.start', {
|
|
284
218
|
model,
|
|
285
219
|
prompt_preview: args.prompt.slice(0, 80),
|
|
@@ -291,25 +225,15 @@ export async function handleGenerateVideo(request, apiClient, progress) {
|
|
|
291
225
|
reference_images: args.reference_images?.length ?? 0,
|
|
292
226
|
save_path: args.save_path ? 'provided' : 'none',
|
|
293
227
|
});
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
safeSavePath = await resolveSafeOutputPath(args.save_path);
|
|
299
|
-
}
|
|
300
|
-
catch (err) {
|
|
301
|
-
if (err instanceof UnsafeOutputPathError)
|
|
302
|
-
return toolErrorFrom(ErrorCode.UNSAFE_PATH, err);
|
|
303
|
-
return toolErrorFrom(ErrorCode.INTERNAL, err);
|
|
304
|
-
}
|
|
305
|
-
}
|
|
228
|
+
const savePathResult = await resolveOptionalOutputPath(args.save_path);
|
|
229
|
+
if (isToolErrorResult(savePathResult))
|
|
230
|
+
return savePathResult;
|
|
231
|
+
const safeSavePath = savePathResult.path;
|
|
306
232
|
const body = buildRequestBody(args, model);
|
|
307
233
|
try {
|
|
308
234
|
await attachFrameImages(args, body);
|
|
309
235
|
}
|
|
310
236
|
catch (err) {
|
|
311
|
-
// Sandbox violation → UNSAFE_PATH; all other decode failures stay
|
|
312
|
-
// as UNSUPPORTED_FORMAT (couldn't read, invalid data URL, etc.).
|
|
313
237
|
if (err instanceof UnsafeOutputPathError) {
|
|
314
238
|
return toolErrorFrom(ErrorCode.UNSAFE_PATH, err, 'Reference/frame image');
|
|
315
239
|
}
|
|
@@ -359,7 +283,6 @@ export async function handleGenerateVideo(request, apiClient, progress) {
|
|
|
359
283
|
}
|
|
360
284
|
try {
|
|
361
285
|
const { content, _meta } = await finalizeCompletedJob(apiClient, outcome.status, safeSavePath);
|
|
362
|
-
// Prepend deprecation warning if applicable
|
|
363
286
|
if (deprecationWarning) {
|
|
364
287
|
content.unshift({ type: 'text', text: deprecationWarning });
|
|
365
288
|
_meta.deprecated_model = true;
|
|
@@ -378,18 +301,10 @@ export async function handleGetVideoStatus(request, apiClient) {
|
|
|
378
301
|
const id = args.video_id?.trim();
|
|
379
302
|
if (!id)
|
|
380
303
|
return toolError(ErrorCode.INVALID_INPUT, 'video_id is required.');
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
safeSavePath = await resolveSafeOutputPath(args.save_path);
|
|
386
|
-
}
|
|
387
|
-
catch (err) {
|
|
388
|
-
if (err instanceof UnsafeOutputPathError)
|
|
389
|
-
return toolErrorFrom(ErrorCode.UNSAFE_PATH, err);
|
|
390
|
-
return toolErrorFrom(ErrorCode.INTERNAL, err);
|
|
391
|
-
}
|
|
392
|
-
}
|
|
304
|
+
const savePathResult = await resolveOptionalOutputPath(args.save_path);
|
|
305
|
+
if (isToolErrorResult(savePathResult))
|
|
306
|
+
return savePathResult;
|
|
307
|
+
const safeSavePath = savePathResult.path;
|
|
393
308
|
let status;
|
|
394
309
|
try {
|
|
395
310
|
status = await apiClient.pollVideoJob(id);
|
|
@@ -1,14 +1,6 @@
|
|
|
1
1
|
import type { OpenRouterAPIClient } from '../openrouter-api.js';
|
|
2
2
|
import { ModelCache } from '../model-cache.js';
|
|
3
|
-
/**
|
|
4
|
-
* Lightweight liveness probe. Runs the following checks:
|
|
5
|
-
* - Hit `/models` via the API client (indirectly validates API key +
|
|
6
|
-
* OpenRouter reachability)
|
|
7
|
-
* - Read cached model count
|
|
8
|
-
* - Report server + protocol versions
|
|
9
|
-
*
|
|
10
|
-
* Returns `{ ok, ... }` so ops can use it as a readiness signal.
|
|
11
|
-
*/
|
|
3
|
+
/** Liveness probe — validates API key, reachability, and cached model count. */
|
|
12
4
|
export declare function handleHealthCheck(_request: {
|
|
13
5
|
params: {
|
|
14
6
|
arguments: Record<string, unknown>;
|
|
@@ -1,14 +1,6 @@
|
|
|
1
1
|
import { SERVER_VERSION, MCP_PROTOCOL_VERSION } from '../version.js';
|
|
2
2
|
import { buildStructuredResult } from './structured-output.js';
|
|
3
|
-
/**
|
|
4
|
-
* Lightweight liveness probe. Runs the following checks:
|
|
5
|
-
* - Hit `/models` via the API client (indirectly validates API key +
|
|
6
|
-
* OpenRouter reachability)
|
|
7
|
-
* - Read cached model count
|
|
8
|
-
* - Report server + protocol versions
|
|
9
|
-
*
|
|
10
|
-
* Returns `{ ok, ... }` so ops can use it as a readiness signal.
|
|
11
|
-
*/
|
|
3
|
+
/** Liveness probe — validates API key, reachability, and cached model count. */
|
|
12
4
|
export async function handleHealthCheck(_request, apiClient, modelCache) {
|
|
13
5
|
let apiKeyValid = false;
|
|
14
6
|
let errorMessage;
|
|
@@ -20,9 +12,6 @@ export async function handleHealthCheck(_request, apiClient, modelCache) {
|
|
|
20
12
|
errorMessage = err instanceof Error ? err.message : String(err);
|
|
21
13
|
}
|
|
22
14
|
const modelsCached = modelCache.isValid() ? modelCache.size() : 0;
|
|
23
|
-
// `ok` means the API was reachable and the key was accepted. An empty
|
|
24
|
-
// catalog counts as success (the API just returned no models) — callers
|
|
25
|
-
// branch on `models_cached` if they care about the count.
|
|
26
15
|
const ok = apiKeyValid;
|
|
27
16
|
return buildStructuredResult({
|
|
28
17
|
ok,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Resolve an image reference to a URL (data URL or http(s) passthrough). */
|
|
2
|
+
export declare function resolveImageUrl(ref: string): Promise<string>;
|
|
3
|
+
/** Resolve an image reference to base64 payload + mime (for video frame uploads). */
|
|
4
|
+
export declare function resolveImageBase64(source: string): Promise<{
|
|
5
|
+
mime: string;
|
|
6
|
+
data: string;
|
|
7
|
+
}>;
|
|
8
|
+
/** OpenRouter dedicated-image API reference shape. */
|
|
9
|
+
export declare function toOpenRouterImageReference(source: string): Promise<{
|
|
10
|
+
type: string;
|
|
11
|
+
image_url: {
|
|
12
|
+
url: string;
|
|
13
|
+
};
|
|
14
|
+
}>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { fetchImageWithMime } from './image-utils.js';
|
|
2
|
+
/** Resolve an image reference to a URL (data URL or http(s) passthrough). */
|
|
3
|
+
export async function resolveImageUrl(ref) {
|
|
4
|
+
const trimmed = ref.trim();
|
|
5
|
+
if (!trimmed)
|
|
6
|
+
throw new Error('empty image reference');
|
|
7
|
+
if (trimmed.startsWith('data:') || /^https?:\/\//i.test(trimmed))
|
|
8
|
+
return trimmed;
|
|
9
|
+
const { buffer, mime } = await fetchImageWithMime(trimmed);
|
|
10
|
+
return `data:${mime};base64,${buffer.toString('base64')}`;
|
|
11
|
+
}
|
|
12
|
+
/** Resolve an image reference to base64 payload + mime (for video frame uploads). */
|
|
13
|
+
export async function resolveImageBase64(source) {
|
|
14
|
+
if (!source.trim())
|
|
15
|
+
throw new Error('empty image source');
|
|
16
|
+
const { buffer, mime } = await fetchImageWithMime(source);
|
|
17
|
+
return { mime, data: buffer.toString('base64') };
|
|
18
|
+
}
|
|
19
|
+
/** OpenRouter dedicated-image API reference shape. */
|
|
20
|
+
export async function toOpenRouterImageReference(source) {
|
|
21
|
+
const url = await resolveImageUrl(source);
|
|
22
|
+
return { type: 'image_url', image_url: { url } };
|
|
23
|
+
}
|
|
@@ -24,4 +24,9 @@ export declare function optimizeImage(buffer: Buffer): Promise<{
|
|
|
24
24
|
base64: string;
|
|
25
25
|
mime: string;
|
|
26
26
|
}>;
|
|
27
|
+
/** Load image bytes with a MIME type (respects OPENROUTER_IMAGE_* fetch limits). */
|
|
28
|
+
export declare function fetchImageWithMime(source: string): Promise<{
|
|
29
|
+
buffer: Buffer;
|
|
30
|
+
mime: string;
|
|
31
|
+
}>;
|
|
27
32
|
export declare function prepareImageUrl(source: string): Promise<string>;
|
|
@@ -160,6 +160,29 @@ export async function optimizeImage(buffer) {
|
|
|
160
160
|
};
|
|
161
161
|
}
|
|
162
162
|
}
|
|
163
|
+
/** Load image bytes with a MIME type (respects OPENROUTER_IMAGE_* fetch limits). */
|
|
164
|
+
export async function fetchImageWithMime(source) {
|
|
165
|
+
if (source.startsWith('data:')) {
|
|
166
|
+
const parsed = parseBase64DataUrl(source);
|
|
167
|
+
if (!parsed)
|
|
168
|
+
throw new Error('Invalid data URL');
|
|
169
|
+
return { buffer: Buffer.from(parsed.base64, 'base64'), mime: parsed.mediaType };
|
|
170
|
+
}
|
|
171
|
+
if (source.startsWith('http://') || source.startsWith('https://')) {
|
|
172
|
+
const { buffer, contentType } = await fetchHttpResource(source, {
|
|
173
|
+
timeoutMs: getFetchTimeoutMs(),
|
|
174
|
+
maxBytes: getMaxDownloadBytes(),
|
|
175
|
+
maxRedirects: getMaxRedirects(),
|
|
176
|
+
});
|
|
177
|
+
const mime = (contentType?.split(';')[0]?.trim() ||
|
|
178
|
+
sniffImageMime(buffer) ||
|
|
179
|
+
'image/jpeg').toLowerCase();
|
|
180
|
+
return { buffer, mime };
|
|
181
|
+
}
|
|
182
|
+
const safe = await resolveSafeInputPath(source);
|
|
183
|
+
const buffer = await fs.readFile(safe);
|
|
184
|
+
return { buffer, mime: getMimeType(safe) };
|
|
185
|
+
}
|
|
163
186
|
export async function prepareImageUrl(source) {
|
|
164
187
|
if (source.startsWith('data:'))
|
|
165
188
|
return source;
|
|
@@ -1,13 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Helper for calling `openai.chat.completions.create()` and getting back
|
|
3
|
-
* BOTH the typed body and the raw fetch `Response` (so we can read the
|
|
4
|
-
* X-OpenRouter-Cache-* headers).
|
|
5
|
-
*
|
|
6
|
-
* The real openai SDK returns an `APIPromise` that exposes `.withResponse()`.
|
|
7
|
-
* Vitest tests typically stub `create()` to return a plain `ChatCompletion`
|
|
8
|
-
* object. This helper handles both cases so tests don't need to mock the
|
|
9
|
-
* chainable.
|
|
10
|
-
*/
|
|
1
|
+
/** Call `openai.chat.completions.create()` and return body + raw Response headers. */
|
|
11
2
|
import type { ChatCompletion } from 'openai/resources/chat/completions.js';
|
|
12
3
|
export interface ChatCompletionWithHeaders {
|
|
13
4
|
data: ChatCompletion;
|
|
@@ -1,16 +1,9 @@
|
|
|
1
1
|
export async function awaitCompletionWithHeaders(call) {
|
|
2
|
-
// Prefer the APIPromise .withResponse() chainable, which returns
|
|
3
|
-
// `{ data, response }` — but only if the object looks like an
|
|
4
|
-
// APIPromise. Mocks that return plain objects get unwrapped via a
|
|
5
|
-
// direct await.
|
|
6
2
|
const maybeChainable = call;
|
|
7
3
|
if (typeof maybeChainable?.withResponse === 'function') {
|
|
8
4
|
const { data, response } = await maybeChainable.withResponse();
|
|
9
5
|
return { data, response };
|
|
10
6
|
}
|
|
11
|
-
// Fallback: await the value directly. Covers both test mocks
|
|
12
|
-
// (which return a plain ChatCompletion via vi.fn().mockResolvedValue())
|
|
13
|
-
// and any exotic SDK shape we don't recognize.
|
|
14
7
|
const data = (await call);
|
|
15
8
|
return { data, response: undefined };
|
|
16
9
|
}
|
|
@@ -1,22 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* `ErrorCode` enum. Every tool handler that calls the OpenAI client routes
|
|
4
|
-
* its `catch` block through `classifyUpstreamError` so error taxonomies
|
|
5
|
-
* don't drift.
|
|
2
|
+
* Map OpenRouter / OpenAI SDK errors to our closed `ErrorCode` enum.
|
|
6
3
|
*/
|
|
7
4
|
import { type ToolErrorResult } from '../errors.js';
|
|
8
|
-
/**
|
|
9
|
-
* Classify a caught error from `openai.*` or a raw `fetch` to the
|
|
10
|
-
* OpenRouter REST API into the closed `ErrorCode` set.
|
|
11
|
-
*
|
|
12
|
-
* Matching strategy:
|
|
13
|
-
* 1. HTTP status first (when available).
|
|
14
|
-
* 2. Message heuristics for common OpenRouter strings (credits, ZDR,
|
|
15
|
-
* "model does not exist", content policy, etc.).
|
|
16
|
-
* 3. Default to INTERNAL to avoid leaking raw shapes.
|
|
17
|
-
*
|
|
18
|
-
* When the error carries a `Retry-After` header (on 429 / 503) we populate
|
|
19
|
-
* `_meta.retry_after_seconds` so agents can back off intelligently. We
|
|
20
|
-
* also attach canonical `suggestions[]` for common cases.
|
|
21
|
-
*/
|
|
5
|
+
/** Classify upstream errors into the closed `ErrorCode` set. */
|
|
22
6
|
export declare function classifyUpstreamError(err: unknown, contextMessage?: string): ToolErrorResult;
|
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* `ErrorCode` enum. Every tool handler that calls the OpenAI client routes
|
|
4
|
-
* its `catch` block through `classifyUpstreamError` so error taxonomies
|
|
5
|
-
* don't drift.
|
|
2
|
+
* Map OpenRouter / OpenAI SDK errors to our closed `ErrorCode` enum.
|
|
6
3
|
*/
|
|
7
4
|
import { ErrorCode, toolError } from '../errors.js';
|
|
8
5
|
function extractRetryAfterSeconds(err) {
|
|
@@ -24,8 +21,6 @@ function extractRetryAfterSeconds(err) {
|
|
|
24
21
|
const n = Number(raw);
|
|
25
22
|
if (Number.isFinite(n) && n >= 0)
|
|
26
23
|
return n;
|
|
27
|
-
// Retry-After can also be an HTTP-date; return undefined for those (caller
|
|
28
|
-
// can still retry on its own backoff schedule).
|
|
29
24
|
return undefined;
|
|
30
25
|
}
|
|
31
26
|
function extractStatus(err) {
|
|
@@ -34,14 +29,11 @@ function extractStatus(err) {
|
|
|
34
29
|
const s = err.status;
|
|
35
30
|
if (typeof s === 'number')
|
|
36
31
|
return s;
|
|
37
|
-
// openai-node sometimes puts the status in `code` for `APIError`.
|
|
38
32
|
const c = err.code;
|
|
39
33
|
if (typeof c === 'number')
|
|
40
34
|
return c;
|
|
41
35
|
if (typeof c === 'string' && /^\d{3}$/.test(c))
|
|
42
36
|
return parseInt(c, 10);
|
|
43
|
-
// Fall back: parse the message for `HTTP NNN` — our internal client wraps
|
|
44
|
-
// fetch failures as `POST /videos failed: HTTP 400 — <detail>`.
|
|
45
37
|
if (err instanceof Error) {
|
|
46
38
|
const m = err.message.match(/\bHTTP (\d{3})\b/);
|
|
47
39
|
if (m)
|
|
@@ -63,30 +55,13 @@ function extractMessage(err) {
|
|
|
63
55
|
return err;
|
|
64
56
|
return 'unknown error';
|
|
65
57
|
}
|
|
66
|
-
/**
|
|
67
|
-
* Classify a caught error from `openai.*` or a raw `fetch` to the
|
|
68
|
-
* OpenRouter REST API into the closed `ErrorCode` set.
|
|
69
|
-
*
|
|
70
|
-
* Matching strategy:
|
|
71
|
-
* 1. HTTP status first (when available).
|
|
72
|
-
* 2. Message heuristics for common OpenRouter strings (credits, ZDR,
|
|
73
|
-
* "model does not exist", content policy, etc.).
|
|
74
|
-
* 3. Default to INTERNAL to avoid leaking raw shapes.
|
|
75
|
-
*
|
|
76
|
-
* When the error carries a `Retry-After` header (on 429 / 503) we populate
|
|
77
|
-
* `_meta.retry_after_seconds` so agents can back off intelligently. We
|
|
78
|
-
* also attach canonical `suggestions[]` for common cases.
|
|
79
|
-
*/
|
|
58
|
+
/** Classify upstream errors into the closed `ErrorCode` set. */
|
|
80
59
|
export function classifyUpstreamError(err, contextMessage) {
|
|
81
60
|
const rawMsg = extractMessage(err);
|
|
82
61
|
const status = extractStatus(err);
|
|
83
62
|
const lower = rawMsg.toLowerCase();
|
|
84
|
-
// Prefix every user-visible message with the handler context when the
|
|
85
|
-
// caller supplied one (e.g. `rerank`, `generate_video.submit`). Makes
|
|
86
|
-
// server-side triage possible without digging through logs.
|
|
87
63
|
const fullMsg = contextMessage ? `${contextMessage}: ${rawMsg}` : rawMsg;
|
|
88
64
|
const retryAfterSeconds = extractRetryAfterSeconds(err);
|
|
89
|
-
// Explicit credit / balance signals.
|
|
90
65
|
if (lower.includes('insufficient balance') ||
|
|
91
66
|
lower.includes('insufficient credits') ||
|
|
92
67
|
lower.includes('requires more credits') ||
|
|
@@ -99,7 +74,6 @@ export function classifyUpstreamError(err, contextMessage) {
|
|
|
99
74
|
],
|
|
100
75
|
});
|
|
101
76
|
}
|
|
102
|
-
// Zero Data Retention.
|
|
103
77
|
if (lower.includes('zdr') || lower.includes('zero data retention')) {
|
|
104
78
|
return toolError(ErrorCode.ZDR_INCOMPATIBLE, fullMsg, { status }, {
|
|
105
79
|
suggestions: [
|
|
@@ -108,7 +82,6 @@ export function classifyUpstreamError(err, contextMessage) {
|
|
|
108
82
|
],
|
|
109
83
|
});
|
|
110
84
|
}
|
|
111
|
-
// Model lookup failures.
|
|
112
85
|
if (lower.includes('model') &&
|
|
113
86
|
(lower.includes('does not exist') ||
|
|
114
87
|
lower.includes('not found') ||
|
|
@@ -120,7 +93,6 @@ export function classifyUpstreamError(err, contextMessage) {
|
|
|
120
93
|
],
|
|
121
94
|
});
|
|
122
95
|
}
|
|
123
|
-
// Content policy / moderation — surface as UPSTREAM_REFUSED so callers can distinguish from 5xx.
|
|
124
96
|
if (lower.includes('content policy') ||
|
|
125
97
|
lower.includes('moderation') ||
|
|
126
98
|
lower.includes('refused')) {
|
|
@@ -128,7 +100,6 @@ export function classifyUpstreamError(err, contextMessage) {
|
|
|
128
100
|
suggestions: ['Rephrase the prompt', 'Try a different provider via provider.order'],
|
|
129
101
|
});
|
|
130
102
|
}
|
|
131
|
-
// Rate-limit specific.
|
|
132
103
|
if (status === 429 || lower.includes('rate limit')) {
|
|
133
104
|
return toolError(ErrorCode.UPSTREAM_REFUSED, fullMsg, { status, reason: 'rate_limit' }, {
|
|
134
105
|
suggestions: [
|
|
@@ -140,7 +111,6 @@ export function classifyUpstreamError(err, contextMessage) {
|
|
|
140
111
|
retry_after_seconds: retryAfterSeconds,
|
|
141
112
|
});
|
|
142
113
|
}
|
|
143
|
-
// Timeouts (AbortError from `AbortSignal.timeout`).
|
|
144
114
|
if (lower.includes('timed out') ||
|
|
145
115
|
lower.includes('timeout') ||
|
|
146
116
|
lower.includes('aborted') ||
|
|
@@ -149,11 +119,9 @@ export function classifyUpstreamError(err, contextMessage) {
|
|
|
149
119
|
suggestions: ['Retry', 'Raise max_wait_ms or max_tokens'],
|
|
150
120
|
});
|
|
151
121
|
}
|
|
152
|
-
// Anything in the 4xx band that isn't covered above — user supplied a bad request.
|
|
153
122
|
if (typeof status === 'number' && status >= 400 && status < 500) {
|
|
154
123
|
return toolError(ErrorCode.INVALID_INPUT, fullMsg, { status });
|
|
155
124
|
}
|
|
156
|
-
// 5xx / network errors.
|
|
157
125
|
if (typeof status === 'number' && status >= 500) {
|
|
158
126
|
return toolError(ErrorCode.UPSTREAM_HTTP, fullMsg, { status }, {
|
|
159
127
|
suggestions: ['Retry after a brief delay', 'Check https://status.openrouter.ai'],
|
|
@@ -1,22 +1,17 @@
|
|
|
1
|
+
import { type ToolErrorResult } from '../errors.js';
|
|
1
2
|
export declare class UnsafeOutputPathError extends Error {
|
|
2
3
|
constructor(message: string);
|
|
3
4
|
}
|
|
4
5
|
/**
|
|
5
|
-
* Resolve and validate a caller-supplied output path.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* Throws `UnsafeOutputPathError` when the resolved path escapes the root
|
|
9
|
-
* (traversal attempt) and the sandbox is enabled.
|
|
6
|
+
* Resolve and validate a caller-supplied output path.
|
|
7
|
+
* Throws `UnsafeOutputPathError` on traversal attempts.
|
|
10
8
|
*/
|
|
11
9
|
export declare function resolveSafeOutputPath(savePath: string): Promise<string>;
|
|
12
|
-
/**
|
|
13
|
-
* Resolve and validate a caller-supplied INPUT path. Unlike
|
|
14
|
-
* `resolveSafeOutputPath`, this never creates directories — it only
|
|
15
|
-
* confirms the path lives inside the input sandbox and returns the
|
|
16
|
-
* absolute path the caller can `fs.readFile` from.
|
|
17
|
-
*
|
|
18
|
-
* Accepts the same `OPENROUTER_ALLOW_UNSAFE_PATHS=1` legacy bypass.
|
|
19
|
-
* Throws `UnsafeOutputPathError` on traversal attempts (re-used type so
|
|
20
|
-
* handlers map errors uniformly to `ErrorCode.UNSAFE_PATH`).
|
|
21
|
-
*/
|
|
10
|
+
/** Resolve and validate a caller-supplied input path (read-only, no mkdir). */
|
|
22
11
|
export declare function resolveSafeInputPath(inputPath: string): Promise<string>;
|
|
12
|
+
export type OptionalOutputPath = {
|
|
13
|
+
path: string | null;
|
|
14
|
+
};
|
|
15
|
+
export declare function isToolErrorResult(result: OptionalOutputPath | ToolErrorResult): result is ToolErrorResult;
|
|
16
|
+
/** Resolve optional save_path; returns a tool error result on sandbox violation. */
|
|
17
|
+
export declare function resolveOptionalOutputPath(savePath: string | undefined): Promise<OptionalOutputPath | ToolErrorResult>;
|