@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.
- package/README.md +198 -145
- package/dist/errors.d.ts +42 -0
- package/dist/errors.js +46 -0
- package/dist/index.js +1 -1
- package/dist/logger.d.ts +22 -0
- package/dist/logger.js +47 -0
- package/dist/model-cache.d.ts +10 -0
- package/dist/model-cache.js +31 -1
- package/dist/openrouter-api.d.ts +54 -0
- package/dist/openrouter-api.js +136 -12
- package/dist/tool-handlers/analyze-audio.d.ts +5 -9
- package/dist/tool-handlers/analyze-audio.js +41 -8
- package/dist/tool-handlers/analyze-image.d.ts +5 -9
- package/dist/tool-handlers/analyze-image.js +38 -8
- package/dist/tool-handlers/analyze-video.d.ts +19 -0
- package/dist/tool-handlers/analyze-video.js +93 -0
- package/dist/tool-handlers/audio-utils.js +7 -9
- package/dist/tool-handlers/chat-completion.d.ts +6 -10
- package/dist/tool-handlers/chat-completion.js +27 -7
- package/dist/tool-handlers/completion-utils.d.ts +27 -0
- package/dist/tool-handlers/completion-utils.js +69 -0
- package/dist/tool-handlers/fetch-utils.d.ts +32 -0
- package/dist/tool-handlers/fetch-utils.js +216 -12
- package/dist/tool-handlers/generate-audio.d.ts +32 -12
- package/dist/tool-handlers/generate-audio.js +77 -46
- package/dist/tool-handlers/generate-image.d.ts +48 -10
- package/dist/tool-handlers/generate-image.js +148 -33
- package/dist/tool-handlers/generate-video.d.ts +78 -0
- package/dist/tool-handlers/generate-video.js +353 -0
- package/dist/tool-handlers/get-model-info.d.ts +2 -9
- package/dist/tool-handlers/get-model-info.js +15 -5
- package/dist/tool-handlers/image-utils.d.ts +17 -1
- package/dist/tool-handlers/image-utils.js +66 -13
- package/dist/tool-handlers/openrouter-errors.d.ts +18 -0
- package/dist/tool-handlers/openrouter-errors.js +99 -0
- package/dist/tool-handlers/path-safety.d.ts +11 -0
- package/dist/tool-handlers/path-safety.js +88 -0
- package/dist/tool-handlers/search-models.d.ts +2 -9
- package/dist/tool-handlers/search-models.js +10 -6
- package/dist/tool-handlers/validate-model.d.ts +2 -9
- package/dist/tool-handlers/validate-model.js +15 -4
- package/dist/tool-handlers/video-utils.d.ts +29 -0
- package/dist/tool-handlers/video-utils.js +174 -0
- package/dist/tool-handlers.js +229 -21
- package/package.json +3 -3
- package/dist/__tests__/audio-utils.test.d.ts +0 -1
- package/dist/__tests__/audio-utils.test.js +0 -120
- package/dist/__tests__/fetch-utils.test.d.ts +0 -1
- package/dist/__tests__/fetch-utils.test.js +0 -76
- package/dist/__tests__/generate-audio.test.d.ts +0 -1
- package/dist/__tests__/generate-audio.test.js +0 -90
- package/dist/__tests__/image-utils.test.d.ts +0 -1
- package/dist/__tests__/image-utils.test.js +0 -75
- package/dist/__tests__/integration.test.d.ts +0 -1
- package/dist/__tests__/integration.test.js +0 -219
- package/dist/__tests__/model-cache.test.d.ts +0 -1
- package/dist/__tests__/model-cache.test.js +0 -96
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { promises as fs } from 'fs';
|
|
2
|
-
import {
|
|
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
|
|
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 =
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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]
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
if (
|
|
98
|
-
|
|
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
|
|
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 (
|
|
116
|
-
const
|
|
117
|
-
|
|
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 !==
|
|
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 (
|
|
142
|
-
|
|
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:
|
|
35
|
+
type: "text";
|
|
20
36
|
text: string;
|
|
21
37
|
mimeType?: undefined;
|
|
22
38
|
data?: undefined;
|
|
23
39
|
} | {
|
|
24
|
-
type:
|
|
40
|
+
type: "image";
|
|
25
41
|
mimeType: string;
|
|
26
42
|
data: string;
|
|
27
43
|
text?: undefined;
|
|
28
44
|
})[];
|
|
29
|
-
|
|
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 {
|
|
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
|
|
30
|
+
return toolError(ErrorCode.INVALID_INPUT, 'prompt is required.');
|
|
8
31
|
}
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
81
|
-
|
|
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 {};
|