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.
- package/README.md +66 -59
- package/package.json +7 -14
- package/src/agent/agent.ts +14 -49
- package/src/agent/context.ts +5 -4
- package/src/agent/delegation.ts +1 -9
- package/src/agent/loop.ts +22 -84
- package/src/agent/subagent.ts +1 -2
- package/src/agent/worker.ts +3 -6
- package/src/index.ts +6 -41
- package/src/providers/google.ts +390 -154
- package/src/providers/openai-compat.ts +12 -27
- package/src/providers/openai.ts +13 -35
- package/src/providers/openrouter.ts +11 -29
- package/src/providers.ts +32 -124
- package/src/session/store.ts +18 -29
- package/src/types/agent.ts +10 -15
- package/src/types/core.ts +5 -32
- package/src/types/message.ts +15 -1
- package/src/types/model.ts +7 -3
- package/src/types/response.ts +5 -0
- package/src/utils/cache.ts +1 -137
- package/src/utils/errors.ts +1 -4
- package/src/utils/headers.ts +3 -19
- package/src/utils/retry.ts +2 -1
- package/src/utils/safe-io.ts +2 -2
- package/src/utils/documents.ts +0 -577
package/src/providers/google.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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"
|
|
343
|
-
blocks.push({ type: "text", text: part.text });
|
|
344
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
900
|
+
return { method, headers, ...(body ? { body: JSON.stringify(body) } : {}), signal };
|
|
766
901
|
}
|
|
767
902
|
|
|
768
903
|
private async doFetch(
|
|
769
904
|
url: string,
|
|
770
|
-
|
|
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
|
|
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
|
-
...
|
|
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
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
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
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
-
...
|
|
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
|
-
//
|
|
982
|
-
//
|
|
983
|
-
const
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
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
|
-
|
|
1013
|
-
|
|
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
|
|
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
|
-
//
|
|
1087
|
-
//
|
|
1088
|
-
//
|
|
1089
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1126
|
-
|
|
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
|
-
|
|
1359
|
+
current.releaseLock();
|
|
1129
1360
|
} catch {}
|
|
1130
|
-
throw Object.assign(new Error("Stream aborted"), { name: "AbortError" });
|
|
1131
1361
|
}
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
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
|
|
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
|
);
|