@chrischall/gemini-mcp 1.9.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.
@@ -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.9.0"
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.9.0",
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.9.0",
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.9.0";
32879
+ var VERSION = "1.11.0";
32872
32880
 
32873
32881
  // src/errors.ts
32874
32882
  init_dist();
@@ -33813,10 +33821,13 @@ function timeoutRiskHint(opts) {
33813
33821
  return lead + `If the host gives up mid-generation the image is still written to disk (and gemini_interact leaves an <image>.json sidecar with the interaction id) \u2014 no rerun needed, just look in the output dir. For a faster in-band result use a Flash model, drop image_size to 1K/2K, or \u2014 in Claude Code \u2014 set MCP_TOOL_TIMEOUT.`;
33814
33822
  }
33815
33823
  function buildMeta(model, seed, opts) {
33816
- const meta3 = { model, seed };
33824
+ const meta3 = reportShape({ model, seed }, opts);
33825
+ if (opts.image_size) meta3.image_size = opts.image_size;
33826
+ return meta3;
33827
+ }
33828
+ function reportShape(meta3, opts) {
33817
33829
  if (opts.aspect_ratio) meta3.aspect_ratio = opts.aspect_ratio;
33818
33830
  if (opts.orientation) meta3.orientation = opts.orientation;
33819
- if (opts.image_size) meta3.image_size = opts.image_size;
33820
33831
  return meta3;
33821
33832
  }
33822
33833
  async function emitMedia(named, kind, opts, meta3, onWritten) {
@@ -33969,6 +33980,98 @@ function createBlobJobStore(bucket, opts) {
33969
33980
  };
33970
33981
  }
33971
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
+
33972
34075
  // src/jobs.ts
33973
34076
  var JOB_TTL_MS = 6e5;
33974
34077
  var DURABLE_JOB_REUSE_MS = 24 * 60 * 60 * 1e3;
@@ -34049,6 +34152,7 @@ function annotateReused(result, jobId) {
34049
34152
  }
34050
34153
  obj.reused = true;
34051
34154
  obj.reused_job_id = jobId;
34155
+ annotateReusedUsage(obj);
34052
34156
  return { ...result, content: [{ ...first, text: JSON.stringify(obj, null, 2) }, ...content.slice(1)] };
34053
34157
  }
