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
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const { spawnSync } = require('child_process');
3
+ const { spawn, spawnSync } = require('child_process');
4
4
  const path = require('path');
5
5
  const fs = require('fs');
6
6
  const { resolvePluginData } = require('./plugin-paths.cjs');
@@ -12,16 +12,18 @@ const KEYCHAIN_TIMEOUT_MS = Number(process.env.MIXDOG_KEYCHAIN_TIMEOUT_MS || 150
12
12
  const POWERSHELL_TIMEOUT_MS = KEYCHAIN_TIMEOUT_MS;
13
13
 
14
14
  // ---------------------------------------------------------------------------
15
- // getSecret read cache — collapse repeated reads during a turn while bounding
16
- // cross-process staleness. Misses are never cached, so a token saved by another
17
- // process becomes visible immediately; hits expire so rotations are observed.
15
+ // getSecret read cache — decrypted secrets stay resident for this process by
16
+ // default, avoiding repeated synchronous keychain subprocesses. An explicit
17
+ // finite TTL can bound cross-process staleness; 0 disables the cache.
18
18
  // ---------------------------------------------------------------------------
19
19
  const KEYCHAIN_CACHE_TTL_MS = (() => {
20
20
  const value = Number(process.env.MIXDOG_KEYCHAIN_CACHE_TTL_MS);
21
- return Number.isFinite(value) && value >= 0 ? value : 30000;
21
+ return Number.isFinite(value) && value >= 0 ? value : Number.POSITIVE_INFINITY;
22
22
  })();
23
23
  /** @type {Map<string, {value: string, expiresAt: number}>} */
24
24
  const _secretCache = new Map();
25
+ const _cacheGenerations = new Map();
26
+ let _cacheEpoch = 0;
25
27
 
26
28
  function _cacheGet(account) {
27
29
  const key = `${SERVICE}\0${account}`;
@@ -38,17 +40,22 @@ function _cacheSet(account, value) {
38
40
  if (value == null || KEYCHAIN_CACHE_TTL_MS === 0) return;
39
41
  _secretCache.set(`${SERVICE}\0${account}`, {
40
42
  value,
41
- expiresAt: Date.now() + KEYCHAIN_CACHE_TTL_MS,
43
+ expiresAt: KEYCHAIN_CACHE_TTL_MS === Number.POSITIVE_INFINITY
44
+ ? Number.POSITIVE_INFINITY
45
+ : Date.now() + KEYCHAIN_CACHE_TTL_MS,
42
46
  });
43
47
  }
44
48
 
45
49
  function _cacheInvalidate(account) {
46
50
  _secretCache.delete(`${SERVICE}\0${account}`);
51
+ _cacheGenerations.set(account, (_cacheGenerations.get(account) || 0) + 1);
47
52
  }
48
53
 
49
54
  function invalidateSecretCache(account) {
50
55
  if (account == null) {
51
56
  _secretCache.clear();
57
+ _cacheGenerations.clear();
58
+ _cacheEpoch += 1;
52
59
  return;
53
60
  }
54
61
  _cacheInvalidate(account);
@@ -91,6 +98,25 @@ function run(cmd, args, opts) {
91
98
 
92
99
  // PS 5.1(powershell.exe) first: pwsh(PS 7) does not auto-load ProtectedData assembly,
93
100
  // so DPAPI calls silently fail there. Windows always ships powershell.exe.
101
+ function resolvePowershellExe() {
102
+ const sysRoot = process.env.SystemRoot || process.env.windir;
103
+ if (!sysRoot) {
104
+ throw new Error('[keychain] cannot resolve SystemRoot to locate powershell.exe for DPAPI');
105
+ }
106
+ return path.join(sysRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
107
+ }
108
+
109
+ function powershellEnv() {
110
+ const env = {};
111
+ for (const key of [
112
+ 'SystemRoot', 'windir', 'PATH', 'PATHEXT',
113
+ 'TEMP', 'TMP', 'USERPROFILE',
114
+ ]) {
115
+ if (process.env[key] !== undefined) env[key] = process.env[key];
116
+ }
117
+ return env;
118
+ }
119
+
94
120
  function powershell(script) {
95
121
  // Bound DPAPI PowerShell calls with a timeout: a hung powershell.exe
96
122
  // (AV scan stall, profile loader, transient cert chain lookup) would
@@ -101,26 +127,52 @@ function powershell(script) {
101
127
  // a PATH lookup, so a shadow `powershell.exe` earlier in PATH cannot hijack
102
128
  // secret encryption/decryption. (pwsh/PS7 is intentionally not used: it does
103
129
  // not auto-load the ProtectedData assembly, so DPAPI silently fails there.)
104
- const _sysRoot = process.env.SystemRoot || process.env.windir;
105
- if (!_sysRoot) {
106
- throw new Error('[keychain] cannot resolve SystemRoot to locate powershell.exe for DPAPI');
107
- }
108
- const _psHosts = [path.join(_sysRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')];
109
- let timedOut = null;
110
- for (const exe of _psHosts) {
111
- const r = run(exe, ['-NonInteractive', '-NoProfile', '-Command', script], { timeout: POWERSHELL_TIMEOUT_MS });
112
- if (r.error && r.error.code === 'ENOENT') continue;
113
- if (r.error && r.error.code === 'ETIMEDOUT') {
114
- timedOut = { exe, timeoutMs: POWERSHELL_TIMEOUT_MS };
115
- continue;
116
- }
117
- return r;
130
+ const exe = resolvePowershellExe();
131
+ const r = run(exe, ['-NonInteractive', '-NoProfile', '-Command', script], { timeout: POWERSHELL_TIMEOUT_MS });
132
+ if (r.error && r.error.code === 'ETIMEDOUT') {
133
+ throw new Error(`[keychain] PowerShell DPAPI command timed out after ${POWERSHELL_TIMEOUT_MS}ms (host: ${exe})`);
118
134
  }
119
- if (timedOut) {
120
- throw new Error(`[keychain] PowerShell DPAPI command timed out after ${timedOut.timeoutMs}ms (last host: ${timedOut.exe})`);
135
+ if (r.error && r.error.code === 'ENOENT') {
136
+ throw new Error(`[keychain] PowerShell not found: ${exe}`);
121
137
  }
122
- const err = new Error('[keychain] PowerShell not found (tried powershell, pwsh)');
123
- throw err;
138
+ return r;
139
+ }
140
+
141
+ function powershellAsync(script) {
142
+ return new Promise((resolve) => {
143
+ let child;
144
+ try {
145
+ child = spawn(
146
+ resolvePowershellExe(),
147
+ ['-NonInteractive', '-NoProfile', '-Command', script],
148
+ {
149
+ env: powershellEnv(),
150
+ shell: false,
151
+ windowsHide: true,
152
+ stdio: ['ignore', 'pipe', 'pipe'],
153
+ timeout: POWERSHELL_TIMEOUT_MS,
154
+ },
155
+ );
156
+ } catch (error) {
157
+ resolve({ status: null, stdout: '', stderr: '', error });
158
+ return;
159
+ }
160
+
161
+ let stdout = '';
162
+ let stderr = '';
163
+ let settled = false;
164
+ child.stdout.setEncoding('utf8');
165
+ child.stderr.setEncoding('utf8');
166
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
167
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
168
+ const finish = (status, error = null) => {
169
+ if (settled) return;
170
+ settled = true;
171
+ resolve({ status, stdout, stderr, error });
172
+ };
173
+ child.once('error', (error) => finish(null, error));
174
+ child.once('close', (status) => finish(status));
175
+ });
124
176
  }
125
177
 
126
178
  function secretsDir() {
@@ -296,6 +348,35 @@ function win32Get(account) {
296
348
  return out;
297
349
  }
298
350
 
351
+ async function prewarmSecrets() {
352
+ try {
353
+ if (platform() !== 'win32' || KEYCHAIN_CACHE_TTL_MS === 0) return;
354
+ const dir = secretsDir();
355
+ const entries = await fs.promises.readdir(dir, { withFileTypes: true });
356
+ const epoch = _cacheEpoch;
357
+ await Promise.all(entries
358
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.dpapi'))
359
+ .map(async (entry) => {
360
+ const account = entry.name.slice(0, -'.dpapi'.length);
361
+ const generation = _cacheGenerations.get(account) || 0;
362
+ try {
363
+ const b64 = (await fs.promises.readFile(path.join(dir, entry.name), 'utf8')).trim();
364
+ if (!b64) return;
365
+ const r = await powershellAsync(`& { ${PS_UNPROTECT} } '${b64}'`);
366
+ const value = (r.stdout || '').trim();
367
+ if (r.error || r.status !== 0 || !value) return;
368
+ if (_cacheEpoch !== epoch || (_cacheGenerations.get(account) || 0) !== generation) return;
369
+ _cacheSet(account, value);
370
+ } catch {
371
+ // Individual corrupt, deleted, or undecryptable entries must
372
+ // not prevent the remaining secrets from warming.
373
+ }
374
+ }));
375
+ } catch {
376
+ // Prewarming is an optional startup optimization and must never fail boot.
377
+ }
378
+ }
379
+
299
380
  function win32Set(account, value) {
300
381
  const dir = secretsDir();
301
382
  fs.mkdirSync(dir, { recursive: true });
@@ -367,4 +448,12 @@ function deleteSecret(account) {
367
448
  }
368
449
  }
369
450
 
370
- module.exports = { getSecret, setSecret, deleteSecret, hasSecret, invalidateSecretCache, SERVICE };
451
+ module.exports = {
452
+ getSecret,
453
+ setSecret,
454
+ deleteSecret,
455
+ hasSecret,
456
+ invalidateSecretCache,
457
+ prewarmSecrets,
458
+ SERVICE,
459
+ };
package/src/repl.mjs CHANGED
@@ -185,6 +185,17 @@ export async function runRepl({ provider: providerName, model, toolMode = 'full'
185
185
  stdout.write(chunk);
186
186
  atLineStart = chunk.endsWith('\n');
187
187
  },
188
+ onTextReset: ({ chars } = {}) => {
189
+ const count = Math.max(0, Number(chars) || 0);
190
+ if (!count || !colorEnabled() || printedToolCard) return false;
191
+ const remaining = streamedText.slice(0, Math.max(0, streamedText.length - count));
192
+ eraseStreamedBlock(streamedText);
193
+ streamedText = remaining;
194
+ if (remaining) stdout.write(remaining);
195
+ printedAny = !!remaining;
196
+ atLineStart = !remaining || remaining.endsWith('\n');
197
+ return true;
198
+ },
188
199
  onUsageDelta: (delta) => applyUsageDelta(stats, delta),
189
200
  },
190
201
  );
@@ -76,6 +76,7 @@ import {
76
76
  clampAnthropicThinkingBudget as clampThinkingBudgetTokens,
77
77
  deferredAnthropicTools as sharedDeferredAnthropicTools,
78
78
  requestAnthropicTools as sharedRequestAnthropicTools,
79
+ normalizeAnthropicNonStreamingResponse,
79
80
  resolveAnthropicCacheTtls as resolveCacheTtls,
80
81
  sanitizeAnthropicInputSchema,
81
82
  toAnthropicToolChoice,
@@ -562,6 +563,7 @@ export class AnthropicOAuthProvider {
562
563
  const onStreamDelta = typeof opts.onStreamDelta === 'function' ? opts.onStreamDelta : null;
563
564
  const onToolCall = typeof opts.onToolCall === 'function' ? opts.onToolCall : null;
564
565
  const onTextDelta = typeof opts.onTextDelta === 'function' ? opts.onTextDelta : null;
566
+ const onTextReset = typeof opts.onTextReset === 'function' ? opts.onTextReset : null;
565
567
  const externalSignal = opts.signal || null;
566
568
  // Test seam: lets the retry harness drive stream outcomes without a
567
569
  // live OAuth session.
@@ -612,7 +614,7 @@ export class AnthropicOAuthProvider {
612
614
  try { totalSignal.removeEventListener('abort', handler); } catch {}
613
615
  };
614
616
 
615
- const doRequest = async (accessToken, requestSignal = null) => {
617
+ const doRequest = async (accessToken, requestSignal = null, requestBody = body) => {
616
618
  const controller = createAbortController();
617
619
  const fetchStartedAt = Date.now();
618
620
 
@@ -665,7 +667,7 @@ export class AnthropicOAuthProvider {
665
667
  'x-app': 'cli',
666
668
  'Content-Type': 'application/json',
667
669
  },
668
- body: JSON.stringify(body),
670
+ body: JSON.stringify(requestBody),
669
671
  signal: controller.signal,
670
672
  dispatcher: getLlmDispatcher(),
671
673
  });
@@ -707,8 +709,8 @@ export class AnthropicOAuthProvider {
707
709
  // Test seam: injectable request factory for retry-path tests.
708
710
  const doRequestImpl = typeof opts._doRequestFn === 'function' ? opts._doRequestFn : doRequest;
709
711
 
710
- const requestWithRetry = async (accessToken) => withRetry(async ({ signal: attemptSignal }) => {
711
- const result = await doRequestImpl(accessToken, attemptSignal);
712
+ const requestWithRetry = async (accessToken, requestBody = body) => withRetry(async ({ signal: attemptSignal }) => {
713
+ const result = await doRequestImpl(accessToken, attemptSignal, requestBody);
712
714
  const status = Number(result?.response?.status || 0);
713
715
  const transientStatus = classifyError({ httpStatus: status }) === 'transient';
714
716
  if (transientStatus || status === 429) {
@@ -738,8 +740,14 @@ export class AnthropicOAuthProvider {
738
740
  backoffMs: ANTHROPIC_RETRY_BACKOFF_MS,
739
741
  retryJitterRatio: ANTHROPIC_RETRY_JITTER_RATIO,
740
742
  retryJitterMode: 'positive',
743
+ // Max/Pro OAuth sessions use subscription quota windows. Claude
744
+ // Code fails their 429s immediately rather than waiting through
745
+ // the API-key/PAYG retry budget (which may carry hours-long
746
+ // Retry-After values).
747
+ retry429: false,
741
748
  perAttemptTimeoutMs: requestTimeoutMs,
742
749
  perAttemptLabel: 'Anthropic OAuth initial response',
750
+ provider: 'anthropic',
743
751
  model: useModel,
744
752
  fallbackModel: opts._fallbackTriggered ? undefined : opts.fallbackModel,
745
753
  onRetry: ({ attempt, lastErr, delayMs, delayReason }) => {
@@ -760,6 +768,51 @@ export class AnthropicOAuthProvider {
760
768
  let firstAttemptError = null;
761
769
  let firstAttemptClassifier = null;
762
770
 
771
+ const recoverNonStreaming = async (midState, streamingError, controller) => {
772
+ const exposedChars = Number(midState?.emittedTextChars) || 0;
773
+ if (!onTextReset || exposedChars <= 0
774
+ || midState.emittedToolCall || midState.partialToolCall || midState.emittedThinking) {
775
+ try { streamingError.liveTextEmitted = true; streamingError.unsafeToRetry = true; } catch {}
776
+ throw streamingError;
777
+ }
778
+ let resetAccepted = false;
779
+ try {
780
+ resetAccepted = await onTextReset({
781
+ chars: exposedChars,
782
+ reason: 'anthropic-streaming-fallback',
783
+ }) === true;
784
+ } catch {}
785
+ if (!resetAccepted) {
786
+ try { streamingError.liveTextEmitted = true; streamingError.unsafeToRetry = true; } catch {}
787
+ throw streamingError;
788
+ }
789
+ try { controller?.abort?.(streamingError); } catch {}
790
+ try { onStageChange?.('requesting', { transport: 'non-streaming-fallback' }); } catch {}
791
+ let fallback = await requestWithRetry(creds.accessToken, { ...body, stream: false });
792
+ if (fallback.response.status === 401) {
793
+ cleanupCancelHandler(fallback.cancelHandler);
794
+ try { fallback.controller?.abort?.(); } catch {}
795
+ creds = await this.ensureAuth({ forceRefresh: true, reason: '401' });
796
+ fallback = await requestWithRetry(creds.accessToken, { ...body, stream: false });
797
+ }
798
+ if (!fallback.response.ok) {
799
+ const text = await fallback.response.text().catch(() => '');
800
+ cleanupCancelHandler(fallback.cancelHandler);
801
+ try { fallback.controller?.abort?.(); } catch {}
802
+ const fallbackError = new Error(`Anthropic OAuth API ${fallback.response.status}: ${this.scrubTokens(text).slice(0, 200)}`);
803
+ fallbackError.status = fallback.response.status;
804
+ fallbackError.httpStatus = fallback.response.status;
805
+ throw fallbackError;
806
+ }
807
+ try {
808
+ const message = await fallback.response.json();
809
+ return normalizeAnthropicNonStreamingResponse(message, useModel);
810
+ } finally {
811
+ cleanupCancelHandler(fallback.cancelHandler);
812
+ try { fallback.controller?.abort?.('Anthropic non-streaming fallback complete'); } catch {}
813
+ }
814
+ };
815
+
763
816
  try {
764
817
  for (let attemptIndex = 0; attemptIndex <= MAX_MIDSTREAM_RETRIES; attemptIndex++) {
765
818
  let response, controller, cancelHandler;
@@ -919,28 +972,12 @@ export class AnthropicOAuthProvider {
919
972
  } catch { /* ignore non-extensible result */ }
920
973
  return result;
921
974
  } catch (err) {
922
- // Live-text invariant: once a non-empty text chunk has been
923
- // relayed to the client (gateway live mode), the rendered output
924
- // cannot be withdrawn and re-issuing would concatenate a second
925
- // attempt. Surface the failure immediately never retry and
926
- // tag the error so upstream layers refuse to retry as well.
975
+ // Acknowledged reset semantics let the owner tombstone this
976
+ // attempt before the full request is restarted non-streaming.
977
+ // Without that acknowledgement, recoverNonStreaming stamps
978
+ // the error unsafe and preserves the no-concatenation rule.
927
979
  if (midState.emittedText) {
928
- try { err.liveTextEmitted = true; err.unsafeToRetry = true; } catch {}
929
- try { controller?.abort?.(err); } catch { /* best-effort teardown */ }
930
- if (attemptIndex > 0 && firstAttemptError) {
931
- try { firstAttemptError.midstreamRetries = attemptIndex; } catch {}
932
- try { firstAttemptError.midstreamClassifier = firstAttemptClassifier; } catch {}
933
- // firstAttemptError is what actually propagates here when
934
- // live text was emitted this attempt — stamp the unsafe
935
- // flags onto IT too, else upstream sees an unmarked error
936
- // and retries, duplicating already-streamed output.
937
- try {
938
- firstAttemptError.liveTextEmitted = true;
939
- firstAttemptError.unsafeToRetry = true;
940
- } catch {}
941
- throw err;
942
- }
943
- throw err;
980
+ return await recoverNonStreaming(midState, err, controller);
944
981
  }
945
982
  // Completed/partial tools and thinking blocks are also replay
946
983
  // boundaries. Stamp the current (latest) error so no upstream
@@ -174,7 +174,10 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
174
174
  content += emit;
175
175
  try { onStreamDelta?.('text'); } catch {}
176
176
  if (onTextDelta) {
177
- if (state) state.emittedText = true;
177
+ if (state) {
178
+ state.emittedText = true;
179
+ state.emittedTextChars = (Number(state.emittedTextChars) || 0) + emit.length;
180
+ }
178
181
  try { onTextDelta(emit); } catch {}
179
182
  }
180
183
  }
@@ -458,7 +461,10 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
458
461
  } else {
459
462
  content += delta.text || '';
460
463
  if (delta.text && onTextDelta) {
461
- if (state) state.emittedText = true;
464
+ if (state) {
465
+ state.emittedText = true;
466
+ state.emittedTextChars = (Number(state.emittedTextChars) || 0) + delta.text.length;
467
+ }
462
468
  try { onTextDelta(delta.text); } catch {}
463
469
  }
464
470
  if (delta.text) {
@@ -45,6 +45,7 @@ import {
45
45
  clampAnthropicThinkingBudget as clampThinkingBudgetTokens,
46
46
  deferredAnthropicTools as sharedDeferredAnthropicTools,
47
47
  requestAnthropicTools as sharedRequestAnthropicTools,
48
+ normalizeAnthropicNonStreamingResponse,
48
49
  resolveAnthropicCacheTtls as resolveCacheTtls,
49
50
  sanitizeAnthropicInputSchema,
50
51
  toAnthropicToolChoice,
@@ -489,6 +490,7 @@ export class AnthropicProvider {
489
490
  const onStreamDelta = typeof opts.onStreamDelta === 'function' ? opts.onStreamDelta : null;
490
491
  const onToolCall = typeof opts.onToolCall === 'function' ? opts.onToolCall : null;
491
492
  const onTextDelta = typeof opts.onTextDelta === 'function' ? opts.onTextDelta : null;
493
+ const onTextReset = typeof opts.onTextReset === 'function' ? opts.onTextReset : null;
492
494
 
493
495
  // No absolute wall-clock cap on streaming generation: a stream still
494
496
  // emitting SSE deltas must not be killed by a fixed total-lifetime timer.
@@ -579,6 +581,48 @@ export class AnthropicProvider {
579
581
  };
580
582
  };
581
583
 
584
+ const recoverNonStreaming = async (midState, streamingError, streamController) => {
585
+ const exposedChars = Number(midState?.emittedTextChars) || 0;
586
+ if (!onTextReset || exposedChars <= 0
587
+ || midState.emittedToolCall || midState.partialToolCall || midState.emittedThinking) {
588
+ try { streamingError.liveTextEmitted = true; streamingError.unsafeToRetry = true; } catch {}
589
+ throw streamingError;
590
+ }
591
+ let resetAccepted = false;
592
+ try {
593
+ resetAccepted = await onTextReset({
594
+ chars: exposedChars,
595
+ reason: 'anthropic-streaming-fallback',
596
+ }) === true;
597
+ } catch {}
598
+ if (!resetAccepted) {
599
+ try { streamingError.liveTextEmitted = true; streamingError.unsafeToRetry = true; } catch {}
600
+ throw streamingError;
601
+ }
602
+ try { streamController.abort?.(streamingError); } catch {}
603
+ try { onStageChange?.('requesting', { transport: 'non-streaming-fallback' }); } catch {}
604
+ const nonStreamingParams = { ...params, stream: false };
605
+ const message = await withRetry(
606
+ async ({ signal: attemptSignal }) => this.client.messages.create(nonStreamingParams, {
607
+ signal: attemptSignal,
608
+ ...(betaHeaders ? { headers: betaHeaders } : {}),
609
+ }),
610
+ {
611
+ signal: totalSignal,
612
+ maxAttempts: anthropicMaxAttempts(),
613
+ backoffMs: ANTHROPIC_RETRY_BACKOFF_MS,
614
+ retryJitterRatio: ANTHROPIC_RETRY_JITTER_RATIO,
615
+ retryJitterMode: 'positive',
616
+ perAttemptTimeoutMs: anthropicRequestTimeoutMs(),
617
+ perAttemptLabel: `${this.name} Anthropic non-streaming fallback`,
618
+ provider: 'anthropic',
619
+ model: useModel,
620
+ fallbackModel: opts._fallbackTriggered ? undefined : opts.fallbackModel,
621
+ },
622
+ );
623
+ return buildReturnFromParse(normalizeAnthropicNonStreamingResponse(message, useModel));
624
+ };
625
+
582
626
  try {
583
627
  for (let attemptIndex = 0; attemptIndex <= MAX_MIDSTREAM_RETRIES; attemptIndex++) {
584
628
  const streamController = createAbortController();
@@ -662,6 +706,7 @@ export class AnthropicProvider {
662
706
  retryJitterMode: 'positive',
663
707
  perAttemptTimeoutMs: anthropicRequestTimeoutMs(),
664
708
  perAttemptLabel: `${this.name} Anthropic streaming response`,
709
+ provider: 'anthropic',
665
710
  model: useModel,
666
711
  fallbackModel: opts._fallbackTriggered ? undefined : opts.fallbackModel,
667
712
  onRetry: ({ attempt, lastErr, delayMs, delayReason }) => {
@@ -740,30 +785,12 @@ export class AnthropicProvider {
740
785
  _fallbackTriggered: true,
741
786
  });
742
787
  }
743
- // Live-text invariant: once a non-empty text chunk has been
744
- // relayed to the client (gateway live mode), the rendered
745
- // output cannot be withdrawn and re-issuing would concatenate
746
- // a second attempt. Surface immediately never retry — and
747
- // tag the error so upstream layers refuse to retry as well.
788
+ // Acknowledged reset semantics let the owner tombstone this
789
+ // attempt before the full request is restarted non-streaming.
790
+ // Without that acknowledgement, recoverNonStreaming stamps
791
+ // the error unsafe and preserves the no-concatenation rule.
748
792
  if (midState.emittedText) {
749
- try { err.liveTextEmitted = true; err.unsafeToRetry = true; } catch {}
750
- try { streamController.abort?.(err); } catch {}
751
- if (attemptIndex > 0 && firstAttemptError) {
752
- try { firstAttemptError.midstreamRetries = attemptIndex; } catch {}
753
- try { firstAttemptError.midstreamClassifier = firstAttemptClassifier; } catch {}
754
- // firstAttemptError (from an earlier retried attempt)
755
- // is what actually propagates when live text was
756
- // emitted this attempt — stamp the unsafe/partial
757
- // flags onto IT too, otherwise upstream sees an
758
- // unmarked error and retries, duplicating streamed
759
- // output.
760
- try {
761
- firstAttemptError.liveTextEmitted = true;
762
- firstAttemptError.unsafeToRetry = true;
763
- } catch {}
764
- throw err;
765
- }
766
- throw err;
793
+ return await recoverNonStreaming(midState, err, streamController);
767
794
  }
768
795
  if (midState.emittedToolCall || midState.partialToolCall || midState.emittedThinking) {
769
796
  try {
@@ -100,10 +100,10 @@ export function _geminiCachePrefixHash({ model, systemInstruction, geminiTools,
100
100
  }));
101
101
  }
102
102
 
103
- // The provider's default explicit-cache TTL is five minutes. A six-minute
104
- // global-cache floor made every freshly-created default cache ineligible for
105
- // cross-session reuse. Ten seconds matches the per-session minimum headroom.
106
- export const GEMINI_GLOBAL_CACHE_MIN_LIVE_MS = 10 * 1000;
103
+ // Keep enough headroom for the provider's 60s first-byte window plus setup
104
+ // overhead. This matches the default five-minute cache's per-session 25%
105
+ // reuse threshold, so a cross-session hit cannot expire while opening.
106
+ export const GEMINI_GLOBAL_CACHE_MIN_LIVE_MS = 75 * 1000;
107
107
  export const GEMINI_GLOBAL_CACHE_MAX_ENTRIES = 128;
108
108
  // Grace window before deleting a superseded cachedContents name (see the
109
109
  // cross-session race note at the delete call site in gemini.mjs). Long enough
@@ -136,6 +136,17 @@ export function _invalidateGeminiCachesForCredentialFingerprint(credentialFinger
136
136
  return removed;
137
137
  }
138
138
 
139
+ export function _invalidateGeminiCacheName(cacheName) {
140
+ if (!cacheName) return 0;
141
+ let removed = 0;
142
+ for (const [key, entry] of geminiGlobalCaches) {
143
+ if (entry?.cacheName !== cacheName) continue;
144
+ geminiGlobalCaches.delete(key);
145
+ removed += 1;
146
+ }
147
+ return removed;
148
+ }
149
+
139
150
  export function _pruneGeminiGlobalCaches(now = Date.now()) {
140
151
  for (const [key, entry] of geminiGlobalCaches) {
141
152
  if (!entry?.cacheName || Number(entry.cacheExpiresAt || 0) <= now) {