@chrischall/gemini-mcp 1.10.0 → 1.11.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 +1 -0
- package/dist/bundle.js +339 -7
- package/dist/client.js +23 -4
- package/dist/jobs.js +4 -0
- package/dist/pricing.js +201 -0
- package/dist/registrars.js +2 -0
- package/dist/session.js +52 -0
- package/dist/tools/generate.js +13 -0
- package/dist/tools/interact.js +4 -0
- package/dist/tools/music.js +4 -0
- package/dist/tools/set.js +17 -2
- package/dist/tools/usage.js +71 -0
- package/dist/tools/video.js +4 -0
- package/dist/usage.js +156 -0
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/server.json +2 -2
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "MCP server for Google Gemini image generation and editing — generate and edit images via the Gemini API",
|
|
10
|
-
"version": "1.
|
|
10
|
+
"version": "1.11.0"
|
|
11
11
|
},
|
|
12
12
|
"plugins": [
|
|
13
13
|
{
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"displayName": "Gemini Images",
|
|
16
16
|
"source": "./",
|
|
17
17
|
"description": "Generate and edit images with Google Gemini image models (Nano Banana / Nano Banana Pro) via natural language prompts",
|
|
18
|
-
"version": "1.
|
|
18
|
+
"version": "1.11.0",
|
|
19
19
|
"author": {
|
|
20
20
|
"name": "Chris Hall"
|
|
21
21
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gemini-mcp",
|
|
3
3
|
"displayName": "Gemini Images",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.11.0",
|
|
5
5
|
"description": "MCP server for Google Gemini image generation and editing — generate and edit images via the Gemini API",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Chris Hall",
|
package/README.md
CHANGED
|
@@ -83,6 +83,7 @@ progress), two guards make re-issuing safe and unnecessary:
|
|
|
83
83
|
| `gemini_video_generate` | Generate a short video (text→video, image→video, or `edit`) via the Gemini omni model (preview); written to disk as MP4 |
|
|
84
84
|
| `gemini_music_generate` | Generate music from a text prompt via a Lyria model — `lyria-3-clip-preview` (~30s, default) or `lyria-3-pro-preview` (longer, WAV-capable); written to disk as MP3/WAV (preview) |
|
|
85
85
|
| `gemini_get_result` | Fetch an async generation started with `async: true` by its `job_id` (status `running` → `done` result). Lets a long generation outlive a host's `tools/call` timeout |
|
|
86
|
+
| `gemini_token_usage` | Token usage and an estimated USD cost for this session so far. Call it before and after a workflow and subtract to attribute that workflow's spend. Priced per call against each call's own model from a dated rate card (`GEMINI_RATE_CARD` overrides it); there is no account-balance endpoint to read, so this is how spend is attributed |
|
|
86
87
|
| `gemini_upload_file` | Upload an image (or video/audio) to the Gemini Files API once — from a `url`, `data_base64`, or a local `path` — and get a reusable `files/<id>` reference |
|
|
87
88
|
| `gemini_list_files` | List the files currently uploaded under this API key, with MIME types and expiry times |
|
|
88
89
|
| `gemini_delete_file` | Delete an uploaded file before its ~48h expiry (confirm-gated) |
|
package/dist/bundle.js
CHANGED
|
@@ -32549,6 +32549,14 @@ var init_parse_lenient = __esm({
|
|
|
32549
32549
|
});
|
|
32550
32550
|
|
|
32551
32551
|
// node_modules/@chrischall/mcp-utils/dist/zod/index.js
|
|
32552
|
+
function toolAnnotations(opts = {}) {
|
|
32553
|
+
return {
|
|
32554
|
+
...opts.title !== void 0 ? { title: opts.title } : {},
|
|
32555
|
+
readOnlyHint: opts.readOnly ?? true,
|
|
32556
|
+
...opts.idempotent !== void 0 ? { idempotentHint: opts.idempotent } : {},
|
|
32557
|
+
...opts.openWorld !== void 0 ? { openWorldHint: opts.openWorld } : {}
|
|
32558
|
+
};
|
|
32559
|
+
}
|
|
32552
32560
|
var PositiveInt, NonNegInt, NonEmptyString, IsoDate, IsoTime, NumericIdString, SafePathSegment, schemaOrigin, schemaConfirm, paginationSchema, pageSchema;
|
|
32553
32561
|
var init_zod2 = __esm({
|
|
32554
32562
|
"node_modules/@chrischall/mcp-utils/dist/zod/index.js"() {
|
|
@@ -32868,7 +32876,7 @@ async function loadStdioDotenv() {
|
|
|
32868
32876
|
}
|
|
32869
32877
|
|
|
32870
32878
|
// src/version.ts
|
|
32871
|
-
var VERSION = "1.
|
|
32879
|
+
var VERSION = "1.11.0";
|
|
32872
32880
|
|
|
32873
32881
|
// src/errors.ts
|
|
32874
32882
|
init_dist();
|
|
@@ -33972,6 +33980,98 @@ function createBlobJobStore(bucket, opts) {
|
|
|
33972
33980
|
};
|
|
33973
33981
|
}
|
|
33974
33982
|
|
|
33983
|
+
// src/usage.ts
|
|
33984
|
+
var num = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
33985
|
+
function modalityTokens(list, modality) {
|
|
33986
|
+
if (!Array.isArray(list)) return void 0;
|
|
33987
|
+
for (const entry of list) {
|
|
33988
|
+
if (!entry || typeof entry !== "object") continue;
|
|
33989
|
+
const e = entry;
|
|
33990
|
+
if (typeof e.modality === "string" && e.modality.toLowerCase() === modality) {
|
|
33991
|
+
return num(e.tokenCount) ?? num(e.tokens);
|
|
33992
|
+
}
|
|
33993
|
+
}
|
|
33994
|
+
return void 0;
|
|
33995
|
+
}
|
|
33996
|
+
function readUsage(body) {
|
|
33997
|
+
if (!body || typeof body !== "object") return void 0;
|
|
33998
|
+
const b = body;
|
|
33999
|
+
const meta3 = b.usageMetadata;
|
|
34000
|
+
if (meta3) {
|
|
34001
|
+
const input = num(meta3.promptTokenCount);
|
|
34002
|
+
const output = num(meta3.candidatesTokenCount);
|
|
34003
|
+
const total = num(meta3.totalTokenCount);
|
|
34004
|
+
if (input !== void 0 || output !== void 0 || total !== void 0) {
|
|
34005
|
+
return compact({
|
|
34006
|
+
input_tokens: input ?? 0,
|
|
34007
|
+
output_tokens: output ?? 0,
|
|
34008
|
+
total_tokens: total ?? (input ?? 0) + (output ?? 0),
|
|
34009
|
+
image_tokens: modalityTokens(meta3.candidatesTokensDetails, "image"),
|
|
34010
|
+
video_tokens: modalityTokens(meta3.candidatesTokensDetails, "video"),
|
|
34011
|
+
audio_tokens: modalityTokens(meta3.candidatesTokensDetails, "audio"),
|
|
34012
|
+
cached_tokens: num(meta3.cachedContentTokenCount)
|
|
34013
|
+
});
|
|
34014
|
+
}
|
|
34015
|
+
}
|
|
34016
|
+
const usage = b.usage;
|
|
34017
|
+
if (usage) {
|
|
34018
|
+
const input = num(usage.total_input_tokens);
|
|
34019
|
+
const output = num(usage.total_output_tokens);
|
|
34020
|
+
const total = num(usage.total_tokens);
|
|
34021
|
+
if (input !== void 0 || output !== void 0 || total !== void 0) {
|
|
34022
|
+
return compact({
|
|
34023
|
+
input_tokens: input ?? 0,
|
|
34024
|
+
output_tokens: output ?? 0,
|
|
34025
|
+
total_tokens: total ?? (input ?? 0) + (output ?? 0),
|
|
34026
|
+
image_tokens: modalityTokens(usage.output_tokens_by_modality, "image"),
|
|
34027
|
+
video_tokens: modalityTokens(usage.output_tokens_by_modality, "video"),
|
|
34028
|
+
audio_tokens: modalityTokens(usage.output_tokens_by_modality, "audio"),
|
|
34029
|
+
cached_tokens: num(usage.total_cached_tokens),
|
|
34030
|
+
thought_tokens: num(usage.total_thought_tokens)
|
|
34031
|
+
});
|
|
34032
|
+
}
|
|
34033
|
+
}
|
|
34034
|
+
return void 0;
|
|
34035
|
+
}
|
|
34036
|
+
function compact(u) {
|
|
34037
|
+
const out = {
|
|
34038
|
+
input_tokens: u.input_tokens,
|
|
34039
|
+
output_tokens: u.output_tokens,
|
|
34040
|
+
total_tokens: u.total_tokens
|
|
34041
|
+
};
|
|
34042
|
+
if (u.image_tokens) out.image_tokens = u.image_tokens;
|
|
34043
|
+
if (u.video_tokens) out.video_tokens = u.video_tokens;
|
|
34044
|
+
if (u.audio_tokens) out.audio_tokens = u.audio_tokens;
|
|
34045
|
+
if (u.cached_tokens) out.cached_tokens = u.cached_tokens;
|
|
34046
|
+
if (u.thought_tokens) out.thought_tokens = u.thought_tokens;
|
|
34047
|
+
return out;
|
|
34048
|
+
}
|
|
34049
|
+
function sumUsage(parts) {
|
|
34050
|
+
const known = parts.filter((p) => p !== void 0);
|
|
34051
|
+
if (known.length === 0) return void 0;
|
|
34052
|
+
return compact(
|
|
34053
|
+
known.reduce(
|
|
34054
|
+
(acc, u) => ({
|
|
34055
|
+
input_tokens: acc.input_tokens + u.input_tokens,
|
|
34056
|
+
output_tokens: acc.output_tokens + u.output_tokens,
|
|
34057
|
+
total_tokens: acc.total_tokens + u.total_tokens,
|
|
34058
|
+
image_tokens: (acc.image_tokens ?? 0) + (u.image_tokens ?? 0),
|
|
34059
|
+
video_tokens: (acc.video_tokens ?? 0) + (u.video_tokens ?? 0),
|
|
34060
|
+
audio_tokens: (acc.audio_tokens ?? 0) + (u.audio_tokens ?? 0),
|
|
34061
|
+
cached_tokens: (acc.cached_tokens ?? 0) + (u.cached_tokens ?? 0),
|
|
34062
|
+
thought_tokens: (acc.thought_tokens ?? 0) + (u.thought_tokens ?? 0)
|
|
34063
|
+
}),
|
|
34064
|
+
{ input_tokens: 0, output_tokens: 0, total_tokens: 0 }
|
|
34065
|
+
)
|
|
34066
|
+
);
|
|
34067
|
+
}
|
|
34068
|
+
function annotateReusedUsage(meta3) {
|
|
34069
|
+
if (!meta3.usage) return meta3;
|
|
34070
|
+
meta3.usage_billed = false;
|
|
34071
|
+
if (meta3.cost_estimate) meta3.cost_billed = false;
|
|
34072
|
+
return meta3;
|
|
34073
|
+
}
|
|
34074
|
+
|
|
33975
34075
|
// src/jobs.ts
|
|
33976
34076
|
var JOB_TTL_MS = 6e5;
|
|
33977
34077
|
var DURABLE_JOB_REUSE_MS = 24 * 60 * 60 * 1e3;
|
|
@@ -34052,6 +34152,7 @@ function annotateReused(result, jobId) {
|
|
|
34052
34152
|
}
|
|
34053
34153
|
obj.reused = true;
|
|
34054
34154
|
obj.reused_job_id = jobId;
|
|
34155
|
+
annotateReusedUsage(obj);
|
|
34055
34156
|
return { ...result, content: [{ ...first, text: JSON.stringify(obj, null, 2) }, ...content.slice(1)] };
|
|
34056
34157
|
}
|
|
34057
34158
|
return {
|
|
@@ -34437,6 +34538,113 @@ var JobRegistry = class {
|
|
|
34437
34538
|
};
|
|
34438
34539
|
var JOB_RECORD_RETENTION_LABEL = "30 days";
|
|
34439
34540
|
|
|
34541
|
+
// src/pricing.ts
|
|
34542
|
+
init_dist();
|
|
34543
|
+
var PRICED_AT = "2026-08-28";
|
|
34544
|
+
var RATE_CARD = Object.freeze({
|
|
34545
|
+
"gemini-3-pro-image": { input: 2, text_output: 12, image_output: 120 },
|
|
34546
|
+
"gemini-3.1-flash-image": { input: 0.5, text_output: 3, image_output: 60 },
|
|
34547
|
+
"gemini-3.1-flash-lite-image": { input: 0.25, text_output: 1.5, image_output: 30 },
|
|
34548
|
+
// Google publishes 2.5-flash-image's output as $0.039 PER IMAGE at 1290
|
|
34549
|
+
// tokens, not as a per-1M rate. 0.039 / 1290 * 1e6 = 30.23 — a back-derived
|
|
34550
|
+
// figure, unlike the four above which are published per-1M directly. The
|
|
34551
|
+
// test reconciles it against the $0.039 it came from.
|
|
34552
|
+
"gemini-2.5-flash-image": { input: 0.3, text_output: 3, image_output: 30.2326 },
|
|
34553
|
+
// Omni is token-billed for video: $17.50/1M at ~5,792 tokens per second of
|
|
34554
|
+
// 720p, which is the ~$0.10/second Google quotes. Veo is the per-second
|
|
34555
|
+
// family; this server does not use it.
|
|
34556
|
+
//
|
|
34557
|
+
// No image-output price is published for omni, so there is no image_output
|
|
34558
|
+
// here. An absent rate prices that modality at zero rather than inventing
|
|
34559
|
+
// one — and the first version of this entry DID invent one ($60, copied
|
|
34560
|
+
// from flash-image), along with a $0.50 input and $3.00 text rate that were
|
|
34561
|
+
// both a third of the real figures. Every number here is now quoted.
|
|
34562
|
+
"gemini-omni-flash": { input: 1.5, text_output: 9, video_output: 17.5 },
|
|
34563
|
+
// Lyria bills per song, so tokens describe the work and not the bill.
|
|
34564
|
+
"lyria-3-clip": { input: 0, text_output: 0, per_generation: 0.04 },
|
|
34565
|
+
"lyria-3-pro": { input: 0, text_output: 0, per_generation: 0.08 }
|
|
34566
|
+
});
|
|
34567
|
+
function normalizeModel(model) {
|
|
34568
|
+
return model.replace(/-preview$/, "");
|
|
34569
|
+
}
|
|
34570
|
+
function loadRateCard(env = process.env) {
|
|
34571
|
+
const raw = readEnvVar("GEMINI_RATE_CARD", { env });
|
|
34572
|
+
if (!raw) return { ...RATE_CARD };
|
|
34573
|
+
let parsed;
|
|
34574
|
+
try {
|
|
34575
|
+
parsed = JSON.parse(raw);
|
|
34576
|
+
} catch {
|
|
34577
|
+
return { ...RATE_CARD };
|
|
34578
|
+
}
|
|
34579
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { ...RATE_CARD };
|
|
34580
|
+
const out = { ...RATE_CARD };
|
|
34581
|
+
for (const [model, override] of Object.entries(parsed)) {
|
|
34582
|
+
if (!override || typeof override !== "object" || Array.isArray(override)) continue;
|
|
34583
|
+
const key = normalizeModel(model);
|
|
34584
|
+
const merged = { ...RATE_CARD[key] ?? {}, ...out[key] ?? {} };
|
|
34585
|
+
for (const [field, value] of Object.entries(override)) {
|
|
34586
|
+
if (typeof value === "number" && Number.isFinite(value)) merged[field] = value;
|
|
34587
|
+
}
|
|
34588
|
+
if (typeof merged.input === "number" && typeof merged.text_output === "number") {
|
|
34589
|
+
out[key] = merged;
|
|
34590
|
+
}
|
|
34591
|
+
}
|
|
34592
|
+
return out;
|
|
34593
|
+
}
|
|
34594
|
+
function estimateCost(model, usage, env = process.env) {
|
|
34595
|
+
const card = loadRateCard(env);
|
|
34596
|
+
const key = normalizeModel(model);
|
|
34597
|
+
const rates = card[key];
|
|
34598
|
+
if (!rates) return void 0;
|
|
34599
|
+
const per = (tokens, rate) => (
|
|
34600
|
+
// An absent rate prices as zero, never NaN. A modality with no published
|
|
34601
|
+
// rate is not evidence of spend, and NaN is absorbing — it would poison
|
|
34602
|
+
// every later total in the session.
|
|
34603
|
+
typeof rate === "number" && Number.isFinite(rate) ? tokens * rate / 1e6 : 0
|
|
34604
|
+
);
|
|
34605
|
+
if (typeof rates.per_generation === "number") {
|
|
34606
|
+
return {
|
|
34607
|
+
usd: rates.per_generation,
|
|
34608
|
+
breakdown: {
|
|
34609
|
+
input_usd: 0,
|
|
34610
|
+
text_output_usd: 0,
|
|
34611
|
+
image_usd: 0,
|
|
34612
|
+
video_usd: 0,
|
|
34613
|
+
audio_usd: 0,
|
|
34614
|
+
per_generation_usd: rates.per_generation
|
|
34615
|
+
},
|
|
34616
|
+
priced_at: PRICED_AT,
|
|
34617
|
+
...overriddenFor(key, rates) ? { overridden: true } : {}
|
|
34618
|
+
};
|
|
34619
|
+
}
|
|
34620
|
+
const imageTokens = usage.image_tokens ?? 0;
|
|
34621
|
+
const videoTokens = usage.video_tokens ?? 0;
|
|
34622
|
+
const audioTokens = usage.audio_tokens ?? 0;
|
|
34623
|
+
const textTokens = Math.max(0, usage.output_tokens - imageTokens - videoTokens - audioTokens);
|
|
34624
|
+
const input_usd = per(usage.input_tokens, rates.input);
|
|
34625
|
+
const text_output_usd = per(textTokens, rates.text_output);
|
|
34626
|
+
const image_usd = per(imageTokens, rates.image_output);
|
|
34627
|
+
const video_usd = per(videoTokens, rates.video_output);
|
|
34628
|
+
const audio_usd = per(audioTokens, rates.audio_output);
|
|
34629
|
+
return {
|
|
34630
|
+
usd: input_usd + text_output_usd + image_usd + video_usd + audio_usd,
|
|
34631
|
+
breakdown: { input_usd, text_output_usd, image_usd, video_usd, audio_usd },
|
|
34632
|
+
priced_at: PRICED_AT,
|
|
34633
|
+
...overriddenFor(key, rates) ? { overridden: true } : {}
|
|
34634
|
+
};
|
|
34635
|
+
}
|
|
34636
|
+
function overriddenFor(key, rates) {
|
|
34637
|
+
const shipped = RATE_CARD[key];
|
|
34638
|
+
if (!shipped) return true;
|
|
34639
|
+
return Object.keys(rates).some((f) => rates[f] !== shipped[f]);
|
|
34640
|
+
}
|
|
34641
|
+
function attachCost(meta3, model, usage) {
|
|
34642
|
+
if (!usage) return meta3;
|
|
34643
|
+
const cost = estimateCost(model, usage);
|
|
34644
|
+
if (cost) meta3.cost_estimate = cost;
|
|
34645
|
+
return meta3;
|
|
34646
|
+
}
|
|
34647
|
+
|
|
34440
34648
|
// src/tools/_confirm.ts
|
|
34441
34649
|
init_dist();
|
|
34442
34650
|
init_images();
|
|
@@ -34526,6 +34734,7 @@ function registerGenerateTools(server, client2) {
|
|
|
34526
34734
|
let capturedGrounding;
|
|
34527
34735
|
const failed = [];
|
|
34528
34736
|
let firstError;
|
|
34737
|
+
const usages = [];
|
|
34529
34738
|
await withProgressHeartbeat(extra, `Generating ${count > 1 ? `${count} images` : "image"} (${model})`, async () => {
|
|
34530
34739
|
for (let i = 0; i < count; i++) {
|
|
34531
34740
|
try {
|
|
@@ -34543,6 +34752,7 @@ function registerGenerateTools(server, client2) {
|
|
|
34543
34752
|
timeoutMs: args.timeout_ms
|
|
34544
34753
|
});
|
|
34545
34754
|
const { images: [img], text } = result;
|
|
34755
|
+
usages.push(result.usage);
|
|
34546
34756
|
if (text && !capturedText) capturedText = text;
|
|
34547
34757
|
if (result.grounding && !capturedGrounding) capturedGrounding = result.grounding;
|
|
34548
34758
|
named.push({ image: img, base: count === 1 ? slug : `${slug}-${String(i + 1).padStart(2, "0")}` });
|
|
@@ -34554,6 +34764,9 @@ function registerGenerateTools(server, client2) {
|
|
|
34554
34764
|
});
|
|
34555
34765
|
if (named.length === 0) throw firstError;
|
|
34556
34766
|
const meta3 = buildMeta(model, seed, { ...args, aspect_ratio: aspectRatio });
|
|
34767
|
+
const usage = sumUsage(usages);
|
|
34768
|
+
if (usage) meta3.usage = usage;
|
|
34769
|
+
attachCost(meta3, model, usage);
|
|
34557
34770
|
if (capturedText) meta3.text = capturedText;
|
|
34558
34771
|
if (capturedGrounding) meta3.grounding = capturedGrounding;
|
|
34559
34772
|
if (report) meta3.image_inputs = report;
|
|
@@ -34632,6 +34845,8 @@ function registerGenerateTools(server, client2) {
|
|
|
34632
34845
|
}));
|
|
34633
34846
|
const { images: [img], text } = result;
|
|
34634
34847
|
const meta3 = buildMeta(model, seed, { ...args, aspect_ratio: aspectRatio });
|
|
34848
|
+
if (result.usage) meta3.usage = result.usage;
|
|
34849
|
+
attachCost(meta3, model, result.usage);
|
|
34635
34850
|
if (text) meta3.text = text;
|
|
34636
34851
|
if (result.grounding) meta3.grounding = result.grounding;
|
|
34637
34852
|
if (report) meta3.image_inputs = report;
|
|
@@ -34720,6 +34935,7 @@ function registerSetTools(server, client2) {
|
|
|
34720
34935
|
const failed = [];
|
|
34721
34936
|
const report = carried.report || masterOnly.report ? { ...masterOnly.report, ...carried.report } : void 0;
|
|
34722
34937
|
const named = [];
|
|
34938
|
+
const usages = [];
|
|
34723
34939
|
const masterResult = await withProgressHeartbeat(extra, `Generating image set (${model})`, async () => {
|
|
34724
34940
|
const result = await client2.generate({
|
|
34725
34941
|
prompt: masterPrompt,
|
|
@@ -34727,6 +34943,7 @@ function registerSetTools(server, client2) {
|
|
|
34727
34943
|
seed,
|
|
34728
34944
|
...cfg
|
|
34729
34945
|
});
|
|
34946
|
+
usages.push(result.usage);
|
|
34730
34947
|
const master = result.images[0];
|
|
34731
34948
|
named.push({ image: master, base: `${slug}-master` });
|
|
34732
34949
|
const scenePrompts = args.scenes ?? Array.from({ length: args.count ?? 0 }, () => args.master_prompt);
|
|
@@ -34743,7 +34960,9 @@ function registerSetTools(server, client2) {
|
|
|
34743
34960
|
let ref = master;
|
|
34744
34961
|
for (let i = 0; i < scenePrompts.length; i++) {
|
|
34745
34962
|
try {
|
|
34746
|
-
const
|
|
34963
|
+
const scene = await client2.generate({ prompt: composePrompt(scenePrompts[i], styleOnly), images: [ref, ...carriedRefs], seed: seed + i + 1, ...cfg });
|
|
34964
|
+
usages.push(scene.usage);
|
|
34965
|
+
const img = scene.images[0];
|
|
34747
34966
|
named.push({ image: img, base: sceneName(i) });
|
|
34748
34967
|
ref = img;
|
|
34749
34968
|
} catch (err) {
|
|
@@ -34752,7 +34971,10 @@ function registerSetTools(server, client2) {
|
|
|
34752
34971
|
}
|
|
34753
34972
|
} else {
|
|
34754
34973
|
const settled = await Promise.allSettled(
|
|
34755
|
-
scenePrompts.map((p, i) => client2.generate({ prompt: composePrompt(p, styleOnly), images: [master, ...carriedRefs], seed: seed + i + 1, ...cfg }).then((r) =>
|
|
34974
|
+
scenePrompts.map((p, i) => client2.generate({ prompt: composePrompt(p, styleOnly), images: [master, ...carriedRefs], seed: seed + i + 1, ...cfg }).then((r) => {
|
|
34975
|
+
usages.push(r.usage);
|
|
34976
|
+
return r.images[0];
|
|
34977
|
+
}))
|
|
34756
34978
|
);
|
|
34757
34979
|
settled.forEach((outcome, i) => {
|
|
34758
34980
|
if (outcome.status === "fulfilled") named.push({ image: outcome.value, base: sceneName(i) });
|
|
@@ -34763,6 +34985,9 @@ function registerSetTools(server, client2) {
|
|
|
34763
34985
|
});
|
|
34764
34986
|
const masterText = masterResult.text;
|
|
34765
34987
|
const meta3 = buildMeta(model, seed, { ...args, aspect_ratio: aspectRatio });
|
|
34988
|
+
const usage = sumUsage(usages);
|
|
34989
|
+
if (usage) meta3.usage = usage;
|
|
34990
|
+
attachCost(meta3, model, usage);
|
|
34766
34991
|
if (masterText) meta3.text = masterText;
|
|
34767
34992
|
if (masterResult.grounding) meta3.grounding = masterResult.grounding;
|
|
34768
34993
|
if (report) meta3.image_inputs = report;
|
|
@@ -35109,9 +35334,56 @@ var SessionState = class {
|
|
|
35109
35334
|
}
|
|
35110
35335
|
return hit;
|
|
35111
35336
|
}
|
|
35337
|
+
/**
|
|
35338
|
+
* Everything this session has spent, in tokens, across every upstream call.
|
|
35339
|
+
*
|
|
35340
|
+
* Accumulated in the CLIENT rather than in the tools, so a call that was
|
|
35341
|
+
* billed and then failed (a safety filter returning no image, say) still
|
|
35342
|
+
* counts — it cost money regardless of whether a tool could use the result.
|
|
35343
|
+
* Per session, never module scope: one hosted process serves many tenants,
|
|
35344
|
+
* and a shared total would report other people's spend as yours.
|
|
35345
|
+
*/
|
|
35346
|
+
usageTotal;
|
|
35347
|
+
/** How many upstream calls contributed to {@link usageTotal}. */
|
|
35348
|
+
billedCalls = 0;
|
|
35349
|
+
/**
|
|
35350
|
+
* What this session has spent in USD, priced per call.
|
|
35351
|
+
*
|
|
35352
|
+
* Accumulated as calls happen rather than derived from {@link usageTotal} at
|
|
35353
|
+
* the end, because the total spans models: summing a Pro call's tokens with
|
|
35354
|
+
* a Lite call's and pricing the result at one rate would be wrong by up to
|
|
35355
|
+
* 4x. Each call is priced against ITS model and only the money is added up.
|
|
35356
|
+
*
|
|
35357
|
+
* `undefined` while nothing priceable has run — a model absent from the rate
|
|
35358
|
+
* card contributes usage but no cost, so a total of `undefined` beside a
|
|
35359
|
+
* non-zero usage is meaningful rather than a bug. (Video and music DO price:
|
|
35360
|
+
* omni is token-billed and Lyria bills per song. It is an unknown or
|
|
35361
|
+
* newly-released model id that goes unpriced.)
|
|
35362
|
+
*/
|
|
35363
|
+
costUsd;
|
|
35364
|
+
/** How many of {@link billedCalls} could actually be priced. */
|
|
35365
|
+
pricedCalls = 0;
|
|
35366
|
+
/**
|
|
35367
|
+
* Fold one upstream call's usage into the running totals. `model` is what
|
|
35368
|
+
* makes the cost side possible; without it the tokens still count.
|
|
35369
|
+
*/
|
|
35370
|
+
recordUsage(usage, model) {
|
|
35371
|
+
if (!usage) return;
|
|
35372
|
+
this.usageTotal = sumUsage([this.usageTotal, usage]);
|
|
35373
|
+
this.billedCalls += 1;
|
|
35374
|
+
if (!model) return;
|
|
35375
|
+
const cost = estimateCost(model, usage);
|
|
35376
|
+
if (!cost) return;
|
|
35377
|
+
this.costUsd = (this.costUsd ?? 0) + cost.usd;
|
|
35378
|
+
this.pricedCalls += 1;
|
|
35379
|
+
}
|
|
35112
35380
|
/** Forget everything. Tests use this; nothing in `src/` calls it. */
|
|
35113
35381
|
reset() {
|
|
35114
35382
|
this.jobs.reset();
|
|
35383
|
+
this.usageTotal = void 0;
|
|
35384
|
+
this.billedCalls = 0;
|
|
35385
|
+
this.costUsd = void 0;
|
|
35386
|
+
this.pricedCalls = 0;
|
|
35115
35387
|
this.lastInteractionId = void 0;
|
|
35116
35388
|
this.lastMusicInteractionId = void 0;
|
|
35117
35389
|
this.lastVideoInteractionId = void 0;
|
|
@@ -35532,6 +35804,16 @@ var GeminiClient = class {
|
|
|
35532
35804
|
const data = await this.call("GET", "/models?pageSize=200");
|
|
35533
35805
|
return filterImageModels(data.models ?? []);
|
|
35534
35806
|
}
|
|
35807
|
+
/**
|
|
35808
|
+
* Fold one call's usage into the session total and hand it straight back, so
|
|
35809
|
+
* a call site reads `const usage = this.recordUsage(readUsage(data), model)` right
|
|
35810
|
+
* where the response lands — before any "returned no media" throw, so a
|
|
35811
|
+
* billed call that a safety filter emptied is still counted.
|
|
35812
|
+
*/
|
|
35813
|
+
recordUsage(usage, model) {
|
|
35814
|
+
this.session.recordUsage(usage, model);
|
|
35815
|
+
return usage;
|
|
35816
|
+
}
|
|
35535
35817
|
async generate(opts) {
|
|
35536
35818
|
const model = resolveModel(opts.model, readEnvVar("GEMINI_IMAGE_MODEL"));
|
|
35537
35819
|
const parts = [{ text: opts.prompt }];
|
|
@@ -35556,6 +35838,7 @@ var GeminiClient = class {
|
|
|
35556
35838
|
requestBody,
|
|
35557
35839
|
resolveTimeoutMs(opts.timeoutMs, opts.imageSize)
|
|
35558
35840
|
);
|
|
35841
|
+
const usage = this.recordUsage(readUsage(data), model);
|
|
35559
35842
|
const images = [];
|
|
35560
35843
|
const textParts = [];
|
|
35561
35844
|
let groundingMeta;
|
|
@@ -35586,7 +35869,7 @@ var GeminiClient = class {
|
|
|
35586
35869
|
if (sources.length > 0) grounding.sources = sources;
|
|
35587
35870
|
}
|
|
35588
35871
|
}
|
|
35589
|
-
return { images, text, grounding };
|
|
35872
|
+
return { images, text, grounding, usage };
|
|
35590
35873
|
}
|
|
35591
35874
|
async interact(opts) {
|
|
35592
35875
|
const model = resolveModel(opts.model, readEnvVar("GEMINI_IMAGE_MODEL"));
|
|
@@ -35607,13 +35890,14 @@ var GeminiClient = class {
|
|
|
35607
35890
|
body.tools = [searchTool];
|
|
35608
35891
|
}
|
|
35609
35892
|
const data = await this.postInteraction(body, opts);
|
|
35893
|
+
const usage = this.recordUsage(readUsage(data), model);
|
|
35610
35894
|
const { images, text, grounding } = this.extractInteraction(data, opts.searchTypes);
|
|
35611
35895
|
if (images.length === 0) {
|
|
35612
35896
|
throw new McpToolError("Gemini returned no image", {
|
|
35613
35897
|
hint: "The request may have been blocked by safety filters \u2014 try rephrasing the prompt."
|
|
35614
35898
|
});
|
|
35615
35899
|
}
|
|
35616
|
-
return { id: data.id, images: await this.downloadAll(images), text, grounding };
|
|
35900
|
+
return { id: data.id, images: await this.downloadAll(images), text, grounding, usage };
|
|
35617
35901
|
}
|
|
35618
35902
|
/**
|
|
35619
35903
|
* Generate video via the omni model (Interactions API). Same plumbing as
|
|
@@ -35652,13 +35936,14 @@ var GeminiClient = class {
|
|
|
35652
35936
|
started = await this.startInteraction(body, opts, timeoutMs);
|
|
35653
35937
|
}
|
|
35654
35938
|
const data = await this.settleInteraction(started, body, timeoutMs);
|
|
35939
|
+
const usage = this.recordUsage(readUsage(data), model);
|
|
35655
35940
|
const { videos, text } = this.extractInteraction(data);
|
|
35656
35941
|
if (videos.length === 0) {
|
|
35657
35942
|
throw new McpToolError("Gemini returned no video", {
|
|
35658
35943
|
hint: "The request may have been blocked by a safety filter \u2014 try a shorter/simpler prompt or a different aspect ratio."
|
|
35659
35944
|
});
|
|
35660
35945
|
}
|
|
35661
|
-
return { id: data.id, videos: await this.downloadAll(videos), text };
|
|
35946
|
+
return { id: data.id, videos: await this.downloadAll(videos), text, usage };
|
|
35662
35947
|
}
|
|
35663
35948
|
/**
|
|
35664
35949
|
* Generate music via a Lyria model (Interactions API, inline audio). Reuses
|
|
@@ -35674,13 +35959,14 @@ var GeminiClient = class {
|
|
|
35674
35959
|
if (opts.previousInteractionId !== void 0) body.previous_interaction_id = opts.previousInteractionId;
|
|
35675
35960
|
if (opts.background === true) body.background = true;
|
|
35676
35961
|
const data = await this.postInteraction(body, opts);
|
|
35962
|
+
const usage = this.recordUsage(readUsage(data), model);
|
|
35677
35963
|
const { audios, text } = this.extractInteraction(data);
|
|
35678
35964
|
if (audios.length === 0) {
|
|
35679
35965
|
throw new McpToolError("Gemini returned no audio", {
|
|
35680
35966
|
hint: "The request may have been blocked by a safety filter \u2014 try rephrasing the prompt."
|
|
35681
35967
|
});
|
|
35682
35968
|
}
|
|
35683
|
-
return { id: data.id, audios: await this.downloadAll(audios), text };
|
|
35969
|
+
return { id: data.id, audios: await this.downloadAll(audios), text, usage };
|
|
35684
35970
|
}
|
|
35685
35971
|
/**
|
|
35686
35972
|
* POST `/interactions`, wait out the store lag on a chained 404, and — when
|
|
@@ -36203,6 +36489,8 @@ function registerInteractTools(server, client2) {
|
|
|
36203
36489
|
if (continuedFromSidecar) meta3.continued_from_sidecar = true;
|
|
36204
36490
|
if (dropped.length) meta3.dropped_previous_output = dropped;
|
|
36205
36491
|
meta3.hint = `To refine this image, call gemini_interact again with previous_interaction_id: "${r.id}" (or continue_last: true) \u2014 do NOT re-attach the returned image as an input; the interaction already contains it.`;
|
|
36492
|
+
if (r.usage) meta3.usage = r.usage;
|
|
36493
|
+
attachCost(meta3, model, r.usage);
|
|
36206
36494
|
if (r.text) meta3.text = r.text;
|
|
36207
36495
|
if (r.grounding) meta3.grounding = r.grounding;
|
|
36208
36496
|
if (report) meta3.image_inputs = report;
|
|
@@ -36311,6 +36599,8 @@ function registerVideoTools(server, client2) {
|
|
|
36311
36599
|
client2.session.lastVideoInteractionId = r.id;
|
|
36312
36600
|
const meta3 = reportShape({ model, interaction_id: r.id }, { ...args, aspect_ratio: aspectRatio });
|
|
36313
36601
|
if (previousInteractionId) meta3.previous_interaction_id = previousInteractionId;
|
|
36602
|
+
if (r.usage) meta3.usage = r.usage;
|
|
36603
|
+
attachCost(meta3, model, r.usage);
|
|
36314
36604
|
if (r.text) meta3.text = r.text;
|
|
36315
36605
|
if (report) meta3.image_inputs = report;
|
|
36316
36606
|
meta3.hint = `To edit this video, call gemini_video_generate again with task: "edit" and previous_interaction_id: "${r.id}" (or continue_last: true).`;
|
|
@@ -36408,6 +36698,8 @@ function registerMusicTools(server, client2) {
|
|
|
36408
36698
|
client2.session.lastMusicInteractionId = r.id;
|
|
36409
36699
|
const meta3 = { model, interaction_id: r.id };
|
|
36410
36700
|
if (previousInteractionId) meta3.previous_interaction_id = previousInteractionId;
|
|
36701
|
+
if (r.usage) meta3.usage = r.usage;
|
|
36702
|
+
attachCost(meta3, model, r.usage);
|
|
36411
36703
|
if (r.text) meta3.text = r.text;
|
|
36412
36704
|
if (report) meta3.image_inputs = report;
|
|
36413
36705
|
meta3.hint = `To continue this track, call gemini_music_generate again with previous_interaction_id: "${r.id}" (or continue_last: true).`;
|
|
@@ -36503,6 +36795,45 @@ function errorMessage(err) {
|
|
|
36503
36795
|
return err instanceof Error ? err.message : String(err);
|
|
36504
36796
|
}
|
|
36505
36797
|
|
|
36798
|
+
// src/tools/usage.ts
|
|
36799
|
+
init_zod();
|
|
36800
|
+
init_dist();
|
|
36801
|
+
function registerUsageTools(server, client2) {
|
|
36802
|
+
server.registerTool(
|
|
36803
|
+
"gemini_token_usage",
|
|
36804
|
+
{
|
|
36805
|
+
description: "Token usage for this session so far \u2014 what every generation has cost in tokens, added up. Call it before and after a workflow and subtract to get that workflow's cost; call it after a single generation for that call's. Reports tokens AND an estimated USD cost, priced per call against each call's own model and stamped with the date its rates were read (override with GEMINI_RATE_CARD). Note there is no account-balance endpoint to query \u2014 Google Cloud is post-paid and its billing data lags by hours \u2014 so this is the accurate way to attribute spend to a call.",
|
|
36806
|
+
annotations: toolAnnotations({ readOnly: true }),
|
|
36807
|
+
inputSchema: {
|
|
36808
|
+
reset: external_exports.boolean().optional().describe("Zero the running total after reporting it, so the next call measures from here. Use it to bracket a workflow without arithmetic.")
|
|
36809
|
+
}
|
|
36810
|
+
},
|
|
36811
|
+
async (args) => {
|
|
36812
|
+
const usage = client2.session.usageTotal;
|
|
36813
|
+
const calls = client2.session.billedCalls;
|
|
36814
|
+
const costUsd = client2.session.costUsd;
|
|
36815
|
+
const priced = client2.session.pricedCalls;
|
|
36816
|
+
if (args.reset) {
|
|
36817
|
+
client2.session.usageTotal = void 0;
|
|
36818
|
+
client2.session.billedCalls = 0;
|
|
36819
|
+
client2.session.costUsd = void 0;
|
|
36820
|
+
client2.session.pricedCalls = 0;
|
|
36821
|
+
}
|
|
36822
|
+
return textResult({
|
|
36823
|
+
usage: usage ?? null,
|
|
36824
|
+
billed_calls: calls,
|
|
36825
|
+
estimated_cost_usd: costUsd ?? null,
|
|
36826
|
+
priced_calls: priced,
|
|
36827
|
+
rates_published: PRICED_AT,
|
|
36828
|
+
...args.reset ? { reset: true } : {},
|
|
36829
|
+
note: usage === void 0 ? "No generation has run in this session yet, so nothing has been billed through it." : priced < calls ? `Estimate covers ${priced} of ${calls} billed calls \u2014 the rest used a model that is not in the rate card. Add it with GEMINI_RATE_CARD to include it.` : "Estimate priced per call against each call's own model, then added up.",
|
|
36830
|
+
rates: `Published rates read on ${PRICED_AT} (paid tier, USD). Override or extend with GEMINI_RATE_CARD if they have moved \u2014 an estimate is only as current as its card.`,
|
|
36831
|
+
scope: "This session only. A restart starts from zero, and a hosted deployment counts each authenticated session separately \u2014 these are your tokens, not the account's."
|
|
36832
|
+
});
|
|
36833
|
+
}
|
|
36834
|
+
);
|
|
36835
|
+
}
|
|
36836
|
+
|
|
36506
36837
|
// src/tools/files.ts
|
|
36507
36838
|
init_zod();
|
|
36508
36839
|
init_dist();
|
|
@@ -36953,6 +37284,7 @@ var TOOL_REGISTRARS = [
|
|
|
36953
37284
|
registerVideoTools,
|
|
36954
37285
|
registerMusicTools,
|
|
36955
37286
|
registerJobTools,
|
|
37287
|
+
registerUsageTools,
|
|
36956
37288
|
registerFileTools,
|
|
36957
37289
|
registerUploadUrlTools,
|
|
36958
37290
|
registerLibraryTools
|
package/dist/client.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { basename } from 'node:path';
|
|
2
2
|
import { readEnvVar, McpToolError, ApiError, createApiClient, formatApiError, fileBlob } from '@chrischall/mcp-utils';
|
|
3
3
|
import { resolveModel, filterImageModels, DEFAULT_VIDEO_MODEL, DEFAULT_MUSIC_MODEL } from './models.js';
|
|
4
|
+
import { readUsage } from './usage.js';
|
|
4
5
|
import { createDiskSink, createR2Sink, tenantIdFor } from './storage/media.js';
|
|
5
6
|
import { createR2Library } from './library.js';
|
|
6
7
|
import { createUploadUrlMinter } from './upload-url.js';
|
|
@@ -560,6 +561,16 @@ export class GeminiClient {
|
|
|
560
561
|
const data = await this.call('GET', '/models?pageSize=200');
|
|
561
562
|
return filterImageModels(data.models ?? []);
|
|
562
563
|
}
|
|
564
|
+
/**
|
|
565
|
+
* Fold one call's usage into the session total and hand it straight back, so
|
|
566
|
+
* a call site reads `const usage = this.recordUsage(readUsage(data), model)` right
|
|
567
|
+
* where the response lands — before any "returned no media" throw, so a
|
|
568
|
+
* billed call that a safety filter emptied is still counted.
|
|
569
|
+
*/
|
|
570
|
+
recordUsage(usage, model) {
|
|
571
|
+
this.session.recordUsage(usage, model);
|
|
572
|
+
return usage;
|
|
573
|
+
}
|
|
563
574
|
async generate(opts) {
|
|
564
575
|
const model = resolveModel(opts.model, readEnvVar('GEMINI_IMAGE_MODEL'));
|
|
565
576
|
const parts = [{ text: opts.prompt }];
|
|
@@ -585,6 +596,11 @@ export class GeminiClient {
|
|
|
585
596
|
if (opts.googleSearch)
|
|
586
597
|
requestBody.tools = [{ google_search: {} }];
|
|
587
598
|
const data = await this.call('POST', `/models/${model}:generateContent`, requestBody, resolveTimeoutMs(opts.timeoutMs, opts.imageSize));
|
|
599
|
+
// Recorded HERE, not at the return: the call is already billed by the
|
|
600
|
+
// time the response lands, and the "no image" throw below is a real
|
|
601
|
+
// outcome of a PAID request (a safety filter). Recording at the return
|
|
602
|
+
// omitted exactly the calls this total exists to catch.
|
|
603
|
+
const usage = this.recordUsage(readUsage(data), model);
|
|
588
604
|
const images = [];
|
|
589
605
|
const textParts = [];
|
|
590
606
|
let groundingMeta;
|
|
@@ -620,7 +636,7 @@ export class GeminiClient {
|
|
|
620
636
|
grounding.sources = sources;
|
|
621
637
|
}
|
|
622
638
|
}
|
|
623
|
-
return { images, text, grounding };
|
|
639
|
+
return { images, text, grounding, usage };
|
|
624
640
|
}
|
|
625
641
|
async interact(opts) {
|
|
626
642
|
const model = resolveModel(opts.model, readEnvVar('GEMINI_IMAGE_MODEL'));
|
|
@@ -647,13 +663,14 @@ export class GeminiClient {
|
|
|
647
663
|
body.tools = [searchTool];
|
|
648
664
|
}
|
|
649
665
|
const data = await this.postInteraction(body, opts);
|
|
666
|
+
const usage = this.recordUsage(readUsage(data), model);
|
|
650
667
|
const { images, text, grounding } = this.extractInteraction(data, opts.searchTypes);
|
|
651
668
|
if (images.length === 0) {
|
|
652
669
|
throw new McpToolError('Gemini returned no image', {
|
|
653
670
|
hint: 'The request may have been blocked by safety filters — try rephrasing the prompt.',
|
|
654
671
|
});
|
|
655
672
|
}
|
|
656
|
-
return { id: data.id, images: await this.downloadAll(images), text, grounding };
|
|
673
|
+
return { id: data.id, images: await this.downloadAll(images), text, grounding, usage };
|
|
657
674
|
}
|
|
658
675
|
/**
|
|
659
676
|
* Generate video via the omni model (Interactions API). Same plumbing as
|
|
@@ -709,13 +726,14 @@ export class GeminiClient {
|
|
|
709
726
|
started = await this.startInteraction(body, opts, timeoutMs);
|
|
710
727
|
}
|
|
711
728
|
const data = await this.settleInteraction(started, body, timeoutMs);
|
|
729
|
+
const usage = this.recordUsage(readUsage(data), model);
|
|
712
730
|
const { videos, text } = this.extractInteraction(data);
|
|
713
731
|
if (videos.length === 0) {
|
|
714
732
|
throw new McpToolError('Gemini returned no video', {
|
|
715
733
|
hint: 'The request may have been blocked by a safety filter — try a shorter/simpler prompt or a different aspect ratio.',
|
|
716
734
|
});
|
|
717
735
|
}
|
|
718
|
-
return { id: data.id, videos: await this.downloadAll(videos), text };
|
|
736
|
+
return { id: data.id, videos: await this.downloadAll(videos), text, usage };
|
|
719
737
|
}
|
|
720
738
|
/**
|
|
721
739
|
* Generate music via a Lyria model (Interactions API, inline audio). Reuses
|
|
@@ -736,13 +754,14 @@ export class GeminiClient {
|
|
|
736
754
|
if (opts.background === true)
|
|
737
755
|
body.background = true;
|
|
738
756
|
const data = await this.postInteraction(body, opts);
|
|
757
|
+
const usage = this.recordUsage(readUsage(data), model);
|
|
739
758
|
const { audios, text } = this.extractInteraction(data);
|
|
740
759
|
if (audios.length === 0) {
|
|
741
760
|
throw new McpToolError('Gemini returned no audio', {
|
|
742
761
|
hint: 'The request may have been blocked by a safety filter — try rephrasing the prompt.',
|
|
743
762
|
});
|
|
744
763
|
}
|
|
745
|
-
return { id: data.id, audios: await this.downloadAll(audios), text };
|
|
764
|
+
return { id: data.id, audios: await this.downloadAll(audios), text, usage };
|
|
746
765
|
}
|
|
747
766
|
/**
|
|
748
767
|
* POST `/interactions`, wait out the store lag on a chained 404, and — when
|