@ai-sdk/google 4.0.0-canary.63 → 4.0.0-canary.65

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.
@@ -13,11 +13,12 @@ import {
13
13
  } from '@ai-sdk/provider-utils';
14
14
  import type {
15
15
  GoogleInteractionsContent,
16
+ GoogleInteractionsContentBlock,
16
17
  GoogleInteractionsFunctionResultContent,
17
18
  GoogleInteractionsImageContent,
18
19
  GoogleInteractionsInput,
20
+ GoogleInteractionsStep,
19
21
  GoogleInteractionsTextContent,
20
- GoogleInteractionsTurn,
21
22
  } from './google-interactions-prompt';
22
23
 
23
24
  export type GoogleInteractionsMediaResolution =
@@ -34,22 +35,20 @@ export type ConvertToGoogleInteractionsInputResult = {
34
35
 
35
36
  /**
36
37
  * Converts an AI SDK `LanguageModelV4Prompt` into the Gemini Interactions
37
- * request shape (`{ input, system_instruction }`).
38
+ * request shape (`{ input: Array<Step>, system_instruction }`).
38
39
  *
39
- * Handles text parts, file parts (image / audio / document / video, all four
40
- * `data.type` shapes), tool-call/tool-result round-tripping, per-block
41
- * `signature` round-tripping (`thought.signature`, `function_call.signature`),
42
- * and statefulness compaction (drop assistant/tool turns whose
43
- * `providerOptions.google.interactionId === previousInteractionId`).
40
+ * Prior assistant content round-trips as discrete steps:
41
+ * - text / image content → `model_output` step with a single `content` array
42
+ * - reasoning → `thought` step (`signature` + `summary`)
43
+ * - tool-call `function_call` step
44
+ * User turns (and tool-result turns from the previous round) are sent as
45
+ * `user_input` steps whose `content[]` holds the user's parts (text, files,
46
+ * and — for tool-result turns — `function_result` blocks).
44
47
  *
45
- * NOTE on PRD Open Q3 (empty-text-with-signature carrier hack from the
46
- * `:generateContent` provider): unnecessary on Interactions because
47
- * `thought.signature` and `function_call.signature` are explicit fields on
48
- * the wire (verified against `googleapis/js-genai`
49
- * `src/interactions/resources/interactions.ts` `ThoughtContent` /
50
- * `FunctionCallContent`). When an input reasoning part has empty text + a
51
- * signature, the converter emits a `thought` block with `signature` and an
52
- * omitted `summary` — no synthetic empty-text carrier needed.
48
+ * Handles text parts, file parts (image / audio / document / video, all four
49
+ * `data.type` shapes), tool-call/tool-result round-tripping, per-step
50
+ * `signature` round-tripping, and statefulness compaction (drop assistant/tool
51
+ * turns whose `providerOptions.google.interactionId === previousInteractionId`).
53
52
  */
54
53
  export function convertToGoogleInteractionsInput({
55
54
  prompt,
@@ -60,20 +59,12 @@ export function convertToGoogleInteractionsInput({
60
59
  prompt: LanguageModelV4Prompt;
61
60
  previousInteractionId?: string;
62
61
  store?: boolean;
63
- /**
64
- * Per-block media resolution applied to every image / video input block
65
- * (the Interactions wire format places `resolution` on the block, not at
66
- * the top level). See js-genai
67
- * `src/interactions/resources/interactions.ts` `ImageContent.resolution`
68
- * and `VideoContent.resolution`.
69
- */
70
62
  mediaResolution?: GoogleInteractionsMediaResolution;
71
63
  }): ConvertToGoogleInteractionsInputResult {
72
64
  const warnings: Array<SharedV4Warning> = [];
73
65
 
74
66
  /*
75
- * Behavior matrix per PRD § "Public-API contracts" → "Configurable behavior
76
- * matrix":
67
+ * Behavior matrix for compaction:
77
68
  *
78
69
  * - `previousInteractionId` set + `store !== false` → compact history (drop
79
70
  * assistant/tool turns whose `providerMetadata.google.interactionId`
@@ -82,10 +73,6 @@ export function convertToGoogleInteractionsInput({
82
73
  * (incoherent combo), still send full history (NO compaction).
83
74
  * - `store === false`, no `previousInteractionId` → no compaction.
84
75
  * - Default → no compaction.
85
- *
86
- * The actual `previous_interaction_id` / `store` body fields are emitted in
87
- * the language model's `getArgs`; this converter only handles the history
88
- * shape and the warning.
89
76
  */
90
77
  const incoherentCombo = previousInteractionId != null && store === false;
91
78
  const shouldCompact = previousInteractionId != null && store !== false;
@@ -105,7 +92,7 @@ export function convertToGoogleInteractionsInput({
105
92
  : prompt;
106
93
 
107
94
  const systemTexts: Array<string> = [];
108
- const turns: Array<GoogleInteractionsTurn> = [];
95
+ const steps: Array<GoogleInteractionsStep> = [];
109
96
 
110
97
  for (const message of compactedPrompt) {
111
98
  switch (message.role) {
@@ -114,14 +101,10 @@ export function convertToGoogleInteractionsInput({
114
101
  break;
115
102
  }
116
103
  case 'user': {
117
- const content: Array<GoogleInteractionsContent> = [];
104
+ const content: Array<GoogleInteractionsContentBlock> = [];
118
105
  for (const part of message.content) {
119
106
  if (part.type === 'text') {
120
- const block: GoogleInteractionsTextContent = {
121
- type: 'text',
122
- text: part.text,
123
- };
124
- content.push(block);
107
+ content.push({ type: 'text', text: part.text });
125
108
  } else if (part.type === 'file') {
126
109
  const fileBlock = convertFilePartToContent({
127
110
  part,
@@ -135,20 +118,34 @@ export function convertToGoogleInteractionsInput({
135
118
  }
136
119
  const merged = mergeAdjacentTextContent(content);
137
120
  if (merged.length > 0) {
138
- turns.push({ role: 'user', content: merged });
121
+ steps.push({ type: 'user_input', content: merged });
139
122
  }
140
123
  break;
141
124
  }
142
125
  case 'assistant': {
143
- const content: Array<GoogleInteractionsContent> = [];
126
+ /*
127
+ * Prior assistant content fans out into one step per logical block.
128
+ * Adjacent text/image content blocks are coalesced into a single
129
+ * `model_output` step (matching how the API emits them on output);
130
+ * reasoning and tool-calls each become their own step.
131
+ */
132
+ let pendingModelOutput: Array<GoogleInteractionsContentBlock> = [];
133
+ const flushModelOutput = () => {
134
+ if (pendingModelOutput.length > 0) {
135
+ steps.push({ type: 'model_output', content: pendingModelOutput });
136
+ pendingModelOutput = [];
137
+ }
138
+ };
139
+
144
140
  for (const part of message.content) {
145
141
  if (part.type === 'text') {
146
- content.push({ type: 'text', text: part.text });
142
+ pendingModelOutput.push({ type: 'text', text: part.text });
147
143
  } else if (part.type === 'reasoning') {
144
+ flushModelOutput();
148
145
  const signature = part.providerOptions?.google?.signature as
149
146
  | string
150
147
  | undefined;
151
- content.push({
148
+ steps.push({
152
149
  type: 'thought',
153
150
  ...(signature != null ? { signature } : {}),
154
151
  summary:
@@ -163,9 +160,10 @@ export function convertToGoogleInteractionsInput({
163
160
  mediaResolution,
164
161
  });
165
162
  if (fileBlock != null) {
166
- content.push(fileBlock);
163
+ pendingModelOutput.push(fileBlock);
167
164
  }
168
165
  } else if (part.type === 'tool-call') {
166
+ flushModelOutput();
169
167
  const signature = part.providerOptions?.google?.signature as
170
168
  | string
171
169
  | undefined;
@@ -173,7 +171,7 @@ export function convertToGoogleInteractionsInput({
173
171
  typeof part.input === 'string'
174
172
  ? safeParseToolArgs(part.input)
175
173
  : ((part.input ?? {}) as Record<string, unknown>);
176
- content.push({
174
+ steps.push({
177
175
  type: 'function_call',
178
176
  id: part.toolCallId,
179
177
  name: part.toolName,
@@ -187,51 +185,17 @@ export function convertToGoogleInteractionsInput({
187
185
  });
188
186
  }
189
187
  }
190
- if (content.length > 0) {
191
- turns.push({ role: 'model', content });
192
- }
188
+ flushModelOutput();
193
189
  break;
194
190
  }
195
191
  case 'tool': {
196
192
  /*
197
- * Tool-result messages are emitted as a `user` turn whose content
198
- * holds one `function_result` block per tool-result part. Wire shape
199
- * (verified against `googleapis/js-genai`
200
- * `samples/interactions_function_calling_client_state.ts` and
201
- * `src/interactions/resources/interactions.ts` `FunctionResultContent`
202
- * around line 979 — RESOLVES PRD Open Q2):
203
- *
204
- * {
205
- * role: 'user',
206
- * content: [
207
- * {
208
- * type: 'function_result',
209
- * call_id: <id from the matching function_call block>,
210
- * name: <tool name>,
211
- * result: <string | unknown | Array<TextContent|ImageContent>>,
212
- * is_error?: boolean,
213
- * signature?: string,
214
- * },
215
- * ],
216
- * }
217
- *
218
- * The `result` field is a discriminated union: a plain string for
219
- * text-only results, or an array of `text` / `image` content blocks
220
- * for mixed text/image results. Our converter takes the AI SDK
221
- * canonical `LanguageModelV4ToolResultOutput` and maps:
222
- * - `{ type: 'text', value }` → `result: <string>`
223
- * - `{ type: 'json', value }` → `result: <stringified JSON>`
224
- * - `{ type: 'error-text', value }` → `result: <string>` + `is_error: true`
225
- * - `{ type: 'error-json', value }` → `result: <stringified JSON>` + `is_error: true`
226
- * - `{ type: 'execution-denied', reason }` → `result: <reason>` + `is_error: true`
227
- * - `{ type: 'content', value: [...] }` → `result: Array<text|image>`
228
- * where each AI SDK `file` part with `mediaType: image/*` becomes
229
- * an Interactions `image` block (file-data path matches
230
- * `convertFilePartToContent` for top-level user images), and `text`
231
- * parts pass through. Non-image file parts fall back to a warning
232
- * because `FunctionResultContent.result` only accepts text/image.
193
+ * Tool-result messages are emitted as a `user_input` step whose
194
+ * content holds one `function_result` block per tool-result part.
195
+ * `function_result` remains a content-block type (it sits inside
196
+ * a step), not a top-level step type.
233
197
  */
234
- const content: Array<GoogleInteractionsContent> = [];
198
+ const content: Array<GoogleInteractionsContentBlock> = [];
235
199
  for (const part of message.content) {
236
200
  if (part.type !== 'tool-result') {
237
201
  warnings.push({
@@ -252,7 +216,7 @@ export function convertToGoogleInteractionsInput({
252
216
  content.push(block);
253
217
  }
254
218
  if (content.length > 0) {
255
- turns.push({ role: 'user', content });
219
+ steps.push({ type: 'user_input', content });
256
220
  }
257
221
  break;
258
222
  }
@@ -262,39 +226,12 @@ export function convertToGoogleInteractionsInput({
262
226
  const systemInstruction =
263
227
  systemTexts.length > 0 ? systemTexts.join('\n\n') : undefined;
264
228
 
265
- let input: GoogleInteractionsInput;
266
- if (turns.length === 0) {
267
- input = '';
268
- } else if (
269
- turns.length === 1 &&
270
- turns[0].role === 'user' &&
271
- Array.isArray(turns[0].content)
272
- ) {
273
- /*
274
- * Single-turn user prompt: send the bare `Array<Content>` shape per the
275
- * Interactions API's preferred single-turn format.
276
- */
277
- input = turns[0].content;
278
- } else {
279
- input = turns;
280
- }
281
-
282
- return { input, systemInstruction, warnings };
229
+ return { input: steps, systemInstruction, warnings };
283
230
  }
284
231
 
285
232
  /**
286
233
  * Maps a single AI SDK `LanguageModelV4FilePart` to a Gemini Interactions
287
234
  * content block (`image` / `audio` / `document` / `video`).
288
- *
289
- * Rules for the four `data.type` cases:
290
- * - `data` (Uint8Array / base64) → block with inline `data` (base64) +
291
- * `mime_type`.
292
- * - `url` → block with `uri` set to the URL string verbatim.
293
- * - `reference` → block with `uri` set to the resolved `google` provider
294
- * reference (Files API URI like
295
- * `https://generativelanguage.googleapis.com/v1beta/files/<id>`).
296
- * - `text` → collapsed to a `text` block (the wire format has no text-on-file
297
- * shape; emit an inline text block instead).
298
235
  */
299
236
  function convertFilePartToContent({
300
237
  part,
@@ -339,12 +276,6 @@ function convertFilePartToContent({
339
276
  return undefined;
340
277
  }
341
278
 
342
- /*
343
- * `resolution` is per-block on the wire (`ImageContent.resolution`,
344
- * `VideoContent.resolution`); only image and video carry it (see
345
- * `googleapis/js-genai` `src/interactions/resources/interactions.ts`).
346
- * Audio / document blocks ignore the option silently.
347
- */
348
279
  const resolutionField =
349
280
  mediaResolution != null && (kind === 'image' || kind === 'video')
350
281
  ? { resolution: mediaResolution }
@@ -388,23 +319,9 @@ function convertFilePartToContent({
388
319
  }
389
320
 
390
321
  /*
391
- * Drops assistant turns that were part of the linked interaction
392
- * (`previousInteractionId`) so the API doesn't see them re-sent on top of its
393
- * server-side state. Also drops any subsequent `tool` (tool-result) message
394
- * whose `tool-result.toolCallId` matches a `tool-call.toolCallId` from the
395
- * dropped assistant turn — server-state already has the matching tool result
396
- * baked in, and re-sending it without its paired call would be malformed.
397
- *
398
- * An assistant message is considered "part of the linked interaction" if any
399
- * of its content parts carry `providerOptions.google.interactionId ===
400
- * previousInteractionId`. This is stamped by `parseGoogleInteractionsOutputs`
401
- * (and the stream transformer) on every output content part.
402
- *
403
- * User messages are always kept regardless of where they fell in the prior
404
- * conversation — only assistant model output and its tool plumbing live on the
405
- * server. (Note that the AI SDK does not stamp `interactionId` onto user
406
- * messages, so even if it did, this function would not have a way to identify
407
- * which user message belongs to which interaction.)
322
+ * Drops assistant messages that were part of the linked interaction
323
+ * (`previousInteractionId`). Tool-result turns whose tool-call counterpart
324
+ * was dropped are also pruned to keep the message stream well-formed.
408
325
  */
409
326
  function compactPromptForPreviousInteraction({
410
327
  prompt,
@@ -606,21 +523,18 @@ function filePartToImageBlock({
606
523
  }
607
524
 
608
525
  /*
609
- * Collapses runs of adjacent text content blocks within a single user message
610
- * into one combined text block, separated by a blank line. The Interactions
611
- * API has no `text+data` shape, so a `data.type === 'text'` file part is
612
- * already lowered to a `text` block by `convertFilePartToContent`; merging
613
- * keeps the wire shape compact and preserves intent when an inline text file
614
- * sits next to a regular text part. Text blocks carrying `annotations` are
615
- * left untouched (annotations are tied to specific text spans).
526
+ * Collapses runs of adjacent text content blocks within a single user step
527
+ * into one combined text block, separated by a blank line. Text blocks
528
+ * carrying `annotations` are left untouched (annotations are tied to specific
529
+ * text spans).
616
530
  */
617
531
  function mergeAdjacentTextContent(
618
- content: Array<GoogleInteractionsContent>,
619
- ): Array<GoogleInteractionsContent> {
532
+ content: Array<GoogleInteractionsContentBlock>,
533
+ ): Array<GoogleInteractionsContentBlock> {
620
534
  if (content.length < 2) {
621
535
  return content;
622
536
  }
623
- const result: Array<GoogleInteractionsContent> = [];
537
+ const result: Array<GoogleInteractionsContentBlock> = [];
624
538
  for (const block of content) {
625
539
  const last = result[result.length - 1];
626
540
  if (
@@ -60,7 +60,7 @@ export function annotationToSource({
60
60
  }
61
61
  case 'file_citation': {
62
62
  const a = annotation as GoogleInteractionsFileCitation;
63
- const uri = a.document_uri ?? a.source ?? a.file_name;
63
+ const uri = a.url ?? a.document_uri ?? a.file_name;
64
64
  if (uri == null || uri.length === 0) return undefined;
65
65
  if (uri.startsWith('http://') || uri.startsWith('https://')) {
66
66
  return {
@@ -176,10 +176,10 @@ export function builtinToolResultToSources({
176
176
  const entry = raw as {
177
177
  file_name?: string;
178
178
  document_uri?: string;
179
- source?: string;
179
+ url?: string;
180
180
  title?: string;
181
181
  };
182
- const uri = entry.document_uri ?? entry.source ?? entry.file_name;
182
+ const uri = entry.url ?? entry.document_uri ?? entry.file_name;
183
183
  if (uri == null || uri.length === 0) continue;
184
184
  if (uri.startsWith('http://') || uri.startsWith('https://')) {
185
185
  sources.push({