agent-accelerator 0.5.0 → 0.5.2

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.
@@ -29,18 +29,19 @@ import type {
29
29
  ProviderGenerateResult,
30
30
  ProviderRawData,
31
31
  } from "../types/model.ts";
32
- import type { ProviderContext, Message, ContentPart } from "../types/message.ts";
32
+ import type { ProviderContext, Message, ContentPart, ProviderSteps, ToolResultPart, MediaResolution } from "../types/message.ts";
33
33
  import type { StandardToolDeclaration, ToolCallRecord } from "../types/tool.ts";
34
34
  import type { TokenUsage } from "../types/core.ts";
35
35
  import { AssistantMessageEventStream } from "../streaming/event-stream.ts";
36
36
  import { SSEParser } from "../streaming/sse-parser.ts";
37
37
  import { AgentResponse } from "../types/response.ts";
38
38
  import { getApiKey, getEnv } from "../utils/env.ts";
39
- import { normalizeMediaInput } from "../utils/media.ts";
39
+ import { normalizeMediaInput, inferMimeType } from "../utils/media.ts";
40
+ import { ConfigError, ValidationError, isAbortError } from "../types/errors.ts";
40
41
  import { safeStringify } from "../utils/serialization.ts";
41
42
  import { stripSchemaForGoogle } from "../tools/schema.ts";
42
43
  import { toConciseProviderError, assertModalitiesSupported } from "../utils/errors.ts";
43
- import { withRetries } from "../utils/retry.ts";
44
+ import { withRetries, sleep } from "../utils/retry.ts";
44
45
  import { setHeaderCaseInsensitive, shouldEchoSignature } from "../utils/headers.ts";
