ak-gemini 2.4.1 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,18 @@ 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,
131
+ cachedTokens: this.lastResponseMetadata.cachedTokens,
119
132
  totalTokens: this.lastResponseMetadata.totalTokens,
120
133
  attempts: 1
121
134
  };
@@ -127,7 +140,7 @@ class Message extends BaseGemini {
127
140
  const text = result.text || '';
128
141
  const response = {
129
142
  text,
130
- usage: this.getLastUsage()
143
+ usage
131
144
  };
132
145
 
133
146
  // 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.6.0",
6
6
  "main": "index.js",
7
7
  "files": [
8
8
  "index.js",
@@ -19,7 +19,9 @@
19
19
  "json-helpers.js",
20
20
  "types.d.ts",
21
21
  "logger.js",
22
- "GUIDE.md"
22
+ "GUIDE.md",
23
+ "UPGRADING.md",
24
+ "CHANGELOG.md"
23
25
  ],
24
26
  "types": "types.d.ts",
25
27
  "exports": {
@@ -65,7 +67,7 @@
65
67
  ],
66
68
  "license": "ISC",
67
69
  "dependencies": {
68
- "@google/genai": "^2.10.0",
70
+ "@google/genai": "^2.12.0",
69
71
  "dotenv": "^17.3.1",
70
72
  "pino": "^10.3.1",
71
73
  "pino-pretty": "^13.1.3"
package/rag-agent.js CHANGED
@@ -213,6 +213,8 @@ class RagAgent extends BaseGemini {
213
213
  this._cumulativeUsage = {
214
214
  promptTokens: this.lastResponseMetadata.promptTokens,
215
215
  responseTokens: this.lastResponseMetadata.responseTokens,
216
+ thoughtsTokens: this.lastResponseMetadata.thoughtsTokens,
217
+ cachedTokens: this.lastResponseMetadata.cachedTokens,
216
218
  totalTokens: this.lastResponseMetadata.totalTokens,
217
219
  attempts: 1
218
220
  };
package/tool-agent.js CHANGED
@@ -200,6 +200,8 @@ class ToolAgent extends BaseGemini {
200
200
  this._cumulativeUsage = {
201
201
  promptTokens: this.lastResponseMetadata.promptTokens,
202
202
  responseTokens: this.lastResponseMetadata.responseTokens,
203
+ thoughtsTokens: this.lastResponseMetadata.thoughtsTokens,
204
+ cachedTokens: this.lastResponseMetadata.cachedTokens,
203
205
  totalTokens: this.lastResponseMetadata.totalTokens,
204
206
  attempts: 1
205
207
  };
package/transformer.js CHANGED
@@ -201,7 +201,7 @@ class Transformer extends BaseGemini {
201
201
  if (opts.labels) messageOptions.labels = opts.labels;
202
202
 
203
203
  // Reset cumulative usage tracking
204
- this._cumulativeUsage = { promptTokens: 0, responseTokens: 0, totalTokens: 0, attempts: 0 };
204
+ this._cumulativeUsage = { promptTokens: 0, responseTokens: 0, thoughtsTokens: 0, cachedTokens: 0, totalTokens: 0, attempts: 0 };
205
205
 
206
206
  let lastError = null;
207
207
 
@@ -215,6 +215,8 @@ class Transformer extends BaseGemini {
215
215
  if (this.lastResponseMetadata) {
216
216
  this._cumulativeUsage.promptTokens += this.lastResponseMetadata.promptTokens || 0;
217
217
  this._cumulativeUsage.responseTokens += this.lastResponseMetadata.responseTokens || 0;
218
+ this._cumulativeUsage.thoughtsTokens += this.lastResponseMetadata.thoughtsTokens || 0;
219
+ this._cumulativeUsage.cachedTokens += this.lastResponseMetadata.cachedTokens || 0;
218
220
  this._cumulativeUsage.totalTokens += this.lastResponseMetadata.totalTokens || 0;
219
221
  this._cumulativeUsage.attempts = attempt + 1;
220
222
  }
@@ -389,6 +391,8 @@ Respond with JSON only – no comments or explanations.
389
391
  this._cumulativeUsage = {
390
392
  promptTokens: this.lastResponseMetadata.promptTokens,
391
393
  responseTokens: this.lastResponseMetadata.responseTokens,
394
+ thoughtsTokens: this.lastResponseMetadata.thoughtsTokens,
395
+ cachedTokens: this.lastResponseMetadata.cachedTokens,
392
396
  totalTokens: this.lastResponseMetadata.totalTokens,
393
397
  attempts: 1
394
398
  };
@@ -422,7 +426,7 @@ Respond with JSON only – no comments or explanations.
422
426
  this.chatSession = this._createChatSession(exampleHistory);
423
427
 
424
428
  this.lastResponseMetadata = null;
425
- this._cumulativeUsage = { promptTokens: 0, responseTokens: 0, totalTokens: 0, attempts: 0 };
429
+ this._cumulativeUsage = { promptTokens: 0, responseTokens: 0, thoughtsTokens: 0, cachedTokens: 0, totalTokens: 0, attempts: 0 };
426
430
 
427
431
  log.debug(`Conversation cleared. Preserved ${exampleHistory.length} example items.`);
428
432
  }
package/types.d.ts CHANGED
@@ -6,11 +6,38 @@ export { ThinkingLevel, HarmCategory, HarmBlockThreshold };
6
6
 
7
7
  export interface ThinkingConfig {
8
8
  includeThoughts?: boolean;
9
- /** Token budget for thinking. 0 = disabled, -1 = automatic. */
9
+ /** Token budget for thinking. 0 = disabled, -1 = automatic. Mutually exclusive with thinkingLevel. */
10
10
  thinkingBudget?: number;
11
11
  thinkingLevel?: ThinkingLevel;
12
12
  }
13
13
 
14
+ /**
15
+ * Cross-package reasoning-effort levels (same option name in ak-claude).
16
+ * Gemini's wire field is `thinkingConfig.thinkingLevel`; `xhigh` and `max` are
17
+ * ak-claude-only levels and clamp to `high` here.
18
+ */
19
+ export type EffortLevel = 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
20
+
21
+ /** Per-million-token rates for a single model. */
22
+ export interface ModelPricing {
23
+ input: number;
24
+ output: number;
25
+ /** Rate for tokens served from a context cache. Absent when Google publishes none — cost then falls back to `input` (overestimate). */
26
+ cachedInput?: number;
27
+ /** >200k-context tier rates, present only on tiered (Pro) models. */
28
+ inputAbove200k?: number;
29
+ outputAbove200k?: number;
30
+ cachedInputAbove200k?: number;
31
+ }
32
+
33
+ /** ModelPricing decorated with the tier threshold and the table's verification date. */
34
+ export interface ResolvedPricing extends ModelPricing {
35
+ /** Prompt-token count above which the *Above200k rates apply. */
36
+ tierThreshold: number;
37
+ /** ISO date the pricing table was last verified against Google's pricing page. */
38
+ asOf: string;
39
+ }
40
+
14
41
  export interface SafetySetting {
15
42
  category: HarmCategory;
16
43
  threshold: HarmBlockThreshold;
@@ -54,6 +81,9 @@ export interface ResponseMetadata {
54
81
  requestedModel: string;
55
82
  promptTokens: number;
56
83
  responseTokens: number;
84
+ thoughtsTokens?: number;
85
+ /** Cached-content tokens. A SUBSET of promptTokens, not an addition to it. */
86
+ cachedTokens?: number;
57
87
  totalTokens: number;
58
88
  timestamp: number;
59
89
  groundingMetadata?: GroundingMetadata | null;
@@ -64,7 +94,17 @@ export interface UsageData {
64
94
  promptTokens: number;
65
95
  /** CUMULATIVE output tokens across all retry attempts */
66
96
  responseTokens: number;
67
- /** CUMULATIVE total tokens across all retry attempts */
97
+ /** CUMULATIVE thinking ("thoughts") tokens; billed at the output rate. Included in totalTokens and estimatedCost. */
98
+ thoughtsTokens?: number;
99
+ /**
100
+ * CUMULATIVE cached-content tokens (`usageMetadata.cachedContentTokenCount`).
101
+ * NOTE: Gemini INCLUDES these in promptTokens — they are a subset, not an
102
+ * addition. `estimatedCost` subtracts them from promptTokens and re-bills them
103
+ * at the discounted cached rate. (ak-claude is the opposite: Anthropic's cache
104
+ * tokens are excluded from input_tokens and added on top.)
105
+ */
106
+ cachedTokens?: number;
107
+ /** CUMULATIVE total tokens across all retry attempts (includes thoughtsTokens) */
68
108
  totalTokens: number;
69
109
  /** Number of attempts (1 = first try success, 2+ = retries needed) */
70
110
  attempts: number;
@@ -76,6 +116,12 @@ export interface UsageData {
76
116
  groundingMetadata?: GroundingMetadata | null;
77
117
  /** Model lifecycle status from Google (e.g., 'DEPRECATED'). Surfaced from @google/genai 1.47+. */
78
118
  modelStatus?: string | null;
119
+ /**
120
+ * Estimated USD cost from MODEL_PRICING (input + cached input + output + thoughts).
121
+ * `null` means the model's pricing is UNKNOWN — it does NOT mean the call was free.
122
+ * Cache STORAGE (per token-hour) and image-output tokens are not modelled.
123
+ */
124
+ estimatedCost?: number | null;
79
125
  }
80
126
 
81
127
  export interface TransformationExample {
@@ -145,6 +191,12 @@ export interface BaseGeminiOptions {
145
191
  chatConfig?: Partial<ChatConfig>;
146
192
  /** Thinking features configuration */
147
193
  thinkingConfig?: ThinkingConfig | null;
194
+ /**
195
+ * Reasoning effort (cross-package option; same name in ak-claude). Maps to
196
+ * `thinkingConfig.thinkingLevel` and wins over an explicit thinkingLevel /
197
+ * thinkingBudget. Throws on an unrecognized level.
198
+ */
199
+ effort?: EffortLevel | null;
148
200
  /** Maximum output tokens (default: 50000, null removes limit) */
149
201
  maxOutputTokens?: number | null;
150
202
  /** Sampling temperature (shorthand for chatConfig.temperature; wins over chatConfig) */
@@ -592,6 +644,8 @@ export declare class BaseGemini {
592
644
  cachedContent: string | null;
593
645
  serviceTier: ServiceTier | null;
594
646
  includeServerSideToolInvocations: boolean;
647
+ /** Normalized `effort` option; null when unset. */
648
+ effort: EffortLevel | null;
595
649
 
596
650
  init(force?: boolean): Promise<void>;
597
651
  seed(examples?: TransformationExample[], opts?: SeedOptions): Promise<any[]>;
@@ -602,11 +656,14 @@ export declare class BaseGemini {
602
656
  estimateCost(nextPayload: Record<string, unknown> | string): Promise<{
603
657
  inputTokens: number;
604
658
  model: string;
605
- pricing: { input: number; output: number };
606
- estimatedInputCost: number;
659
+ pricing: ResolvedPricing | null;
660
+ estimatedInputCost: number | null;
607
661
  note: string;
608
662
  }>;
609
663
 
664
+ /** @internal Validates the `effort` option; throws on an unknown level. */
665
+ protected _normalizeEffort(effort?: string | null): EffortLevel | null;
666
+
610
667
  // Context Caching
611
668
  createCache(config?: CacheConfig): Promise<CachedContentInfo>;
612
669
  getCache(cacheName: string): Promise<CachedContentInfo>;
@@ -757,6 +814,32 @@ export declare class ImageGenerator extends BaseGemini {
757
814
 
758
815
  export declare function extractJSON(text: string): any;
759
816
  export declare function attemptJSONRecovery(text: string, maxAttempts?: number): any | null;
817
+ /** Validates a parsed value against a subset of JSON Schema. Returns error strings ([] means valid). */
818
+ export declare function validateSchema(data: any, schema: Record<string, any>, path?: string): string[];
819
+
820
+ /** Per-million-token pricing keyed by model id. */
821
+ export declare const MODEL_PRICING: Record<string, ModelPricing>;
822
+ /** ISO date the pricing table was last verified against Google's published rates. */
823
+ export declare const MODEL_PRICING_AS_OF: string;
824
+ /** Floating `-latest` alias → canonical model id, for pricing resolution. */
825
+ export declare const MODEL_ALIASES: Record<string, string>;
826
+ /** Valid values for the `effort` option. */
827
+ export declare const EFFORT_LEVELS: readonly EffortLevel[];
828
+ /** Resolves pricing for a model id (follows -latest aliases). `null` means UNKNOWN, not free. */
829
+ export declare function resolvePricing(modelId: string | null | undefined): ResolvedPricing | null;
830
+ /**
831
+ * Estimated USD cost from token counts. `null` means UNKNOWN, not free.
832
+ * `cachedTokens` is a SUBSET of `promptTokens` (Gemini includes them) and is
833
+ * subtracted before billing at the cached rate. Thinking tokens bill at the
834
+ * output rate. Tier is selected from `promptTokens` vs 200k.
835
+ */
836
+ export declare function computeCost(
837
+ modelId: string | null | undefined,
838
+ promptTokens: number,
839
+ responseTokens: number,
840
+ thoughtsTokens?: number,
841
+ cachedTokens?: number
842
+ ): number | null;
760
843
 
761
844
  declare const _default: {
762
845
  Transformer: typeof Transformer;