mixdog 0.9.53 → 0.9.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/agent-model-liveness-test.mjs +68 -0
- package/scripts/anthropic-transport-policy-test.mjs +260 -0
- package/scripts/gemini-provider-test.mjs +396 -1
- package/scripts/grok-oauth-refresh-race-test.mjs +76 -1
- package/scripts/openai-oauth-ws-1006-retry-test.mjs +81 -1
- package/scripts/process-lifecycle-test.mjs +13 -2
- package/scripts/provider-admission-scheduler-test.mjs +4 -5
- package/scripts/provider-contract-test.mjs +91 -33
- package/scripts/provider-toolcall-test.mjs +97 -17
- package/src/repl.mjs +11 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +62 -25
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +8 -2
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +50 -23
- package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +15 -4
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +136 -27
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +104 -20
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +96 -75
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +40 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +5 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +35 -4
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +3 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +49 -9
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +14 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +9 -4
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +53 -13
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +53 -7
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +23 -1
- package/src/runtime/agent/orchestrator/stall-policy.mjs +6 -13
- package/src/runtime/shared/memory-snapshot.mjs +45 -36
- package/src/runtime/shared/process-lifecycle.mjs +113 -36
- package/src/session-runtime/session-turn-api.mjs +1 -0
- package/src/tui/dist/index.mjs +31 -0
- package/src/tui/engine/turn.mjs +31 -0
|
@@ -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
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
const pendingTool = leaked
|
|
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 {
|
|
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 {
|
|
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, {
|
|
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`,
|
|
461
|
-
const r = firstByteReject; firstByteReject = null;
|
|
548
|
+
const e = geminiTimeoutError(`${label} first byte`, firstByteTimeoutMs);
|
|
549
|
+
const r = firstByteReject; firstByteReject = null;
|
|
550
|
+
rejectAfterCancellation(r, e);
|
|
462
551
|
}
|
|
463
|
-
},
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|