ak-gemini 2.3.0 → 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
@@ -37,11 +37,13 @@ const THINKING_SUPPORTED_MODELS = [
37
37
  ];
38
38
 
39
39
  /**
40
- * Model pricing per million tokens (Paid Tier Standard, base rate, as of May 2026).
40
+ * Model pricing per million tokens (Paid Tier Standard, base rate, as of July 2026).
41
41
  * Source: https://ai.google.dev/gemini-api/docs/pricing
42
42
  *
43
43
  * NOTES:
44
44
  * - Pro models use tiered pricing (≤200k vs >200k context). Listed rate is ≤200k base tier.
45
+ * - Gemma models (e.g. Gemma 4) are intentionally excluded — they are open models with
46
+ * no paid per-token tier on the Gemini API (Vertex deployments bill by compute).
45
47
  * - Image-output tokens on Nano Banana models bill at $60/M (1.5 Flash Image) or $120/M (3 Pro Image).
46
48
  * Only text-input/text-output rates are modelled here; image-output cost is NOT included in estimateCost().
47
49
  * - Audio input is more expensive on most models — listed rate covers text/image/video input.
@@ -52,6 +54,7 @@ const MODEL_PRICING = {
52
54
  'gemini-3.1-flash-lite': { input: 0.25, output: 1.50 },
53
55
  // Gemini 3.x preview
54
56
  'gemini-3.1-pro-preview': { input: 2.00, output: 12.00 }, // ≤200k tier
57
+ 'gemini-3-pro-preview': { input: 2.00, output: 12.00 }, // ≤200k tier; launch rate (superseded by 3.1, off the pricing page)
55
58
  'gemini-3-flash-preview': { input: 0.50, output: 3.00 },
56
59
  'gemini-3.1-flash-lite-preview': { input: 0.25, output: 1.50 },
57
60
  'gemini-3.1-flash-image-preview': { input: 0.50, output: 3.00 }, // text-only; image-output is $60/M
@@ -68,7 +71,65 @@ const MODEL_PRICING = {
68
71
  'gemini-embedding-001': { input: 0.15, output: 0 }
69
72
  };
70
73
 
71
- 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 };
72
133
 
73
134
  // ── BaseGemini Class ─────────────────────────────────────────────────────────
74
135
 
@@ -155,6 +216,13 @@ class BaseGemini {
155
216
  if (this.serviceTier) this.chatConfig['serviceTier'] = this.serviceTier;
156
217
  if (this.includeServerSideToolInvocations) this.chatConfig['includeServerSideToolInvocations'] = true;
157
218
 
219
+ // ── Sampling Shortcuts ──
220
+ // Top-level sugar for chatConfig sampling knobs; wins over chatConfig
221
+ // (same precedence as the maxOutputTokens promotion below)
222
+ for (const key of ['temperature', 'topP', 'topK']) {
223
+ if (options[key] !== undefined) this.chatConfig[key] = options[key];
224
+ }
225
+
158
226
  // Apply systemPrompt to chatConfig
159
227
  if (this.systemPrompt) {
160
228
  this.chatConfig.systemInstruction = this.systemPrompt;
@@ -227,18 +295,28 @@ class BaseGemini {
227
295
  const chatOptions = this._getChatCreateOptions();
228
296
  this.chatSession = this.genAIClient.chats.create(chatOptions);
229
297
 
230
- if (this.healthCheck) {
231
- try {
232
- await this._withRetry(() => this.genAIClient.models.list());
233
- log.debug(`${this.constructor.name}: API connection successful.`);
234
- } catch (e) {
235
- throw new Error(`${this.constructor.name} initialization failed: ${e.message}`);
236
- }
237
- }
298
+ await this._healthCheckPing();
238
299
 
239
300
  log.debug(`${this.constructor.name}: Chat session initialized.`);
240
301
  }
241
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
+
242
320
  /**
243
321
  * Builds the options object for `genAIClient.chats.create()`.
244
322
  * Override in subclasses to add tools, grounding, etc.
@@ -321,6 +399,7 @@ class BaseGemini {
321
399
  * @param {string} [opts.contextKey='CONTEXT'] - Key for optional context
322
400
  * @param {string} [opts.explanationKey='EXPLANATION'] - Key for optional explanations
323
401
  * @param {string} [opts.systemPromptKey='SYSTEM'] - Key for system prompt overrides in examples
402
+ * @param {'json'|'text'} [opts.format='json'] - Model-turn format: 'json' wraps answers in a {data} envelope (Transformer protocol); 'text' stores ANSWER verbatim (prose agents like Chat)
324
403
  * @returns {Promise<Array>} The updated chat history
325
404
  */
