mixdog 0.9.53 → 0.9.55

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 (77) hide show
  1. package/package.json +2 -1
  2. package/scripts/agent-model-liveness-test.mjs +68 -0
  3. package/scripts/anthropic-transport-policy-test.mjs +260 -0
  4. package/scripts/desktop-session-bridge-test.mjs +47 -0
  5. package/scripts/gemini-provider-test.mjs +396 -1
  6. package/scripts/grok-oauth-refresh-race-test.mjs +76 -1
  7. package/scripts/interrupted-turn-history-test.mjs +28 -0
  8. package/scripts/openai-oauth-ws-1006-retry-test.mjs +81 -1
  9. package/scripts/pending-completion-drop-test.mjs +160 -4
  10. package/scripts/pending-messages-lock-nonblocking-test.mjs +62 -0
  11. package/scripts/process-lifecycle-test.mjs +18 -4
  12. package/scripts/prompt-input-parity-test.mjs +145 -0
  13. package/scripts/provider-admission-scheduler-test.mjs +4 -5
  14. package/scripts/provider-contract-test.mjs +91 -33
  15. package/scripts/provider-toolcall-test.mjs +97 -17
  16. package/scripts/streaming-tail-window-test.mjs +146 -0
  17. package/scripts/tui-runtime-load-bench-entry.jsx +608 -0
  18. package/scripts/tui-runtime-load-bench.mjs +45 -0
  19. package/scripts/tui-store-frame-batch-test.mjs +99 -0
  20. package/scripts/tui-transcript-perf-test.mjs +367 -2
  21. package/scripts/write-backpressure-test.mjs +147 -0
  22. package/src/cli.mjs +5 -5
  23. package/src/lib/keychain-cjs.cjs +114 -25
  24. package/src/repl.mjs +11 -0
  25. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +62 -25
  26. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +8 -2
  27. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +50 -23
  28. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +15 -4
  29. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +136 -27
  30. package/src/runtime/agent/orchestrator/providers/gemini.mjs +104 -20
  31. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +96 -75
  32. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +40 -1
  33. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +5 -0
  34. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +35 -4
  35. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +3 -1
  36. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +49 -9
  37. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +14 -2
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +9 -4
  39. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +53 -13
  40. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +145 -23
  41. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +20 -4
  42. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +316 -63
  43. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +23 -1
  44. package/src/runtime/agent/orchestrator/session/store.mjs +46 -1
  45. package/src/runtime/agent/orchestrator/stall-policy.mjs +6 -13
  46. package/src/runtime/memory/lib/trace-store.mjs +66 -4
  47. package/src/runtime/shared/buffered-appender.mjs +83 -4
  48. package/src/runtime/shared/memory-snapshot.mjs +45 -36
  49. package/src/runtime/shared/process-lifecycle.mjs +166 -45
  50. package/src/runtime/shared/process-shutdown.mjs +2 -1
  51. package/src/session-runtime/model-route-api.mjs +4 -1
  52. package/src/session-runtime/native-search.mjs +4 -2
  53. package/src/session-runtime/provider-auth-api.mjs +8 -1
  54. package/src/session-runtime/provider-models.mjs +8 -3
  55. package/src/session-runtime/resource-api.mjs +2 -1
  56. package/src/session-runtime/runtime-core.mjs +32 -0
  57. package/src/session-runtime/session-turn-api.mjs +1 -0
  58. package/src/session-runtime/warmup-schedulers.mjs +5 -1
  59. package/src/standalone/agent-tool.mjs +19 -1
  60. package/src/tui/App.jsx +10 -4
  61. package/src/tui/app/text-layout.mjs +9 -1
  62. package/src/tui/app/transcript-window.mjs +146 -60
  63. package/src/tui/app/use-mouse-input.mjs +16 -8
  64. package/src/tui/app/use-transcript-scroll.mjs +71 -19
  65. package/src/tui/app/use-transcript-window.mjs +21 -10
  66. package/src/tui/components/PromptInput.jsx +13 -6
  67. package/src/tui/dist/index.mjs +1094 -232
  68. package/src/tui/engine/context-state.mjs +31 -31
  69. package/src/tui/engine/frame-batched-store.mjs +75 -0
  70. package/src/tui/engine/session-api-ext.mjs +6 -1
  71. package/src/tui/engine/session-api.mjs +9 -3
  72. package/src/tui/engine/session-flow.mjs +54 -27
  73. package/src/tui/engine/turn.mjs +44 -3
  74. package/src/tui/engine.mjs +515 -36
  75. package/src/tui/input-editing.mjs +33 -13
  76. package/src/tui/markdown/measure-rendered-rows.mjs +117 -5
  77. package/vendor/ink/build/ink.js +8 -16