34054
34158
  return {
@@ -34434,6 +34538,113 @@ var JobRegistry = class {
34434
34538
  };
34435
34539
  var JOB_RECORD_RETENTION_LABEL = "30 days";
34436
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
+
34437
34648
  // src/tools/_confirm.ts
34438
34649
  init_dist();
34439
34650
  init_images();
@@ -34523,6 +34734,7 @@ function registerGenerateTools(server, client2) {
34523
34734
  let capturedGrounding;
34524
34735
  const failed = [];
34525
34736
  let firstError;
34737
+ const usages = [];
34526
34738
  await withProgressHeartbeat(extra, `Generating ${count > 1 ? `${count} images` : "image"} (${model})`, async () => {
34527
34739
  for (let i = 0; i < count; i++) {
34528
34740
  try {
@@ -34540,6 +34752,7 @@ function registerGenerateTools(server, client2) {
34540
34752
  timeoutMs: args.timeout_ms
34541
34753
  });
34542
34754
  const { images: [img], text } = result;
34755
+ usages.push(result.usage);
34543
34756
  if (text && !capturedText) capturedText = text;
34544
34757
  if (result.grounding && !capturedGrounding) capturedGrounding = result.grounding;
34545
34758
  named.push({ image: img, base: count === 1 ? slug : `${slug}-${String(i + 1).padStart(2, "0")}` });
@@ -34551,6 +34764,9 @@ function registerGenerateTools(server, client2) {
34551
34764
  });
34552
34765
  if (named.length === 0) throw firstError;
34553
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);
34554
34770
  if (capturedText) meta3.text = capturedText;
34555
34771
  if (capturedGrounding) meta3.grounding = capturedGrounding;
34556
34772
  if (report) meta3.image_inputs = report;
@@ -34629,6 +34845,8 @@ function registerGenerateTools(server, client2) {
34629
34845
  }));
34630
34846
  const { images: [img], text } = result;
34631
34847
  const meta3 = buildMeta(model, seed, { ...args, aspect_ratio: aspectRatio });
34848
+ if (result.usage) meta3.usage = result.usage;
34849
+ attachCost(meta3, model, result.usage);
34632
34850
  if (text) meta3.text = text;
34633
34851
  if (result.grounding) meta3.grounding = result.grounding;
34634
34852
  if (report) meta3.image_inputs = report;
@@ -34717,6 +34935,7 @@ function registerSetTools(server, client2) {
34717
34935
  const failed = [];
34718
34936
  const report = carried.report || masterOnly.report ? { ...masterOnly.report, ...carried.report } : void 0;
34719
34937
  const named = [];
34938
+ const usages = [];
34720
34939
  const masterResult = await withProgressHeartbeat(extra, `Generating image set (${model})`, async () => {
34721
34940
  const result = await client2.generate({
34722
34941
  prompt: masterPrompt,
@@ -34724,6 +34943,7 @@ function registerSetTools(server, client2) {
34724
34943
  seed,
34725
34944
  ...cfg
34726
34945
  });
34946
+ usages.push(result.usage);
34727
34947
  const master = result.images[0];
34728
34948
  named.push({ image: master, base: `${slug}-master` });
34729
34949
  const scenePrompts = args.scenes ?? Array.from({ length: args.count ?? 0 }, () => args.master_prompt);
@@ -34740,7 +34960,9 @@ function registerSetTools(server, client2) {
34740
34960
  let ref = master;
34741
34961
  for (let i = 0; i < scenePrompts.length; i++) {
34742
34962
  try {
34743
- const { images: [img] } = await client2.generate({ prompt: composePrompt(scenePrompts[i], styleOnly), images: [ref, ...carriedRefs], seed: seed + i + 1, ...cfg });
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];
34744
34966
  named.push({ image: img, base: sceneName(i) });
34745
34967
  ref = img;
34746
34968
  } catch (err) {
@@ -34749,7 +34971,10 @@ function registerSetTools(server, client2) {
34749
34971
  }
34750
34972
  } else {
34751
34973
  const settled = await Promise.allSettled(
34752
- scenePrompts.map((p, i) => client2.generate({ prompt: composePrompt(p, styleOnly), images: [master, ...carriedRefs], seed: seed + i + 1, ...cfg }).then((r) => r.images[0]))
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
+ }))
34753
34978
  );
34754
34979
  settled.forEach((outcome, i) => {
34755
34980
  if (outcome.status === "fulfilled") named.push({ image: outcome.value, base: sceneName(i) });
@@ -34760,6 +34985,9 @@ function registerSetTools(server, client2) {
34760
34985
  });
34761
34986
  const masterText = masterResult.text;
34762
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);
34763
34991
  if (masterText) meta3.text = masterText;
34764
34992
  if (masterResult.grounding) meta3.grounding = masterResult.grounding;
34765
34993
  if (report) meta3.image_inputs = report;
@@ -35106,9 +35334,56 @@ var SessionState = class {
35106
35334
  }
35107
35335
  return hit;
35108
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
+ }
35109
35380
  /** Forget everything. Tests use this; nothing in `src/` calls it. */
35110
35381
  reset() {
35111
35382
  this.jobs.reset();
35383
+ this.usageTotal = void 0;
35384
+ this.billedCalls = 0;
35385
+ this.costUsd = void 0;
35386
+ this.pricedCalls = 0;
35112
35387
  this.lastInteractionId = void 0;
35113
35388
  this.lastMusicInteractionId = void 0;
35114
35389
  this.lastVideoInteractionId = void 0;
@@ -35529,6 +35804,16 @@ var GeminiClient = class {
35529
35804
  const data = await this.call("GET", "/models?pageSize=200");
35530
35805
  return filterImageModels(data.models ?? []);
35531
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
+ }
35532
35817
  async generate(opts) {
35533
35818
  const model = resolveModel(opts.model, readEnvVar("GEMINI_IMAGE_MODEL"));
35534
35819
  const parts = [{ text: opts.prompt }];
@@ -35553,6 +35838,7 @@ var GeminiClient = class {
35553
35838
  requestBody,
35554
35839
  resolveTimeoutMs(opts.timeoutMs, opts.imageSize)
35555
35840
  );
35841
+ const usage = this.recordUsage(readUsage(data), model);
35556
35842
  const images = [];
35557
35843
  const textParts = [];
35558
35844
  let groundingMeta;
@@ -35583,7 +35869,7 @@ var GeminiClient = class {
35583
35869
  if (sources.length > 0) grounding.sources = sources;
35584
35870
  }
35585
35871
  }
35586
- return { images, text, grounding };
35872
+ return { images, text, grounding, usage };
35587
35873
  }
35588
35874
  async interact(opts) {
35589
35875
  const model = resolveModel(opts.model, readEnvVar("GEMINI_IMAGE_MODEL"));
@@ -35604,13 +35890,14 @@ var GeminiClient = class {
35604
35890
  body.tools = [searchTool];
35605
35891
  }
35606
35892
  const data = await this.postInteraction(body, opts);
35893
+ const usage = this.recordUsage(readUsage(data), model);
35607
35894
  const { images, text, grounding } = this.extractInteraction(data, opts.searchTypes);
35608
35895
  if (images.length === 0) {
35609
35896
  throw new McpToolError("Gemini returned no image", {
35610
35897
  hint: "The request may have been blocked by safety filters \u2014 try rephrasing the prompt."
35611
35898
  });
35612
35899
  }
35613
- return { id: data.id, images: await this.downloadAll(images), text, grounding };
35900
+ return { id: data.id, images: await this.downloadAll(images), text, grounding, usage };
35614
35901
  }
35615
35902
  /**
35616
35903
  * Generate video via the omni model (Interactions API). Same plumbing as
@@ -35649,13 +35936,14 @@ var GeminiClient = class {
35649
35936
  started = await this.startInteraction(body, opts, timeoutMs);
35650
35937
  }
35651
35938
  const data = await this.settleInteraction(started, body, timeoutMs);
35939
+ const usage = this.recordUsage(readUsage(data), model);
35652
35940
  const { videos, text } = this.extractInteraction(data);
35653
35941
  if (videos.length === 0) {
35654
35942
  throw new McpToolError("Gemini returned no video", {
35655
35943
  hint: "The request may have been blocked by a safety filter \u2014 try a shorter/simpler prompt or a different aspect ratio."
35656
35944
  });
35657
35945
  }
35658
- return { id: data.id, videos: await this.downloadAll(videos), text };
35946
+ return { id: data.id, videos: await this.downloadAll(videos), text, usage };
35659
35947
  }
35660
35948
  /**
35661
35949
  * Generate music via a Lyria model (Interactions API, inline audio). Reuses
@@ -35671,13 +35959,14 @@ var GeminiClient = class {
35671
35959
  if (opts.previousInteractionId !== void 0) body.previous_interaction_id = opts.previousInteractionId;
35672
35960
  if (opts.background === true) body.background = true;
35673
35961
  const data = await this.postInteraction(body, opts);
35962
+ const usage = this.recordUsage(readUsage(data), model);
35674
35963
  const { audios, text } = this.extractInteraction(data);
35675
35964
  if (audios.length === 0) {
35676
35965
  throw new McpToolError("Gemini returned no audio", {
35677
35966
  hint: "The request may have been blocked by a safety filter \u2014 try rephrasing the prompt."
35678
35967
  });
35679
35968
  }
35680
- return { id: data.id, audios: await this.downloadAll(audios), text };
35969
+ return { id: data.id, audios: await this.downloadAll(audios), text, usage };
35681
35970
  }
35682
35971
  /**
35683
35972
  * POST `/interactions`, wait out the store lag on a chained 404, and — when
@@ -36194,12 +36483,14 @@ function registerInteractTools(server, client2) {
36194
36483
  }
36195
36484
  });
36196
36485
  client2.session.lastInteractionId = r.id;
36197
- const meta3 = { model, interaction_id: r.id };
36486
+ const meta3 = reportShape({ model, interaction_id: r.id }, { ...args, aspect_ratio: aspectRatio });
36198
36487
  if (chainRecovered) meta3.chain_recovered = chainRecovered;
36199
36488
  else if (previousInteractionId) meta3.previous_interaction_id = previousInteractionId;
36200
36489
  if (continuedFromSidecar) meta3.continued_from_sidecar = true;
36201
36490
  if (dropped.length) meta3.dropped_previous_output = dropped;
36202
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);
36203
36494
  if (r.text) meta3.text = r.text;
36204
36495
  if (r.grounding) meta3.grounding = r.grounding;
36205
36496
  if (report) meta3.image_inputs = report;
@@ -36306,8 +36597,10 @@ function registerVideoTools(server, client2) {
36306
36597
  onInteractionStarted: ctx.reportInteraction
36307
36598
  }));
36308
36599
  client2.session.lastVideoInteractionId = r.id;
36309
- const meta3 = { model, interaction_id: r.id };
36600
+ const meta3 = reportShape({ model, interaction_id: r.id }, { ...args, aspect_ratio: aspectRatio });
36310
36601
  if (previousInteractionId) meta3.previous_interaction_id = previousInteractionId;
36602
+ if (r.usage) meta3.usage = r.usage;
36603
+ attachCost(meta3, model, r.usage);
36311
36604
  if (r.text) meta3.text = r.text;
36312
36605
  if (report) meta3.image_inputs = report;
36313
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).`;
@@ -36405,6 +36698,8 @@ function registerMusicTools(server, client2) {
36405
36698
  client2.session.lastMusicInteractionId = r.id;
36406
36699
  const meta3 = { model, interaction_id: r.id };
36407
36700
  if (previousInteractionId) meta3.previous_interaction_id = previousInteractionId;
36701
+ if (r.usage) meta3.usage = r.usage;
36702
+ attachCost(meta3, model, r.usage);
36408
36703
  if (r.text) meta3.text = r.text;
36409
36704
  if (report) meta3.image_inputs = report;
36410
36705
  meta3.hint = `To continue this track, call gemini_music_generate again with previous_interaction_id: "${r.id}" (or continue_last: true).`;
@@ -36500,6 +36795,45 @@ function errorMessage(err) {
36500
36795
  return err instanceof Error ? err.message : String(err);
36501
36796
  }
36502
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
+
36503
36837
  // src/tools/files.ts
36504
36838
  init_zod();
36505
36839
  init_dist();
@@ -36950,6 +37284,7 @@ var TOOL_REGISTRARS = [
36950
37284
  registerVideoTools,
36951
37285
  registerMusicTools,
36952
37286
  registerJobTools,
37287
+ registerUsageTools,
36953
37288
  registerFileTools,
36954
37289
  registerUploadUrlTools,
36955
37290
  registerLibraryTools