@chrischall/gemini-mcp 1.10.0 → 1.11.1

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.10.0"
10
+ "version": "1.11.1"
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.10.0",
18
+ "version": "1.11.1",
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.10.0",
4
+ "version": "1.11.1",
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.10.0";
32879
+ var VERSION = "1.11.1";
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 { 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];
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) => 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
+ }))
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