45
46
  import {
46
47
  newToolCallId,
@@ -64,7 +65,7 @@ import { getModelFromCatalog, getModelsForProvider, modelSupportsStructuredOutpu
64
65
  import {
65
66
  mapThinkingLevelToGoogle,
66
67
  mapServiceTierToGoogle,
67
- applyCacheForGoogle,
68
+ resolveCache,
68
69
  normalizeToolChoice,
69
70
  lastProviderFor,
70
71
  noteProviderTurn,
@@ -77,16 +78,102 @@ import {
77
78
  emitProviderWarning,
78
79
  } from "../providers.ts";
79
80
 
81
+ // ---------------------------------------------------------------------------
82
+ // Supported inputs (api-ref `interactions-api.md` enums, `docs/file-input-methods.md`)
83
+ // ---------------------------------------------------------------------------
84
+
85
+ /** Image MIME types Google accepts inline. */
86
+ export const GOOGLE_IMAGE_MIME = ["image/png", "image/jpeg", "image/webp", "image/heic", "image/heif", "image/gif", "image/bmp", "image/tiff"] as const;
87
+ /** Audio MIME types Google accepts inline (audio is never fetched by URL). */
88
+ export const GOOGLE_AUDIO_MIME = ["audio/wav", "audio/mp3", "audio/aiff", "audio/aac", "audio/ogg", "audio/flac", "audio/mpeg", "audio/m4a", "audio/l16", "audio/opus", "audio/alaw", "audio/mulaw", "audio/webm"] as const;
89
+ /** Video MIME types Google accepts inline. */
90
+ export const GOOGLE_VIDEO_MIME = ["video/mp4", "video/mpeg", "video/mpg", "video/mov", "video/avi", "video/x-flv", "video/webm", "video/wmv", "video/3gpp"] as const;
91
+ /** Document MIME types Google accepts (the only two). */
92
+ export const GOOGLE_DOCUMENT_MIME = ["application/pdf", "text/csv"] as const;
93
+ /** MIME types Google fetches itself from a public `https://` URL. */
94
+ export const GOOGLE_URL_MIME = [
95
+ "text/html", "text/css", "text/plain", "text/xml", "text/csv", "text/rtf", "text/javascript",
96
+ "application/json", "application/pdf",
97
+ "image/bmp", "image/jpeg", "image/png", "image/webp",
98
+ "video/mp4", "video/mpeg", "video/quicktime", "video/avi", "video/x-flv", "video/mpg", "video/webm", "video/wmv", "video/3gpp",
99
+ ] as const;
100
+ /** Common MIME spellings mapped to Google's inline names. */
101
+ export const GOOGLE_INLINE_MIME_ALIASES: Readonly<Record<string, string>> = {
102
+ "video/quicktime": "video/mov",
103
+ "audio/x-wav": "audio/wav",
104
+ "audio/wave": "audio/wav",
105
+ "image/jpg": "image/jpeg",
106
+ };
107
+ // Documented size limits. Not enforced yet: oversized inputs get Google's own error.
108
+ /** Max inline payload per request (bytes). */
109
+ export const GOOGLE_MAX_INLINE_REQUEST_BYTES = 100 * 1024 * 1024;
110
+ /** Max inline PDF size (bytes). */
111
+ export const GOOGLE_MAX_INLINE_PDF_BYTES = 50 * 1024 * 1024;
112
+ /** Max size of one file Google fetches by URL (bytes). */
113
+ export const GOOGLE_MAX_URL_FILE_BYTES = 15 * 1024 * 1024;
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Long decode (`docs/long-decode.md`, `docs/background-execution.md`)
117
+ // ---------------------------------------------------------------------------
118
+
119
+ /** Output tokens one non-background request may produce; above it Gemini 4 runs as a background interaction (long decode). */
120
+ export const GOOGLE_MAX_SYNC_OUTPUT_TOKENS = 65_536;
121
+ // A background stream pauses between ~5K-token chunks; only this much silence counts as a dropped connection (docs: 15-20 min).
122
+ const BACKGROUND_STREAM_STALL_MS = 15 * 60_000;
123
+ // Reconnects in a row that bring no new event before a background stream gives up.
124
+ const BACKGROUND_MAX_RECONNECTS = 5;
125
+
126
+ // True when a request needs Gemini 4 long decode (background); any other model above the limit fails before the network; in: model id + maxOutputTokens, out: buildBody, isLongRunning.
127
+ function needsLongDecode(modelId: string, maxOutputTokens: number | undefined): boolean {
128
+ if (!maxOutputTokens || maxOutputTokens <= GOOGLE_MAX_SYNC_OUTPUT_TOKENS) return false;
129
+ if (/^gemini-4/i.test(modelId)) return true;
130
+ throw new ConfigError(
131
+ `[Agent Accelerator] [google/${modelId}] maxOutputTokens ${maxOutputTokens} is above Google's ${GOOGLE_MAX_SYNC_OUTPUT_TOKENS} limit; only Gemini 4 models can write more (long decode). Lower maxOutputTokens or use a gemini-4 model.`
132
+ );
133
+ }
134
+
135
+ // True while a background interaction is still working; in: Interaction.status, out: polling and stream reconnects.
136
+ function isRunning(status: string | undefined): boolean {
137
+ return status === "queued" || status === "in_progress";
138
+ }
139
+
140
+ // The two reader calls the stream loop uses (Bun ships two incompatible reader typings, so only these are typed).
141
+ type StreamReader = { read(): Promise<{ done: boolean; value?: Uint8Array }>; cancel(): Promise<void> };
142
+
143
+ // Reads the next chunk, or cancels the reader and rejects after `ms` of silence; in: a background stream reader, out: the stream read loop.
144
+ async function readWithin(reader: StreamReader, ms: number) {
145
+ let timer: ReturnType<typeof setTimeout> | undefined;
146
+ const stalled = new Promise<never>((_, reject) => {
147
+ timer = setTimeout(() => {
148
+ void reader.cancel().catch(() => {});
149
+ reject(new Error("Google background stream stalled"));
150
+ }, ms);
151
+ });
152
+ try {
153
+ return await Promise.race([reader.read(), stalled]);
154
+ } finally {
155
+ clearTimeout(timer);
156
+ }
157
+ }
158
+
159
+ type MediaBlockType = "image" | "audio" | "video" | "document";
160
+
161
+ const INLINE_MIME: Record<MediaBlockType, readonly string[]> = {
162
+ image: GOOGLE_IMAGE_MIME,
163
+ audio: GOOGLE_AUDIO_MIME,
164
+ video: GOOGLE_VIDEO_MIME,
165
+ document: GOOGLE_DOCUMENT_MIME,
166
+ };
167
+
168
+ const FILES_API_PREFIX = "https://generativelanguage.googleapis.com/v1beta/files/";
169
+ const YOUTUBE_URL = /^https:\/\/(www\.|m\.)?(youtube\.com|youtu\.be)\//i;
170
+
80
171
  // ---------------------------------------------------------------------------
81
172
  // Interactions wire shapes (subset used by this adapter)
82
173
  // ---------------------------------------------------------------------------
83
174
 
84
- type InteractionContentBlock =
85
- | { type: "text"; text: string }
86
- | { type: "image"; mime_type: string; data?: string; uri?: string }
87
- | { type: "audio"; mime_type: string; data?: string; uri?: string }
88
- | { type: "video"; mime_type: string; data?: string; uri?: string }
89
- | { type: "document"; mime_type: string; data?: string; uri?: string };
175
+ type MediaBlock = { type: MediaBlockType; mime_type?: string; data?: string; uri?: string; resolution?: MediaResolution };
176
+ type InteractionContentBlock = { type: "text"; text: string } | MediaBlock;
90
177
 
91
178
  type InteractionInputStep =
92
179
  | { type: "user_input"; content: string | InteractionContentBlock[] }
@@ -97,6 +184,7 @@ type InteractionInputStep =
97
184
  name: string;
98
185
  call_id: string;
99
186
  result: InteractionContentBlock[];
187
+ is_error?: boolean;
100
188
  }
101
189
  | { type: string; [k: string]: unknown };
102
190
 
@@ -111,6 +199,7 @@ interface InteractionRequestBody {
111
199
  store?: boolean;
112
200
  previous_interaction_id?: string;
113
201
  stream?: boolean;
202
+ background?: boolean;
114
203
  }
115
204
 
116
205
  interface InteractionStep {
@@ -147,14 +236,15 @@ interface InteractionObject {
147
236
  [k: string]: unknown;
148
237
  }
149
238
 
150
- const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
239
+ /** One parsed Interactions HTTP reply (create POST or poll GET). */
240
+ interface InteractionReply {
241
+ status: number;
242
+ statusText: string;
243
+ headers: Record<string, string>;
244
+ interaction: InteractionObject;
245
+ }
151
246
 
152
- /** Internal headers that must never leak onto Google REST requests. */
153
- const INTERNAL_HEADERS = new Set([
154
- "x-thought-signature-map",
155
- "x-cached-content-id",
156
- "x-multimodal-user-content",
157
- ]);
247
+ const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
158
248
 
159
249
  /** Per-session interaction chaining (provider-generated ids only). */
160
250
  interface ChainEntry {
@@ -328,48 +418,102 @@ function toGoogleToolChoice(
328
418
  return { allowed_tools: { mode: norm.mode } };
329
419
  }
330
420
 
331
- function mediaBlockType(partType: string): "image" | "audio" | "video" | "document" {
332
- if (partType === "image" || partType === "audio" || partType === "video") return partType;
333
- // Canonical `file` parts are PDFs/documents. The guides reference document
334
- // processing with the same content-block pattern; the wire type is
335
- // `document`. If the server ever rejects it, the concise error surfaces it.
336
- return "document";
421
+ type MediaValue = string | Uint8Array | ArrayBuffer;
422
+
423
+ // Lower-cases a MIME type and drops parameters (`text/csv; charset=utf-8` -> `text/csv`); in: part or response MIME, out: list checks.
424
+ function bareMime(mime: string | undefined): string | undefined {
425
+ return mime ? mime.split(";")[0]!.trim().toLowerCase() || undefined : undefined;
426
+ }
427
+
428
+ // Returns the `uri` block for a link Google fetches itself (YouTube, Files API, `gs://`, https URL of a URL-supported type), else undefined; in: a media part's string value, out: contentPartsToBlocks.
429
+ function uriBlock(type: MediaBlockType, value: string, mimeType?: string): MediaBlock | undefined {
430
+ const explicit = bareMime(mimeType);
431
+ const withMime = (mime?: string): MediaBlock => ({ type, uri: value, ...(mime ? { mime_type: mime } : {}) });
432
+ if (type === "video" && YOUTUBE_URL.test(value)) return withMime(explicit);
433
+ const mime = explicit ?? bareMime(inferMimeType(value, ""));
434
+ if (value.startsWith(FILES_API_PREFIX) || value.startsWith("gs://")) return withMime(mime);
435
+ return value.startsWith("https://") && mime && (GOOGLE_URL_MIME as readonly string[]).includes(mime) ? withMime(mime) : undefined;
436
+ }
437
+
438
+ // Inlines a media part as base64 under Google's MIME name, failing before the network on an unsupported type; in: any media value (bytes, data URL, path, http URL), out: contentPartsToBlocks.
439
+ async function inlineBlock(type: MediaBlockType, value: MediaValue, mimeType?: string): Promise<MediaBlock> {
440
+ const norm = await normalizeMediaInput(value, mimeType);
441
+ const raw = bareMime(norm.mimeType) ?? "";
442
+ const mime = GOOGLE_INLINE_MIME_ALIASES[raw] ?? raw;
443
+ if (!INLINE_MIME[type].includes(mime)) {
444
+ const what = type === "document" ? "Google reads only PDF and CSV documents" : `Google does not accept ${type} type "${mime}"`;
445
+ throw new ValidationError(
446
+ `[Agent Accelerator] ${what} (got "${mime}"; supported: ${INLINE_MIME[type].join(", ")}). Convert the file or pass the correct mimeType.`
447
+ );
448
+ }
449
+ return { type, mime_type: mime, data: norm.base64Data };
337
450
  }
338
451
 
452
+ // Maps canonical text and media parts to Interactions content blocks (one builder for chained and full-history input); in: a message's parts, out: request `input`.
339
453
  async function contentPartsToBlocks(parts: ContentPart[]): Promise<InteractionContentBlock[]> {
340
454
  const blocks: InteractionContentBlock[] = [];
341
455
  for (const part of parts) {
342
- if (part.type === "text" && part.text) {
343
- blocks.push({ type: "text", text: part.text });
344
- } else if (
345
- part.type === "image" ||
346
- part.type === "audio" ||
347
- part.type === "video" ||
348
- part.type === "file"
349
- ) {
350
- const raw = (part as { image?: unknown; audio?: unknown; video?: unknown; file?: unknown }).image ??
351
- (part as { audio?: unknown }).audio ??
352
- (part as { video?: unknown }).video ??
353
- (part as { file?: unknown }).file;
354
- const norm = await normalizeMediaInput(
355
- raw as string | Uint8Array | ArrayBuffer,
356
- (part as { mimeType?: string }).mimeType
357
- );
358
- blocks.push({
359
- type: mediaBlockType(part.type),
360
- mime_type: norm.mimeType,
361
- data: norm.base64Data,
362
- } as InteractionContentBlock);
456
+ if (part.type === "text") {
457
+ if (part.text) blocks.push({ type: "text", text: part.text });
458
+ continue;
363
459
  }
460
+ if (part.type !== "image" && part.type !== "audio" && part.type !== "video" && part.type !== "file") continue;
461
+ const type: MediaBlockType = part.type === "file" ? "document" : part.type;
462
+ const value = (part as unknown as Record<string, MediaValue>)[part.type]!;
463
+ const uri = typeof value === "string" ? uriBlock(type, value, part.mimeType) : undefined;
464
+ const block = uri ?? (await inlineBlock(type, value, part.mimeType));
465
+ // Per-item detail level (image and video blocks only, api-ref `MediaResolution`).
466
+ const resolution = part.type === "image" || part.type === "video" ? part.resolution : undefined;
467
+ blocks.push(resolution ? { ...block, resolution } : block);
364
468
  }
365
469
  return blocks;
366
470
  }
367
471
 
472
+ // Keeps a turn's model-generated steps exactly as received (input echoes and empty signatures dropped); in: REST `steps` or assembled stream steps, out: ProviderGenerateResult.providerSteps.
473
+ function replaySteps(steps: unknown[] | undefined): ProviderSteps | undefined {
474
+ const kept = (steps ?? [])
475
+ .filter((s) => {
476
+ const type = (s as { type?: unknown } | null)?.type;
477
+ return typeof type === "string" && type !== "user_input" && type !== "function_result";
478
+ })
479
+ .map((s) => {
480
+ const { signature, ...rest } = s as Record<string, unknown>;
481
+ return signature === "" ? rest : s;
482
+ });
483
+ return kept.length > 0 ? { provider: "google", steps: kept } : undefined;
484
+ }
485
+
486
+ // Appends streamed text to the last text block of a step's `content`/`summary`, or starts one; in: step.delta, out: the assembled stream step.
487
+ function appendBlockText(step: Record<string, unknown> | undefined, key: "content" | "summary", text: string): void {
488
+ if (!step) return;
489
+ const blocks = (Array.isArray(step[key]) ? step[key] : (step[key] = [])) as Array<{ type?: string; text?: string }>;
490
+ const last = blocks[blocks.length - 1];
491
+ if (last?.type === "text" && typeof last.text === "string") last.text += text;
492
+ else blocks.push({ type: "text", text });
493
+ }
494
+
495
+ // Joins the `text` of content/summary blocks; in: a `step.start` step's `content` or `summary`, out: stream text/thinking.
496
+ function blockText(blocks: unknown): string {
497
+ if (!Array.isArray(blocks)) return "";
498
+ return blocks.map((b) => (typeof b?.text === "string" ? b.text : "")).join("");
499
+ }
500
+
368
501
  function resultToText(result: unknown): string {
369
502
  if (typeof result === "string") return result;
370
503
  return safeStringify(result);
371
504
  }
372
505
 
506
+ // Builds the documented `function_result` step (`is_error` only when the tool failed); in: a canonical tool_result part, out: chained or full-history `input`.
507
+ function functionResultStep(part: ToolResultPart): InteractionInputStep {
508
+ return {
509
+ type: "function_result",
510
+ name: part.name,
511
+ call_id: part.id,
512
+ result: [{ type: "text", text: resultToText(part.result) }],
513
+ ...(part.isError ? { is_error: true } : {}),
514
+ };
515
+ }
516
+
373
517
  /**
374
518
  * Latest-turn input for stateful chaining (the server already holds history):
375
519
  * everything AFTER the last assistant message, in order. That is normally one
@@ -407,12 +551,7 @@ async function latestTurnInput(context: ProviderContext): Promise<string | Inter
407
551
  if (!Array.isArray(m.content)) continue;
408
552
  for (const part of m.content) {
409
553
  if (part.type === "tool_result") {
410
- steps.push({
411
- type: "function_result",
412
- name: part.name,
413
- call_id: part.id,
414
- result: [{ type: "text", text: resultToText(part.result) }],
415
- });
554
+ steps.push(functionResultStep(part));
416
555
  }
417
556
  }
418
557
  } else if (m.role === "user") {
@@ -427,7 +566,7 @@ async function latestTurnInput(context: ProviderContext): Promise<string | Inter
427
566
  return steps;
428
567
  }
429
568
 
430
- /** Full-history Steps for stateless (provider-switch) turns. */
569
+ // Rebuilds the whole conversation as Interactions steps; Google turns replay their stored raw steps verbatim, others are reconstructed; in: AgentContext messages, out: request `input` on reseed/switch turns.
431
570
  async function fullHistorySteps(context: ProviderContext): Promise<InteractionInputStep[]> {
432
571
  const steps: InteractionInputStep[] = [];
433
572
  for (const m of context.messages) {
@@ -440,6 +579,11 @@ async function fullHistorySteps(context: ProviderContext): Promise<InteractionIn
440
579
  if (blocks.length > 0) steps.push({ type: "user_input", content: blocks });
441
580
  }
442
581
  } else if (m.role === "assistant") {
582
+ // Exact replay: same bytes as the server returned (thought signatures included), best for implicit caching.
583
+ if (m.providerSteps?.provider === "google") {
584
+ steps.push(...(m.providerSteps.steps as InteractionInputStep[]));
585
+ continue;
586
+ }
443
587
  if (typeof m.content === "string") {
444
588
  if (m.content) {
445
589
  steps.push({
@@ -450,6 +594,7 @@ async function fullHistorySteps(context: ProviderContext): Promise<InteractionIn
450
594
  continue;
451
595
  }
452
596
  const texts: string[] = [];
597
+ const turnStart = steps.length;
453
598
  for (const part of m.content) {
454
599
  if (part.type === "thinking" && part.thinking) {
455
600
  // Q-18: preserve namespaced signatures only — foreign-provider
@@ -482,7 +627,7 @@ async function fullHistorySteps(context: ProviderContext): Promise<InteractionIn
482
627
  type: "model_output",
483
628
  content: texts.map((t) => ({ type: "text", text: t })),
484
629
  } as InteractionInputStep);
485
- } else if (m.thoughtSignature && !steps.some((s) => s.type === "thought")) {
630
+ } else if (m.thoughtSignature && !steps.slice(turnStart).some((s) => s.type === "thought")) {
486
631
  // Preserve a signature-only thought so stateless chaining keeps
487
632
  // reasoning continuity even when no summary text was retained.
488
633
  // Q-18: only for google/untagged signatures.
@@ -495,12 +640,7 @@ async function fullHistorySteps(context: ProviderContext): Promise<InteractionIn
495
640
  if (!Array.isArray(m.content)) continue;
496
641
  for (const part of m.content) {
497
642
  if (part.type === "tool_result") {
498
- steps.push({
499
- type: "function_result",
500
- name: part.name,
501
- call_id: part.id,
502
- result: [{ type: "text", text: resultToText(part.result) }],
503
- });
643
+ steps.push(functionResultStep(part));
504
644
  }
505
645
  }
506
646
  }
@@ -604,16 +744,10 @@ function parseInteraction(
604
744
  provider: "google",
605
745
  raw,
606
746
  durationMs,
747
+ providerSteps: replaySteps(interaction.steps),
607
748
  };
608
749
  }
609
750
 
610
- /** Local per-provider signature tag (Q-18): google history preserves only
611
- * google/untagged signatures; foreign-provider signatures never echo here. */
612
- function withGoogleSignature(sig: string | undefined): { thoughtSignature?: string; thoughtSignatureProvider?: string } {
613
- if (!sig) return {};
614
- return { thoughtSignature: sig, thoughtSignatureProvider: "google" };
615
- }
616
-
617
751
  // ---------------------------------------------------------------------------
618
752
  // Provider
619
753
  // ---------------------------------------------------------------------------
@@ -652,7 +786,8 @@ export class GoogleInteractionsProvider implements Provider {
652
786
  return (async () => {
653
787
  const { thinkingLevel } = mapThinkingLevelToGoogle(options?.thinking?.level);
654
788
  const serviceTier = mapServiceTierToGoogle(options?.serviceTier);
655
- applyCacheForGoogle(options?.cache, `google/${modelId}`);
789
+ // The Interactions API caches implicitly only (docs: caching.md).
790
+ resolveCache("google", options?.cache, ["implicit"], `google/${modelId}`);
656
791
 
657
792
  const toolNames = (tools ?? []).map((t) => t.name);
658
793
  const toolChoice = toGoogleToolChoice(options?.toolChoice, toolNames);
@@ -674,6 +809,7 @@ export class GoogleInteractionsProvider implements Provider {
674
809
  model: modelId,
675
810
  input: "",
676
811
  ...(stream ? { stream: true } : {}),
812
+ ...(needsLongDecode(modelId, options?.maxOutputTokens) ? { background: true } : {}),
677
813
  };
678
814
  // Structured outputs: native `response_format` when supported, else
679
815
  // emulated via system instruction (catalog `structured_output: false`
@@ -730,7 +866,8 @@ export class GoogleInteractionsProvider implements Provider {
730
866
  // provider's turns, so send the full canonical history explicitly and
731
867
  // statelessly, then resume chaining fresh on the next turn.
732
868
  body.input = await fullHistorySteps(context);
733
- body.store = false;
869
+ // Background runs need storage (`store: false` is a 400); this turn then simply starts a fresh chain.
870
+ if (!body.background) body.store = false;
734
871
  } else if (continuing) {
735
872
  body.input = await latestTurnInput(context);
736
873
  body.previous_interaction_id = chain!.interactionId;
@@ -748,33 +885,32 @@ export class GoogleInteractionsProvider implements Provider {
748
885
  }
749
886
 
750
887
  private requestInit(
751
- body: InteractionRequestBody,
888
+ method: "POST" | "GET",
889
+ body: InteractionRequestBody | undefined,
752
890
  options: ProviderRequestOptions | undefined,
753
891
  apiKey: string,
754
892
  signal?: AbortSignal
755
893
  ): RequestInit {
756
- const headers: Record<string, string> = { "Content-Type": "application/json" };
757
- for (const [k, v] of Object.entries(options?.headers ?? {})) {
758
- if (!INTERNAL_HEADERS.has(k.toLowerCase())) headers[k] = v;
759
- }
894
+ const headers: Record<string, string> = { "Content-Type": "application/json", ...options?.headers };
760
895
  // Q-17: key travels as the `x-goog-api-key` header (never `?key=` in the
761
896
  // URL, which would leak into logs). Case-insensitive merge so a custom
762
897
  // `X-Goog-Api-Key` never duplicates the canonical header. Proxy flows
763
898
  // (`apiKey === "unused"`) preserve custom auth instead of overwriting.
764
899
  if (apiKey !== "unused") setHeaderCaseInsensitive(headers, "x-goog-api-key", apiKey);
765
- return { method: "POST", headers, body: JSON.stringify(body), signal };
900
+ return { method, headers, ...(body ? { body: JSON.stringify(body) } : {}), signal };
766
901
  }
767
902
 
768
903
  private async doFetch(
769
904
  url: string,
770
- body: InteractionRequestBody,
905
+ method: "POST" | "GET",
906
+ body: InteractionRequestBody | undefined,
771
907
  options: ProviderRequestOptions | undefined,
772
908
  apiKey: string,
773
909
  signal?: AbortSignal
774
910
  ): Promise<{ status: number; statusText: string; headers: Record<string, string>; text: string }> {
775
911
  const { signal: effective, cleanup } = combinedSignal(signal, timeoutFor(options as { timeoutMs?: number } | undefined));
776
912
  try {
777
- const res = await (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, effective));
913
+ const res = await (options?.fetch ?? fetch)(url, this.requestInit(method, body, options, apiKey, effective));
778
914
  const text = await res.text();
779
915
  const headers: Record<string, string> = {};
780
916
  res.headers.forEach((v, k) => {
@@ -815,6 +951,45 @@ export class GoogleInteractionsProvider implements Provider {
815
951
  throw toConciseProviderError(err, "google", modelId);
816
952
  }
817
953
 
954
+ /** Poll delays (ms) for background interactions: before the first poll, then between polls. */
955
+ pollDelaysMs: [number, number] = [2_000, 5_000];
956
+
957
+ /** True for Gemini 4 long decode (`maxOutputTokens` above 65,536): the request runs in the background for up to 24 h. */
958
+ isLongRunning(model: string | ModelSpec, options?: ProviderRequestOptions): boolean {
959
+ return needsLongDecode(cleanModelId(model), options?.maxOutputTokens);
960
+ }
961
+
962
+ // One Interactions HTTP call parsed into its interaction (errors mapped concisely); in: create POST or poll GET, out: generate.
963
+ private async fetchInteraction(
964
+ url: string,
965
+ method: "POST" | "GET",
966
+ body: InteractionRequestBody | undefined,
967
+ options: ProviderRequestOptions | undefined,
968
+ apiKey: string,
969
+ modelId: string
970
+ ): Promise<InteractionReply> {
971
+ const res = await this.doFetch(url, method, body, options, apiKey, options?.signal);
972
+ this.throwIfError(res.status, res.statusText, url, res.text, modelId, res.headers);
973
+ try {
974
+ return { status: res.status, statusText: res.statusText, headers: res.headers, interaction: JSON.parse(res.text) as InteractionObject };
975
+ } catch {
976
+ throw toConciseProviderError(
977
+ Object.assign(new Error("Invalid JSON response from Google Interactions API"), {
978
+ statusCode: res.status,
979
+ responseBody: res.text.slice(0, 500),
980
+ url,
981
+ }),
982
+ "google",
983
+ modelId
984
+ );
985
+ }
986
+ }
987
+
988
+ // Fire-and-forget cancel so an aborted background run stops billing; in: an aborted generate/stream, out: POST {id}/cancel.
989
+ private cancelInteraction(baseUrl: string, id: string, options: ProviderRequestOptions | undefined, apiKey: string): void {
990
+ void this.doFetch(`${baseUrl}/interactions/${id}/cancel`, "POST", undefined, options, apiKey).catch(() => {});
991
+ }
992
+
818
993
  async generate(
819
994
  model: string | ModelSpec,
820
995
  context: ProviderContext,
@@ -836,7 +1011,7 @@ export class GoogleInteractionsProvider implements Provider {
836
1011
  assertModalitiesSupported(context, "google", clean);
837
1012
  const baseUrl = resolveBaseUrl(options);
838
1013
  const url = `${baseUrl}/interactions`;
839
- const sessionId = options?.sessionId || options?.cache?.sessionId;
1014
+ const sessionId = options?.sessionId;
840
1015
  const tools = options?.tools as StandardToolDeclaration[] | undefined;
841
1016
  let body = await this.buildBody(clean, context, options, sessionId, tools, false);
842
1017
 
@@ -845,32 +1020,39 @@ export class GoogleInteractionsProvider implements Provider {
845
1020
  method: "POST",
846
1021
  headers: redactedHeaders({
847
1022
  "Content-Type": "application/json",
848
- ...Object.fromEntries(
849
- Object.entries(options?.headers ?? {}).filter(([k]) => !INTERNAL_HEADERS.has(k.toLowerCase()))
850
- ),
1023
+ ...options?.headers,
851
1024
  "x-goog-api-key": "[REDACTED]",
852
1025
  }),
853
1026
  body: b,
854
1027
  });
855
1028
  let rawRequest = makeRawRequest(body);
1029
+ const retryOptions = {
1030
+ ...retryOptionsFrom(options),
1031
+ signal: options?.signal,
1032
+ label: { providerId: "google", modelId: clean },
1033
+ };
856
1034
 
857
- const doCall = async (): Promise<ProviderGenerateResult> => {
858
- const res = await this.doFetch(url, body, options, apiKey, options?.signal);
859
- this.throwIfError(res.status, res.statusText, url, res.text, clean, res.headers);
860
- let interaction: InteractionObject;
1035
+ // Polls a background interaction until it leaves queued/in_progress; an abort also cancels it server-side.
1036
+ const waitForCompletion = async (reply: InteractionReply): Promise<InteractionReply> => {
1037
+ const id = reply.interaction.id;
1038
+ if (!id) return reply;
861
1039
  try {
862
- interaction = JSON.parse(res.text) as InteractionObject;
863
- } catch {
864
- throw toConciseProviderError(
865
- Object.assign(new Error("Invalid JSON response from Google Interactions API"), {
866
- statusCode: res.status,
867
- responseBody: res.text.slice(0, 500),
868
- url,
869
- }),
870
- "google",
871
- clean
872
- );
1040
+ for (let poll = 0; isRunning(reply.interaction.status); poll++) {
1041
+ await sleep(this.pollDelaysMs[poll === 0 ? 0 : 1], options?.signal);
1042
+ reply = await withRetries(() => this.fetchInteraction(`${url}/${id}`, "GET", undefined, options, apiKey, clean), retryOptions);
1043
+ }
1044
+ return reply;
1045
+ } catch (err) {
1046
+ if (options?.signal?.aborted || isAbortError(err)) this.cancelInteraction(baseUrl, id, options, apiKey);
1047
+ throw err;
873
1048
  }
1049
+ };
1050
+
1051
+ // Create (retried as a whole), wait if it runs in the background, then record the chain and map the result.
1052
+ const run = async (): Promise<ProviderGenerateResult> => {
1053
+ let reply = await withRetries(() => this.fetchInteraction(url, "POST", body, options, apiKey, clean), retryOptions);
1054
+ if (body.background) reply = await waitForCompletion(reply);
1055
+ const { interaction } = reply;
874
1056
  if (interaction.id && sessionId && body.store !== false) {
875
1057
  setChain(sessionId, { interactionId: interaction.id, model: clean, at: Date.now() });
876
1058
  } else if (body.store === false && sessionId) {
@@ -882,19 +1064,14 @@ export class GoogleInteractionsProvider implements Provider {
882
1064
  interaction,
883
1065
  clean,
884
1066
  Date.now() - startTime,
885
- { request: rawRequest, response: { status: res.status, statusText: res.statusText, headers: res.headers, body: interaction } }
1067
+ { request: rawRequest, response: { status: reply.status, statusText: reply.statusText, headers: reply.headers, body: interaction } }
886
1068
  );
887
1069
  checkTurnTokens(sessionId, parsed.usage);
888
1070
  return parsed;
889
1071
  };
890
1072
 
891
- const retryOptions = {
892
- ...retryOptionsFrom(options),
893
- signal: options?.signal,
894
- label: { providerId: "google", modelId: clean },
895
- };
896
1073
  try {
897
- return await withRetries(doCall, retryOptions);
1074
+ return await run();
898
1075
  } catch (err) {
899
1076
  if (err instanceof Error && (err as { name?: string }).name === "AbortError") throw err;
900
1077
  // Reactive self-heal (Rule 2): a chained pointer the server no longer
@@ -905,7 +1082,7 @@ export class GoogleInteractionsProvider implements Provider {
905
1082
  clearInteractionChains(sessionId);
906
1083
  body = await this.buildBody(clean, context, options, sessionId, tools, false);
907
1084
  rawRequest = makeRawRequest(body);
908
- return await withRetries(doCall, retryOptions);
1085
+ return await run();
909
1086
  }
910
1087
  throw err;
911
1088
  }
@@ -943,6 +1120,8 @@ export class GoogleInteractionsProvider implements Provider {
943
1120
  // visible in catch).
944
1121
  let evictSessionId: string | undefined;
945
1122
  let usedChain = false;
1123
+ // Set once a background interaction id is known: an abort then cancels it server-side.
1124
+ let cancelBackground = (): void => {};
946
1125
  try {
947
1126
  // Hard generation floor (mirrors generate): no network on legacy ids.
948
1127
  assertSupportedGeneration(clean);
@@ -955,7 +1134,7 @@ export class GoogleInteractionsProvider implements Provider {
955
1134
  assertModalitiesSupported(context, "google", clean);
956
1135
  const baseUrl = resolveBaseUrl(options);
957
1136
  const url = `${baseUrl}/interactions?alt=sse`;
958
- const sessionId = options?.sessionId || options?.cache?.sessionId;
1137
+ const sessionId = options?.sessionId;
959
1138
  const tools = options?.tools as StandardToolDeclaration[] | undefined;
960
1139
  const body = await this.buildBody(clean, context, options, sessionId, tools, true);
961
1140
  evictSessionId = sessionId;
@@ -969,39 +1148,39 @@ export class GoogleInteractionsProvider implements Provider {
969
1148
  method: "POST",
970
1149
  headers: redactedHeaders({
971
1150
  "Content-Type": "application/json",
972
- ...Object.fromEntries(
973
- Object.entries(options?.headers ?? {}).filter(([k]) => !INTERNAL_HEADERS.has(k.toLowerCase()))
974
- ),
1151
+ ...options?.headers,
975
1152
  "x-goog-api-key": "[REDACTED]",
976
1153
  }),
977
1154
  body,
978
1155
  };
979
1156
 
980
1157
  const streamTimeoutMs = timeoutFor(options as { timeoutMs?: number } | undefined);
981
- // Transient failures (429/5xx/network) are retried here, before the
982
- // first byte; once the stream is live it is never retried.
983
- const res = await openStreamResponse({
984
- fetchOnce: async () => {
985
- const timer =
986
- streamTimeoutMs && streamTimeoutMs > 0
987
- ? setTimeout(() => {
988
- try {
989
- linked.abort();
990
- } catch {}
991
- }, streamTimeoutMs)
992
- : undefined;
993
- try {
994
- return await (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, linked.signal));
995
- } finally {
996
- if (timer) clearTimeout(timer);
997
- }
998
- },
999
- onHttpError: (r, text) => this.throwIfError(r.status, r.statusText, url, text, clean, headersToRecord(r.headers)),
1000
- noBodyError: () => toConciseProviderError(new Error("Google streaming response had no body"), "google", clean),
1001
- retry: retryOptionsFrom(options),
1002
- signal: linked.signal,
1003
- label: { providerId: "google", modelId: clean },
1004
- });
1158
+ // Opens one SSE connection (the create POST, or a background resume GET). Transient failures
1159
+ // (429/5xx/network) are retried before the first byte; a live stream is never retried.
1160
+ const open = (target: string, method: "POST" | "GET"): Promise<Response> =>
1161
+ openStreamResponse({
1162
+ fetchOnce: async () => {
1163
+ const timer =
1164
+ streamTimeoutMs && streamTimeoutMs > 0
1165
+ ? setTimeout(() => {
1166
+ try {
1167
+ linked.abort();
1168
+ } catch {}
1169
+ }, streamTimeoutMs)
1170
+ : undefined;
1171
+ try {
1172
+ return await (options?.fetch ?? fetch)(target, this.requestInit(method, method === "POST" ? body : undefined, options, apiKey, linked.signal));
1173
+ } finally {
1174
+ if (timer) clearTimeout(timer);
1175
+ }
1176
+ },
1177
+ onHttpError: (r, text) => this.throwIfError(r.status, r.statusText, target, text, clean, headersToRecord(r.headers)),
1178
+ noBodyError: () => toConciseProviderError(new Error("Google streaming response had no body"), "google", clean),
1179
+ retry: retryOptionsFrom(options),
1180
+ signal: linked.signal,
1181
+ label: { providerId: "google", modelId: clean },
1182
+ });
1183
+ const res = await open(url, "POST");
1005
1184
  const responseHeaders: Record<string, string> = {};
1006
1185
  res.headers.forEach((v, k) => {
1007
1186
  responseHeaders[k] = v;
@@ -1009,13 +1188,14 @@ export class GoogleInteractionsProvider implements Provider {
1009
1188
  const responseMeta: Record<string, unknown> & { status: number; statusText: string; headers: Record<string, string> } = { status: res.status, statusText: res.statusText, headers: responseHeaders };
1010
1189
  eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
1011
1190
 
1012
- const parser = new SSEParser();
1013
- const reader = res.body!.getReader(); // openStreamResponse guarantees a body
1014
- const decoder = new TextDecoder();
1191
+ let reader: StreamReader | undefined;
1192
+ let lastEventId: string | undefined;
1015
1193
  let text = "";
1016
1194
  let thinking = "";
1017
1195
  let signature: string | undefined;
1018
1196
  const calls = new Map<number, { id: string; name: string; startArgs: string; deltaArgs: string }>();
1197
+ // Every step as the server would return it (start + deltas), for exact history replay.
1198
+ const steps = new Map<number, Record<string, unknown>>();
1019
1199
  let usage: TokenUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
1020
1200
  let finishReason = "stop";
1021
1201
  let sawFinishReason = false;
@@ -1030,11 +1210,14 @@ export class GoogleInteractionsProvider implements Provider {
1030
1210
  () => {
1031
1211
  aborted = true;
1032
1212
  try {
1033
- void reader.cancel();
1213
+ void reader?.cancel();
1034
1214
  } catch {}
1035
1215
  },
1036
1216
  { once: true }
1037
1217
  );
1218
+ cancelBackground = () => {
1219
+ if (body.background && responseId) this.cancelInteraction(baseUrl, responseId, options, apiKey);
1220
+ };
1038
1221
 
1039
1222
  const handleMessage = (event: string | undefined, data: string): void => {
1040
1223
  if (!data) return;
@@ -1051,6 +1234,7 @@ export class GoogleInteractionsProvider implements Provider {
1051
1234
  return;
1052
1235
  }
1053
1236
  const type = (msg["event_type"] as string) || event;
1237
+ if (typeof msg["event_id"] === "string") lastEventId = msg["event_id"] as string;
1054
1238
  if (type === "error") {
1055
1239
  // SSE error event (e.g. rate_limit_exceeded mid-stream). Must
1056
1240
  // surface — silently ending with empty text + zero usage hides it.
@@ -1070,32 +1254,48 @@ export class GoogleInteractionsProvider implements Provider {
1070
1254
  const delta = (msg["delta"] as Record<string, unknown>) ?? {};
1071
1255
  const dType = delta["type"] as string;
1072
1256
  if (dType === "text" && typeof delta["text"] === "string") {
1257
+ appendBlockText(steps.get(index), "content", delta["text"] as string);
1073
1258
  text += delta["text"] as string;
1074
1259
  eventStream.push({ type: "text_delta", delta: delta["text"] as string, partialText: text });
1075
1260
  } else if (dType === "thought_summary") {
1076
1261
  const content = delta["content"] as { type?: string; text?: string } | undefined;
1077
1262
  const t = content?.text ?? (typeof delta["text"] === "string" ? (delta["text"] as string) : "");
1078
1263
  if (t) {
1264
+ appendBlockText(steps.get(index), "summary", t);
1079
1265
  thinking += t;
1080
1266
  eventStream.push({ type: "thinking_delta", thinkingDelta: t, partialThinking: thinking });
1081
1267
  }
1082
1268
  } else if (dType === "thought_signature" && typeof delta["signature"] === "string") {
1269
+ const s = steps.get(index);
1270
+ if (s) s["signature"] = delta["signature"];
1083
1271
  // Wire truth: signature arrives as its own delta (no text event).
1084
1272
  if (!signature) signature = delta["signature"] as string;
1085
1273
  } else if (dType === "arguments" || dType === "arguments_delta") {
1086
- // Wire truth is `arguments`; prose says `partial_arguments` — accept both.
1087
- // Deltas accumulate separately from step.start (see
1088
- // parseStreamedToolArguments): start may be `{}` with the full
1089
- // JSON in one delta, or a genuine first partial.
1090
- const chunk =
1091
- (delta["arguments"] as string) ?? (delta["partial_arguments"] as string) ?? "";
1274
+ // `arguments` carries a JSON string chunk (docs: streaming.md). Deltas accumulate
1275
+ // separately from step.start (see parseStreamedToolArguments): start may be `{}`
1276
+ // with the full JSON in one delta, or a genuine first partial.
1277
+ const chunk = (delta["arguments"] as string) ?? "";
1092
1278
  const entry = calls.get(index);
1093
1279
  if (entry && typeof chunk === "string") entry.deltaArgs += chunk;
1094
1280
  }
1095
1281
  } else if (type === "step.start") {
1096
1282
  const index = (msg["index"] as number) ?? 0;
1097
1283
  const step = (msg["step"] as Record<string, unknown>) ?? {};
1098
- if (step["type"] === "function_call") {
1284
+ steps.set(index, step);
1285
+ if (step["type"] === "model_output") {
1286
+ const t = blockText(step["content"]);
1287
+ if (t) {
1288
+ text += t;
1289
+ eventStream.push({ type: "text_delta", delta: t, partialText: text });
1290
+ }
1291
+ } else if (step["type"] === "thought") {
1292
+ const t = blockText(step["summary"]);
1293
+ if (t) {
1294
+ thinking += t;
1295
+ eventStream.push({ type: "thinking_delta", thinkingDelta: t, partialThinking: thinking });
1296
+ }
1297
+ if (!signature && typeof step["signature"] === "string" && step["signature"]) signature = step["signature"];
1298
+ } else if (step["type"] === "function_call") {
1099
1299
  const args = step["arguments"];
1100
1300
  const startArgs = typeof args === "string" ? args : args ? JSON.stringify(args) : "";
1101
1301
  calls.set(index, {
@@ -1105,6 +1305,13 @@ export class GoogleInteractionsProvider implements Provider {
1105
1305
  deltaArgs: "",
1106
1306
  });
1107
1307
  }
1308
+ } else if (type === "step.stop" || type === "interaction.status_update") {
1309
+ // Cumulative usage so far: keeps usage when the stream is cut before `interaction.completed`.
1310
+ const stepUsage = msg["usage"] as InteractionObject["usage"] | undefined;
1311
+ if (stepUsage) {
1312
+ usage = mapUsage(stepUsage);
1313
+ sawUsage = true;
1314
+ }
1108
1315
  } else if (type === "interaction.completed") {
1109
1316
  const interaction = (msg["interaction"] as InteractionObject) ?? {};
1110
1317
  // Failed/cancelled interactions must surface loudly — never
@@ -1122,30 +1329,57 @@ export class GoogleInteractionsProvider implements Provider {
1122
1329
  }
1123
1330
  };
1124
1331
 
1125
- while (true) {
1126
- if (linked.signal.aborted || eventStream.isCancelled()) {
1332
+ // Reads one connection to its end. A background connection that drops or stalls just ends
1333
+ // (the caller reconnects); provider error events from handleMessage always throw.
1334
+ const readAll = async (response: Response): Promise<void> => {
1335
+ const parser = new SSEParser();
1336
+ const decoder = new TextDecoder();
1337
+ const current = (reader = response.body!.getReader()); // openStreamResponse guarantees a body
1338
+ try {
1339
+ while (true) {
1340
+ if (linked.signal.aborted || eventStream.isCancelled()) {
1341
+ try {
1342
+ await current.cancel();
1343
+ } catch {}
1344
+ throw Object.assign(new Error("Stream aborted"), { name: "AbortError" });
1345
+ }
1346
+ let chunk: { done: boolean; value?: Uint8Array };
1347
+ try {
1348
+ chunk = body.background ? await readWithin(current, BACKGROUND_STREAM_STALL_MS) : await current.read();
1349
+ } catch (err) {
1350
+ if (!body.background || aborted || isAbortError(err)) throw err;
1351
+ break;
1352
+ }
1353
+ if (chunk.done) break;
1354
+ for (const m of parser.feed(decoder.decode(chunk.value, { stream: true }))) handleMessage(m.event, m.data);
1355
+ }
1356
+ for (const m of parser.flush()) handleMessage(m.event, m.data);
1357
+ } finally {
1127
1358
  try {
1128
- await reader.cancel();
1359
+ current.releaseLock();
1129
1360
  } catch {}
1130
- throw Object.assign(new Error("Stream aborted"), { name: "AbortError" });
1131
1361
  }
1132
- const { done, value } = await reader.read();
1133
- if (done) break;
1134
- const chunk = decoder.decode(value, { stream: true });
1135
- for (const m of parser.feed(chunk)) handleMessage(m.event, m.data);
1362
+ };
1363
+
1364
+ await readAll(res);
1365
+ // Long decode: a background stream may drop; resume after the last event until the interaction ends.
1366
+ for (let misses = 0; body.background && completedBody === undefined && !aborted; ) {
1367
+ if (!responseId || ++misses > BACKGROUND_MAX_RECONNECTS) throw incompleteStreamError("google", clean);
1368
+ const before = framesSeen;
1369
+ const resume = `${baseUrl}/interactions/${responseId}?stream=true${lastEventId ? `&last_event_id=${encodeURIComponent(lastEventId)}` : ""}`;
1370
+ await readAll(await open(resume, "GET"));
1371
+ if (framesSeen > before) misses = 0;
1136
1372
  }
1137
- for (const m of parser.flush()) handleMessage(m.event, m.data);
1138
- try {
1139
- reader.releaseLock();
1140
- } catch {}
1141
1373
 
1142
1374
  if (linked.signal.aborted || eventStream.isCancelled() || aborted) {
1143
1375
  throw Object.assign(new Error("Stream aborted"), { name: "AbortError" });
1144
1376
  }
1145
1377
 
1146
1378
  const toolCalls: ToolCallRecord[] = [];
1147
- for (const c of calls.values()) {
1379
+ for (const [index, c] of calls) {
1148
1380
  const args = parseStreamedToolArguments(c.startArgs, c.deltaArgs);
1381
+ const step = steps.get(index);
1382
+ if (step) step["arguments"] = args;
1149
1383
  const record: ToolCallRecord = {
1150
1384
  id: c.id,
1151
1385
  name: c.name,
@@ -1200,6 +1434,7 @@ export class GoogleInteractionsProvider implements Provider {
1200
1434
  response: { ...responseMeta, body: completedBody, badFrames } as unknown as ProviderRawData["response"],
1201
1435
  },
1202
1436
  durationMs: Date.now() - startTime,
1437
+ providerSteps: replaySteps([...steps.entries()].sort(([a], [b]) => a - b).map(([, s]) => s)),
1203
1438
  });
1204
1439
  eventStream.push({ type: "done", delta: "", usage, finishReason, responseId });
1205
1440
  eventStream.end(finalResponse);
@@ -1210,6 +1445,7 @@ export class GoogleInteractionsProvider implements Provider {
1210
1445
  eventStream.isCancelled() ||
1211
1446
  (raw as { name?: string }).name === "AbortError";
1212
1447
  if (isAbort) {
1448
+ cancelBackground();
1213
1449
  eventStream.fail(
1214
1450
  Object.assign(new Error("Stream aborted"), { name: "AbortError" })
1215
1451
  );