agent-accelerator 0.3.0 → 0.4.5

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.
@@ -39,12 +39,6 @@ const MODALITY_OVERRIDES: Record<string, { input?: string[]; output?: string[] }
39
39
  // OpenRouter routes this free model to an endpoint that parses PDFs,
40
40
  // verified end-to-end via examples/08. If routing changes, the runtime
41
41
  // 404 still surfaces as a one-line error.
42
- "openrouter/nvidia/nemotron-3.5-lightning:free": { input: ["text", "pdf"] },
43
- // Live-verified 2026-09-24: `stealth/space-bunny-alpha` accepts PDF
44
- // `input_file` over Responses and PDFs via the file-parser plugin over Chat
45
- // Completions (Berkshire letter + sample-files PDF both completed), though
46
- // models.dev lists text/image/video only.
47
- "openrouter/stealth/space-bunny-alpha": { input: ["text", "image", "video", "pdf"] },
48
42
  };
49
43
 
50
44
  function applyModalityOverrides(provider: string, modelId: string, base: ModelModalities): ModelModalities {
@@ -68,10 +62,72 @@ function normalizeModelIdForLookup(modelId: string): string {
68
62
 
69
63
  // ---------------------------------------------------------------------------
70
64
  // Internal cache — battle-tested: avoid repeated global scans (7487 models)
65
+ // Bound lookupCache to 1000 entries LRU (Q-33): evict oldest on overflow.
71
66
  // ---------------------------------------------------------------------------
72
67
  const lookupCache = new Map<string, ModelSpec | undefined>();
73
68
  const providerModelsCache = new Map<string, ModelSpec[]>();
74
69
 
70
+ /** Bounded LRU set for lookupCache (Q-33): keeps at most 1000 entries. */
71
+ function setLookupCache(key: string, value: ModelSpec | undefined): void {
72
+ if (lookupCache.has(key)) lookupCache.delete(key);
73
+ else if (lookupCache.size >= 1000) {
74
+ const oldest = lookupCache.keys().next();
75
+ if (!oldest.done) lookupCache.delete(oldest.value);
76
+ }
77
+ lookupCache.set(key, value);
78
+ }
79
+
80
+ // Custom-registered models (Q-33): checked first in getModelFromCatalog.
81
+ const customModelRegistry = new Map<string, ModelSpec>();
82
+
83
+ function customRegistryKey(provider: string, modelId: string): string {
84
+ return `${provider.toLowerCase()}::${modelId.toLowerCase()}`;
85
+ }
86
+
87
+ /**
88
+ * Registers a custom model spec that takes precedence over the catalog (Q-33).
89
+ * Stored in a separate map, checked first in `getModelFromCatalog`.
90
+ */
91
+ export function registerModel(spec: ModelSpec): void {
92
+ if (!spec || typeof spec.id !== "string" || typeof spec.provider !== "string") {
93
+ throw new Error("[Agent Accelerator] registerModel requires spec.id and spec.provider strings");
94
+ }
95
+ customModelRegistry.set(customRegistryKey(String(spec.provider), String(spec.id)), { ...spec });
96
+ // Invalidate derived caches so the custom model is visible immediately.
97
+ lookupCache.clear();
98
+ providerModelsCache.clear();
99
+ globalIndex = null;
100
+ }
101
+
102
+ /** Clears custom-registered models (primarily for tests). */
103
+ export function clearCustomModels(): void {
104
+ customModelRegistry.clear();
105
+ lookupCache.clear();
106
+ providerModelsCache.clear();
107
+ globalIndex = null;
108
+ }
109
+
110
+ /**
111
+ * Returns where a model resolves from without touching frozen types (Q-33):
112
+ * `"override"` for custom-registered or MODALITY_OVERRIDES-covered models,
113
+ * `"catalog"` for plain catalog hits, `"unknown"` when unresolvable.
114
+ */
115
+ export function getModelSource(provider: string, modelId: string): "catalog" | "override" | "unknown" {
116
+ const p = String(provider ?? "").toLowerCase();
117
+ const m = String(modelId ?? "");
118
+ const norm = normalizeModelIdForLookup(m);
119
+ if (customModelRegistry.has(customRegistryKey(p, m)) || customModelRegistry.has(customRegistryKey(p, norm))) {
120
+ return "override";
121
+ }
122
+ if (
123
+ MODALITY_OVERRIDES[`${p}/${m}`] !== undefined ||
124
+ MODALITY_OVERRIDES[`${p}/${norm}`] !== undefined
125
+ ) {
126
+ return "override";
127
+ }
128
+ return getModelFromCatalog(provider, modelId) ? "catalog" : "unknown";
129
+ }
130
+
75
131
  function mapCapabilities(raw: any, cost: ModelCost, reasoning: boolean, toolCall: boolean, modalities: ModelModalities) {
76
132
  return {
77
133
  supportsThinking: reasoning,
@@ -82,6 +138,7 @@ function mapCapabilities(raw: any, cost: ModelCost, reasoning: boolean, toolCall
82
138
  supportsExplicitCaching: cost.cache_read !== undefined,
83
139
  supportsParallelToolCalls: toolCall,
84
140
  supportsStreaming: true,
141
+ supportsStructuredOutput: raw.structured_output === undefined ? true : !!raw.structured_output,
85
142
  supportsReasoningToggle: Array.isArray(raw.reasoning_options) && raw.reasoning_options.some((o: any) => o.type === "toggle"),
86
143
  supportsReasoningEffort: Array.isArray(raw.reasoning_options) && raw.reasoning_options.some((o: any) => o.type === "effort"),
87
144
  modalities: [...(modalities.input || []), ...(modalities.output || [])].filter((v: any, i: any, a: any) => a.indexOf(v) === i),
@@ -180,6 +237,16 @@ export function getModelFromCatalog(providerInput: string, modelIdInput: string)
180
237
  const modelId = modelIdInput.trim();
181
238
  let result: ModelSpec | undefined;
182
239
 
240
+ // Q-33: custom-registered models win over the catalog.
241
+ const customHit =
242
+ customModelRegistry.get(customRegistryKey(provider, modelId)) ??
243
+ customModelRegistry.get(customRegistryKey(provider, normalizeModelIdForLookup(modelId)));
244
+ if (customHit) {
245
+ result = { ...customHit };
246
+ setLookupCache(cacheKey, result);
247
+ return result;
248
+ }
249
+
183
250
  const aliasProviders = PROVIDER_ALIASES[provider] || [provider];
184
251
  const tryProviders = [...new Set([...aliasProviders, provider, provider.replace("-zen", ""), provider.replace("-go", "")])];
185
252
  const catalog = getActiveCatalog();
@@ -197,14 +264,17 @@ export function getModelFromCatalog(providerInput: string, modelIdInput: string)
197
264
  const raw = providerData.models[cand];
198
265
  if (raw) {
199
266
  result = mapModelSpec(p as ProviderId, cand, raw);
200
- lookupCache.set(cacheKey, result);
267
+ setLookupCache(cacheKey, result);
201
268
  return result;
202
269
  }
203
270
  }
271
+ // Q-33: tightened fuzzy match — exact, `endsWith("/"+modelId)` boundary,
272
+ // or normalized equality. The old loose `modelId.endsWith(key)` is removed
273
+ // (e.g. "gpt-4o-mini-extra" must not match "mini").
204
274
  for (const [key, raw] of Object.entries(providerData.models as Record<string, any>)) {
205
- if (key === modelId || key.endsWith(`/${modelId}`) || modelId.endsWith(key) || normalizeModelIdForLookup(key) === normalizeModelIdForLookup(modelId)) {
275
+ if (key === modelId || key.endsWith(`/${modelId}`) || normalizeModelIdForLookup(key) === normalizeModelIdForLookup(modelId)) {
206
276
  result = mapModelSpec(p as ProviderId, key, raw as any);
207
- lookupCache.set(cacheKey, result);
277
+ setLookupCache(cacheKey, result);
208
278
  return result;
209
279
  }
210
280
  }
@@ -217,11 +287,11 @@ export function getModelFromCatalog(providerInput: string, modelIdInput: string)
217
287
  const hit = index.get(lowerModelId) || index.get(strippedLower);
218
288
  if (hit) {
219
289
  result = mapModelSpec(hit.provider, hit.key, hit.raw);
220
- lookupCache.set(cacheKey, result);
290
+ setLookupCache(cacheKey, result);
221
291
  return result;
222
292
  }
223
293
 
224
- lookupCache.set(cacheKey, undefined);
294
+ setLookupCache(cacheKey, undefined);
225
295
  return undefined;
226
296
  }
227
297
 
@@ -349,16 +419,16 @@ export class ThinkingLevelError extends Error {
349
419
  remedy?: string;
350
420
  }) {
351
421
  const remedyLines = opts.remedy
352
- ? `\n\n \x1b[36m💡 How to fix:\x1b[0m\n ${opts.remedy}`
422
+ ? `\n\n How to fix:\n ${opts.remedy}`
353
423
  : "";
354
424
  const allowedLine = opts.allowedLevels.length > 0
355
- ? `\n \x1b[1mAllowed Levels:\x1b[0m [${opts.allowedLevels.map((l) => `"${l}"`).join(", ")}]`
425
+ ? `\n Allowed Levels: [${opts.allowedLevels.map((l) => `"${l}"`).join(", ")}]`
356
426
  : "";
357
427
 
358
428
  const formattedMessage =
359
- `\x1b[31m[Agent Accelerator] ThinkingLevel Mismatch for "${opts.provider}/${opts.modelId}":\x1b[0m\n` +
360
- ` \x1b[1mRequested Level:\x1b[0m "${opts.requestedLevel}"\n` +
361
- ` \x1b[1mIssue:\x1b[0m ${opts.reason}` +
429
+ `[Agent Accelerator] ThinkingLevel Mismatch for "${opts.provider}/${opts.modelId}":\n` +
430
+ ` Requested Level: "${opts.requestedLevel}"\n` +
431
+ ` Issue: ${opts.reason}` +
362
432
  allowedLine +
363
433
  remedyLines;
364
434
 
@@ -454,7 +524,7 @@ export function validateModelThinking(provider: string, modelId: string, request
454
524
  * @example `const googleModels = getModelsForProvider("google");`
455
525
  */
456
526
  export function getModelsForProvider(provider: string): ModelSpec[] {
457
- if (providerModelsCache.has(provider)) return providerModelsCache.get(provider)!;
527
+ if (providerModelsCache.has(provider)) return [...providerModelsCache.get(provider)!];
458
528
  const aliases = PROVIDER_ALIASES[provider] || [provider];
459
529
  const seen = new Set<string>();
460
530
  const out: ModelSpec[] = [];
@@ -468,9 +538,19 @@ export function getModelsForProvider(provider: string): ModelSpec[] {
468
538
  out.push(mapModelSpec(alias as ProviderId, key, raw));
469
539
  }
470
540
  }
541
+ // Q-33: include custom-registered models for this provider/alias set.
542
+ for (const spec of customModelRegistry.values()) {
543
+ if (aliases.includes(String(spec.provider)) || String(spec.provider) === provider) {
544
+ if (!seen.has(spec.id)) {
545
+ seen.add(spec.id);
546
+ out.push({ ...spec });
547
+ }
548
+ }
549
+ }
471
550
  // Also include our internal provider id mapping
551
+ // Q-33: return copies — cache the canonical array, hand out a fresh copy.
472
552
  providerModelsCache.set(provider, out);
473
- return out;
553
+ return [...out];
474
554
  }
475
555
 
476
556
  export const GOOGLE_MODELS: ModelSpec[] = [...getModelsForProvider("google")];
@@ -509,6 +589,30 @@ export {
509
589
  type RefreshCatalogOptions,
510
590
  } from "./catalog-cache.ts";
511
591
 
592
+ /**
593
+ * True when the model can enforce `response_format`/`text.format` natively.
594
+ * Unknown models (custom endpoints, dynamic routers) count as capable —
595
+ * the provider verdict stands, matching `assertModalitiesSupported`.
596
+ * Models with catalog `structured_output: false` return false so callers can
597
+ * emulate via prompt instruction + client validation instead of 400ing.
598
+ *
599
+ * @example `modelSupportsStructuredOutput("openrouter", "inclusionai/ling-3.0-flash-sante:free")`
600
+ */
601
+ export function modelSupportsStructuredOutput(provider: string, modelId: string): boolean {
602
+ try {
603
+ const spec = getModelFromCatalog(provider, modelId);
604
+ if (!spec) return true;
605
+ if (typeof spec.capabilities?.supportsStructuredOutput === "boolean") {
606
+ return spec.capabilities.supportsStructuredOutput;
607
+ }
608
+ const raw = (spec as { raw?: { structured_output?: unknown } }).raw;
609
+ if (raw && typeof raw.structured_output === "boolean") return raw.structured_output;
610
+ return true;
611
+ } catch {
612
+ return true;
613
+ }
614
+ }
615
+
512
616
  /**
513
617
  * Permissive placeholder so custom endpoints never fail preflight:
514
618
  * allows every ThinkingLevel (none -> max + dynamic).
@@ -543,6 +647,7 @@ export function createGenericModelSpec(provider: string, modelId: string): Model
543
647
  supportsLongCacheRetention: false,
544
648
  supportsParallelToolCalls: true,
545
649
  supportsStreaming: true,
650
+ supportsStructuredOutput: true,
546
651
  modalities: ["text"],
547
652
  },
548
653
  pricing: {},
@@ -40,8 +40,19 @@ import { safeStringify } from "../utils/serialization.ts";
40
40
  import { stripSchemaForGoogle } from "../tools/schema.ts";
41
41
  import { toConciseProviderError, assertModalitiesSupported } from "../utils/errors.ts";
42
42
  import { withRetries } from "../utils/retry.ts";
43
+ import { setHeaderCaseInsensitive, shouldEchoSignature } from "../utils/headers.ts";
44
+ import {
45
+ newToolCallId,
46
+ redactedHeaders,
47
+ readErrorPayload,
48
+ incompleteStreamError,
49
+ combinedSignal,
50
+ timeoutFor,
51
+ hasAuthHeader,
52
+ clampToolCallId,
53
+ } from "./shared.ts";
43
54
  import { createGenericModelSpec } from "../models/catalog.ts";
44
- import { getModelFromCatalog, getModelsForProvider } from "../models/catalog.ts";
55
+ import { getModelFromCatalog, getModelsForProvider, modelSupportsStructuredOutput } from "../models/catalog.ts";
45
56
  import {
46
57
  mapThinkingLevelToGoogle,
47
58
  mapServiceTierToGoogle,
@@ -50,6 +61,10 @@ import {
50
61
  lastProviderFor,
51
62
  noteProviderTurn,
52
63
  parseStreamedToolArguments,
64
+ mapOutputToGoogle,
65
+ normalizeStructuredOutput,
66
+ augmentSystemPromptWithStructuredOutput,
67
+ emitProviderWarning,
53
68
  } from "../providers.ts";
54
69
 
55
70
  // ---------------------------------------------------------------------------
@@ -167,7 +182,10 @@ function resolveBaseUrl(options?: ProviderRequestOptions): string {
167
182
  }
168
183
 
169
184
  function resolveApiKey(options?: ProviderRequestOptions): string | undefined {
170
- return options?.apiKey || getApiKey("google", undefined, options?.env);
185
+ const key = getApiKey("google", options?.apiKey, options?.env);
186
+ if (key) return key;
187
+ if (hasAuthHeader(options?.headers)) return "unused";
188
+ return undefined;
171
189
  }
172
190
 
173
191
  function cleanModelId(model: string | ModelSpec): string {
@@ -304,9 +322,16 @@ async function fullHistorySteps(context: ProviderContext): Promise<InteractionIn
304
322
  const texts: string[] = [];
305
323
  for (const part of m.content) {
306
324
  if (part.type === "thinking" && part.thinking) {
325
+ // Q-18: preserve namespaced signatures only — foreign-provider
326
+ // signatures never echo here; untagged legacy echoes (compat).
327
+ const partProvider = (part as { thoughtSignatureProvider?: unknown }).thoughtSignatureProvider;
328
+ const msgProvider = (m as { thoughtSignatureProvider?: unknown }).thoughtSignatureProvider;
329
+ const sig = shouldEchoSignature(partProvider ?? msgProvider, "google")
330
+ ? part.thoughtSignature || m.thoughtSignature || ""
331
+ : "";
307
332
  steps.push({
308
333
  type: "thought",
309
- signature: part.thoughtSignature || m.thoughtSignature || "",
334
+ signature: sig,
310
335
  summary: [{ type: "text", text: part.thinking }],
311
336
  });
312
337
  } else if (part.type === "tool_call") {
@@ -328,7 +353,11 @@ async function fullHistorySteps(context: ProviderContext): Promise<InteractionIn
328
353
  } else if (m.thoughtSignature && !steps.some((s) => s.type === "thought")) {
329
354
  // Preserve a signature-only thought so stateless chaining keeps
330
355
  // reasoning continuity even when no summary text was retained.
331
- steps.push({ type: "thought", signature: m.thoughtSignature });
356
+ // Q-18: only for google/untagged signatures.
357
+ const msgProvider = (m as { thoughtSignatureProvider?: unknown }).thoughtSignatureProvider;
358
+ if (shouldEchoSignature(msgProvider, "google")) {
359
+ steps.push({ type: "thought", signature: m.thoughtSignature });
360
+ }
332
361
  }
333
362
  } else if (m.role === "tool") {
334
363
  if (!Array.isArray(m.content)) continue;
@@ -408,7 +437,7 @@ function parseInteraction(
408
437
  const args =
409
438
  step.arguments && typeof step.arguments === "object" ? step.arguments : { raw: step.arguments };
410
439
  toolCalls.push({
411
- id: step.id || `call_${Math.random().toString(36).slice(2, 9)}`,
440
+ id: step.id || newToolCallId(),
412
441
  name: step.name || "unknown",
413
442
  arguments: args as Record<string, unknown>,
414
443
  rawArguments: JSON.stringify(args),
@@ -431,26 +460,11 @@ function parseInteraction(
431
460
  };
432
461
  }
433
462
 
434
- function redactedHeaders(headers: Record<string, string>): Record<string, string> {
435
- const out: Record<string, string> = {};
436
- for (const [k, v] of Object.entries(headers)) {
437
- out[k] = k.toLowerCase() === "x-goog-api-key" ? "[REDACTED]" : v;
438
- }
439
- return out;
440
- }
441
-
442
- function readErrorPayload(bodyText: string): { message: string; code?: string | number } {
443
- try {
444
- const parsed: unknown = JSON.parse(bodyText);
445
- const first = Array.isArray(parsed) ? parsed[0] : parsed;
446
- const err = (first as { error?: { message?: string; code?: string | number; status?: string } })?.error;
447
- if (err && typeof err.message === "string") {
448
- return { message: err.message, code: err.code ?? err.status };
449
- }
450
- return { message: bodyText.slice(0, 300) };
451
- } catch {
452
- return { message: bodyText.slice(0, 300) };
453
- }
463
+ /** Local per-provider signature tag (Q-18): google history preserves only
464
+ * google/untagged signatures; foreign-provider signatures never echo here. */
465
+ function withGoogleSignature(sig: string | undefined): { thoughtSignature?: string; thoughtSignatureProvider?: string } {
466
+ if (!sig) return {};
467
+ return { thoughtSignature: sig, thoughtSignatureProvider: "google" };
454
468
  }
455
469
 
456
470
  // ---------------------------------------------------------------------------
@@ -512,11 +526,43 @@ export class GoogleInteractionsProvider implements Provider {
512
526
  input: "",
513
527
  ...(stream ? { stream: true } : {}),
514
528
  };
515
- if (context.systemPrompt) body.system_instruction = context.systemPrompt;
529
+ // Structured outputs: native `response_format` when supported, else
530
+ // emulated via system instruction (catalog `structured_output: false`
531
+ // would 400 on the wire shape).
532
+ let outputEmulated = false;
533
+ let outputSpec: import("../types/model.ts").StructuredOutputSpec | undefined;
534
+ try {
535
+ outputSpec = normalizeStructuredOutput(options?.output ?? (options as { outputSchema?: unknown })?.outputSchema);
536
+ } catch {
537
+ outputSpec = undefined;
538
+ }
539
+ let effectiveSystemPrompt = context.systemPrompt;
540
+ if (outputSpec && !modelSupportsStructuredOutput("google", modelId)) {
541
+ outputEmulated = true;
542
+ effectiveSystemPrompt = augmentSystemPromptWithStructuredOutput(context.systemPrompt, outputSpec);
543
+ emitProviderWarning({
544
+ provider: "google",
545
+ capability: "structured output",
546
+ requested: `${outputSpec.name} (google/${modelId})`,
547
+ reason: "the catalog reports structured_output: false for this model, so native response_format is unsupported.",
548
+ fallback: "emulated JSON instruction + client validation (no response_format is sent)",
549
+ });
550
+ }
551
+ if (effectiveSystemPrompt) body.system_instruction = effectiveSystemPrompt;
516
552
  const googleTools = toGoogleTools(tools);
517
553
  if (googleTools) body.tools = googleTools;
518
554
  if (Object.keys(generationConfig).length > 0) body.generation_config = generationConfig;
519
555
  if (serviceTier) body.service_tier = serviceTier;
556
+ // Structured outputs: Interactions `response_format{type:text,mime_type:application/json,schema}`.
557
+ // Skipped when emulated (instruction already in `system_instruction`).
558
+ if (outputSpec && !outputEmulated) {
559
+ try {
560
+ const mapped = mapOutputToGoogle(outputSpec);
561
+ if (mapped) (body as unknown as Record<string, unknown>)["response_format"] = mapped.responseFormat;
562
+ } catch {
563
+ // why: invalid output shapes fail fast at Agent construction/loop validation.
564
+ }
565
+ }
520
566
 
521
567
  const prevProvider = lastProviderFor(sessionId);
522
568
  const switched = !!prevProvider && prevProvider !== "google";
@@ -557,7 +603,11 @@ export class GoogleInteractionsProvider implements Provider {
557
603
  for (const [k, v] of Object.entries(options?.headers ?? {})) {
558
604
  if (!INTERNAL_HEADERS.has(k.toLowerCase())) headers[k] = v;
559
605
  }
560
- headers["x-goog-api-key"] = apiKey;
606
+ // Q-17: key travels as the `x-goog-api-key` header (never `?key=` in the
607
+ // URL, which would leak into logs). Case-insensitive merge so a custom
608
+ // `X-Goog-Api-Key` never duplicates the canonical header. Proxy flows
609
+ // (`apiKey === "unused"`) preserve custom auth instead of overwriting.
610
+ if (apiKey !== "unused") setHeaderCaseInsensitive(headers, "x-goog-api-key", apiKey);
561
611
  return { method: "POST", headers, body: JSON.stringify(body), signal };
562
612
  }
563
613
 
@@ -568,13 +618,18 @@ export class GoogleInteractionsProvider implements Provider {
568
618
  apiKey: string,
569
619
  signal?: AbortSignal
570
620
  ): Promise<{ status: number; statusText: string; headers: Record<string, string>; text: string }> {
571
- const res = await fetch(url, this.requestInit(body, options, apiKey, signal));
572
- const text = await res.text();
573
- const headers: Record<string, string> = {};
574
- res.headers.forEach((v, k) => {
575
- headers[k] = v;
576
- });
577
- return { status: res.status, statusText: res.statusText, headers, text };
621
+ const { signal: effective, cleanup } = combinedSignal(signal, timeoutFor(options as { timeoutMs?: number } | undefined));
622
+ try {
623
+ const res = await fetch(url, this.requestInit(body, options, apiKey, effective));
624
+ const text = await res.text();
625
+ const headers: Record<string, string> = {};
626
+ res.headers.forEach((v, k) => {
627
+ headers[k] = v;
628
+ });
629
+ return { status: res.status, statusText: res.statusText, headers, text };
630
+ } finally {
631
+ cleanup();
632
+ }
578
633
  }
579
634
 
580
635
  private throwIfError(
@@ -585,13 +640,21 @@ export class GoogleInteractionsProvider implements Provider {
585
640
  modelId: string
586
641
  ): void {
587
642
  if (status >= 200 && status < 300) return;
588
- const { message, code } = readErrorPayload(bodyText);
589
- const err: Record<string, unknown> & Error = new Error(message) as Record<string, unknown> & Error;
643
+ const { message, code, errorType, raw } = readErrorPayload(bodyText);
644
+ const hint =
645
+ status === 401
646
+ ? " 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)."
647
+ : "";
648
+ const fullMessage = `${message}${hint}`;
649
+ const err: Record<string, unknown> & Error = new Error(fullMessage) as Record<string, unknown> & Error;
590
650
  (err as Record<string, unknown>)["statusCode"] = status;
591
651
  (err as Record<string, unknown>)["status"] = status;
592
652
  (err as Record<string, unknown>)["responseBody"] = bodyText.slice(0, 500);
593
653
  (err as Record<string, unknown>)["url"] = url.split("?")[0];
654
+ (err as Record<string, unknown>)["data"] = { error: { message: fullMessage } };
594
655
  if (code !== undefined) (err as Record<string, unknown>)["code"] = code;
656
+ if (errorType) (err as Record<string, unknown>)["errorType"] = errorType;
657
+ if (raw) (err as Record<string, unknown>)["raw"] = raw;
595
658
  throw toConciseProviderError(err, "google", modelId);
596
659
  }
597
660
 
@@ -733,7 +796,21 @@ export class GoogleInteractionsProvider implements Provider {
733
796
  body,
734
797
  };
735
798
 
736
- const res = await fetch(url, this.requestInit(body, options, apiKey, linked.signal));
799
+ const streamTimeoutMs = timeoutFor(options as { timeoutMs?: number } | undefined);
800
+ const streamTimeoutTimer =
801
+ streamTimeoutMs && streamTimeoutMs > 0
802
+ ? setTimeout(() => {
803
+ try {
804
+ linked.abort();
805
+ } catch {}
806
+ }, streamTimeoutMs)
807
+ : undefined;
808
+ let res: Response;
809
+ try {
810
+ res = await fetch(url, this.requestInit(body, options, apiKey, linked.signal));
811
+ } finally {
812
+ if (streamTimeoutTimer) clearTimeout(streamTimeoutTimer);
813
+ }
737
814
  if (!res.ok || !res.body) {
738
815
  const text = !res.ok ? await res.text().catch(() => "") : "";
739
816
  if (!res.ok) this.throwIfError(res.status, res.statusText, url, text, clean);
@@ -743,7 +820,7 @@ export class GoogleInteractionsProvider implements Provider {
743
820
  res.headers.forEach((v, k) => {
744
821
  responseHeaders[k] = v;
745
822
  });
746
- const responseMeta = { status: res.status, statusText: res.statusText, headers: responseHeaders };
823
+ const responseMeta: Record<string, unknown> & { status: number; statusText: string; headers: Record<string, string> } = { status: res.status, statusText: res.statusText, headers: responseHeaders };
747
824
  eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
748
825
 
749
826
  const parser = new SSEParser();
@@ -755,6 +832,10 @@ export class GoogleInteractionsProvider implements Provider {
755
832
  const calls = new Map<number, { id: string; name: string; startArgs: string; deltaArgs: string }>();
756
833
  let usage: TokenUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
757
834
  let finishReason = "stop";
835
+ let sawFinishReason = false;
836
+ let sawUsage = false;
837
+ let badFrames = 0;
838
+ let framesSeen = 0;
758
839
  let responseId: string | undefined;
759
840
  let completedBody: unknown = undefined;
760
841
  let aborted = false;
@@ -770,11 +851,17 @@ export class GoogleInteractionsProvider implements Provider {
770
851
  );
771
852
 
772
853
  const handleMessage = (event: string | undefined, data: string): void => {
773
- if (!data || data === "[DONE]") return;
854
+ if (!data) return;
855
+ if (data === "[DONE]") {
856
+ sawFinishReason = sawFinishReason || completedBody !== undefined || sawUsage;
857
+ return;
858
+ }
774
859
  let msg: Record<string, unknown>;
775
860
  try {
776
861
  msg = JSON.parse(data) as Record<string, unknown>;
862
+ framesSeen += 1;
777
863
  } catch {
864
+ badFrames += 1;
778
865
  return;
779
866
  }
780
867
  const type = (msg["event_type"] as string) || event;
@@ -826,7 +913,7 @@ export class GoogleInteractionsProvider implements Provider {
826
913
  const args = step["arguments"];
827
914
  const startArgs = typeof args === "string" ? args : args ? JSON.stringify(args) : "";
828
915
  calls.set(index, {
829
- id: (step["id"] as string) || `call_${Math.random().toString(36).slice(2, 9)}`,
916
+ id: (step["id"] as string) || newToolCallId(),
830
917
  name: (step["name"] as string) || "unknown",
831
918
  startArgs,
832
919
  deltaArgs: "",
@@ -836,8 +923,12 @@ export class GoogleInteractionsProvider implements Provider {
836
923
  const interaction = (msg["interaction"] as InteractionObject) ?? {};
837
924
  completedBody = msg["interaction"];
838
925
  if (interaction.id) responseId = interaction.id;
839
- if (interaction.usage) usage = mapUsage(interaction.usage);
926
+ if (interaction.usage) {
927
+ usage = mapUsage(interaction.usage);
928
+ sawUsage = true;
929
+ }
840
930
  finishReason = mapFinishReason(interaction.status);
931
+ sawFinishReason = true;
841
932
  eventStream.push({ type: "usage", usage });
842
933
  }
843
934
  };
@@ -882,10 +973,25 @@ export class GoogleInteractionsProvider implements Provider {
882
973
  }
883
974
  noteProviderTurn(sessionId, "google");
884
975
 
976
+ // Q-23: terminal-state guard — fail `incomplete_stream` (retryable)
977
+ // instead of resolving empty success. Skipped for non-SSE payloads
978
+ // (JSON mocks) where no SSE frames were ever seen.
979
+ const hasTerminal = completedBody !== undefined || sawFinishReason || sawUsage;
885
980
  // Trim provider trailing blank lines from finalized thinking (live
886
981
  // deltas already emitted raw; display collapse happens in the
887
982
  // wrapThinking presentation layer). Stateful bodies unaffected.
888
- const cleanThinking = thinking.replace(/\n{3,}/g, "\n\n").trim();
983
+ const cleanThinkingEarly = thinking.replace(/\n{3,}/g, "\n\n").trim();
984
+ const ctEntry = Object.entries(responseHeaders).find(([k]) => k.toLowerCase() === "content-type");
985
+ const isSSEPayload =
986
+ (typeof ctEntry?.[1] === "string" && ((ctEntry[1] as string).includes("event-stream") || (ctEntry[1] as string).startsWith("text/"))) ||
987
+ framesSeen > 0 ||
988
+ badFrames > 0;
989
+ if (!hasTerminal && !text && !cleanThinkingEarly && toolCalls.length === 0 && isSSEPayload) {
990
+ eventStream.fail(incompleteStreamError("google", clean));
991
+ return;
992
+ }
993
+
994
+ const cleanThinking = cleanThinkingEarly;
889
995
  const finalResponse = new AgentResponse({
890
996
  text,
891
997
  thinking: cleanThinking || undefined,
@@ -896,7 +1002,10 @@ export class GoogleInteractionsProvider implements Provider {
896
1002
  responseId,
897
1003
  model: clean,
898
1004
  provider: "google",
899
- raw: { request: rawRequest, response: { ...responseMeta, body: completedBody } },
1005
+ raw: {
1006
+ request: rawRequest,
1007
+ response: { ...responseMeta, body: completedBody, badFrames } as unknown as ProviderRawData["response"],
1008
+ },
900
1009
  durationMs: Date.now() - startTime,
901
1010
  });
902
1011
  eventStream.push({ type: "done", delta: "", usage, finishReason, responseId });