@@ -44,6 +44,41 @@ export function geminiTruncatedStreamError(message) {
44
44
  );
45
45
  }
46
46
 
47
+ export function geminiStreamCorruptionError(message, cause = null) {
48
+ return Object.assign(
49
+ new Error(message, cause ? { cause } : undefined),
50
+ {
51
+ name: 'GeminiStreamCorruptionError',
52
+ code: 'TRUNCATED_STREAM',
53
+ truncatedStream: true,
54
+ streamCorruption: true,
55
+ },
56
+ );
57
+ }
58
+
59
+ function isGeminiSdkStreamParseError(err) {
60
+ let cursor = err;
61
+ const seen = new Set();
62
+ for (let depth = 0; cursor && depth < 5 && !seen.has(cursor); depth++) {
63
+ seen.add(cursor);
64
+ if (cursor instanceof SyntaxError) return true;
65
+ const name = String(cursor?.name || '');
66
+ const message = String(cursor?.message || '');
67
+ if (name === 'GoogleGenerativeAIError'
68
+ && /(?:parse|parsing|json|unexpected token|unexpected end|unterminated)/i.test(message)) {
69
+ return true;
70
+ }
71
+ cursor = cursor?.cause;
72
+ }
73
+ return false;
74
+ }
75
+
76
+ function normalizeGeminiSdkStreamError(err, label) {
77
+ return isGeminiSdkStreamParseError(err)
78
+ ? geminiStreamCorruptionError(`${label} corrupt SDK SSE JSON`, err)
79
+ : err;
80
+ }
81
+
47
82
  // CC-rule safety stamp for Gemini stream failures (provider-stall audit):
48
83
  // once text has been relayed to the live gateway or a leaked tool call was
49
84
  // dispatched, replaying the request would double-render/double-execute — the