326
405
  async seed(examples, opts = {}) {
@@ -336,6 +415,7 @@ class BaseGemini {
336
415
  const contextKey = opts.contextKey || 'CONTEXT';
337
416
  const explanationKey = opts.explanationKey || 'EXPLANATION';
338
417
  const systemPromptKey = opts.systemPromptKey || 'SYSTEM';
418
+ const format = opts.format || 'json';
339
419
 
340
420
  // Check for system prompt override in examples
341
421
  const instructionExample = examples.find(ex => ex[systemPromptKey]);
@@ -367,9 +447,15 @@ class BaseGemini {
367
447
  userText += promptText;
368
448
  }
369
449
 
370
- if (answerValue) modelResponse.data = answerValue;
371
- if (explanationValue) modelResponse.explanation = explanationValue;
372
- const modelText = JSON.stringify(modelResponse, null, 2);
450
+ let modelText;
451
+ if (format === 'text') {
452
+ modelText = isJSON(answerValue) ? JSON.stringify(answerValue, null, 2) : String(answerValue || '');
453
+ if (explanationValue) log.warn('seed(): EXPLANATION has no representation in text format; ignored.');
454
+ } else {
455
+ if (answerValue) modelResponse.data = answerValue;
456
+ if (explanationValue) modelResponse.explanation = explanationValue;
457
+ modelText = JSON.stringify(modelResponse, null, 2);
458
+ }
373
459
 
374
460
  if (userText.trim().length && modelText.trim().length > 0) {
375
461
  historyToAdd.push({ role: 'user', parts: [{ text: userText.trim() }] });
@@ -398,12 +484,17 @@ class BaseGemini {
398
484
  */
399
485
  _captureMetadata(response) {
400
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;
401
490
  this.lastResponseMetadata = {
402
491
  modelVersion: response.modelVersion || null,
403
492
  requestedModel: this.modelName,
404
- promptTokens: response.usageMetadata?.promptTokenCount || 0,
405
- responseTokens: response.usageMetadata?.candidatesTokenCount || 0,
406
- 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),
407
498
  timestamp: Date.now(),
408
499
  groundingMetadata: response.candidates?.[0]?.groundingMetadata || null,
409
500
  modelStatus
@@ -423,19 +514,73 @@ class BaseGemini {
423
514
  if (!this.lastResponseMetadata) return null;
424
515
 
425
516
  const meta = this.lastResponseMetadata;
426
- 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 };
427
518
  const useCumulative = cumulative.attempts > 0;
428
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
+
429
525
  return {
430
- promptTokens: useCumulative ? cumulative.promptTokens : meta.promptTokens,
431
- responseTokens: useCumulative ? cumulative.responseTokens : meta.responseTokens,
432
- totalTokens: useCumulative ? cumulative.totalTokens : meta.totalTokens,
526
+ promptTokens,
527
+ responseTokens,
528
+ thoughtsTokens,
529
+ totalTokens,
433
530
  attempts: useCumulative ? cumulative.attempts : 1,
434
531
  modelVersion: meta.modelVersion,
435
532
  requestedModel: meta.requestedModel,
436
533
  timestamp: meta.timestamp,
437
534
  groundingMetadata: meta.groundingMetadata || null,
438
- 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)
439
584
  };
440
585
  }
441
586
 
@@ -482,14 +627,16 @@ class BaseGemini {
482
627
  */
483
628
  async estimateCost(nextPayload) {
484
629
  const tokenInfo = await this.estimate(nextPayload);
485
- const pricing = MODEL_PRICING[this.modelName] || { input: 0, output: 0 };
630
+ const pricing = resolvePricing(this.modelName);
486
631
 
487
632
  return {
488
633
  inputTokens: tokenInfo.inputTokens,
489
634
  model: this.modelName,
490
635
  pricing: pricing,
491
- estimatedInputCost: (tokenInfo.inputTokens / 1_000_000) * pricing.input,
492
- 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`
493
640
  };
494
641
  }
495
642
 
package/chat.js CHANGED
@@ -47,6 +47,19 @@ class Chat extends BaseGemini {
47
47
  log.debug(`Chat created with model: ${this.modelName}`);
48
48
  }
49
49
 
50
+ /**
51
+ * Seeds the conversation with example pairs stored as plain prose turns.
52
+ * Chat is a prose agent — model turns are stored verbatim, not wrapped in
53
+ * Transformer's {data} JSON envelope.
54
+ *
55
+ * @param {import('./types').TransformationExample[]} [examples]
56
+ * @param {import('./types').SeedOptions} [opts={}]
57
+ * @returns {Promise<Array>} The updated chat history
58
+ */
59
+ async seed(examples, opts = {}) {
60
+ return super.seed(examples, { format: 'text', ...opts });
61
+ }
62
+
50
63
  /**
51
64
  * Send a text message and get a response. Adds to conversation history.
52
65
  *
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");
@@ -338,6 +416,8 @@ var MODEL_PRICING = {
338
416
  // Gemini 3.x preview
339
417
  "gemini-3.1-pro-preview": { input: 2, output: 12 },
340
418
  // ≤200k tier
419
+ "gemini-3-pro-preview": { input: 2, output: 12 },
420
+ // ≤200k tier; launch rate (superseded by 3.1, off the pricing page)
341
421
  "gemini-3-flash-preview": { input: 0.5, output: 3 },
342
422
  "gemini-3.1-flash-lite-preview": { input: 0.25, output: 1.5 },
343
423
  "gemini-3.1-flash-image-preview": { input: 0.5, output: 3 },
@@ -357,6 +437,36 @@ var MODEL_PRICING = {
357
437
  // Embeddings
358
438
  "gemini-embedding-001": { input: 0.15, output: 0 }
359
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
+ }
360
470
  var BaseGemini = class {
361
471
  /**
362
472
  * @param {BaseGeminiOptions} [options={}]
@@ -398,6 +508,9 @@ var BaseGemini = class {
398
508
  };
399
509
  if (this.serviceTier) this.chatConfig["serviceTier"] = this.serviceTier;
400
510
  if (this.includeServerSideToolInvocations) this.chatConfig["includeServerSideToolInvocations"] = true;
511
+ for (const key of ["temperature", "topP", "topK"]) {
512
+ if (options[key] !== void 0) this.chatConfig[key] = options[key];
513
+ }
401
514
  if (this.systemPrompt) {
402
515
  this.chatConfig.systemInstruction = this.systemPrompt;
403
516
  } else if (this.systemPrompt === null && options.systemPrompt === void 0) {
@@ -448,16 +561,25 @@ var BaseGemini = class {
448
561
  logger_default.debug(`Initializing ${this.constructor.name} chat session with model: ${this.modelName}...`);
449
562
  const chatOptions = this._getChatCreateOptions();
450
563
  this.chatSession = this.genAIClient.chats.create(chatOptions);
451
- if (this.healthCheck) {
452
- try {
453
- await this._withRetry(() => this.genAIClient.models.list());
454
- logger_default.debug(`${this.constructor.name}: API connection successful.`);
455
- } catch (e) {
456
- throw new Error(`${this.constructor.name} initialization failed: ${e.message}`);
457
- }
458
- }
564
+ await this._healthCheckPing();
459
565
  logger_default.debug(`${this.constructor.name}: Chat session initialized.`);
460
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
+ }
461
583
  /**
462
584
  * Builds the options object for `genAIClient.chats.create()`.
463
585
  * Override in subclasses to add tools, grounding, etc.
@@ -531,6 +653,7 @@ var BaseGemini = class {
531
653
  * @param {string} [opts.contextKey='CONTEXT'] - Key for optional context
532
654
  * @param {string} [opts.explanationKey='EXPLANATION'] - Key for optional explanations
533
655
  * @param {string} [opts.systemPromptKey='SYSTEM'] - Key for system prompt overrides in examples
656
+ * @param {'json'|'text'} [opts.format='json'] - Model-turn format: 'json' wraps answers in a {data} envelope (Transformer protocol); 'text' stores ANSWER verbatim (prose agents like Chat)
534
657
  * @returns {Promise<Array>} The updated chat history
535
658
  */
536
659
  async seed(examples, opts = {}) {
@@ -544,6 +667,7 @@ var BaseGemini = class {
544
667
  const contextKey = opts.contextKey || "CONTEXT";
545
668
  const explanationKey = opts.explanationKey || "EXPLANATION";
546
669
  const systemPromptKey = opts.systemPromptKey || "SYSTEM";
670
+ const format = opts.format || "json";
547
671
  const instructionExample = examples.find((ex) => ex[systemPromptKey]);
548
672
  if (instructionExample) {
549
673
  logger_default.debug(`Found system prompt in examples; reinitializing chat.`);
@@ -572,9 +696,15 @@ ${contextText}
572
696
  let promptText = isJSON(promptValue) ? JSON.stringify(promptValue, null, 2) : promptValue;
573
697
  userText += promptText;
574
698
  }
575
- if (answerValue) modelResponse.data = answerValue;
576
- if (explanationValue) modelResponse.explanation = explanationValue;
577
- const modelText = JSON.stringify(modelResponse, null, 2);
699
+ let modelText;
700
+ if (format === "text") {
701
+ modelText = isJSON(answerValue) ? JSON.stringify(answerValue, null, 2) : String(answerValue || "");
702
+ if (explanationValue) logger_default.warn("seed(): EXPLANATION has no representation in text format; ignored.");
703
+ } else {
704
+ if (answerValue) modelResponse.data = answerValue;
705
+ if (explanationValue) modelResponse.explanation = explanationValue;
706
+ modelText = JSON.stringify(modelResponse, null, 2);
707
+ }
578
708
  if (userText.trim().length && modelText.trim().length > 0) {
579
709
  historyToAdd.push({ role: "user", parts: [{ text: userText.trim() }] });
580
710
  historyToAdd.push({ role: "model", parts: [{ text: modelText.trim() }] });
@@ -596,12 +726,17 @@ ${contextText}
596
726
  */
597
727
  _captureMetadata(response) {
598
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;
599
732
  this.lastResponseMetadata = {
600
733
  modelVersion: response.modelVersion || null,
601
734
  requestedModel: this.modelName,
602
- promptTokens: response.usageMetadata?.promptTokenCount || 0,
603
- responseTokens: response.usageMetadata?.candidatesTokenCount || 0,
604
- 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,
605
740
  timestamp: Date.now(),
606
741
  groundingMetadata: response.candidates?.[0]?.groundingMetadata || null,
607
742
  modelStatus
@@ -619,18 +754,68 @@ ${contextText}
619
754
  getLastUsage() {
620
755
  if (!this.lastResponseMetadata) return null;
621
756
  const meta = this.lastResponseMetadata;
622
- 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 };
623
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;
624
763
  return {
625
- promptTokens: useCumulative ? cumulative.promptTokens : meta.promptTokens,
626
- responseTokens: useCumulative ? cumulative.responseTokens : meta.responseTokens,
627
- totalTokens: useCumulative ? cumulative.totalTokens : meta.totalTokens,
764
+ promptTokens,
765
+ responseTokens,
766
+ thoughtsTokens,
767
+ totalTokens,
628
768
  attempts: useCumulative ? cumulative.attempts : 1,
629
769
  modelVersion: meta.modelVersion,
630
770
  requestedModel: meta.requestedModel,
631
771
  timestamp: meta.timestamp,
632
772
  groundingMetadata: meta.groundingMetadata || null,
633
- 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)
634
819
  };
635
820
  }
636
821
  // ── Token Estimation ─────────────────────────────────────────────────────
@@ -666,13 +851,13 @@ ${contextText}
666
851
  */
667
852
  async estimateCost(nextPayload) {
668
853
  const tokenInfo = await this.estimate(nextPayload);
669
- const pricing = MODEL_PRICING[this.modelName] || { input: 0, output: 0 };
854
+ const pricing = resolvePricing(this.modelName);
670
855
  return {
671
856
  inputTokens: tokenInfo.inputTokens,
672
857
  model: this.modelName,
673
858
  pricing,
674
- estimatedInputCost: tokenInfo.inputTokens / 1e6 * pricing.input,
675
- 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`
676
861
  };
677
862
  }
678
863
  // ── Context Caching ─────────────────────────────────────────────────────
@@ -938,7 +1123,8 @@ var Transformer = class extends base_default {
938
1123
  answerKey: this.answerKey,
939
1124
  contextKey: this.contextKey,
940
1125
  explanationKey: this.explanationKey,
941
- systemPromptKey: this.systemPromptKey
1126
+ systemPromptKey: this.systemPromptKey,
1127
+ format: "json"
942
1128
  });
943
1129
  }
944
1130
  // ── Primary Send Method ──────────────────────────────────────────────────
@@ -1216,6 +1402,18 @@ var Chat = class extends base_default {
1216
1402
  super(options);
1217
1403
  logger_default.debug(`Chat created with model: ${this.modelName}`);
1218
1404
  }
1405
+ /**
1406
+ * Seeds the conversation with example pairs stored as plain prose turns.
1407
+ * Chat is a prose agent — model turns are stored verbatim, not wrapped in
1408
+ * Transformer's {data} JSON envelope.
1409
+ *
1410
+ * @param {import('./types').TransformationExample[]} [examples]
1411
+ * @param {import('./types').SeedOptions} [opts={}]
1412
+ * @returns {Promise<Array>} The updated chat history
1413
+ */
1414
+ async seed(examples, opts = {}) {
1415
+ return super.seed(examples, { format: "text", ...opts });
1416
+ }
1219
1417
  /**
1220
1418
  * Send a text message and get a response. Adds to conversation history.
1221
1419
  *
@@ -1284,6 +1482,9 @@ var Message = class extends base_default {
1284
1482
  }
1285
1483
  if (options.responseMimeType) {
1286
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'.");
1287
1488
  }
1288
1489
  this._isStructured = !!(options.responseSchema || options.responseMimeType === "application/json");
1289
1490
  logger_default.debug(`Message created (structured=${this._isStructured})`);
@@ -1297,12 +1498,7 @@ var Message = class extends base_default {
1297
1498
  async init(force = false) {
1298
1499
  if (this._initialized && !force) return;
1299
1500
  logger_default.debug(`Initializing ${this.constructor.name} with model: ${this.modelName}...`);
1300
- try {
1301
- await this.genAIClient.models.list();
1302
- logger_default.debug(`${this.constructor.name}: API connection successful.`);
1303
- } catch (e) {
1304
- throw new Error(`${this.constructor.name} initialization failed: ${e.message}`);
1305
- }
1501
+ await this._healthCheckPing();
1306
1502
  this._initialized = true;
1307
1503
  logger_default.debug(`${this.constructor.name}: Initialized (stateless mode).`);
1308
1504
  }
@@ -1328,10 +1524,12 @@ var Message = class extends base_default {
1328
1524
  ...this.vertexai && Object.keys(mergedLabels).length > 0 && { labels: mergedLabels }
1329
1525
  }
1330
1526
  }));
1527
+ const usage = this._usageFromResponse(result);
1331
1528
  this._captureMetadata(result);
1332
1529
  this._cumulativeUsage = {
1333
1530
  promptTokens: this.lastResponseMetadata.promptTokens,
1334
1531
  responseTokens: this.lastResponseMetadata.responseTokens,
1532
+ thoughtsTokens: this.lastResponseMetadata.thoughtsTokens,
1335
1533
  totalTokens: this.lastResponseMetadata.totalTokens,
1336
1534
  attempts: 1
1337
1535
  };
@@ -1341,7 +1539,7 @@ var Message = class extends base_default {
1341
1539
  const text = result.text || "";
1342
1540
  const response = {
1343
1541
  text,
1344
- usage: this.getLastUsage()
1542
+ usage
1345
1543
  };
1346
1544
  if (this._isStructured) {
1347
1545
  try {
@@ -2955,12 +3153,7 @@ var Embedding = class extends base_default {
2955
3153
  async init(force = false) {
2956
3154
  if (this._initialized && !force) return;
2957
3155
  logger_default.debug(`Initializing ${this.constructor.name} with model: ${this.modelName}...`);
2958
- try {
2959
- await this.genAIClient.models.list();
2960
- logger_default.debug(`${this.constructor.name}: API connection successful.`);
2961
- } catch (e) {
2962
- throw new Error(`${this.constructor.name} initialization failed: ${e.message}`);
2963
- }
3156
+ await this._healthCheckPing();
2964
3157
  this._initialized = true;
2965
3158
  logger_default.debug(`${this.constructor.name}: Initialized (stateless mode).`);
2966
3159
  }
@@ -3092,12 +3285,7 @@ var ImageGenerator = class extends base_default {
3092
3285
  async init(force = false) {
3093
3286
  if (this._initialized && !force) return;
3094
3287
  logger_default.debug(`Initializing ${this.constructor.name} with model: ${this.modelName}...`);
3095
- try {
3096
- await this.genAIClient.models.list();
3097
- logger_default.debug(`${this.constructor.name}: API connection successful.`);
3098
- } catch (e) {
3099
- throw new Error(`${this.constructor.name} initialization failed: ${e.message}`);
3100
- }
3288
+ await this._healthCheckPing();
3101
3289
  this._initialized = true;
3102
3290
  }
3103
3291
  /**
@@ -3139,10 +3327,12 @@ var ImageGenerator = class extends base_default {
3139
3327
  contents: [{ role: "user", parts }],
3140
3328
  config: this._buildConfig(opts)
3141
3329
  }));
3330
+ const usage = this._usageFromResponse(result);
3142
3331
  this._captureMetadata(result);
3143
3332
  this._cumulativeUsage = {
3144
3333
  promptTokens: this.lastResponseMetadata.promptTokens,
3145
3334
  responseTokens: this.lastResponseMetadata.responseTokens,
3335
+ thoughtsTokens: this.lastResponseMetadata.thoughtsTokens,
3146
3336
  totalTokens: this.lastResponseMetadata.totalTokens,
3147
3337
  attempts: 1
3148
3338
  };
@@ -3162,7 +3352,7 @@ var ImageGenerator = class extends base_default {
3162
3352
  if (images.length === 0) {
3163
3353
  logger_default.warn("ImageGenerator: no images returned. Check prompt or safety filters.");
3164
3354
  }
3165
- return { images, text: text || null, usage: this.getLastUsage() };
3355
+ return { images, text: text || null, usage };
3166
3356
  }
3167
3357
  /**
3168
3358
  * Convenience: write one or all images to disk.
@@ -3222,12 +3412,17 @@ var index_default = { Transformer: transformer_default, Chat: chat_default, Mess
3222
3412
  HarmBlockThreshold,
3223
3413
  HarmCategory,
3224
3414
  ImageGenerator,
3415
+ MODEL_ALIASES,
3416
+ MODEL_PRICING,
3225
3417
  Message,
3226
3418
  RagAgent,
3227
3419
  ThinkingLevel,
3228
3420
  ToolAgent,
3229
3421
  Transformer,
3230
3422
  attemptJSONRecovery,
3423
+ computeCost,
3231
3424
  extractJSON,
3232
- log
3425
+ log,
3426
+ resolvePricing,
3427
+ validateSchema
3233
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.3.0",
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.6.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/transformer.js CHANGED
@@ -141,7 +141,8 @@ class Transformer extends BaseGemini {
141
141
  answerKey: this.answerKey,
142
142
  contextKey: this.contextKey,
143
143
  explanationKey: this.explanationKey,
144
- systemPromptKey: this.systemPromptKey
144
+ systemPromptKey: this.systemPromptKey,
145
+ format: 'json'
145
146
  });
146
147
  }
147
148
 
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 {
@@ -147,6 +151,12 @@ export interface BaseGeminiOptions {
147
151
  thinkingConfig?: ThinkingConfig | null;
148
152
  /** Maximum output tokens (default: 50000, null removes limit) */
149
153
  maxOutputTokens?: number | null;
154
+ /** Sampling temperature (shorthand for chatConfig.temperature; wins over chatConfig) */
155
+ temperature?: number;
156
+ /** Nucleus sampling (shorthand for chatConfig.topP; wins over chatConfig) */
157
+ topP?: number;
158
+ /** Top-k sampling (shorthand for chatConfig.topK; wins over chatConfig) */
159
+ topK?: number;
150
160
  /** Log level (default: based on NODE_ENV) */
151
161
  logLevel?: LogLevel;
152
162
 
@@ -563,6 +573,8 @@ export interface SeedOptions {
563
573
  contextKey?: string;
564
574
  explanationKey?: string;
565
575
  systemPromptKey?: string;
576
+ /** Model-turn format: 'json' wraps answers in a {data} envelope (Transformer protocol); 'text' stores ANSWER verbatim (prose agents like Chat). Default: 'json' */
577
+ format?: 'json' | 'text';
566
578
  }
567
579
 
568
580
  // ── Class Declarations ───────────────────────────────────────────────────────
@@ -594,8 +606,8 @@ export declare class BaseGemini {
594
606
  estimateCost(nextPayload: Record<string, unknown> | string): Promise<{
595
607
  inputTokens: number;
596
608
  model: string;
597
- pricing: { input: number; output: number };
598
- estimatedInputCost: number;
609
+ pricing: { input: number; output: number } | null;
610
+ estimatedInputCost: number | null;
599
611
  note: string;
600
612
  }>;
601
613
 
@@ -749,6 +761,17 @@ export declare class ImageGenerator extends BaseGemini {
749
761
 
750
762
  export declare function extractJSON(text: string): any;
751
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;
752
775
 
753
776
  declare const _default: {
754
777
  Transformer: typeof Transformer;