ak-gemini 2.4.1 → 2.5.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/CHANGELOG.md ADDED
@@ -0,0 +1,46 @@
1
+ # Changelog
2
+
3
+ ## 2.5.0
4
+
5
+ Fixes from a downstream consumer's adversarial review. This is a **minor** bump —
6
+ it includes small but real behavior changes (see "Behavior changes" below).
7
+
8
+ ### Behavior changes (read before upgrading)
9
+ - **`Message` structured output:** passing `responseSchema` without
10
+ `responseMimeType` now auto-defaults `responseMimeType: 'application/json'`
11
+ (the SDK requires it and does not add it). Previously such a call returned a
12
+ 400 or unparseable prose. Explicit `responseMimeType` is still honored.
13
+ - **`init()` no longer validates connectivity by default.** `Message`,
14
+ `Embedding`, and `ImageGenerator` previously called `models.list()`
15
+ unconditionally during `init()`. This is now gated behind `healthCheck: true`
16
+ (default `false`), matching `BaseGemini`. If you relied on `init()` as a
17
+ startup credentials gate, pass `healthCheck: true`. `models.list()` is a
18
+ different IAM surface than `generateContent` and added a per-instance
19
+ round-trip.
20
+
21
+ ### Added
22
+ - **`usage.estimatedCost`** — every `send()`/`generate()` result and
23
+ `getLastUsage()` now include an estimated USD cost from `MODEL_PRICING`
24
+ (`null` when the model is unpriced). Thinking ("thoughts") tokens are billed at
25
+ the output rate and are now included in cost and exposed as
26
+ `usage.thoughtsTokens`.
27
+ - **Concurrency-safe per-call usage.** `Message.send()` and
28
+ `ImageGenerator.generate()` return `result.usage` computed synchronously from
29
+ that call's own response. `getLastUsage()` still reflects the instance's last
30
+ call and is unsafe to read across concurrent sends on a shared instance — use
31
+ `result.usage`.
32
+ - **Pricing helpers exported:** `MODEL_PRICING`, `MODEL_ALIASES`,
33
+ `resolvePricing()`, `computeCost()`. `resolvePricing` follows `-latest`
34
+ aliases and peels version-build suffixes (e.g. `gemini-2.5-flash-001` →
35
+ `gemini-2.5-flash`).
36
+ - **`validateSchema()`** JSON-Schema validator exported (for cross-package
37
+ symmetry; Gemini enforces schemas natively).
38
+
39
+ ### Fixed
40
+ - `estimateCost()` now uses `resolvePricing()` and returns `null` cost fields for
41
+ unknown models (was `0`), consistent with `usage.estimatedCost`.
42
+ - `estimatedCost` no longer returns `null` for a priced model when the API echoes
43
+ a version-suffixed build id in `modelVersion`.
44
+
45
+ ### Dependencies
46
+ - `@google/genai` `^2.10.0` → `^2.12.0` (no breaking changes to the used surface).
package/GUIDE.md CHANGED
@@ -147,6 +147,8 @@ console.log(result.data);
147
147
 
148
148
  Key difference from `Chat`: `result.data` contains the parsed JSON object. `result.text` contains the raw string.
149
149
 
150
+ > **Note:** `responseSchema` requires `responseMimeType: 'application/json'`. If you pass `responseSchema` without a `responseMimeType`, ak-gemini now auto-defaults it to `'application/json'` (logged at debug). Passing an explicit `responseMimeType` is still honored.
151
+
150
152
  ### When to Use Message
151
153
 
152
154
  - Classification, tagging, or labeling
@@ -1037,10 +1039,18 @@ const usage = instance.getLastUsage();
1037
1039
  // attempts: 1, // 1 = first try, 2+ = retries needed
1038
1040
  // modelVersion: 'gemini-2.5-flash-001', // actual model that responded
1039
1041
  // requestedModel: 'gemini-2.5-flash', // model you requested
1040
- // timestamp: 1710000000000
1042
+ // timestamp: 1710000000000,
1043
+ // thoughtsTokens: 0, // thinking tokens, billed at output rate (in totalTokens + cost)
1044
+ // estimatedCost: 0.00123 // USD from MODEL_PRICING; null if model unpriced
1041
1045
  // }
1042
1046
  ```
1043
1047
 
1048
+ > **Thinking tokens:** for thinking-enabled models, `thoughtsTokens` are billed at the output rate and are included in both `totalTokens` and `estimatedCost`. `responseTokens` is candidate (visible) output only.
1049
+
1050
+ > **Concurrency:** `getLastUsage()` reflects the **instance's last completed call** and mutates on every `send()` — it is **not** safe to read across concurrent sends on a shared instance. The **stateless** classes (`Message`, `ImageGenerator`) return a per-call **`result.usage`** computed synchronously from that call's own response, which *is* concurrency-safe — use it when sharing one instance across concurrent calls. The stateful classes (`Chat`, `Transformer`, `RagAgent`, `ToolAgent`, `CodeAgent`) maintain history/rounds and are not designed to be shared across concurrent calls; their `result.usage` is derived from instance state.
1051
+
1052
+ > **`estimatedCost` / `MODEL_ALIASES` caveat:** `estimatedCost` resolves `-latest` aliases and version-suffixed builds via `MODEL_PRICING`/`MODEL_ALIASES`. Alias→price mappings are pinned as of 2026-07 (e.g. `gemini-flash-latest` → `gemini-3.5-flash`); when Google rotates what a `-latest` alias points to, `estimatedCost` for that alias may be wrong until the table is updated. Pin explicit model ids for billing-grade accuracy.
1053
+
1044
1054
  ### Cost Estimation
1045
1055
 
1046
1056
  Estimate cost *before* sending:
package/README.md CHANGED
@@ -86,8 +86,15 @@ const msg = new Message({
86
86
 
87
87
  const result = await msg.send('Alice works at Acme in New York.');
88
88
  // result.data → { entities: ['Alice', 'Acme', 'New York'] }
89
+ // result.usage → { promptTokens, responseTokens, thoughtsTokens, totalTokens, estimatedCost, ... }
89
90
  ```
90
91
 
