@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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -0
- package/dist/bundle.js +346 -11
- package/dist/client.js +23 -4
- package/dist/jobs.js +4 -0
- package/dist/pricing.js +201 -0
- package/dist/registrars.js +2 -0
- package/dist/session.js +52 -0
- package/dist/tools/generate.js +13 -0
- package/dist/tools/interact.js +8 -2
- package/dist/tools/music.js +4 -0
- package/dist/tools/set.js +17 -2
- package/dist/tools/shared.js +19 -5
- package/dist/tools/usage.js +71 -0
- package/dist/tools/video.js +8 -2
- package/dist/usage.js +156 -0
- package/dist/version.js +1 -1
- package/mint.yaml +57 -0
- package/package.json +4 -3
- package/server.json +2 -2
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.
|
package/dist/pricing.js
ADDED
|
@@ -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
|
+
}
|
package/dist/registrars.js
CHANGED
|
@@ -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;
|
package/dist/tools/generate.js
CHANGED
|
@@ -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)
|
package/dist/tools/interact.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/dist/tools/music.js
CHANGED
|
@@ -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
|
|
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 })
|
|
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)
|
package/dist/tools/shared.js
CHANGED
|
@@ -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
|
-
|
|
451
|
-
|
|
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
|
/**
|