@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.
package/dist/jobs.js CHANGED
@@ -2,6 +2,7 @@ import { randomUUID, createHash } from 'node:crypto';
2
2
  import { textResult, McpToolError } from '@chrischall/mcp-utils';
3
3
  import { downloadFilename } from './media-name.js';
4
4
  import { HEARTBEAT_MS } from './job-store.js';
5
+ import { annotateReusedUsage } from './usage.js';
5
6
  /** Recently-completed jobs are reusable by key for this long, then evicted. */
6
7
  const JOB_TTL_MS = 600_000; // 10 min
7
8
  /**
@@ -137,6 +138,9 @@ function annotateReused(result, jobId) {
137
138
  }
138
139
  obj.reused = true;
139
140
  obj.reused_job_id = jobId;
141
+ // The recorded usage describes the generation being handed back, but this
142
+ // call did not pay for it — mark it so a workflow total can exclude it.
143
+ annotateReusedUsage(obj);
140
144
  return { ...result, content: [{ ...first, text: JSON.stringify(obj, null, 2) }, ...content.slice(1)] };
141
145
  }
142
146
  // No text meta block (e.g. inline mode with empty meta) — prepend one.
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Turning tokens into money.
3
+ *
4
+ * This is possible — and only just — because image output is billed per token
5
+ * like everything else, at a rate 20-40x the text-output rate. Google's
6
+ * published per-image prices ARE that arithmetic: 1120 image tokens at
7
+ * $30/1M is the $0.0336 quoted for a 1K flash-lite image, and 2000 at $120/1M
8
+ * is the $0.24 quoted for a 4K pro image. `tests/pricing.test.ts` reconciles
9
+ * every rate for which Google publishes a cross-check — the per-image prices,
10
+ * and omni's per-second video figure — so a typo in those fails as an equality
11
+ * rather than becoming a plausible wrong number.
12
+ *
13
+ * The rates with no published cross-check (an input rate, a text-output rate)
14
+ * are only as good as the transcription, which is why they are quoted verbatim
15
+ * in the entries below and dated. That gap is not academic: omni's entry
16
+ * originally carried an input and text rate a third of the real ones, and an
17
+ * image-output rate invented by copying another model, precisely because only
18
+ * the figure with a cross-check had been looked up.
19
+ *
20
+ * The consequence for the caller: output tokens MUST be split into image and
21
+ * text before pricing. Billing an image model's text tokens at the image rate
22
+ * overstates them twentyfold.
23
+ *
24
+ * ## Why a shipped default, and why it is dated
25
+ *
26
+ * A hardcoded rate card goes stale silently, and a confident wrong cost is
27
+ * worse than no cost because people act on it. Two things keep that honest
28
+ * here: every estimate carries {@link PRICED_AT}, the date these rates were
29
+ * read from Google's pricing page, so a caller can see the card's age; and
30
+ * `GEMINI_RATE_CARD` overrides or extends it without editing the package, so
31
+ * a stale number is the operator's to fix in their own environment rather
32
+ * than something they must wait on a release for.
33
+ *
34
+ * Everything not in the card prices as `undefined` rather than zero.
35
+ *
36
+ * Video and music ARE in the card, contrary to the first version of this
37
+ * module. `gemini-omni-flash` — the model this server's video tool uses — is
38
+ * token-billed at $17.50/1M (~5,792 tokens per second of 720p, which is the
39
+ * ~$0.10/second Google quotes); it is Veo that bills per second, and this
40
+ * server does not use Veo. Lyria genuinely is not token-billed: it charges per
41
+ * song, which {@link ModelRates.per_generation} exists for.
42
+ *
43
+ * These are the STANDARD paid-tier rates. Google also publishes a Batch tier
44
+ * at roughly half price, which this server cannot reach — every call it makes
45
+ * is synchronous `generateContent` or `interactions`, never the Batch API — so
46
+ * an estimate here is the standard price and is not silently optimistic.
47
+ *
48
+ * Source: https://ai.google.dev/gemini-api/docs/pricing (paid tier, USD).
49
+ */
50
+ import { readEnvVar } from '@chrischall/mcp-utils';
51
+ /** The date the shipped rates were read from Google's pricing page. */
52
+ export const PRICED_AT = '2026-08-28';
53
+ /**
54
+ * Paid-tier rates, USD per 1M tokens. Image generation has no free tier.
55
+ * `-preview` ids are priced as their GA twin (see {@link normalizeModel}).
56
+ */
57
+ export const RATE_CARD = Object.freeze({
58
+ 'gemini-3-pro-image': { input: 2.0, text_output: 12.0, image_output: 120.0 },
59
+ 'gemini-3.1-flash-image': { input: 0.5, text_output: 3.0, image_output: 60.0 },
60
+ 'gemini-3.1-flash-lite-image': { input: 0.25, text_output: 1.5, image_output: 30.0 },
61
+ // Google publishes 2.5-flash-image's output as $0.039 PER IMAGE at 1290
62
+ // tokens, not as a per-1M rate. 0.039 / 1290 * 1e6 = 30.23 — a back-derived
63
+ // figure, unlike the four above which are published per-1M directly. The
64
+ // test reconciles it against the $0.039 it came from.
65
+ 'gemini-2.5-flash-image': { input: 0.3, text_output: 3.0, image_output: 30.2326 },
66
+ // Omni is token-billed for video: $17.50/1M at ~5,792 tokens per second of
67
+ // 720p, which is the ~$0.10/second Google quotes. Veo is the per-second
68
+ // family; this server does not use it.
69
+ //
70
+ // No image-output price is published for omni, so there is no image_output
71
+ // here. An absent rate prices that modality at zero rather than inventing
72
+ // one — and the first version of this entry DID invent one ($60, copied
73
+ // from flash-image), along with a $0.50 input and $3.00 text rate that were
74
+ // both a third of the real figures. Every number here is now quoted.
75
+ 'gemini-omni-flash': { input: 1.5, text_output: 9.0, video_output: 17.5 },
76
+ // Lyria bills per song, so tokens describe the work and not the bill.
77
+ 'lyria-3-clip': { input: 0, text_output: 0, per_generation: 0.04 },
78
+ 'lyria-3-pro': { input: 0, text_output: 0, per_generation: 0.08 },
79
+ });
80
+ /** `foo-preview` prices as `foo` — the fleet uses both spellings. */
81
+ function normalizeModel(model) {
82
+ return model.replace(/-preview$/, '');
83
+ }
84
+ /**
85
+ * The effective rate card: the shipped one, with `GEMINI_RATE_CARD` merged
86
+ * over it. A malformed override is ignored rather than thrown — the cost
87
+ * figure is a convenience and the generation is the job, so a bad rate card
88
+ * must not fail every call.
89
+ */
90
+ export function loadRateCard(env = process.env) {
91
+ const raw = readEnvVar('GEMINI_RATE_CARD', { env });
92
+ if (!raw)
93
+ return { ...RATE_CARD };
94
+ let parsed;
95
+ try {
96
+ parsed = JSON.parse(raw);
97
+ }
98
+ catch {
99
+ return { ...RATE_CARD };
100
+ }
101
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
102
+ return { ...RATE_CARD };
103
+ // Merge PER FIELD, not per model. A shallow per-model merge let a partial
104
+ // override — `{"gemini-3-pro-image": {"image_output": 100}}`, the obvious
105
+ // thing to write when one rate moves — drop `input` and `text_output`
106
+ // entirely, and an undefined rate multiplied by a token count is NaN. That
107
+ // NaN then flowed into the session accumulator, where it is absorbing: one
108
+ // bad call and every later total reads NaN for the rest of the session.
109
+ const out = { ...RATE_CARD };
110
+ for (const [model, override] of Object.entries(parsed)) {
111
+ if (!override || typeof override !== 'object' || Array.isArray(override))
112
+ continue;
113
+ // Store under the SAME key `estimateCost` looks up. Writing the raw key
114
+ // meant an override for `lyria-3-clip-preview` — the id a caller actually
115
+ // holds, since the video and music models are preview-only — landed under
116
+ // that key while lookup normalized to `lyria-3-clip`, so it was silently
117
+ // ignored: the operator's rate was accepted, stored, and never used.
118
+ const key = normalizeModel(model);
119
+ const merged = { ...(RATE_CARD[key] ?? {}), ...(out[key] ?? {}) };
120
+ for (const [field, value] of Object.entries(override)) {
121
+ // Only finite numbers get in. A string, null or NaN in the override is
122
+ // dropped rather than propagated into arithmetic.
123
+ if (typeof value === 'number' && Number.isFinite(value))
124
+ merged[field] = value;
125
+ }
126
+ // A model still lacking the two mandatory rates cannot be priced at all,
127
+ // and half a rate card is worse than none.
128
+ if (typeof merged.input === 'number' && typeof merged.text_output === 'number') {
129
+ out[key] = merged;
130
+ }
131
+ }
132
+ return out;
133
+ }
134
+ /**
135
+ * Estimate what a call cost, or `undefined` when there is no rate for the
136
+ * model — never a zero, which would read as "this was free".
137
+ */
138
+ export function estimateCost(model, usage, env = process.env) {
139
+ const card = loadRateCard(env);
140
+ const key = normalizeModel(model);
141
+ const rates = card[key];
142
+ if (!rates)
143
+ return undefined;
144
+ const per = (tokens, rate) =>
145
+ // An absent rate prices as zero, never NaN. A modality with no published
146
+ // rate is not evidence of spend, and NaN is absorbing — it would poison
147
+ // every later total in the session.
148
+ typeof rate === 'number' && Number.isFinite(rate) ? (tokens * rate) / 1_000_000 : 0;
149
+ // Billed per call, not per token: the tokens describe the work and say
150
+ // nothing about the bill, so pricing them would double-count.
151
+ if (typeof rates.per_generation === 'number') {
152
+ return {
153
+ usd: rates.per_generation,
154
+ breakdown: {
155
+ input_usd: 0, text_output_usd: 0, image_usd: 0, video_usd: 0, audio_usd: 0,
156
+ per_generation_usd: rates.per_generation,
157
+ },
158
+ priced_at: PRICED_AT,
159
+ ...(overriddenFor(key, rates) ? { overridden: true } : {}),
160
+ };
161
+ }
162
+ const imageTokens = usage.image_tokens ?? 0;
163
+ const videoTokens = usage.video_tokens ?? 0;
164
+ const audioTokens = usage.audio_tokens ?? 0;
165
+ // Whatever the API attributed to no modality is text/thinking output — the
166
+ // cheap end, so an unrecognised modality understates rather than inflates.
167
+ const textTokens = Math.max(0, usage.output_tokens - imageTokens - videoTokens - audioTokens);
168
+ const input_usd = per(usage.input_tokens, rates.input);
169
+ const text_output_usd = per(textTokens, rates.text_output);
170
+ const image_usd = per(imageTokens, rates.image_output);
171
+ const video_usd = per(videoTokens, rates.video_output);
172
+ const audio_usd = per(audioTokens, rates.audio_output);
173
+ return {
174
+ usd: input_usd + text_output_usd + image_usd + video_usd + audio_usd,
175
+ breakdown: { input_usd, text_output_usd, image_usd, video_usd, audio_usd },
176
+ priced_at: PRICED_AT,
177
+ ...(overriddenFor(key, rates) ? { overridden: true } : {}),
178
+ };
179
+ }
180
+ /** Did any rate for this model come from the operator rather than the shipped card? */
181
+ function overriddenFor(key, rates) {
182
+ const shipped = RATE_CARD[key];
183
+ if (!shipped)
184
+ return true;
185
+ return Object.keys(rates).some((f) => rates[f] !== shipped[f]);
186
+ }
187
+ /**
188
+ * Attach a cost estimate to a result's meta, when the model has a rate.
189
+ *
190
+ * Silent when it does not — a model absent from the rate card. An absent
191
+ * `cost_estimate` beside a present `usage` means "no rate for this model",
192
+ * which is the honest answer rather than a zero.
193
+ */
194
+ export function attachCost(meta, model, usage) {
195
+ if (!usage)
196
+ return meta;
197
+ const cost = estimateCost(model, usage);
198
+ if (cost)
199
+ meta.cost_estimate = cost;
200
+ return meta;
201
+ }
@@ -17,6 +17,7 @@ import { registerInteractTools } from './tools/interact.js';
17
17
  import { registerVideoTools } from './tools/video.js';
