@chrischall/gemini-mcp 1.3.0 → 1.5.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 +76 -3
- package/dist/bundle.js +523 -51
- package/dist/client.js +24 -0
- package/dist/inputs.js +37 -1
- package/dist/jobs.js +122 -2
- package/dist/library.js +151 -0
- package/dist/media-cleanup.js +4 -1
- package/dist/media-endpoint.js +109 -15
- package/dist/media-url.js +2 -2
- package/dist/put-endpoint.js +122 -0
- package/dist/storage/media.js +53 -4
- package/dist/tools/files.js +61 -3
- package/dist/tools/generate.js +75 -30
- package/dist/tools/interact.js +3 -2
- package/dist/tools/library.js +186 -0
- package/dist/tools/music.js +3 -2
- package/dist/tools/set.js +76 -12
- package/dist/tools/shared.js +202 -2
- package/dist/tools/uploads.js +69 -0
- package/dist/tools/video.js +3 -2
- package/dist/upload-url.js +138 -0
- package/dist/version.js +1 -1
- package/dist/zip.js +113 -0
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { loadSigningKey } from './media-url.js';
|
|
2
|
+
import { verifyUploadSignature, UPLOAD_MAX_BYTES, UPLOAD_KEY_PREFIX, acceptableUploadType } from './upload-url.js';
|
|
3
|
+
import { wholeMb } from './bytes.js';
|
|
4
|
+
function json(body, status) {
|
|
5
|
+
return new Response(JSON.stringify(body, null, 2), {
|
|
6
|
+
status,
|
|
7
|
+
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
/** Every error names the fix — the caller here is a shell, not a model. */
|
|
11
|
+
function fail(status, error, hint) {
|
|
12
|
+
return json({ error, hint }, status);
|
|
13
|
+
}
|
|
14
|
+
/** A key this endpoint is willing to even verify a signature for: the upload
|
|
15
|
+
* prefix, safe segment charset, no dot-segments, no empty segments. */
|
|
16
|
+
export function wellFormedUploadKey(key) {
|
|
17
|
+
if (!key.startsWith(`${UPLOAD_KEY_PREFIX}/`))
|
|
18
|
+
return false;
|
|
19
|
+
const segments = key.split('/');
|
|
20
|
+
if (segments.length < 2)
|
|
21
|
+
return false;
|
|
22
|
+
return segments.every((s) => s.length > 0 && s !== '.' && s !== '..' && /^[A-Za-z0-9._-]+$/.test(s));
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Build the `/put` handler. `basePath` is stripped from the pathname to get the
|
|
26
|
+
* R2 key, mirroring the `/media` handler so the two never drift.
|
|
27
|
+
*/
|
|
28
|
+
export function createSignedPutHandler(env, basePath = '/put/') {
|
|
29
|
+
const maxBytes = env.maxBytes ?? UPLOAD_MAX_BYTES;
|
|
30
|
+
return async function handleSignedPut(request) {
|
|
31
|
+
if (request.method === 'OPTIONS') {
|
|
32
|
+
return new Response(null, { status: 204, headers: { allow: 'PUT, OPTIONS' } });
|
|
33
|
+
}
|
|
34
|
+
if (request.method !== 'PUT') {
|
|
35
|
+
return fail(405, `Method ${request.method} not allowed; signed upload URLs are PUT-only.`, 'Send the file bytes with: curl -sS -X PUT -H "Content-Type: <the signed type>" --data-binary @photo.jpg "<upload_url>"');
|
|
36
|
+
}
|
|
37
|
+
const url = new URL(request.url);
|
|
38
|
+
if (!url.pathname.startsWith(basePath))
|
|
39
|
+
return fail(404, 'Not found.', 'Use the exact upload_url returned by gemini_get_upload_url.');
|
|
40
|
+
const key = url.pathname
|
|
41
|
+
.slice(basePath.length)
|
|
42
|
+
.split('/')
|
|
43
|
+
.map((segment) => {
|
|
44
|
+
try {
|
|
45
|
+
return decodeURIComponent(segment);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return segment;
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
.join('/');
|
|
52
|
+
// Charset-checked BEFORE signature verification: the cross-protocol safety
|
|
53
|
+
// argument in upload-url.ts rests on keys never containing control chars.
|
|
54
|
+
if (!wellFormedUploadKey(key)) {
|
|
55
|
+
return fail(404, 'Not a valid upload key.', 'Use the exact upload_url returned by gemini_get_upload_url — the key path cannot be altered.');
|
|
56
|
+
}
|
|
57
|
+
const ct = url.searchParams.get('ct');
|
|
58
|
+
if (ct && !acceptableUploadType(ct)) {
|
|
59
|
+
return fail(415, `Unsupported content type "${ct}" — signed uploads accept raster image types only ` +
|
|
60
|
+
'(jpeg/png/webp/gif/avif/heic/heif/bmp/tiff; not SVG, which can carry scripts).', 'Mint the URL with a raster image content_type (e.g. image/jpeg).');
|
|
61
|
+
}
|
|
62
|
+
const now = env.now?.() ?? Date.now();
|
|
63
|
+
// Read-only key load: verification must never CREATE a signing secret (see
|
|
64
|
+
// loadSigningKey — a stale KV read would clobber the secret every
|
|
65
|
+
// outstanding media link was signed with).
|
|
66
|
+
const signingKey = await loadSigningKey(env.secret, env.store);
|
|
67
|
+
const check = signingKey
|
|
68
|
+
? await verifyUploadSignature(signingKey, key, ct, url.searchParams.get('exp'), url.searchParams.get('sig'), now)
|
|
69
|
+
: { ok: false, reason: 'invalid' };
|
|
70
|
+
if (!check.ok) {
|
|
71
|
+
if (check.reason === 'expired') {
|
|
72
|
+
return fail(410, 'This upload URL has expired (they are valid for ~10 minutes).', 'Mint a fresh one with gemini_get_upload_url and retry.');
|
|
73
|
+
}
|
|
74
|
+
return fail(403, 'Invalid or missing upload signature.', 'Use the upload_url exactly as returned by gemini_get_upload_url, including the ct, exp and sig query parameters.');
|
|
75
|
+
}
|
|
76
|
+
const sentType = (request.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase();
|
|
77
|
+
if (sentType !== ct) {
|
|
78
|
+
return fail(415, `Content-Type "${sentType || '(none)'}" does not match the type this URL was minted for ("${ct}").`, `Send -H "Content-Type: ${ct}", or mint a new URL with the right content_type.`);
|
|
79
|
+
}
|
|
80
|
+
const declared = Number(request.headers.get('content-length'));
|
|
81
|
+
if (!request.headers.get('content-length') || !Number.isFinite(declared) || declared <= 0) {
|
|
82
|
+
return fail(411, 'Content-Length is required (and must be > 0).', 'curl sets it automatically with --data-binary @file.');
|
|
83
|
+
}
|
|
84
|
+
if (declared > maxBytes) {
|
|
85
|
+
return fail(413, `Upload is ${wholeMb(declared)}MB; the cap is ${wholeMb(maxBytes)}MB.`, 'Downscale the image before uploading — a reference photo does not need to be full-resolution.');
|
|
86
|
+
}
|
|
87
|
+
if (!request.body) {
|
|
88
|
+
return fail(400, 'Request had no body.', 'Send the file bytes as the request body, e.g. --data-binary @photo.jpg.');
|
|
89
|
+
}
|
|
90
|
+
// Enforce the cap on bytes actually READ, not the declared length — a
|
|
91
|
+
// hostile client can under-declare and stream more.
|
|
92
|
+
const chunks = [];
|
|
93
|
+
let total = 0;
|
|
94
|
+
const reader = request.body.getReader();
|
|
95
|
+
for (;;) {
|
|
96
|
+
const { done, value } = await reader.read();
|
|
97
|
+
if (done)
|
|
98
|
+
break;
|
|
99
|
+
total += value.byteLength;
|
|
100
|
+
if (total > maxBytes) {
|
|
101
|
+
await reader.cancel().catch(() => { });
|
|
102
|
+
return fail(413, `Upload exceeded the ${wholeMb(maxBytes)}MB cap while being read.`, 'Downscale the image before uploading.');
|
|
103
|
+
}
|
|
104
|
+
chunks.push(value);
|
|
105
|
+
}
|
|
106
|
+
const body = new Uint8Array(total);
|
|
107
|
+
let offset = 0;
|
|
108
|
+
for (const chunk of chunks) {
|
|
109
|
+
body.set(chunk, offset);
|
|
110
|
+
offset += chunk.byteLength;
|
|
111
|
+
}
|
|
112
|
+
await env.bucket.put(key, body, { httpMetadata: { contentType: ct ?? 'application/octet-stream' } });
|
|
113
|
+
return json({
|
|
114
|
+
r2_key: key,
|
|
115
|
+
size_bytes: total,
|
|
116
|
+
content_type: ct,
|
|
117
|
+
hint: `Uploaded. Reference it by r2_key: pass it in images_r2_keys on a generation tool (master_images_r2_keys on gemini_image_set), ` +
|
|
118
|
+
`save it permanently with gemini_save_character, or convert it to a ~48h Gemini Files API reference with gemini_upload_file({ r2_key }). ` +
|
|
119
|
+
`Uploads follow the connector's media retention schedule (MEDIA_TTL_DAYS, default 7 days); saved characters are kept indefinitely.`,
|
|
120
|
+
}, 201);
|
|
121
|
+
};
|
|
122
|
+
}
|
package/dist/storage/media.js
CHANGED
|
@@ -36,11 +36,16 @@ export function createDiskSink() {
|
|
|
36
36
|
note: () => undefined,
|
|
37
37
|
};
|
|
38
38
|
}
|
|
39
|
+
/** Stable, non-reversible short tenant id from a per-session secret. */
|
|
40
|
+
export async function tenantIdFor(secret) {
|
|
41
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(secret));
|
|
42
|
+
return [...new Uint8Array(digest).slice(0, 6)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
43
|
+
}
|
|
39
44
|
// `gen`, not `media`: the objects are served at `/media/<key>`, and a `media`
|
|
40
45
|
// prefix would make every URL read `/media/media/…`.
|
|
41
46
|
const KEY_PREFIX_DEFAULT = 'gen';
|
|
42
47
|
/** Object-key-safe name: no slashes, no dot-segments, never empty. */
|
|
43
|
-
function safeKeySegment(base) {
|
|
48
|
+
export function safeKeySegment(base) {
|
|
44
49
|
const cleaned = base
|
|
45
50
|
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
|
46
51
|
.replace(/\.{2,}/g, '-')
|
|
@@ -62,19 +67,63 @@ export function createR2Sink(bucket, opts) {
|
|
|
62
67
|
const publicBase = opts.publicBaseUrl?.replace(/\/+$/, '');
|
|
63
68
|
const signedBase = opts.signedBaseUrl?.replace(/\/+$/, '');
|
|
64
69
|
const ttlMs = opts.urlTtlMs ?? 0;
|
|
70
|
+
const readPrefixes = [prefix, ...(opts.readPrefixes ?? [])];
|
|
71
|
+
let tenantCache;
|
|
72
|
+
const tenantId = () => (tenantCache ??= Promise.resolve(typeof opts.tenant === 'function' ? opts.tenant() : opts.tenant));
|
|
73
|
+
/** `<prefix>/` or `<prefix>/<tenant>/` — where every WRITE goes. */
|
|
74
|
+
const ownPrefix = async () => {
|
|
75
|
+
const tenant = await tenantId();
|
|
76
|
+
return tenant ? `${prefix}/${tenant}/` : `${prefix}/`;
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Ownership gate for caller-supplied keys: this session's own namespace
|
|
80
|
+
* under any readable prefix (`gen/<tenant>/…`, plus e.g. `up/`/`lib/` on the
|
|
81
|
+
* Worker). A foreign key is refused without touching storage, so the refusal
|
|
82
|
+
* is indistinguishable from a swept object. With no tenant configured there
|
|
83
|
+
* is nothing to enforce (single-user shapes).
|
|
84
|
+
*/
|
|
85
|
+
const owned = async (key) => {
|
|
86
|
+
if (!opts.tenant)
|
|
87
|
+
return true;
|
|
88
|
+
const tenant = await tenantId();
|
|
89
|
+
return readPrefixes.some((p) => key.startsWith(`${p}/${tenant}/`));
|
|
90
|
+
};
|
|
65
91
|
return {
|
|
66
92
|
kind: 'r2',
|
|
67
93
|
persistsFiles: false,
|
|
68
|
-
async
|
|
94
|
+
async read(key) {
|
|
95
|
+
if (!bucket.get || !(await owned(key)))
|
|
96
|
+
return undefined;
|
|
97
|
+
const object = await bucket.get(key);
|
|
98
|
+
if (!object)
|
|
99
|
+
return undefined;
|
|
100
|
+
return {
|
|
101
|
+
bytes: new Uint8Array(await object.arrayBuffer()),
|
|
102
|
+
mimeType: object.httpMetadata?.contentType ?? 'application/octet-stream',
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
async resign(key) {
|
|
106
|
+
if (!(await owned(key)))
|
|
107
|
+
return undefined;
|
|
108
|
+
// Only for objects that still exist — re-signing a swept key would hand
|
|
109
|
+
// back a link that 404s, which is no better than the expired one.
|
|
110
|
+
if (bucket.get && !(await bucket.get(key)))
|
|
111
|
+
return undefined;
|
|
112
|
+
return describe(key, now().getTime() + ttlMs);
|
|
113
|
+
},
|
|
114
|
+
async persist(items, persistOpts) {
|
|
69
115
|
// `mediaExt` is pure string work but lives in images.ts next to node:fs
|
|
70
116
|
// imports; the dynamic import keeps that module off a Worker's eager
|
|
71
117
|
// module graph.
|
|
72
118
|
const { mediaExt } = await import('../images.js');
|
|
119
|
+
const keyBase = await ownPrefix();
|
|
73
120
|
const day = now().toISOString().slice(0, 10);
|
|
74
|
-
|
|
121
|
+
// Per-persist TTL override, clamped so a link never outlives its object.
|
|
122
|
+
const effectiveTtlMs = Math.min(persistOpts.urlTtlMs ?? ttlMs, opts.maxUrlTtlMs ?? Infinity);
|
|
123
|
+
const expiresAtMs = now().getTime() + effectiveTtlMs;
|
|
75
124
|
const refs = [];
|
|
76
125
|
for (const it of items) {
|
|
77
|
-
const key = `${
|
|
126
|
+
const key = `${keyBase}${day}/${randomId()}-${safeKeySegment(it.base)}.${mediaExt(it.mimeType)}`;
|
|
78
127
|
await bucket.put(key, base64ToBytes(it.base64), { httpMetadata: { contentType: it.mimeType } });
|
|
79
128
|
refs.push(await describe(key, expiresAtMs));
|
|
80
129
|
}
|
package/dist/tools/files.js
CHANGED
|
@@ -3,6 +3,7 @@ import { McpToolError, textResult } from '@chrischall/mcp-utils';
|
|
|
3
3
|
import { withProgressHeartbeat } from './shared.js';
|
|
4
4
|
import { base64ToBytes, wholeMb } from '../bytes.js';
|
|
5
5
|
import { fileNameFromUrl } from '../fetch-image.js';
|
|
6
|
+
import { downloadFilename } from '../media-endpoint.js';
|
|
6
7
|
import { previewUnlessConfirmed, previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
7
8
|
/**
|
|
8
9
|
* First-class Gemini Files API access.
|
|
@@ -70,7 +71,9 @@ export function registerFileTools(server, client) {
|
|
|
70
71
|
'Use this instead of pasting base64 into a tool call: the reference is a short string, so no image bytes ever ' +
|
|
71
72
|
'enter the conversation, and it can be reused across many generations until it expires (~48h). ' +
|
|
72
73
|
'Provide exactly one of `url` (the server downloads it), `data_base64`' +
|
|
73
|
-
(onDisk
|
|
74
|
+
(onDisk
|
|
75
|
+
? ', or `path` (a local file).'
|
|
76
|
+
: ', or `r2_key` (re-upload media this connector generated, from media[].r2_key in an earlier result).') +
|
|
74
77
|
(onDisk
|
|
75
78
|
? ''
|
|
76
79
|
: ' This connector also accepts raw bytes over HTTP: POST them to /upload with the same Authorization header ' +
|
|
@@ -89,6 +92,16 @@ export function registerFileTools(server, client) {
|
|
|
89
92
|
.min(1)
|
|
90
93
|
.optional()
|
|
91
94
|
.describe('Raw base64 or a data: URI. Last resort — this is the one form that costs model context (~14k tokens for a modest JPEG).'),
|
|
95
|
+
r2_key: z
|
|
96
|
+
.string()
|
|
97
|
+
.min(1)
|
|
98
|
+
.optional()
|
|
99
|
+
.describe(onDisk
|
|
100
|
+
? 'Unavailable on this server (media is written to local disk) — pass the file path via `path` instead.'
|
|
101
|
+
: 'An `r2_key` from a previous generation result (media[].r2_key) — re-uploads media THIS connector generated, ' +
|
|
102
|
+
'so a generated image can become a reference image in one cheap call. The server reads its own bucket directly: ' +
|
|
103
|
+
'no HTTP request, no signed URL, and no expiry to worry about. Do NOT pass a /media URL as `url` for this — ' +
|
|
104
|
+
'that would be the connector fetching itself and needs a signature.'),
|
|
92
105
|
path: z
|
|
93
106
|
.string()
|
|
94
107
|
.min(1)
|
|
@@ -105,10 +118,10 @@ export function registerFileTools(server, client) {
|
|
|
105
118
|
confirm: schemaConfirm,
|
|
106
119
|
},
|
|
107
120
|
}, async (args, extra) => {
|
|
108
|
-
const sources = [args.url && 'url', args.data_base64 && 'data_base64', args.path && 'path'].filter(Boolean);
|
|
121
|
+
const sources = [args.url && 'url', args.data_base64 && 'data_base64', args.r2_key && 'r2_key', args.path && 'path'].filter(Boolean);
|
|
109
122
|
if (sources.length !== 1) {
|
|
110
123
|
throw new McpToolError(sources.length === 0
|
|
111
|
-
? 'Provide exactly one of `url`, `data_base64`' + (onDisk ? ' or `path`.' : '
|
|
124
|
+
? 'Provide exactly one of `url`, `data_base64`' + (onDisk ? ' or `path`.' : ' or `r2_key`.')
|
|
112
125
|
: `Provide exactly one source, not ${sources.length} (${sources.join(', ')}).`);
|
|
113
126
|
}
|
|
114
127
|
if (args.path) {
|
|
@@ -131,6 +144,17 @@ export function registerFileTools(server, client) {
|
|
|
131
144
|
// this tool takes video and audio too, so 'image' would be a lie.
|
|
132
145
|
args.display_name ?? fileNameFromUrl(args.url, 'upload'));
|
|
133
146
|
}
|
|
147
|
+
if (args.r2_key) {
|
|
148
|
+
if (onDisk) {
|
|
149
|
+
throw new McpToolError('`r2_key` is only available on the hosted connector — this server writes media to local disk, ' +
|
|
150
|
+
'so pass the generated file via `path` instead.');
|
|
151
|
+
}
|
|
152
|
+
// Reading our own bucket rather than fetching our own /media URL. A
|
|
153
|
+
// self-fetch would need a signature the caller cannot mint, which is
|
|
154
|
+
// exactly the 403 loop this parameter exists to remove.
|
|
155
|
+
const stored = await client.readStoredMedia(args.r2_key);
|
|
156
|
+
return client.uploadBytes(stored.bytes, args.mime_type ?? stored.mimeType, args.display_name ?? downloadFilename(args.r2_key));
|
|
157
|
+
}
|
|
134
158
|
if (args.data_base64) {
|
|
135
159
|
const { decodeImageInput } = await import('../images.js');
|
|
136
160
|
const decoded = decodeImageInput(args.data_base64);
|
|
@@ -171,6 +195,40 @@ export function registerFileTools(server, client) {
|
|
|
171
195
|
: 'No files uploaded (or all have expired — retention is ~48h). Upload one with gemini_upload_file.',
|
|
172
196
|
});
|
|
173
197
|
});
|
|
198
|
+
// Hosted connector only. On a disk-backed server the result carries a path
|
|
199
|
+
// that never expires, so a "refresh my link" tool would only ever error —
|
|
200
|
+
// don't advertise it there (mirrors the Worker not registering video).
|
|
201
|
+
if (!onDisk) {
|
|
202
|
+
server.registerTool('gemini_sign_media', {
|
|
203
|
+
description: 'Mint a FRESH signed URL for media this connector generated, from its `r2_key`. ' +
|
|
204
|
+
'Signed media links expire well before the object behind them is cleaned up, so an expired link is not a dead ' +
|
|
205
|
+
'end — re-sign it instead of re-generating (and re-paying for) the image. Keywords: expired link, refresh url, ' +
|
|
206
|
+
're-sign, media url, download again.',
|
|
207
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
208
|
+
inputSchema: {
|
|
209
|
+
r2_key: z.string().min(1).describe('The `media[].r2_key` from an earlier generation result'),
|
|
210
|
+
},
|
|
211
|
+
}, async (args) => {
|
|
212
|
+
const sink = client.mediaSink;
|
|
213
|
+
if (!sink?.resign) {
|
|
214
|
+
throw new McpToolError('This server does not serve media URLs — generated files are written to disk and the path in the result is already usable.', {
|
|
215
|
+
hint: 'gemini_sign_media applies to the hosted connector only.',
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
const fresh = await sink.resign(args.r2_key.trim());
|
|
219
|
+
if (!fresh) {
|
|
220
|
+
// Remediation goes in the MESSAGE: MCP serialization drops hints.
|
|
221
|
+
throw new McpToolError(`No stored media for r2_key "${args.r2_key}" — it has probably passed its retention window. Re-run the generation to get a new one.`, { hint: 'Objects are swept on a retention schedule (MEDIA_TTL_DAYS, default 7 days).' });
|
|
222
|
+
}
|
|
223
|
+
return textResult({
|
|
224
|
+
url: fresh.ref,
|
|
225
|
+
r2_key: fresh.key,
|
|
226
|
+
...(fresh.expiresAt ? { expires_at: fresh.expiresAt } : {}),
|
|
227
|
+
curl_hint: `curl -sS -o ${downloadFilename(fresh.key ?? 'media')} "${fresh.ref}"`,
|
|
228
|
+
hint: 'In a Claude chat client, download this URL into your sandbox and present it as an output file — inline image blocks and markdown embeds do not render for the end user.',
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
}
|
|
174
232
|
server.registerTool('gemini_delete_file', {
|
|
175
233
|
description: 'Delete an uploaded file, image or photo (by file_uri) from the Gemini Files API before its ~48h expiry. Any tool call still referencing it ' +
|
|
176
234
|
'will then fail with a generic 404, so delete only references you are finished with.',
|
package/dist/tools/generate.js
CHANGED
|
@@ -3,7 +3,7 @@ import { readEnvVar } from '@chrischall/mcp-utils';
|
|
|
3
3
|
import { resolveModel } from '../models.js';
|
|
4
4
|
import { slugify, baseName } from '../images.js';
|
|
5
5
|
import { resolveImageInputs, requireImageInput } from '../inputs.js';
|
|
6
|
-
import { emit, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, resolveVideoInput, videoPathSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
|
|
6
|
+
import { emit, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, resolveVideoInput, videoPathSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema, imagesR2KeysSchema, charactersSchema, styleSchema, resolveCharacterRefs, composePrompt } from './shared.js';
|
|
7
7
|
import { fingerprintRequest } from '../jobs.js';
|
|
8
8
|
import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
9
9
|
const GENERATE_ENDPOINT = '/v1beta/models/{model}:generateContent';
|
|
@@ -24,7 +24,10 @@ export function registerGenerateTools(server, client) {
|
|
|
24
24
|
images: z.array(z.string().min(1)).optional().describe('Paths to reference input images (image-conditioned generation)'),
|
|
25
25
|
images_url: imagesUrlSchema(),
|
|
26
26
|
images_file_uris: imagesFileUrisSchema(),
|
|
27
|
+
images_r2_keys: imagesR2KeysSchema(),
|
|
27
28
|
images_base64: z.array(z.string().min(1)).optional().describe('Reference images as base64 strings or data URIs. Last resort: prefer images_url or images_file_uris, which keep image bytes out of the conversation'),
|
|
29
|
+
characters: charactersSchema,
|
|
30
|
+
style: styleSchema,
|
|
28
31
|
video_url: z.string().url().optional().describe('Public YouTube URL (or a previously uploaded Files API uri) as a video reference (video→image; use a Flash model e.g. gemini-3.1-flash-image)'),
|
|
29
32
|
video_path: videoPathSchema,
|
|
30
33
|
confirm: schemaConfirm,
|
|
@@ -49,15 +52,22 @@ export function registerGenerateTools(server, client) {
|
|
|
49
52
|
model, prompt: args.prompt, aspect_ratio: args.aspect_ratio, image_size: args.image_size,
|
|
50
53
|
thinking_level: args.thinking_level, google_search: args.google_search, count,
|
|
51
54
|
images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
|
|
52
|
-
images_url: args.images_url, images_file_uris: args.images_file_uris,
|
|
55
|
+
images_url: args.images_url, images_file_uris: args.images_file_uris, images_r2_keys: args.images_r2_keys,
|
|
56
|
+
characters: args.characters, style: args.style,
|
|
53
57
|
video_url: args.video_url, video_path: args.video_path,
|
|
54
58
|
});
|
|
55
|
-
return client.session.jobs.dispatch({ toolName: 'gemini_image_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
59
|
+
return client.session.jobs.dispatch({ toolName: 'gemini_image_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async () => {
|
|
56
60
|
const seed = pickSeed(args.seed);
|
|
57
61
|
const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
|
|
62
|
+
// Saved characters/style resolve first (and fail fast on an unknown
|
|
63
|
+
// name — before anything billable), so their reference images lead the
|
|
64
|
+
// input order the prompt preamble describes.
|
|
65
|
+
const charRefs = await resolveCharacterRefs(client, args.characters, args.style);
|
|
66
|
+
const prompt = composePrompt(args.prompt, charRefs);
|
|
58
67
|
// Resolved ONCE, before the count loop: a URL is fetched (and a large
|
|
59
68
|
// one uploaded) a single time no matter how many images are generated.
|
|
60
|
-
const { inputs:
|
|
69
|
+
const { inputs: directInputs, report } = await withProgressHeartbeat(extra, 'Resolving reference images', () => resolveImageInputs(args, client));
|
|
70
|
+
const refInputs = [...charRefs.inputs, ...directInputs];
|
|
61
71
|
// Upload ONCE (before the count loop) — every generate call references
|
|
62
72
|
// the same files/… uri.
|
|
63
73
|
const video = await withProgressHeartbeat(extra, 'Uploading video to the Gemini Files API', () => resolveVideoInput(args, client));
|
|
@@ -66,31 +76,46 @@ export function registerGenerateTools(server, client) {
|
|
|
66
76
|
// For count>1, surface the FIRST call's grounding (each call grounds
|
|
67
77
|
// independently; one representative set of sources beats concatenating N).
|
|
68
78
|
let capturedGrounding;
|
|
79
|
+
// count>1 is N billed generations in one tools/call, and any one can
|
|
80
|
+
// fail (a safety filter, a transient 5xx). Failures are collected per
|
|
81
|
+
// image so one bad draw cannot lose the ones that succeeded — same
|
|
82
|
+
// contract as gemini_image_set's failed_scenes. Zero successes still
|
|
83
|
+
// throws: an empty success is worse than an error.
|
|
84
|
+
const failed = [];
|
|
85
|
+
let firstError;
|
|
69
86
|
await withProgressHeartbeat(extra, `Generating ${count > 1 ? `${count} images` : 'image'} (${model})`, async () => {
|
|
70
87
|
for (let i = 0; i < count; i++) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
88
|
+
try {
|
|
89
|
+
// Distinct seed per image (seed+0 for the single-image case == echoed seed)
|
|
90
|
+
// so count>1 yields N *different* images, not N duplicates.
|
|
91
|
+
const result = await client.generate({
|
|
92
|
+
prompt,
|
|
93
|
+
images: refInputs.length > 0 ? refInputs : undefined,
|
|
94
|
+
model: args.model,
|
|
95
|
+
aspectRatio: args.aspect_ratio,
|
|
96
|
+
imageSize: args.image_size,
|
|
97
|
+
seed: seed + i,
|
|
98
|
+
thinkingLevel: args.thinking_level,
|
|
99
|
+
googleSearch: args.google_search,
|
|
100
|
+
videoUrl: video.videoUrl,
|
|
101
|
+
videoMimeType: video.videoMimeType,
|
|
102
|
+
timeoutMs: args.timeout_ms,
|
|
103
|
+
});
|
|
104
|
+
const { images: [img], text } = result;
|
|
105
|
+
if (text && !capturedText)
|
|
106
|
+
capturedText = text;
|
|
107
|
+
if (result.grounding && !capturedGrounding)
|
|
108
|
+
capturedGrounding = result.grounding;
|
|
109
|
+
named.push({ image: img, base: count === 1 ? slug : `${slug}-${String(i + 1).padStart(2, '0')}` });
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
firstError ??= err;
|
|
113
|
+
failed.push({ image: i + 1, error: err instanceof Error ? err.message : String(err) });
|
|
114
|
+
}
|
|
92
115
|
}
|
|
93
116
|
});
|
|
117
|
+
if (named.length === 0)
|
|
118
|
+
throw firstError;
|
|
94
119
|
const meta = buildMeta(model, seed, args);
|
|
95
120
|
if (capturedText)
|
|
96
121
|
meta.text = capturedText;
|
|
@@ -98,6 +123,14 @@ export function registerGenerateTools(server, client) {
|
|
|
98
123
|
meta.grounding = capturedGrounding;
|
|
99
124
|
if (report)
|
|
100
125
|
meta.image_inputs = report;
|
|
126
|
+
if (charRefs.meta)
|
|
127
|
+
Object.assign(meta, charRefs.meta);
|
|
128
|
+
if (failed.length) {
|
|
129
|
+
meta.failed_images = failed;
|
|
130
|
+
meta.partial =
|
|
131
|
+
`${failed.length} of ${count} image(s) failed; the ${named.length} that succeeded are returned above. ` +
|
|
132
|
+
'Re-run with count set to the shortfall rather than the whole batch — each failure message is in failed_images.';
|
|
133
|
+
}
|
|
101
134
|
if (video.videoFileMeta)
|
|
102
135
|
meta.video_file = video.videoFileMeta;
|
|
103
136
|
meta.hint = REFINE_HINT;
|
|
@@ -118,14 +151,20 @@ export function registerGenerateTools(server, client) {
|
|
|
118
151
|
images: z.array(z.string().min(1)).optional().describe('Paths to input image file(s) (1 = edit, 2+ = compose)'),
|
|
119
152
|
images_url: imagesUrlSchema('Input images'),
|
|
120
153
|
images_file_uris: imagesFileUrisSchema('Input images'),
|
|
154
|
+
images_r2_keys: imagesR2KeysSchema('Input images'),
|
|
121
155
|
images_base64: z.array(z.string().min(1)).optional().describe('Input images as base64 strings or data URIs. Last resort: prefer images_url or images_file_uris, which keep image bytes out of the conversation'),
|
|
156
|
+
characters: charactersSchema,
|
|
157
|
+
style: styleSchema,
|
|
122
158
|
filename: z.string().optional().describe('Base filename for the output image (extension stripped; default: slugified prompt)'),
|
|
123
159
|
confirm: schemaConfirm,
|
|
124
160
|
...sharedImageSchema,
|
|
125
161
|
},
|
|
126
162
|
}, async (args, extra) => {
|
|
127
163
|
assertLocalInputsAvailable(client.mediaSink, args);
|
|
128
|
-
|
|
164
|
+
// Saved characters count as input images for the required-input check —
|
|
165
|
+
// the library attaches their references inside the handler.
|
|
166
|
+
if (!args.characters?.length)
|
|
167
|
+
requireImageInput(args);
|
|
129
168
|
// Confirm-gate local file inputs (see gemini_image_generate). base64 /
|
|
130
169
|
// clipboard inputs are not local-path reads and pass through ungated.
|
|
131
170
|
const gate = await previewLocalInputsUnlessConfirmed(args.confirm, SEND_LOCAL_ACTION, GENERATE_ENDPOINT, args.images);
|
|
@@ -136,14 +175,18 @@ export function registerGenerateTools(server, client) {
|
|
|
136
175
|
model, prompt: args.prompt, aspect_ratio: args.aspect_ratio, image_size: args.image_size,
|
|
137
176
|
thinking_level: args.thinking_level, google_search: args.google_search,
|
|
138
177
|
images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
|
|
139
|
-
images_url: args.images_url, images_file_uris: args.images_file_uris,
|
|
178
|
+
images_url: args.images_url, images_file_uris: args.images_file_uris, images_r2_keys: args.images_r2_keys,
|
|
179
|
+
characters: args.characters, style: args.style,
|
|
140
180
|
});
|
|
141
|
-
return client.session.jobs.dispatch({ toolName: 'gemini_image_edit', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
181
|
+
return client.session.jobs.dispatch({ toolName: 'gemini_image_edit', fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async () => {
|
|
142
182
|
const seed = pickSeed(args.seed);
|
|
143
183
|
const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
|
|
144
|
-
const
|
|
184
|
+
const charRefs = await resolveCharacterRefs(client, args.characters, args.style);
|
|
185
|
+
const prompt = composePrompt(args.prompt, charRefs);
|
|
186
|
+
const { inputs: directInputs, report } = await withProgressHeartbeat(extra, 'Resolving input images', () => resolveImageInputs(args, client));
|
|
187
|
+
const inputs = [...charRefs.inputs, ...directInputs];
|
|
145
188
|
const result = await withProgressHeartbeat(extra, `Editing image (${model})`, () => client.generate({
|
|
146
|
-
prompt
|
|
189
|
+
prompt,
|
|
147
190
|
images: inputs,
|
|
148
191
|
model: args.model,
|
|
149
192
|
aspectRatio: args.aspect_ratio,
|
|
@@ -161,6 +204,8 @@ export function registerGenerateTools(server, client) {
|
|
|
161
204
|
meta.grounding = result.grounding;
|
|
162
205
|
if (report)
|
|
163
206
|
meta.image_inputs = report;
|
|
207
|
+
if (charRefs.meta)
|
|
208
|
+
Object.assign(meta, charRefs.meta);
|
|
164
209
|
meta.hint = REFINE_HINT;
|
|
165
210
|
const risk = timeoutRiskHint({ model, imageSize: args.image_size, persistsFiles: client.mediaSink?.persistsFiles });
|
|
166
211
|
if (risk)
|
package/dist/tools/interact.js
CHANGED
|
@@ -5,7 +5,7 @@ import { ChainedRequest404Error } from '../client.js';
|
|
|
5
5
|
import { slugify, baseName, resolveImagePath, writeSidecar, resolveOutputDir, readImageAsInline } from '../images.js';
|
|
6
6
|
import { resolveImageInputs } from '../inputs.js';
|
|
7
7
|
import { findInteractionImages, latestInteractionId } from '../sidecar.js';
|
|
8
|
-
import { emit, ASPECT_RATIOS, IMAGE_SIZES, MODEL_CHOICE_GUIDE, resolveVideoInput, videoPathSchema, timeoutMsSchema, timeoutRiskHint, idempotencyKeySchema, asyncSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
|
|
8
|
+
import { emit, ASPECT_RATIOS, IMAGE_SIZES, MODEL_CHOICE_GUIDE, resolveVideoInput, videoPathSchema, timeoutMsSchema, timeoutRiskHint, idempotencyKeySchema, asyncSchema, maxWaitMsSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
|
|
9
9
|
import { fingerprintRequest } from '../jobs.js';
|
|
10
10
|
import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
11
11
|
// The last-interaction id and the written-outputs set used to live here, at
|
|
@@ -126,6 +126,7 @@ export function registerInteractTools(server, client) {
|
|
|
126
126
|
timeout_ms: timeoutMsSchema,
|
|
127
127
|
idempotency_key: idempotencyKeySchema,
|
|
128
128
|
async: asyncSchema,
|
|
129
|
+
max_wait_ms: maxWaitMsSchema,
|
|
129
130
|
from_clipboard: z
|
|
130
131
|
.boolean()
|
|
131
132
|
.optional()
|
|
@@ -180,7 +181,7 @@ export function registerInteractTools(server, client) {
|
|
|
180
181
|
images_url: args.images_url, images_file_uris: args.images_file_uris,
|
|
181
182
|
video_url: args.video_url, video_path: args.video_path,
|
|
182
183
|
});
|
|
183
|
-
return client.session.jobs.dispatch({ toolName: 'gemini_interact', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
184
|
+
return client.session.jobs.dispatch({ toolName: 'gemini_interact', fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async () => {
|
|
184
185
|
const { inputs, report } = await resolveImageInputs({ ...args, images }, client);
|
|
185
186
|
const video = await withProgressHeartbeat(extra, 'Uploading video to the Gemini Files API', () => resolveVideoInput(args, client));
|
|
186
187
|
const callOpts = {
|