@@ -63,10 +98,10 @@ export function stampGeminiStreamFailure(err, {
63
98
  const finalizedText = textLeakGuard?.getVisibleText?.();
64
99
  const visibleText = typeof finalizedText === 'string' ? finalizedText : relayedText;
65
100
  const visible = visibleText.length > 0;
66
- // A native functionCall chunk (not a text-leaked one) is also an in-flight
67
- // tool use replaying would double-dispatch, and partial-final must NOT
68
- // treat it as a clean no-tool summary.
69
- const pendingTool = leaked || sawFunctionCall === true;
101
+ // Native functionCall chunks are only dispatched after successful stream
102
+ // completion in gemini.mjs. Until then a reconnect is safe. Text-leaked
103
+ // calls are dispatched while parsing and remain a hard replay boundary.
104
+ const pendingTool = leaked;
70
105
  try {
71
106
  if (visible) { err.liveTextEmitted = true; err.unsafeToRetry = true; }
72
107
  if (pendingTool) { err.emittedToolCall = true; err.unsafeToRetry = true; }
@@ -277,6 +312,10 @@ export function createGeminiTextLeakGuard({ knownToolNames, onTextDelta, onToolC
277
312
 
278
313
  export async function consumeGeminiRestStreamResponse(response, { signal, onStreamDelta, onTextDelta, textLeakGuard, label }) {
279
314
  if (!response?.body) throw new Error(`${label}: missing response body`);
315
+ if (signal?.aborted) {
316
+ const reason = signal.reason;
317
+ throw reason instanceof Error ? reason : new Error(`${label} aborted`);
318
+ }
280
319
  const reader = response.body.getReader();
281
320
  const decoder = new TextDecoder();
282
321
  let buffer = '';
@@ -332,10 +371,6 @@ export async function consumeGeminiRestStreamResponse(response, { signal, onStre
332
371
  };
333
372
 
334
373
  if (signal) {
335
- if (signal.aborted) {
336
- const reason = signal.reason;
337
- throw reason instanceof Error ? reason : new Error(`${label} aborted`);
338
- }
339
374
  signal.addEventListener('abort', onAbort, { once: true });
340
375
  }
341
376
 
@@ -373,7 +408,11 @@ export async function consumeGeminiRestStreamResponse(response, { signal, onStre
373
408
  const data = line.slice(6).trim();
374
409
  if (!data || data === '[DONE]') continue;
375
410
  let parsed;
376
- try { parsed = JSON.parse(data); } catch { continue; }
411
+ try {
412
+ parsed = JSON.parse(data);
413
+ } catch (cause) {
414
+ throw geminiStreamCorruptionError(`${label} corrupt SSE JSON`, cause);
415
+ }
377
416
  if (!sawStreamChunk) {
378
417
  sawStreamChunk = true;
379
418
  clearFirstByteTimer();
@@ -407,7 +446,9 @@ export async function consumeGeminiRestStreamResponse(response, { signal, onStre
407
446
  if (t) relayedText += t;
408
447
  relayGeminiStreamText(t, { onTextDelta, textLeakGuard });
409
448
  }
410
- } catch { /* skip malformed tail */ }
449
+ } catch (cause) {
450
+ throw geminiStreamCorruptionError(`${label} corrupt SSE tail JSON`, cause);
451
+ }
411
452
  }
412
453
  }
413
454
  }
@@ -437,7 +478,16 @@ export async function consumeGeminiRestStreamResponse(response, { signal, onStre
437
478
  return aggregated;
438
479
  }
439
480
 
440
- export async function consumeGeminiSdkStream(streamResult, { signal, onStreamDelta, onTextDelta, textLeakGuard, label }) {
481
+ export async function consumeGeminiSdkStream(streamResult, {
482
+ signal,
483
+ onStreamDelta,
484
+ onTextDelta,
485
+ textLeakGuard,
486
+ label,
487
+ cancelGeneration,
488
+ firstByteTimeoutMs = GEMINI_FIRST_BYTE_TIMEOUT_MS,
489
+ cancellationGraceMs = 250,
490
+ }) {
441
491
  let sawStreamChunk = false;
442
492
  let idleTimedOut = false;
443
493
  let idleTimer = null;
@@ -453,15 +503,53 @@ export async function consumeGeminiSdkStream(streamResult, { signal, onStreamDel
453
503
  try { textLeakGuard?.finalize(); } catch {}
454
504
  };
455
505
 
506
+ let iterator = null;
507
+ let cancellation = null;
508
+ let forcedFailure = null;
509
+ const cancelInFlight = (err) => {
510
+ if (cancellation) return cancellation;
511
+ forcedFailure = err;
512
+ cancellation = (async () => {
513
+ try { cancelGeneration?.(err); } catch {}
514
+ let returnPromise;
515
+ try {
516
+ returnPromise = Promise.resolve(iterator?.return?.());
517
+ } catch {
518
+ return;
519
+ }
520
+ // iterator.return() is best-effort cleanup. A broken SDK iterator
521
+ // must not deadlock the timeout path and prevent withRetry from
522
+ // beginning the next attempt after the generation was aborted.
523
+ let graceTimer = null;
524
+ try {
525
+ await Promise.race([
526
+ returnPromise.catch(() => {}),
527
+ new Promise((resolve) => {
528
+ graceTimer = setTimeout(resolve, Math.max(0, cancellationGraceMs));
529
+ }),
530
+ ]);
531
+ } finally {
532
+ if (graceTimer) clearTimeout(graceTimer);
533
+ // Keep observing a late rejection after the grace race expires.
534
+ returnPromise.catch(() => {});
535
+ }
536
+ })();
537
+ return cancellation;
538
+ };
539
+
540
+ const rejectAfterCancellation = (reject, err) => {
541
+ cancelInFlight(err).then(() => reject(err), () => reject(err));
542
+ };
543
+
456
544
  const armFirstByteTimer = () => {
457
545
  if (firstByteTimer) clearTimeout(firstByteTimer);
458
546
  firstByteTimer = setTimeout(() => {
459
547
  if (firstByteReject) {
460
- const e = geminiTimeoutError(`${label} first byte`, GEMINI_FIRST_BYTE_TIMEOUT_MS);
461
- const r = firstByteReject; firstByteReject = null; r(e);
548
+ const e = geminiTimeoutError(`${label} first byte`, firstByteTimeoutMs);
549
+ const r = firstByteReject; firstByteReject = null;
550
+ rejectAfterCancellation(r, e);
462
551
  }
463
- }, GEMINI_FIRST_BYTE_TIMEOUT_MS);
464
- if (firstByteTimer.unref) firstByteTimer.unref();
552
+ }, firstByteTimeoutMs);
465
553
  };
466
554
 
467
555
  const clearFirstByteTimer = () => {
@@ -479,10 +567,10 @@ export async function consumeGeminiSdkStream(streamResult, { signal, onStreamDel
479
567
  idleTimedOut = true;
480
568
  if (inFlightReject) {
481
569
  const e = geminiTimeoutError(`${label} SSE idle`, PROVIDER_SSE_IDLE_TIMEOUT_MS);
482
- const r = inFlightReject; inFlightReject = null; r(e);
570
+ const r = inFlightReject; inFlightReject = null;
571
+ rejectAfterCancellation(r, e);
483
572
  }
484
573
  }, PROVIDER_SSE_IDLE_TIMEOUT_MS);
485
- if (idleTimer.unref) idleTimer.unref();
486
574
  };
487
575
 
488
576
  if (signal?.aborted) {
@@ -490,7 +578,17 @@ export async function consumeGeminiSdkStream(streamResult, { signal, onStreamDel
490
578
  throw reason instanceof Error ? reason : new Error(`${label} aborted`);
491
579
  }
492
580
 
493
- const iterator = streamResult.stream[Symbol.asyncIterator]();
581
+ iterator = streamResult.stream[Symbol.asyncIterator]();
582
+ // The SDK tees the parsed stream into `response`. Even though local
583
+ // aggregation normally avoids awaiting it, its rejection must remain
584
+ // observed across parser/abort retries.
585
+ let responsePromise;
586
+ try {
587
+ responsePromise = Promise.resolve(streamResult.response);
588
+ } catch (err) {
589
+ responsePromise = Promise.reject(err);
590
+ }
591
+ responsePromise.catch(() => {});
494
592
 
495
593
  // Wire the abort signal to actually CANCEL iteration (mirror of the REST
496
594
  // consumer's onAbort -> reader.cancel). Without this, a parent / client /
@@ -511,10 +609,7 @@ export async function consumeGeminiSdkStream(streamResult, { signal, onStreamDel
511
609
  firstByteReject = null;
512
610
  r(err);
513
611
  }
514
- try {
515
- const ret = iterator.return?.();
516
- if (ret && typeof ret.catch === 'function') ret.catch(() => {});
517
- } catch {}
612
+ cancelInFlight(err).catch(() => {});
518
613
  };
519
614
  signal.addEventListener('abort', onSignalAbort, { once: true });
520
615
  }
@@ -536,12 +631,26 @@ export async function consumeGeminiSdkStream(streamResult, { signal, onStreamDel
536
631
  (value) => {
537
632
  inFlightReject = null;
538
633
  firstByteReject = null;
539
- resolve(value);
634
+ if (forcedFailure) {
635
+ cancellation.then(
636
+ () => reject(forcedFailure),
637
+ () => reject(forcedFailure),
638
+ );
639
+ } else {
640
+ resolve(value);
641
+ }
540
642
  },
541
643
  (err) => {
542
644
  inFlightReject = null;
543
645
  firstByteReject = null;
544
- reject(err);
646
+ if (forcedFailure) {
647
+ cancellation.then(
648
+ () => reject(forcedFailure),
649
+ () => reject(forcedFailure),
650
+ );
651
+ } else {
652
+ reject(err);
653
+ }
545
654
  },
546
655
  );
547
656
  });
@@ -553,7 +662,7 @@ export async function consumeGeminiSdkStream(streamResult, { signal, onStreamDel
553
662
  const reason = signal.reason;
554
663
  throw reason instanceof Error ? reason : new Error(`${label} aborted`);
555
664
  }
556
- throw err;
665
+ throw normalizeGeminiSdkStreamError(err, label);
557
666
  }
558
667
  if (step.done) break;
559
668
  if (!sawStreamChunk) {
@@ -605,9 +714,9 @@ export async function consumeGeminiSdkStream(streamResult, { signal, onStreamDel
605
714
  } else {
606
715
  let response;
607
716
  try {
608
- response = await streamResult.response;
717
+ response = await responsePromise;
609
718
  } catch (err) {
610
- let failure = err;
719
+ let failure = normalizeGeminiSdkStreamError(err, label);
611
720
  if (signal?.aborted) {
612
721
  const reason = signal.reason;
613
722
  failure = reason instanceof Error ? reason : new Error(`${label} aborted`);
@@ -7,6 +7,8 @@ import {
7
7
  PROVIDER_MAX_BEFORE_WARN_MS,
8
8
  PROVIDER_SSE_IDLE_TIMEOUT_MS,
9
9
  PROVIDER_SSE_IDLE_WATCHDOG_ENABLED,
10
+ PROVIDER_CACHE_CREATE_TIMEOUT_MS,
11
+ PROVIDER_CACHE_CREATE_TOTAL_TIMEOUT_MS,
10
12
  providerTimeoutError,
11
13
  resolveTimeoutMs,
12
14
  createTimeoutSignal,
@@ -48,6 +50,7 @@ import {
48
50
  GEMINI_GLOBAL_CACHE_DELETE_GRACE_MS,
49
51
  _geminiCredentialFingerprint,
50
52
  _invalidateGeminiCachesForCredentialFingerprint,
53
+ _invalidateGeminiCacheName,
51
54
  } from './gemini-cache.mjs';
52
55
  import {
53
56
  GEMINI_MODELS as MODELS,
@@ -68,6 +71,41 @@ export { fetchGeminiModelPages, resolveLatestGeminiModel, ensureLatestGeminiMode
68
71
  // De-dupes concurrent force-refreshes so they share one HTTP round-trip,
69
72
  // mirroring anthropic-oauth's _modelRefreshInFlight.
70
73
  let _modelRefreshInFlight = null;
74
+ const GEMINI_AVAILABILITY_TIMEOUT_MS = 1_000;
75
+
76
+ function geminiRestError(res, text, label) {
77
+ let payload = null;
78
+ try { payload = text ? JSON.parse(text) : null; } catch {}
79
+ const detail = payload?.error || payload || null;
80
+ const err = new Error(`${label} ${res.status}: ${text.slice(0, 300)}`);
81
+ err.status = res.status;
82
+ err.httpStatus = res.status;
83
+ err.headers = res.headers;
84
+ if (detail) {
85
+ err.error = detail;
86
+ err.data = payload;
87
+ if (detail.status) err.geminiStatus = detail.status;
88
+ if (Array.isArray(detail.details)) err.details = detail.details;
89
+ const retryAfter = res.headers?.get?.('retry-after')
90
+ ?? res.headers?.get?.('retry-after-ms');
91
+ // RESOURCE_EXHAUSTED without a server retry window is deterministic
92
+ // quota exhaustion. With Retry-After present, leave it request-local
93
+ // so withRetry can honor the mandated delay.
94
+ if (detail.status && (retryAfter == null || retryAfter === '')) {
95
+ err.code = detail.status;
96
+ }
97
+ }
98
+ return err;
99
+ }
100
+
101
+ function isGeminiCachedContentError(err, cacheName) {
102
+ const status = Number(err?.status || err?.httpStatus || 0);
103
+ if (status !== 400 && status !== 404) return false;
104
+ const text = `${err?.message || ''} ${JSON.stringify(err?.data || '')}`.toLowerCase();
105
+ return text.includes('cachedcontent')
106
+ || text.includes('cached content')
107
+ || (cacheName && text.includes(String(cacheName).toLowerCase()));
108
+ }
71
109
 
72
110
  // --- Cache accounting/trace: extracted to gemini-cache.mjs ---
73
111
  // --- Stream consumption/guards: extracted to gemini-stream.mjs ---
@@ -129,7 +167,8 @@ export class GeminiProvider {
129
167
  // one cache slot warm without re-creation overhead; storage cost (~$0.5/M
130
168
  // tokens/hour) is dwarfed by the 75% input-price discount on hits beyond
131
169
  // a few iterations.
132
- async _ensureGeminiCache({ apiKey, model, systemInstruction, geminiTools, toolConfig, contents, opts }) {
170
+ async _ensureGeminiCache({ apiKey, model, systemInstruction, geminiTools, toolConfig, contents, opts, skipExplicitCache = false }) {
171
+ if (skipExplicitCache) return null;
133
172
  if (Array.isArray(opts?.nativeTools) && opts.nativeTools.length) return null;
134
173
  // Kill-switch: MIXDOG_GEMINI_EXPLICIT_CACHE=0 skips cachedContents
135
174
  // entirely and relies on Gemini's implicit prefix caching (2.5+/3.x
@@ -314,17 +353,23 @@ export class GeminiProvider {
314
353
  // only the 20s ceiling. Without merging opts.signal a session that is
315
354
  // aborted (stall-watchdog / closeSession) mid-cache-create leaves this
316
355
  // preflight request running until its own timeout fires.
317
- const res = await this._fetch(url, {
318
- method: 'POST',
319
- headers: { 'Content-Type': 'application/json' },
320
- body: JSON.stringify(body),
321
- signal: opts.signal
322
- ? AbortSignal.any([opts.signal, AbortSignal.timeout(20_000)])
323
- : AbortSignal.timeout(20_000),
324
- dispatcher: getLlmDispatcher(),
325
- });
326
- if (!res.ok) {
327
- const text = await res.text().catch(() => '');
356
+ const createTotal = createTimeoutSignal(
357
+ opts.signal,
358
+ PROVIDER_CACHE_CREATE_TOTAL_TIMEOUT_MS,
359
+ 'Gemini cachedContents.create total',
360
+ );
361
+ let data;
362
+ try {
363
+ data = await withRetry(async ({ signal: attemptSignal }) => {
364
+ const res = await this._fetch(url, {
365
+ method: 'POST',
366
+ headers: { 'Content-Type': 'application/json' },
367
+ body: JSON.stringify(body),
368
+ signal: attemptSignal,
369
+ dispatcher: getLlmDispatcher(),
370
+ });
371
+ if (res.ok) return await res.json();
372
+ const text = await res.text().catch(() => '');
328
373
  try {
329
374
  appendAgentTrace({
330
375
  sessionId: opts.sessionId || opts.session?.id || null,
@@ -339,9 +384,15 @@ export class GeminiProvider {
339
384
  },
340
385
  });
341
386
  } catch {}
342
- return null;
387
+ throw geminiRestError(res, text, 'Gemini cachedContents.create');
388
+ }, {
389
+ signal: createTotal.signal,
390
+ perAttemptTimeoutMs: PROVIDER_CACHE_CREATE_TIMEOUT_MS,
391
+ perAttemptLabel: 'Gemini cachedContents.create',
392
+ });
393
+ } finally {
394
+ createTotal.cleanup();
343
395
  }
344
- const data = await res.json();
345
396
  const cacheName = data?.name || null;
346
397
  if (!cacheName) return null;
347
398
  const cacheTokenSize = Number(data?.usageMetadata?.totalTokenCount || 0) || 0;
@@ -422,7 +473,9 @@ export class GeminiProvider {
422
473
  geminiGlobalCacheCreates.set(globalCacheKey, createTask);
423
474
  try {
424
475
  const created = await createTask;
425
- if (!created?.cacheName) return canAttachState ? state.cacheName : null;
476
+ // A failed refresh must not silently retain a cache that may have
477
+ // expired or been evicted server-side. The caller proceeds uncached.
478
+ if (!created?.cacheName) return null;
426
479
  _attachGeminiCacheState(opts, created, currentIter);
427
480
  return created.cacheName;
428
481
  } finally {
@@ -460,7 +513,7 @@ export class GeminiProvider {
460
513
  }
461
514
  }
462
515
 
463
- async _doSend(messages, model, tools, sendOpts) {
516
+ async _doSend(messages, model, tools, sendOpts, internal = {}) {
464
517
  const opts = sendOpts || {};
465
518
  const signal = opts.signal || null;
466
519
  const onStreamDelta = typeof opts.onStreamDelta === 'function' ? opts.onStreamDelta : null;
@@ -510,6 +563,7 @@ export class GeminiProvider {
510
563
  toolConfig,
511
564
  contents,
512
565
  opts,
566
+ skipExplicitCache: internal.skipExplicitCache === true,
513
567
  });
514
568
  try { opts.onStageChange?.('requesting'); } catch {}
515
569
 
@@ -569,9 +623,7 @@ export class GeminiProvider {
569
623
  }
570
624
  if (!res.ok) {
571
625
  const text = await res.text().catch(() => '');
572
- const err = new Error(`Gemini REST streamGenerateContent ${res.status}: ${text.slice(0, 300)}`);
573
- err.status = res.status;
574
- throw err;
626
+ throw geminiRestError(res, text, 'Gemini REST streamGenerateContent');
575
627
  }
576
628
  textLeakGuard = buildTextLeakGuard();
577
629
  return await consumeGeminiRestStreamResponse(res, {
@@ -590,6 +642,18 @@ export class GeminiProvider {
590
642
  },
591
643
  },
592
644
  );
645
+ } catch (err) {
646
+ if (!internal.skipExplicitCache
647
+ && err?.unsafeToRetry !== true
648
+ && isGeminiCachedContentError(err, cachedContent)) {
649
+ _invalidateGeminiCacheName(cachedContent);
650
+ if (opts.providerState?.gemini?.cacheName === cachedContent) {
651
+ const { gemini: _dropGemini, ...rest } = opts.providerState;
652
+ opts.providerState = rest;
653
+ }
654
+ return await this._doSend(messages, model, tools, opts, { skipExplicitCache: true });
655
+ }
656
+ throw err;
593
657
  } finally {
594
658
  restPassthrough.cleanup();
595
659
  }
@@ -666,6 +730,9 @@ export class GeminiProvider {
666
730
  onTextDelta,
667
731
  textLeakGuard,
668
732
  label: 'Gemini SDK streamGenerateContent',
733
+ cancelGeneration: (reason) => {
734
+ if (!reqController.signal.aborted) reqController.abort(reason);
735
+ },
669
736
  });
670
737
  } finally {
671
738
  clearConnectTimer();
@@ -867,13 +934,30 @@ export class GeminiProvider {
867
934
  }
868
935
 
869
936
  async isAvailable() {
937
+ const controller = new AbortController();
938
+ const timeout = setTimeout(() => {
939
+ controller.abort(providerTimeoutError('Gemini availability probe', GEMINI_AVAILABILITY_TIMEOUT_MS));
940
+ }, GEMINI_AVAILABILITY_TIMEOUT_MS);
870
941
  try {
871
942
  const model = this.genAI.getGenerativeModel({ model: DEFAULT_MODEL });
872
- await model.generateContent('hi');
943
+ const generation = Promise.resolve(model.generateContent('hi', { signal: controller.signal }));
944
+ generation.catch(() => {});
945
+ await Promise.race([
946
+ generation,
947
+ new Promise((_, reject) => {
948
+ if (controller.signal.aborted) {
949
+ reject(controller.signal.reason);
950
+ return;
951
+ }
952
+ controller.signal.addEventListener('abort', () => reject(controller.signal.reason), { once: true });
953
+ }),
954
+ ]);
873
955
  return true;
874
956
  }
875
957
  catch {
876
958
  return false;
959
+ } finally {
960
+ clearTimeout(timeout);
877
961
  }
878
962
  }
879
963
  }