@stabgan/openrouter-mcp-multimodal 2.0.0 → 3.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.
Files changed (54) hide show
  1. package/README.md +137 -43
  2. package/dist/errors.d.ts +42 -0
  3. package/dist/errors.js +46 -0
  4. package/dist/index.js +1 -1
  5. package/dist/logger.d.ts +22 -0
  6. package/dist/logger.js +47 -0
  7. package/dist/model-cache.d.ts +10 -0
  8. package/dist/model-cache.js +31 -1
  9. package/dist/openrouter-api.d.ts +54 -0
  10. package/dist/openrouter-api.js +128 -12
  11. package/dist/tool-handlers/analyze-audio.d.ts +5 -9
  12. package/dist/tool-handlers/analyze-audio.js +41 -8
  13. package/dist/tool-handlers/analyze-image.d.ts +5 -9
  14. package/dist/tool-handlers/analyze-image.js +38 -8
  15. package/dist/tool-handlers/analyze-video.d.ts +19 -0
  16. package/dist/tool-handlers/analyze-video.js +93 -0
  17. package/dist/tool-handlers/audio-utils.js +7 -9
  18. package/dist/tool-handlers/chat-completion.d.ts +6 -10
  19. package/dist/tool-handlers/chat-completion.js +27 -7
  20. package/dist/tool-handlers/completion-utils.d.ts +27 -0
  21. package/dist/tool-handlers/completion-utils.js +69 -0
  22. package/dist/tool-handlers/fetch-utils.d.ts +21 -0
  23. package/dist/tool-handlers/fetch-utils.js +166 -11
  24. package/dist/tool-handlers/generate-audio.d.ts +32 -12
  25. package/dist/tool-handlers/generate-audio.js +77 -46
  26. package/dist/tool-handlers/generate-image.d.ts +26 -10
  27. package/dist/tool-handlers/generate-image.js +79 -27
  28. package/dist/tool-handlers/generate-video.d.ts +78 -0
  29. package/dist/tool-handlers/generate-video.js +353 -0
  30. package/dist/tool-handlers/get-model-info.js +8 -2
  31. package/dist/tool-handlers/image-utils.d.ts +17 -1
  32. package/dist/tool-handlers/image-utils.js +66 -13
  33. package/dist/tool-handlers/openrouter-errors.d.ts +18 -0
  34. package/dist/tool-handlers/openrouter-errors.js +99 -0
  35. package/dist/tool-handlers/path-safety.d.ts +11 -0
  36. package/dist/tool-handlers/path-safety.js +88 -0
  37. package/dist/tool-handlers/search-models.js +1 -3
  38. package/dist/tool-handlers/validate-model.js +8 -2
  39. package/dist/tool-handlers/video-utils.d.ts +29 -0
  40. package/dist/tool-handlers/video-utils.js +174 -0
  41. package/dist/tool-handlers.js +199 -21
  42. package/package.json +3 -3
  43. package/dist/__tests__/audio-utils.test.d.ts +0 -1
  44. package/dist/__tests__/audio-utils.test.js +0 -120
  45. package/dist/__tests__/fetch-utils.test.d.ts +0 -1
  46. package/dist/__tests__/fetch-utils.test.js +0 -76
  47. package/dist/__tests__/generate-audio.test.d.ts +0 -1
  48. package/dist/__tests__/generate-audio.test.js +0 -90
  49. package/dist/__tests__/image-utils.test.d.ts +0 -1
  50. package/dist/__tests__/image-utils.test.js +0 -75
  51. package/dist/__tests__/integration.test.d.ts +0 -1
  52. package/dist/__tests__/integration.test.js +0 -219
  53. package/dist/__tests__/model-cache.test.d.ts +0 -1
  54. package/dist/__tests__/model-cache.test.js +0 -96
@@ -1,6 +1,6 @@
1
1
  import path from 'path';
2
2
  import { promises as fs } from 'fs';
