mixdog 0.9.50 → 0.9.52

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 (134) hide show
  1. package/package.json +5 -3
  2. package/scripts/abort-recovery-test.mjs +17 -1
  3. package/scripts/agent-model-liveness-test.mjs +79 -2
  4. package/scripts/anthropic-admission-retry-integration-test.mjs +119 -0
  5. package/scripts/anthropic-transport-policy-test.mjs +466 -0
  6. package/scripts/atomic-lock-tryonce-test.mjs +60 -1
  7. package/scripts/build-tui.mjs +13 -1
  8. package/scripts/channel-daemon-smoke.mjs +630 -10
  9. package/scripts/code-graph-aggregate-cwd-test.mjs +41 -37
  10. package/scripts/code-graph-disk-hit-test.mjs +11 -0
  11. package/scripts/code-graph-root-federation-test.mjs +273 -0
  12. package/scripts/compact-pressure-test.mjs +55 -1
  13. package/scripts/compact-smoke.mjs +80 -0
  14. package/scripts/context-mcp-metering-test.mjs +1350 -19
  15. package/scripts/deferred-tool-loading-test.mjs +17 -0
  16. package/scripts/gemini-provider-test.mjs +1053 -0
  17. package/scripts/hook-bus-test.mjs +23 -0
  18. package/scripts/internal-tools-normalization-test.mjs +10 -0
  19. package/scripts/interrupted-turn-history-test.mjs +371 -0
  20. package/scripts/lifecycle-api-test.mjs +76 -0
  21. package/scripts/max-output-recovery-test.mjs +55 -0
  22. package/scripts/mcp-grace-deferred-test.mjs +89 -13
  23. package/scripts/memory-pg-recovery-test.mjs +59 -0
  24. package/scripts/openai-oauth-ws-1006-retry-test.mjs +391 -4
  25. package/scripts/process-lifecycle-test.mjs +389 -0
  26. package/scripts/provider-admission-scheduler-test.mjs +582 -0
  27. package/scripts/provider-contract-test.mjs +268 -0
  28. package/scripts/provider-toolcall-test.mjs +520 -3
  29. package/scripts/reactive-compact-persist-smoke.mjs +59 -0
  30. package/scripts/resource-admission-test.mjs +789 -0
  31. package/scripts/session-bench-cache-break-test.mjs +102 -0
  32. package/scripts/session-bench.mjs +101 -42
  33. package/scripts/shell-failure-diagnostics-test.mjs +73 -4
  34. package/scripts/shell-jobs-windows-hide-test.mjs +1 -1
  35. package/scripts/smoke-loop-failure-summary-test.mjs +38 -0
  36. package/scripts/smoke-loop-failure-summary.mjs +16 -0
  37. package/scripts/smoke-loop.mjs +2 -7
  38. package/scripts/steering-drain-buckets-test.mjs +18 -0
  39. package/scripts/tool-failures.mjs +15 -1
  40. package/scripts/toolcall-args-test.mjs +14 -6
  41. package/scripts/tui-transcript-perf-test.mjs +43 -7
  42. package/scripts/web-fetch-routing-test.mjs +158 -0
  43. package/src/cli.mjs +15 -2
  44. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +26 -0
  45. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +4 -1
  46. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -0
  47. package/src/runtime/agent/orchestrator/internal-tools.mjs +2 -0
  48. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +331 -0
  49. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +118 -26
  50. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +29 -17
  51. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +136 -38
  52. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +24 -4
  53. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +554 -42
  54. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +67 -18
  55. package/src/runtime/agent/orchestrator/providers/gemini.mjs +95 -46
  56. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -4
  57. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +54 -14
  58. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +1 -1
  59. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +14 -3
  60. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +10 -80
  61. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +89 -17
  62. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +93 -26
  63. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +144 -51
  64. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +22 -8
  65. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +74 -1
  66. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +111 -6
  67. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +13 -17
  68. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +78 -12
  69. package/src/runtime/agent/orchestrator/providers/opencode-go.mjs +44 -5
  70. package/src/runtime/agent/orchestrator/providers/registry.mjs +49 -8
  71. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +224 -104
  72. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +127 -55
  73. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +99 -32
  74. package/src/runtime/agent/orchestrator/session/context-utils.mjs +17 -1
  75. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +38 -0
  76. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +15 -0
  77. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +69 -37
  78. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +10 -1
  79. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +8 -28
  80. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +6 -7
  81. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +2 -0
  82. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +10 -1
  83. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +42 -1
  84. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +5 -1
  85. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +220 -0
  86. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +24 -4
  87. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +5 -0
  88. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +17 -0
  89. package/src/runtime/agent/orchestrator/session/store.mjs +89 -22
  90. package/src/runtime/agent/orchestrator/stall-policy.mjs +2 -12
  91. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +42 -4
  92. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +74 -37
  93. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +223 -2
  94. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +380 -37
  95. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +176 -21
  96. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +14 -0
  97. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +108 -2
  98. package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +93 -0
  99. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +12 -3
  100. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +124 -14
  101. package/src/runtime/memory/index.mjs +22 -4
  102. package/src/runtime/memory/lib/pg/adapter.mjs +84 -10
  103. package/src/runtime/memory/lib/pg/process.mjs +91 -47
  104. package/src/runtime/memory/lib/pg/supervisor.mjs +50 -13
  105. package/src/runtime/search/index.mjs +41 -0
  106. package/src/runtime/search/lib/http-fetch.mjs +154 -0
  107. package/src/runtime/search/tool-defs.mjs +23 -0
  108. package/src/runtime/shared/atomic-file.mjs +28 -150
  109. package/src/runtime/shared/process-lifecycle.mjs +363 -0
  110. package/src/runtime/shared/process-shutdown.mjs +27 -4
  111. package/src/runtime/shared/resource-admission.mjs +359 -0
  112. package/src/runtime/shared/staged-child-result.mjs +19 -0
  113. package/src/session-runtime/context-status.mjs +38 -27
  114. package/src/session-runtime/lifecycle-api.mjs +26 -6
  115. package/src/session-runtime/provider-request-tools.mjs +72 -0
  116. package/src/session-runtime/runtime-core.mjs +43 -18
  117. package/src/session-runtime/session-turn-api.mjs +5 -4
  118. package/src/session-runtime/tool-catalog.mjs +375 -15
  119. package/src/standalone/agent-tool.mjs +17 -38
  120. package/src/standalone/channel-daemon-client.mjs +200 -38
  121. package/src/standalone/channel-daemon-transport.mjs +136 -9
  122. package/src/standalone/channel-worker.mjs +79 -12
  123. package/src/standalone/hook-bus/handlers.mjs +4 -0
  124. package/src/standalone/hook-bus/rules.mjs +7 -1
  125. package/src/standalone/hook-bus.mjs +10 -2
  126. package/src/tui/App.jsx +17 -11
  127. package/src/tui/app/live-spinner-visibility.mjs +20 -0
  128. package/src/tui/dist/index.mjs +101 -281
  129. package/src/tui/engine/session-api-ext.mjs +13 -2
  130. package/src/tui/engine/session-api.mjs +1 -1
  131. package/src/tui/engine/turn.mjs +10 -6
  132. package/src/tui/engine.mjs +13 -4
  133. package/src/tui/index.jsx +4 -1
  134. package/src/tui/lib/voice-setup.mjs +16 -0
