@ai-sdk/google 3.0.67 → 3.0.68
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 +6 -0
- package/dist/index.d.mts +90 -1
- package/dist/index.d.ts +90 -1
- package/dist/index.js +2383 -49
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2353 -1
- package/dist/index.mjs.map +1 -1
- package/docs/15-google-generative-ai.mdx +396 -0
- package/package.json +3 -3
- package/src/google-provider.ts +34 -0
- package/src/index.ts +6 -0
- package/src/interactions/build-google-interactions-stream-transform.ts +711 -0
- package/src/interactions/convert-google-interactions-usage.ts +47 -0
- package/src/interactions/convert-to-google-interactions-input.ts +630 -0
- package/src/interactions/extract-google-interactions-sources.ts +245 -0
- package/src/interactions/google-interactions-agent.ts +16 -0
- package/src/interactions/google-interactions-api.ts +466 -0
- package/src/interactions/google-interactions-language-model-options.ts +136 -0
- package/src/interactions/google-interactions-language-model.ts +609 -0
- package/src/interactions/google-interactions-prompt.ts +457 -0
- package/src/interactions/google-interactions-provider-metadata.ts +23 -0
- package/src/interactions/map-google-interactions-finish-reason.ts +33 -0
- package/src/interactions/parse-google-interactions-outputs.ts +257 -0
- package/src/interactions/poll-google-interactions.ts +110 -0
- package/src/interactions/prepare-google-interactions-tools.ts +245 -0
- package/src/interactions/synthesize-google-interactions-agent-stream.ts +185 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { JSONObject, LanguageModelV3Usage } from '@ai-sdk/provider';
|
|
2
|
+
import type { GoogleInteractionsUsage } from './google-interactions-api';
|
|
3
|
+
|
|
4
|
+
export function convertGoogleInteractionsUsage(
|
|
5
|
+
usage: GoogleInteractionsUsage | undefined | null,
|
|
6
|
+
): LanguageModelV3Usage {
|
|
7
|
+
if (usage == null) {
|
|
8
|
+
return {
|
|
9
|
+
inputTokens: {
|
|
10
|
+
total: undefined,
|
|
11
|
+
noCache: undefined,
|
|
12
|
+
cacheRead: undefined,
|
|
13
|
+
cacheWrite: undefined,
|
|
14
|
+
},
|
|
15
|
+
outputTokens: {
|
|
16
|
+
total: undefined,
|
|
17
|
+
text: undefined,
|
|
18
|
+
reasoning: undefined,
|
|
19
|
+
},
|
|
20
|
+
raw: undefined,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const totalInput = usage.total_input_tokens ?? 0;
|
|
25
|
+
const totalOutput = usage.total_output_tokens ?? 0;
|
|
26
|
+
const totalThought = usage.total_thought_tokens ?? 0;
|
|
27
|
+
const totalCached = usage.total_cached_tokens ?? 0;
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
inputTokens: {
|
|
31
|
+
total: usage.total_input_tokens ?? undefined,
|
|
32
|
+
noCache:
|
|
33
|
+
usage.total_input_tokens == null ? undefined : totalInput - totalCached,
|
|
34
|
+
cacheRead: usage.total_cached_tokens ?? undefined,
|
|
35
|
+
cacheWrite: undefined,
|
|
36
|
+
},
|
|
37
|
+
outputTokens: {
|
|
38
|
+
total:
|
|
39
|
+
usage.total_output_tokens == null && usage.total_thought_tokens == null
|
|
40
|
+
? undefined
|
|
41
|
+
: totalOutput + totalThought,
|
|
42
|
+
text: usage.total_output_tokens ?? undefined,
|
|
43
|
+
reasoning: usage.total_thought_tokens ?? undefined,
|
|
44
|
+
},
|
|
45
|
+
raw: usage as unknown as JSONObject,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
LanguageModelV3FilePart,
|
|
3
|
+
LanguageModelV3Prompt,
|
|
4
|
+
LanguageModelV3ToolResultOutput,
|
|
5
|
+
SharedV3Warning,
|
|
6
|
+
} from '@ai-sdk/provider';
|
|
7
|
+
import { convertToBase64 } from '@ai-sdk/provider-utils';
|
|
8
|
+
import type {
|
|
9
|
+
GoogleInteractionsContent,
|
|
10
|
+
GoogleInteractionsFunctionResultContent,
|
|
11
|
+
GoogleInteractionsImageContent,
|
|
12
|
+
GoogleInteractionsInput,
|
|
13
|
+
GoogleInteractionsTextContent,
|
|
14
|
+
GoogleInteractionsTurn,
|
|
15
|
+
} from './google-interactions-prompt';
|
|
16
|
+
|
|
17
|
+
function getTopLevelMediaType(mediaType: string): string {
|
|
18
|
+
const slashIndex = mediaType.indexOf('/');
|
|
19
|
+
return slashIndex === -1 ? mediaType : mediaType.substring(0, slashIndex);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isFullMediaType(mediaType: string): boolean {
|
|
23
|
+
const slashIndex = mediaType.indexOf('/');
|
|
24
|
+
if (slashIndex === -1) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
const subtype = mediaType.substring(slashIndex + 1);
|
|
28
|
+
return subtype.length > 0 && subtype !== '*';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type GoogleInteractionsMediaResolution =
|
|
32
|
+
| 'low'
|
|
33
|
+
| 'medium'
|
|
34
|
+
| 'high'
|
|
35
|
+
| 'ultra_high';
|
|
36
|
+
|
|
37
|
+
export type ConvertToGoogleInteractionsInputResult = {
|
|
38
|
+
input: GoogleInteractionsInput;
|
|
39
|
+
systemInstruction: string | undefined;
|
|
40
|
+
warnings: Array<SharedV3Warning>;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Converts an AI SDK `LanguageModelV3Prompt` into the Gemini Interactions
|
|
45
|
+
* request shape (`{ input, system_instruction }`).
|
|
46
|
+
*
|
|
47
|
+
* Handles text parts, file parts (image / audio / document / video, all four
|
|
48
|
+
* `data.type` shapes), tool-call/tool-result round-tripping, per-block
|
|
49
|
+
* `signature` round-tripping (`thought.signature`, `function_call.signature`),
|
|
50
|
+
* and statefulness compaction (drop assistant/tool turns whose
|
|
51
|
+
* `providerOptions.google.interactionId === previousInteractionId`).
|
|
52
|
+
*
|
|
53
|
+
* NOTE on PRD Open Q3 (empty-text-with-signature carrier hack from the
|
|
54
|
+
* `:generateContent` provider): unnecessary on Interactions because
|
|
55
|
+
* `thought.signature` and `function_call.signature` are explicit fields on
|
|
56
|
+
* the wire (verified against `googleapis/js-genai`
|
|
57
|
+
* `src/interactions/resources/interactions.ts` `ThoughtContent` /
|
|
58
|
+
* `FunctionCallContent`). When an input reasoning part has empty text + a
|
|
59
|
+
* signature, the converter emits a `thought` block with `signature` and an
|
|
60
|
+
* omitted `summary` — no synthetic empty-text carrier needed.
|
|
61
|
+
*/
|
|
62
|
+
export function convertToGoogleInteractionsInput({
|
|
63
|
+
prompt,
|
|
64
|
+
previousInteractionId,
|
|
65
|
+
store,
|
|
66
|
+
mediaResolution,
|
|
67
|
+
}: {
|
|
68
|
+
prompt: LanguageModelV3Prompt;
|
|
69
|
+
previousInteractionId?: string;
|
|
70
|
+
store?: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Per-block media resolution applied to every image / video input block
|
|
73
|
+
* (the Interactions wire format places `resolution` on the block, not at
|
|
74
|
+
* the top level). See js-genai
|
|
75
|
+
* `src/interactions/resources/interactions.ts` `ImageContent.resolution`
|
|
76
|
+
* and `VideoContent.resolution`.
|
|
77
|
+
*/
|
|
78
|
+
mediaResolution?: GoogleInteractionsMediaResolution;
|
|
79
|
+
}): ConvertToGoogleInteractionsInputResult {
|
|
80
|
+
const warnings: Array<SharedV3Warning> = [];
|
|
81
|
+
|
|
82
|
+
/*
|
|
83
|
+
* Behavior matrix per PRD § "Public-API contracts" → "Configurable behavior
|
|
84
|
+
* matrix":
|
|
85
|
+
*
|
|
86
|
+
* - `previousInteractionId` set + `store !== false` → compact history (drop
|
|
87
|
+
* assistant/tool turns whose `providerMetadata.google.interactionId`
|
|
88
|
+
* matches), emit `previous_interaction_id`.
|
|
89
|
+
* - `previousInteractionId` set + `store === false` → emit warning
|
|
90
|
+
* (incoherent combo), still send full history (NO compaction).
|
|
91
|
+
* - `store === false`, no `previousInteractionId` → no compaction.
|
|
92
|
+
* - Default → no compaction.
|
|
93
|
+
*
|
|
94
|
+
* The actual `previous_interaction_id` / `store` body fields are emitted in
|
|
95
|
+
* the language model's `getArgs`; this converter only handles the history
|
|
96
|
+
* shape and the warning.
|
|
97
|
+
*/
|
|
98
|
+
const incoherentCombo = previousInteractionId != null && store === false;
|
|
99
|
+
const shouldCompact = previousInteractionId != null && store !== false;
|
|
100
|
+
if (incoherentCombo) {
|
|
101
|
+
warnings.push({
|
|
102
|
+
type: 'other',
|
|
103
|
+
message:
|
|
104
|
+
'google.interactions: providerOptions.google.previousInteractionId was set together with store: false. These are incoherent (the prior interaction cannot be referenced when nothing was stored on the server); the full history will be sent and previous_interaction_id will still be emitted.',
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const compactedPrompt = shouldCompact
|
|
109
|
+
? compactPromptForPreviousInteraction({
|
|
110
|
+
prompt,
|
|
111
|
+
previousInteractionId,
|
|
112
|
+
})
|
|
113
|
+
: prompt;
|
|
114
|
+
|
|
115
|
+
const systemTexts: Array<string> = [];
|
|
116
|
+
const turns: Array<GoogleInteractionsTurn> = [];
|
|
117
|
+
|
|
118
|
+
for (const message of compactedPrompt) {
|
|
119
|
+
switch (message.role) {
|
|
120
|
+
case 'system': {
|
|
121
|
+
systemTexts.push(message.content);
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
case 'user': {
|
|
125
|
+
const content: Array<GoogleInteractionsContent> = [];
|
|
126
|
+
for (const part of message.content) {
|
|
127
|
+
if (part.type === 'text') {
|
|
128
|
+
const block: GoogleInteractionsTextContent = {
|
|
129
|
+
type: 'text',
|
|
130
|
+
text: part.text,
|
|
131
|
+
};
|
|
132
|
+
content.push(block);
|
|
133
|
+
} else if (part.type === 'file') {
|
|
134
|
+
const fileBlock = convertFilePartToContent({
|
|
135
|
+
part,
|
|
136
|
+
warnings,
|
|
137
|
+
mediaResolution,
|
|
138
|
+
});
|
|
139
|
+
if (fileBlock != null) {
|
|
140
|
+
content.push(fileBlock);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const merged = mergeAdjacentTextContent(content);
|
|
145
|
+
if (merged.length > 0) {
|
|
146
|
+
turns.push({ role: 'user', content: merged });
|
|
147
|
+
}
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
case 'assistant': {
|
|
151
|
+
const content: Array<GoogleInteractionsContent> = [];
|
|
152
|
+
for (const part of message.content) {
|
|
153
|
+
if (part.type === 'text') {
|
|
154
|
+
content.push({ type: 'text', text: part.text });
|
|
155
|
+
} else if (part.type === 'reasoning') {
|
|
156
|
+
const signature = part.providerOptions?.google?.signature as
|
|
157
|
+
| string
|
|
158
|
+
| undefined;
|
|
159
|
+
content.push({
|
|
160
|
+
type: 'thought',
|
|
161
|
+
...(signature != null ? { signature } : {}),
|
|
162
|
+
summary:
|
|
163
|
+
part.text.length > 0
|
|
164
|
+
? [{ type: 'text', text: part.text }]
|
|
165
|
+
: undefined,
|
|
166
|
+
});
|
|
167
|
+
} else if (part.type === 'tool-call') {
|
|
168
|
+
const signature = part.providerOptions?.google?.signature as
|
|
169
|
+
| string
|
|
170
|
+
| undefined;
|
|
171
|
+
const args =
|
|
172
|
+
typeof part.input === 'string'
|
|
173
|
+
? safeParseToolArgs(part.input)
|
|
174
|
+
: ((part.input ?? {}) as Record<string, unknown>);
|
|
175
|
+
content.push({
|
|
176
|
+
type: 'function_call',
|
|
177
|
+
id: part.toolCallId,
|
|
178
|
+
name: part.toolName,
|
|
179
|
+
arguments: args,
|
|
180
|
+
...(signature != null ? { signature } : {}),
|
|
181
|
+
});
|
|
182
|
+
} else {
|
|
183
|
+
warnings.push({
|
|
184
|
+
type: 'other',
|
|
185
|
+
message: `google.interactions: unsupported assistant content part type "${part.type}"; part dropped.`,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (content.length > 0) {
|
|
190
|
+
turns.push({ role: 'model', content });
|
|
191
|
+
}
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
case 'tool': {
|
|
195
|
+
/*
|
|
196
|
+
* Tool-result messages are emitted as a `user` turn whose content
|
|
197
|
+
* holds one `function_result` block per tool-result part. Wire shape
|
|
198
|
+
* (verified against `googleapis/js-genai`
|
|
199
|
+
* `samples/interactions_function_calling_client_state.ts` and
|
|
200
|
+
* `src/interactions/resources/interactions.ts` `FunctionResultContent`
|
|
201
|
+
* around line 979 — RESOLVES PRD Open Q2):
|
|
202
|
+
*
|
|
203
|
+
* {
|
|
204
|
+
* role: 'user',
|
|
205
|
+
* content: [
|
|
206
|
+
* {
|
|
207
|
+
* type: 'function_result',
|
|
208
|
+
* call_id: <id from the matching function_call block>,
|
|
209
|
+
* name: <tool name>,
|
|
210
|
+
* result: <string | unknown | Array<TextContent|ImageContent>>,
|
|
211
|
+
* is_error?: boolean,
|
|
212
|
+
* signature?: string,
|
|
213
|
+
* },
|
|
214
|
+
* ],
|
|
215
|
+
* }
|
|
216
|
+
*
|
|
217
|
+
* The `result` field is a discriminated union: a plain string for
|
|
218
|
+
* text-only results, or an array of `text` / `image` content blocks
|
|
219
|
+
* for mixed text/image results. Our converter takes the AI SDK
|
|
220
|
+
* canonical `LanguageModelV3ToolResultOutput` and maps:
|
|
221
|
+
* - `{ type: 'text', value }` → `result: <string>`
|
|
222
|
+
* - `{ type: 'json', value }` → `result: <stringified JSON>`
|
|
223
|
+
* - `{ type: 'error-text', value }` → `result: <string>` + `is_error: true`
|
|
224
|
+
* - `{ type: 'error-json', value }` → `result: <stringified JSON>` + `is_error: true`
|
|
225
|
+
* - `{ type: 'execution-denied', reason }` → `result: <reason>` + `is_error: true`
|
|
226
|
+
* - `{ type: 'content', value: [...] }` → `result: Array<text|image>`
|
|
227
|
+
* where each AI SDK `file` part with `mediaType: image/*` becomes
|
|
228
|
+
* an Interactions `image` block (file-data path matches
|
|
229
|
+
* `convertFilePartToContent` for top-level user images), and `text`
|
|
230
|
+
* parts pass through. Non-image file parts fall back to a warning
|
|
231
|
+
* because `FunctionResultContent.result` only accepts text/image.
|
|
232
|
+
*/
|
|
233
|
+
const content: Array<GoogleInteractionsContent> = [];
|
|
234
|
+
for (const part of message.content) {
|
|
235
|
+
if (part.type !== 'tool-result') {
|
|
236
|
+
warnings.push({
|
|
237
|
+
type: 'other',
|
|
238
|
+
message: `google.interactions: unsupported tool message part type "${part.type}"; part dropped.`,
|
|
239
|
+
});
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const block = convertToolResultPart({
|
|
243
|
+
toolCallId: part.toolCallId,
|
|
244
|
+
toolName: part.toolName,
|
|
245
|
+
output: part.output,
|
|
246
|
+
signature: part.providerOptions?.google?.signature as
|
|
247
|
+
| string
|
|
248
|
+
| undefined,
|
|
249
|
+
warnings,
|
|
250
|
+
});
|
|
251
|
+
content.push(block);
|
|
252
|
+
}
|
|
253
|
+
if (content.length > 0) {
|
|
254
|
+
turns.push({ role: 'user', content });
|
|
255
|
+
}
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const systemInstruction =
|
|
262
|
+
systemTexts.length > 0 ? systemTexts.join('\n\n') : undefined;
|
|
263
|
+
|
|
264
|
+
let input: GoogleInteractionsInput;
|
|
265
|
+
if (turns.length === 0) {
|
|
266
|
+
input = '';
|
|
267
|
+
} else if (
|
|
268
|
+
turns.length === 1 &&
|
|
269
|
+
turns[0].role === 'user' &&
|
|
270
|
+
Array.isArray(turns[0].content)
|
|
271
|
+
) {
|
|
272
|
+
/*
|
|
273
|
+
* Single-turn user prompt: send the bare `Array<Content>` shape per the
|
|
274
|
+
* Interactions API's preferred single-turn format.
|
|
275
|
+
*/
|
|
276
|
+
input = turns[0].content;
|
|
277
|
+
} else {
|
|
278
|
+
input = turns;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return { input, systemInstruction, warnings };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Maps a single AI SDK `LanguageModelV3FilePart` to a Gemini Interactions
|
|
286
|
+
* content block (`image` / `audio` / `document` / `video`).
|
|
287
|
+
*
|
|
288
|
+
* Rules for the V3 `data` shapes:
|
|
289
|
+
* - `Uint8Array` / `string` (base64) → block with inline `data` (base64) +
|
|
290
|
+
* `mime_type`.
|
|
291
|
+
* - `URL` → block with `uri` set to the URL string verbatim. Files API URIs
|
|
292
|
+
* (e.g. `https://generativelanguage.googleapis.com/v1beta/files/<id>`) and
|
|
293
|
+
* YouTube URLs are passed through the same way.
|
|
294
|
+
*/
|
|
295
|
+
function convertFilePartToContent({
|
|
296
|
+
part,
|
|
297
|
+
warnings,
|
|
298
|
+
mediaResolution,
|
|
299
|
+
}: {
|
|
300
|
+
part: LanguageModelV3FilePart;
|
|
301
|
+
warnings: Array<SharedV3Warning>;
|
|
302
|
+
mediaResolution?: GoogleInteractionsMediaResolution;
|
|
303
|
+
}): GoogleInteractionsContent | undefined {
|
|
304
|
+
const topLevel = getTopLevelMediaType(part.mediaType);
|
|
305
|
+
let kind: 'image' | 'audio' | 'video' | 'document' | undefined;
|
|
306
|
+
switch (topLevel) {
|
|
307
|
+
case 'image':
|
|
308
|
+
kind = 'image';
|
|
309
|
+
break;
|
|
310
|
+
case 'audio':
|
|
311
|
+
kind = 'audio';
|
|
312
|
+
break;
|
|
313
|
+
case 'video':
|
|
314
|
+
kind = 'video';
|
|
315
|
+
break;
|
|
316
|
+
case 'application':
|
|
317
|
+
kind = 'document';
|
|
318
|
+
break;
|
|
319
|
+
default:
|
|
320
|
+
kind = undefined;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (kind == null) {
|
|
324
|
+
warnings.push({
|
|
325
|
+
type: 'other',
|
|
326
|
+
message: `google.interactions: unsupported file media type "${part.mediaType}"; part dropped.`,
|
|
327
|
+
});
|
|
328
|
+
return undefined;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/*
|
|
332
|
+
* `resolution` is per-block on the wire (`ImageContent.resolution`,
|
|
333
|
+
* `VideoContent.resolution`); only image and video carry it (see
|
|
334
|
+
* `googleapis/js-genai` `src/interactions/resources/interactions.ts`).
|
|
335
|
+
* Audio / document blocks ignore the option silently.
|
|
336
|
+
*/
|
|
337
|
+
const resolutionField =
|
|
338
|
+
mediaResolution != null && (kind === 'image' || kind === 'video')
|
|
339
|
+
? { resolution: mediaResolution }
|
|
340
|
+
: {};
|
|
341
|
+
|
|
342
|
+
if (part.data instanceof URL) {
|
|
343
|
+
return {
|
|
344
|
+
type: kind,
|
|
345
|
+
uri: part.data.toString(),
|
|
346
|
+
...(isFullMediaType(part.mediaType) ? { mime_type: part.mediaType } : {}),
|
|
347
|
+
...resolutionField,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (!isFullMediaType(part.mediaType)) {
|
|
352
|
+
warnings.push({
|
|
353
|
+
type: 'other',
|
|
354
|
+
message: `google.interactions: inline file data requires a full IANA media type (e.g. "image/png"), got "${part.mediaType}"; part dropped.`,
|
|
355
|
+
});
|
|
356
|
+
return undefined;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
type: kind,
|
|
361
|
+
data: convertToBase64(part.data),
|
|
362
|
+
mime_type: part.mediaType,
|
|
363
|
+
...resolutionField,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/*
|
|
368
|
+
* Drops assistant turns that were part of the linked interaction
|
|
369
|
+
* (`previousInteractionId`) so the API doesn't see them re-sent on top of its
|
|
370
|
+
* server-side state. Also drops any subsequent `tool` (tool-result) message
|
|
371
|
+
* whose `tool-result.toolCallId` matches a `tool-call.toolCallId` from the
|
|
372
|
+
* dropped assistant turn — server-state already has the matching tool result
|
|
373
|
+
* baked in, and re-sending it without its paired call would be malformed.
|
|
374
|
+
*
|
|
375
|
+
* An assistant message is considered "part of the linked interaction" if any
|
|
376
|
+
* of its content parts carry `providerOptions.google.interactionId ===
|
|
377
|
+
* previousInteractionId`. This is stamped by `parseGoogleInteractionsOutputs`
|
|
378
|
+
* (and the stream transformer) on every output content part.
|
|
379
|
+
*
|
|
380
|
+
* User messages are always kept regardless of where they fell in the prior
|
|
381
|
+
* conversation — only assistant model output and its tool plumbing live on the
|
|
382
|
+
* server. (Note that the AI SDK does not stamp `interactionId` onto user
|
|
383
|
+
* messages, so even if it did, this function would not have a way to identify
|
|
384
|
+
* which user message belongs to which interaction.)
|
|
385
|
+
*/
|
|
386
|
+
function compactPromptForPreviousInteraction({
|
|
387
|
+
prompt,
|
|
388
|
+
previousInteractionId,
|
|
389
|
+
}: {
|
|
390
|
+
prompt: LanguageModelV3Prompt;
|
|
391
|
+
previousInteractionId: string;
|
|
392
|
+
}): LanguageModelV3Prompt {
|
|
393
|
+
const out: LanguageModelV3Prompt = [];
|
|
394
|
+
const droppedToolCallIds = new Set<string>();
|
|
395
|
+
|
|
396
|
+
for (const message of prompt) {
|
|
397
|
+
if (message.role === 'assistant') {
|
|
398
|
+
const matchesLinkedInteraction = message.content.some(part => {
|
|
399
|
+
const partInteractionId = (
|
|
400
|
+
part as { providerOptions?: { google?: { interactionId?: string } } }
|
|
401
|
+
).providerOptions?.google?.interactionId;
|
|
402
|
+
return partInteractionId === previousInteractionId;
|
|
403
|
+
});
|
|
404
|
+
if (matchesLinkedInteraction) {
|
|
405
|
+
for (const part of message.content) {
|
|
406
|
+
if (part.type === 'tool-call') {
|
|
407
|
+
droppedToolCallIds.add(part.toolCallId);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
out.push(message);
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
if (message.role === 'tool') {
|
|
416
|
+
const remaining = message.content.filter(part => {
|
|
417
|
+
if (part.type !== 'tool-result') {
|
|
418
|
+
return true;
|
|
419
|
+
}
|
|
420
|
+
return !droppedToolCallIds.has(part.toolCallId);
|
|
421
|
+
});
|
|
422
|
+
if (remaining.length === 0) {
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
out.push({
|
|
426
|
+
...message,
|
|
427
|
+
content: remaining as typeof message.content,
|
|
428
|
+
});
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
out.push(message);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return out;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function safeParseToolArgs(input: string): Record<string, unknown> {
|
|
438
|
+
try {
|
|
439
|
+
const parsed = JSON.parse(input);
|
|
440
|
+
if (
|
|
441
|
+
parsed != null &&
|
|
442
|
+
typeof parsed === 'object' &&
|
|
443
|
+
!Array.isArray(parsed)
|
|
444
|
+
) {
|
|
445
|
+
return parsed as Record<string, unknown>;
|
|
446
|
+
}
|
|
447
|
+
return { value: parsed };
|
|
448
|
+
} catch {
|
|
449
|
+
return { value: input };
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function convertToolResultPart({
|
|
454
|
+
toolCallId,
|
|
455
|
+
toolName,
|
|
456
|
+
output,
|
|
457
|
+
signature,
|
|
458
|
+
warnings,
|
|
459
|
+
}: {
|
|
460
|
+
toolCallId: string;
|
|
461
|
+
toolName: string;
|
|
462
|
+
output: LanguageModelV3ToolResultOutput;
|
|
463
|
+
signature: string | undefined;
|
|
464
|
+
warnings: Array<SharedV3Warning>;
|
|
465
|
+
}): GoogleInteractionsFunctionResultContent {
|
|
466
|
+
const base = {
|
|
467
|
+
type: 'function_result' as const,
|
|
468
|
+
call_id: toolCallId,
|
|
469
|
+
name: toolName,
|
|
470
|
+
...(signature != null ? { signature } : {}),
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
switch (output.type) {
|
|
474
|
+
case 'text':
|
|
475
|
+
return { ...base, result: output.value };
|
|
476
|
+
case 'json':
|
|
477
|
+
return { ...base, result: JSON.stringify(output.value) };
|
|
478
|
+
case 'error-text':
|
|
479
|
+
return { ...base, is_error: true, result: output.value };
|
|
480
|
+
case 'error-json':
|
|
481
|
+
return { ...base, is_error: true, result: JSON.stringify(output.value) };
|
|
482
|
+
case 'execution-denied':
|
|
483
|
+
return {
|
|
484
|
+
...base,
|
|
485
|
+
is_error: true,
|
|
486
|
+
result: output.reason ?? 'Tool execution denied by user.',
|
|
487
|
+
};
|
|
488
|
+
case 'content': {
|
|
489
|
+
const blocks: Array<
|
|
490
|
+
GoogleInteractionsTextContent | GoogleInteractionsImageContent
|
|
491
|
+
> = [];
|
|
492
|
+
for (const item of output.value) {
|
|
493
|
+
if (item.type === 'text') {
|
|
494
|
+
blocks.push({ type: 'text', text: item.text });
|
|
495
|
+
} else if (item.type === 'image-data') {
|
|
496
|
+
const imageBlock = filePartToImageBlock({
|
|
497
|
+
part: {
|
|
498
|
+
type: 'file',
|
|
499
|
+
mediaType: item.mediaType,
|
|
500
|
+
data: item.data,
|
|
501
|
+
},
|
|
502
|
+
warnings,
|
|
503
|
+
});
|
|
504
|
+
if (imageBlock != null) {
|
|
505
|
+
blocks.push(imageBlock);
|
|
506
|
+
}
|
|
507
|
+
} else if (item.type === 'image-url') {
|
|
508
|
+
const imageBlock = filePartToImageBlock({
|
|
509
|
+
part: {
|
|
510
|
+
type: 'file',
|
|
511
|
+
mediaType: 'image/*',
|
|
512
|
+
data: new URL(item.url),
|
|
513
|
+
},
|
|
514
|
+
warnings,
|
|
515
|
+
});
|
|
516
|
+
if (imageBlock != null) {
|
|
517
|
+
blocks.push(imageBlock);
|
|
518
|
+
}
|
|
519
|
+
} else if (item.type === 'file-data' || item.type === 'file-url') {
|
|
520
|
+
const mediaType =
|
|
521
|
+
item.type === 'file-data' ? item.mediaType : 'application/*';
|
|
522
|
+
const topLevel = getTopLevelMediaType(mediaType);
|
|
523
|
+
if (topLevel !== 'image') {
|
|
524
|
+
warnings.push({
|
|
525
|
+
type: 'other',
|
|
526
|
+
message: `google.interactions: tool-result file with mediaType "${mediaType}" is not supported (Interactions \`function_result.result\` accepts only text and image content); part dropped.`,
|
|
527
|
+
});
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
const imageBlock = filePartToImageBlock({
|
|
531
|
+
part:
|
|
532
|
+
item.type === 'file-data'
|
|
533
|
+
? {
|
|
534
|
+
type: 'file',
|
|
535
|
+
mediaType: item.mediaType,
|
|
536
|
+
data: item.data,
|
|
537
|
+
}
|
|
538
|
+
: {
|
|
539
|
+
type: 'file',
|
|
540
|
+
mediaType,
|
|
541
|
+
data: new URL(item.url),
|
|
542
|
+
},
|
|
543
|
+
warnings,
|
|
544
|
+
});
|
|
545
|
+
if (imageBlock != null) {
|
|
546
|
+
blocks.push(imageBlock);
|
|
547
|
+
}
|
|
548
|
+
} else {
|
|
549
|
+
warnings.push({
|
|
550
|
+
type: 'other',
|
|
551
|
+
message: `google.interactions: tool-result content part type "${(item as { type: string }).type}" is not supported; part dropped.`,
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return { ...base, result: blocks };
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function filePartToImageBlock({
|
|
561
|
+
part,
|
|
562
|
+
warnings,
|
|
563
|
+
}: {
|
|
564
|
+
part: {
|
|
565
|
+
type: 'file';
|
|
566
|
+
mediaType: string;
|
|
567
|
+
data: Uint8Array | string | URL;
|
|
568
|
+
filename?: string;
|
|
569
|
+
};
|
|
570
|
+
warnings: Array<SharedV3Warning>;
|
|
571
|
+
}): GoogleInteractionsImageContent | undefined {
|
|
572
|
+
if (part.data instanceof URL) {
|
|
573
|
+
return {
|
|
574
|
+
type: 'image',
|
|
575
|
+
uri: part.data.toString(),
|
|
576
|
+
...(isFullMediaType(part.mediaType) ? { mime_type: part.mediaType } : {}),
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (!isFullMediaType(part.mediaType)) {
|
|
581
|
+
warnings.push({
|
|
582
|
+
type: 'other',
|
|
583
|
+
message: `google.interactions: tool-result image part requires a full IANA media type (e.g. "image/png"), got "${part.mediaType}"; part dropped.`,
|
|
584
|
+
});
|
|
585
|
+
return undefined;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
return {
|
|
589
|
+
type: 'image',
|
|
590
|
+
data: convertToBase64(part.data),
|
|
591
|
+
mime_type: part.mediaType,
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/*
|
|
596
|
+
* Collapses runs of adjacent text content blocks within a single user message
|
|
597
|
+
* into one combined text block, separated by a blank line. The Interactions
|
|
598
|
+
* API has no `text+data` shape, so a `data.type === 'text'` file part is
|
|
599
|
+
* already lowered to a `text` block by `convertFilePartToContent`; merging
|
|
600
|
+
* keeps the wire shape compact and preserves intent when an inline text file
|
|
601
|
+
* sits next to a regular text part. Text blocks carrying `annotations` are
|
|
602
|
+
* left untouched (annotations are tied to specific text spans).
|
|
603
|
+
*/
|
|
604
|
+
function mergeAdjacentTextContent(
|
|
605
|
+
content: Array<GoogleInteractionsContent>,
|
|
606
|
+
): Array<GoogleInteractionsContent> {
|
|
607
|
+
if (content.length < 2) {
|
|
608
|
+
return content;
|
|
609
|
+
}
|
|
610
|
+
const result: Array<GoogleInteractionsContent> = [];
|
|
611
|
+
for (const block of content) {
|
|
612
|
+
const last = result[result.length - 1];
|
|
613
|
+
if (
|
|
614
|
+
block.type === 'text' &&
|
|
615
|
+
last != null &&
|
|
616
|
+
last.type === 'text' &&
|
|
617
|
+
(last as GoogleInteractionsTextContent).annotations == null &&
|
|
618
|
+
(block as GoogleInteractionsTextContent).annotations == null
|
|
619
|
+
) {
|
|
620
|
+
const merged: GoogleInteractionsTextContent = {
|
|
621
|
+
type: 'text',
|
|
622
|
+
text: `${(last as GoogleInteractionsTextContent).text}\n\n${(block as GoogleInteractionsTextContent).text}`,
|
|
623
|
+
};
|
|
624
|
+
result[result.length - 1] = merged;
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
result.push(block);
|
|
628
|
+
}
|
|
629
|
+
return result;
|
|
630
|
+
}
|