3
- import { readEnvInt, isBlockedIPv4 as _isBlockedIPv4, assertUrlSafeForFetch as _assertUrlSafeForFetch, fetchHttpResource, } from './fetch-utils.js';
3
+ import { readEnvInt, isBlockedIPv4 as _isBlockedIPv4, assertUrlSafeForFetch as _assertUrlSafeForFetch, fetchHttpResource, parseBase64DataUrl, } from './fetch-utils.js';
4
4
  // Re-export for backward compatibility (tests import from image-utils)
5
5
  export const isBlockedIPv4 = _isBlockedIPv4;
6
6
  export const assertUrlSafeForFetch = _assertUrlSafeForFetch;
@@ -66,24 +66,71 @@ export async function fetchHttpImage(urlString) {
66
66
  }
67
67
  export async function fetchImage(source) {
68
68
  if (source.startsWith('data:')) {
69
- const match = source.match(/^data:[^;]+;base64,(.+)$/);
70
- if (!match)
69
+ const parsed = parseBase64DataUrl(source);
70
+ if (!parsed)
71
71
  throw new Error('Invalid data URL');
72
- const b64 = match[1];
73
- const approxBytes = Math.ceil((b64.length * 3) / 4);
72
+ const approxBytes = Math.ceil((parsed.base64.length * 3) / 4);
74
73
  if (approxBytes > getMaxDataUrlBytes())
75
74
  throw new Error('Data URL too large');
76
- return Buffer.from(b64, 'base64');
75
+ return Buffer.from(parsed.base64, 'base64');
77
76
  }
78
77
  if (source.startsWith('http://') || source.startsWith('https://')) {
79
78
  return fetchHttpImage(source);
80
79
  }
81
80
  return fs.readFile(source);
82
81
  }
82
+ /**
83
+ * Sniff image MIME type from magic bytes. Used to label the output of a
84
+ * failed `sharp` optimization (where we return original bytes but don't
85
+ * know the source MIME yet) and HTTP image responses whose Content-Type
86
+ * header is missing or wrong.
87
+ */
88
+ export function sniffImageMime(buffer) {
89
+ if (buffer.length < 4)
90
+ return null;
91
+ // PNG: 89 50 4E 47 0D 0A 1A 0A
92
+ if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) {
93
+ return 'image/png';
94
+ }
95
+ // JPEG: FF D8 FF
96
+ if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
97
+ return 'image/jpeg';
98
+ }
99
+ // GIF: 47 49 46 38
100
+ if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x38) {
101
+ return 'image/gif';
102
+ }
103
+ // WebP: RIFF....WEBP
104
+ if (buffer.length >= 12 &&
105
+ buffer[0] === 0x52 &&
106
+ buffer[1] === 0x49 &&
107
+ buffer[2] === 0x46 &&
108
+ buffer[3] === 0x46 &&
109
+ buffer[8] === 0x57 &&
110
+ buffer[9] === 0x45 &&
111
+ buffer[10] === 0x42 &&
112
+ buffer[11] === 0x50) {
113
+ return 'image/webp';
114
+ }
115
+ // BMP
116
+ if (buffer[0] === 0x42 && buffer[1] === 0x4d)
117
+ return 'image/bmp';
118
+ return null;
119
+ }
120
+ /**
121
+ * Optimize an image buffer and return both the base64 payload AND the MIME
122
+ * type that matches that payload. Callers should NOT assume JPEG — the
123
+ * pipeline falls back to the original bytes (with its detected MIME) when
124
+ * sharp is unavailable or fails. This closes BUG-012.
125
+ */
83
126
  export async function optimizeImage(buffer) {
84
127
  const sharp = await loadSharp();
85
- if (!sharp)
86
- return buffer.toString('base64');
128
+ if (!sharp) {
129
+ return {
130
+ base64: buffer.toString('base64'),
131
+ mime: sniffImageMime(buffer) ?? 'application/octet-stream',
132
+ };
133
+ }
87
134
  const maxDim = getMaxImageDimension();
88
135
  const quality = getImageJpegQuality();
89
136
  try {
@@ -94,17 +141,23 @@ export async function optimizeImage(buffer) {
94
141
  pipeline = pipeline.resize(opts);
95
142
  }
96
143
  const out = await pipeline.jpeg({ quality }).toBuffer();
97
- return out.toString('base64');
144
+ return { base64: out.toString('base64'), mime: 'image/jpeg' };
98
145
  }
99
146
  catch {
100
- return buffer.toString('base64');
147
+ return {
148
+ base64: buffer.toString('base64'),
149
+ mime: sniffImageMime(buffer) ?? 'application/octet-stream',
150
+ };
101
151
  }
102
152
  }