@@ -17,7 +17,12 @@ import {
17
17
  } from '../stall-policy.mjs';
18
18
  import { scanLeakedToolCalls } from './anthropic-leaked-toolcall.mjs';
19
19
  import { traceHash, stableTraceStringify } from './trace-utils.mjs';
20
- import { parseToolCalls, emitGeminiToolCalls, collectGeminiGroundingSources } from './gemini.mjs';
20
+ import {
21
+ parseToolCalls,
22
+ emitGeminiToolCalls,
23
+ collectGeminiGroundingSources,
24
+ parseGeminiTextPartMetadata,
25
+ } from './gemini.mjs';
21
26
 
22
27
  export const GEMINI_FIRST_BYTE_TIMEOUT_MS = resolveTimeoutMs(
23
28
  'MIXDOG_GEMINI_FIRST_BYTE_TIMEOUT_MS',
@@ -47,10 +52,17 @@ export function geminiTruncatedStreamError(message) {
47
52
  // stall after visible output was silently retried. Visible-text stalls also
48
53
  // gain streamStalled + partialContent so the loop's partial-final path can
49
54
  // keep the streamed output instead of dropping the turn.
50
- export function stampGeminiStreamFailure(err, { relayedText = '', textLeakGuard = null, sawFunctionCall = false } = {}) {
55
+ export function stampGeminiStreamFailure(err, {
56
+ relayedText = '',
57
+ textLeakGuard = null,
58
+ sawFunctionCall = false,
59
+ chunks = [],
60
+ } = {}) {
51
61
  if (!err || typeof err !== 'object') return err;
52
62
  const leaked = (textLeakGuard?.getLeakedToolCalls?.() || []).length > 0;
53
- const visible = relayedText.length > 0;
63
+ const finalizedText = textLeakGuard?.getVisibleText?.();
64
+ const visibleText = typeof finalizedText === 'string' ? finalizedText : relayedText;
65
+ const visible = visibleText.length > 0;
54
66
  // A native functionCall chunk (not a text-leaked one) is also an in-flight
55
67
  // tool use — replaying would double-dispatch, and partial-final must NOT
56
68
  // treat it as a clean no-tool summary.
@@ -58,13 +70,16 @@ export function stampGeminiStreamFailure(err, { relayedText = '', textLeakGuard
58
70
  try {
59
71
  if (visible) { err.liveTextEmitted = true; err.unsafeToRetry = true; }
60
72
  if (pendingTool) { err.emittedToolCall = true; err.unsafeToRetry = true; }
73
+ const partialParts = aggregateGeminiStreamChunks(chunks)?.candidates?.[0]?.content?.parts || [];
74
+ const providerMetadata = parseGeminiTextPartMetadata(partialParts);
75
+ if (providerMetadata) err.providerMetadata = providerMetadata;
61
76
  // TRUNCATED_STREAM EOF after visible output must also carry the
62
77
  // partial-final stamps (streamStalled/partialContent), aligning with the
63
78
  // compat streams — otherwise live output is dropped instead of kept.
64
79
  if (visible && !leaked
65
80
  && (err.code === 'EGEMINITIMEOUT' || err.code === 'TRUNCATED_STREAM' || err.truncatedStream === true)) {
66
81
  err.streamStalled = true;
67
- if (typeof err.partialContent !== 'string') err.partialContent = relayedText;
82
+ if (typeof err.partialContent !== 'string') err.partialContent = visibleText;
68
83
  if (err.pendingToolUse === undefined) err.pendingToolUse = pendingTool;
69
84
  }
70
85
  } catch { /* best-effort */ }
@@ -113,6 +128,7 @@ export function aggregateGeminiStreamChunks(responses) {
113
128
  for (const part of candidate.content.parts) {
114
129
  const newPart = {};
115
130
  if (part.text) newPart.text = part.text;
131
+ if (part.thought === true) newPart.thought = true;
116
132
  if (part.functionCall) newPart.functionCall = part.functionCall;
117
133
  if (part.thoughtSignature) newPart.thoughtSignature = part.thoughtSignature;
118
134
  if (part.thought_signature) newPart.thought_signature = part.thought_signature;
@@ -130,11 +146,11 @@ export function aggregateGeminiStreamChunks(responses) {
130
146
  return aggregatedResponse;
131
147
  }
132
148
 
133
- export function assertGeminiStreamCompleted({ sawStreamChunk, finishReason, label }) {
149
+ export function assertGeminiStreamCompleted({ sawStreamChunk, finishReason, promptBlockReason, label }) {
134
150
  if (!sawStreamChunk) {
135
151
  throw geminiTruncatedStreamError(`${label} truncated: empty stream`);
136
152
  }
137
- if (!finishReason) {
153
+ if (!finishReason && !promptBlockReason) {
138
154
  throw geminiTruncatedStreamError(`${label} truncated: no finishReason`);
139
155
  }
140
156
  }
@@ -148,7 +164,8 @@ export function geminiChunkText(chunk) {
148
164
  if (!Array.isArray(parts)) return '';
149
165
  let text = '';
150
166
  for (const p of parts) {
151
- if (p && typeof p.text === 'string') text += p.text;
167
+ // Thought summaries are reasoning, not user-visible answer deltas.
168
+ if (p && p.thought !== true && typeof p.text === 'string') text += p.text;
152
169
  }
153
170
  return text;
154
171
  }
@@ -171,6 +188,7 @@ export function createGeminiTextLeakGuard({ knownToolNames, onTextDelta, onToolC
171
188
  const _enabled = _knownTools.size > 0;
172
189
  const _isKnownTool = (name) => _knownTools.has(name);
173
190
  let leakBuffer = '';
191
+ let visibleText = '';
174
192
  const leakedCalls = [];
175
193
  const dispatchedFingerprints = new Set();
176
194
 
@@ -207,7 +225,10 @@ export function createGeminiTextLeakGuard({ knownToolNames, onTextDelta, onToolC
207
225
  const { emit, calls, rest } = scanLeakedToolCalls(leakBuffer, { isKnownTool: _isKnownTool, final });
208
226
  leakBuffer = rest;
209
227
  if (emit && onTextDelta) {
228
+ visibleText += emit;
210
229
  try { onTextDelta(emit); } catch {}
230
+ } else if (emit) {
231
+ visibleText += emit;
211
232
  }
212
233
  for (const c of calls) dispatchLeakedCall(c);
213
234
  };
@@ -217,6 +238,7 @@ export function createGeminiTextLeakGuard({ knownToolNames, onTextDelta, onToolC
217
238
  feedText(text) {
218
239
  if (!text) return;
219
240
  if (!_enabled) {
241
+ visibleText += text;
220
242
  try { onTextDelta?.(text); } catch {}
221
243
  return;
222
244
  }
@@ -247,6 +269,9 @@ export function createGeminiTextLeakGuard({ knownToolNames, onTextDelta, onToolC
247
269
  getLeakedToolCalls() {
248
270
  return leakedCalls.length ? [...leakedCalls] : [];
249
271
  },
272
+ getVisibleText() {
273
+ return visibleText;
274
+ },
250
275
  };
251
276
  }
252
277
 
@@ -262,6 +287,12 @@ export async function consumeGeminiRestStreamResponse(response, { signal, onStre
262
287
  let idleReject = null;
263
288
  let relayedText = '';
264
289
  let sawFunctionCall = false;
290
+ let leakGuardFinalized = false;
291
+ const finalizeLeakGuard = () => {
292
+ if (leakGuardFinalized) return;
293
+ leakGuardFinalized = true;
294
+ try { textLeakGuard?.finalize(); } catch {}
295
+ };
265
296
 
266
297
  let firstByteTimer = setTimeout(() => {
267
298
  try { reader.cancel('first byte timeout'); } catch {}
@@ -381,25 +412,27 @@ export async function consumeGeminiRestStreamResponse(response, { signal, onStre
381
412
  }
382
413
  }
383
414
  } catch (err) {
384
- throw stampGeminiStreamFailure(err, { relayedText, textLeakGuard, sawFunctionCall });
415
+ finalizeLeakGuard();
416
+ throw stampGeminiStreamFailure(err, { relayedText, textLeakGuard, sawFunctionCall, chunks: allChunks });
385
417
  } finally {
386
418
  clearFirstByteTimer();
387
419
  if (idleTimer) clearTimeout(idleTimer);
388
420
  if (signal) signal.removeEventListener('abort', onAbort);
389
421
  try { reader.releaseLock(); } catch {}
390
- try { textLeakGuard?.finalize(); } catch {}
422
+ finalizeLeakGuard();
391
423
  }
392
424
 
393
425
  const aggregated = aggregateGeminiStreamChunks(allChunks);
394
426
  const finishReason = aggregated.candidates?.[0]?.finishReason || null;
427
+ const promptBlockReason = aggregated.promptFeedback?.blockReason || null;
395
428
  // Truncation (no finishReason) after visible output must carry the same
396
429
  // safety stamps as an in-loop failure — assert throws OUTSIDE the catch
397
430
  // above, so stamp here too (review High: transient TRUNCATED_STREAM would
398
431
  // otherwise replay/double-render live text via withRetry).
399
432
  try {
400
- assertGeminiStreamCompleted({ sawStreamChunk, finishReason, label });
433
+ assertGeminiStreamCompleted({ sawStreamChunk, finishReason, promptBlockReason, label });
401
434
  } catch (err) {
402
- throw stampGeminiStreamFailure(err, { relayedText, textLeakGuard, sawFunctionCall });
435
+ throw stampGeminiStreamFailure(err, { relayedText, textLeakGuard, sawFunctionCall, chunks: allChunks });
403
436
  }
404
437
  return aggregated;
405
438
  }
@@ -413,6 +446,12 @@ export async function consumeGeminiSdkStream(streamResult, { signal, onStreamDel
413
446
  let inFlightReject = null;
414
447
  let relayedText = '';
415
448
  let sawFunctionCall = false;
449
+ let leakGuardFinalized = false;
450
+ const finalizeLeakGuard = () => {
451
+ if (leakGuardFinalized) return;
452
+ leakGuardFinalized = true;
453
+ try { textLeakGuard?.finalize(); } catch {}
454
+ };
416
455
 
417
456
  const armFirstByteTimer = () => {
418
457
  if (firstByteTimer) clearTimeout(firstByteTimer);
@@ -536,18 +575,20 @@ export async function consumeGeminiSdkStream(streamResult, { signal, onStreamDel
536
575
  }
537
576
  } catch (err) {
538
577
  clearFirstByteTimer();
578
+ let failure = err;
539
579
  if (signal?.aborted) {
540
580
  const reason = signal.reason;
541
- throw reason instanceof Error ? reason : new Error(`${label} aborted`);
581
+ failure = reason instanceof Error ? reason : new Error(`${label} aborted`);
542
582
  }
543
- throw stampGeminiStreamFailure(err, { relayedText, textLeakGuard, sawFunctionCall });
583
+ finalizeLeakGuard();
584
+ throw stampGeminiStreamFailure(failure, { relayedText, textLeakGuard, sawFunctionCall, chunks: collectedChunks });
544
585
  } finally {
545
586
  clearFirstByteTimer();
546
587
  if (idleTimer) clearTimeout(idleTimer);
547
588
  if (signal && onSignalAbort) {
548
589
  try { signal.removeEventListener('abort', onSignalAbort); } catch {}
549
590
  }
550
- try { textLeakGuard?.finalize(); } catch {}
591
+ finalizeLeakGuard();
551
592
  }
552
593
 
553
594
  // Aggregate the raw wire chunks locally instead of awaiting the SDK's
@@ -566,21 +607,29 @@ export async function consumeGeminiSdkStream(streamResult, { signal, onStreamDel
566
607
  try {
567
608
  response = await streamResult.response;
568
609
  } catch (err) {
610
+ let failure = err;
569
611
  if (signal?.aborted) {
570
612
  const reason = signal.reason;
571
- throw reason instanceof Error ? reason : new Error(`${label} aborted`);
613
+ failure = reason instanceof Error ? reason : new Error(`${label} aborted`);
572
614
  }
573
- throw err;
615
+ finalizeLeakGuard();
616
+ throw stampGeminiStreamFailure(failure, {
617
+ relayedText,
618
+ textLeakGuard,
619
+ sawFunctionCall,
620
+ chunks: collectedChunks,
621
+ });
574
622
  }
575
623
  raw = response?.candidates ? response : (response?.response || response);
576
624
  }
577
625
  const finishReason = raw?.candidates?.[0]?.finishReason || null;
626
+ const promptBlockReason = raw?.promptFeedback?.blockReason || null;
578
627
  // Same stamping as the REST consumer: truncation after visible output
579
628
  // must not classify transient (review High).
580
629
  try {
581
- assertGeminiStreamCompleted({ sawStreamChunk, finishReason, label });
630
+ assertGeminiStreamCompleted({ sawStreamChunk, finishReason, promptBlockReason, label });
582
631
  } catch (err) {
583
- throw stampGeminiStreamFailure(err, { relayedText, textLeakGuard, sawFunctionCall });
632
+ throw stampGeminiStreamFailure(err, { relayedText, textLeakGuard, sawFunctionCall, chunks: collectedChunks });
584
633
  }
585
634
  return raw;
586
635
  }
@@ -29,6 +29,8 @@ import {
29
29
  parseToolCalls,
30
30
  emitGeminiToolCalls,
31
31
  collectGeminiGroundingSources,
32
+ parseGeminiThinkingParts,
33
+ parseGeminiTextPartMetadata,
32
34
  } from './gemini-schema.mjs';
33
35
  import {
34
36
  _estimateGeminiCacheTokens,
@@ -45,12 +47,14 @@ import {
45
47
  writeGeminiCacheTrace,
46
48
  geminiGlobalCacheCreates,
47
49
  GEMINI_GLOBAL_CACHE_DELETE_GRACE_MS,
50
+ _geminiCredentialFingerprint,
51
+ _invalidateGeminiCachesForCredentialFingerprint,
48
52
  } from './gemini-cache.mjs';
49
53
 
50
54
  // Legacy import path: tests + gemini-stream import these from gemini.mjs.
51
55
  // Re-export the extracted symbols so existing importers resolve unchanged.
52
56
  export { createGeminiTextLeakGuard };
53
- export { parseToolCalls, emitGeminiToolCalls, collectGeminiGroundingSources };
57
+ export { parseToolCalls, emitGeminiToolCalls, collectGeminiGroundingSources, parseGeminiTextPartMetadata };
54
58
 
55
59
  const MODELS = [
56
60
  { id: 'gemini-3-flash-preview', name: 'Gemini 3 Flash Preview', provider: 'gemini', contextWindow: 1048576 },
@@ -74,6 +78,25 @@ const GEMINI_MODEL_CACHE_SCHEMA_VERSION = 2;
74
78
  // mirroring anthropic-oauth's _modelRefreshInFlight.
75
79
  let _modelRefreshInFlight = null;
76
80
 
81
+ export async function fetchGeminiModelPages(apiKey, fetchFn = fetch) {
82
+ const items = [];
83
+ let pageToken = '';
84
+ do {
85
+ const params = new URLSearchParams({ key: apiKey, pageSize: '1000' });
86
+ if (pageToken) params.set('pageToken', pageToken);
87
+ const url = `https://generativelanguage.googleapis.com/v1beta/models?${params}`;
88
+ const res = await fetchFn(url, {
89
+ signal: AbortSignal.timeout(60_000),
90
+ dispatcher: getLlmDispatcher(),
91
+ });
92
+ if (!res.ok) throw new Error(`gemini list_models ${res.status}`);
93
+ const data = await res.json();
94
+ if (Array.isArray(data?.models)) items.push(...data.models);
95
+ pageToken = typeof data?.nextPageToken === 'string' ? data.nextPageToken : '';
96
+ } while (pageToken);
97
+ return items;
98
+ }
99
+
77
100
  const _modelCache = makeModelCache({
78
101
  fileName: 'gemini-models.json',
79
102
  ttlMs: MODEL_CACHE_TTL_MS,
@@ -139,14 +162,24 @@ export class GeminiProvider {
139
162
  name = 'gemini';
140
163
  genAI;
141
164
  config;
165
+ _fetch;
166
+ _preconnect;
167
+ _createGenAI;
168
+ _modelCache;
142
169
 
143
- constructor(config) {
170
+ constructor(config = {}) {
144
171
  this.config = config;
172
+ this._fetch = typeof config.fetchFn === 'function' ? config.fetchFn : fetch;
173
+ this._preconnect = typeof config.preconnectFn === 'function' ? config.preconnectFn : preconnect;
174
+ this._createGenAI = typeof config.createGenAI === 'function'
175
+ ? config.createGenAI
176
+ : (apiKey) => new GoogleGenerativeAI(apiKey);
177
+ this._modelCache = config.modelCache || _modelCache;
145
178
  const apiKey = config.apiKey || process.env.GEMINI_API_KEY || '';
146
- this.genAI = new GoogleGenerativeAI(apiKey);
179
+ this.genAI = config.genAI || this._createGenAI(apiKey);
147
180
  // Warm a kept-alive socket to the Gemini REST API so the first cache/
148
181
  // generateContent request skips the cold TLS handshake. Best-effort.
149
- preconnect('https://generativelanguage.googleapis.com');
182
+ this._preconnect('https://generativelanguage.googleapis.com');
150
183
  }
151
184
 
152
185
  reloadApiKey() {
@@ -159,9 +192,11 @@ export class GeminiProvider {
159
192
  // key via _getApiKey() → this.config.apiKey) don't keep using
160
193
  // the stale key after a rotation; genAI alone is not enough.
161
194
  this.config = { ...(this.config || {}), apiKey: newKey };
162
- this.genAI = new GoogleGenerativeAI(newKey);
195
+ this.genAI = this._createGenAI(newKey);
163
196
  }
197
+ return newKey || '';
164
198
  } catch { /* best effort */ }
199
+ return '';
165
200
  }
166
201
 
167
202
  _getApiKey() {
@@ -176,7 +211,7 @@ export class GeminiProvider {
176
211
  // one cache slot warm without re-creation overhead; storage cost (~$0.5/M
177
212
  // tokens/hour) is dwarfed by the 75% input-price discount on hits beyond
178
213
  // a few iterations.
179
- async _ensureGeminiCache({ apiKey, model, systemInstruction, geminiTools, contents, opts }) {
214
+ async _ensureGeminiCache({ apiKey, model, systemInstruction, geminiTools, toolConfig, contents, opts }) {
180
215
  if (Array.isArray(opts?.nativeTools) && opts.nativeTools.length) return null;
181
216
  // Kill-switch: MIXDOG_GEMINI_EXPLICIT_CACHE=0 skips cachedContents
182
217
  // entirely and relies on Gemini's implicit prefix caching (2.5+/3.x
@@ -184,6 +219,7 @@ export class GeminiProvider {
184
219
  const explicitMode = String(process.env.MIXDOG_GEMINI_EXPLICIT_CACHE || '').trim().toLowerCase();
185
220
  if (['0', 'false', 'off', 'no'].includes(explicitMode)) return null;
186
221
  const state = opts.providerState?.gemini || null;
222
+ const credentialFingerprint = _geminiCredentialFingerprint(apiKey);
187
223
  const now = Date.now();
188
224
  const currentIter = Number.isFinite(Number(opts.iteration)) ? Number(opts.iteration) : 1;
189
225
  const refreshEveryN = Number(process.env.MIXDOG_GEMINI_CACHE_REFRESH_EVERY) > 0
@@ -213,17 +249,21 @@ export class GeminiProvider {
213
249
  model,
214
250
  systemInstruction,
215
251
  geminiTools,
252
+ toolConfig,
216
253
  contents,
217
254
  prefixCount: statePrefixContentCount,
218
255
  })
219
256
  : null;
220
257
  const modelMatches = !!state?.cacheName && state?.cacheModel === model;
258
+ const credentialMatches = !!state?.cacheName
259
+ && state?.cacheCredentialFingerprint === credentialFingerprint;
221
260
  const prefixMatches = !!state?.cacheName
222
261
  && statePrefixContentCount != null
223
262
  && statePrefixContentCount <= (Array.isArray(contents) ? contents.length : 0)
224
263
  && !!state?.cachePrefixHash
225
264
  && state.cachePrefixHash === currentStatePrefixHash;
226
- const canAttachState = !!state?.cacheName && cacheLiveMs > 0 && modelMatches && prefixMatches;
265
+ const canAttachState = !!state?.cacheName && cacheLiveMs > 0
266
+ && modelMatches && credentialMatches && prefixMatches;
227
267
  const canReuseState = canAttachState && cacheLiveMs > reuseHeadroomMs && itersSinceCreate < refreshEveryN;
228
268
  try {
229
269
  appendAgentTrace({
@@ -239,6 +279,7 @@ export class GeminiProvider {
239
279
  statePrefixHash: state?.cachePrefixHash || null,
240
280
  currentStatePrefixHash,
241
281
  modelMatches,
282
+ credentialMatches,
242
283
  prefixMatches,
243
284
  canAttachState,
244
285
  cacheLiveMs,
@@ -279,11 +320,12 @@ export class GeminiProvider {
279
320
  model,
280
321
  systemInstruction,
281
322
  geminiTools,
323
+ toolConfig,
282
324
  contents,
283
325
  prefixCount: cachePrefixContentCount,
284
326
  });
285
327
  const globalCacheKey = _geminiGlobalCacheKey({
286
- apiKey,
328
+ credentialFingerprint,
287
329
  model,
288
330
  cachePrefixHash,
289
331
  cachePrefixContentCount,
@@ -340,6 +382,7 @@ export class GeminiProvider {
340
382
  if (Array.isArray(geminiTools) && geminiTools.length) {
341
383
  body.tools = geminiTools;
342
384
  }
385
+ if (toolConfig) body.toolConfig = toolConfig;
343
386
  // Capture conversation prefix (everything except the latest user/
344
387
  // tool input that the generateContent call will carry) inside the
345
388
  // cache. cachedContents only accepts role='user' or 'model';
@@ -353,7 +396,7 @@ export class GeminiProvider {
353
396
  // only the 20s ceiling. Without merging opts.signal a session that is
354
397
  // aborted (stall-watchdog / closeSession) mid-cache-create leaves this
355
398
  // preflight request running until its own timeout fires.
356
- const res = await fetch(url, {
399
+ const res = await this._fetch(url, {
357
400
  method: 'POST',
358
401
  headers: { 'Content-Type': 'application/json' },
359
402
  body: JSON.stringify(body),
@@ -436,7 +479,7 @@ export class GeminiProvider {
436
479
  setTimeout(() => {
437
480
  if (_geminiGlobalCacheNameIsLive(priorCacheName)) return;
438
481
  const delUrl = `https://generativelanguage.googleapis.com/v1beta/${priorCacheName}?key=${encodeURIComponent(apiKey)}`;
439
- fetch(delUrl, { method: 'DELETE', signal: AbortSignal.timeout(10_000), dispatcher: getLlmDispatcher() })
482
+ this._fetch(delUrl, { method: 'DELETE', signal: AbortSignal.timeout(10_000), dispatcher: getLlmDispatcher() })
440
483
  .catch(() => { /* TTL expiry will reclaim it */ });
441
484
  }, GEMINI_GLOBAL_CACHE_DELETE_GRACE_MS).unref?.();
442
485
  }
@@ -449,6 +492,7 @@ export class GeminiProvider {
449
492
  cacheTokenSize,
450
493
  cachePrefixContentCount,
451
494
  cachePrefixHash,
495
+ cacheCredentialFingerprint: credentialFingerprint,
452
496
  };
453
497
  _setGeminiGlobalCache(globalCacheKey, entry);
454
498
  return entry;
@@ -473,16 +517,25 @@ export class GeminiProvider {
473
517
  async send(messages, model, tools, sendOpts) {
474
518
  // Re-warm a kept-alive socket before the turn (TTL-gated no-op while
475
519
  // hot) so a post-idle request skips the cold TLS handshake.
476
- preconnect('https://generativelanguage.googleapis.com');
520
+ this._preconnect('https://generativelanguage.googleapis.com');
477
521
  try {
478
522
  return await this._doSend(messages, model, tools, sendOpts);
479
523
  } catch (err) {
480
- if (err.message && (err.message.includes('401') || err.message.includes('403'))) {
524
+ if (err?.status === 401 || err?.status === 403
525
+ || (err?.message && (err.message.includes('401') || err.message.includes('403')))) {
481
526
  if (err.liveTextEmitted === true || err.emittedToolCall === true || err.unsafeToRetry === true) {
482
527
  throw err;
483
528
  }
484
529
  process.stderr.write(`[provider] Auth error, re-reading provider authentication...\n`);
485
- this.reloadApiKey();
530
+ const oldCredentialFingerprint = _geminiCredentialFingerprint(this._getApiKey());
531
+ const newKey = this.reloadApiKey();
532
+ _invalidateGeminiCachesForCredentialFingerprint(oldCredentialFingerprint);
533
+ const geminiState = sendOpts?.providerState?.gemini;
534
+ if (geminiState?.cacheName) {
535
+ const { gemini: _dropGemini, ...rest } = sendOpts.providerState;
536
+ sendOpts.providerState = rest;
537
+ }
538
+ if (!newKey) throw err;
486
539
  return await this._doSend(messages, model, tools, sendOpts);
487
540
  }
488
541
  throw err;
@@ -527,20 +580,16 @@ export class GeminiProvider {
527
580
  let textLeakGuard = null;
528
581
 
529
582
  // Explicit cachedContents (system + tools + prior-turn transcript).
530
- // Per Google docs, `tools` must be supplied on BOTH the cache create
531
- // call AND every subsequent generate_content call the cache stores
532
- // the schema for prompt-token credit but the runtime model still
533
- // needs the tool schema to actually emit function calls. Sending
534
- // cachedContent without tools yields an empty completion (function
535
- // calling silently disabled). The contents payload captures the
536
- // accumulated prefix; we refresh the cache every N iterations so
537
- // recent turns also enter the cached prefix instead of being billed
538
- // at full input rates.
583
+ // Cache system/tools/toolConfig together. Google rejects repeating
584
+ // those fields on generateContent when cachedContent is attached.
585
+ // The contents payload captures the accumulated prefix; refresh every
586
+ // N iterations so recent turns also enter the cached prefix.
539
587
  const cachedContent = await this._ensureGeminiCache({
540
588
  apiKey: this._getApiKey(),
541
589
  model: useModel,
542
590
  systemInstruction,
543
591
  geminiTools,
592
+ toolConfig,
544
593
  contents,
545
594
  opts,
546
595
  });
@@ -567,7 +616,8 @@ export class GeminiProvider {
567
616
  contents: deltaContents.length ? deltaContents : contents.slice(-1),
568
617
  cachedContent,
569
618
  };
570
- if (toolConfig) body.toolConfig = toolConfig;
619
+ // cachedContent owns tools + toolConfig. The API rejects a
620
+ // generateContent request that repeats either field.
571
621
  // Option A (mirror anthropic-oauth): no absolute wall-clock cap on a
572
622
  // live streaming turn. A stream that keeps emitting SSE deltas must
573
623
  // not be killed by a fixed total-lifetime timer — that false-aborts
@@ -589,7 +639,7 @@ export class GeminiProvider {
589
639
  );
590
640
  let res;
591
641
  try {
592
- res = await fetch(genUrl, {
642
+ res = await this._fetch(genUrl, {
593
643
  method: 'POST',
594
644
  headers: { 'Content-Type': 'application/json' },
595
645
  body: JSON.stringify(body),
@@ -726,8 +776,10 @@ export class GeminiProvider {
726
776
  cachedContent,
727
777
  });
728
778
  const candidate = response.candidates?.[0] || null;
729
- const textParts = candidate?.content?.parts?.filter(p => 'text' in p) ?? [];
779
+ const responseParts = candidate?.content?.parts ?? [];
780
+ const textParts = responseParts.filter(p => p?.thought !== true && 'text' in p);
730
781
  const rawContent = textParts.map(p => 'text' in p ? p.text : '').join('');
782
+ const providerMetadata = parseGeminiTextPartMetadata(responseParts);
731
783
  const content = textLeakGuard?.enabled
732
784
  ? textLeakGuard.scrubAssistantText(rawContent)
733
785
  : rawContent;
@@ -752,18 +804,13 @@ export class GeminiProvider {
752
804
  // nudge, or surface to the user. Missing finishReason (still
753
805
  // streaming / unknown) is left alone — existing success paths for
754
806
  // genuinely complete responses keep working.
755
- const finishReason = candidate?.finishReason || null;
756
- const incompleteFinishReasons = new Set([
757
- 'MAX_TOKENS',
758
- 'SAFETY',
759
- 'RECITATION',
760
- 'OTHER',
761
- 'BLOCKLIST',
762
- 'PROHIBITED_CONTENT',
763
- 'SPII',
764
- 'MALFORMED_FUNCTION_CALL',
765
- ]);
766
- if (finishReason && incompleteFinishReasons.has(finishReason)) {
807
+ const promptBlockReason = response.promptFeedback?.blockReason || null;
808
+ const finishReason = candidate?.finishReason || (promptBlockReason ? `PROMPT_${promptBlockReason}` : null);
809
+ const normalizedFinishReason = String(finishReason || '').replace(/^FINISH_REASON_/, '');
810
+ // STOP is the only successful terminal reason. Treat newly-added
811
+ // safety/image/tool/malformed reasons as incomplete by default instead
812
+ // of silently accepting partial or empty output.
813
+ if (finishReason && normalizedFinishReason !== 'STOP') {
767
814
  const err = Object.assign(
768
815
  new Error(`Gemini response incomplete: finishReason=${finishReason}`),
769
816
  {
@@ -773,6 +820,7 @@ export class GeminiProvider {
773
820
  finishReason,
774
821
  partialContent: content,
775
822
  partialToolCalls: toolCalls,
823
+ providerMetadata,
776
824
  model: useModel,
777
825
  rawUsage: response.usageMetadata || null,
778
826
  },
@@ -844,6 +892,7 @@ export class GeminiProvider {
844
892
  model: useModel,
845
893
  toolCalls,
846
894
  citations: citations.length ? citations : undefined,
895
+ providerMetadata,
847
896
  providerState: opts.providerState,
848
897
  // Use the same normalized usage object traceAgentUsage recorded,
849
898
  // including snake_case SDK aliases and cache-create fallback.
@@ -852,7 +901,7 @@ export class GeminiProvider {
852
901
  }
853
902
 
854
903
  async listModels() {
855
- const cached = _modelCache.loadSync();
904
+ const cached = this._modelCache.loadSync();
856
905
  if (cached) return cached;
857
906
  // Dynamic lookup via Gemini v1beta /models. Requires API key.
858
907
  const apiKey = this.config.apiKey || process.env.GEMINI_API_KEY;
@@ -869,16 +918,13 @@ export class GeminiProvider {
869
918
  // TTL check) and _refreshModelCache() (bypassing it). Throws on failure so
870
919
  // each caller applies its own fallback/logging.
871
920
  async _fetchAndCacheModels(apiKey) {
872
- const url = `https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(apiKey)}`;
873
- const listSignal = AbortSignal.timeout(60_000);
874
- const res = await fetch(url, { signal: listSignal, dispatcher: getLlmDispatcher() });
875
- if (!res.ok) throw new Error(`gemini list_models ${res.status}`);
876
- const data = await res.json();
877
- const items = Array.isArray(data?.models) ? data.models : [];
921
+ const items = await fetchGeminiModelPages(apiKey, this._fetch);
878
922
  // Filter to Gemini family; skip embedding/imagen and non-coding SKUs
879
923
  // (robotics, computer-use) that the chat picker should never show.
880
924
  const normalized = items
881
925
  .filter(m => (m?.name || '').includes('gemini'))
926
+ .filter(m => !Array.isArray(m?.supportedGenerationMethods)
927
+ || m.supportedGenerationMethods.includes('generateContent'))
882
928
  .filter(m => !/embedding|aqa|imagen|robotics|computer-use/.test(m?.name || ''))
883
929
  .map(m => {
884
930
  const id = (m.name || '').replace(/^models\//, '');
@@ -905,8 +951,11 @@ export class GeminiProvider {
905
951
  // LiteLLM catalog overlays pricing and updated metadata.
906
952
  const { enrichModels } = await import('./model-catalog.mjs');
907
953
  const { sanitizeModelList } = await import('./model-list-sanitize.mjs');
908
- const enriched = sanitizeModelList(await enrichModels(normalized), { provider: 'gemini' });
909
- _modelCache.save(enriched);
954
+ const enriched = sanitizeModelList(await enrichModels(normalized, {
955
+ fetchFn: this._fetch,
956
+ force: this.config.catalogForceRefresh === true,
957
+ }), { provider: 'gemini' });
958
+ this._modelCache.save(enriched);
910
959
  return enriched;
911
960
  }
912
961
 
@@ -713,9 +713,6 @@ export class GrokOAuthProvider {
713
713
  constructor(config) {
714
714
  this.config = config || {};
715
715
  this.tokens = loadTokens();
716
- // Warm a kept-alive socket to the xAI inference API so the first
717
- // request skips the cold TLS handshake. Best-effort; never throws.
718
- preconnect(INFERENCE_BASE_URL);
719
716
  }
720
717
 
721
718
  async ensureAuth({ forceRefresh = false } = {}) {
@@ -805,7 +802,12 @@ export class GrokOAuthProvider {
805
802
  async send(messages, model, tools, sendOpts) {
806
803
  // Re-warm a kept-alive socket before the turn (TTL-gated no-op while
807
804
  // hot) so a post-idle request skips the cold TLS handshake.
808
- preconnect(INFERENCE_BASE_URL);
805
+ if (this.config?.preconnect !== false) {
806
+ const warm = typeof this.config?.preconnectFn === 'function'
807
+ ? this.config.preconnectFn
808
+ : preconnect;
809
+ warm(INFERENCE_BASE_URL);
810
+ }
809
811
  const useModel = normalizeGrokModelId(
810
812
  model || await ensureLatestGrokModel(this),
811
813
  );
@@ -829,6 +831,12 @@ export class GrokOAuthProvider {
829
831
  // fix it, so it must surface unretried.
830
832
  populateHttpStatusFromMessage(err);
831
833
  if (Number(err?.httpStatus || err?.status) === 401) {
834
+ // A stream-level 401 after text/tool dispatch cannot be safely
835
+ // replayed: the client has already observed output or may have
836
+ // executed a side-effecting tool.
837
+ if (err.liveTextEmitted === true || err.emittedToolCall === true || err.unsafeToRetry === true) {
838
+ throw err;
839
+ }
832
840
  process.stderr.write('[grok-oauth] 401, force-refreshing token...\n');
833
841
  const fresh = await this.ensureAuth({ forceRefresh: true });
834
842
  const retryInner = this._ensureInner(fresh.access_token, useModel);