ak-gemini 2.4.1 → 2.6.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/base.js CHANGED
@@ -25,6 +25,22 @@ const DEFAULT_THINKING_CONFIG = {
25
25
 
26
26
  const DEFAULT_MAX_OUTPUT_TOKENS = 50_000;
27
27
 
28
+ /**
29
+ * Effort levels accepted by the cross-package `effort` option.
30
+ * Gemini's wire field is `thinkingConfig.thinkingLevel`, which accepts
31
+ * minimal/low/medium/high. `xhigh` and `max` exist on ak-claude only and are
32
+ * clamped to `high` so the same config object works against both packages.
33
+ */
34
+ const EFFORT_LEVELS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
35
+ const EFFORT_TO_THINKING_LEVEL = {
36
+ minimal: 'minimal',
37
+ low: 'low',
38
+ medium: 'medium',
39
+ high: 'high',
40
+ xhigh: 'high',
41
+ max: 'high'
42
+ };
43
+
28
44
  /** Models that support thinking features. Image / live / tts variants intentionally excluded. */
29
45
  const THINKING_SUPPORTED_MODELS = [
30
46
  /^gemini-3(\.\d+)?-pro(-preview)?$/,
@@ -36,33 +52,56 @@ const THINKING_SUPPORTED_MODELS = [
36
52
  /^gemini-2\.0-flash$/
37
53
  ];
38
54
 
55
+ /** Date the pricing table below was last verified against Google's pricing page. */
56
+ const MODEL_PRICING_AS_OF = '2026-07-28';
57
+
58
+ /** Context-window size at which tiered models switch to their higher rate. */
59
+ const TIER_THRESHOLD = 200_000;
60
+
39
61
  /**
40
- * Model pricing per million tokens (Paid Tier Standard, base rate, as of July 2026).
62
+ * Model pricing per million tokens (Paid Tier Standard, base rate, as of MODEL_PRICING_AS_OF).
41
63
  * Source: https://ai.google.dev/gemini-api/docs/pricing
42
64
  *
65
+ * Fields:
66
+ * - `input` / `output` — standard per-1M rates (≤200k tier where a model is tiered).
67
+ * - `cachedInput` — per-1M rate for tokens served from a context cache. Omitted when
68
+ * Google does not publish one; computeCost() then falls back to the full `input`
69
+ * rate, which OVERSTATES rather than understates cost.
70
+ * - `inputAbove200k` / `outputAbove200k` / `cachedInputAbove200k` — >200k context tier.
71
+ *
43
72
  * NOTES:
44
- * - Pro models use tiered pricing (≤200k vs >200k context). Listed rate is ≤200k base tier.
45
73
  * - Gemma models (e.g. Gemma 4) are intentionally excluded — they are open models with
46
74
  * no paid per-token tier on the Gemini API (Vertex deployments bill by compute).
47
75
  * - Image-output tokens on Nano Banana models bill at $60/M (1.5 Flash Image) or $120/M (3 Pro Image).
48
76
  * Only text-input/text-output rates are modelled here; image-output cost is NOT included in estimateCost().
49
77
  * - Audio input is more expensive on most models — listed rate covers text/image/video input.
78
+ * - Cache STORAGE ($1.00/1M tokens/hour on Flash tiers, $4.50 on Pro) is time-based and
79
+ * NOT modelled here — estimatedCost only covers per-token charges.
80
+ * - A null result from resolvePricing()/computeCost() means UNKNOWN, not free.
50
81
  */
51
82
  const MODEL_PRICING = {
52
83
  // Gemini 3.x stable
53
- 'gemini-3.5-flash': { input: 1.50, output: 9.00 },
54
- 'gemini-3.1-flash-lite': { input: 0.25, output: 1.50 },
84
+ 'gemini-3.6-flash': { input: 1.50, output: 7.50, cachedInput: 0.15 },
85
+ 'gemini-3.5-flash': { input: 1.50, output: 9.00, cachedInput: 0.15 },
86
+ 'gemini-3.5-flash-lite': { input: 0.30, output: 2.50, cachedInput: 0.03 },
87
+ 'gemini-3.1-flash-lite': { input: 0.25, output: 1.50, cachedInput: 0.025 },
55
88
  // Gemini 3.x preview
56
- 'gemini-3.1-pro-preview': { input: 2.00, output: 12.00 }, // ≤200k tier
89
+ 'gemini-3.1-pro-preview': {
90
+ input: 2.00, output: 12.00, cachedInput: 0.20,
91
+ inputAbove200k: 4.00, outputAbove200k: 18.00, cachedInputAbove200k: 0.40
92
+ },
57
93
  'gemini-3-pro-preview': { input: 2.00, output: 12.00 }, // ≤200k tier; launch rate (superseded by 3.1, off the pricing page)
58
94
  'gemini-3-flash-preview': { input: 0.50, output: 3.00 },
59
- 'gemini-3.1-flash-lite-preview': { input: 0.25, output: 1.50 },
95
+ 'gemini-3.1-flash-lite-preview': { input: 0.25, output: 1.50, cachedInput: 0.025 },
60
96
  'gemini-3.1-flash-image-preview': { input: 0.50, output: 3.00 }, // text-only; image-output is $60/M
61
97
  'gemini-3-pro-image-preview': { input: 2.00, output: 12.00 }, // text-only; image-output is $120/M
62
98
  // Gemini 2.5 stable
63
- 'gemini-2.5-flash': { input: 0.30, output: 2.50 },
64
- 'gemini-2.5-flash-lite': { input: 0.10, output: 0.40 },
65
- 'gemini-2.5-pro': { input: 1.25, output: 10.00 }, // ≤200k tier
99
+ 'gemini-2.5-flash': { input: 0.30, output: 3.00, cachedInput: 0.05 },
100
+ 'gemini-2.5-flash-lite': { input: 0.10, output: 0.40, cachedInput: 0.01 },
101
+ 'gemini-2.5-pro': {
102
+ input: 1.25, output: 10.00, cachedInput: 0.125,
103
+ inputAbove200k: 2.50, outputAbove200k: 15.00, cachedInputAbove200k: 0.25
104
+ },
66
105
  'gemini-2.5-flash-image': { input: 0.30, output: 0 }, // image-output is ~$0.039/image (1290 tokens)
67
106
  // Deprecated but kept for back-compat (shut down June 2026)
68
107
  'gemini-2.0-flash': { input: 0.10, output: 0.40 },
@@ -71,7 +110,108 @@ const MODEL_PRICING = {
71
110
  'gemini-embedding-001': { input: 0.15, output: 0 }
72
111
  };
73
112
 
74
- export { DEFAULT_SAFETY_SETTINGS, DEFAULT_THINKING_CONFIG, THINKING_SUPPORTED_MODELS, MODEL_PRICING, DEFAULT_MAX_OUTPUT_TOKENS };
113
+ /**
114
+ * Alias → canonical model id map for pricing resolution.
115
+ * Google publishes floating `-latest` aliases that resolve server-side to a
116
+ * concrete model; they never appear in MODEL_PRICING directly. Map them to the
117
+ * canonical id whose rate they currently bill at so estimatedCost can resolve.
118
+ *
119
+ * These go stale whenever Google rotates an alias. Prefer the model id the API
120
+ * ECHOES (`response.modelVersion`) over the alias you requested — `_estimatedCost`
121
+ * already does this, and alias resolution is logged at debug level.
122
+ */
123
+ const MODEL_ALIASES = {
124
+ 'gemini-flash-latest': 'gemini-3.6-flash',
125
+ 'gemini-pro-latest': 'gemini-3.1-pro-preview',
126
+ 'gemini-flash-lite-latest': 'gemini-3.5-flash-lite'
127
+ };
128
+
129
+ /**
130
+ * Resolves pricing for a model id, following `-latest` aliases.
131
+ * A null result means pricing is UNKNOWN, not free.
132
+ * @param {string|null|undefined} modelId
133
+ * @returns {{ input: number, output: number, cachedInput?: number, inputAbove200k?: number, outputAbove200k?: number, cachedInputAbove200k?: number, tierThreshold: number, asOf: string }|null}
134
+ */
135
+ function resolvePricing(modelId) {
136
+ const entry = _lookupPricing(modelId);
137
+ if (!entry) return null;
138
+ return { ...entry, tierThreshold: TIER_THRESHOLD, asOf: MODEL_PRICING_AS_OF };
139
+ }
140
+
141
+ /**
142
+ * Raw table lookup with alias + pinned-build fallback. No asOf/tier decoration.
143
+ * @param {string|null|undefined} modelId
144
+ * @returns {any|null}
145
+ * @private
146
+ */
147
+ function _lookupPricing(modelId) {
148
+ if (!modelId) return null;
149
+ const tryKey = (id) => {
150
+ if (MODEL_PRICING[id]) return MODEL_PRICING[id];
151
+ const alias = MODEL_ALIASES[id];
152
+ if (alias && MODEL_PRICING[alias]) {
153
+ log.debug(`Pricing: alias "${id}" resolved to "${alias}". If Google has rotated this alias the rate may be wrong — prefer the echoed modelVersion.`);
154
+ return MODEL_PRICING[alias];
155
+ }
156
+ return null;
157
+ };
158
+ let hit = tryKey(modelId);
159
+ if (hit) return hit;
160
+ // The API echoes a pinned build in modelVersion (e.g. 'gemini-2.5-flash-001',
161
+ // 'gemini-3-flash-preview-09-2025') even when you request the bare id. Peel
162
+ // trailing '-<digits>' groups and retry until a priced id matches.
163
+ let stripped = modelId;
164
+ while (true) {
165
+ const next = stripped.replace(/-\d{2,}$/, '');
166
+ if (next === stripped) break;
167
+ stripped = next;
168
+ hit = tryKey(stripped);
169
+ if (hit) return hit;
170
+ }
171
+ return null;
172
+ }
173
+
174
+ /**
175
+ * Computes estimated USD cost from token counts using MODEL_PRICING.
176
+ * Thinking ("thoughts") tokens are billed at the output rate.
177
+ *
178
+ * IMPORTANT: Gemini's `promptTokenCount` INCLUDES `cachedContentTokenCount`, so
179
+ * cached tokens are SUBTRACTED from promptTokens and re-billed at the discounted
180
+ * cached rate. (ak-claude is the opposite — Anthropic's `input_tokens` excludes
181
+ * cache tokens and they are added on top. Do not "unify" these.)
182
+ *
183
+ * When a model has no published `cachedInput` rate, cached tokens bill at the full
184
+ * input rate — an overestimate, never an underestimate.
185
+ *
186
+ * Cache STORAGE (per token-hour) is not modelled.
187
+ *
188
+ * @param {string|null|undefined} modelId
189
+ * @param {number} promptTokens - Total prompt tokens as reported by the API (INCLUDES cached tokens)
190
+ * @param {number} responseTokens
191
+ * @param {number} [thoughtsTokens=0] - Thinking tokens (billed at output rate)
192
+ * @param {number} [cachedTokens=0] - Tokens served from a context cache (subset of promptTokens)
193
+ * @returns {number|null} Cost in USD, or null when pricing is UNKNOWN (not free).
194
+ */
195
+ function computeCost(modelId, promptTokens, responseTokens, thoughtsTokens = 0, cachedTokens = 0) {
196
+ const pricing = resolvePricing(modelId);
197
+ if (!pricing) return null;
198
+
199
+ const above = (promptTokens || 0) > TIER_THRESHOLD;
200
+ const inputRate = (above && pricing.inputAbove200k) || pricing.input;
201
+ const outputRate = (above && pricing.outputAbove200k) || pricing.output;
202
+ const cachedRate = above
203
+ ? (pricing.cachedInputAbove200k ?? pricing.cachedInput ?? inputRate)
204
+ : (pricing.cachedInput ?? inputRate);
205
+
206
+ const cached = Math.max(0, Math.min(cachedTokens || 0, promptTokens || 0));
207
+ const billablePrompt = Math.max(0, (promptTokens || 0) - cached);
208
+
209
+ return (billablePrompt / 1_000_000) * inputRate
210
+ + (cached / 1_000_000) * cachedRate
211
+ + (((responseTokens || 0) + (thoughtsTokens || 0)) / 1_000_000) * outputRate;
212
+ }
213
+
214
+ export { DEFAULT_SAFETY_SETTINGS, DEFAULT_THINKING_CONFIG, THINKING_SUPPORTED_MODELS, MODEL_PRICING, MODEL_PRICING_AS_OF, MODEL_ALIASES, DEFAULT_MAX_OUTPUT_TOKENS, EFFORT_LEVELS, resolvePricing, computeCost };
75
215
 
76
216
  // ── BaseGemini Class ─────────────────────────────────────────────────────────
77
217
 
@@ -193,6 +333,9 @@ class BaseGemini {
193
333
  }
194
334
 
195
335
  // ── Thinking Config ──
336
+ // `effort` is the cross-package option (same name in ak-claude); it maps to
337
+ // Gemini's `thinkingConfig.thinkingLevel`.
338
+ this.effort = this._normalizeEffort(options.effort);
196
339
  this._configureThinking(options.thinkingConfig);
197
340
 
198
341
  // ── GenAI Client ──
@@ -214,6 +357,8 @@ class BaseGemini {
214
357
  this._cumulativeUsage = {
215
358
  promptTokens: 0,
216
359
  responseTokens: 0,
360
+ thoughtsTokens: 0,
361
+ cachedTokens: 0,
217
362
  totalTokens: 0,
218
363
  attempts: 0
219
364
  };
@@ -237,18 +382,28 @@ class BaseGemini {
237
382
  const chatOptions = this._getChatCreateOptions();
238
383
  this.chatSession = this.genAIClient.chats.create(chatOptions);
239
384
 
240
- if (this.healthCheck) {
241
- try {
242
- await this._withRetry(() => this.genAIClient.models.list());
243
- log.debug(`${this.constructor.name}: API connection successful.`);
244
- } catch (e) {
245
- throw new Error(`${this.constructor.name} initialization failed: ${e.message}`);
246
- }
247
- }
385
+ await this._healthCheckPing();
248
386
 
249
387
  log.debug(`${this.constructor.name}: Chat session initialized.`);
250
388
  }
251
389
 
390
+ /**
391
+ * Opt-in connectivity check via `models.list()` — runs only when
392
+ * `healthCheck: true`. Fails fast (no exponential backoff): a readiness probe
393
+ * should surface a bad config immediately, not after ~30s of 429 retries.
394
+ * @returns {Promise<void>}
395
+ * @protected
396
+ */
397
+ async _healthCheckPing() {
398
+ if (!this.healthCheck) return;
399
+ try {
400
+ await this.genAIClient.models.list();
401
+ log.debug(`${this.constructor.name}: API connection successful.`);
402
+ } catch (e) {
403
+ throw new Error(`${this.constructor.name} initialization failed: ${e.message}`);
404
+ }
405
+ }
406
+
252
407
  /**
253
408
  * Builds the options object for `genAIClient.chats.create()`.
254
409
  * Override in subclasses to add tools, grounding, etc.
@@ -316,7 +471,7 @@ class BaseGemini {
316
471
  }
317
472
  this.chatSession = this._createChatSession([]);
318
473
  this.lastResponseMetadata = null;
319
- this._cumulativeUsage = { promptTokens: 0, responseTokens: 0, totalTokens: 0, attempts: 0 };
474
+ this._cumulativeUsage = { promptTokens: 0, responseTokens: 0, thoughtsTokens: 0, cachedTokens: 0, totalTokens: 0, attempts: 0 };
320
475
  log.debug(`${this.constructor.name}: Conversation history cleared.`);
321
476
  }
322
477
 
@@ -416,12 +571,20 @@ class BaseGemini {
416
571
  */
417
572
  _captureMetadata(response) {
418
573
  const modelStatus = response?.modelStatus || null;
574
+ const promptTokens = response.usageMetadata?.promptTokenCount || 0;
575
+ const responseTokens = response.usageMetadata?.candidatesTokenCount || 0;
576
+ const thoughtsTokens = response.usageMetadata?.thoughtsTokenCount || 0;
577
+ // NB: promptTokenCount INCLUDES cachedContentTokenCount — see computeCost().
578
+ const cachedTokens = response.usageMetadata?.cachedContentTokenCount || 0;
419
579
  this.lastResponseMetadata = {
420
580
  modelVersion: response.modelVersion || null,
421
581
  requestedModel: this.modelName,
422
- promptTokens: response.usageMetadata?.promptTokenCount || 0,
423
- responseTokens: response.usageMetadata?.candidatesTokenCount || 0,
424
- totalTokens: response.usageMetadata?.totalTokenCount || 0,
582
+ promptTokens,
583
+ responseTokens,
584
+ thoughtsTokens,
585
+ cachedTokens,
586
+ // totalTokenCount includes thoughts; fall back to summing the parts.
587
+ totalTokens: response.usageMetadata?.totalTokenCount || (promptTokens + responseTokens + thoughtsTokens),
425
588
  timestamp: Date.now(),
426
589
  groundingMetadata: response.candidates?.[0]?.groundingMetadata || null,
427
590
  modelStatus
@@ -441,19 +604,82 @@ class BaseGemini {
441
604
  if (!this.lastResponseMetadata) return null;
442
605
 
443
606
  const meta = this.lastResponseMetadata;
444
- const cumulative = this._cumulativeUsage || { promptTokens: 0, responseTokens: 0, totalTokens: 0, attempts: 1 };
607
+ const cumulative = this._cumulativeUsage || { promptTokens: 0, responseTokens: 0, totalTokens: 0, thoughtsTokens: 0, cachedTokens: 0, attempts: 1 };
445
608
  const useCumulative = cumulative.attempts > 0;
446
609
 
610
+ const promptTokens = useCumulative ? cumulative.promptTokens : meta.promptTokens;
611
+ const responseTokens = useCumulative ? cumulative.responseTokens : meta.responseTokens;
612
+ const thoughtsTokens = useCumulative ? (cumulative.thoughtsTokens || 0) : (meta.thoughtsTokens || 0);
613
+ const totalTokens = useCumulative ? cumulative.totalTokens : meta.totalTokens;
614
+ // Cached tokens accumulate across retries when tracked; otherwise fall back
615
+ // to the last response's value (single-call classes).
616
+ const cachedTokens = useCumulative && cumulative.cachedTokens !== undefined
617
+ ? cumulative.cachedTokens
618
+ : (meta.cachedTokens || 0);
619
+
447
620
  return {
448
- promptTokens: useCumulative ? cumulative.promptTokens : meta.promptTokens,
449
- responseTokens: useCumulative ? cumulative.responseTokens : meta.responseTokens,
450
- totalTokens: useCumulative ? cumulative.totalTokens : meta.totalTokens,
621
+ promptTokens,
622
+ responseTokens,
623
+ thoughtsTokens,
624
+ cachedTokens,
625
+ totalTokens,
451
626
  attempts: useCumulative ? cumulative.attempts : 1,
452
627
  modelVersion: meta.modelVersion,
453
628
  requestedModel: meta.requestedModel,
454
629
  timestamp: meta.timestamp,
455
630
  groundingMetadata: meta.groundingMetadata || null,
456
- modelStatus: meta.modelStatus || null
631
+ modelStatus: meta.modelStatus || null,
632
+ estimatedCost: this._estimatedCost(meta.modelVersion, promptTokens, responseTokens, thoughtsTokens, cachedTokens)
633
+ };
634
+ }
635
+
636
+ /**
637
+ * Estimated USD cost, preferring the model id the API echoed (`modelVersion`)
638
+ * and falling back to the requested model when that build isn't priced.
639
+ * @param {string|null|undefined} modelVersion
640
+ * @param {number} promptTokens
641
+ * @param {number} responseTokens
642
+ * @param {number} [thoughtsTokens=0]
643
+ * @param {number} [cachedTokens=0] - Cached-content tokens (a SUBSET of promptTokens)
644
+ * @returns {number|null}
645
+ * @protected
646
+ */
647
+ _estimatedCost(modelVersion, promptTokens, responseTokens, thoughtsTokens = 0, cachedTokens = 0) {
648
+ return computeCost(modelVersion, promptTokens, responseTokens, thoughtsTokens, cachedTokens)
649
+ ?? computeCost(this.modelName, promptTokens, responseTokens, thoughtsTokens, cachedTokens);
650
+ }
651
+
652
+ /**
653
+ * Builds a usage object directly from a single API response, WITHOUT reading
654
+ * mutable instance state (`lastResponseMetadata`/`_cumulativeUsage`). Safe to
655
+ * call under concurrent send() calls on a shared instance — unlike
656
+ * getLastUsage(), which reflects whichever call most recently mutated the
657
+ * instance and can cross-talk between concurrent sends.
658
+ * @param {Object} response - A single generateContent() response
659
+ * @param {number} [attempts=1] - Attempts this call consumed
660
+ * @returns {UsageData}
661
+ * @protected
662
+ */
663
+ _usageFromResponse(response, attempts = 1) {
664
+ const promptTokens = response?.usageMetadata?.promptTokenCount || 0;
665
+ const responseTokens = response?.usageMetadata?.candidatesTokenCount || 0;
666
+ const thoughtsTokens = response?.usageMetadata?.thoughtsTokenCount || 0;
667
+ const cachedTokens = response?.usageMetadata?.cachedContentTokenCount || 0;
668
+ const totalTokens = response?.usageMetadata?.totalTokenCount || (promptTokens + responseTokens + thoughtsTokens);
669
+ const modelVersion = response?.modelVersion || null;
670
+ return {
671
+ promptTokens,
672
+ responseTokens,
673
+ thoughtsTokens,
674
+ cachedTokens,
675
+ totalTokens,
676
+ attempts,
677
+ modelVersion,
678
+ requestedModel: this.modelName,
679
+ timestamp: Date.now(),
680
+ groundingMetadata: response?.candidates?.[0]?.groundingMetadata || null,
681
+ modelStatus: response?.modelStatus || null,
682
+ estimatedCost: this._estimatedCost(modelVersion, promptTokens, responseTokens, thoughtsTokens, cachedTokens)
457
683
  };
458
684
  }
459
685
 
@@ -500,14 +726,16 @@ class BaseGemini {
500
726
  */
501
727
  async estimateCost(nextPayload) {
502
728
  const tokenInfo = await this.estimate(nextPayload);
503
- const pricing = MODEL_PRICING[this.modelName] || { input: 0, output: 0 };
729
+ const pricing = resolvePricing(this.modelName);
504
730
 
505
731
  return {
506
732
  inputTokens: tokenInfo.inputTokens,
507
733
  model: this.modelName,
508
734
  pricing: pricing,
509
- estimatedInputCost: (tokenInfo.inputTokens / 1_000_000) * pricing.input,
510
- note: 'Cost is for input tokens only; output cost depends on response length'
735
+ estimatedInputCost: pricing ? (tokenInfo.inputTokens / 1_000_000) * pricing.input : null,
736
+ note: pricing
737
+ ? 'Cost is for input tokens only; output cost depends on response length'
738
+ : `No pricing known for model "${this.modelName}"; estimatedInputCost is null`
511
739
  };
512
740
  }
513
741
 
@@ -690,7 +918,7 @@ class BaseGemini {
690
918
  _configureThinking(thinkingConfig) {
691
919
  const modelSupportsThinking = THINKING_SUPPORTED_MODELS.some(p => p.test(this.modelName));
692
920
 
693
- if (thinkingConfig === undefined) return;
921
+ if (thinkingConfig === undefined && this.effort === null) return;
694
922
 
695
923
  if (thinkingConfig === null) {
696
924
  delete this.chatConfig.thinkingConfig;
@@ -699,17 +927,47 @@ class BaseGemini {
699
927
  }
700
928
 
701
929
  if (!modelSupportsThinking) {
702
- log.warn(`Model ${this.modelName} does not support thinking features. Ignoring thinkingConfig.`);
930
+ log.warn(`Model ${this.modelName} does not support thinking features. Ignoring thinkingConfig/effort.`);
703
931
  return;
704
932
  }
705
933
 
706
- const config = { ...DEFAULT_THINKING_CONFIG, ...thinkingConfig };
707
- if (thinkingConfig.thinkingLevel !== undefined) {
934
+ const config = { ...DEFAULT_THINKING_CONFIG, ...(thinkingConfig || {}) };
935
+
936
+ // `effort` wins over an explicit thinkingLevel/thinkingBudget — it is the
937
+ // cross-package knob and is set deliberately.
938
+ if (this.effort !== null) {
939
+ const level = EFFORT_TO_THINKING_LEVEL[this.effort];
940
+ if (level !== this.effort) {
941
+ log.debug(`Effort '${this.effort}' has no Gemini equivalent; using thinkingLevel '${level}'.`);
942
+ }
943
+ config.thinkingLevel = level;
944
+ }
945
+
946
+ // thinkingLevel and thinkingBudget are mutually exclusive on the wire.
947
+ if (config.thinkingLevel !== undefined) {
708
948
  delete config.thinkingBudget;
709
949
  }
950
+
710
951
  this.chatConfig.thinkingConfig = config;
711
952
  log.debug(`Thinking config applied: ${JSON.stringify(config)}`);
712
953
  }
954
+
955
+ /**
956
+ * Validates the cross-package `effort` option.
957
+ * Throws rather than warns: this runs in the constructor before log levels are
958
+ * finalized, so a warning could be swallowed.
959
+ * @param {string|null|undefined} effort
960
+ * @returns {'minimal'|'low'|'medium'|'high'|'xhigh'|'max'|null}
961
+ * @protected
962
+ */
963
+ _normalizeEffort(effort) {
964
+ if (effort === undefined || effort === null) return null;
965
+ const level = String(effort).toLowerCase();
966
+ if (!EFFORT_LEVELS.includes(level)) {
967
+ throw new Error(`Invalid effort "${effort}". Expected one of: ${EFFORT_LEVELS.join(', ')}.`);
968
+ }
969
+ return /** @type {any} */ (level);
970
+ }
713
971
  }
714
972
 
715
973
  export default BaseGemini;
package/chat.js CHANGED
@@ -87,6 +87,8 @@ class Chat extends BaseGemini {
87
87
  this._cumulativeUsage = {
88
88
  promptTokens: this.lastResponseMetadata.promptTokens,
89
89
  responseTokens: this.lastResponseMetadata.responseTokens,
90
+ thoughtsTokens: this.lastResponseMetadata.thoughtsTokens,
91
+ cachedTokens: this.lastResponseMetadata.cachedTokens,
90
92
  totalTokens: this.lastResponseMetadata.totalTokens,
91
93
  attempts: 1
92
94
  };
package/code-agent.js CHANGED
@@ -922,6 +922,8 @@ ${lang.codeRules}
922
922
  this._cumulativeUsage = {
923
923
  promptTokens: this.lastResponseMetadata.promptTokens,
924
924
  responseTokens: this.lastResponseMetadata.responseTokens,
925
+ thoughtsTokens: this.lastResponseMetadata.thoughtsTokens,
926
+ cachedTokens: this.lastResponseMetadata.cachedTokens,
925
927
  totalTokens: this.lastResponseMetadata.totalTokens,
926
928
  attempts: 1
927
929
  };
package/embedding.js CHANGED
@@ -54,12 +54,10 @@ export default class Embedding extends BaseGemini {
54
54
 
55
55
  log.debug(`Initializing ${this.constructor.name} with model: ${this.modelName}...`);
56
56
 
57
- try {
58
- await this.genAIClient.models.list();
59
- log.debug(`${this.constructor.name}: API connection successful.`);
60
- } catch (e) {
61
- throw new Error(`${this.constructor.name} initialization failed: ${e.message}`);
62
- }
57
+ // Connectivity check is opt-in (healthCheck: true) — avoids an extra
58
+ // round-trip and the models.list() IAM surface. First embed() is a fine
59
+ // error signal on its own.
60
+ await this._healthCheckPing();
63
61
 
64
62
  this._initialized = true;
65
63
  log.debug(`${this.constructor.name}: Initialized (stateless mode).`);
@@ -52,12 +52,10 @@ export default class ImageGenerator extends BaseGemini {
52
52
 
53
53
  log.debug(`Initializing ${this.constructor.name} with model: ${this.modelName}...`);
54
54
 
55
- try {
56
- await this.genAIClient.models.list();
57
- log.debug(`${this.constructor.name}: API connection successful.`);
58
- } catch (e) {
59
- throw new Error(`${this.constructor.name} initialization failed: ${e.message}`);
60
- }
55
+ // Connectivity check is opt-in (healthCheck: true) — avoids an extra
56
+ // round-trip and the models.list() IAM surface. First generate() is a fine
57
+ // error signal on its own.
58
+ await this._healthCheckPing();
61
59
 
62
60
  this._initialized = true;
63
61
  }
@@ -108,10 +106,15 @@ export default class ImageGenerator extends BaseGemini {
108
106
  config: this._buildConfig(opts)
109
107
  }));
110
108
 
109
+ // Per-call usage computed synchronously from THIS response (concurrency-safe).
110
+ const usage = this._usageFromResponse(result);
111
+
111
112
  this._captureMetadata(result);
112
113
  this._cumulativeUsage = {
113
114
  promptTokens: this.lastResponseMetadata.promptTokens,
114
115
  responseTokens: this.lastResponseMetadata.responseTokens,
116
+ thoughtsTokens: this.lastResponseMetadata.thoughtsTokens,
117
+ cachedTokens: this.lastResponseMetadata.cachedTokens,
115
118
  totalTokens: this.lastResponseMetadata.totalTokens,
116
119
  attempts: 1
117
120
  };
@@ -134,7 +137,7 @@ export default class ImageGenerator extends BaseGemini {
134
137
  log.warn('ImageGenerator: no images returned. Check prompt or safety filters.');
135
138
  }
136
139
 
137
- return { images, text: text || null, usage: this.getLastUsage() };
140
+ return { images, text: text || null, usage };
138
141
  }
139
142
 
140
143
  /**