@stabgan/openrouter-mcp-multimodal 2.0.0 → 3.1.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 (57) hide show
  1. package/README.md +198 -145
  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 +136 -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 +32 -0
  23. package/dist/tool-handlers/fetch-utils.js +216 -12
  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 +48 -10
  27. package/dist/tool-handlers/generate-image.js +148 -33
  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.d.ts +2 -9
  31. package/dist/tool-handlers/get-model-info.js +15 -5
  32. package/dist/tool-handlers/image-utils.d.ts +17 -1
  33. package/dist/tool-handlers/image-utils.js +66 -13
  34. package/dist/tool-handlers/openrouter-errors.d.ts +18 -0
  35. package/dist/tool-handlers/openrouter-errors.js +99 -0
  36. package/dist/tool-handlers/path-safety.d.ts +11 -0
  37. package/dist/tool-handlers/path-safety.js +88 -0
  38. package/dist/tool-handlers/search-models.d.ts +2 -9
  39. package/dist/tool-handlers/search-models.js +10 -6
  40. package/dist/tool-handlers/validate-model.d.ts +2 -9
  41. package/dist/tool-handlers/validate-model.js +15 -4
  42. package/dist/tool-handlers/video-utils.d.ts +29 -0
  43. package/dist/tool-handlers/video-utils.js +174 -0
  44. package/dist/tool-handlers.js +229 -21
  45. package/package.json +3 -3
  46. package/dist/__tests__/audio-utils.test.d.ts +0 -1
  47. package/dist/__tests__/audio-utils.test.js +0 -120
  48. package/dist/__tests__/fetch-utils.test.d.ts +0 -1
  49. package/dist/__tests__/fetch-utils.test.js +0 -76
  50. package/dist/__tests__/generate-audio.test.d.ts +0 -1
  51. package/dist/__tests__/generate-audio.test.js +0 -90
  52. package/dist/__tests__/image-utils.test.d.ts +0 -1
  53. package/dist/__tests__/image-utils.test.js +0 -75
  54. package/dist/__tests__/integration.test.d.ts +0 -1
  55. package/dist/__tests__/integration.test.js +0 -219
  56. package/dist/__tests__/model-cache.test.d.ts +0 -1
  57. package/dist/__tests__/model-cache.test.js +0 -96
@@ -1,14 +1,17 @@
1
1
  import { promises as fs } from 'fs';
2
- import { dirname, extname } from 'path';
2
+ import { extname } from 'path';
3
+ import { resolveSafeOutputPath, UnsafeOutputPathError } from './path-safety.js';
4
+ import { ErrorCode, toolError, toolErrorFrom } from '../errors.js';
5
+ import { classifyUpstreamError } from './openrouter-errors.js';
3
6
  const DEFAULT_MODEL = 'openai/gpt-audio';
4
7
  const DEFAULT_VOICE = 'alloy';
5
8
  const DEFAULT_FORMAT = 'pcm16';
6
9
  const VALID_FORMATS = ['wav', 'mp3', 'flac', 'opus', 'pcm16'];
7
- const PCM_SAMPLE_RATE = 24000;
10
+ const DEFAULT_PCM_SAMPLE_RATE = 24000;
8
11
  const PCM_BITS_PER_SAMPLE = 16;
9
12
  const PCM_NUM_CHANNELS = 1;
