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.
Files changed (46) hide show
  1. package/README.md +149 -57
  2. package/bunfig.toml +1 -0
  3. package/package.json +2 -3
  4. package/src/agent/agent.ts +414 -118
  5. package/src/agent/context.ts +11 -16
  6. package/src/agent/delegation.ts +111 -274
  7. package/src/agent/loop.ts +327 -99
  8. package/src/agent/subagent.ts +4 -12
  9. package/src/agent/worker.ts +435 -0
  10. package/src/index.ts +58 -5
  11. package/src/models/catalog-cache.ts +65 -25
  12. package/src/models/catalog.ts +47 -2
  13. package/src/providers/google.ts +338 -81
  14. package/src/providers/openai-compat.ts +171 -61
  15. package/src/providers/openai.ts +174 -44
  16. package/src/providers/openrouter.ts +129 -44
  17. package/src/providers/shared.ts +169 -17
  18. package/src/providers.ts +375 -11
  19. package/src/scheduler/scheduled-actions.md +41 -0
  20. package/src/scheduler/scheduler.ts +814 -0
  21. package/src/scheduler/store.ts +263 -0
  22. package/src/scheduler/time.ts +148 -0
  23. package/src/scheduler/tool.ts +151 -0
  24. package/src/session/store.ts +229 -113
  25. package/src/streaming/event-stream.ts +6 -4
  26. package/src/telemetry-file.ts +31 -0
  27. package/src/telemetry.ts +65 -0
  28. package/src/tools/executor.ts +163 -38
  29. package/src/tools/subagent-result.ts +60 -0
  30. package/src/types/agent.ts +74 -22
  31. package/src/types/model.ts +39 -0
  32. package/src/types/response.ts +9 -0
  33. package/src/types/scheduler.ts +81 -0
  34. package/src/types/tool.ts +15 -0
  35. package/src/utils/cache.ts +14 -5
  36. package/src/utils/documents.ts +112 -52
  37. package/src/utils/env.ts +21 -7
  38. package/src/utils/errors.ts +26 -1
  39. package/src/utils/headers.ts +15 -15
  40. package/src/utils/home.ts +30 -0
  41. package/src/utils/media.ts +57 -12
  42. package/src/utils/raw-capture.ts +76 -0
  43. package/src/utils/retry.ts +11 -3
  44. package/src/utils/safe-io.ts +252 -0
  45. package/src/utils/session.ts +7 -8
  46. package/src/data/README.md +0 -84
@@ -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 NOT rejected here —
20
- * the Interactions API documents 2.5 support; the catalog still governs
21
- * thinking validation upstream.
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: string; summary?: Array<{ type: string; text?: string }> }
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) interactionChains.delete(sessionId);
162
- else interactionChains.clear();
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
- return options?.apiKey || getApiKey("google", undefined, options?.env);
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
- /** Latest-turn input for stateful chaining (server already holds history). */
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
- // Trailing tool messages -> function_result steps.
258
- const trailingTool: Message[] = [];
383
+ let lastAssistant = -1;
259
384
  for (let i = messages.length - 1; i >= 0; i--) {
260
- const m = messages[i]!;
261
- if (m.role === "tool") trailingTool.unshift(m);
262
- else break;
385
+ if (messages[i]!.role === "assistant") {
386
+ lastAssistant = i;
387
+ break;
388
+ }
263
389
  }
264
- if (trailingTool.length > 0) {
265
- const steps: InteractionInputStep[] = [];
266
- for (const m of trailingTool) {
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
- const last = messages[messages.length - 1];
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
- steps.push({
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
- const output = raw?.total_output_tokens ?? 0;
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: raw?.total_thought_tokens ?? 0,
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
- step.arguments && typeof step.arguments === "object" ? step.arguments : { raw: step.arguments };
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
- if (context.systemPrompt) body.system_instruction = context.systemPrompt;
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
- const chain = chainFor(sessionId);
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
- setHeaderCaseInsensitive(headers, "x-goog-api-key", apiKey);
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 err: Record<string, unknown> & Error = new Error(message) as Record<string, unknown> & Error;
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
- const body = await this.buildBody(clean, context, options, sessionId, tools, false);
841
+ let body = await this.buildBody(clean, context, options, sessionId, tools, false);
634
842
 
635
- const rawRequest = {
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
- return parseInteraction(
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
- const streamTimeoutTimer =
752
- streamTimeoutMs && streamTimeoutMs > 0
753
- ? setTimeout(() => {
754
- try {
755
- linked.abort();
756
- } catch {}
757
- }, streamTimeoutMs)
758
- : undefined;
759
- let res: Response;
760
- try {
761
- res = await fetch(url, this.requestInit(body, options, apiKey, linked.signal));
762
- } finally {
763
- if (streamTimeoutTimer) clearTimeout(streamTimeoutTimer);
764
- }
765
- if (!res.ok || !res.body) {
766
- const text = !res.ok ? await res.text().catch(() => "") : "";
767
- if (!res.ok) this.throwIfError(res.status, res.statusText, url, text, clean);
768
- throw toConciseProviderError(new Error("Google streaming response had no body"), "google", clean);
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.getReader();
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 + c.deltaArgs,
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) interactionChains.delete(sessionId);
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
- /abort|cancell?ed/i.test(String((raw as { message?: string }).message ?? raw));
971
- eventStream.fail(
972
- isAbort ? Object.assign(new Error("Stream aborted"), { name: "AbortError" }) : raw
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);