@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.
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
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)
@@ -5,8 +5,9 @@ import { ChainedRequest404Error } from '../client.js';
5
5
  import { slugify, baseName, resolveImagePath, writeSidecar, resolveOutputDir, readImageAsInline } from '../images.js';
6
6
  import { resolveImageInputs } from '../inputs.js';
7
7
  import { findInteractionImages, latestInteractionId } from '../sidecar.js';
8
- import { emit, resolveAspectRatio, orientationSchema, ASPECT_RATIOS, IMAGE_SIZES, MODEL_CHOICE_GUIDE, resolveVideoInput, videoPathSchema, timeoutMsSchema, timeoutRiskHint, idempotencyKeySchema, asyncSchema, maxWaitMsSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
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
@@ -259,7 +260,9 @@ export function registerInteractTools(server, client) {
259
260
  }
260
261
  });
261
262
  client.session.lastInteractionId = r.id;
262
- const meta = { model, interaction_id: r.id };
263
+ // `aspectRatio` is the RESOLVED value (see resolveAspectRatio above), so
264
+ // an `orientation: 'portrait'` request can be seen to have become 9:16.
265
+ const meta = reportShape({ model, interaction_id: r.id }, { ...args, aspect_ratio: aspectRatio });
263
266
  // On recovery the prior id was NOT used — reporting it as
264
267
  // previous_interaction_id would misrepresent what the model saw.
265
268
  if (chainRecovered)
@@ -273,6 +276,9 @@ export function registerInteractTools(server, client) {
273
276
  meta.hint =
274
277
  `To refine this image, call gemini_interact again with previous_interaction_id: "${r.id}" (or continue_last: true) — ` +
275
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);
276
282
  if (r.text)
277
283
  meta.text = r.text;
278
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)
@@ -446,15 +446,29 @@ export function timeoutRiskHint(opts) {
446
446
  }
447
447
  /** Build the result metadata echoed to the caller (omitting unset optionals). */
448
448
  export function buildMeta(model, seed, opts) {
449
- const meta = { model, seed };
450
- // The RESOLVED ratio, and the orientation only when one was asked for — so a
451
- // caller who said "portrait" can see both what they asked and what it became.
449
+ const meta = reportShape({ model, seed }, opts);
450
+ if (opts.image_size)
451
+ meta.image_size = opts.image_size;
452
+ return meta;
453
+ }
454
+ /**
455
+ * Record the shape a generation produced, into any meta object.
456
+ *
457
+ * The RESOLVED ratio, and the `orientation` only when one was actually asked
458
+ * for — so a caller who said "portrait" sees both what they asked and what it
459
+ * became, while a bare `21:9` is not handed back a word nobody used.
460
+ *
461
+ * It lives apart from `buildMeta` because two tools cannot use that function:
462
+ * `gemini_interact` and `gemini_video_generate` build their metas around
463
+ * `interaction_id` and chain state rather than a seed, and so reported no shape
464
+ * at all until #166. Sharing the rule rather than copying it is what keeps
465
+ * those hand-rolled metas from drifting again.
466
+ */
467
+ export function reportShape(meta, opts) {
452
468
  if (opts.aspect_ratio)
453
469
  meta.aspect_ratio = opts.aspect_ratio;
454
470
  if (opts.orientation)
455
471
  meta.orientation = opts.orientation;
456
- if (opts.image_size)
457
- meta.image_size = opts.image_size;
458
472
  return meta;
459
473
  }
460
474
  /**