92
+ > **`responseSchema` requires `responseMimeType: 'application/json'`.** As of 2.5.0, passing `responseSchema` without a `responseMimeType` auto-defaults it to `'application/json'` — you can omit the line above. An explicit `responseMimeType` is still honored.
93
+
94
+ > **Connectivity check is opt-in.** `Message`, `Embedding`, and `ImageGenerator` do **not** ping the API during `init()` unless you pass `healthCheck: true` (default `false`, as of 2.5.0). The first `send()`/`embed()`/`generate()` surfaces auth/connectivity errors on its own.
95
+
96
+ > **Per-call usage under concurrency.** Read `result.usage` (computed from that call's own response) rather than `getLastUsage()` when sharing a `Message`/`ImageGenerator` instance across concurrent `send()`s — `getLastUsage()` reflects only the instance's last completed call.
97
+
91
98
  ### ToolAgent — Agent with User-Provided Tools
92
99
 
93
100
  Provide tool declarations and an executor function. The agent manages the tool-use loop automatically.
package/base.js CHANGED
@@ -71,7 +71,65 @@ const MODEL_PRICING = {
71
71
  'gemini-embedding-001': { input: 0.15, output: 0 }
72
72
  };
73
73
 
74
- export { DEFAULT_SAFETY_SETTINGS, DEFAULT_THINKING_CONFIG, THINKING_SUPPORTED_MODELS, MODEL_PRICING, DEFAULT_MAX_OUTPUT_TOKENS };
74
+ /**
75
+ * Alias → canonical model id map for pricing resolution.
76
+ * Google publishes floating `-latest` aliases that resolve server-side to a
77
+ * concrete model; they never appear in MODEL_PRICING directly. Map them to the
78
+ * canonical id whose rate they currently bill at so estimatedCost can resolve.
79
+ * Revisit when Google rotates what an alias points to.
80
+ */
81
+ const MODEL_ALIASES = {
82
+ 'gemini-flash-latest': 'gemini-3.5-flash',
83
+ 'gemini-pro-latest': 'gemini-3.1-pro-preview',
84
+ 'gemini-flash-lite-latest': 'gemini-3.1-flash-lite'
85
+ };
86
+
87
+ /**
88
+ * Resolves pricing for a model id, following `-latest` aliases.
89
+ * @param {string|null|undefined} modelId
90
+ * @returns {{ input: number, output: number }|null} Pricing, or null if unknown.
91
+ */
92
+ function resolvePricing(modelId) {
93
+ if (!modelId) return null;
94
+ const tryKey = (id) => {
95
+ if (MODEL_PRICING[id]) return MODEL_PRICING[id];
96
+ const alias = MODEL_ALIASES[id];
97
+ if (alias && MODEL_PRICING[alias]) return MODEL_PRICING[alias];
98
+ return null;
99
+ };
100
+ let hit = tryKey(modelId);
101
+ if (hit) return hit;
102
+ // The API echoes a pinned build in modelVersion (e.g. 'gemini-2.5-flash-001',
103
+ // 'gemini-3-flash-preview-09-2025') even when you request the bare id. Peel
104
+ // trailing '-<digits>' groups and retry until a priced id matches.
105
+ let stripped = modelId;
106
+ while (true) {
107
+ const next = stripped.replace(/-\d{2,}$/, '');
108
+ if (next === stripped) break;
109
+ stripped = next;
110
+ hit = tryKey(stripped);
111
+ if (hit) return hit;
112
+ }
113
+ return null;
114
+ }
115
+
116
+ /**
117
+ * Computes estimated USD cost from token counts using MODEL_PRICING.
118
+ * Thinking ("thoughts") tokens are billed at the output rate.
119
+ * @param {string|null|undefined} modelId
120
+ * @param {number} promptTokens
121
+ * @param {number} responseTokens
122
+ * @param {number} [thoughtsTokens=0] - Thinking tokens (billed at output rate)
123
+ * @returns {number|null} Cost in USD, or null when pricing is unknown.
124
+ */
125
+ function computeCost(modelId, promptTokens, responseTokens, thoughtsTokens = 0) {
126
+ const pricing = resolvePricing(modelId);
127
+ if (!pricing) return null;
128
+ return (promptTokens / 1_000_000) * pricing.input
129
+ + ((responseTokens + thoughtsTokens) / 1_000_000) * pricing.output;
130
+ }
131
+
132
+ export { DEFAULT_SAFETY_SETTINGS, DEFAULT_THINKING_CONFIG, THINKING_SUPPORTED_MODELS, MODEL_PRICING, MODEL_ALIASES, DEFAULT_MAX_OUTPUT_TOKENS, resolvePricing, computeCost };
75
133
 
76
134
  // ── BaseGemini Class ─────────────────────────────────────────────────────────
77
135
 
@@ -237,18 +295,28 @@ class BaseGemini {
237
295
  const chatOptions = this._getChatCreateOptions();
238
296
  this.chatSession = this.genAIClient.chats.create(chatOptions);
239
297
 
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
- }
298
+ await this._healthCheckPing();
248
299
 
249
300
  log.debug(`${this.constructor.name}: Chat session initialized.`);
250
301
  }
251
302
 
303
+ /**
304
+ * Opt-in connectivity check via `models.list()` — runs only when
305
+ * `healthCheck: true`. Fails fast (no exponential backoff): a readiness probe
306
+ * should surface a bad config immediately, not after ~30s of 429 retries.
307
+ * @returns {Promise<void>}
308
+ * @protected
309
+ */
310
+ async _healthCheckPing() {
311
+ if (!this.healthCheck) return;
312
+ try {
313
+ await this.genAIClient.models.list();
314
+ log.debug(`${this.constructor.name}: API connection successful.`);
315
+ } catch (e) {
316
+ throw new Error(`${this.constructor.name} initialization failed: ${e.message}`);
317
+ }
318
+ }
319
+
252
320
  /**
253
321
  * Builds the options object for `genAIClient.chats.create()`.
254
322
  * Override in subclasses to add tools, grounding, etc.
@@ -416,12 +484,17 @@ class BaseGemini {
416
484
  */
