@chrischall/gemini-mcp 1.0.4 → 1.2.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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +75 -1
- package/dist/bundle.js +854 -137
- package/dist/bytes.js +46 -0
- package/dist/client.js +194 -17
- package/dist/fetch-image.js +361 -0
- package/dist/images.js +0 -19
- package/dist/index.js +2 -1
- package/dist/inputs.js +165 -0
- package/dist/session.js +39 -0
- package/dist/storage/media.js +2 -13
- package/dist/tools/files.js +188 -0
- package/dist/tools/generate.js +21 -12
- package/dist/tools/interact.js +12 -5
- package/dist/tools/music.js +10 -4
- package/dist/tools/set.js +29 -7
- package/dist/tools/shared.js +44 -6
- package/dist/tools/video.js +10 -4
- package/dist/upload-endpoint.js +79 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/skills/gemini-mcp/SKILL.md +65 -6
package/dist/bytes.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* base64 ↔ bytes, and byte-size formatting.
|
|
3
|
+
*
|
|
4
|
+
* These conversions had grown four near-identical copies — an inline
|
|
5
|
+
* `Uint8Array.from(atob(x), c => c.charCodeAt(0))` in `inputs.ts` and twice in
|
|
6
|
+
* `tools/files.ts`, plus a private `decodeBase64` in `storage/media.ts` — and
|
|
7
|
+
* a fifth (`encodeBase64`) that was subtly different because it is the only one
|
|
8
|
+
* that has to chunk. One place, so the chunking rule can't be forgotten in copy
|
|
9
|
+
* number six.
|
|
10
|
+
*
|
|
11
|
+
* `atob`/`btoa` rather than `Buffer`: this module is on the Worker's import
|
|
12
|
+
* graph, and they are the primitive both runtimes share. Module scope stays
|
|
13
|
+
* side-effect-free for the same reason (see the note in client.ts).
|
|
14
|
+
*/
|
|
15
|
+
/** Decode base64 into raw bytes. */
|
|
16
|
+
export function base64ToBytes(base64) {
|
|
17
|
+
const binary = atob(base64);
|
|
18
|
+
const bytes = new Uint8Array(binary.length);
|
|
19
|
+
for (let i = 0; i < binary.length; i++)
|
|
20
|
+
bytes[i] = binary.charCodeAt(i);
|
|
21
|
+
return bytes;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Encode raw bytes as base64.
|
|
25
|
+
*
|
|
26
|
+
* Chunked because `String.fromCharCode(...bytes)` spreads one argument per
|
|
27
|
+
* byte and blows the engine's argument limit somewhere around 100–125k — i.e.
|
|
28
|
+
* it works fine on every small test fixture and throws on the first real
|
|
29
|
+
* photo, which is the worst possible place to learn about it.
|
|
30
|
+
*/
|
|
31
|
+
export function bytesToBase64(bytes) {
|
|
32
|
+
const CHUNK = 0x8000;
|
|
33
|
+
let binary = '';
|
|
34
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
35
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
36
|
+
}
|
|
37
|
+
return btoa(binary);
|
|
38
|
+
}
|
|
39
|
+
/** Whole megabytes, for limits stated in tool descriptions (`25MB`). */
|
|
40
|
+
export function wholeMb(bytes) {
|
|
41
|
+
return Math.round(bytes / (1024 * 1024));
|
|
42
|
+
}
|
|
43
|
+
/** One-decimal megabytes, for error messages about an actual size (`15.4 MB`). */
|
|
44
|
+
export function formatMb(bytes) {
|
|
45
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
46
|
+
}
|
package/dist/client.js
CHANGED
|
@@ -3,6 +3,7 @@ import { readEnvVar, McpToolError, ApiError, createApiClient, formatApiError, fi
|
|
|
3
3
|
import { resolveModel, filterImageModels, DEFAULT_VIDEO_MODEL, DEFAULT_MUSIC_MODEL } from './models.js';
|
|
4
4
|
import { createDiskSink } from './storage/media.js';
|
|
5
5
|
import { SessionState } from './session.js';
|
|
6
|
+
import { fetchRemoteImage } from './fetch-image.js';
|
|
6
7
|
// NOTE: this module must stay SIDE-EFFECT-FREE at module scope — no I/O, no
|
|
7
8
|
// top-level await, no `import.meta.url`. `src/worker.ts` imports it, so every
|
|
8
9
|
// line here runs during Cloudflare isolate startup, where global-scope I/O is
|
|
@@ -96,6 +97,37 @@ export class ChainedRequest404Error extends McpToolError {
|
|
|
96
97
|
this.retries = retries;
|
|
97
98
|
}
|
|
98
99
|
}
|
|
100
|
+
/** `generateContent` part for one reference image (by uri, else inline). */
|
|
101
|
+
function generatePart(img) {
|
|
102
|
+
return img.uri
|
|
103
|
+
? { file_data: { file_uri: img.uri, mime_type: img.mimeType } }
|
|
104
|
+
: { inline_data: { mime_type: img.mimeType, data: img.base64 } };
|
|
105
|
+
}
|
|
106
|
+
/** Interactions `input` part for one reference image (by uri, else inline). */
|
|
107
|
+
function interactPart(img) {
|
|
108
|
+
return img.uri
|
|
109
|
+
? { type: 'image', uri: img.uri, mime_type: img.mimeType }
|
|
110
|
+
: { type: 'image', mime_type: img.mimeType, data: img.base64 };
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Reduce any accepted spelling of a Files API reference to the bare resource
|
|
114
|
+
* name `files/<id>`.
|
|
115
|
+
*
|
|
116
|
+
* Callers hand these back and forth all day — a result echoes the full
|
|
117
|
+
* `https://…/v1beta/files/<id>` uri, docs and error messages say `files/<id>` —
|
|
118
|
+
* so both must work. The name is interpolated into a URL path, which is why the
|
|
119
|
+
* shape is asserted rather than assumed (same posture as the model-id guard).
|
|
120
|
+
*/
|
|
121
|
+
export function normalizeFileName(ref) {
|
|
122
|
+
const trimmed = ref.trim();
|
|
123
|
+
const match = /(?:^|\/)(files\/[\w-]+)\/?$/.exec(trimmed);
|
|
124
|
+
if (!match) {
|
|
125
|
+
throw new McpToolError(`Not a Gemini Files API reference: "${ref}"`, {
|
|
126
|
+
hint: 'Pass the `files/<id>` name (or the full https://…/v1beta/files/<id> uri) returned by gemini_upload_file / POST /upload.',
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return match[1];
|
|
130
|
+
}
|
|
99
131
|
export class GeminiClient {
|
|
100
132
|
/**
|
|
101
133
|
* A key handed in explicitly (the connector's per-session key). When absent
|
|
@@ -184,6 +216,17 @@ export class GeminiClient {
|
|
|
184
216
|
}
|
|
185
217
|
return key;
|
|
186
218
|
}
|
|
219
|
+
/**
|
|
220
|
+
* Fetch one `images_url` reference server-side. Routed through the client so
|
|
221
|
+
* it uses the SAME injected `fetchImpl` as every upstream call — which is
|
|
222
|
+
* what makes it mockable in tests and correct on the Worker.
|
|
223
|
+
*/
|
|
224
|
+
async fetchRemoteImage(url, opts = {}) {
|
|
225
|
+
// Same timeout resolution as every other upstream call (per-call
|
|
226
|
+
// `timeout_ms` → $GEMINI_TIMEOUT_MS → 60s), so a URL fetch cannot be the
|
|
227
|
+
// one unbounded network operation in the server.
|
|
228
|
+
return fetchRemoteImage(url, { ...opts, fetchImpl: this.fetchImpl, timeoutMs: resolveTimeoutMs(opts.timeoutMs) });
|
|
229
|
+
}
|
|
187
230
|
/** The default model after env override (no per-call arg). */
|
|
188
231
|
defaultModel() {
|
|
189
232
|
return resolveModel(undefined, readEnvVar('GEMINI_IMAGE_MODEL'));
|
|
@@ -215,8 +258,67 @@ export class GeminiClient {
|
|
|
215
258
|
* (enforced locally before any bytes are sent).
|
|
216
259
|
*/
|
|
217
260
|
async uploadVideo(path, mimeType) {
|
|
218
|
-
const key = this.requireKey();
|
|
219
261
|
const blob = await fileBlob(path, { type: mimeType, maxBytes: FILE_MAX_BYTES, label: 'Video' });
|
|
262
|
+
return this.uploadToFilesApi(blob, mimeType, basename(path), blob.size);
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Upload already-in-memory bytes (a fetched `images_url`, a `data_base64`
|
|
266
|
+
* payload, an inline `gemini_upload_file` body) to the Files API. Same three
|
|
267
|
+
* steps as {@link uploadVideo}, minus the filesystem — which is what makes it
|
|
268
|
+
* usable on the hosted connector, where there is no disk to stream from.
|
|
269
|
+
*/
|
|
270
|
+
async uploadBytes(bytes, mimeType, displayName) {
|
|
271
|
+
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
272
|
+
if (view.byteLength > FILE_MAX_BYTES) {
|
|
273
|
+
throw new McpToolError(`File is ${view.byteLength} bytes, over the Gemini Files API limit of ${FILE_MAX_BYTES} bytes (2 GB).`);
|
|
274
|
+
}
|
|
275
|
+
// `new Blob([view])` already copies exactly the VIEW's bytes — its byte
|
|
276
|
+
// offset and length, not the whole backing buffer — so an extra `.slice()`
|
|
277
|
+
// here bought nothing and doubled peak memory, which matters most on the
|
|
278
|
+
// Worker's 128MB isolate where the fetched buffer is still live.
|
|
279
|
+
// The cast is a TS lib artefact: `BlobPart` is declared over
|
|
280
|
+
// `ArrayBuffer`-backed views, while a `Uint8Array` is generic in its buffer
|
|
281
|
+
// type. The runtime accepts any view.
|
|
282
|
+
const body = new Blob([view], { type: mimeType });
|
|
283
|
+
return this.uploadToFilesApi(body, mimeType, displayName, view.byteLength);
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Upload a request body straight through to the Files API without buffering
|
|
287
|
+
* it — the direct-upload HTTP endpoint (`POST /upload` on the Worker) hands
|
|
288
|
+
* us a `ReadableStream` and a `Content-Length`, and the whole point of that
|
|
289
|
+
* endpoint is that the bytes never materialize anywhere they'd cost context
|
|
290
|
+
* or memory.
|
|
291
|
+
*
|
|
292
|
+
* `contentLength` is REQUIRED because the resumable protocol's start step
|
|
293
|
+
* declares it up front (`X-Goog-Upload-Header-Content-Length`); a stream of
|
|
294
|
+
* unknown length has to be buffered by the caller first.
|
|
295
|
+
*/
|
|
296
|
+
async uploadStream(stream, mimeType, displayName, contentLength) {
|
|
297
|
+
if (contentLength > FILE_MAX_BYTES) {
|
|
298
|
+
throw new McpToolError(`File is ${contentLength} bytes, over the Gemini Files API limit of ${FILE_MAX_BYTES} bytes (2 GB).`);
|
|
299
|
+
}
|
|
300
|
+
return this.uploadToFilesApi(stream, mimeType, displayName, contentLength);
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* The resumable upload itself. Three live-verified steps (docs/GEMINI-API.md
|
|
304
|
+
* "Files API — local video upload"):
|
|
305
|
+
*
|
|
306
|
+
* 1. `POST {UPLOAD_BASE_URL}/files` (command `start`) → the upload session
|
|
307
|
+
* URL arrives in the `x-goog-upload-url` RESPONSE HEADER (body is empty).
|
|
308
|
+
* 2. `POST <session url>` (command `upload, finalize`) with the bytes.
|
|
309
|
+
* Response: `{file:…}`.
|
|
310
|
+
* 3. Poll `GET /v1beta/files/<id>` while `state` is `PROCESSING`, until
|
|
311
|
+
* `ACTIVE` (the poll response is the File object UNWRAPPED). Images are
|
|
312
|
+
* usually `ACTIVE` immediately; video is what processes.
|
|
313
|
+
*
|
|
314
|
+
* These two upload calls are raw `fetchImpl` (not `createApiClient`): step 1
|
|
315
|
+
* needs the response *header* and step 2 posts a binary body — neither fits
|
|
316
|
+
* `fetchJson`. The poll DOES go through the shared client (timeout + retry).
|
|
317
|
+
* Uploads deliberately have no abort timeout: a multi-hundred-MB file can
|
|
318
|
+
* legitimately take longer than any fixed budget.
|
|
319
|
+
*/
|
|
320
|
+
async uploadToFilesApi(body, mimeType, displayName, contentLength) {
|
|
321
|
+
const key = this.requireKey();
|
|
220
322
|
// 1. start — establish the resumable upload session
|
|
221
323
|
const startPath = '/files';
|
|
222
324
|
const startRes = await this.fetchImpl(`${UPLOAD_BASE_URL}${startPath}`, {
|
|
@@ -225,11 +327,11 @@ export class GeminiClient {
|
|
|
225
327
|
'x-goog-api-key': key,
|
|
226
328
|
'X-Goog-Upload-Protocol': 'resumable',
|
|
227
329
|
'X-Goog-Upload-Command': 'start',
|
|
228
|
-
'X-Goog-Upload-Header-Content-Length': String(
|
|
330
|
+
'X-Goog-Upload-Header-Content-Length': String(contentLength),
|
|
229
331
|
'X-Goog-Upload-Header-Content-Type': mimeType,
|
|
230
332
|
'Content-Type': 'application/json',
|
|
231
333
|
},
|
|
232
|
-
body: JSON.stringify({ file: { display_name:
|
|
334
|
+
body: JSON.stringify({ file: { display_name: displayName } }),
|
|
233
335
|
});
|
|
234
336
|
if (!startRes.ok) {
|
|
235
337
|
throw new McpToolError(formatApiError(startRes.status, 'POST', `/upload/v1beta${startPath}`, await startRes.text(), { service: SERVICE }));
|
|
@@ -242,13 +344,23 @@ export class GeminiClient {
|
|
|
242
344
|
}
|
|
243
345
|
// 2. upload + finalize — single shot; the session URL is self-authorizing
|
|
244
346
|
// (no api-key header needed; verified live).
|
|
347
|
+
// A Blob body is left EXACTLY as the live-verified video upload sent it:
|
|
348
|
+
// fetch derives Content-Length from the blob, and setting the header by
|
|
349
|
+
// hand is the kind of change that breaks a verified path for no gain. A
|
|
350
|
+
// stream has no inherent length, so it declares one — otherwise the runtime
|
|
351
|
+
// falls back to chunked encoding, which the resumable protocol does not
|
|
352
|
+
// accept (this half is docs-derived; see docs/GEMINI-API.md).
|
|
353
|
+
const streaming = !(body instanceof Blob);
|
|
245
354
|
const upRes = await this.fetchImpl(uploadUrl, {
|
|
246
355
|
method: 'POST',
|
|
247
356
|
headers: {
|
|
248
357
|
'X-Goog-Upload-Offset': '0',
|
|
249
358
|
'X-Goog-Upload-Command': 'upload, finalize',
|
|
359
|
+
...(streaming ? { 'Content-Length': String(contentLength) } : {}),
|
|
250
360
|
},
|
|
251
|
-
body:
|
|
361
|
+
body: body,
|
|
362
|
+
// Required by undici/workerd to send a ReadableStream body at all.
|
|
363
|
+
...(streaming ? { duplex: 'half' } : {}),
|
|
252
364
|
});
|
|
253
365
|
if (!upRes.ok) {
|
|
254
366
|
throw new McpToolError(formatApiError(upRes.status, 'POST', '/upload/v1beta/files (finalize)', await upRes.text(), { service: SERVICE }));
|
|
@@ -261,23 +373,90 @@ export class GeminiClient {
|
|
|
261
373
|
if (!/^files\/[\w-]+$/.test(name)) {
|
|
262
374
|
throw new McpToolError(`${SERVICE} upload returned an unexpected file name: ${name || '(none)'}`);
|
|
263
375
|
}
|
|
264
|
-
// 3. poll PROCESSING → ACTIVE
|
|
376
|
+
// 3. poll PROCESSING → ACTIVE. Images are normally ACTIVE on arrival; it is
|
|
377
|
+
// video that spends time here.
|
|
265
378
|
for (let attempt = 0; file.state === 'PROCESSING'; attempt++) {
|
|
266
379
|
if (attempt >= FILE_POLL_MAX_ATTEMPTS) {
|
|
267
|
-
throw new McpToolError(`${SERVICE}
|
|
268
|
-
hint: 'Very long videos can take a while to process — retry later by passing the file uri as video_url.',
|
|
380
|
+
throw new McpToolError(`${SERVICE} file ${name} is still processing after ${(FILE_POLL_INTERVAL_MS * FILE_POLL_MAX_ATTEMPTS) / 60_000} minutes`, {
|
|
381
|
+
hint: 'Very long videos can take a while to process — retry later by passing the file uri as video_url / images_file_uris.',
|
|
269
382
|
});
|
|
270
383
|
}
|
|
271
384
|
await this.sleep(FILE_POLL_INTERVAL_MS);
|
|
272
385
|
file = await this.call('GET', `/${name}`);
|
|
273
386
|
}
|
|
274
387
|
if (file.state !== 'ACTIVE') {
|
|
275
|
-
throw new McpToolError(`${SERVICE}
|
|
388
|
+
throw new McpToolError(`${SERVICE} file processing failed (state ${file.state ?? 'unknown'})${file.error?.message ? `: ${file.error.message}` : ''}`, { hint: 'Check the file is a supported format/codec and under 2 GB.' });
|
|
276
389
|
}
|
|
277
390
|
if (!file.uri) {
|
|
278
391
|
throw new McpToolError(`${SERVICE} did not return a uri for uploaded file ${name}`);
|
|
279
392
|
}
|
|
280
|
-
return {
|
|
393
|
+
return {
|
|
394
|
+
name,
|
|
395
|
+
uri: file.uri,
|
|
396
|
+
mimeType: file.mimeType ?? mimeType,
|
|
397
|
+
expirationTime: file.expirationTime,
|
|
398
|
+
sizeBytes: file.sizeBytes,
|
|
399
|
+
displayName: file.displayName,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* List the files this API key currently has in the Files API. Retention is
|
|
404
|
+
* ~48h, so the list is naturally short-lived — an empty list after a day is
|
|
405
|
+
* expiry, not a bug.
|
|
406
|
+
*/
|
|
407
|
+
async listFiles(pageSize = 100) {
|
|
408
|
+
const size = Math.min(Math.max(Math.trunc(pageSize), 1), 100);
|
|
409
|
+
const data = await this.call('GET', `/files?pageSize=${size}`);
|
|
410
|
+
return (data.files ?? [])
|
|
411
|
+
.filter((f) => !!f.name && !!f.uri)
|
|
412
|
+
.map((f) => ({
|
|
413
|
+
name: f.name,
|
|
414
|
+
uri: f.uri,
|
|
415
|
+
mimeType: f.mimeType ?? 'application/octet-stream',
|
|
416
|
+
state: f.state,
|
|
417
|
+
createTime: f.createTime,
|
|
418
|
+
expirationTime: f.expirationTime,
|
|
419
|
+
sizeBytes: f.sizeBytes,
|
|
420
|
+
displayName: f.displayName,
|
|
421
|
+
}));
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Fetch one file's metadata. Used to resolve a caller-supplied
|
|
425
|
+
* `images_file_uris` entry to its real MIME type — and, as a side effect, to
|
|
426
|
+
* fail fast with a clear "that file is gone" instead of the Interactions
|
|
427
|
+
* API's generic 404 after a billable request was already built.
|
|
428
|
+
*/
|
|
429
|
+
async getFile(ref) {
|
|
430
|
+
const resource = normalizeFileName(ref);
|
|
431
|
+
const file = await this.call('GET', `/${resource}`);
|
|
432
|
+
if (!file.uri) {
|
|
433
|
+
throw new McpToolError(`${SERVICE} returned no uri for ${resource}`, {
|
|
434
|
+
hint: 'Files expire ~48h after upload — re-upload with gemini_upload_file (or POST /upload) and use the new uri.',
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
if (file.state && file.state !== 'ACTIVE') {
|
|
438
|
+
throw new McpToolError(`${SERVICE} file ${resource} is not usable yet (state ${file.state}).`, {
|
|
439
|
+
hint: 'Wait for the upload to finish processing, then retry.',
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
return {
|
|
443
|
+
name: file.name ?? resource,
|
|
444
|
+
uri: file.uri,
|
|
445
|
+
mimeType: file.mimeType ?? 'application/octet-stream',
|
|
446
|
+
expirationTime: file.expirationTime,
|
|
447
|
+
sizeBytes: file.sizeBytes,
|
|
448
|
+
displayName: file.displayName,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Delete one uploaded file. `name` is interpolated into the URL path, so it
|
|
453
|
+
* is validated to a bare `files/<id>` for the same reason the model id is
|
|
454
|
+
* (see {@link uploadToFilesApi}); a full `https://…/v1beta/files/<id>` uri is
|
|
455
|
+
* accepted and reduced to that form, since that is what every result echoes.
|
|
456
|
+
*/
|
|
457
|
+
async deleteFile(name) {
|
|
458
|
+
const resource = normalizeFileName(name);
|
|
459
|
+
await this.call('DELETE', `/${resource}`);
|
|
281
460
|
}
|
|
282
461
|
async listModels() {
|
|
283
462
|
const data = await this.call('GET', '/models?pageSize=200');
|
|
@@ -286,9 +465,8 @@ export class GeminiClient {
|
|
|
286
465
|
async generate(opts) {
|
|
287
466
|
const model = resolveModel(opts.model, readEnvVar('GEMINI_IMAGE_MODEL'));
|
|
288
467
|
const parts = [{ text: opts.prompt }];
|
|
289
|
-
for (const img of opts.images ?? [])
|
|
290
|
-
parts.push(
|
|
291
|
-
}
|
|
468
|
+
for (const img of opts.images ?? [])
|
|
469
|
+
parts.push(generatePart(img));
|
|
292
470
|
if (opts.videoUrl) {
|
|
293
471
|
parts.push({ file_data: { file_uri: opts.videoUrl, mime_type: opts.videoMimeType ?? 'video/mp4' } });
|
|
294
472
|
}
|
|
@@ -349,9 +527,8 @@ export class GeminiClient {
|
|
|
349
527
|
async interact(opts) {
|
|
350
528
|
const model = resolveModel(opts.model, readEnvVar('GEMINI_IMAGE_MODEL'));
|
|
351
529
|
const inputParts = [{ type: 'text', text: opts.input }];
|
|
352
|
-
for (const img of opts.images ?? [])
|
|
353
|
-
inputParts.push(
|
|
354
|
-
}
|
|
530
|
+
for (const img of opts.images ?? [])
|
|
531
|
+
inputParts.push(interactPart(img));
|
|
355
532
|
if (opts.videoUrl) {
|
|
356
533
|
inputParts.push({ type: 'video', uri: opts.videoUrl, mime_type: opts.videoMimeType ?? 'video/mp4' });
|
|
357
534
|
}
|
|
@@ -390,7 +567,7 @@ export class GeminiClient {
|
|
|
390
567
|
const model = opts.model ?? DEFAULT_VIDEO_MODEL;
|
|
391
568
|
const inputParts = [{ type: 'text', text: opts.input }];
|
|
392
569
|
for (const img of opts.images ?? [])
|
|
393
|
-
inputParts.push(
|
|
570
|
+
inputParts.push(interactPart(img));
|
|
394
571
|
const responseFormat = { type: 'video', delivery: 'inline' };
|
|
395
572
|
if (opts.aspectRatio)
|
|
396
573
|
responseFormat.aspect_ratio = opts.aspectRatio;
|
|
@@ -416,7 +593,7 @@ export class GeminiClient {
|
|
|
416
593
|
const model = opts.model ?? DEFAULT_MUSIC_MODEL;
|
|
417
594
|
const inputParts = [{ type: 'text', text: opts.input }];
|
|
418
595
|
for (const img of opts.images ?? [])
|
|
419
|
-
inputParts.push(
|
|
596
|
+
inputParts.push(interactPart(img));
|
|
420
597
|
const responseFormat = { type: 'audio' };
|
|
421
598
|
if (opts.audioFormat)
|
|
422
599
|
responseFormat.audio_format = opts.audioFormat;
|