@genesislcap/foundation-ai 15.14.2 → 15.15.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.
@@ -1,5 +1,6 @@
1
1
  import { __awaiter } from "tslib";
2
2
  import { SUPPORTED_GEMINI_MODEL_IDS, } from '../types';
3
+ import { normalizeImageMime, stripImageDataUrl } from '../utils/image-mime';
3
4
  import { logger } from '../utils/logger';
4
5
  import { scaleTemperature } from '../utils/temperature';
5
6
  import { geminiTokenCost } from '../utils/token-cost';
@@ -71,16 +72,72 @@ const GEMINI_MODEL_WARNINGS = {
71
72
  * Only the flash tiers can actually be turned off. `gemini-2.5-pro` always thinks (its minimum
72
73
  * budget is 128; `0` is rejected), which is the same constraint Anthropic's Fable 5 has — so a
73
74
  * clamp here is the norm across providers, not a Gemini quirk. The 3.x tiers moved to a
74
- * `thinkingLevel` enum and do not take a numeric budget on every tier, so they are deliberately
75
- * left on their default rather than sent a value that may not apply: a clamp costs money, but a
76
- * guess costs a 400 on a turn the user is waiting for. Wire `thinkingLevel` through when the
77
- * per-tier support is confirmed.
75
+ * `thinkingLevel` enum instead of a numeric budget, and a graded {@link ChatThinkingPolicy} now
76
+ * maps onto it (GENC-1508) the "wire it through once per-tier support is confirmed" this
77
+ * comment used to defer. The 2.5 tiers keep the budget dialect and ignore graded levels rather
78
+ * than being sent an enum they do not take: a clamp costs money, but a guess costs a 400 on a
79
+ * turn the user is waiting for.
78
80
  */
79
81
  const GEMINI_THINKING_DISABLEABLE = [
80
82
  'gemini-2.5-flash',
81
83
  'gemini-2.5-flash-lite',
82
84
  ];
85
+ /** Tiers that accept `generationConfig.mediaResolution`. Gemini 3 only, like `thinkingLevel`. */
86
+ const GEMINI_MEDIA_RESOLUTION_TIERS = [
87
+ 'gemini-3.5-flash',
88
+ 'gemini-3.1-flash-lite',
89
+ 'gemini-3.1-pro-preview',
90
+ ];
91
+ /** The tiers that take `thinkingConfig.thinkingLevel` rather than a numeric `thinkingBudget`. */
92
+ const GEMINI_THINKING_LEVEL_TIERS = [
93
+ 'gemini-3.5-flash',
94
+ 'gemini-3.1-flash-lite',
95
+ 'gemini-3.1-pro-preview',
96
+ ];
97
+ /**
98
+ * Graded policy → Gemini `thinkingLevel`. Exhaustive `Record` for the same reason as the
99
+ * Anthropic table: a new level must be mapped here or the build fails.
100
+ *
101
+ * `'max'` clamps DOWN to `high` — Gemini's ladder stops there. See {@link ChatThinkingPolicy}.
102
+ */
103
+ const GEMINI_THINKING_LEVEL = {
104
+ minimal: 'minimal',
105
+ low: 'low',
106
+ medium: 'medium',
107
+ high: 'high',
108
+ max: 'high',
109
+ };
110
+ /**
111
+ * Tiers that accept only the coarse `low` / `high` pair rather than the full four-level enum.
112
+ *
113
+ * Currently EVERY `thinkingLevel` tier. The Gemini 3 line did not ship `minimal` and `medium`
114
+ * across every tier at once, and this file's own doctrine is that an unverified enum value is
115
+ * worse than a coarser one — "a clamp costs money, but a guess costs a 400 on a turn the user is
116
+ * waiting for". `low`/`high` are the pair confirmed across the line, so the fine levels fold onto
117
+ * their nearer neighbour here rather than being forwarded on faith to a tier that may 400 on
118
+ * them. Remove a tier from this list once its support for the full enum has been OBSERVED, not
119
+ * assumed — `GEMINI_THINKING_LEVEL`'s finer mapping then takes effect for it immediately.
120
+ */
121
+ const GEMINI_COARSE_LEVEL_TIERS = [
122
+ 'gemini-3.5-flash',
123
+ 'gemini-3.1-flash-lite',
124
+ 'gemini-3.1-pro-preview',
125
+ ];
126
+ const GEMINI_COARSE_LEVEL = {
127
+ minimal: 'low',
128
+ low: 'low',
129
+ medium: 'high',
130
+ high: 'high',
131
+ };
132
+ const isGradedLevel = (p) => p !== undefined && p !== 'auto' && p !== 'off';
83
133
  function geminiThinkingConfig(model, policy) {
134
+ if (isGradedLevel(policy) && GEMINI_THINKING_LEVEL_TIERS.includes(model)) {
135
+ const level = GEMINI_THINKING_LEVEL[policy];
136
+ return {
137
+ includeThoughts: true,
138
+ thinkingLevel: GEMINI_COARSE_LEVEL_TIERS.includes(model) ? GEMINI_COARSE_LEVEL[level] : level,
139
+ };
140
+ }
84
141
  if (policy === 'off' && GEMINI_THINKING_DISABLEABLE.includes(model)) {
85
142
  // No reasoning to return once there is none to bill for.
86
143
  return { includeThoughts: false, thinkingBudget: 0 };
@@ -105,6 +162,32 @@ const SKIP_SIGNATURE = 'skip_thought_signature_validator';
105
162
  * provider-neutral `providerMetadata` bag. Private to GeminiTransport.
106
163
  */
107
164
  const GEMINI_PROVIDER_KEY = 'gemini';
165
+ /**
166
+ * Split attachments into image parts and text parts.
167
+ *
168
+ * Images are emitted BEFORE the accompanying text part; text attachments keep their existing
169
+ * position AFTER it, so a turn carrying no images serialises byte-identically to before this
170
+ * existed. Branches on `kind`, never on `mimeType` — the composer's `readAsText` path can
171
+ * produce an image mime with text content (see `ChatImageAttachment`).
172
+ */
173
+ function splitAttachmentParts(atts) {
174
+ const imageParts = [];
175
+ const textParts = [];
176
+ for (const att of atts) {
177
+ if (att.kind === 'image') {
178
+ imageParts.push({
179
+ inlineData: {
180
+ mimeType: normalizeImageMime(att.mimeType),
181
+ data: stripImageDataUrl(att.data),
182
+ },
183
+ });
184
+ }
185
+ else {
186
+ textParts.push({ text: `[File: ${att.name}]\n${att.content}` });
187
+ }
188
+ }
189
+ return { imageParts, textParts };
190
+ }
108
191
  /**
109
192
  * Thrown when Gemini returns a `MALFORMED_FUNCTION_CALL` finish reason,
110
193
  * typically because the model tried to batch multiple tool calls using
@@ -130,23 +213,32 @@ export class MalformedFunctionCallError extends Error {
130
213
  */
131
214
  export class GeminiTransport {
132
215
  /**
133
- * Warn once when a requested policy is silently clamped. Turning thinking off is a *cost*
134
- * decision, so a caller who asked for it and kept paying for reasoning tokens needs to hear
135
- * about it but only once, not per turn.
216
+ * Warn once when a requested policy cannot be honoured on this model a *cost* decision the
217
+ * caller made that the wire silently reversed, so they hear about it, but only once, not per
218
+ * turn. Two shapes reach here (the Anthropic twin warns on the same two):
136
219
  *
137
- * Only `'off'` can actually be denied. `'auto'` is already what an omitted budget produces on
138
- * every model here dynamic thinking is the documented default so warning that it was
139
- * "ignored" would be false, and latching on it would spend the one warning the genuinely
140
- * unhonourable `'off'` needs.
220
+ * - `'off'` on a tier that can't disable thinking only the flash tiers accept a zero thinking
221
+ * budget; everything else keeps paying for reasoning it was told to stop. (`'auto'` is NOT this
222
+ * case: it is already what an omitted budget produces on every model here, so warning it was
223
+ * "ignored" would be false and would spend the one warning the genuine cases need.)
224
+ * - A graded depth (`minimal`…`max`) on a 2.5 tier — those speak the numeric-budget dialect,
225
+ * not the Gemini 3 `thinkingLevel` enum, so the level is dropped and the turn runs at the
226
+ * default posture. Without this, a caller asking for `high` on `gemini-2.5-pro` and quietly
227
+ * getting the default is left to wonder why.
141
228
  */
142
229
  warnIfThinkingUnclampable(policy) {
143
- if (policy !== 'off' ||
144
- GEMINI_THINKING_DISABLEABLE.includes(this.model) ||
145
- this.warnedThinkingClamped) {
230
+ if (this.warnedThinkingClamped) {
231
+ return;
232
+ }
233
+ if (policy === 'off' && !GEMINI_THINKING_DISABLEABLE.includes(this.model)) {
234
+ this.warnedThinkingClamped = true;
235
+ logger.warn(`GeminiTransport: thinkingPolicy 'off' ignored — ${this.model} runs its default thinking posture and takes no budget we can safely set. Reasoning tokens are still billed at the candidate rate; use a flash tier if you need them gone.`);
146
236
  return;
147
237
  }
148
- this.warnedThinkingClamped = true;
149
- logger.warn(`GeminiTransport: thinkingPolicy 'off' ignored — ${this.model} runs its default thinking posture and takes no budget we can safely set. Reasoning tokens are still billed at the candidate rate; use a flash tier if you need them gone.`);
238
+ if (isGradedLevel(policy) && !GEMINI_THINKING_LEVEL_TIERS.includes(this.model)) {
239
+ this.warnedThinkingClamped = true;
240
+ logger.warn(`GeminiTransport: thinkingPolicy '${policy}' ignored — ${this.model} takes a numeric thinking budget, not the graded 'thinkingLevel' the Gemini 3 tiers use, so this turn runs at its default reasoning posture. Use a Gemini 3 tier to set reasoning depth.`);
241
+ }
150
242
  }
151
243
  constructor(config = {}) {
152
244
  var _a, _b, _c;
@@ -218,7 +310,7 @@ export class GeminiTransport {
218
310
  // ── ChatTransport (multi-turn chat) ────────────────────────────────────
219
311
  sendChatMessage(history, userMessage, options) {
220
312
  return __awaiter(this, void 0, void 0, function* () {
221
- var _a, _b;
313
+ var _a, _b, _c;
222
314
  // NOTE: `options.cachePolicy` is intentionally NOT read here. Gemini 2.5+ does *implicit*
223
315
  // caching automatically and always-on — it caches as much of the stable request prefix as it
224
316
  // can on every call, regardless of the policy's `scope` (including `'default'`). We issue no
@@ -264,6 +356,31 @@ export class GeminiTransport {
264
356
  // supports one; Gemini 2.5 Pro always thinks regardless. See `geminiThinkingConfig`.
265
357
  this.warnIfThinkingUnclampable(options === null || options === void 0 ? void 0 : options.thinkingPolicy);
266
358
  const generationConfig = { thinkingConfig: geminiThinkingConfig(this.model, options === null || options === void 0 ? void 0 : options.thinkingPolicy) };
359
+ // Per-turn output ceiling; see ChatRequestOptions.maxTokens for why the instance default
360
+ // is not enough. Omitted when unset, so an unchanged caller sends an unchanged body.
361
+ if ((options === null || options === void 0 ? void 0 : options.maxTokens) != null) {
362
+ generationConfig.maxOutputTokens = options.maxTokens;
363
+ }
364
+ // Image fidelity, from the FIRST image attachment that states a preference. Gemini bills
365
+ // image tokens by resolution, so this is a real cost lever — and it is the one thing on
366
+ // ChatImageAttachment that Anthropic has no equivalent for, where the only lever is the
367
+ // pixels you send. Deliberately not synthesised into an Anthropic knob.
368
+ const resolutionHint = [
369
+ ...((_b = options === null || options === void 0 ? void 0 : options.attachments) !== null && _b !== void 0 ? _b : []),
370
+ ...history.flatMap((m) => { var _a; return (_a = m.attachments) !== null && _a !== void 0 ? _a : []; }),
371
+ // Tool-result images too: `ChatToolResult.attachments` is typed ChatImageAttachment[], so
372
+ // `detail` is offerable there, and a render handed back by a tool is the whole point of
373
+ // the tool-result image path. Omitting this source made the field silently inert there.
374
+ ...history.flatMap((m) => { var _a, _b; return (_b = (_a = m.toolResult) === null || _a === void 0 ? void 0 : _a.attachments) !== null && _b !== void 0 ? _b : []; }),
375
+ ].find((att) => att.kind === 'image' && att.detail);
376
+ // Gated by tier for the same reason thinkingLevel is: a clamp costs money, a guess costs a
377
+ // 400. Note it is per-REQUEST, so the first stated preference governs every image in the
378
+ // turn — there is no per-image control on the wire.
379
+ if ((resolutionHint === null || resolutionHint === void 0 ? void 0 : resolutionHint.kind) === 'image' &&
380
+ resolutionHint.detail &&
381
+ GEMINI_MEDIA_RESOLUTION_TIERS.includes(this.model)) {
382
+ generationConfig.mediaResolution = `MEDIA_RESOLUTION_${resolutionHint.detail.toUpperCase()}`;
383
+ }
267
384
  // Normalized [0,1] temperature → Gemini's native range, anchored so 0.5
268
385
  // maps to its default (native 1) and 1 to its ceiling (native 2).
269
386
  if ((options === null || options === void 0 ? void 0 : options.temperature) != null) {
@@ -274,7 +391,7 @@ export class GeminiTransport {
274
391
  }
275
392
  // Names of the tools offered this turn — used to validate a repaired
276
393
  // malformed call against the real tool surface before accepting it.
277
- const offeredToolNames = new Set(((_b = options === null || options === void 0 ? void 0 : options.tools) !== null && _b !== void 0 ? _b : []).map((t) => t.name));
394
+ const offeredToolNames = new Set(((_c = options === null || options === void 0 ? void 0 : options.tools) !== null && _c !== void 0 ? _c : []).map((t) => t.name));
278
395
  // Structured output (native JSON mode). Gemini 3 can combine it with function calling;
279
396
  // Gemini 2.x cannot, so on 2.x apply the schema only on a pure structured turn (no tools) and
280
397
  // otherwise drop it (the caller keeps a prompt-instruction + validator fallback). Passed as a
@@ -289,7 +406,7 @@ export class GeminiTransport {
289
406
  generationConfig }, (applyResponseSchema
290
407
  ? { responseSchema: toGeminiSchema(options.responseSchema) }
291
408
  : {})), options === null || options === void 0 ? void 0 : options.signal);
292
- return this.fromGeminiResponse(response, offeredToolNames);
409
+ return this.fromGeminiResponse(response, offeredToolNames, generationConfig.maxOutputTokens);
293
410
  });
294
411
  }
295
412
  /**
@@ -321,7 +438,7 @@ export class GeminiTransport {
321
438
  return costUsd;
322
439
  }
323
440
  toGeminiContents(history, userMessage, attachments) {
324
- var _a, _b, _c;
441
+ var _a, _b, _c, _d;
325
442
  const contents = [];
326
443
  // Gemini requires functionResponse.name to match the original functionCall.name.
327
444
  // Our internal IDs are UUIDs, so build a lookup from ID → function name as we go.
@@ -350,8 +467,22 @@ export class GeminiTransport {
350
467
  },
351
468
  ],
352
469
  });
470
+ // Images ride in their OWN following user content rather than inside
471
+ // `functionResponse.response`. That field is a protobuf Struct and per-tier support for
472
+ // media nested in it is unverified; a separate content is plainly legal and costs an
473
+ // extra content entry. Only emitted when the tool actually returned images, so a
474
+ // result without them is byte-identical to before.
475
+ const resultImages = (_b = msg.toolResult.attachments) !== null && _b !== void 0 ? _b : [];
476
+ if (resultImages.length) {
477
+ contents.push({
478
+ role: 'user',
479
+ parts: resultImages.map((att) => ({
480
+ inlineData: { mimeType: normalizeImageMime(att.mimeType), data: att.data },
481
+ })),
482
+ });
483
+ }
353
484
  }
354
- else if ((_b = msg.toolCalls) === null || _b === void 0 ? void 0 : _b.length) {
485
+ else if ((_c = msg.toolCalls) === null || _c === void 0 ? void 0 : _c.length) {
355
486
  for (const tc of msg.toolCalls) {
356
487
  toolCallNameById.set(tc.id, tc.name);
357
488
  }
@@ -378,21 +509,25 @@ export class GeminiTransport {
378
509
  }),
379
510
  });
380
511
  }
381
- else if (role === 'user' && ((_c = msg.attachments) === null || _c === void 0 ? void 0 : _c.length)) {
382
- const msgAttachmentParts = msg.attachments.map((att) => ({
383
- text: `[File: ${att.name}]\n${att.content}`,
384
- }));
385
- contents.push({ role: 'user', parts: [{ text: msg.content }, ...msgAttachmentParts] });
512
+ else if (role === 'user' && ((_d = msg.attachments) === null || _d === void 0 ? void 0 : _d.length)) {
513
+ const { imageParts, textParts } = splitAttachmentParts(msg.attachments);
514
+ // Empty text parts are dropped, not sent — see the Anthropic twin. An image-only
515
+ // message has no caption to carry.
516
+ contents.push({
517
+ role: 'user',
518
+ parts: [...imageParts, ...(msg.content ? [{ text: msg.content }] : []), ...textParts],
519
+ });
386
520
  }
387
521
  else {
388
522
  contents.push({ role, parts: [{ text: msg.content }] });
389
523
  }
390
524
  }
391
- const attachmentParts = (attachments !== null && attachments !== void 0 ? attachments : []).map((att) => ({
392
- text: `[File: ${att.name}]\n${att.content}`,
393
- }));
394
- if (userMessage || attachmentParts.length) {
395
- contents.push({ role: 'user', parts: [{ text: userMessage }, ...attachmentParts] });
525
+ const { imageParts, textParts } = splitAttachmentParts(attachments !== null && attachments !== void 0 ? attachments : []);
526
+ if (userMessage || imageParts.length || textParts.length) {
527
+ contents.push({
528
+ role: 'user',
529
+ parts: [...imageParts, ...(userMessage ? [{ text: userMessage }] : []), ...textParts],
530
+ });
396
531
  }
397
532
  return contents;
398
533
  }
@@ -431,7 +566,13 @@ export class GeminiTransport {
431
566
  cost,
432
567
  };
433
568
  }
434
- fromGeminiResponse(response, offeredToolNames = new Set()) {
569
+ fromGeminiResponse(response, offeredToolNames = new Set(),
570
+ /**
571
+ * The per-turn cap actually sent, when one was. Left `undefined` on the default path so a
572
+ * truncation still reports "the model's output-token limit" — which is the truth when no
573
+ * `maxOutputTokens` was set, and a lie the moment one is.
574
+ */
575
+ effectiveMaxTokens) {
435
576
  var _a, _b, _c, _d;
436
577
  const { inputTokens, outputTokens, thoughtsTokens, cacheReadTokens, cost } = this.usageFromGemini(response.usageMetadata);
437
578
  const candidates = response === null || response === void 0 ? void 0 : response.candidates;
@@ -502,7 +643,7 @@ export class GeminiTransport {
502
643
  textParts: textParts.length,
503
644
  });
504
645
  }
505
- this.guardMaxTokensTruncation(finishReason, toolCalls, narration, outputTokens);
646
+ this.guardMaxTokensTruncation(finishReason, toolCalls, narration, outputTokens, effectiveMaxTokens);
506
647
  if (inputTokens != null)
507
648
  base.inputTokens = inputTokens;
508
649
  if (outputTokens != null)
@@ -543,14 +684,16 @@ export class GeminiTransport {
543
684
  * `responseMeta.finishReason`, for the caller to decide. Usage/cost is logged
544
685
  * before this check runs, so the spent tokens stay accounted for.
545
686
  */
546
- guardMaxTokensTruncation(finishReason, toolCalls, narration, outputTokens) {
687
+ guardMaxTokensTruncation(finishReason, toolCalls, narration, outputTokens,
688
+ /** The per-turn cap sent, when one was — so the error names the cap it actually hit. */
689
+ effectiveMaxTokens) {
547
690
  if (finishReason !== 'MAX_TOKENS')
548
691
  return;
549
692
  // A truncated text-only answer is partial but legible — keep it (the driver's
550
693
  // emptiness rule is `!content.trim()`, so whitespace-only does NOT count).
551
694
  if (toolCalls.length === 0 && narration.trim() !== '')
552
695
  return;
553
- throw new ResponseTruncatedError(this.model, undefined, outputTokens, toolCalls.map((tc) => tc.name));
696
+ throw new ResponseTruncatedError(this.model, effectiveMaxTokens, outputTokens, toolCalls.map((tc) => tc.name));
554
697
  }
555
698
  /**
556
699
  * Log the full shape of a blank or non-STOP response so its cause is legible
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Normalise an image MIME type to the spelling both vendors accept.
3
+ *
4
+ * Only `image/jpg` → `image/jpeg` today. That is a rename, not a guess: it is the same format
5
+ * under a label neither API recognises — Anthropic documents `image/jpeg`, `image/png`,
6
+ * `image/gif` and `image/webp`, and rejects `image/jpg` with a 400. The alias is common enough
7
+ * to be worth absorbing (anything deriving a type from a `.jpg` extension produces it), and the
8
+ * cost of not absorbing it is a failed turn the user is waiting on.
9
+ *
10
+ * Deliberately NOT a validator. A genuinely unsupported type (`image/bmp`, `image/svg+xml`)
11
+ * passes through untouched so the vendor's own error names it, rather than this function
12
+ * inventing a substitute or throwing on input a future model tier might accept.
13
+ *
14
+ * Lives here rather than inline in each transport because it is one rule that must hold for
15
+ * both; copied into two files it drifts the first time only one is updated.
16
+ */
17
+ export function normalizeImageMime(mimeType) {
18
+ return mimeType === 'image/jpg' ? 'image/jpeg' : mimeType;
19
+ }
20
+ /**
21
+ * Strip a leading `data:` URL prefix from a base64 image payload, returning the bare bytes both
22
+ * vendors' image blocks expect.
23
+ *
24
+ * {@link ChatImageAttachment.data} is documented as bare base64, but the natural browser way to
25
+ * produce it — `FileReader.readAsDataURL` — yields `data:image/png;base64,…`, and handed in
26
+ * verbatim that prefix is a 400 on both APIs. This absorbs that one mistake: a
27
+ * `data:<mime>;base64,` preamble (any or no mime) is removed; anything already bare passes
28
+ * through untouched. Anchored and `;base64,`-terminated so it only ever removes a real data-URL
29
+ * header, never a slice of the payload (base64 contains no comma).
30
+ *
31
+ * Like {@link normalizeImageMime} it is a normaliser, not a validator — it does not check that
32
+ * the remainder is valid base64, and lives here for the same reason: one rule both transports
33
+ * must share.
34
+ */
35
+ export function stripImageDataUrl(data) {
36
+ return data.replace(/^data:[^,]*;base64,/, '');
37
+ }