18
18
  import { registerMusicTools } from './tools/music.js';
19
19
  import { registerJobTools } from './tools/jobs.js';
20
+ import { registerUsageTools } from './tools/usage.js';
20
21
  import { registerFileTools } from './tools/files.js';
21
22
  import { registerUploadUrlTools } from './tools/uploads.js';
22
23
  import { registerLibraryTools } from './tools/library.js';
@@ -44,6 +45,7 @@ export const TOOL_REGISTRARS = [
44
45
  registerVideoTools,
45
46
  registerMusicTools,
46
47
  registerJobTools,
48
+ registerUsageTools,
47
49
  registerFileTools,
48
50
  registerUploadUrlTools,
49
51
  registerLibraryTools,
package/dist/session.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { JobRegistry } from './jobs.js';
2
+ import { sumUsage } from './usage.js';
3
+ import { estimateCost } from './pricing.js';
2
4
  /**
3
5
  * Everything the server remembers *between tool calls on behalf of one user*.
4
6
  *
@@ -82,9 +84,59 @@ export class SessionState {
82
84
  }
83
85
  return hit;
84
86
  }
87
+ /**
88
+ * Everything this session has spent, in tokens, across every upstream call.
89
+ *
90
+ * Accumulated in the CLIENT rather than in the tools, so a call that was
91
+ * billed and then failed (a safety filter returning no image, say) still
92
+ * counts — it cost money regardless of whether a tool could use the result.
93
+ * Per session, never module scope: one hosted process serves many tenants,
94
+ * and a shared total would report other people's spend as yours.
95
+ */
96
+ usageTotal;
97
+ /** How many upstream calls contributed to {@link usageTotal}. */
98
+ billedCalls = 0;
99
+ /**
100
+ * What this session has spent in USD, priced per call.
101
+ *
102
+ * Accumulated as calls happen rather than derived from {@link usageTotal} at
103
+ * the end, because the total spans models: summing a Pro call's tokens with
104
+ * a Lite call's and pricing the result at one rate would be wrong by up to
105
+ * 4x. Each call is priced against ITS model and only the money is added up.
106
+ *
107
+ * `undefined` while nothing priceable has run — a model absent from the rate
108
+ * card contributes usage but no cost, so a total of `undefined` beside a
109
+ * non-zero usage is meaningful rather than a bug. (Video and music DO price:
110
+ * omni is token-billed and Lyria bills per song. It is an unknown or
111
+ * newly-released model id that goes unpriced.)
112
+ */
113
+ costUsd;
114
+ /** How many of {@link billedCalls} could actually be priced. */
115
+ pricedCalls = 0;
116
+ /**
117
+ * Fold one upstream call's usage into the running totals. `model` is what
118
+ * makes the cost side possible; without it the tokens still count.
119
+ */
120
+ recordUsage(usage, model) {
121
+ if (!usage)
122
+ return;
123
+ this.usageTotal = sumUsage([this.usageTotal, usage]);
124
+ this.billedCalls += 1;
125
+ if (!model)
126
+ return;
127
+ const cost = estimateCost(model, usage);
128
+ if (!cost)
129
+ return;
130
+ this.costUsd = (this.costUsd ?? 0) + cost.usd;
131
+ this.pricedCalls += 1;
132
+ }
85
133
  /** Forget everything. Tests use this; nothing in `src/` calls it. */
86
134
  reset() {
87
135
  this.jobs.reset();
136
+ this.usageTotal = undefined;
137
+ this.billedCalls = 0;
138
+ this.costUsd = undefined;
139
+ this.pricedCalls = 0;
88
140
  this.lastInteractionId = undefined;
89
141
  this.lastMusicInteractionId = undefined;
90
142
  this.lastVideoInteractionId = undefined;
@@ -5,6 +5,8 @@ import { slugify, baseName } from '../images.js';
5
5
  import { resolveImageInputs, requireImageInput } from '../inputs.js';
6
6
  import { emit, resolveAspectRatio, ASPECT_RATIOS, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, resolveVideoInput, videoPathSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema, imagesR2KeysSchema, charactersSchema, styleSchema, resolveCharacterRefs, composePrompt } from './shared.js';
7
7
  import { fingerprintRequest } from '../jobs.js';
8
+ import { sumUsage } from '../usage.js';
9
+ import { attachCost } from '../pricing.js';
8
10
  import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
9
11
  const GENERATE_ENDPOINT = '/v1beta/models/{model}:generateContent';
10
12
  const SEND_LOCAL_ACTION = 'Send local file input(s) to the Gemini API';
@@ -88,6 +90,9 @@ export function registerGenerateTools(server, client) {
88
90
  // throws: an empty success is worse than an error.
89
91
  const failed = [];
90
92
  let firstError;
93
+ // One entry per SUCCESSFUL upstream call. `count: N` is N billable
94
+ // requests, so reporting the first would understate the tool by N-1.
95
+ const usages = [];
91
96
  await withProgressHeartbeat(extra, `Generating ${count > 1 ? `${count} images` : 'image'} (${model})`, async () => {
92
97
  for (let i = 0; i < count; i++) {
93
98
  try {
@@ -107,6 +112,7 @@ export function registerGenerateTools(server, client) {
107
112
  timeoutMs: args.timeout_ms,
108
113
  });
109
114
  const { images: [img], text } = result;
115
+ usages.push(result.usage);
110
116
  if (text && !capturedText)
111
117
  capturedText = text;
112
118
  if (result.grounding && !capturedGrounding)
@@ -122,6 +128,10 @@ export function registerGenerateTools(server, client) {
122
128
  if (named.length === 0)
123
129
  throw firstError;
124
130
  const meta = buildMeta(model, seed, { ...args, aspect_ratio: aspectRatio });
131
+ const usage = sumUsage(usages);
132
+ if (usage)
133
+ meta.usage = usage;
134
+ attachCost(meta, model, usage);
125
135
  if (capturedText)
126
136
  meta.text = capturedText;
127
137
  if (capturedGrounding)
@@ -208,6 +218,9 @@ export function registerGenerateTools(server, client) {
208
218
  }));
209
219
  const { images: [img], text } = result;
210
220
  const meta = buildMeta(model, seed, { ...args, aspect_ratio: aspectRatio });
221
+ if (result.usage)
222
+ meta.usage = result.usage;
223
+ attachCost(meta, model, result.usage);
211
224
  if (text)
212
225
  meta.text = text;
213
226
  if (result.grounding)
@@ -7,6 +7,7 @@ import { resolveImageInputs } from '../inputs.js';
7
7
  import { findInteractionImages, latestInteractionId } from '../sidecar.js';
8
8
  import { emit, reportShape, resolveAspectRatio, orientationSchema, ASPECT_RATIOS, IMAGE_SIZES, MODEL_CHOICE_GUIDE, resolveVideoInput, videoPathSchema, timeoutMsSchema, timeoutRiskHint, idempotencyKeySchema, asyncSchema, maxWaitMsSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
9
9
  import { fingerprintRequest } from '../jobs.js';
10
+ import { attachCost } from '../pricing.js';
10
11
  import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
11
12
  // The last-interaction id and the written-outputs set used to live here, at
12
13
  // module scope. On the hosted connector that leaks across tenants — one
@@ -275,6 +276,9 @@ export function registerInteractTools(server, client) {
275
276
  meta.hint =
276
277
  `To refine this image, call gemini_interact again with previous_interaction_id: "${r.id}" (or continue_last: true) — ` +
277
278
  'do NOT re-attach the returned image as an input; the interaction already contains it.';
279
+ if (r.usage)
280
+ meta.usage = r.usage;
281
+ attachCost(meta, model, r.usage);
278
282
  if (r.text)
279
283
  meta.text = r.text;
280
284
  if (r.grounding)
@@ -5,6 +5,7 @@ import { resolveImageInputs } from '../inputs.js';
5
5
  import { DEFAULT_MUSIC_MODEL } from '../models.js';
6
6
  import { emitMedia, timeoutMsSchema, idempotencyKeySchema, asyncSchema, maxWaitMsSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
7
7
  import { fingerprintRequest } from '../jobs.js';
8
+ import { attachCost } from '../pricing.js';
8
9
  import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
9
10
  const MUSIC_MODELS = ['lyria-3-clip-preview', 'lyria-3-pro-preview'];
10
11
  const AUDIO_FORMATS = ['mp3', 'wav'];
@@ -86,6 +87,9 @@ export function registerMusicTools(server, client) {
86
87
  const meta = { model, interaction_id: r.id };
87
88
  if (previousInteractionId)
88
89
  meta.previous_interaction_id = previousInteractionId;
90
+ if (r.usage)
91
+ meta.usage = r.usage;
92
+ attachCost(meta, model, r.usage);
89
93
  if (r.text)
90
94
  meta.text = r.text;
91
95
  if (report)
package/dist/tools/set.js CHANGED
@@ -5,6 +5,8 @@ import { slugify, baseName } from '../images.js';
5
5
  import { resolveImageInputs } from '../inputs.js';
6
6
  import { emit, resolveAspectRatio, ASPECT_RATIOS, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema, imagesR2KeysSchema, charactersSchema, styleSchema, resolveCharacterRefs, composePrompt, persistBundle, SET_URL_TTL_MS } from './shared.js';
7
7
  import { fingerprintRequest } from '../jobs.js';
8
+ import { sumUsage } from '../usage.js';
9
+ import { attachCost } from '../pricing.js';
8
10
  import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
9
11
  export function registerSetTools(server, client) {
10
12
  server.registerTool('gemini_image_set', {
@@ -90,6 +92,11 @@ export function registerSetTools(server, client) {
90
92
  ? { ...masterOnly.report, ...carried.report }
91
93
  : undefined;
92
94
  const named = [];
95
+ // A set is the most expensive tool here — master plus one call per
96
+ // scene — so omitting its usage understates spend more than anywhere
97
+ // else. Collected from every call that RETURNED, including scenes
98
+ // whose image was later discarded: they were billed regardless.
99
+ const usages = [];
93
100
  const masterResult = await withProgressHeartbeat(extra, `Generating image set (${model})`, async () => {
94
101
  const result = await client.generate({
95
102
  prompt: masterPrompt,
@@ -97,6 +104,7 @@ export function registerSetTools(server, client) {
97
104
  seed,
98
105
  ...cfg,
99
106
  });
107
+ usages.push(result.usage);
100
108
  const master = result.images[0];
101
109
  named.push({ image: master, base: `${slug}-master` });
102
110
  // Only the master's text is surfaced (below). Per-scene text is intentionally
@@ -122,7 +130,9 @@ export function registerSetTools(server, client) {
122
130
  let ref = master;
123
131
  for (let i = 0; i < scenePrompts.length; i++) {
124
132
  try {
125
- const { images: [img] } = await client.generate({ prompt: composePrompt(scenePrompts[i], styleOnly), images: [ref, ...carriedRefs], seed: seed + i + 1, ...cfg });
133
+ const scene = await client.generate({ prompt: composePrompt(scenePrompts[i], styleOnly), images: [ref, ...carriedRefs], seed: seed + i + 1, ...cfg });
134
+ usages.push(scene.usage);
135
+ const img = scene.images[0];
126
136
  named.push({ image: img, base: sceneName(i) });
127
137
  // Chain mode anchors each scene on the previous OUTPUT, so a
128
138
  // failure must not advance the reference — the next scene
@@ -135,7 +145,8 @@ export function registerSetTools(server, client) {
135
145
  }
136
146
  }
137
147
  else {
138
- const settled = await Promise.allSettled(scenePrompts.map((p, i) => client.generate({ prompt: composePrompt(p, styleOnly), images: [master, ...carriedRefs], seed: seed + i + 1, ...cfg }).then((r) => r.images[0])));
148
+ const settled = await Promise.allSettled(scenePrompts.map((p, i) => client.generate({ prompt: composePrompt(p, styleOnly), images: [master, ...carriedRefs], seed: seed + i + 1, ...cfg })
149
+ .then((r) => { usages.push(r.usage); return r.images[0]; })));
139
150
  settled.forEach((outcome, i) => {
140
151
  if (outcome.status === 'fulfilled')
141
152
  named.push({ image: outcome.value, base: sceneName(i) });
@@ -147,6 +158,10 @@ export function registerSetTools(server, client) {
147
158
  });
148
159
  const masterText = masterResult.text;
149
160
  const meta = buildMeta(model, seed, { ...args, aspect_ratio: aspectRatio });
161
+ const usage = sumUsage(usages);
162
+ if (usage)
163
+ meta.usage = usage;
164
+ attachCost(meta, model, usage);
150
165
  if (masterText)
151
166
  meta.text = masterText;
152
167
  if (masterResult.grounding)
@@ -0,0 +1,71 @@
1
+ import { z } from 'zod';
2
+ import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
3
+ import { PRICED_AT } from '../pricing.js';
4
+ /**
5
+ * What this session has spent, in tokens.
6
+ *
7
+ * This exists because the obvious thing — read the account balance before and
8
+ * after — cannot be built. The Generative Language API's discovery document
9
+ * has no billing, quota, usage, account or credit resource at all, and Cloud
10
+ * Billing v1 has no notion of a balance either (its schema never mentions the
11
+ * word): Google Cloud is post-paid, so there is no wallet to read. Even with a
12
+ * service account and the Cloud Billing scopes, actual spend arrives through
13
+ * BigQuery export hours later — far too late to attribute to one call.
14
+ *
15
+ * Tokens do the job better anyway. They are exact, they are per-call, they
16
+ * arrive with the response, and they attribute: a before/after balance diff
17
+ * cannot separate your generation from anything else billing the account in
18
+ * the same window, and this can.
19
+ *
20
+ * Money IS reported, with two safeguards. Image output turned out to be billed
21
+ * per token like everything else — just at 20-40x the text-output rate — so
22
+ * the published per-image prices are exactly the token arithmetic, and an
23
+ * estimate is arithmetic rather than guesswork (see src/pricing.ts). The
24
+ * safeguards are that every figure carries the date its rates were read, and
25
+ * `GEMINI_RATE_CARD` overrides them without waiting on a release.
26
+ *
27
+ * The session's cost is accumulated PER CALL, not derived from the token total
28
+ * at the end: the total spans models, and pricing a Pro call's tokens at a
29
+ * Lite rate would be wrong by 4x.
30
+ */
31
+ export function registerUsageTools(server, client) {
32
+ server.registerTool('gemini_token_usage', {
33
+ description: 'Token usage for this session so far — what every generation has cost in tokens, added up. ' +
34
+ 'Call it before and after a workflow and subtract to get that workflow\'s cost; call it after a ' +
35
+ '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 — Google Cloud is post-paid and its billing data lags by hours — so this is the accurate way to attribute spend to a call.',
36
+ annotations: toolAnnotations({ readOnly: true }),
37
+ inputSchema: {
38
+ reset: z
39
+ .boolean()
40
+ .optional()
41
+ .describe('Zero the running total after reporting it, so the next call measures from here. Use it to bracket a workflow without arithmetic.'),
42
+ },
43
+ }, async (args) => {
44
+ const usage = client.session.usageTotal;
45
+ const calls = client.session.billedCalls;
46
+ const costUsd = client.session.costUsd;
47
+ const priced = client.session.pricedCalls;
48
+ if (args.reset) {
49
+ client.session.usageTotal = undefined;
50
+ client.session.billedCalls = 0;
51
+ client.session.costUsd = undefined;
52
+ client.session.pricedCalls = 0;
53
+ }
54
+ return textResult({
55
+ usage: usage ?? null,
56
+ billed_calls: calls,
57
+ estimated_cost_usd: costUsd ?? null,
58
+ priced_calls: priced,
59
+ rates_published: PRICED_AT,
60
+ ...(args.reset ? { reset: true } : {}),
61
+ note: usage === undefined
62
+ ? 'No generation has run in this session yet, so nothing has been billed through it.'
63
+ : priced < calls
64
+ ? `Estimate covers ${priced} of ${calls} billed calls — the rest used a model that is not in the rate card. Add it with GEMINI_RATE_CARD to include it.`
65
+ : 'Estimate priced per call against each call\'s own model, then added up.',
66
+ rates: `Published rates read on ${PRICED_AT} (paid tier, USD). Override or extend with GEMINI_RATE_CARD ` +
67
+ 'if they have moved — an estimate is only as current as its card.',
68
+ scope: 'This session only. A restart starts from zero, and a hosted deployment counts each authenticated session separately — these are your tokens, not the account\'s.',
69
+ });
70
+ });
71
+ }
@@ -5,6 +5,7 @@ import { resolveImageInputs } from '../inputs.js';
5
5
  import { DEFAULT_VIDEO_MODEL } from '../models.js';
6
6
  import { emitMedia, reportShape, resolveAspectRatio, orientationSchema, timeoutMsSchema, idempotencyKeySchema, asyncSchema, maxWaitMsSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
7
7
  import { fingerprintRequest } from '../jobs.js';
8
+ import { attachCost } from '../pricing.js';
8
9
  import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
9
10
  /** omni's own aspect-ratio enum — NOT the image tools' ASPECT_RATIOS. */
10
11
  const VIDEO_ASPECT_RATIOS = ['16:9', '9:16'];
@@ -105,6 +106,9 @@ export function registerVideoTools(server, client) {
105
106
  const meta = reportShape({ model, interaction_id: r.id }, { ...args, aspect_ratio: aspectRatio });
106
107
  if (previousInteractionId)
107
108
  meta.previous_interaction_id = previousInteractionId;
109
+ if (r.usage)
110
+ meta.usage = r.usage;
111
+ attachCost(meta, model, r.usage);
108
112
  if (r.text)
109
113
  meta.text = r.text;
110
114
  if (report)