agent-accelerator 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +149 -57
- package/bunfig.toml +1 -0
- package/package.json +2 -3
- package/src/agent/agent.ts +414 -118
- package/src/agent/context.ts +11 -16
- package/src/agent/delegation.ts +111 -274
- package/src/agent/loop.ts +327 -99
- package/src/agent/subagent.ts +4 -12
- package/src/agent/worker.ts +435 -0
- package/src/index.ts +58 -5
- package/src/models/catalog-cache.ts +65 -25
- package/src/models/catalog.ts +47 -2
- package/src/providers/google.ts +338 -81
- package/src/providers/openai-compat.ts +171 -61
- package/src/providers/openai.ts +174 -44
- package/src/providers/openrouter.ts +129 -44
- package/src/providers/shared.ts +169 -17
- package/src/providers.ts +375 -11
- package/src/scheduler/scheduled-actions.md +41 -0
- package/src/scheduler/scheduler.ts +814 -0
- package/src/scheduler/store.ts +263 -0
- package/src/scheduler/time.ts +148 -0
- package/src/scheduler/tool.ts +151 -0
- package/src/session/store.ts +229 -113
- package/src/streaming/event-stream.ts +6 -4
- package/src/telemetry-file.ts +31 -0
- package/src/telemetry.ts +65 -0
- package/src/tools/executor.ts +163 -38
- package/src/tools/subagent-result.ts +60 -0
- package/src/types/agent.ts +74 -22
- package/src/types/model.ts +39 -0
- package/src/types/response.ts +9 -0
- package/src/types/scheduler.ts +81 -0
- package/src/types/tool.ts +15 -0
- package/src/utils/cache.ts +14 -5
- package/src/utils/documents.ts +112 -52
- package/src/utils/env.ts +21 -7
- package/src/utils/errors.ts +26 -1
- package/src/utils/headers.ts +15 -15
- package/src/utils/home.ts +30 -0
- package/src/utils/media.ts +57 -12
- package/src/utils/raw-capture.ts +76 -0
- package/src/utils/retry.ts +11 -3
- package/src/utils/safe-io.ts +252 -0
- package/src/utils/session.ts +7 -8
- package/src/data/README.md +0 -84
package/src/providers/google.ts
CHANGED
|
@@ -16,9 +16,10 @@
|
|
|
16
16
|
* - Stateful chaining via `previous_interaction_id` keyed by session; falls
|
|
17
17
|
* back to stateless full-history (`store: false`) when the session mixed
|
|
18
18
|
* providers, keeping the canonical conversation provider-agnostic.
|
|
19
|
-
* - Unlike the legacy transport path, Gemini 2.x is
|
|
20
|
-
* the
|
|
21
|
-
*
|
|
19
|
+
* - Unlike the legacy transport path, Gemini 2.x is rejected here —
|
|
20
|
+
* the minimum supported generation is Gemini 3 (deprecated 1.x/2.x fail
|
|
21
|
+
* fast before any network call); the catalog still governs thinking
|
|
22
|
+
* validation upstream.
|
|
22
23
|
*/
|
|
23
24
|
import type {
|
|
24
25
|
Provider,
|
|
@@ -43,14 +44,23 @@ import { withRetries } from "../utils/retry.ts";
|
|
|
43
44
|
import { setHeaderCaseInsensitive, shouldEchoSignature } from "../utils/headers.ts";
|
|
44
45
|
import {
|
|
45
46
|
newToolCallId,
|
|
47
|
+
parseArguments,
|
|
46
48
|
redactedHeaders,
|
|
47
49
|
readErrorPayload,
|
|
48
50
|
incompleteStreamError,
|
|
51
|
+
isNotFoundChainError,
|
|
49
52
|
combinedSignal,
|
|
50
53
|
timeoutFor,
|
|
54
|
+
hasAuthHeader,
|
|
55
|
+
clampToolCallId,
|
|
56
|
+
openStreamResponse,
|
|
57
|
+
retryOptionsFrom,
|
|
58
|
+
headersToRecord,
|
|
59
|
+
parseRetryAfterMs,
|
|
51
60
|
} from "./shared.ts";
|
|
61
|
+
import { telemetryBus } from "../telemetry.ts";
|
|
52
62
|
import { createGenericModelSpec } from "../models/catalog.ts";
|
|
53
|
-
import { getModelFromCatalog, getModelsForProvider } from "../models/catalog.ts";
|
|
63
|
+
import { getModelFromCatalog, getModelsForProvider, modelSupportsStructuredOutput } from "../models/catalog.ts";
|
|
54
64
|
import {
|
|
55
65
|
mapThinkingLevelToGoogle,
|
|
56
66
|
mapServiceTierToGoogle,
|
|
@@ -59,6 +69,12 @@ import {
|
|
|
59
69
|
lastProviderFor,
|
|
60
70
|
noteProviderTurn,
|
|
61
71
|
parseStreamedToolArguments,
|
|
72
|
+
selectRawArguments,
|
|
73
|
+
mapOutputToGoogle,
|
|
74
|
+
mapGenerationParamsToGoogle,
|
|
75
|
+
normalizeStructuredOutput,
|
|
76
|
+
augmentSystemPromptWithStructuredOutput,
|
|
77
|
+
emitProviderWarning,
|
|
62
78
|
} from "../providers.ts";
|
|
63
79
|
|
|
64
80
|
// ---------------------------------------------------------------------------
|
|
@@ -74,7 +90,7 @@ type InteractionContentBlock =
|
|
|
74
90
|
|
|
75
91
|
type InteractionInputStep =
|
|
76
92
|
| { type: "user_input"; content: string | InteractionContentBlock[] }
|
|
77
|
-
| { type: "thought"; signature
|
|
93
|
+
| { type: "thought"; signature?: string; summary?: Array<{ type: string; text?: string }> }
|
|
78
94
|
| { type: "function_call"; id: string; name: string; arguments: Record<string, unknown> }
|
|
79
95
|
| {
|
|
80
96
|
type: "function_result";
|
|
@@ -116,6 +132,7 @@ interface InteractionObject {
|
|
|
116
132
|
created?: string;
|
|
117
133
|
updated?: string;
|
|
118
134
|
service_tier?: string;
|
|
135
|
+
error?: { message?: string; code?: string | number } | null;
|
|
119
136
|
steps?: InteractionStep[];
|
|
120
137
|
usage?: {
|
|
121
138
|
total_tokens?: number;
|
|
@@ -143,9 +160,34 @@ const INTERNAL_HEADERS = new Set([
|
|
|
143
160
|
interface ChainEntry {
|
|
144
161
|
interactionId: string;
|
|
145
162
|
model: string;
|
|
163
|
+
/** Wall-clock ms of the turn that established this chain (drives the TTL gate). */
|
|
164
|
+
at: number;
|
|
146
165
|
}
|
|
147
166
|
const interactionChains = new Map<string, ChainEntry>();
|
|
148
167
|
|
|
168
|
+
/** Interaction storage TTLs: free 24h / paid 55d; local gate fires 1h/1d early. */
|
|
169
|
+
const FREE_CHAIN_TTL_MS = 23 * 3600 * 1000;
|
|
170
|
+
const PAID_CHAIN_TTL_MS = 54 * 24 * 3600 * 1000;
|
|
171
|
+
|
|
172
|
+
/** Last-turn total tokens per session for the rehydration invariant (Rule 3). */
|
|
173
|
+
const lastTurnTotals = new Map<string, number>();
|
|
174
|
+
|
|
175
|
+
function resolveInteractionTier(options?: ProviderRequestOptions): "free" | "paid" {
|
|
176
|
+
return options?.googleTier === "paid" ? "paid" : "free";
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function chainTtlMs(options?: ProviderRequestOptions): number {
|
|
180
|
+
return resolveInteractionTier(options) === "paid" ? PAID_CHAIN_TTL_MS : FREE_CHAIN_TTL_MS;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Emits a chain anomaly on the telemetry bus (+ legacy global hook). Never throws. */
|
|
184
|
+
function reportChainAnomaly(detail: { type: string; [k: string]: unknown }): void {
|
|
185
|
+
telemetryBus.emitWarning(detail.type, `[Agent Accelerator] [google] ${String(detail.type)}`, detail);
|
|
186
|
+
try {
|
|
187
|
+
(globalThis as unknown as { __agentAccelAnomaly?: (d: unknown) => void }).__agentAccelAnomaly?.(detail);
|
|
188
|
+
} catch {}
|
|
189
|
+
}
|
|
190
|
+
|
|
149
191
|
function chainFor(sessionId: string | undefined): ChainEntry | undefined {
|
|
150
192
|
if (!sessionId) return undefined;
|
|
151
193
|
return interactionChains.get(sessionId);
|
|
@@ -156,10 +198,57 @@ function setChain(sessionId: string | undefined, entry: ChainEntry): void {
|
|
|
156
198
|
interactionChains.set(sessionId, entry);
|
|
157
199
|
}
|
|
158
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Drops a chain entry whose storage TTL elapsed locally (Rule 1: zero wasted
|
|
203
|
+
* RTT). Returns undefined when gated so the caller falls through to the
|
|
204
|
+
* full-history seeding branch. Also resets the token baseline so the reseed
|
|
205
|
+
* never trips the rehydration invariant.
|
|
206
|
+
*/
|
|
207
|
+
function liveChainFor(sessionId: string | undefined, options?: ProviderRequestOptions): ChainEntry | undefined {
|
|
208
|
+
const entry = chainFor(sessionId);
|
|
209
|
+
if (!entry || !sessionId) return entry;
|
|
210
|
+
if (typeof entry.at === "number" && Date.now() - entry.at >= chainTtlMs(options)) {
|
|
211
|
+
interactionChains.delete(sessionId);
|
|
212
|
+
lastTurnTotals.delete(sessionId);
|
|
213
|
+
reportChainAnomaly({ type: "interaction_chain_ttl_expired", sessionId, model: entry.model });
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
return entry;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Records a successful turn's token total and alerts when input tokens drop
|
|
221
|
+
* below the previous turn's total (Rule 3: a caller dropped the chain without
|
|
222
|
+
* rehydrating history). Never throws; observability-only.
|
|
223
|
+
*/
|
|
224
|
+
function checkTurnTokens(sessionId: string | undefined, usage: { inputTokens: number; outputTokens: number; cachedTokens?: number }): void {
|
|
225
|
+
try {
|
|
226
|
+
if (!sessionId) return;
|
|
227
|
+
const total = (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0);
|
|
228
|
+
const prev = lastTurnTotals.get(sessionId);
|
|
229
|
+
lastTurnTotals.set(sessionId, total);
|
|
230
|
+
if (prev !== undefined && prev > 0 && (usage.inputTokens ?? 0) < prev) {
|
|
231
|
+
const input = usage.inputTokens ?? 0;
|
|
232
|
+
reportChainAnomaly({
|
|
233
|
+
type: "interaction_history_shrink",
|
|
234
|
+
sessionId,
|
|
235
|
+
inputTokens: input,
|
|
236
|
+
previousTotalTokens: prev,
|
|
237
|
+
cacheHitRate: input > 0 ? (usage.cachedTokens ?? 0) / input : 0,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
} catch {}
|
|
241
|
+
}
|
|
242
|
+
|
|
159
243
|
/** Clears chained interaction state (mainly for tests). */
|
|
160
244
|
export function clearInteractionChains(sessionId?: string): void {
|
|
161
|
-
if (sessionId)
|
|
162
|
-
|
|
245
|
+
if (sessionId) {
|
|
246
|
+
interactionChains.delete(sessionId);
|
|
247
|
+
lastTurnTotals.delete(sessionId);
|
|
248
|
+
} else {
|
|
249
|
+
interactionChains.clear();
|
|
250
|
+
lastTurnTotals.clear();
|
|
251
|
+
}
|
|
163
252
|
}
|
|
164
253
|
|
|
165
254
|
// ---------------------------------------------------------------------------
|
|
@@ -176,7 +265,10 @@ function resolveBaseUrl(options?: ProviderRequestOptions): string {
|
|
|
176
265
|
}
|
|
177
266
|
|
|
178
267
|
function resolveApiKey(options?: ProviderRequestOptions): string | undefined {
|
|
179
|
-
|
|
268
|
+
const key = getApiKey("google", options?.apiKey, options?.env);
|
|
269
|
+
if (key) return key;
|
|
270
|
+
if (hasAuthHeader(options?.headers)) return "unused";
|
|
271
|
+
return undefined;
|
|
180
272
|
}
|
|
181
273
|
|
|
182
274
|
function cleanModelId(model: string | ModelSpec): string {
|
|
@@ -184,6 +276,33 @@ function cleanModelId(model: string | ModelSpec): string {
|
|
|
184
276
|
return rawId.replace(/^(google\/|gemini\/|models\/)/i, "");
|
|
185
277
|
}
|
|
186
278
|
|
|
279
|
+
/**
|
|
280
|
+
* Enforces the Gemini 3 minimum generation floor: Gemini 1.x/2.x are
|
|
281
|
+
* deprecated and unsupported — fail fast before any network call.
|
|
282
|
+
* Non-`gemini-` model ids are left to the catalog/provider verdict.
|
|
283
|
+
*/
|
|
284
|
+
function assertSupportedGeneration(clean: string): void {
|
|
285
|
+
const match = clean.match(/^gemini-(\d+)(?:[.\-]|$)/i);
|
|
286
|
+
if (match && Number(match[1]) < 3) {
|
|
287
|
+
throw new Error(
|
|
288
|
+
`[Agent Accelerator] [google/${clean}] Unsupported generation: Gemini ${match[1]}.x and older are strictly rejected by this SDK. ` +
|
|
289
|
+
`Use a Gemini 3 or later model for full support and feature coverage.`
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Throws a concise error for failed/cancelled interactions (never silent empty success). */
|
|
295
|
+
function throwIfFailedInteraction(interaction: InteractionObject, modelId: string): void {
|
|
296
|
+
if (interaction.status !== "failed" && interaction.status !== "cancelled") return;
|
|
297
|
+
const message =
|
|
298
|
+
interaction.error && typeof interaction.error.message === "string" && interaction.error.message
|
|
299
|
+
? interaction.error.message
|
|
300
|
+
: `Google interaction ${interaction.status}`;
|
|
301
|
+
const failure: Record<string, unknown> & Error = new Error(message) as Record<string, unknown> & Error;
|
|
302
|
+
if (interaction.error?.code !== undefined) failure["code"] = interaction.error.code;
|
|
303
|
+
throw toConciseProviderError(failure, "google", modelId);
|
|
304
|
+
}
|
|
305
|
+
|
|
187
306
|
function toGoogleTools(tools?: StandardToolDeclaration[]): Array<Record<string, unknown>> | undefined {
|
|
188
307
|
if (!tools || tools.length === 0) return undefined;
|
|
189
308
|
return tools.map((t) => ({
|
|
@@ -251,19 +370,40 @@ function resultToText(result: unknown): string {
|
|
|
251
370
|
return safeStringify(result);
|
|
252
371
|
}
|
|
253
372
|
|
|
254
|
-
/**
|
|
373
|
+
/**
|
|
374
|
+
* Latest-turn input for stateful chaining (the server already holds history):
|
|
375
|
+
* everything AFTER the last assistant message, in order. That is normally one
|
|
376
|
+
* user prompt (sent as plain text/blocks), but after tool execution it is the
|
|
377
|
+
* `function_result` steps, and a mid-run `steer()` can append user messages
|
|
378
|
+
* behind them. Sending only the last message would drop the tool results and
|
|
379
|
+
* leave the chained interaction waiting on its function calls.
|
|
380
|
+
*/
|
|
255
381
|
async function latestTurnInput(context: ProviderContext): Promise<string | InteractionContentBlock[] | InteractionInputStep[]> {
|
|
256
382
|
const messages = context.messages;
|
|
257
|
-
|
|
258
|
-
const trailingTool: Message[] = [];
|
|
383
|
+
let lastAssistant = -1;
|
|
259
384
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
385
|
+
if (messages[i]!.role === "assistant") {
|
|
386
|
+
lastAssistant = i;
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
263
389
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
390
|
+
const tail = messages.slice(lastAssistant + 1).filter((m) => m.role !== "system");
|
|
391
|
+
if (tail.length === 0) return "";
|
|
392
|
+
|
|
393
|
+
const hasToolResults = tail.some((m) => m.role === "tool");
|
|
394
|
+
if (!hasToolResults && tail.length === 1) {
|
|
395
|
+
const only = tail[0]!;
|
|
396
|
+
if (typeof only.content === "string") return only.content;
|
|
397
|
+
const blocks = await contentPartsToBlocks(only.content);
|
|
398
|
+
if (blocks.length === 1 && blocks[0]!.type === "text") {
|
|
399
|
+
return (blocks[0] as { type: "text"; text: string }).text;
|
|
400
|
+
}
|
|
401
|
+
return blocks;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const steps: InteractionInputStep[] = [];
|
|
405
|
+
for (const m of tail) {
|
|
406
|
+
if (m.role === "tool") {
|
|
267
407
|
if (!Array.isArray(m.content)) continue;
|
|
268
408
|
for (const part of m.content) {
|
|
269
409
|
if (part.type === "tool_result") {
|
|
@@ -275,17 +415,16 @@ async function latestTurnInput(context: ProviderContext): Promise<string | Inter
|
|
|
275
415
|
});
|
|
276
416
|
}
|
|
277
417
|
}
|
|
418
|
+
} else if (m.role === "user") {
|
|
419
|
+
if (typeof m.content === "string") {
|
|
420
|
+
steps.push({ type: "user_input", content: [{ type: "text", text: m.content }] });
|
|
421
|
+
} else {
|
|
422
|
+
const blocks = await contentPartsToBlocks(m.content);
|
|
423
|
+
if (blocks.length > 0) steps.push({ type: "user_input", content: blocks });
|
|
424
|
+
}
|
|
278
425
|
}
|
|
279
|
-
return steps;
|
|
280
426
|
}
|
|
281
|
-
|
|
282
|
-
if (!last) return "";
|
|
283
|
-
if (typeof last.content === "string") return last.content;
|
|
284
|
-
const blocks = await contentPartsToBlocks(last.content);
|
|
285
|
-
if (blocks.length === 1 && blocks[0]!.type === "text") {
|
|
286
|
-
return (blocks[0] as { type: "text"; text: string }).text;
|
|
287
|
-
}
|
|
288
|
-
return blocks;
|
|
427
|
+
return steps;
|
|
289
428
|
}
|
|
290
429
|
|
|
291
430
|
/** Full-history Steps for stateless (provider-switch) turns. */
|
|
@@ -315,16 +454,18 @@ async function fullHistorySteps(context: ProviderContext): Promise<InteractionIn
|
|
|
315
454
|
if (part.type === "thinking" && part.thinking) {
|
|
316
455
|
// Q-18: preserve namespaced signatures only — foreign-provider
|
|
317
456
|
// signatures never echo here; untagged legacy echoes (compat).
|
|
457
|
+
// Absent signatures are omitted (never sent as empty strings).
|
|
318
458
|
const partProvider = (part as { thoughtSignatureProvider?: unknown }).thoughtSignatureProvider;
|
|
319
459
|
const msgProvider = (m as { thoughtSignatureProvider?: unknown }).thoughtSignatureProvider;
|
|
320
460
|
const sig = shouldEchoSignature(partProvider ?? msgProvider, "google")
|
|
321
461
|
? part.thoughtSignature || m.thoughtSignature || ""
|
|
322
462
|
: "";
|
|
323
|
-
|
|
463
|
+
const thoughtStep: { type: "thought"; signature?: string; summary: Array<{ type: string; text?: string }> } = {
|
|
324
464
|
type: "thought",
|
|
325
|
-
signature: sig,
|
|
326
465
|
summary: [{ type: "text", text: part.thinking }],
|
|
327
|
-
}
|
|
466
|
+
};
|
|
467
|
+
if (sig) thoughtStep.signature = sig;
|
|
468
|
+
steps.push(thoughtStep);
|
|
328
469
|
} else if (part.type === "tool_call") {
|
|
329
470
|
steps.push({
|
|
330
471
|
type: "function_call",
|
|
@@ -373,7 +514,11 @@ async function fullHistorySteps(context: ProviderContext): Promise<InteractionIn
|
|
|
373
514
|
|
|
374
515
|
function mapUsage(raw?: InteractionObject["usage"]): TokenUsage {
|
|
375
516
|
const input = raw?.total_input_tokens ?? 0;
|
|
376
|
-
|
|
517
|
+
// Google reports thought tokens separately from `total_output_tokens`, but bills them as output
|
|
518
|
+
// ("response pricing is the sum of output tokens and thinking tokens"). Fold them into output so
|
|
519
|
+
// pricing is right and `thinkingTokens` stays a subset of `outputTokens`.
|
|
520
|
+
const thought = raw?.total_thought_tokens ?? 0;
|
|
521
|
+
const output = (raw?.total_output_tokens ?? 0) + thought;
|
|
377
522
|
// Canonical invariant: cached hits are a SUBSET of input. Google usually
|
|
378
523
|
// honors this, but long chained runs have been observed reporting
|
|
379
524
|
// total_cached_tokens > total_input_tokens (which yields >100% hit rates
|
|
@@ -386,13 +531,14 @@ function mapUsage(raw?: InteractionObject["usage"]): TokenUsage {
|
|
|
386
531
|
cachedTokens: cached,
|
|
387
532
|
cacheReadTokens: cached,
|
|
388
533
|
cacheWriteTokens: 0,
|
|
389
|
-
thinkingTokens:
|
|
534
|
+
thinkingTokens: thought,
|
|
390
535
|
};
|
|
391
536
|
}
|
|
392
537
|
|
|
393
538
|
function mapFinishReason(status?: string): string {
|
|
394
539
|
if (status === "requires_action") return "tool_calls";
|
|
395
540
|
if (status === "incomplete") return "length";
|
|
541
|
+
if (status === "failed" || status === "cancelled" || status === "error") return "error";
|
|
396
542
|
return "stop";
|
|
397
543
|
}
|
|
398
544
|
|
|
@@ -402,6 +548,9 @@ function parseInteraction(
|
|
|
402
548
|
durationMs: number,
|
|
403
549
|
raw: ProviderRawData
|
|
404
550
|
): ProviderGenerateResult {
|
|
551
|
+
// Failed/cancelled interactions must surface loudly — never resolve as
|
|
552
|
+
// empty successful stops.
|
|
553
|
+
throwIfFailedInteraction(interaction, modelId);
|
|
405
554
|
let text = "";
|
|
406
555
|
const thinkingParts: string[] = [];
|
|
407
556
|
let thoughtSignature: string | undefined;
|
|
@@ -425,8 +574,15 @@ function parseInteraction(
|
|
|
425
574
|
if (block.type === "text" && block.text) text += block.text;
|
|
426
575
|
}
|
|
427
576
|
} else if (step.type === "function_call") {
|
|
577
|
+
// Object args as-is; a JSON string is parsed (unparseable text becomes the
|
|
578
|
+
// truncated marker); absent args mean a zero-argument call.
|
|
579
|
+
const rawArgs: unknown = step.arguments;
|
|
428
580
|
const args =
|
|
429
|
-
|
|
581
|
+
rawArgs && typeof rawArgs === "object"
|
|
582
|
+
? rawArgs
|
|
583
|
+
: typeof rawArgs === "string"
|
|
584
|
+
? parseArguments(rawArgs)
|
|
585
|
+
: {};
|
|
430
586
|
toolCalls.push({
|
|
431
587
|
id: step.id || newToolCallId(),
|
|
432
588
|
name: step.name || "unknown",
|
|
@@ -511,24 +667,63 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
511
667
|
options?.thinking?.enabled !== false && (options?.thinking?.level ?? "dynamic") !== "none";
|
|
512
668
|
if (thinkingActive) generationConfig["thinking_summaries"] = "auto";
|
|
513
669
|
if (toolChoice) generationConfig["tool_choice"] = toolChoice;
|
|
670
|
+
// max_output_tokens only; stop and seed warn+drop.
|
|
671
|
+
Object.assign(generationConfig, mapGenerationParamsToGoogle(options, `google/${modelId}`));
|
|
514
672
|
|
|
515
673
|
const body: InteractionRequestBody = {
|
|
516
674
|
model: modelId,
|
|
517
675
|
input: "",
|
|
518
676
|
...(stream ? { stream: true } : {}),
|
|
519
677
|
};
|
|
520
|
-
|
|
678
|
+
// Structured outputs: native `response_format` when supported, else
|
|
679
|
+
// emulated via system instruction (catalog `structured_output: false`
|
|
680
|
+
// would 400 on the wire shape).
|
|
681
|
+
let outputEmulated = false;
|
|
682
|
+
let outputSpec: import("../types/model.ts").StructuredOutputSpec | undefined;
|
|
683
|
+
try {
|
|
684
|
+
outputSpec = normalizeStructuredOutput(options?.output ?? (options as { outputSchema?: unknown })?.outputSchema);
|
|
685
|
+
} catch {
|
|
686
|
+
outputSpec = undefined;
|
|
687
|
+
}
|
|
688
|
+
let effectiveSystemPrompt = context.systemPrompt;
|
|
689
|
+
if (outputSpec && !modelSupportsStructuredOutput("google", modelId)) {
|
|
690
|
+
outputEmulated = true;
|
|
691
|
+
effectiveSystemPrompt = augmentSystemPromptWithStructuredOutput(context.systemPrompt, outputSpec);
|
|
692
|
+
emitProviderWarning({
|
|
693
|
+
provider: "google",
|
|
694
|
+
capability: "structured output",
|
|
695
|
+
requested: `${outputSpec.name} (google/${modelId})`,
|
|
696
|
+
reason: "the catalog reports structured_output: false for this model, so native response_format is unsupported.",
|
|
697
|
+
fallback: "emulated JSON instruction + client validation (no response_format is sent)",
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
if (effectiveSystemPrompt) body.system_instruction = effectiveSystemPrompt;
|
|
521
701
|
const googleTools = toGoogleTools(tools);
|
|
522
702
|
if (googleTools) body.tools = googleTools;
|
|
523
703
|
if (Object.keys(generationConfig).length > 0) body.generation_config = generationConfig;
|
|
524
704
|
if (serviceTier) body.service_tier = serviceTier;
|
|
705
|
+
// Structured outputs: Interactions `response_format{type:text,mime_type:application/json,schema}`.
|
|
706
|
+
// Skipped when emulated (instruction already in `system_instruction`).
|
|
707
|
+
if (outputSpec && !outputEmulated) {
|
|
708
|
+
try {
|
|
709
|
+
const mapped = mapOutputToGoogle(outputSpec);
|
|
710
|
+
if (mapped) (body as unknown as Record<string, unknown>)["response_format"] = mapped.responseFormat;
|
|
711
|
+
} catch {
|
|
712
|
+
// why: invalid output shapes fail fast at Agent construction/loop validation.
|
|
713
|
+
}
|
|
714
|
+
}
|
|
525
715
|
|
|
526
716
|
const prevProvider = lastProviderFor(sessionId);
|
|
527
717
|
const switched = !!prevProvider && prevProvider !== "google";
|
|
528
|
-
|
|
718
|
+
// Proactive TTL gate (Rule 1): expired chains are dropped locally so
|
|
719
|
+
// this turn seeds fresh instead of wasting an RTT on a dead pointer.
|
|
720
|
+
const chain = liveChainFor(sessionId, options);
|
|
529
721
|
// Interaction ids are model-scoped server-side: chaining a 2.5 id into
|
|
530
722
|
// a 3.5 request 400s, so a same-provider model switch resumes stateless.
|
|
531
723
|
const continuing = !!chain && !switched && chain.model === modelId && context.messages.length > 1;
|
|
724
|
+
// A model switch reseeds from full history and tokenizes differently: drop the token
|
|
725
|
+
// baseline so the new model's first turn is not mistaken for a shrunken history.
|
|
726
|
+
if (chain && sessionId && chain.model !== modelId) lastTurnTotals.delete(sessionId);
|
|
532
727
|
|
|
533
728
|
if (switched) {
|
|
534
729
|
// Cross-provider turn: the Google server never saw the other
|
|
@@ -564,8 +759,9 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
564
759
|
}
|
|
565
760
|
// Q-17: key travels as the `x-goog-api-key` header (never `?key=` in the
|
|
566
761
|
// URL, which would leak into logs). Case-insensitive merge so a custom
|
|
567
|
-
// `X-Goog-Api-Key` never duplicates the canonical header.
|
|
568
|
-
|
|
762
|
+
// `X-Goog-Api-Key` never duplicates the canonical header. Proxy flows
|
|
763
|
+
// (`apiKey === "unused"`) preserve custom auth instead of overwriting.
|
|
764
|
+
if (apiKey !== "unused") setHeaderCaseInsensitive(headers, "x-goog-api-key", apiKey);
|
|
569
765
|
return { method: "POST", headers, body: JSON.stringify(body), signal };
|
|
570
766
|
}
|
|
571
767
|
|
|
@@ -578,7 +774,7 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
578
774
|
): Promise<{ status: number; statusText: string; headers: Record<string, string>; text: string }> {
|
|
579
775
|
const { signal: effective, cleanup } = combinedSignal(signal, timeoutFor(options as { timeoutMs?: number } | undefined));
|
|
580
776
|
try {
|
|
581
|
-
const res = await fetch(url, this.requestInit(body, options, apiKey, effective));
|
|
777
|
+
const res = await (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, effective));
|
|
582
778
|
const text = await res.text();
|
|
583
779
|
const headers: Record<string, string> = {};
|
|
584
780
|
res.headers.forEach((v, k) => {
|
|
@@ -595,17 +791,27 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
595
791
|
statusText: string,
|
|
596
792
|
url: string,
|
|
597
793
|
bodyText: string,
|
|
598
|
-
modelId: string
|
|
794
|
+
modelId: string,
|
|
795
|
+
headers?: Record<string, string>
|
|
599
796
|
): void {
|
|
600
797
|
if (status >= 200 && status < 300) return;
|
|
601
|
-
const { message, code, errorType } = readErrorPayload(bodyText);
|
|
602
|
-
const
|
|
798
|
+
const { message, code, errorType, raw } = readErrorPayload(bodyText);
|
|
799
|
+
const hint =
|
|
800
|
+
status === 401
|
|
801
|
+
? " Check GEMINI_API_KEY/GOOGLE_API_KEY (no quotes/spaces, correct provider), GOOGLE_BASE_URL, and that the key is loaded (bun --env-file=.env.local)."
|
|
802
|
+
: "";
|
|
803
|
+
const fullMessage = `${message}${hint}`;
|
|
804
|
+
const err: Record<string, unknown> & Error = new Error(fullMessage) as Record<string, unknown> & Error;
|
|
603
805
|
(err as Record<string, unknown>)["statusCode"] = status;
|
|
604
806
|
(err as Record<string, unknown>)["status"] = status;
|
|
605
807
|
(err as Record<string, unknown>)["responseBody"] = bodyText.slice(0, 500);
|
|
606
808
|
(err as Record<string, unknown>)["url"] = url.split("?")[0];
|
|
809
|
+
(err as Record<string, unknown>)["data"] = { error: { message: fullMessage } };
|
|
607
810
|
if (code !== undefined) (err as Record<string, unknown>)["code"] = code;
|
|
608
811
|
if (errorType) (err as Record<string, unknown>)["errorType"] = errorType;
|
|
812
|
+
if (raw) (err as Record<string, unknown>)["raw"] = raw;
|
|
813
|
+
const retryAfterMs = parseRetryAfterMs(headers);
|
|
814
|
+
if (retryAfterMs !== undefined) (err as Record<string, unknown>)["retryAfterMs"] = retryAfterMs;
|
|
609
815
|
throw toConciseProviderError(err, "google", modelId);
|
|
610
816
|
}
|
|
611
817
|
|
|
@@ -616,6 +822,8 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
616
822
|
): Promise<ProviderGenerateResult> {
|
|
617
823
|
const startTime = Date.now();
|
|
618
824
|
const clean = cleanModelId(model);
|
|
825
|
+
// Hard generation floor: Gemini 1.x/2.x fail fast before any network call.
|
|
826
|
+
assertSupportedGeneration(clean);
|
|
619
827
|
const apiKey = resolveApiKey(options);
|
|
620
828
|
if (!apiKey) {
|
|
621
829
|
throw new Error(
|
|
@@ -630,9 +838,9 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
630
838
|
const url = `${baseUrl}/interactions`;
|
|
631
839
|
const sessionId = options?.sessionId || options?.cache?.sessionId;
|
|
632
840
|
const tools = options?.tools as StandardToolDeclaration[] | undefined;
|
|
633
|
-
|
|
841
|
+
let body = await this.buildBody(clean, context, options, sessionId, tools, false);
|
|
634
842
|
|
|
635
|
-
const
|
|
843
|
+
const makeRawRequest = (b: InteractionRequestBody): ProviderRawData["request"] => ({
|
|
636
844
|
url,
|
|
637
845
|
method: "POST",
|
|
638
846
|
headers: redactedHeaders({
|
|
@@ -642,12 +850,13 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
642
850
|
),
|
|
643
851
|
"x-goog-api-key": "[REDACTED]",
|
|
644
852
|
}),
|
|
645
|
-
body,
|
|
646
|
-
};
|
|
853
|
+
body: b,
|
|
854
|
+
});
|
|
855
|
+
let rawRequest = makeRawRequest(body);
|
|
647
856
|
|
|
648
857
|
const doCall = async (): Promise<ProviderGenerateResult> => {
|
|
649
858
|
const res = await this.doFetch(url, body, options, apiKey, options?.signal);
|
|
650
|
-
this.throwIfError(res.status, res.statusText, url, res.text, clean);
|
|
859
|
+
this.throwIfError(res.status, res.statusText, url, res.text, clean, res.headers);
|
|
651
860
|
let interaction: InteractionObject;
|
|
652
861
|
try {
|
|
653
862
|
interaction = JSON.parse(res.text) as InteractionObject;
|
|
@@ -663,28 +872,41 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
663
872
|
);
|
|
664
873
|
}
|
|
665
874
|
if (interaction.id && sessionId && body.store !== false) {
|
|
666
|
-
setChain(sessionId, { interactionId: interaction.id, model: clean });
|
|
875
|
+
setChain(sessionId, { interactionId: interaction.id, model: clean, at: Date.now() });
|
|
667
876
|
} else if (body.store === false && sessionId) {
|
|
668
877
|
interactionChains.delete(sessionId);
|
|
878
|
+
lastTurnTotals.delete(sessionId);
|
|
669
879
|
}
|
|
670
880
|
noteProviderTurn(sessionId, "google");
|
|
671
|
-
|
|
881
|
+
const parsed = parseInteraction(
|
|
672
882
|
interaction,
|
|
673
883
|
clean,
|
|
674
884
|
Date.now() - startTime,
|
|
675
885
|
{ request: rawRequest, response: { status: res.status, statusText: res.statusText, headers: res.headers, body: interaction } }
|
|
676
886
|
);
|
|
887
|
+
checkTurnTokens(sessionId, parsed.usage);
|
|
888
|
+
return parsed;
|
|
677
889
|
};
|
|
678
890
|
|
|
891
|
+
const retryOptions = {
|
|
892
|
+
...retryOptionsFrom(options),
|
|
893
|
+
signal: options?.signal,
|
|
894
|
+
label: { providerId: "google", modelId: clean },
|
|
895
|
+
};
|
|
679
896
|
try {
|
|
680
|
-
return await withRetries(doCall,
|
|
681
|
-
maxRetries: options?.maxRetries,
|
|
682
|
-
maxRetryDelayMs: options?.maxRetryDelayMs,
|
|
683
|
-
signal: options?.signal,
|
|
684
|
-
label: { providerId: "google", modelId: clean },
|
|
685
|
-
});
|
|
897
|
+
return await withRetries(doCall, retryOptions);
|
|
686
898
|
} catch (err) {
|
|
687
899
|
if (err instanceof Error && (err as { name?: string }).name === "AbortError") throw err;
|
|
900
|
+
// Reactive self-heal (Rule 2): a chained pointer the server no longer
|
|
901
|
+
// has is retried once against the full canonical history with the dead
|
|
902
|
+
// chain cleared, so the turn succeeds instead of wedging the session.
|
|
903
|
+
if (sessionId && body.previous_interaction_id && isNotFoundChainError(err)) {
|
|
904
|
+
reportChainAnomaly({ type: "interaction_chain_evicted", sessionId, model: clean });
|
|
905
|
+
clearInteractionChains(sessionId);
|
|
906
|
+
body = await this.buildBody(clean, context, options, sessionId, tools, false);
|
|
907
|
+
rawRequest = makeRawRequest(body);
|
|
908
|
+
return await withRetries(doCall, retryOptions);
|
|
909
|
+
}
|
|
688
910
|
throw err;
|
|
689
911
|
}
|
|
690
912
|
}
|
|
@@ -717,7 +939,13 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
717
939
|
});
|
|
718
940
|
|
|
719
941
|
(async () => {
|
|
942
|
+
// Hoisted for the catch-block self-heal (consts inside try are not
|
|
943
|
+
// visible in catch).
|
|
944
|
+
let evictSessionId: string | undefined;
|
|
945
|
+
let usedChain = false;
|
|
720
946
|
try {
|
|
947
|
+
// Hard generation floor (mirrors generate): no network on legacy ids.
|
|
948
|
+
assertSupportedGeneration(clean);
|
|
721
949
|
const apiKey = resolveApiKey(options);
|
|
722
950
|
if (!apiKey) {
|
|
723
951
|
throw new Error(
|
|
@@ -730,6 +958,8 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
730
958
|
const sessionId = options?.sessionId || options?.cache?.sessionId;
|
|
731
959
|
const tools = options?.tools as StandardToolDeclaration[] | undefined;
|
|
732
960
|
const body = await this.buildBody(clean, context, options, sessionId, tools, true);
|
|
961
|
+
evictSessionId = sessionId;
|
|
962
|
+
usedChain = body.previous_interaction_id !== undefined;
|
|
733
963
|
|
|
734
964
|
// Audit trail mirrors generate (custom headers included); the
|
|
735
965
|
// Interactions API sends no session-affinity headers (implicit
|
|
@@ -748,25 +978,30 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
748
978
|
};
|
|
749
979
|
|
|
750
980
|
const streamTimeoutMs = timeoutFor(options as { timeoutMs?: number } | undefined);
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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
|
+
});
|
|
770
1005
|
const responseHeaders: Record<string, string> = {};
|
|
771
1006
|
res.headers.forEach((v, k) => {
|
|
772
1007
|
responseHeaders[k] = v;
|
|
@@ -775,7 +1010,7 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
775
1010
|
eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
|
|
776
1011
|
|
|
777
1012
|
const parser = new SSEParser();
|
|
778
|
-
const reader = res.body
|
|
1013
|
+
const reader = res.body!.getReader(); // openStreamResponse guarantees a body
|
|
779
1014
|
const decoder = new TextDecoder();
|
|
780
1015
|
let text = "";
|
|
781
1016
|
let thinking = "";
|
|
@@ -872,6 +1107,9 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
872
1107
|
}
|
|
873
1108
|
} else if (type === "interaction.completed") {
|
|
874
1109
|
const interaction = (msg["interaction"] as InteractionObject) ?? {};
|
|
1110
|
+
// Failed/cancelled interactions must surface loudly — never
|
|
1111
|
+
// resolve as empty successful stops.
|
|
1112
|
+
throwIfFailedInteraction(interaction, clean);
|
|
875
1113
|
completedBody = msg["interaction"];
|
|
876
1114
|
if (interaction.id) responseId = interaction.id;
|
|
877
1115
|
if (interaction.usage) {
|
|
@@ -912,17 +1150,21 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
912
1150
|
id: c.id,
|
|
913
1151
|
name: c.name,
|
|
914
1152
|
arguments: args,
|
|
915
|
-
rawArguments: c.startArgs
|
|
1153
|
+
rawArguments: selectRawArguments(c.startArgs, c.deltaArgs),
|
|
916
1154
|
};
|
|
917
1155
|
toolCalls.push(record);
|
|
918
1156
|
eventStream.push({ type: "tool_call_complete", toolCall: record });
|
|
919
1157
|
}
|
|
920
1158
|
|
|
921
1159
|
if (sessionId) {
|
|
922
|
-
if (responseId && body.store !== false) setChain(sessionId, { interactionId: responseId, model: clean });
|
|
923
|
-
else if (body.store === false)
|
|
1160
|
+
if (responseId && body.store !== false) setChain(sessionId, { interactionId: responseId, model: clean, at: Date.now() });
|
|
1161
|
+
else if (body.store === false) {
|
|
1162
|
+
interactionChains.delete(sessionId);
|
|
1163
|
+
lastTurnTotals.delete(sessionId);
|
|
1164
|
+
}
|
|
924
1165
|
}
|
|
925
1166
|
noteProviderTurn(sessionId, "google");
|
|
1167
|
+
checkTurnTokens(sessionId, usage);
|
|
926
1168
|
|
|
927
1169
|
// Q-23: terminal-state guard — fail `incomplete_stream` (retryable)
|
|
928
1170
|
// instead of resolving empty success. Skipped for non-SSE payloads
|
|
@@ -966,11 +1208,26 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
966
1208
|
const isAbort =
|
|
967
1209
|
linked.signal.aborted ||
|
|
968
1210
|
eventStream.isCancelled() ||
|
|
969
|
-
(raw as { name?: string }).name === "AbortError"
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
1211
|
+
(raw as { name?: string }).name === "AbortError";
|
|
1212
|
+
if (isAbort) {
|
|
1213
|
+
eventStream.fail(
|
|
1214
|
+
Object.assign(new Error("Stream aborted"), { name: "AbortError" })
|
|
1215
|
+
);
|
|
1216
|
+
} else if (evictSessionId && usedChain && isNotFoundChainError(raw)) {
|
|
1217
|
+
// Reactive self-heal, fail-clean variant (Rule 2): the `start`
|
|
1218
|
+
// event is already delivered, so a transparent in-stream retry
|
|
1219
|
+
// would duplicate it. Clear the dead pointer now so the next turn
|
|
1220
|
+
// reseeds from full history; mark retryable so a plain caller retry
|
|
1221
|
+
// of this turn succeeds.
|
|
1222
|
+
reportChainAnomaly({ type: "interaction_chain_evicted", sessionId: evictSessionId, model: clean });
|
|
1223
|
+
clearInteractionChains(evictSessionId);
|
|
1224
|
+
const retryable = toConciseProviderError(raw, "google", clean);
|
|
1225
|
+
(retryable as unknown as Record<string, unknown>)["retryable"] = true;
|
|
1226
|
+
(retryable as unknown as Record<string, unknown>)["transient"] = true;
|
|
1227
|
+
eventStream.fail(retryable);
|
|
1228
|
+
} else {
|
|
1229
|
+
eventStream.fail(raw);
|
|
1230
|
+
}
|
|
974
1231
|
} finally {
|
|
975
1232
|
try {
|
|
976
1233
|
options?.signal?.removeEventListener("abort", forwardUserAbort);
|