417
485
  _captureMetadata(response) {
418
486
  const modelStatus = response?.modelStatus || null;
487
+ const promptTokens = response.usageMetadata?.promptTokenCount || 0;
488
+ const responseTokens = response.usageMetadata?.candidatesTokenCount || 0;
489
+ const thoughtsTokens = response.usageMetadata?.thoughtsTokenCount || 0;
419
490
  this.lastResponseMetadata = {
420
491
  modelVersion: response.modelVersion || null,
421
492
  requestedModel: this.modelName,
422
- promptTokens: response.usageMetadata?.promptTokenCount || 0,
423
- responseTokens: response.usageMetadata?.candidatesTokenCount || 0,
424
- totalTokens: response.usageMetadata?.totalTokenCount || 0,
493
+ promptTokens,
494
+ responseTokens,
495
+ thoughtsTokens,
496
+ // totalTokenCount includes thoughts; fall back to summing the parts.
497
+ totalTokens: response.usageMetadata?.totalTokenCount || (promptTokens + responseTokens + thoughtsTokens),
425
498
  timestamp: Date.now(),
426
499
  groundingMetadata: response.candidates?.[0]?.groundingMetadata || null,
427
500
  modelStatus
@@ -441,19 +514,73 @@ class BaseGemini {
441
514
  if (!this.lastResponseMetadata) return null;
442
515
 
443
516
  const meta = this.lastResponseMetadata;
444
- const cumulative = this._cumulativeUsage || { promptTokens: 0, responseTokens: 0, totalTokens: 0, attempts: 1 };
517
+ const cumulative = this._cumulativeUsage || { promptTokens: 0, responseTokens: 0, totalTokens: 0, thoughtsTokens: 0, attempts: 1 };
445
518
  const useCumulative = cumulative.attempts > 0;
446
519
 
520
+ const promptTokens = useCumulative ? cumulative.promptTokens : meta.promptTokens;
521
+ const responseTokens = useCumulative ? cumulative.responseTokens : meta.responseTokens;
522
+ const thoughtsTokens = useCumulative ? (cumulative.thoughtsTokens || 0) : (meta.thoughtsTokens || 0);
523
+ const totalTokens = useCumulative ? cumulative.totalTokens : meta.totalTokens;
524
+
447
525
  return {
448
- promptTokens: useCumulative ? cumulative.promptTokens : meta.promptTokens,
449
- responseTokens: useCumulative ? cumulative.responseTokens : meta.responseTokens,
450
- totalTokens: useCumulative ? cumulative.totalTokens : meta.totalTokens,
526
+ promptTokens,
527
+ responseTokens,
528
+ thoughtsTokens,
529
+ totalTokens,
451
530
  attempts: useCumulative ? cumulative.attempts : 1,
452
531
  modelVersion: meta.modelVersion,
453
532
  requestedModel: meta.requestedModel,
454
533
  timestamp: meta.timestamp,
455
534
  groundingMetadata: meta.groundingMetadata || null,
456
- modelStatus: meta.modelStatus || null
535
+ modelStatus: meta.modelStatus || null,
536
+ estimatedCost: this._estimatedCost(meta.modelVersion, promptTokens, responseTokens, thoughtsTokens)
537
+ };
538
+ }
539
+
540
+ /**
541
+ * Estimated USD cost, preferring the model id the API echoed (`modelVersion`)
542
+ * and falling back to the requested model when that build isn't priced.
543
+ * @param {string|null|undefined} modelVersion
544
+ * @param {number} promptTokens
545
+ * @param {number} responseTokens
546
+ * @param {number} [thoughtsTokens=0]
547
+ * @returns {number|null}
548
+ * @protected
549
+ */
550
+ _estimatedCost(modelVersion, promptTokens, responseTokens, thoughtsTokens = 0) {
551
+ return computeCost(modelVersion, promptTokens, responseTokens, thoughtsTokens)
552
+ ?? computeCost(this.modelName, promptTokens, responseTokens, thoughtsTokens);
553
+ }
554
+
555
+ /**
556
+ * Builds a usage object directly from a single API response, WITHOUT reading
557
+ * mutable instance state (`lastResponseMetadata`/`_cumulativeUsage`). Safe to
558
+ * call under concurrent send() calls on a shared instance — unlike
559
+ * getLastUsage(), which reflects whichever call most recently mutated the
560
+ * instance and can cross-talk between concurrent sends.
561
+ * @param {Object} response - A single generateContent() response
562
+ * @param {number} [attempts=1] - Attempts this call consumed
563
+ * @returns {UsageData}
564
+ * @protected
565
+ */
566
+ _usageFromResponse(response, attempts = 1) {
567
+ const promptTokens = response?.usageMetadata?.promptTokenCount || 0;
568
+ const responseTokens = response?.usageMetadata?.candidatesTokenCount || 0;
569
+ const thoughtsTokens = response?.usageMetadata?.thoughtsTokenCount || 0;
570
+ const totalTokens = response?.usageMetadata?.totalTokenCount || (promptTokens + responseTokens + thoughtsTokens);
571
+ const modelVersion = response?.modelVersion || null;
572
+ return {
573
+ promptTokens,
574
+ responseTokens,
575
+ thoughtsTokens,
576
+ totalTokens,
577
+ attempts,
578
+ modelVersion,
579
+ requestedModel: this.modelName,
580
+ timestamp: Date.now(),
581
+ groundingMetadata: response?.candidates?.[0]?.groundingMetadata || null,
582
+ modelStatus: response?.modelStatus || null,
583
+ estimatedCost: this._estimatedCost(modelVersion, promptTokens, responseTokens, thoughtsTokens)
457
584
  };
458
585
  }
459
586
 
@@ -500,14 +627,16 @@ class BaseGemini {
500
627
  */
501
628
  async estimateCost(nextPayload) {
502
629
  const tokenInfo = await this.estimate(nextPayload);
503
- const pricing = MODEL_PRICING[this.modelName] || { input: 0, output: 0 };
630
+ const pricing = resolvePricing(this.modelName);
504
631
 
505
632
  return {
506
633
  inputTokens: tokenInfo.inputTokens,
507
634
  model: this.modelName,
508
635
  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'
636
+ estimatedInputCost: pricing ? (tokenInfo.inputTokens / 1_000_000) * pricing.input : null,
637
+ note: pricing
638
+ ? 'Cost is for input tokens only; output cost depends on response length'
639
+ : `No pricing known for model "${this.modelName}"; estimatedInputCost is null`
511
640
  };
512
641
  }
513
642
 
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,14 @@ 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,
115
117
  totalTokens: this.lastResponseMetadata.totalTokens,
116
118
  attempts: 1
117
119
  };
@@ -134,7 +136,7 @@ export default class ImageGenerator extends BaseGemini {
134
136
  log.warn('ImageGenerator: no images returned. Check prompt or safety filters.');
135
137
  }
136
138
 
137
- return { images, text: text || null, usage: this.getLastUsage() };
139
+ return { images, text: text || null, usage };
138
140
  }
139
141
 
140
142
  /**
package/index.cjs CHANGED
@@ -36,15 +36,20 @@ __export(index_exports, {
36
36
  HarmBlockThreshold: () => import_genai2.HarmBlockThreshold,
37
37
  HarmCategory: () => import_genai2.HarmCategory,
38
38
  ImageGenerator: () => ImageGenerator,
39
+ MODEL_ALIASES: () => MODEL_ALIASES,
40
+ MODEL_PRICING: () => MODEL_PRICING,
39
41
  Message: () => message_default,
40
42
  RagAgent: () => rag_agent_default,
41
43
  ThinkingLevel: () => import_genai2.ThinkingLevel,
42
44
  ToolAgent: () => tool_agent_default,
43
45
  Transformer: () => transformer_default,
44
46
  attemptJSONRecovery: () => attemptJSONRecovery,
47
+ computeCost: () => computeCost,
45
48
  default: () => index_default,
46
49
  extractJSON: () => extractJSON,
47
- log: () => logger_default
50
+ log: () => logger_default,
51
+ resolvePricing: () => resolvePricing,
52
+ validateSchema: () => validateSchema
48
53
  });
49
54
  module.exports = __toCommonJS(index_exports);
50
55
 
@@ -255,6 +260,79 @@ function findCompleteJSONStructures(text) {
255
260
  }
256
261
  return results;
257
262
  }
263
+ function validateSchema(data, schema, path2 = "$") {
264
+ const errors = [];
265
+ if (!schema || typeof schema !== "object") return errors;
266
+ if (data === null && schema.nullable === true) return errors;
267
+ if (Array.isArray(schema.enum)) {
268
+ const target = JSON.stringify(data);
269
+ const ok = schema.enum.some((v) => v === data || JSON.stringify(v) === target);
270
+ if (!ok) errors.push(`${path2}: value ${JSON.stringify(data)} is not one of allowed enum values ${JSON.stringify(schema.enum)}`);
271
+ }
272
+ if (schema.type !== void 0) {
273
+ const types = Array.isArray(schema.type) ? schema.type : [schema.type];
274
+ if (schema.nullable === true) types.push("null");
275
+ if (!types.some((t) => matchesType(data, t))) {
276
+ errors.push(`${path2}: expected type ${types.join("|")} but got ${describeType(data)}`);
277
+ return errors;
278
+ }
279
+ }
280
+ const isObject = data !== null && typeof data === "object" && !Array.isArray(data);
281
+ if (isObject && (schema.properties || schema.required || schema.additionalProperties === false)) {
282
+ const props = schema.properties || {};
283
+ if (Array.isArray(schema.required)) {
284
+ for (const key of schema.required) {
285
+ if (!Object.hasOwn(data, key)) errors.push(`${path2}: missing required property "${key}"`);
286
+ }
287
+ }
288
+ if (schema.additionalProperties === false) {
289
+ for (const key of Object.keys(data)) {
290
+ if (!Object.hasOwn(props, key)) errors.push(`${path2}: unexpected property "${key}" (additionalProperties is false)`);
291
+ }
292
+ }
293
+ for (const [key, subSchema] of Object.entries(props)) {
294
+ if (Object.hasOwn(data, key)) {
295
+ errors.push(...validateSchema(
296
+ data[key],
297
+ /** @type {any} */
298
+ subSchema,
299
+ `${path2}.${key}`
300
+ ));
301
+ }
302
+ }
303
+ }
304
+ if (Array.isArray(data) && schema.items && typeof schema.items === "object" && !Array.isArray(schema.items)) {
305
+ data.forEach((item, i) => {
306
+ errors.push(...validateSchema(item, schema.items, `${path2}[${i}]`));
307
+ });
308
+ }
309
+ return errors;
310
+ }
311
+ function matchesType(value, type) {
312
+ switch (type) {
313
+ case "string":
314
+ return typeof value === "string";
315
+ case "number":
316
+ return typeof value === "number" && !Number.isNaN(value);
317
+ case "integer":
318
+ return typeof value === "number" && Number.isInteger(value);
319
+ case "boolean":
320
+ return typeof value === "boolean";
321
+ case "object":
322
+ return value !== null && typeof value === "object" && !Array.isArray(value);
323
+ case "array":
324
+ return Array.isArray(value);
325
+ case "null":
326
+ return value === null;
327
+ default:
328
+ return true;
329
+ }
330
+ }
331
+ function describeType(value) {
332
+ if (value === null) return "null";
333
+ if (Array.isArray(value)) return "array";
334
+ return typeof value;
335
+ }
258
336
  function extractJSON(text) {
259
337
  if (!text || typeof text !== "string") {
260
338
  throw new Error("No text provided for JSON extraction");
@@ -359,6 +437,36 @@ var MODEL_PRICING = {
359
437
  // Embeddings
360
438
  "gemini-embedding-001": { input: 0.15, output: 0 }
361
439
  };
440
+ var MODEL_ALIASES = {
441
+ "gemini-flash-latest": "gemini-3.5-flash",
442
+ "gemini-pro-latest": "gemini-3.1-pro-preview",
443
+ "gemini-flash-lite-latest": "gemini-3.1-flash-lite"
444
+ };
445
+ function resolvePricing(modelId) {
446
+ if (!modelId) return null;
447
+ const tryKey = (id) => {
448
+ if (MODEL_PRICING[id]) return MODEL_PRICING[id];
449
+ const alias = MODEL_ALIASES[id];
450
+ if (alias && MODEL_PRICING[alias]) return MODEL_PRICING[alias];
451
+ return null;
452
+ };
453
+ let hit = tryKey(modelId);
454
+ if (hit) return hit;
455
+ let stripped = modelId;
456
+ while (true) {
457
+ const next = stripped.replace(/-\d{2,}$/, "");
458
+ if (next === stripped) break;
459
+ stripped = next;
460
+ hit = tryKey(stripped);
461
+ if (hit) return hit;
462
+ }
463
+ return null;
464
+ }
465
+ function computeCost(modelId, promptTokens, responseTokens, thoughtsTokens = 0) {
466
+ const pricing = resolvePricing(modelId);
467
+ if (!pricing) return null;
468
+ return promptTokens / 1e6 * pricing.input + (responseTokens + thoughtsTokens) / 1e6 * pricing.output;
469
+ }
362
470
  var BaseGemini = class {
363
471
  /**
364
472
  * @param {BaseGeminiOptions} [options={}]
@@ -453,16 +561,25 @@ var BaseGemini = class {
453
561
  logger_default.debug(`Initializing ${this.constructor.name} chat session with model: ${this.modelName}...`);
454
562
  const chatOptions = this._getChatCreateOptions();
455
563
  this.chatSession = this.genAIClient.chats.create(chatOptions);
456
- if (this.healthCheck) {
457
- try {
458
- await this._withRetry(() => this.genAIClient.models.list());
459
- logger_default.debug(`${this.constructor.name}: API connection successful.`);
460
- } catch (e) {
461
- throw new Error(`${this.constructor.name} initialization failed: ${e.message}`);
462
- }
463
- }
564
+ await this._healthCheckPing();
464
565
  logger_default.debug(`${this.constructor.name}: Chat session initialized.`);
465
566
  }
567
+ /**
568
+ * Opt-in connectivity check via `models.list()` — runs only when
569
+ * `healthCheck: true`. Fails fast (no exponential backoff): a readiness probe
570
+ * should surface a bad config immediately, not after ~30s of 429 retries.
571
+ * @returns {Promise<void>}
572
+ * @protected
573
+ */
574
+ async _healthCheckPing() {
575
+ if (!this.healthCheck) return;
576
+ try {
577
+ await this.genAIClient.models.list();
578
+ logger_default.debug(`${this.constructor.name}: API connection successful.`);
579
+ } catch (e) {
580
+ throw new Error(`${this.constructor.name} initialization failed: ${e.message}`);
581
+ }
582
+ }
466
583
  /**
467
584
  * Builds the options object for `genAIClient.chats.create()`.
468
585
  * Override in subclasses to add tools, grounding, etc.
@@ -609,12 +726,17 @@ ${contextText}
609
726
  */
610
727
  _captureMetadata(response) {
611
728
  const modelStatus = response?.modelStatus || null;
729
+ const promptTokens = response.usageMetadata?.promptTokenCount || 0;
730
+ const responseTokens = response.usageMetadata?.candidatesTokenCount || 0;
731
+ const thoughtsTokens = response.usageMetadata?.thoughtsTokenCount || 0;
612
732
  this.lastResponseMetadata = {
613
733
  modelVersion: response.modelVersion || null,
614
734
  requestedModel: this.modelName,
615
- promptTokens: response.usageMetadata?.promptTokenCount || 0,
616
- responseTokens: response.usageMetadata?.candidatesTokenCount || 0,
617
- totalTokens: response.usageMetadata?.totalTokenCount || 0,
735
+ promptTokens,
736
+ responseTokens,
737
+ thoughtsTokens,
738
+ // totalTokenCount includes thoughts; fall back to summing the parts.
739
+ totalTokens: response.usageMetadata?.totalTokenCount || promptTokens + responseTokens + thoughtsTokens,
618
740
  timestamp: Date.now(),
619
741
  groundingMetadata: response.candidates?.[0]?.groundingMetadata || null,
620
742
  modelStatus
@@ -632,18 +754,68 @@ ${contextText}
632
754
  getLastUsage() {
633
755
  if (!this.lastResponseMetadata) return null;
634
756
  const meta = this.lastResponseMetadata;
635
- const cumulative = this._cumulativeUsage || { promptTokens: 0, responseTokens: 0, totalTokens: 0, attempts: 1 };
757
+ const cumulative = this._cumulativeUsage || { promptTokens: 0, responseTokens: 0, totalTokens: 0, thoughtsTokens: 0, attempts: 1 };
636
758
  const useCumulative = cumulative.attempts > 0;
759
+ const promptTokens = useCumulative ? cumulative.promptTokens : meta.promptTokens;
760
+ const responseTokens = useCumulative ? cumulative.responseTokens : meta.responseTokens;
761
+ const thoughtsTokens = useCumulative ? cumulative.thoughtsTokens || 0 : meta.thoughtsTokens || 0;
762
+ const totalTokens = useCumulative ? cumulative.totalTokens : meta.totalTokens;
637
763
  return {
638
- promptTokens: useCumulative ? cumulative.promptTokens : meta.promptTokens,
639
- responseTokens: useCumulative ? cumulative.responseTokens : meta.responseTokens,
640
- totalTokens: useCumulative ? cumulative.totalTokens : meta.totalTokens,
764
+ promptTokens,
765
+ responseTokens,
766
+ thoughtsTokens,
767
+ totalTokens,
641
768
  attempts: useCumulative ? cumulative.attempts : 1,
642
769
  modelVersion: meta.modelVersion,
643
770
  requestedModel: meta.requestedModel,
644
771
  timestamp: meta.timestamp,
645
772
  groundingMetadata: meta.groundingMetadata || null,
646
- modelStatus: meta.modelStatus || null
773
+ modelStatus: meta.modelStatus || null,
774
+ estimatedCost: this._estimatedCost(meta.modelVersion, promptTokens, responseTokens, thoughtsTokens)
775
+ };
776
+ }
777
+ /**
778
+ * Estimated USD cost, preferring the model id the API echoed (`modelVersion`)
779
+ * and falling back to the requested model when that build isn't priced.
780
+ * @param {string|null|undefined} modelVersion
781
+ * @param {number} promptTokens
782
+ * @param {number} responseTokens
783
+ * @param {number} [thoughtsTokens=0]
784
+ * @returns {number|null}
785
+ * @protected
786
+ */
787
+ _estimatedCost(modelVersion, promptTokens, responseTokens, thoughtsTokens = 0) {
788
+ return computeCost(modelVersion, promptTokens, responseTokens, thoughtsTokens) ?? computeCost(this.modelName, promptTokens, responseTokens, thoughtsTokens);
789
+ }
790
+ /**
791
+ * Builds a usage object directly from a single API response, WITHOUT reading
792
+ * mutable instance state (`lastResponseMetadata`/`_cumulativeUsage`). Safe to
793
+ * call under concurrent send() calls on a shared instance — unlike
794
+ * getLastUsage(), which reflects whichever call most recently mutated the
795
+ * instance and can cross-talk between concurrent sends.
796
+ * @param {Object} response - A single generateContent() response
797
+ * @param {number} [attempts=1] - Attempts this call consumed
798
+ * @returns {UsageData}
799
+ * @protected
800
+ */
801
+ _usageFromResponse(response, attempts = 1) {
802
+ const promptTokens = response?.usageMetadata?.promptTokenCount || 0;
803
+ const responseTokens = response?.usageMetadata?.candidatesTokenCount || 0;
804
+ const thoughtsTokens = response?.usageMetadata?.thoughtsTokenCount || 0;
805
+ const totalTokens = response?.usageMetadata?.totalTokenCount || promptTokens + responseTokens + thoughtsTokens;
806
+ const modelVersion = response?.modelVersion || null;
807
+ return {
808
+ promptTokens,
809
+ responseTokens,
810
+ thoughtsTokens,
811
+ totalTokens,
812
+ attempts,
813
+ modelVersion,
814
+ requestedModel: this.modelName,
815
+ timestamp: Date.now(),
816
+ groundingMetadata: response?.candidates?.[0]?.groundingMetadata || null,
817
+ modelStatus: response?.modelStatus || null,
818
+ estimatedCost: this._estimatedCost(modelVersion, promptTokens, responseTokens, thoughtsTokens)
647
819
  };
648
820
  }
649
821
  // ── Token Estimation ─────────────────────────────────────────────────────
@@ -679,13 +851,13 @@ ${contextText}
679
851
  */
680
852
  async estimateCost(nextPayload) {
681
853
  const tokenInfo = await this.estimate(nextPayload);
682
- const pricing = MODEL_PRICING[this.modelName] || { input: 0, output: 0 };
854
+ const pricing = resolvePricing(this.modelName);
683
855
  return {
684
856
  inputTokens: tokenInfo.inputTokens,
685
857
  model: this.modelName,
686
858
  pricing,
687
- estimatedInputCost: tokenInfo.inputTokens / 1e6 * pricing.input,
688
- note: "Cost is for input tokens only; output cost depends on response length"
859
+ estimatedInputCost: pricing ? tokenInfo.inputTokens / 1e6 * pricing.input : null,
860
+ note: pricing ? "Cost is for input tokens only; output cost depends on response length" : `No pricing known for model "${this.modelName}"; estimatedInputCost is null`
689
861
  };
690
862
  }
691
863
  // ── Context Caching ─────────────────────────────────────────────────────
@@ -1310,6 +1482,9 @@ var Message = class extends base_default {
1310
1482
  }
1311
1483
  if (options.responseMimeType) {
1312
1484
  this.chatConfig.responseMimeType = options.responseMimeType;
1485
+ } else if (options.responseSchema) {
1486
+ this.chatConfig.responseMimeType = "application/json";
1487
+ logger_default.debug("responseSchema set without responseMimeType \u2014 defaulting responseMimeType to 'application/json'.");
1313
1488
  }
1314
1489
  this._isStructured = !!(options.responseSchema || options.responseMimeType === "application/json");
1315
1490
  logger_default.debug(`Message created (structured=${this._isStructured})`);
@@ -1323,12 +1498,7 @@ var Message = class extends base_default {
1323
1498
  async init(force = false) {
1324
1499
  if (this._initialized && !force) return;
1325
1500
  logger_default.debug(`Initializing ${this.constructor.name} with model: ${this.modelName}...`);
1326
- try {
1327
- await this.genAIClient.models.list();
1328
- logger_default.debug(`${this.constructor.name}: API connection successful.`);
1329
- } catch (e) {
1330
- throw new Error(`${this.constructor.name} initialization failed: ${e.message}`);
1331
- }
1501
+ await this._healthCheckPing();
1332
1502
  this._initialized = true;
1333
1503
  logger_default.debug(`${this.constructor.name}: Initialized (stateless mode).`);
1334
1504
  }
@@ -1354,10 +1524,12 @@ var Message = class extends base_default {
1354
1524
  ...this.vertexai && Object.keys(mergedLabels).length > 0 && { labels: mergedLabels }
1355
1525
  }
1356
1526
  }));
1527
+ const usage = this._usageFromResponse(result);
1357
1528
  this._captureMetadata(result);
1358
1529
  this._cumulativeUsage = {
1359
1530
  promptTokens: this.lastResponseMetadata.promptTokens,
1360
1531
  responseTokens: this.lastResponseMetadata.responseTokens,
1532
+ thoughtsTokens: this.lastResponseMetadata.thoughtsTokens,
1361
1533
  totalTokens: this.lastResponseMetadata.totalTokens,
1362
1534
  attempts: 1
1363
1535
  };
@@ -1367,7 +1539,7 @@ var Message = class extends base_default {
1367
1539
  const text = result.text || "";
1368
1540
  const response = {
1369
1541
  text,
1370
- usage: this.getLastUsage()
1542
+ usage
1371
1543
  };
1372
1544
  if (this._isStructured) {
1373
1545
  try {
@@ -2981,12 +3153,7 @@ var Embedding = class extends base_default {
2981
3153
  async init(force = false) {
2982
3154
  if (this._initialized && !force) return;
2983
3155
  logger_default.debug(`Initializing ${this.constructor.name} with model: ${this.modelName}...`);
2984
- try {
2985
- await this.genAIClient.models.list();
2986
- logger_default.debug(`${this.constructor.name}: API connection successful.`);
2987
- } catch (e) {
2988
- throw new Error(`${this.constructor.name} initialization failed: ${e.message}`);
2989
- }
3156
+ await this._healthCheckPing();
2990
3157
  this._initialized = true;
2991
3158
  logger_default.debug(`${this.constructor.name}: Initialized (stateless mode).`);
2992
3159
  }
@@ -3118,12 +3285,7 @@ var ImageGenerator = class extends base_default {
3118
3285
  async init(force = false) {
3119
3286
  if (this._initialized && !force) return;
3120
3287
  logger_default.debug(`Initializing ${this.constructor.name} with model: ${this.modelName}...`);
3121
- try {
3122
- await this.genAIClient.models.list();
3123
- logger_default.debug(`${this.constructor.name}: API connection successful.`);
3124
- } catch (e) {
3125
- throw new Error(`${this.constructor.name} initialization failed: ${e.message}`);
3126
- }
3288
+ await this._healthCheckPing();
3127
3289
  this._initialized = true;
3128
3290
  }
3129
3291
  /**
@@ -3165,10 +3327,12 @@ var ImageGenerator = class extends base_default {
3165
3327
  contents: [{ role: "user", parts }],
3166
3328
  config: this._buildConfig(opts)
3167
3329
  }));
3330
+ const usage = this._usageFromResponse(result);
3168
3331
  this._captureMetadata(result);
3169
3332
  this._cumulativeUsage = {
3170
3333
  promptTokens: this.lastResponseMetadata.promptTokens,
3171
3334
  responseTokens: this.lastResponseMetadata.responseTokens,
3335
+ thoughtsTokens: this.lastResponseMetadata.thoughtsTokens,
3172
3336
  totalTokens: this.lastResponseMetadata.totalTokens,
3173
3337
  attempts: 1
3174
3338
  };
@@ -3188,7 +3352,7 @@ var ImageGenerator = class extends base_default {
3188
3352
  if (images.length === 0) {
3189
3353
  logger_default.warn("ImageGenerator: no images returned. Check prompt or safety filters.");
3190
3354
  }
3191
- return { images, text: text || null, usage: this.getLastUsage() };
3355
+ return { images, text: text || null, usage };
3192
3356
  }
3193
3357
  /**
3194
3358
  * Convenience: write one or all images to disk.
@@ -3248,12 +3412,17 @@ var index_default = { Transformer: transformer_default, Chat: chat_default, Mess
3248
3412
  HarmBlockThreshold,
3249
3413
  HarmCategory,
3250
3414
  ImageGenerator,
3415
+ MODEL_ALIASES,
3416
+ MODEL_PRICING,
3251
3417
  Message,
3252
3418
  RagAgent,
3253
3419
  ThinkingLevel,
3254
3420
  ToolAgent,
3255
3421
  Transformer,
3256
3422
  attemptJSONRecovery,
3423
+ computeCost,
3257
3424
  extractJSON,
3258
- log
3425
+ log,
3426
+ resolvePricing,
3427
+ validateSchema
3259
3428
  });
package/index.js CHANGED
@@ -29,9 +29,10 @@ export { default as RagAgent } from './rag-agent.js';
29
29
  export { default as Embedding } from './embedding.js';
30
30
  export { default as ImageGenerator } from './image-generator.js';
31
31
  export { default as BaseGemini } from './base.js';
32
+ export { MODEL_PRICING, MODEL_ALIASES, resolvePricing, computeCost } from './base.js';
32
33
  export { default as log } from './logger.js';
33
34
  export { ThinkingLevel, HarmCategory, HarmBlockThreshold } from '@google/genai';
34
- export { extractJSON, attemptJSONRecovery } from './json-helpers.js';
35
+ export { extractJSON, attemptJSONRecovery, validateSchema } from './json-helpers.js';
35
36
 
36
37
  // ── Default Export (namespace object) ──
37
38
 
package/json-helpers.js CHANGED
@@ -267,6 +267,111 @@ function findCompleteJSONStructures(text) {
267
267
  return results;
268
268
  }
269
269
 
270
+ /**
271
+ * Validates a parsed value against a (subset of) JSON Schema. Dependency-light —
272
+ * intended to guard fallback structured-output paths where the model isn't forced
273
+ * to emit schema-valid JSON.
274
+ *
275
+ * Supported keywords: `type` (string or array of strings; 'integer' checks for
276
+ * whole numbers), `required`, `properties`, `additionalProperties: false`,
277
+ * `items` (single schema), `enum`. Unknown keywords are ignored (lenient).
278
+ *
279
+ * @param {*} data - The parsed value to validate
280
+ * @param {Object} schema - JSON Schema object
281
+ * @param {string} [path='$'] - Internal: JSON path prefix for error messages
282
+ * @returns {string[]} Array of human-readable error strings; empty means valid.
283
+ */
284
+ export function validateSchema(data, schema, path = '$') {
285
+ // NOTE: ak-gemini enforces structured output natively (responseSchema), so this
286
+ // validator has no runtime call site here — it exists for cross-package API
287
+ // symmetry with ak-claude (whose Vertex fallback path relies on it). Keep this
288
+ // implementation byte-identical to ak-claude/json-helpers.js; apply fixes to both.
289
+ const errors = [];
290
+ if (!schema || typeof schema !== 'object') return errors;
291
+
292
+ // ── nullable (OpenAPI-style) ── a null value is valid on a nullable node.
293
+ if (data === null && schema.nullable === true) return errors;
294
+
295
+ // ── enum ── strict equality first, then deep-equal for object/array members.
296
+ if (Array.isArray(schema.enum)) {
297
+ const target = JSON.stringify(data);
298
+ const ok = schema.enum.some(v => v === data || JSON.stringify(v) === target);
299
+ if (!ok) errors.push(`${path}: value ${JSON.stringify(data)} is not one of allowed enum values ${JSON.stringify(schema.enum)}`);
300
+ }
301
+
302
+ // ── type ──
303
+ if (schema.type !== undefined) {
304
+ const types = Array.isArray(schema.type) ? schema.type : [schema.type];
305
+ if (schema.nullable === true) types.push('null');
306
+ if (!types.some(t => matchesType(data, t))) {
307
+ errors.push(`${path}: expected type ${types.join('|')} but got ${describeType(data)}`);
308
+ // If the type is wrong, deeper checks are meaningless — stop here for this node.
309
+ return errors;
310
+ }
311
+ }
312
+
313
+ // ── object ── (Object.hasOwn avoids prototype-chain keys like 'toString')
314
+ const isObject = data !== null && typeof data === 'object' && !Array.isArray(data);
315
+ if (isObject && (schema.properties || schema.required || schema.additionalProperties === false)) {
316
+ const props = schema.properties || {};
317
+
318
+ if (Array.isArray(schema.required)) {
319
+ for (const key of schema.required) {
320
+ if (!Object.hasOwn(data, key)) errors.push(`${path}: missing required property "${key}"`);
321
+ }
322
+ }
323
+
324
+ if (schema.additionalProperties === false) {
325
+ for (const key of Object.keys(data)) {
326
+ if (!Object.hasOwn(props, key)) errors.push(`${path}: unexpected property "${key}" (additionalProperties is false)`);
327
+ }
328
+ }
329
+
330
+ for (const [key, subSchema] of Object.entries(props)) {
331
+ if (Object.hasOwn(data, key)) {
332
+ errors.push(...validateSchema(data[key], /** @type {any} */ (subSchema), `${path}.${key}`));
333
+ }
334
+ }
335
+ }
336
+
337
+ // ── array ──
338
+ if (Array.isArray(data) && schema.items && typeof schema.items === 'object' && !Array.isArray(schema.items)) {
339
+ data.forEach((item, i) => {
340
+ errors.push(...validateSchema(item, schema.items, `${path}[${i}]`));
341
+ });
342
+ }
343
+
344
+ return errors;
345
+ }
346
+
347
+ /**
348
+ * @param {*} value
349
+ * @param {string} type - JSON Schema type name
350
+ * @returns {boolean}
351
+ */
352
+ function matchesType(value, type) {
353
+ switch (type) {
354
+ case 'string': return typeof value === 'string';
355
+ case 'number': return typeof value === 'number' && !Number.isNaN(value);
356
+ case 'integer': return typeof value === 'number' && Number.isInteger(value);
357
+ case 'boolean': return typeof value === 'boolean';
358
+ case 'object': return value !== null && typeof value === 'object' && !Array.isArray(value);
359
+ case 'array': return Array.isArray(value);
360
+ case 'null': return value === null;
361
+ default: return true; // unknown type keyword — be lenient
362
+ }
363
+ }
364
+
365
+ /**
366
+ * @param {*} value
367
+ * @returns {string}
368
+ */
369
+ function describeType(value) {
370
+ if (value === null) return 'null';
371
+ if (Array.isArray(value)) return 'array';
372
+ return typeof value;
373
+ }
374
+
270
375
  /**
271
376
  * Extracts valid JSON from model response text using multiple strategies.
272
377
  * @param {string} text - The model response text
package/message.js CHANGED
@@ -53,6 +53,13 @@ class Message extends BaseGemini {
53
53
  }
54
54
  if (options.responseMimeType) {
55
55
  this.chatConfig.responseMimeType = options.responseMimeType;
56
+ } else if (options.responseSchema) {
57
+ // Google's @google/genai requires response_mime_type: 'application/json'
58
+ // whenever a responseSchema is set — it does NOT add it automatically.
59
+ // Passing a schema without the mime type yields a 400 INVALID_ARGUMENT
60
+ // (Vertex) or prose output that fails JSON extraction. Auto-pair them.
61
+ this.chatConfig.responseMimeType = 'application/json';
62
+ log.debug("responseSchema set without responseMimeType — defaulting responseMimeType to 'application/json'.");
56
63
  }
57
64
 
58
65
  this._isStructured = !!(options.responseSchema || options.responseMimeType === 'application/json');
@@ -71,12 +78,11 @@ class Message extends BaseGemini {
71
78
 
72
79
  log.debug(`Initializing ${this.constructor.name} with model: ${this.modelName}...`);
73
80
 
74
- try {
75
- await this.genAIClient.models.list();
76
- log.debug(`${this.constructor.name}: API connection successful.`);
77
- } catch (e) {
78
- throw new Error(`${this.constructor.name} initialization failed: ${e.message}`);
79
- }
81
+ // Connectivity check is opt-in (healthCheck: true). models.list() is a
82
+ // different IAM surface than generateContent — a service account allowed to
83
+ // generate but not list publisher models would fail here misleadingly — and
84
+ // it adds a round-trip per instance. The first send() is a fine error signal.
85
+ await this._healthCheckPing();
80
86
 
81
87
  this._initialized = true;
82
88
  log.debug(`${this.constructor.name}: Initialized (stateless mode).`);
@@ -111,11 +117,17 @@ class Message extends BaseGemini {
111
117
  }
112
118
  }));
113
119
 
120
+ // Compute per-call usage synchronously from THIS response before any other
121
+ // concurrent send() can mutate instance state. result.usage is safe under
122
+ // concurrency; getLastUsage() (instance state, updated below) is not.
123
+ const usage = this._usageFromResponse(result);
124
+
114
125
  this._captureMetadata(result);
115
126
 
116
127
  this._cumulativeUsage = {
117
128
  promptTokens: this.lastResponseMetadata.promptTokens,
118
129
  responseTokens: this.lastResponseMetadata.responseTokens,
130
+ thoughtsTokens: this.lastResponseMetadata.thoughtsTokens,
119
131
  totalTokens: this.lastResponseMetadata.totalTokens,
120
132
  attempts: 1
121
133
  };
@@ -127,7 +139,7 @@ class Message extends BaseGemini {
127
139
  const text = result.text || '';
128
140
  const response = {
129
141
  text,
130
- usage: this.getLastUsage()
142
+ usage
131
143
  };
132
144
 
133
145
  // Parse structured data if configured
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "ak-gemini",
3
3
  "author": "ak@mixpanel.com",
4
4
  "description": "AK's Generative AI Helper for doing... everything",
5
- "version": "2.4.1",
5
+ "version": "2.5.0",
6
6
  "main": "index.js",
7
7
  "files": [
8
8
  "index.js",
@@ -19,7 +19,8 @@
19
19
  "json-helpers.js",
20
20
  "types.d.ts",
21
21
  "logger.js",
22
- "GUIDE.md"
22
+ "GUIDE.md",
23
+ "CHANGELOG.md"
23
24
  ],
24
25
  "types": "types.d.ts",
25
26
  "exports": {
@@ -65,7 +66,7 @@
65
66
  ],
66
67
  "license": "ISC",
67
68
  "dependencies": {
68
- "@google/genai": "^2.10.0",
69
+ "@google/genai": "^2.12.0",
69
70
  "dotenv": "^17.3.1",
70
71
  "pino": "^10.3.1",
71
72
  "pino-pretty": "^13.1.3"
package/types.d.ts CHANGED
@@ -64,7 +64,9 @@ export interface UsageData {
64
64
  promptTokens: number;
65
65
  /** CUMULATIVE output tokens across all retry attempts */
66
66
  responseTokens: number;
67
- /** CUMULATIVE total tokens across all retry attempts */
67
+ /** CUMULATIVE thinking ("thoughts") tokens; billed at the output rate. Included in totalTokens and estimatedCost. */
68
+ thoughtsTokens?: number;
69
+ /** CUMULATIVE total tokens across all retry attempts (includes thoughtsTokens) */
68
70
  totalTokens: number;
69
71
  /** Number of attempts (1 = first try success, 2+ = retries needed) */
70
72
  attempts: number;
@@ -76,6 +78,8 @@ export interface UsageData {
76
78
  groundingMetadata?: GroundingMetadata | null;
77
79
  /** Model lifecycle status from Google (e.g., 'DEPRECATED'). Surfaced from @google/genai 1.47+. */
78
80
  modelStatus?: string | null;
81
+ /** Estimated USD cost from MODEL_PRICING (input+output). null when the model's pricing is unknown. */
82
+ estimatedCost?: number | null;
79
83
  }
80
84
 
81
85
  export interface TransformationExample {
@@ -602,8 +606,8 @@ export declare class BaseGemini {
602
606
  estimateCost(nextPayload: Record<string, unknown> | string): Promise<{
603
607
  inputTokens: number;
604
608
  model: string;
605
- pricing: { input: number; output: number };
606
- estimatedInputCost: number;
609
+ pricing: { input: number; output: number } | null;
610
+ estimatedInputCost: number | null;
607
611
  note: string;
608
612
  }>;
609
613
 
@@ -757,6 +761,17 @@ export declare class ImageGenerator extends BaseGemini {
757
761
 
758
762
  export declare function extractJSON(text: string): any;
759
763
  export declare function attemptJSONRecovery(text: string, maxAttempts?: number): any | null;
764
+ /** Validates a parsed value against a subset of JSON Schema. Returns error strings ([] means valid). */
765
+ export declare function validateSchema(data: any, schema: Record<string, any>, path?: string): string[];
766
+
767
+ /** Per-million-token pricing keyed by model id. */
768
+ export declare const MODEL_PRICING: Record<string, { input: number; output: number }>;
769
+ /** Floating `-latest` alias → canonical model id, for pricing resolution. */
770
+ export declare const MODEL_ALIASES: Record<string, string>;
771
+ /** Resolves pricing for a model id (follows -latest aliases). null when unknown. */
772
+ export declare function resolvePricing(modelId: string | null | undefined): { input: number; output: number } | null;
773
+ /** Estimated USD cost from token counts. null when the model's pricing is unknown. */
774
+ export declare function computeCost(modelId: string | null | undefined, promptTokens: number, responseTokens: number): number | null;
760
775
 
761
776
  declare const _default: {
762
777
  Transformer: typeof Transformer;