10
- /** Create a 44-byte WAV header for raw PCM16 data. */
11
- export function createWavHeader(dataLength, sampleRate = PCM_SAMPLE_RATE) {
13
+ /** Create a 44-byte WAV header for raw PCM16 data at `sampleRate` Hz. */
14
+ export function createWavHeader(dataLength, sampleRate = DEFAULT_PCM_SAMPLE_RATE) {
12
15
  const header = Buffer.alloc(44);
13
16
  const byteRate = sampleRate * PCM_NUM_CHANNELS * (PCM_BITS_PER_SAMPLE / 8);
14
17
  const blockAlign = PCM_NUM_CHANNELS * (PCM_BITS_PER_SAMPLE / 8);
@@ -28,20 +31,31 @@ export function createWavHeader(dataLength, sampleRate = PCM_SAMPLE_RATE) {
28
31
  return header;
29
32
  }
30
33
  /**
31
- * Detect audio container format from magic bytes.
32
- * Uses subarray (not deprecated slice). Tighter MP3 frame sync validation.
34
+ * Detect audio container format from magic bytes. Uses `Buffer.subarray()`
35
+ * (not deprecated `slice()`). MP3 detection is intentionally strict:
36
+ * - Accept ID3v2 tags (`'ID3'`) as unambiguous MP3.
37
+ * - Accept raw frame sync only when every MPEG header field falls in a
38
+ * non-reserved range: version != 0b01, layer != 0b00, bitrate != 0b1111,
39
+ * sample rate index != 0b11. This removes the false positives that a
40
+ * sync-word-only check produces on random binary.
33
41
  */
34
42
  export function detectAudioFormat(data) {
35
- if (data.length >= 3) {
36
- if (data[0] === 0x49 && data[1] === 0x44 && data[2] === 0x33) {
43
+ if (data.length >= 3 && data[0] === 0x49 && data[1] === 0x44 && data[2] === 0x33) {
44
+ return { ext: 'mp3', mimeType: 'audio/mpeg' };
45
+ }
46
+ if (data.length >= 4 && data[0] === 0xff && (data[1] & 0xe0) === 0xe0) {
47
+ const b1 = data[1];
48
+ const b2 = data[2];
49
+ const versionBits = (b1 >> 3) & 0x03; // 01 = reserved
50
+ const layerBits = (b1 >> 1) & 0x03; // 00 = reserved
51
+ const bitrateIndex = (b2 >> 4) & 0x0f; // 1111 = bad
52
+ const sampleRateIndex = (b2 >> 2) & 0x03; // 11 = reserved
53
+ if (versionBits !== 0x01 &&
54
+ layerBits !== 0x00 &&
55
+ bitrateIndex !== 0x0f &&
56
+ sampleRateIndex !== 0x03) {
37
57
  return { ext: 'mp3', mimeType: 'audio/mpeg' };
38
58
  }
39
- if (data[0] === 0xff && (data[1] & 0xe0) === 0xe0) {
40
- const versionBits = (data[1] >> 3) & 0x03;
41
- if (versionBits !== 0x01) {
42
- return { ext: 'mp3', mimeType: 'audio/mpeg' };
43
- }
44
- }
45
59
  }
46
60
  if (data.length >= 12) {
47
61
  const riff = data.subarray(0, 4).toString('ascii');
@@ -59,8 +73,8 @@ export function detectAudioFormat(data) {
59
73
  }
60
74
  return { ext: 'pcm', mimeType: 'audio/pcm' };
61
75
  }
62
- export function wrapPcmInWav(pcmData) {
63
- return Buffer.concat([createWavHeader(pcmData.length), pcmData]);
76
+ export function wrapPcmInWav(pcmData, sampleRate = DEFAULT_PCM_SAMPLE_RATE) {
77
+ return Buffer.concat([createWavHeader(pcmData.length, sampleRate), pcmData]);
64
78
  }
65
79
  /** Strip existing extension (if any) and append a new one. */
66
80
  export function replaceExtension(filePath, newExt) {
@@ -69,39 +83,62 @@ export function replaceExtension(filePath, newExt) {
69
83
  return `${base}.${newExt}`;
70
84
  }
71
85
  export async function handleGenerateAudio(request, openai) {
72
- const { prompt, model, voice, format, save_path } = request.params.arguments;
86
+ const { prompt, model, voice, format, save_path } = request.params.arguments ?? {
87
+ prompt: '',
88
+ };
73
89
  if (!prompt?.trim()) {
74
- return { content: [{ type: 'text', text: 'Prompt is required.' }], isError: true };
90
+ return toolError(ErrorCode.INVALID_INPUT, 'prompt is required.');
91
+ }
92
+ // Fail-fast on unsafe paths BEFORE spending tokens.
93
+ let safeBase = null;
94
+ if (save_path) {
95
+ try {
96
+ safeBase = await resolveSafeOutputPath(save_path);
97
+ }
98
+ catch (e) {
99
+ if (e instanceof UnsafeOutputPathError)
100
+ return toolErrorFrom(ErrorCode.UNSAFE_PATH, e);
101
+ return toolErrorFrom(ErrorCode.INTERNAL, e);
102
+ }
75
103
  }
76
104
  const selectedFormat = VALID_FORMATS.includes(format ?? '')
77
105
  ? format
78
106
  : DEFAULT_FORMAT;
79
107
  const selectedVoice = voice?.trim() || DEFAULT_VOICE;
108
+ let stream;
80
109
  try {
81
- const stream = await openai.chat.completions.create({
110
+ stream = (await openai.chat.completions.create({
82
111
  model: model || DEFAULT_MODEL,
83
112
  messages: [{ role: 'user', content: prompt }],
84
113
  modalities: ['text', 'audio'],
85
114
  audio: { voice: selectedVoice, format: selectedFormat },
86
115
  stream: true,
87
116
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
88
- });
117
+ }));
118
+ }
119
+ catch (err) {
120
+ return classifyUpstreamError(err, 'generate_audio');
121
+ }
122
+ try {
89
123
  const audioChunks = [];
90
124
  const transcriptChunks = [];
91
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
92
125
  for await (const chunk of stream) {
93
- const delta = chunk.choices?.[0]?.delta;
94
- if (delta?.audio) {
95
- if (delta.audio.data)
96
- audioChunks.push(delta.audio.data);
97
- if (delta.audio.transcript)
98
- transcriptChunks.push(delta.audio.transcript);
126
+ const delta = chunk.choices?.[0]
127
+ ?.delta;
128
+ if (delta && typeof delta === 'object' && delta.audio) {
129
+ const a = delta.audio;
130
+ if (typeof a.data === 'string')
131
+ audioChunks.push(a.data);
132
+ if (typeof a.transcript === 'string')
133
+ transcriptChunks.push(a.transcript);
99
134
  }
100
135
  }
101
136
  const fullAudioBase64 = audioChunks.join('');
102
137
  const transcript = transcriptChunks.join('');
103
138
  if (!fullAudioBase64) {
104
- return { content: [{ type: 'text', text: transcript || 'No audio generated.' }] };
139
+ return toolError(ErrorCode.INTERNAL, transcript
140
+ ? `No audio returned (model emitted transcript only): ${transcript.slice(0, 300)}`
141
+ : 'No audio returned.', { reason: 'no_audio_in_stream' });
105
142
  }
106
143
  let audioBuffer = Buffer.from(fullAudioBase64, 'base64');
107
144
  const detected = detectAudioFormat(audioBuffer);
@@ -112,13 +149,11 @@ export async function handleGenerateAudio(request, openai) {
112
149
  detected.mimeType = 'audio/wav';
113
150
  }
114
151
  const returnBase64 = audioBuffer.toString('base64');
115
- if (save_path) {
116
- const dir = dirname(save_path);
117
- await fs.mkdir(dir, { recursive: true });
118
- const fileExt = extname(save_path).toLowerCase().slice(1);
119
- const actualSavePath = fileExt === detected.ext ? save_path : replaceExtension(save_path, detected.ext);
152
+ if (safeBase) {
153
+ const fileExt = extname(safeBase).toLowerCase().slice(1);
154
+ const actualSavePath = fileExt === detected.ext ? safeBase : replaceExtension(safeBase, detected.ext);
120
155
  await fs.writeFile(actualSavePath, audioBuffer);
121
- const formatNote = actualSavePath !== save_path
156
+ const formatNote = actualSavePath !== safeBase
122
157
  ? ` (detected ${detected.ext.toUpperCase()}, saved as ${actualSavePath})`
123
158
  : '';
124
159
  const result = transcript
@@ -129,6 +164,11 @@ export async function handleGenerateAudio(request, openai) {
129
164
  { type: 'text', text: result },
130
165
  { type: 'audio', mimeType: detected.mimeType, data: returnBase64 },
131
166
  ],
167
+ _meta: {
168
+ save_path: actualSavePath,
169
+ mime: detected.mimeType,
170
+ size_bytes: audioBuffer.length,
171
+ },
132
172
  };
133
173
  }
134
174
  return {
@@ -136,19 +176,10 @@ export async function handleGenerateAudio(request, openai) {
136
176
  { type: 'text', text: transcript || 'Audio generated successfully.' },
137
177
  { type: 'audio', mimeType: detected.mimeType, data: returnBase64 },
138
178
  ],
179
+ _meta: { mime: detected.mimeType, size_bytes: audioBuffer.length },
139
180
  };
140
181
  }
141
- catch (error) {
142
- let msg;
143
- if (error instanceof Error) {
144
- msg = error.message;
145
- const oaiErr = error;
146
- if (oaiErr.error?.message)
147
- msg = `${msg} - ${oaiErr.error.message}`;
148
- }
149
- else {
150
- msg = String(error);
151
- }
152
- return { content: [{ type: 'text', text: `Error: ${msg}` }], isError: true };
182
+ catch (err) {
183
+ return classifyUpstreamError(err, 'generate_audio (stream)');
153
184
  }
154
185
  }
@@ -3,28 +3,66 @@ export interface GenerateImageToolRequest {
3
3
  prompt: string;
4
4
  model?: string;
5
5
  save_path?: string;
6
+ /**
7
+ * Output aspect ratio. Passed through as `image_config.aspect_ratio`.
8
+ * Supported by OpenRouter image models (e.g. `1:1`, `16:9`, `9:16`,
9
+ * `4:3`, `3:4`, `21:9`). Model-dependent — unsupported values fall back
10
+ * to the model's default. See
11
+ * https://openrouter.ai/docs/guides/overview/multimodal/image-generation
12
+ */
13
+ aspect_ratio?: string;
14
+ /**
15
+ * Output image resolution bucket. Passed through as
16
+ * `image_config.image_size`. Typical values: `0.5K`, `1K` (default),
17
+ * `2K`, `4K`. Model-dependent.
18
+ */
19
+ image_size?: string;
20
+ /**
21
+ * Upper bound on the completion budget. Without this OpenRouter
22
+ * reserves the model's full context window (~29k for Gemini
23
+ * image models), which can trigger a 402 on low-credit accounts even
24
+ * though the actual generation uses far fewer tokens. 4096 is plenty
25
+ * for the image payload + any caption.
26
+ */
27
+ max_tokens?: number;
6
28
  }
7
29
  export declare function handleGenerateImage(request: {
8
30
  params: {
9
31
  arguments: GenerateImageToolRequest;
10
32
  };
11
- }, openai: OpenAI): Promise<{
12
- content: {
13
- type: string;
14
- text: string;
15
- }[];
16
- isError: boolean;
17
- } | {
33
+ }, openai: OpenAI): Promise<import("../errors.js").ToolErrorResult | {
18
34
  content: ({
19
- type: string;
35
+ type: "text";
20
36
  text: string;
21
37
  mimeType?: undefined;
22
38
  data?: undefined;
23
39
  } | {
24
- type: string;
40
+ type: "image";
25
41
  mimeType: string;
26
42
  data: string;
27
43
  text?: undefined;
28
44
  })[];
29
- isError?: undefined;
45
+ _meta: {
46
+ usage?: {
47
+ prompt_tokens: number;
48
+ completion_tokens: number;
49
+ total_tokens: number;
50
+ } | undefined;
51
+ save_path: string;
52
+ mime: string;
53
+ };
54
+ } | {
55
+ content: {
56
+ type: "image";
57
+ mimeType: string;
58
+ data: string;
59
+ }[];
60
+ _meta: {
61
+ usage?: {
62
+ prompt_tokens: number;
63
+ completion_tokens: number;
64
+ total_tokens: number;
65
+ } | undefined;
66
+ mime: string;
67
+ };
30
68
  }>;
@@ -1,43 +1,145 @@
1
1
  import { promises as fs } from 'fs';
2
- import { dirname } from 'path';
2
+ import { resolveSafeOutputPath, UnsafeOutputPathError } from './path-safety.js';
3
+ import { parseBase64DataUrl } from './fetch-utils.js';
4
+ import { ErrorCode, toolError, toolErrorFrom } from '../errors.js';
5
+ import { classifyUpstreamError } from './openrouter-errors.js';
3
6
  const DEFAULT_MODEL = 'google/gemini-2.5-flash-image';
7
+ // OpenRouter-documented aspect ratios (standard + extended). Extended are
8
+ // only honored by models that support them (e.g. gemini-3.1-flash-image),
9
+ // others fall back to the model's default.
10
+ const VALID_ASPECT_RATIOS = new Set([
11
+ '1:1',
12
+ '2:3',
13
+ '3:2',
14
+ '3:4',
15
+ '4:3',
16
+ '4:5',
17
+ '5:4',
18
+ '9:16',
19
+ '16:9',
20
+ '21:9',
21
+ '1:4',
22
+ '4:1',
23
+ '1:8',
24
+ '8:1',
25
+ ]);
26
+ const VALID_IMAGE_SIZES = new Set(['0.5K', '1K', '2K', '4K']);
4
27
  export async function handleGenerateImage(request, openai) {
5
- const { prompt, model, save_path } = request.params.arguments;
28
+ const { prompt, model, save_path, aspect_ratio, image_size, max_tokens } = request.params.arguments ?? { prompt: '' };
6
29
  if (!prompt?.trim()) {
7
- return { content: [{ type: 'text', text: 'Prompt is required.' }], isError: true };
30
+ return toolError(ErrorCode.INVALID_INPUT, 'prompt is required.');
8
31
  }
9
- try {
10
- const completion = await openai.chat.completions.create({
11
- model: model || DEFAULT_MODEL,
12
- messages: [{ role: 'user', content: `Generate an image: ${prompt}` }],
13
- });
14
- const message = completion.choices[0]?.message;
15
- if (!message) {
16
- return { content: [{ type: 'text', text: 'No response from model.' }], isError: true };
32
+ // Validate optional shape fields early so callers get a clear error
33
+ // instead of a cryptic upstream 400.
34
+ if (aspect_ratio !== undefined && !VALID_ASPECT_RATIOS.has(aspect_ratio)) {
35
+ return toolError(ErrorCode.INVALID_INPUT, `aspect_ratio '${aspect_ratio}' is not supported. Valid values: ${[...VALID_ASPECT_RATIOS].join(', ')}.`);
36
+ }
37
+ if (image_size !== undefined && !VALID_IMAGE_SIZES.has(image_size)) {
38
+ return toolError(ErrorCode.INVALID_INPUT, `image_size '${image_size}' is not supported. Valid values: ${[...VALID_IMAGE_SIZES].join(', ')}.`);
39
+ }
40
+ // Fail-fast on unsafe paths BEFORE spending tokens.
41
+ let safePathResolved = null;
42
+ if (save_path) {
43
+ try {
44
+ safePathResolved = await resolveSafeOutputPath(save_path);
17
45
  }
18
- const base64 = extractBase64(message);
19
- if (base64) {
20
- if (save_path) {
21
- const dir = dirname(save_path);
22
- await fs.mkdir(dir, { recursive: true });
23
- await fs.writeFile(save_path, Buffer.from(base64.data, 'base64'));
24
- return {
25
- content: [
26
- { type: 'text', text: `Image saved to: ${save_path}` },
27
- { type: 'image', mimeType: base64.mime, data: base64.data },
28
- ],
29
- };
46
+ catch (err) {
47
+ if (err instanceof UnsafeOutputPathError) {
48
+ return toolErrorFrom(ErrorCode.UNSAFE_PATH, err);
30
49
  }
31
- return { content: [{ type: 'image', mimeType: base64.mime, data: base64.data }] };
50
+ return toolErrorFrom(ErrorCode.INTERNAL, err);
32
51
  }
52
+ }
53
+ // Assemble the request body. OpenRouter's image-generation guide requires
54
+ // - `modalities: ["image", "text"]` so multimodal models (like Gemini)
55
+ // know to emit an image, not just text;
56
+ // - `image_config.{aspect_ratio, image_size}` for shape control.
57
+ // The OpenAI SDK doesn't type these fields, but passes unknown members
58
+ // through to the server, so we attach them via a typed cast.
59
+ const imageConfig = {};
60
+ if (aspect_ratio)
61
+ imageConfig.aspect_ratio = aspect_ratio;
62
+ if (image_size)
63
+ imageConfig.image_size = image_size;
64
+ const body = {
65
+ model: model || DEFAULT_MODEL,
66
+ messages: [{ role: 'user', content: `Generate an image: ${prompt}` }],
67
+ modalities: ['image', 'text'],
68
+ };
69
+ if (Object.keys(imageConfig).length > 0)
70
+ body.image_config = imageConfig;
71
+ if (typeof max_tokens === 'number' && max_tokens > 0)
72
+ body.max_tokens = max_tokens;
73
+ let completion;
74
+ try {
75
+ // OpenRouter-specific `image_config` isn't in the OpenAI SDK's typings,
76
+ // but the SDK passes unknown fields straight through to the server.
77
+ // We never pass `stream: true`, so the response is always
78
+ // ChatCompletion.
79
+ completion = (await openai.chat.completions.create(body));
80
+ }
81
+ catch (err) {
82
+ return classifyUpstreamError(err, 'generate_image');
83
+ }
84
+ const message = completion.choices[0]?.message;
85
+ if (!message) {
86
+ return toolError(ErrorCode.INTERNAL, 'No response from model.');
87
+ }
88
+ const base64 = extractBase64(message);
89
+ if (!base64) {
90
+ // Model talked but did not emit an image. Surface this as a distinct
91
+ // condition so callers don't treat chatter as a successful image.
33
92
  const content = message.content;
34
93
  const text = typeof content === 'string' ? content : JSON.stringify(content);
35
- return { content: [{ type: 'text', text }] };
94
+ return toolError(ErrorCode.UPSTREAM_REFUSED, `Model returned no image. Text response: ${text.slice(0, 300)}`, {
95
+ reason: 'no_image_in_response',
96
+ finish_reason: completion.choices[0]?.finish_reason,
97
+ });
36
98
  }
37
- catch (error) {
38
- const msg = error instanceof Error ? error.message : String(error);
39
- return { content: [{ type: 'text', text: `Error: ${msg}` }], isError: true };
99
+ if (safePathResolved) {
100
+ try {
101
+ await fs.writeFile(safePathResolved, Buffer.from(base64.data, 'base64'));
102
+ }
103
+ catch (err) {
104
+ return toolErrorFrom(ErrorCode.INTERNAL, err, 'Write');
105
+ }
106
+ const usage = completion.usage;
107
+ return {
108
+ content: [
109
+ { type: 'text', text: `Image saved to: ${safePathResolved}` },
110
+ { type: 'image', mimeType: base64.mime, data: base64.data },
111
+ ],
112
+ _meta: {
113
+ save_path: safePathResolved,
114
+ mime: base64.mime,
115
+ ...(usage
116
+ ? {
117
+ usage: {
118
+ prompt_tokens: usage.prompt_tokens,
119
+ completion_tokens: usage.completion_tokens,
120
+ total_tokens: usage.total_tokens,
121
+ },
122
+ }
123
+ : {}),
124
+ },
125
+ };
40
126
  }
127
+ const usage = completion.usage;
128
+ return {
129
+ content: [{ type: 'image', mimeType: base64.mime, data: base64.data }],
130
+ _meta: {
131
+ mime: base64.mime,
132
+ ...(usage
133
+ ? {
134
+ usage: {
135
+ prompt_tokens: usage.prompt_tokens,
136
+ completion_tokens: usage.completion_tokens,
137
+ total_tokens: usage.total_tokens,
138
+ },
139
+ }
140
+ : {}),
141
+ },
142
+ };
41
143
  }
42
144
  function extractBase64(message) {
43
145
  const images = message.images;
@@ -68,15 +170,28 @@ function extractBase64(message) {
68
170
  }
69
171
  }
70
172
  if (typeof message.content === 'string') {
71
- const match = message.content.match(/data:image\/([^;]+);base64,([A-Za-z0-9+/=]+)/);
72
- if (match)
73
- return { data: match[2], mime: `image/${match[1]}` };
173
+ // Scan the string for an embedded data URL. We deliberately don't use
174
+ // a single regex here because data URLs may carry MIME parameters
175
+ // (e.g. `data:image/png;charset=binary;base64,...`) which trips the
176
+ // naive `data:([^;]+);base64,(.+)` form.
177
+ const start = message.content.indexOf('data:image/');
178
+ if (start >= 0) {
179
+ // Find the end of the data URL: a whitespace or closing quote/paren.
180
+ const tail = message.content.slice(start);
181
+ const end = tail.search(/[\s)"']/);
182
+ const url = end === -1 ? tail : tail.slice(0, end);
183
+ const parsed = parseDataUrl(url);
184
+ if (parsed)
185
+ return parsed;
186
+ }
74
187
  }
75
188
  return null;
76
189
  }
77
190
  function parseDataUrl(url) {
78
191
  if (!url?.startsWith('data:'))
79
192
  return null;
80
- const match = url.match(/^data:([^;]+);base64,(.+)$/);
81
- return match ? { data: match[2], mime: match[1] } : null;
193
+ const parsed = parseBase64DataUrl(url);
194
+ if (!parsed)
195
+ return null;
196
+ return { data: parsed.base64, mime: parsed.mediaType };
82
197
  }
@@ -0,0 +1,78 @@
1
+ import type { OpenRouterAPIClient, VideoJobStatus } from '../openrouter-api.js';
2
+ export interface GenerateVideoToolRequest {
3
+ prompt: string;
4
+ model?: string;
5
+ resolution?: string;
6
+ aspect_ratio?: string;
7
+ duration?: number;
8
+ seed?: number;
9
+ first_frame_image?: string;
10
+ last_frame_image?: string;
11
+ reference_images?: string[];
12
+ provider?: Record<string, unknown>;
13
+ save_path?: string;
14
+ max_wait_ms?: number;
15
+ poll_interval_ms?: number;
16
+ }
17
+ export interface GetVideoStatusToolRequest {
18
+ video_id: string;
19
+ save_path?: string;
20
+ polling_url?: string;
21
+ }
22
+ type ProgressHook = (update: {
23
+ status: string;
24
+ progress?: number;
25
+ attempt: number;
26
+ video_id: string;
27
+ }) => void | Promise<void>;
28
+ declare function buildRequestBody(args: GenerateVideoToolRequest, model: string): Record<string, unknown>;
29
+ declare function extractJobError(status: VideoJobStatus): string;
30
+ declare function stripAndReplaceExt(p: string, newExt: string): string;
31
+ export declare function handleGenerateVideo(request: {
32
+ params: {
33
+ arguments: GenerateVideoToolRequest;
34
+ };
35
+ }, apiClient: OpenRouterAPIClient, progress?: ProgressHook): Promise<import("../errors.js").ToolErrorResult | {
36
+ content: {
37
+ type: "text";
38
+ text: string;
39
+ }[];
40
+ isError: false;
41
+ _meta: {
42
+ code: "JOB_STILL_RUNNING";
43
+ video_id: string;
44
+ polling_url: string;
45
+ last_status: string | undefined;
46
+ };
47
+ } | {
48
+ content: Record<string, unknown>[];
49
+ _meta: Record<string, unknown>;
50
+ isError?: undefined;
51
+ }>;
52
+ export declare function handleGetVideoStatus(request: {
53
+ params: {
54
+ arguments: GetVideoStatusToolRequest;
55
+ };
56
+ }, apiClient: OpenRouterAPIClient): Promise<import("../errors.js").ToolErrorResult | {
57
+ content: Record<string, unknown>[];
58
+ _meta: Record<string, unknown>;
59
+ isError?: undefined;
60
+ } | {
61
+ content: {
62
+ type: "text";
63
+ text: string;
64
+ }[];
65
+ isError: false;
66
+ _meta: {
67
+ code: "JOB_STILL_RUNNING";
68
+ video_id: string;
69
+ last_status: string;
70
+ progress: number | undefined;
71
+ };
72
+ }>;
73
+ export declare const _internals: {
74
+ buildRequestBody: typeof buildRequestBody;
75
+ stripAndReplaceExt: typeof stripAndReplaceExt;
76
+ extractJobError: typeof extractJobError;
77
+ };
78
+ export {};