103
153
  export async function prepareImageUrl(source) {
104
154
  if (source.startsWith('data:'))
105
155
  return source;
106
156
  const buffer = await fetchImage(source);
107
- const base64 = await optimizeImage(buffer);
108
- const mime = source.startsWith('http') ? 'image/jpeg' : getMimeType(source);
109
- return `data:${mime};base64,${base64}`;
157
+ const { base64, mime } = await optimizeImage(buffer);
158
+ // When optimization succeeded, mime is 'image/jpeg'. When it failed, we
159
+ // use the sniffed mime. For local files we prefer the extension-derived
160
+ // mime (more specific) when optimization fell back.
161
+ const finalMime = mime === 'image/jpeg' || source.startsWith('http') ? mime : getMimeType(source);
162
+ return `data:${finalMime};base64,${base64}`;
110
163
  }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Shared mapping from OpenRouter / OpenAI SDK error shapes to our closed
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.
6
+ */
7
+ 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
+ export declare function classifyUpstreamError(err: unknown, _contextMessage?: string): ToolErrorResult;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Shared mapping from OpenRouter / OpenAI SDK error shapes to our closed
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.
6
+ */
7
+ import { ErrorCode, toolError } from '../errors.js';
8
+ function extractStatus(err) {
9
+ if (typeof err !== 'object' || err === null)
10
+ return undefined;
11
+ const s = err.status;
12
+ if (typeof s === 'number')
13
+ return s;
14
+ // openai-node sometimes puts the status in `code` for `APIError`.
15
+ const c = err.code;
16
+ if (typeof c === 'number')
17
+ return c;
18
+ if (typeof c === 'string' && /^\d{3}$/.test(c))
19
+ return parseInt(c, 10);
20
+ // Fall back: parse the message for `HTTP NNN` — our internal client wraps
21
+ // fetch failures as `POST /videos failed: HTTP 400 — <detail>`.
22
+ if (err instanceof Error) {
23
+ const m = err.message.match(/\bHTTP (\d{3})\b/);
24
+ if (m)
25
+ return parseInt(m[1], 10);
26
+ }
27
+ return undefined;
28
+ }
29
+ function extractMessage(err) {
30
+ if (err instanceof Error) {
31
+ const nested = err.error;
32
+ if (nested && typeof nested === 'object' && typeof nested.message === 'string') {
33
+ return `${err.message} — ${nested.message}`;
34
+ }
35
+ if (typeof nested === 'string')
36
+ return `${err.message} — ${nested}`;
37
+ return err.message;
38
+ }
39
+ if (typeof err === 'string')
40
+ return err;
41
+ return 'unknown error';
42
+ }
43
+ /**
44
+ * Classify a caught error from `openai.*` or a raw `fetch` to the
45
+ * OpenRouter REST API into the closed `ErrorCode` set.
46
+ *
47
+ * Matching strategy:
48
+ * 1. HTTP status first (when available).
49
+ * 2. Message heuristics for common OpenRouter strings (credits, ZDR,
50
+ * "model does not exist", content policy, etc.).
51
+ * 3. Default to INTERNAL to avoid leaking raw shapes.
52
+ */
53
+ export function classifyUpstreamError(err, _contextMessage) {
54
+ const msg = extractMessage(err);
55
+ const status = extractStatus(err);
56
+ const lower = msg.toLowerCase();
57
+ const fullMsg = msg;
58
+ // Explicit credit / balance signals.
59
+ if (lower.includes('insufficient balance') ||
60
+ lower.includes('insufficient credits') ||
61
+ lower.includes('requires more credits') ||
62
+ lower.includes('requires at least') ||
63
+ status === 402) {
64
+ return toolError(ErrorCode.UPSTREAM_REFUSED, fullMsg, { status, reason: 'credits' });
65
+ }
66
+ // Zero Data Retention.
67
+ if (lower.includes('zdr') || lower.includes('zero data retention')) {
68
+ return toolError(ErrorCode.ZDR_INCOMPATIBLE, fullMsg, { status });
69
+ }
70
+ // Model lookup failures.
71
+ if (lower.includes('model') &&
72
+ (lower.includes('does not exist') || lower.includes('not found') || lower.includes('invalid model'))) {
73
+ return toolError(ErrorCode.MODEL_NOT_FOUND, fullMsg, { status });
74
+ }
75
+ // Content policy / moderation — surface as UPSTREAM_REFUSED so callers can distinguish from 5xx.
76
+ if (lower.includes('content policy') || lower.includes('moderation') || lower.includes('refused')) {
77
+ return toolError(ErrorCode.UPSTREAM_REFUSED, fullMsg, { status, reason: 'policy' });
78
+ }
79
+ // Rate-limit specific.
80
+ if (status === 429 || lower.includes('rate limit')) {
81
+ return toolError(ErrorCode.UPSTREAM_REFUSED, fullMsg, { status, reason: 'rate_limit' });
82
+ }
83
+ // Timeouts (AbortError from `AbortSignal.timeout`).
84
+ if (lower.includes('timed out') ||
85
+ lower.includes('timeout') ||
86
+ lower.includes('aborted') ||
87
+ (err instanceof Error && err.name === 'AbortError')) {
88
+ return toolError(ErrorCode.UPSTREAM_TIMEOUT, fullMsg, { status });
89
+ }
90
+ // Anything in the 4xx band that isn't covered above — user supplied a bad request.
91
+ if (typeof status === 'number' && status >= 400 && status < 500) {
92
+ return toolError(ErrorCode.INVALID_INPUT, fullMsg, { status });
93
+ }
94
+ // 5xx / network errors.
95
+ if (typeof status === 'number' && status >= 500) {
96
+ return toolError(ErrorCode.UPSTREAM_HTTP, fullMsg, { status });
97
+ }
98
+ return toolError(ErrorCode.UPSTREAM_HTTP, fullMsg);
99
+ }
@@ -0,0 +1,11 @@
1
+ export declare class UnsafeOutputPathError extends Error {
2
+ constructor(message: string);
3
+ }
4
+ /**
5
+ * Resolve and validate a caller-supplied output path. Creates the parent
6
+ * directory if needed. Returns the absolute path that is safe to write.
7
+ *
8
+ * Throws `UnsafeOutputPathError` when the resolved path escapes the root
9
+ * (traversal attempt) and the sandbox is enabled.
10
+ */
11
+ export declare function resolveSafeOutputPath(savePath: string): Promise<string>;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Output-path sandbox. Tools that write files (`generate_image`,
3
+ * `generate_audio`, future `generate_video`) route their `save_path`
4
+ * through `resolveSafeOutputPath` so an untrusted MCP caller cannot
5
+ * traverse outside the configured output root.
6
+ *
7
+ * Root resolution order:
8
+ * 1. `OPENROUTER_OUTPUT_DIR` env var (if set and non-empty).
9
+ * 2. `process.cwd()`.
10
+ *
11
+ * Set `OPENROUTER_ALLOW_UNSAFE_PATHS=1` to disable the sandbox entirely
12
+ * (legacy v2 behavior). This is discouraged — document the trade-off
13
+ * where it appears in user configs.
14
+ */
15
+ import path from 'node:path';
16
+ import { promises as fs } from 'node:fs';
17
+ function getOutputRoot() {
18
+ const override = process.env.OPENROUTER_OUTPUT_DIR;
19
+ if (override && override.length > 0)
20
+ return path.resolve(override);
21
+ return process.cwd();
22
+ }
23
+ function isUnsafeMode() {
24
+ const v = process.env.OPENROUTER_ALLOW_UNSAFE_PATHS;
25
+ return v === '1' || v?.toLowerCase() === 'true';
26
+ }
27
+ export class UnsafeOutputPathError extends Error {
28
+ constructor(message) {
29
+ super(message);
30
+ this.name = 'UnsafeOutputPathError';
31
+ }
32
+ }
33
+ /**
34
+ * Resolve and validate a caller-supplied output path. Creates the parent
35
+ * directory if needed. Returns the absolute path that is safe to write.
36
+ *
37
+ * Throws `UnsafeOutputPathError` when the resolved path escapes the root
38
+ * (traversal attempt) and the sandbox is enabled.
39
+ */
40
+ export async function resolveSafeOutputPath(savePath) {
41
+ if (isUnsafeMode()) {
42
+ const abs = path.resolve(savePath);
43
+ await fs.mkdir(path.dirname(abs), { recursive: true });
44
+ return abs;
45
+ }
46
+ const root = getOutputRoot();
47
+ const rootReal = await fs.realpath(root).catch(() => path.resolve(root));
48
+ // Resolve relative paths against the real root; absolute paths stay as
49
+ // given so we can check them against the root prefix below.
50
+ const candidate = path.isAbsolute(savePath)
51
+ ? path.resolve(savePath)
52
+ : path.resolve(rootReal, savePath);
53
+ // Walk up from the candidate dir to find the first component that exists
54
+ // so we can realpath it. This lets us create new subdirectories under the
55
+ // root while still catching symlink-based traversal.
56
+ const withSep = rootReal.endsWith(path.sep) ? rootReal : rootReal + path.sep;
57
+ const candidateDir = path.dirname(candidate);
58
+ const existingAncestor = await findExistingAncestor(candidateDir);
59
+ const ancestorReal = await fs.realpath(existingAncestor);
60
+ // The realpath of the first-existing ancestor MUST be within the root.
61
+ if (!(ancestorReal === rootReal || ancestorReal.startsWith(withSep))) {
62
+ throw new UnsafeOutputPathError(`save_path resolves outside OPENROUTER_OUTPUT_DIR (${rootReal}). ` +
63
+ `Set OPENROUTER_OUTPUT_DIR to a wider root or OPENROUTER_ALLOW_UNSAFE_PATHS=1 to disable this check.`);
64
+ }
65
+ // Safe to create missing intermediate directories now.
66
+ await fs.mkdir(candidateDir, { recursive: true });
67
+ // Re-realpath the final parent in case mkdir traversed a symlink.
68
+ const parentReal = await fs.realpath(candidateDir);
69
+ if (!(parentReal === rootReal || parentReal.startsWith(withSep))) {
70
+ throw new UnsafeOutputPathError(`save_path escapes OPENROUTER_OUTPUT_DIR via symlink (${rootReal}).`);
71
+ }
72
+ return path.join(parentReal, path.basename(candidate));
73
+ }
74
+ async function findExistingAncestor(dir) {
75
+ let current = dir;
76
+ for (;;) {
77
+ try {
78
+ await fs.access(current);
79
+ return current;
80
+ }
81
+ catch {
82
+ const parent = path.dirname(current);
83
+ if (parent === current)
84
+ return current; // reached root
85
+ current = parent;
86
+ }
87
+ }
88
+ }
@@ -1,8 +1,6 @@
1
1
  export async function handleSearchModels(request, apiClient, modelCache) {
2
2
  try {
3
- if (!modelCache.isValid()) {
4
- modelCache.setModels(await apiClient.getModels());
5
- }
3
+ await modelCache.ensureFresh(() => apiClient.getModels());
6
4
  const results = modelCache.search(request.params.arguments);
7
5
  return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
8
6
  }
@@ -1,6 +1,12 @@
1
1
  export async function handleValidateModel(request, modelCache, apiClient) {
2
- if (!modelCache.isValid() && apiClient) {
3
- modelCache.setModels(await apiClient.getModels());
2
+ if (apiClient) {
3
+ try {
4
+ await modelCache.ensureFresh(() => apiClient.getModels());
5
+ }
6
+ catch (error) {
7
+ const msg = error instanceof Error ? error.message : String(error);
8
+ return { content: [{ type: 'text', text: `Error: ${msg}` }], isError: true };
9
+ }
4
10
  }
5
11
  if (!modelCache.isValid()) {
6
12
  return { content: [{ type: 'text', text: 'No model data available.' }], isError: true };
@@ -0,0 +1,29 @@
1
+ export { isBlockedIPv4, assertUrlSafeForFetch } from './fetch-utils.js';
2
+ export declare const SUPPORTED_VIDEO_FORMATS: readonly ["mp4", "mpeg", "mov", "webm"];
3
+ export type VideoFormat = (typeof SUPPORTED_VIDEO_FORMATS)[number];
4
+ /** Map a file extension (without the dot) to a canonical VideoFormat. */
5
+ export declare function getVideoFormat(filePath: string): VideoFormat | undefined;
6
+ /** Canonical MIME type for each format. */
7
+ export declare function getVideoMimeType(format: VideoFormat): string;
8
+ /**
9
+ * Detect a container from the first bytes of a buffer. Recognizes mp4/mov
10
+ * (`ftyp` box at offset 4), webm (EBML magic `1A 45 DF A3`), and MPEG-PS
11
+ * (`00 00 01 BA` / `00 00 01 B3`). Returns `undefined` if no match.
12
+ *
13
+ * Intentionally conservative: if the magic doesn't match, the caller falls
14
+ * back to the filename / Content-Type.
15
+ */
16
+ export declare function detectVideoFormat(buffer: Buffer): VideoFormat | undefined;
17
+ export interface VideoData {
18
+ data: string;
19
+ format: VideoFormat;
20
+ mediaType: string;
21
+ sizeBytes: number;
22
+ }
23
+ /**
24
+ * Prepare a video from any source (data URL / HTTP URL / local file) as
25
+ * base64 + MIME. OpenRouter requires the client to send video as either a
26
+ * URL or a data URL; we always base64 it so the tool works regardless of
27
+ * provider quirks.
28
+ */
29
+ export declare function prepareVideoData(source: string): Promise<VideoData>;
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Video format detection and fetch utilities. Mirrors the structure of
3
+ * `audio-utils.ts`: all network/security logic comes from `fetch-utils.ts`,
4
+ * this module owns format detection, base64 encoding, and MIME mapping.
5
+ *
6
+ * OpenRouter's video-understanding docs (accessed 2026-04-20) list four
7
+ * supported container formats: mp4, mpeg, mov, webm.
8
+ */
9
+ import path from 'node:path';
10
+ import { promises as fs } from 'node:fs';
11
+ import { readEnvInt, fetchHttpResource, parseBase64DataUrl } from './fetch-utils.js';
12
+ export { isBlockedIPv4, assertUrlSafeForFetch } from './fetch-utils.js';
13
+ const DEFAULT_FETCH_TIMEOUT_MS = 60_000;
14
+ const DEFAULT_MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024; // 100 MB
15
+ const DEFAULT_MAX_REDIRECTS = 8;
16
+ const DEFAULT_MAX_DATA_URL_BYTES = 100 * 1024 * 1024;
17
+ function getFetchTimeoutMs() {
18
+ return readEnvInt('OPENROUTER_VIDEO_FETCH_TIMEOUT_MS', DEFAULT_FETCH_TIMEOUT_MS, 1000);
19
+ }
20
+ function getMaxDownloadBytes() {
21
+ return readEnvInt('OPENROUTER_VIDEO_MAX_DOWNLOAD_BYTES', DEFAULT_MAX_DOWNLOAD_BYTES, 1024);
22
+ }
23
+ function getMaxRedirects() {
24
+ return readEnvInt('OPENROUTER_VIDEO_MAX_REDIRECTS', DEFAULT_MAX_REDIRECTS, 0);
25
+ }
26
+ function getMaxDataUrlBytes() {
27
+ return readEnvInt('OPENROUTER_VIDEO_MAX_DATA_URL_BYTES', DEFAULT_MAX_DATA_URL_BYTES, 1024);
28
+ }
29
+ export const SUPPORTED_VIDEO_FORMATS = ['mp4', 'mpeg', 'mov', 'webm'];
30
+ /** Map a file extension (without the dot) to a canonical VideoFormat. */
31
+ export function getVideoFormat(filePath) {
32
+ const ext = path.extname(filePath).toLowerCase().slice(1);
33
+ const alias = {
34
+ mp4: 'mp4',
35
+ m4v: 'mp4',
36
+ mpeg: 'mpeg',
37
+ mpg: 'mpeg',
38
+ mov: 'mov',
39
+ qt: 'mov',
40
+ webm: 'webm',
41
+ };
42
+ return alias[ext];
43
+ }
44
+ /** Canonical MIME type for each format. */
45
+ export function getVideoMimeType(format) {
46
+ const map = {
47
+ mp4: 'video/mp4',
48
+ mpeg: 'video/mpeg',
49
+ mov: 'video/mov',
50
+ webm: 'video/webm',
51
+ };
52
+ return map[format];
53
+ }
54
+ function mimeSubtypeToFormat(subtype) {
55
+ const aliasMap = {
56
+ mp4: 'mp4',
57
+ 'x-m4v': 'mp4',
58
+ mpeg: 'mpeg',
59
+ mov: 'mov',
60
+ quicktime: 'mov',
61
+ 'x-quicktime': 'mov',
62
+ webm: 'webm',
63
+ };
64
+ const lower = subtype.toLowerCase();
65
+ return aliasMap[lower];
66
+ }
67
+ function formatFromContentType(ct) {
68
+ if (!ct)
69
+ return undefined;
70
+ const mime = ct.split(';')[0].trim().toLowerCase();
71
+ if (!mime.startsWith('video/'))
72
+ return undefined;
73
+ return mimeSubtypeToFormat(mime.slice(6));
74
+ }
75
+ /**
76
+ * Detect a container from the first bytes of a buffer. Recognizes mp4/mov
77
+ * (`ftyp` box at offset 4), webm (EBML magic `1A 45 DF A3`), and MPEG-PS
78
+ * (`00 00 01 BA` / `00 00 01 B3`). Returns `undefined` if no match.
79
+ *
80
+ * Intentionally conservative: if the magic doesn't match, the caller falls
81
+ * back to the filename / Content-Type.
82
+ */
83
+ export function detectVideoFormat(buffer) {
84
+ if (buffer.length >= 12) {
85
+ const box = buffer.subarray(4, 8).toString('ascii');
86
+ if (box === 'ftyp') {
87
+ // Brand tells mp4 vs mov. 'qt ' and 'mov ' indicate QuickTime.
88
+ const brand = buffer.subarray(8, 12).toString('ascii');
89
+ if (brand === 'qt ' || brand === 'mov ')
90
+ return 'mov';
91
+ return 'mp4';
92
+ }
93
+ }
94
+ if (buffer.length >= 4) {
95
+ // EBML header — WebM & Matroska.
96
+ if (buffer[0] === 0x1a &&
97
+ buffer[1] === 0x45 &&
98
+ buffer[2] === 0xdf &&
99
+ buffer[3] === 0xa3) {
100
+ return 'webm';
101
+ }
102
+ // MPEG-PS / MPEG-TS start codes.
103
+ if (buffer[0] === 0x00 &&
104
+ buffer[1] === 0x00 &&
105
+ buffer[2] === 0x01 &&
106
+ (buffer[3] === 0xba || buffer[3] === 0xb3 || buffer[3] === 0xe0)) {
107
+ return 'mpeg';
108
+ }
109
+ }
110
+ return undefined;
111
+ }
112
+ /**
113
+ * Prepare a video from any source (data URL / HTTP URL / local file) as
114
+ * base64 + MIME. OpenRouter requires the client to send video as either a
115
+ * URL or a data URL; we always base64 it so the tool works regardless of
116
+ * provider quirks.
117
+ */
118
+ export async function prepareVideoData(source) {
119
+ // --- data URL ---
120
+ if (source.startsWith('data:')) {
121
+ const parsed = parseBase64DataUrl(source);
122
+ if (!parsed)
123
+ throw new Error('Invalid video data URL');
124
+ if (!parsed.mediaType.startsWith('video/')) {
125
+ throw new Error(`Data URL is not a video/* MIME: ${parsed.mediaType}`);
126
+ }
127
+ const format = mimeSubtypeToFormat(parsed.mediaType.slice(6));
128
+ if (!format) {
129
+ throw new Error(`Unsupported video format from MIME: ${parsed.mediaType}. Supported: ${SUPPORTED_VIDEO_FORMATS.join(', ')}`);
130
+ }
131
+ const approxBytes = Math.ceil((parsed.base64.length * 3) / 4);
132
+ if (approxBytes > getMaxDataUrlBytes())
133
+ throw new Error('Video data URL too large');
134
+ return {
135
+ data: parsed.base64,
136
+ format,
137
+ mediaType: getVideoMimeType(format),
138
+ sizeBytes: approxBytes,
139
+ };
140
+ }
141
+ // --- HTTP(S) URL ---
142
+ if (source.startsWith('http://') || source.startsWith('https://')) {
143
+ const { buffer, contentType } = await fetchHttpResource(source, {
144
+ timeoutMs: getFetchTimeoutMs(),
145
+ maxBytes: getMaxDownloadBytes(),
146
+ maxRedirects: getMaxRedirects(),
147
+ });
148
+ const urlPath = new URL(source).pathname;
149
+ const format = detectVideoFormat(buffer) ??
150
+ getVideoFormat(urlPath) ??
151
+ formatFromContentType(contentType);
152
+ if (!format) {
153
+ throw new Error(`Could not determine video format from ${source}. Supported: ${SUPPORTED_VIDEO_FORMATS.join(', ')}`);
154
+ }
155
+ return {
156
+ data: buffer.toString('base64'),
157
+ format,
158
+ mediaType: getVideoMimeType(format),
159
+ sizeBytes: buffer.length,
160
+ };
161
+ }
162
+ // --- local file ---
163
+ const buffer = await fs.readFile(source);
164
+ const format = detectVideoFormat(buffer) ?? getVideoFormat(source);
165
+ if (!format) {
166
+ throw new Error(`Unsupported video format for file: ${source}. Supported: ${SUPPORTED_VIDEO_FORMATS.join(', ')}`);
167
+ }
168
+ return {
169
+ data: buffer.toString('base64'),
170
+ format,
171
+ mediaType: getVideoMimeType(format),
172
+ sizeBytes: buffer.length,
173
+ };
174
+ }