@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.
- package/dist/dts/index.d.ts +1 -1
- package/dist/dts/index.d.ts.map +1 -1
- package/dist/dts/transports/anthropic-transport.d.ts +9 -0
- package/dist/dts/transports/anthropic-transport.d.ts.map +1 -1
- package/dist/dts/transports/gemini-transport.d.ts +11 -7
- package/dist/dts/transports/gemini-transport.d.ts.map +1 -1
- package/dist/dts/types/chat.types.d.ts +103 -5
- package/dist/dts/types/chat.types.d.ts.map +1 -1
- package/dist/dts/utils/image-mime.d.ts +34 -0
- package/dist/dts/utils/image-mime.d.ts.map +1 -0
- package/dist/esm/transports/anthropic-transport.js +178 -32
- package/dist/esm/transports/gemini-transport.js +178 -35
- package/dist/esm/utils/image-mime.js +37 -0
- package/dist/foundation-ai.api.json +405 -90
- package/dist/foundation-ai.d.ts +129 -15
- package/package.json +11 -11
|
@@ -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
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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
|
|
134
|
-
*
|
|
135
|
-
*
|
|
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
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
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 (
|
|
144
|
-
|
|
145
|
-
|
|
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.
|
|
149
|
-
|
|
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(((
|
|
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 ((
|
|
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' && ((
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
contents.push({
|
|
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
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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,
|
|
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
|
+
}
|