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
@@ -49,8 +49,8 @@ function _captureMidstreamAbort(state, reason) {
49
49
 
50
50
  // Abort-aware mid-stream backoff sleep → shared sleepWithAbort
51
51
  // (retry-classifier.mjs). abortMessage preserves the prior fallback text.
52
- export function _midstreamSleepWithAbort(ms, signal) {
53
- return sleepWithAbort(ms, signal, undefined, 'Anthropic OAuth mid-stream retry backoff aborted');
52
+ export function _midstreamSleepWithAbort(ms, signal, sleepFn) {
53
+ return sleepWithAbort(ms, signal, sleepFn, 'Anthropic OAuth mid-stream retry backoff aborted');
54
54
  }
55
55
 
56
56
  function _statusForAnthropicSseError(type, message) {
@@ -83,9 +83,8 @@ function _anthropicSseError(event) {
83
83
  }
84
84
 
85
85
  export async function parseSSEStream(response, signal, abortStream, onStreamDelta, onToolCall, state, onTextDelta, knownToolNames) {
86
- // parseSSEStream is entered only after Anthropic returned an HTTP response.
87
- // Headers prove transport health, but are not a semantic model event.
88
- try { onStreamDelta?.('transport'); } catch {}
86
+ // onStreamDelta is a semantic-progress callback. HTTP headers and raw SSE
87
+ // bytes prove transport health but must not refresh semantic watchdogs.
89
88
  const reader = response.body.getReader();
90
89
  const decoder = new TextDecoder();
91
90
  // SEMANTIC idle window: reset only by real model events (message/content/
@@ -287,17 +286,18 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
287
286
  if (_c && typeof _c.catch === 'function') _c.catch(() => {});
288
287
  } catch {}
289
288
  };
290
- if (signal) {
291
- if (signal.aborted) {
292
- _captureMidstreamAbort(state, signal.reason);
293
- throw signal.reason instanceof Error
294
- ? signal.reason
295
- : new Error('Anthropic OAuth SSE stream aborted');
296
- }
297
- signal.addEventListener('abort', onAbort, { once: true });
298
- }
299
-
300
289
  try {
290
+ // Reader ownership begins at getReader() above, so even a signal that
291
+ // was already aborted must pass through this try/finally cleanup path.
292
+ if (signal) {
293
+ if (signal.aborted) {
294
+ _captureMidstreamAbort(state, signal.reason);
295
+ throw signal.reason instanceof Error
296
+ ? signal.reason
297
+ : new Error('Anthropic OAuth SSE stream aborted');
298
+ }
299
+ signal.addEventListener('abort', onAbort, { once: true });
300
+ }
301
301
  // Part A / reviewer fix: do NOT arm the SEMANTIC idle timer before the
302
302
  // stream has produced its first event. A slow first response is governed
303
303
  // by armFirstMessageTimer() (first-byte window) alone; arming the
@@ -331,8 +331,6 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
331
331
  }
332
332
  const { done, value } = chunk;
333
333
  if (done) break;
334
- try { onStreamDelta?.('transport'); } catch {}
335
-
336
334
  buffer += decoder.decode(value, { stream: true });
337
335
  const lines = buffer.split('\n');
338
336
  buffer = lines.pop() || '';
@@ -382,6 +380,12 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
382
380
  if (event.type === 'message_start' && event.message) {
383
381
  clearFirstMessageTimer();
384
382
  if (state) state.sawMessageStart = true;
383
+ // The first protocol event proves the response transport
384
+ // is live. Report it only here (never for raw bytes,
385
+ // comments, or ping events), then separately report the
386
+ // semantic message boundary. Content kinds remain
387
+ // reasoning/text/tool-specific below.
388
+ try { onStreamDelta?.('transport'); } catch {}
385
389
  try { onStreamDelta?.('semantic'); } catch {}
386
390
  if (event.message.model) model = event.message.model;
387
391
  if (event.message.usage) {
@@ -395,6 +399,7 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
395
399
  if (event.type === 'content_block_start') {
396
400
  const block = event.content_block;
397
401
  if (block?.type === 'tool_use') {
402
+ if (state) state.partialToolCall = true;
398
403
  pendingToolInputs.set(event.index, {
399
404
  id: block.id || '',
400
405
  name: block.name || '',
@@ -402,6 +407,7 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
402
407
  });
403
408
  }
404
409
  if (block?.type === 'thinking' || block?.type === 'redacted_thinking') {
410
+ if (state) state.emittedThinking = true;
405
411
  if (block.type === 'redacted_thinking') {
406
412
  // Redacted blocks round-trip EXACTLY as
407
413
  // {type:'redacted_thinking',data} — no thinking/
@@ -463,6 +469,7 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
463
469
  }
464
470
  }
465
471
  if (delta?.type === 'thinking_delta' || delta?.type === 'signature_delta') {
472
+ if (state) state.emittedThinking = true;
466
473
  // Extended-thinking block: provider reasoning without
467
474
  // user-visible text. Track presence so a final turn
468
475
  // that emitted ONLY thinking (no text_delta, no
@@ -490,6 +497,7 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
490
497
  }
491
498
  }
492
499
  if (delta?.type === 'input_json_delta') {
500
+ if (state) state.partialToolCall = true;
493
501
  const pending = pendingToolInputs.get(event.index);
494
502
  if (pending) {
495
503
  pending.inputJson += delta.partial_json || '';
@@ -658,6 +666,10 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
658
666
  if (idleTimer) clearTimeout(idleTimer);
659
667
  clearFirstMessageTimer();
660
668
  if (signal) signal.removeEventListener('abort', onAbort);
669
+ // message_stop deliberately exits before EOF because Anthropic may keep
670
+ // sending pings. Cancel the reader so the successful response body and
671
+ // underlying keep-alive connection are not stranded.
672
+ try { await reader.cancel('Anthropic SSE complete'); } catch {}
661
673
  try { reader.releaseLock(); } catch (err) {
662
674
  try { process.stderr.write(`[anthropic-oauth] reader releaseLock failed: ${err?.message ?? String(err)}\n`); } catch {}
663
675
  }
@@ -1,7 +1,18 @@
1
1
  import { createRequire } from 'node:module';
2
2
  import { getAgentApiKey } from '../../../shared/provider-api-key.mjs';
3
3
  import { sanitizeToolPairs, sanitizeAnthropicContentPairs, foldUserTextIntoToolResultTail } from '../session/context-utils.mjs';
4
- import { classifyError, midstreamBackoffFor, sleepWithAbort, withRetry, retryAfterMsFromError } from './retry-classifier.mjs';
4
+ import {
5
+ ANTHROPIC_RETRY_BACKOFF_MS,
6
+ ANTHROPIC_RETRY_JITTER_RATIO,
7
+ AnthropicFallbackTriggeredError,
8
+ anthropicMaxAttempts,
9
+ anthropicRequestTimeoutMs,
10
+ classifyError,
11
+ midstreamBackoffFor,
12
+ sleepWithAbort,
13
+ withRetry,
14
+ retryAfterMsFromError,
15
+ } from './retry-classifier.mjs';
5
16
  import { traceAgentUsage } from '../agent-trace.mjs';
6
17
  import {
7
18
  PROVIDER_FIRST_BYTE_TIMEOUT_MS,
@@ -23,9 +34,11 @@ import {
23
34
  import { normalizeContentForAnthropic } from './media-normalization.mjs';
24
35
  import { enrichModels } from './model-catalog.mjs';
25
36
  import { sanitizeModelList } from './model-list-sanitize.mjs';
37
+ import { providerNativeToolPrefixCount } from '../../../../session-runtime/provider-request-tools.mjs';
26
38
  import { makeModelCache } from './model-cache.mjs';
27
39
  import { resolveAnthropicMaxTokens } from './anthropic-max-tokens.mjs';
28
40
  import { getLlmDispatcher } from '../../../shared/llm/http-agent.mjs';
41
+ import { notifyCurrentAnthropicRateLimit } from './admission-scheduler.mjs';
29
42
 
30
43
  const require = createRequire(import.meta.url);
31
44
  let _Anthropic = null;
@@ -231,7 +244,12 @@ function resolveMaxTokens(model) {
231
244
  }
232
245
 
233
246
  // Test-only escape hatch for scripts/anthropic-maxtokens-test.mjs.
234
- export const _test = { resolveMaxTokens, deferredAnthropicTools, sanitizeInputSchema: _sanitizeInputSchema };
247
+ export const _test = {
248
+ resolveMaxTokens,
249
+ deferredAnthropicTools,
250
+ requestAnthropicTools,
251
+ sanitizeInputSchema: _sanitizeInputSchema,
252
+ };
235
253
 
236
254
  const MIN_THINKING_BUDGET = 1024;
237
255
  const THINKING_OUTPUT_RESERVE = 1024;
@@ -356,6 +374,24 @@ function deferredAnthropicTools(activeTools, messages, opts) {
356
374
  .filter((tool) => tool?.name && discovered.has(String(tool.name)) && !active.has(String(tool.name)))
357
375
  .map((tool) => ({ ...tool, deferLoading: true }));
358
376
  }
377
+ function requestAnthropicTools(tools, messages, opts) {
378
+ const activeTools = Array.isArray(tools) ? tools : [];
379
+ if (opts?.providerToolSnapshotAuthoritative === true) {
380
+ const nativePrefixCount = providerNativeToolPrefixCount(
381
+ activeTools,
382
+ opts.providerNativeToolPrefixCount,
383
+ );
384
+ return [
385
+ ...activeTools.slice(0, nativePrefixCount),
386
+ ...toAnthropicTools(activeTools.slice(nativePrefixCount)),
387
+ ];
388
+ }
389
+ const deferredTools = deferredAnthropicTools(activeTools, messages, opts);
390
+ return [
391
+ ...nativeAnthropicTools(opts),
392
+ ...toAnthropicTools([...activeTools, ...deferredTools]),
393
+ ];
394
+ }
359
395
  function toAnthropicMessages(messages) {
360
396
  // Marker-free lowering. cache_control is applied AFTER sanitization by
361
397
  // applyAnthropicCacheMarkers() so that block drops/inserts/reorders
@@ -409,6 +445,7 @@ function toAnthropicMessages(messages) {
409
445
  content: references.length
410
446
  ? references.map((tool_name) => ({ type: 'tool_reference', tool_name }))
411
447
  : normalizeContentForAnthropic(m.content),
448
+ ...((m.toolKind === 'error' || m.isError === true) ? { is_error: true } : {}),
412
449
  };
413
450
  if (last?.role === 'user' && Array.isArray(last.content)) {
414
451
  last.content.push(block);
@@ -549,15 +586,18 @@ export class AnthropicProvider {
549
586
  name = 'anthropic';
550
587
  client;
551
588
  config;
589
+ apiKey;
552
590
  fastModeBetaHeaderLatched = false;
553
591
  constructor(config) {
554
- this.config = config;
555
- this.name = config.name || 'anthropic';
556
- const betaHeaders = config.disableBetaHeaders ? null : buildAnthropicBetaHeaders({ toolSearch: true });
592
+ this.config = config || {};
593
+ this.name = this.config.name || 'anthropic';
594
+ this.apiKey = this.config.apiKey || (this.name === 'anthropic' ? process.env.ANTHROPIC_API_KEY : null);
595
+ const betaHeaders = this.config.disableBetaHeaders ? null : buildAnthropicBetaHeaders();
557
596
  this.client = new (loadAnthropic())({
558
- apiKey: config.apiKey || process.env.ANTHROPIC_API_KEY,
559
- ...(config.baseURL ? { baseURL: config.baseURL } : {}),
560
- defaultHeaders: { ...(betaHeaders ? { 'anthropic-beta': betaHeaders } : {}), ...(config.extraHeaders || {}) },
597
+ apiKey: this.apiKey,
598
+ ...(this.config.baseURL ? { baseURL: this.config.baseURL } : {}),
599
+ defaultHeaders: { ...(betaHeaders ? { 'anthropic-beta': betaHeaders } : {}), ...(this.config.extraHeaders || {}) },
600
+ maxRetries: 0,
561
601
  });
562
602
  }
563
603
  reloadApiKey() {
@@ -567,11 +607,16 @@ export class AnthropicProvider {
567
607
  || (this.name === 'anthropic' ? process.env.ANTHROPIC_API_KEY : null);
568
608
  if (newKey) {
569
609
  this.config = { ...(this.config || {}), apiKey: newKey };
570
- const betaHeaders = this.config.disableBetaHeaders ? null : buildAnthropicBetaHeaders({ toolSearch: true });
610
+ this.apiKey = newKey;
611
+ // Tool-search is a request capability, not an account
612
+ // capability. Keep it off the client defaults and add it only
613
+ // on turns that actually serialize defer_loading tools.
614
+ const betaHeaders = this.config.disableBetaHeaders ? null : buildAnthropicBetaHeaders();
571
615
  this.client = new (loadAnthropic())({
572
616
  apiKey: newKey,
573
617
  ...(this.config.baseURL ? { baseURL: this.config.baseURL } : {}),
574
618
  defaultHeaders: { ...(betaHeaders ? { 'anthropic-beta': betaHeaders } : {}), ...(this.config.extraHeaders || {}) },
619
+ maxRetries: 0,
575
620
  });
576
621
  }
577
622
  } catch { /* best effort */ }
@@ -586,8 +631,16 @@ export class AnthropicProvider {
586
631
  try {
587
632
  return await this._doSend(messages, model, tools, sendOpts);
588
633
  } catch (err) {
589
- if (err.message && (err.message.includes('401') || err.message.includes('403'))
590
- && !err.liveTextEmitted && !err.emittedToolCall && !err.unsafeToRetry) {
634
+ const status = Number(err?.status || err?.httpStatus || err?.response?.status || 0);
635
+ const outputWasExposed = err?.liveTextEmitted === true
636
+ || err?.emittedText === true
637
+ || err?.emittedToolCall === true
638
+ || err?.toolCallEmitted === true
639
+ || err?.partialToolCall === true
640
+ || err?.emittedThinking === true
641
+ || err?.unsafeToRetry === true;
642
+ if (status === 401
643
+ && !outputWasExposed) {
591
644
  process.stderr.write(`[provider] Auth error, re-reading provider authentication...\n`);
592
645
  this.reloadApiKey();
593
646
  return await this._doSend(messages, model, tools, sendOpts);
@@ -640,11 +693,11 @@ export class AnthropicProvider {
640
693
  system: systemBlocks.length ? systemBlocks : undefined,
641
694
  messages: anthropicMessages,
642
695
  };
643
- const nativeTools = nativeAnthropicTools(opts);
644
- if (tools?.length || nativeTools.length) {
696
+ const requestTools = requestAnthropicTools(tools, chatMsgs, opts);
697
+ if (requestTools.length) {
645
698
  // No cache_control on tools — the system BP covers tools via
646
699
  // Anthropic prefix semantics (order: tools → system → messages).
647
- params.tools = [...nativeTools, ...toAnthropicTools([...(tools || []), ...deferredAnthropicTools(tools || [], chatMsgs, opts)])];
700
+ params.tools = requestTools;
648
701
  }
649
702
  // tool_choice only when tools are actually present (Anthropic rejects
650
703
  // tool_choice without tools). 'none' rides the hard-cap final turn to
@@ -653,6 +706,8 @@ export class AnthropicProvider {
653
706
  const toolChoice = toAnthropicToolChoice(opts.toolChoice);
654
707
  if (toolChoice) params.tool_choice = toolChoice;
655
708
  }
709
+ const hasDeferredTools = Array.isArray(params.tools)
710
+ && params.tools.some((tool) => tool?.defer_loading === true);
656
711
  // Known tool names for the shared parseSSEStream leaked-tool-call guard
657
712
  // (same guard fixes both Anthropic providers). Recovered leaked calls
658
713
  // are only synthesized when they name a tool actually offered here.
@@ -711,7 +766,7 @@ export class AnthropicProvider {
711
766
  : {
712
767
  'anthropic-beta': buildAnthropicBetaHeaders({
713
768
  fastMode: this.fastModeBetaHeaderLatched,
714
- toolSearch: true,
769
+ toolSearch: hasDeferredTools,
715
770
  effort: shouldIncludeEffortBeta(useModel, opts),
716
771
  }),
717
772
  };
@@ -795,6 +850,8 @@ export class AnthropicProvider {
795
850
  sawMessageStart: false,
796
851
  sawCompleted: false,
797
852
  emittedToolCall: false,
853
+ partialToolCall: false,
854
+ emittedThinking: false,
798
855
  // Gateway live-text relay invariant: set by parseSSEStream
799
856
  // once a non-empty text chunk has been forwarded. A later
800
857
  // failure is non-retryable (rendered text cannot be
@@ -806,11 +863,12 @@ export class AnthropicProvider {
806
863
 
807
864
  let firstBytePoll = null;
808
865
  let firstByteTimeout = null;
866
+ let response = null;
809
867
 
810
868
  try {
811
869
  try { onStageChange?.('requesting'); } catch {}
812
870
 
813
- const response = await withRetry(
871
+ response = await withRetry(
814
872
  async ({ signal: attemptSignal }) => {
815
873
  const res = await this.client.messages.create(params, {
816
874
  signal: attemptSignal,
@@ -821,13 +879,14 @@ export class AnthropicProvider {
821
879
  const err = new Error(`Anthropic API ${res.status}: ${text.slice(0, 200)}`);
822
880
  err.status = res.status;
823
881
  err.httpStatus = res.status;
882
+ err.initialResponseError = true;
824
883
  // Carry response headers so withRetry can honor a
825
884
  // short Retry-After and upstream can read quota hints.
826
885
  err.headers = res.headers;
827
886
  err.response = { status: res.status, headers: res.headers };
828
- // 429: promote to a quota-style error equivalent to
829
- // anthropic-oauth's anthropicQuotaError so upstream
830
- // quota handling and unsafe-to-retry gating apply.
887
+ // This is an initial-response 429, before SSE
888
+ // output/tool exposure, so the request-local
889
+ // withRetry loop may retry it with jitter.
831
890
  if (res.status === 429) {
832
891
  const retryAfterMs = retryAfterMsFromError({ headers: res.headers, response: { headers: res.headers } });
833
892
  err.name = 'ProviderQuotaError';
@@ -835,7 +894,6 @@ export class AnthropicProvider {
835
894
  err.retryAfterMs = retryAfterMs;
836
895
  err.providerQuota = true;
837
896
  err.quotaExceeded = true;
838
- err.unsafeToRetry = true;
839
897
  }
840
898
  throw err;
841
899
  }
@@ -846,9 +904,17 @@ export class AnthropicProvider {
846
904
  },
847
905
  {
848
906
  signal: totalSignal,
849
- perAttemptTimeoutMs: PROVIDER_FIRST_BYTE_TIMEOUT_MS,
907
+ maxAttempts: anthropicMaxAttempts(),
908
+ backoffMs: ANTHROPIC_RETRY_BACKOFF_MS,
909
+ retryJitterRatio: ANTHROPIC_RETRY_JITTER_RATIO,
910
+ retryJitterMode: 'positive',
911
+ perAttemptTimeoutMs: anthropicRequestTimeoutMs(),
850
912
  perAttemptLabel: `${this.name} Anthropic streaming response`,
913
+ model: useModel,
914
+ fallbackModel: opts._fallbackTriggered ? undefined : opts.fallbackModel,
851
915
  onRetry: ({ attempt, lastErr, delayMs, delayReason }) => {
916
+ const status = Number(lastErr?.httpStatus || lastErr?.status || lastErr?.response?.status || 0);
917
+ if (status === 429) notifyCurrentAnthropicRateLimit(lastErr);
852
918
  const delayLabel = Number.isFinite(Number(delayMs))
853
919
  ? `, delay ${delayMs}ms${delayReason ? ` (${delayReason})` : ''}`
854
920
  : '';
@@ -890,6 +956,7 @@ export class AnthropicProvider {
890
956
  onTextDelta,
891
957
  knownToolNames,
892
958
  );
959
+ try { streamController.abort?.('Anthropic SSE complete'); } catch {}
893
960
 
894
961
  if (firstBytePoll) {
895
962
  clearInterval(firstBytePoll);
@@ -913,6 +980,14 @@ export class AnthropicProvider {
913
980
 
914
981
  return buildReturnFromParse(parseResult);
915
982
  } catch (err) {
983
+ if (err instanceof AnthropicFallbackTriggeredError) {
984
+ process.stderr.write(`[${this.name}] ${err.message}\n`);
985
+ return this._doSend(messages, err.fallbackModel, tools, {
986
+ ...opts,
987
+ fallbackModel: undefined,
988
+ _fallbackTriggered: true,
989
+ });
990
+ }
916
991
  // Live-text invariant: once a non-empty text chunk has been
917
992
  // relayed to the client (gateway live mode), the rendered
918
993
  // output cannot be withdrawn and re-issuing would concatenate
@@ -934,10 +1009,24 @@ export class AnthropicProvider {
934
1009
  firstAttemptError.liveTextEmitted = true;
935
1010
  firstAttemptError.unsafeToRetry = true;
936
1011
  } catch {}
937
- throw firstAttemptError;
1012
+ throw err;
938
1013
  }
939
1014
  throw err;
940
1015
  }
1016
+ if (midState.emittedToolCall || midState.partialToolCall || midState.emittedThinking) {
1017
+ try {
1018
+ err.emittedToolCall = !!midState.emittedToolCall;
1019
+ err.partialToolCall = !!midState.partialToolCall;
1020
+ err.emittedThinking = !!midState.emittedThinking;
1021
+ err.unsafeToRetry = true;
1022
+ } catch {}
1023
+ try { streamController.abort?.(err); } catch {}
1024
+ throw err;
1025
+ }
1026
+ // withRetry already exhausted the full request-level budget.
1027
+ // Do not accidentally grant an additional SSE retry budget
1028
+ // to an initial HTTP 429 that never produced a stream.
1029
+ if (err?.initialResponseError) throw err;
941
1030
  if (err?.isEmptyStream && attemptIndex < MAX_MIDSTREAM_RETRIES) {
942
1031
  firstAttemptError = err;
943
1032
  firstAttemptClassifier = 'empty_stream';
@@ -953,6 +1042,8 @@ export class AnthropicProvider {
953
1042
  if (classifyError(err) === 'transient'
954
1043
  && !midState.sawMessageStart
955
1044
  && !midState.emittedToolCall
1045
+ && !midState.partialToolCall
1046
+ && !midState.emittedThinking
956
1047
  && attemptIndex < MAX_MIDSTREAM_RETRIES) {
957
1048
  firstAttemptError = err;
958
1049
  firstAttemptClassifier = err?.providerErrorType || 'sse_transient';
@@ -968,6 +1059,8 @@ export class AnthropicProvider {
968
1059
  if ((err?.truncatedStream === true || err?.code === 'TRUNCATED_STREAM')
969
1060
  && classifyError(err) === 'transient'
970
1061
  && !midState.emittedToolCall
1062
+ && !midState.partialToolCall
1063
+ && !midState.emittedThinking
971
1064
  && attemptIndex < MAX_MIDSTREAM_RETRIES) {
972
1065
  firstAttemptError = err;
973
1066
  firstAttemptClassifier = 'truncated_stream';
@@ -984,19 +1077,31 @@ export class AnthropicProvider {
984
1077
  if (classifier && attemptIndex < MAX_MIDSTREAM_RETRIES) {
985
1078
  firstAttemptError = err;
986
1079
  firstAttemptClassifier = classifier;
1080
+ const status = Number(err?.httpStatus || err?.status || 0);
1081
+ let retryDelayMs = null;
1082
+ if (status === 429) {
1083
+ if (!err.headers && response?.headers) err.headers = response.headers;
1084
+ if (!err.response && response) err.response = { status, headers: response.headers };
1085
+ retryDelayMs = retryAfterMsFromError(err);
1086
+ if (retryDelayMs != null) err.retryAfterMs = retryDelayMs;
1087
+ notifyCurrentAnthropicRateLimit(err);
1088
+ }
987
1089
  try { streamController.abort?.(err); } catch {}
988
1090
  try {
989
1091
  process.stderr.write(
990
1092
  `[${this.name}] mid-stream recovered: retry ${attemptIndex + 1}/${MAX_MIDSTREAM_RETRIES} (cause: ${classifier})\n`,
991
1093
  );
992
1094
  } catch {}
993
- await _midstreamSleepWithAbort(midstreamBackoffFor(attemptIndex + 1), totalSignal);
1095
+ await _midstreamSleepWithAbort(
1096
+ retryDelayMs ?? midstreamBackoffFor(attemptIndex + 1),
1097
+ totalSignal,
1098
+ );
994
1099
  continue;
995
1100
  }
996
1101
  if (attemptIndex > 0 && firstAttemptError) {
997
- try { firstAttemptError.midstreamRetries = attemptIndex; } catch {}
998
- try { firstAttemptError.midstreamClassifier = firstAttemptClassifier; } catch {}
999
- throw firstAttemptError;
1102
+ try { err.midstreamRetries = attemptIndex; } catch {}
1103
+ try { err.midstreamClassifier = firstAttemptClassifier; } catch {}
1104
+ throw err;
1000
1105
  }
1001
1106
  throw err;
1002
1107
  } finally {
@@ -1011,7 +1116,7 @@ export class AnthropicProvider {
1011
1116
  }
1012
1117
  }
1013
1118
  async listModels() {
1014
- const apiKey = this.config?.apiKey || (this.name === 'anthropic' ? process.env.ANTHROPIC_API_KEY : null);
1119
+ const apiKey = this.apiKey || this.config?.apiKey || (this.name === 'anthropic' ? process.env.ANTHROPIC_API_KEY : null);
1015
1120
  if (!apiKey) return MODELS;
1016
1121
  try {
1017
1122
  const base = String(this.config?.baseURL || 'https://api.anthropic.com').replace(/\/+$/, '');
@@ -1043,16 +1148,9 @@ export class AnthropicProvider {
1043
1148
  }
1044
1149
  }
1045
1150
  async isAvailable() {
1046
- try {
1047
- await this.client.messages.create({
1048
- model: 'claude-haiku-4-5-20251001',
1049
- max_tokens: 1,
1050
- messages: [{ role: 'user', content: 'hi' }],
1051
- });
1052
- return true;
1053
- }
1054
- catch {
1055
- return false;
1056
- }
1151
+ // Availability probes must not spend tokens or depend on a live
1152
+ // network. Dispatch owns authentication validation and 401 reload.
1153
+ return !!(this.apiKey || this.config?.apiKey
1154
+ || (this.name === 'anthropic' && process.env.ANTHROPIC_API_KEY));
1057
1155
  }
1058
1156
  }
@@ -90,16 +90,20 @@ export function _geminiCachePrefixContents(contents, prefixCount) {
90
90
  });
91
91
  }
92
92
 
93
- export function _geminiCachePrefixHash({ model, systemInstruction, geminiTools, contents, prefixCount }) {
93
+ export function _geminiCachePrefixHash({ model, systemInstruction, geminiTools, toolConfig, contents, prefixCount }) {
94
94
  return traceHash(stableTraceStringify({
95
95
  model: model || null,
96
96
  systemInstruction: systemInstruction || '',
97
97
  tools: geminiTools || [],
98
+ toolConfig: toolConfig || null,
98
99
  contents: _geminiCachePrefixContents(contents, prefixCount),
99
100
  }));
100
101
  }
101
102
 
102
- export const GEMINI_GLOBAL_CACHE_MIN_LIVE_MS = 6 * 60 * 1000;
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
107
  export const GEMINI_GLOBAL_CACHE_MAX_ENTRIES = 128;
104
108
  // Grace window before deleting a superseded cachedContents name (see the
105
109
  // cross-session race note at the delete call site in gemini.mjs). Long enough
@@ -108,15 +112,30 @@ export const GEMINI_GLOBAL_CACHE_DELETE_GRACE_MS = 2 * 60 * 1000;
108
112
  export const geminiGlobalCaches = new Map();
109
113
  export const geminiGlobalCacheCreates = new Map();
110
114
 
111
- export function _geminiGlobalCacheKey({ apiKey, model, cachePrefixHash, cachePrefixContentCount }) {
115
+ export function _geminiCredentialFingerprint(apiKey) {
116
+ return traceHash(String(apiKey || ''));
117
+ }
118
+
119
+ export function _geminiGlobalCacheKey({ credentialFingerprint, model, cachePrefixHash, cachePrefixContentCount }) {
112
120
  return traceHash(stableTraceStringify({
113
- apiKeyHash: traceHash(apiKey || ''),
121
+ credentialFingerprint: credentialFingerprint || null,
114
122
  model: model || null,
115
123
  cachePrefixHash,
116
124
  cachePrefixContentCount,
117
125
  }));
118
126
  }
119
127
 
128
+ export function _invalidateGeminiCachesForCredentialFingerprint(credentialFingerprint) {
129
+ if (!credentialFingerprint) return 0;
130
+ let removed = 0;
131
+ for (const [key, entry] of geminiGlobalCaches) {
132
+ if (entry?.cacheCredentialFingerprint !== credentialFingerprint) continue;
133
+ geminiGlobalCaches.delete(key);
134
+ removed += 1;
135
+ }
136
+ return removed;
137
+ }
138
+
120
139
  export function _pruneGeminiGlobalCaches(now = Date.now()) {
121
140
  for (const [key, entry] of geminiGlobalCaches) {
122
141
  if (!entry?.cacheName || Number(entry.cacheExpiresAt || 0) <= now) {
@@ -172,6 +191,7 @@ export function _attachGeminiCacheState(opts, entry, currentIter) {
172
191
  cacheTokenSize: entry.cacheTokenSize,
173
192
  cachePrefixContentCount: entry.cachePrefixContentCount,
174
193
  cachePrefixHash: entry.cachePrefixHash,
194
+ cacheCredentialFingerprint: entry.cacheCredentialFingerprint,
175
195
  },
176
196
  };
177
197
  }