mixdog 0.9.1 → 0.9.2

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 (214) hide show
  1. package/package.json +8 -1
  2. package/scripts/_bench-cwc.json +20 -0
  3. package/scripts/agent-loop-policy-test.mjs +37 -0
  4. package/scripts/agent-parallel-smoke.mjs +54 -10
  5. package/scripts/background-task-meta-smoke.mjs +1 -1
  6. package/scripts/bench-run.mjs +262 -0
  7. package/scripts/compact-smoke.mjs +12 -0
  8. package/scripts/compact-trigger-migration-smoke.mjs +67 -1
  9. package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
  10. package/scripts/internal-comms-bench.mjs +727 -0
  11. package/scripts/internal-comms-smoke.mjs +75 -0
  12. package/scripts/lead-workflow-smoke.mjs +4 -4
  13. package/scripts/live-worker-smoke.mjs +9 -9
  14. package/scripts/output-style-bench.mjs +285 -0
  15. package/scripts/output-style-smoke.mjs +13 -10
  16. package/scripts/patch-replay.mjs +90 -0
  17. package/scripts/provider-stream-stall-test.mjs +276 -0
  18. package/scripts/provider-toolcall-test.mjs +599 -1
  19. package/scripts/routing-corpus.mjs +281 -0
  20. package/scripts/session-bench.mjs +1526 -0
  21. package/scripts/session-diag.mjs +595 -0
  22. package/scripts/task-bench.mjs +207 -0
  23. package/scripts/tool-failures.mjs +6 -6
  24. package/scripts/tool-smoke.mjs +306 -66
  25. package/scripts/toolcall-args-test.mjs +81 -0
  26. package/src/agents/debugger/AGENT.md +4 -4
  27. package/src/agents/heavy-worker/AGENT.md +4 -2
  28. package/src/agents/reviewer/AGENT.md +4 -4
  29. package/src/agents/worker/AGENT.md +4 -2
  30. package/src/app.mjs +10 -6
  31. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  32. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  33. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  34. package/src/headless-role.mjs +14 -14
  35. package/src/help.mjs +1 -0
  36. package/src/lib/rules-builder.cjs +32 -54
  37. package/src/mixdog-session-runtime.mjs +710 -318
  38. package/src/output-styles/default.md +12 -7
  39. package/src/output-styles/minimal.md +25 -0
  40. package/src/output-styles/oneline.md +21 -0
  41. package/src/output-styles/simple.md +10 -9
  42. package/src/repl.mjs +12 -2
  43. package/src/rules/agent/00-common.md +7 -5
  44. package/src/rules/agent/30-explorer.md +7 -8
  45. package/src/rules/lead/01-general.md +3 -1
  46. package/src/rules/lead/lead-tool.md +7 -0
  47. package/src/rules/shared/01-tool.md +17 -12
  48. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  49. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  50. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  51. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  52. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  53. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  54. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  55. package/src/runtime/agent/orchestrator/context/collect.mjs +131 -67
  56. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  57. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  58. package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -16
  59. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  60. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  61. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  62. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +332 -57
  63. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +59 -32
  64. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +27 -20
  65. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  66. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  67. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  68. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +210 -21
  69. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +78 -3
  70. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +202 -98
  71. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +183 -20
  72. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  73. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +11 -5
  74. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  75. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +15 -9
  76. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  77. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  78. package/src/runtime/agent/orchestrator/session/loop.mjs +394 -132
  79. package/src/runtime/agent/orchestrator/session/manager.mjs +217 -170
  80. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  81. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  82. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  83. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  84. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -24
  85. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  86. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  87. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +0 -0
  88. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  89. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  90. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  91. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  92. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  93. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +13 -4
  94. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  95. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +18 -2
  96. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  97. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  98. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -1
  99. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  100. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4076 -3985
  101. package/src/runtime/agent/orchestrator/tools/patch.mjs +116 -2
  102. package/src/runtime/channels/backends/discord.mjs +99 -9
  103. package/src/runtime/channels/backends/telegram.mjs +501 -0
  104. package/src/runtime/channels/index.mjs +441 -1224
  105. package/src/runtime/channels/lib/config.mjs +54 -2
  106. package/src/runtime/channels/lib/format.mjs +4 -2
  107. package/src/runtime/channels/lib/output-forwarder.mjs +80 -67
  108. package/src/runtime/channels/lib/runtime-paths.mjs +29 -0
  109. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  110. package/src/runtime/channels/lib/telegram-format.mjs +283 -0
  111. package/src/runtime/channels/lib/tool-format.mjs +1 -1
  112. package/src/runtime/channels/lib/transcript-discovery.mjs +19 -1
  113. package/src/runtime/channels/lib/webhook.mjs +59 -31
  114. package/src/runtime/channels/tool-defs.mjs +1 -1
  115. package/src/runtime/memory/index.mjs +184 -19
  116. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  117. package/src/runtime/memory/lib/core-memory-store.mjs +1 -1
  118. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  119. package/src/runtime/memory/lib/memory-cycle2.mjs +9 -6
  120. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  121. package/src/runtime/memory/lib/memory.mjs +101 -4
  122. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  123. package/src/runtime/memory/lib/session-ingest.mjs +107 -0
  124. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  125. package/src/runtime/memory/tool-defs.mjs +6 -3
  126. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  127. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  128. package/src/runtime/shared/config.mjs +9 -0
  129. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  130. package/src/runtime/shared/schedules-store.mjs +21 -19
  131. package/src/runtime/shared/tool-surface.mjs +98 -13
  132. package/src/runtime/shared/transcript-writer.mjs +129 -0
  133. package/src/runtime/shared/update-checker.mjs +214 -0
  134. package/src/standalone/agent-tool.mjs +255 -109
  135. package/src/standalone/channel-admin.mjs +133 -40
  136. package/src/standalone/channel-worker.mjs +8 -291
  137. package/src/standalone/explore-tool.mjs +2 -2
  138. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  139. package/src/standalone/provider-admin.mjs +11 -0
  140. package/src/standalone/usage-dashboard.mjs +1 -1
  141. package/src/tui/App.jsx +2096 -732
  142. package/src/tui/components/ConfirmBar.jsx +47 -0
  143. package/src/tui/components/ContextPanel.jsx +5 -3
  144. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  145. package/src/tui/components/Markdown.jsx +22 -98
  146. package/src/tui/components/Message.jsx +14 -35
  147. package/src/tui/components/Picker.jsx +87 -12
  148. package/src/tui/components/PromptInput.jsx +83 -7
  149. package/src/tui/components/QueuedCommands.jsx +1 -1
  150. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  151. package/src/tui/components/Spinner.jsx +7 -7
  152. package/src/tui/components/StatusLine.jsx +40 -21
  153. package/src/tui/components/TextEntryPanel.jsx +51 -7
  154. package/src/tui/components/ToolExecution.jsx +170 -98
  155. package/src/tui/components/TurnDone.jsx +4 -4
  156. package/src/tui/components/UsagePanel.jsx +1 -1
  157. package/src/tui/components/tool-output-format.mjs +159 -21
  158. package/src/tui/components/tool-output-format.test.mjs +87 -0
  159. package/src/tui/display-width.mjs +69 -0
  160. package/src/tui/display-width.test.mjs +35 -0
  161. package/src/tui/dist/index.mjs +6965 -2391
  162. package/src/tui/engine.mjs +287 -126
  163. package/src/tui/index.jsx +117 -7
  164. package/src/tui/keyboard-protocol.mjs +42 -0
  165. package/src/tui/lib/voice-recorder.mjs +453 -0
  166. package/src/tui/markdown/format-token.mjs +129 -76
  167. package/src/tui/markdown/format-token.test.mjs +61 -19
  168. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  169. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  170. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  171. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  172. package/src/tui/markdown/table-layout.mjs +9 -9
  173. package/src/tui/paste-attachments.mjs +0 -11
  174. package/src/tui/prompt-history-store.mjs +129 -0
  175. package/src/tui/prompt-history-store.test.mjs +52 -0
  176. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  177. package/src/tui/theme.mjs +41 -657
  178. package/src/tui/themes/base.mjs +86 -0
  179. package/src/tui/themes/basic.mjs +85 -0
  180. package/src/tui/themes/catppuccin.mjs +72 -0
  181. package/src/tui/themes/dracula.mjs +70 -0
  182. package/src/tui/themes/everforest.mjs +71 -0
  183. package/src/tui/themes/gruvbox.mjs +71 -0
  184. package/src/tui/themes/index.mjs +71 -0
  185. package/src/tui/themes/indigo.mjs +78 -0
  186. package/src/tui/themes/kanagawa.mjs +80 -0
  187. package/src/tui/themes/light.mjs +81 -0
  188. package/src/tui/themes/nord.mjs +72 -0
  189. package/src/tui/themes/onedark.mjs +16 -0
  190. package/src/tui/themes/rosepine.mjs +70 -0
  191. package/src/tui/themes/teal.mjs +81 -0
  192. package/src/tui/themes/tokyonight.mjs +79 -0
  193. package/src/tui/themes/utils.mjs +106 -0
  194. package/src/tui/themes/warm.mjs +79 -0
  195. package/src/tui/transcript-tool-failures.mjs +13 -2
  196. package/src/ui/markdown.mjs +1 -1
  197. package/src/ui/model-display.mjs +2 -2
  198. package/src/ui/statusline.mjs +26 -27
  199. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  200. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  201. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  202. package/src/workflows/default/WORKFLOW.md +39 -12
  203. package/src/workflows/sequential/WORKFLOW.md +46 -0
  204. package/src/workflows/solo/WORKFLOW.md +7 -0
  205. package/vendor/ink/build/display-width.js +62 -0
  206. package/vendor/ink/build/ink.js +100 -12
  207. package/vendor/ink/build/measure-text.js +4 -1
  208. package/vendor/ink/build/output.js +115 -9
  209. package/vendor/ink/build/render-node-to-output.js +4 -1
  210. package/vendor/ink/build/render.js +4 -0
  211. package/src/output-styles/extreme-simple.md +0 -20
  212. package/src/rules/lead/04-workflow.md +0 -51
  213. package/src/workflows/default/workflow.json +0 -13
  214. package/src/workflows/solo/workflow.json +0 -7
@@ -21,11 +21,13 @@ import { enrichModels } from './model-catalog.mjs';
21
21
  import { makeModelCache } from './model-cache.mjs';
22
22
  import { sanitizeToolPairs, sanitizeAnthropicContentPairs } from '../session/context-utils.mjs';
23
23
  import {
24
+ PROVIDER_FIRST_BYTE_TIMEOUT_MS,
24
25
  PROVIDER_HTTP_RESPONSE_TIMEOUT_MS,
25
26
  PROVIDER_RETRY_BACKOFF_MS,
26
27
  PROVIDER_RETRY_MAX_ATTEMPTS,
27
- PROVIDER_SSE_IDLE_TIMEOUT_MS,
28
28
  PROVIDER_SSE_IDLE_WATCHDOG_ENABLED,
29
+ PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS,
30
+ streamStalledError,
29
31
  createPassthroughSignal,
30
32
  } from '../stall-policy.mjs';
31
33
  import {
@@ -38,14 +40,23 @@ import {
38
40
  withRetry,
39
41
  } from './retry-classifier.mjs';
40
42
  import { buildAnthropicBetaHeaders, supportsAnthropicFastMode } from './anthropic-betas.mjs';
43
+ import {
44
+ applyAnthropicEffortToBody,
45
+ effortValuesForModel,
46
+ shouldIncludeEffortBeta,
47
+ } from './anthropic-effort.mjs';
41
48
  import { getLlmDispatcher, preconnect } from '../../../shared/llm/http-agent.mjs';
42
49
  import { normalizeContentForAnthropic } from './media-normalization.mjs';
43
50
  import { makeInvalidToolArgsMarker } from './openai-compat-stream.mjs';
51
+ import { scanLeakedToolCalls, createToolCallDedupe } from './anthropic-leaked-toolcall.mjs';
44
52
 
45
53
  // --- Model catalog cache helpers ---
46
54
  // Disk-backed cache so repeated process starts (cron, tool calls) don't
47
55
  // hammer /v1/models. 24h TTL matches the upstream client cadence.
48
56
  const MODEL_CACHE_TTL_MS = 24 * 60 * 60_000;
57
+ // Bump when the on-disk cache shape changes so stale-shape entries are
58
+ // discarded instead of misread (mirrors openai-oauth's schema-version gate).
59
+ const ANTHROPIC_MODEL_CACHE_SCHEMA_VERSION = 1;
49
60
  // SSE progress emits (per-request "Response …" and "Done:" lines). Off by default.
50
61
  const SSE_VERBOSE = process.env.MIXDOG_SSE_VERBOSE === '1';
51
62
 
@@ -64,6 +75,7 @@ const SSE_MIDSTREAM_POLICY = {
64
75
  };
65
76
 
66
77
  function formatRetryAfter(ms) {
78
+ if (ms == null) return '';
67
79
  const n = Number(ms);
68
80
  if (!Number.isFinite(n) || n < 0) return '';
69
81
  if (n >= 60_000 && n % 60_000 === 0) return `${Math.round(n / 60_000)}m`;
@@ -93,6 +105,7 @@ function anthropicQuotaError(status, headers, bodyText = '') {
93
105
  const _modelCache = makeModelCache({
94
106
  fileName: 'anthropic-oauth-models.json',
95
107
  ttlMs: MODEL_CACHE_TTL_MS,
108
+ version: ANTHROPIC_MODEL_CACHE_SCHEMA_VERSION,
96
109
  onSave: (m) => { _inMemoryCatalog = Array.isArray(m) ? m.slice() : null; },
97
110
  });
98
111
 
@@ -134,6 +147,10 @@ function _displayModel(id) {
134
147
  return `claude-${m[1].toLowerCase()}-${m[2]}${m[3] ? `.${m[3]}` : ''}`;
135
148
  }
136
149
 
150
+ function _capabilitySupported(capability) {
151
+ return capability === true || capability?.supported === true;
152
+ }
153
+
137
154
  // Classify a model id into our common tier/family shape. Anthropic's catalog
138
155
  // mixes dated ids (claude-opus-4-5-20251101), versioned aliases
139
156
  // (claude-opus-4-6), and the raw family tokens resolved via env vars.
@@ -150,15 +167,19 @@ function _normalizeAnthropicModel(raw) {
150
167
  const releaseDate = dated
151
168
  ? id.match(/-(\d{4})(\d{2})(\d{2})$/)
152
169
  : null;
170
+ const effortValues = effortValuesForModel(raw?.capabilities, id);
153
171
  return {
154
172
  id,
155
173
  display: raw?.display_name || _prettyName(id, family),
156
174
  family,
157
175
  provider: 'anthropic-oauth',
158
- contextWindow: raw?.context_window || raw?.max_context_window || _defaultContextForModel(id, family),
176
+ contextWindow: raw?.context_window || raw?.max_context_window || raw?.max_input_tokens || _defaultContextForModel(id, family),
177
+ outputTokens: raw?.max_tokens || raw?.max_output_tokens || null,
159
178
  tier,
160
179
  latest: false, // assigned in a second pass once full list is known
161
180
  releaseDate: releaseDate ? `${releaseDate[1]}-${releaseDate[2]}-${releaseDate[3]}` : null,
181
+ supportsReasoning: effortValues.length > 0 || _capabilitySupported(raw?.capabilities?.thinking),
182
+ reasoningOptions: effortValues.length ? [{ type: 'effort', values: effortValues }] : [],
162
183
  };
163
184
  }
164
185
 
@@ -298,9 +319,11 @@ function resolveCliVersion() {
298
319
  }
299
320
 
300
321
  function requiresSystemPrefix(model) {
301
- // Opus / Sonnet require the OAuth system prefix when authenticated
302
- // via OAuth. Haiku does not.
303
- return /^claude-(opus|sonnet)/i.test(String(model || ''));
322
+ // High-tier Claude OAuth models require the first-party system prefix for
323
+ // OAuth pool routing. Haiku does not; keep every other Claude family (Opus,
324
+ // Sonnet, Fable, and future non-Haiku families) on the prefixed path.
325
+ const id = String(model || '').toLowerCase();
326
+ return /^claude-/.test(id) && !/^claude-haiku(?:-|$)/.test(id);
304
327
  }
305
328
 
306
329
  // OAuth rate-limit pool routing is gated by the server inspecting the first
@@ -363,22 +386,77 @@ const MAX_TOKENS = {
363
386
  'claude-haiku-4-5-20251001': 8192,
364
387
  };
365
388
 
366
- function resolveMaxTokens(model) {
389
+ // Sanity floor/ceiling for resolveMaxTokens. Catalog-reported outputTokens
390
+ // (from the Anthropic API, cached to disk) can be trusted above these
391
+ // hardcoded fallbacks, but we still clamp to a safety cap so a bad/huge
392
+ // catalog value can't blow the thinking+output budget, and floor so a
393
+ // missing/zero catalog value never degenerates to an unusable cap.
394
+ const MAX_TOKENS_FLOOR = 8192;
395
+ const DEFAULT_SAFETY_CAP = 65536;
396
+ // Parse MIXDOG_ANTHROPIC_MAX_OUTPUT_TOKENS strictly: only a finite positive
397
+ // number is a valid override. Invalid values ("0", negatives, garbage,
398
+ // whitespace) are treated as unset — NOT as "use the default cap outright" —
399
+ // so resolveMaxTokens still consults catalog/fallback for low-cap models.
400
+ function _envMaxOutputOverride() {
401
+ const raw = process.env.MIXDOG_ANTHROPIC_MAX_OUTPUT_TOKENS;
402
+ if (raw == null || String(raw).trim() === '') return null;
403
+ const n = Number(raw);
404
+ if (!Number.isFinite(n) || n <= 0) return null;
405
+ return Math.floor(n);
406
+ }
407
+
408
+ // Catalog-reported outputTokens for a model id, read from the in-memory
409
+ // catalog mirror (lazily populated from the disk cache if the mirror hasn't
410
+ // been warmed yet by listModels()). Never throws — any failure just means
411
+ // "no catalog data", and callers fall back to the static heuristics below.
412
+ function _catalogOutputTokens(model) {
413
+ if (!model) return null;
414
+ try {
415
+ if (!Array.isArray(_inMemoryCatalog)) {
416
+ const cached = _modelCache.loadSync();
417
+ if (Array.isArray(cached)) _inMemoryCatalog = cached.slice();
418
+ }
419
+ if (!Array.isArray(_inMemoryCatalog)) return null;
420
+ const entry = _inMemoryCatalog.find(m => m?.id === model);
421
+ const out = Number(entry?.outputTokens);
422
+ return Number.isFinite(out) && out > 0 ? out : null;
423
+ } catch {
424
+ return null;
425
+ }
426
+ }
427
+
428
+ function _fallbackMaxTokens(model) {
367
429
  if (MAX_TOKENS[model]) return MAX_TOKENS[model];
368
430
  const id = String(model || '').toLowerCase();
369
431
  if (id.includes('opus')) return 65536;
432
+ if (id.includes('fable')) return 65536;
433
+ // Sonnet 5+ ships a much larger output budget than the legacy 4.x line
434
+ // (this is the claude-sonnet-5 fix: 16384 was starving visible output
435
+ // once extended thinking ate into the same hard cap). Keep sonnet-4-x
436
+ // conservative at 16384; only bump 5+.
437
+ const sonnetVersion = id.match(/^claude-sonnet-(\d+)/);
438
+ if (sonnetVersion) return Number(sonnetVersion[1]) >= 5 ? 65536 : 16384;
370
439
  if (id.includes('sonnet')) return 16384;
371
440
  if (id.includes('haiku')) return 8192;
372
441
  return 8192;
373
442
  }
374
443
 
375
- const EFFORT_BUDGET = {
376
- low: 1024,
377
- medium: 4096,
378
- high: 16384,
379
- xhigh: 32768,
380
- max: 32768,
381
- };
444
+ // resolveMaxTokens: catalog-driven max_tokens for a model id.
445
+ // 1. MIXDOG_ANTHROPIC_MAX_OUTPUT_TOKENS env override, if set, wins outright.
446
+ // 2. Catalog outputTokens (trusted over hardcoded heuristics when present),
447
+ // clamped to [MAX_TOKENS_FLOOR, safetyCap].
448
+ // 3. Static MAX_TOKENS table / family heuristic fallback when the catalog
449
+ // has no entry for this model, also clamped to the safety cap.
450
+ function resolveMaxTokens(model) {
451
+ const envOverride = _envMaxOutputOverride();
452
+ if (envOverride != null) return envOverride;
453
+ const safetyCap = DEFAULT_SAFETY_CAP;
454
+ const catalogValue = _catalogOutputTokens(model);
455
+ if (catalogValue != null) {
456
+ return Math.max(MAX_TOKENS_FLOOR, Math.min(catalogValue, safetyCap));
457
+ }
458
+ return Math.min(_fallbackMaxTokens(model), safetyCap);
459
+ }
382
460
 
383
461
  const MIN_THINKING_BUDGET = 1024;
384
462
  const THINKING_OUTPUT_RESERVE = 1024;
@@ -392,10 +470,6 @@ function clampThinkingBudgetTokens(value, maxTokens) {
392
470
  return Math.max(MIN_THINKING_BUDGET, Math.min(desired, ceiling));
393
471
  }
394
472
 
395
- // Tracks which unknown effort labels we've already logged so a repeated
396
- // session-level misconfig doesn't flood stderr with the same warning.
397
- const _LOGGED_UNKNOWN_EFFORT = new Set();
398
-
399
473
  // Layered cache TTLs — stable layers get 1h, volatile layers get 5m.
400
474
  // Anthropic requires 1h entries to appear before 5m entries in the request.
401
475
  const CACHE_TTL_STABLE = { type: 'ephemeral', ttl: '1h' }; // tools, system, tier3, messages
@@ -938,10 +1012,23 @@ function _anthropicSseError(event) {
938
1012
  return err;
939
1013
  }
940
1014
 
941
- async function parseSSEStream(response, signal, abortStream, onStreamDelta, onToolCall, state, onTextDelta) {
1015
+ async function parseSSEStream(response, signal, abortStream, onStreamDelta, onToolCall, state, onTextDelta, knownToolNames) {
942
1016
  const reader = response.body.getReader();
943
1017
  const decoder = new TextDecoder();
944
- const SSE_IDLE_TIMEOUT_MS = PROVIDER_SSE_IDLE_TIMEOUT_MS;
1018
+ // SEMANTIC idle window: reset only by real model events (message/content/
1019
+ // tool deltas), NOT by raw keepalive bytes. A ping-only wedge therefore
1020
+ // trips this within the window instead of hanging until the 30-min agent
1021
+ // watchdog. See resetIdleTimer + the per-event reset in the loop below.
1022
+ // state.semanticIdleTimeoutMs is a test/override seam (same shape as
1023
+ // firstMessageTimeoutMs); production uses the shared env-backed default.
1024
+ const SSE_IDLE_TIMEOUT_MS = Number.isFinite(Number(state?.semanticIdleTimeoutMs))
1025
+ && Number(state.semanticIdleTimeoutMs) > 0
1026
+ ? Number(state.semanticIdleTimeoutMs)
1027
+ : PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS;
1028
+ const SSE_FIRST_MESSAGE_TIMEOUT_MS = Number.isFinite(Number(state?.firstMessageTimeoutMs))
1029
+ && Number(state.firstMessageTimeoutMs) > 0
1030
+ ? Number(state.firstMessageTimeoutMs)
1031
+ : PROVIDER_FIRST_BYTE_TIMEOUT_MS;
945
1032
  let content = '';
946
1033
  let hasThinkingContent = false;
947
1034
  const contentBlockTypes = new Set();
@@ -951,16 +1038,136 @@ async function parseSSEStream(response, signal, abortStream, onStreamDelta, onTo
951
1038
  let stopReason = null;
952
1039
  let buffer = '';
953
1040
  let idleTimedOut = false;
1041
+ let firstMessageTimedOut = false;
954
1042
  let idleTimer = null;
1043
+ let firstMessageTimer = null;
955
1044
  let currentEvent = '';
956
1045
 
957
1046
  const pendingToolInputs = new Map();
958
1047
 
1048
+ // Leaked tool-call guard. The model (esp. Opus via OAuth) occasionally
1049
+ // emits a tool call as plain text tags inside `text_delta` instead of a
1050
+ // native `tool_use` block. `leakBuffer` is a minimal rolling window that
1051
+ // only holds back text when a partial sentinel prefix is present, so a
1052
+ // tag split across chunk boundaries is still detected while ordinary text
1053
+ // still streams promptly. The guard is additive: the native tool_use path
1054
+ // (content_block_start/input_json_delta/content_block_stop) is untouched.
1055
+ const _knownTools = knownToolNames instanceof Set
1056
+ ? knownToolNames
1057
+ : new Set(Array.isArray(knownToolNames) ? knownToolNames : []);
1058
+ const _leakGuardEnabled = _knownTools.size > 0;
1059
+ const _isKnownTool = (name) => _knownTools.has(name);
1060
+ let leakBuffer = '';
1061
+ // Running markdown fence/inline-code state threaded across text_delta
1062
+ // chunks (Fix 1): a tool-call tag inside a ```code fence``` or inline span
1063
+ // is a doc example, not a real call — the guard emits it as text.
1064
+ let leakFenceState = undefined;
1065
+ // Cross-path fingerprint dedupe (Fix 2): a synthesized text-leaked call and
1066
+ // an identical native tool_use block must dispatch onToolCall exactly once.
1067
+ const _toolDedupe = createToolCallDedupe();
1068
+
1069
+ // Synthesize + dispatch a recovered leaked call exactly like the native
1070
+ // content_block_stop path (push into toolCalls, flag state, eager
1071
+ // onToolCall). A generated id mirrors the `toolu_`-prefixed native shape.
1072
+ const dispatchLeakedCall = (recovered) => {
1073
+ let args = recovered?.arguments;
1074
+ if (args === null || typeof args !== 'object' || Array.isArray(args)) args = {};
1075
+ // Skip if an identical native (or prior synthetic) call already fired.
1076
+ if (!_toolDedupe.shouldDispatch(recovered.name, args)) return;
1077
+ const call = {
1078
+ id: `toolu_leaked_${randomBytes(8).toString('hex')}`,
1079
+ name: recovered.name,
1080
+ arguments: args,
1081
+ };
1082
+ toolCalls.push(call);
1083
+ if (state) state.emittedToolCall = true;
1084
+ try { onToolCall?.(call); } catch {}
1085
+ try { onStreamDelta?.(); } catch {}
1086
+ };
1087
+
1088
+ // Feed accumulated text through the scanner. On `final` nothing is held
1089
+ // back so legitimate text is never lost at stream end.
1090
+ const pumpLeakBuffer = (final) => {
1091
+ if (!_leakGuardEnabled) return;
1092
+ if (!leakBuffer && !final) return;
1093
+ const { emit, calls, rest, fenceState } = scanLeakedToolCalls(leakBuffer, { isKnownTool: _isKnownTool, final, fenceState: leakFenceState });
1094
+ leakBuffer = rest;
1095
+ leakFenceState = fenceState;
1096
+ if (emit) {
1097
+ content += emit;
1098
+ if (onTextDelta) {
1099
+ if (state) state.emittedText = true;
1100
+ try { onTextDelta(emit); } catch {}
1101
+ }
1102
+ }
1103
+ for (const c of calls) dispatchLeakedCall(c);
1104
+ };
1105
+
959
1106
  // Holds the in-flight reader.read() race rejector so the idle timer can
960
1107
  // force-unblock the loop even when reader.cancel() fails to settle the
961
1108
  // pending read (undici half-open socket). See resetIdleTimer below.
962
1109
  let idleReject = null;
963
1110
 
1111
+ const firstMessageTimeoutError = () => {
1112
+ const err = new Error(`Anthropic OAuth SSE stream produced no message_start within ${SSE_FIRST_MESSAGE_TIMEOUT_MS}ms`);
1113
+ err.code = 'EEMPTYSTREAM';
1114
+ err.isEmptyStream = true;
1115
+ err.firstByteTimeout = true;
1116
+ return err;
1117
+ };
1118
+
1119
+ const clearFirstMessageTimer = () => {
1120
+ if (firstMessageTimer) {
1121
+ clearTimeout(firstMessageTimer);
1122
+ firstMessageTimer = null;
1123
+ }
1124
+ };
1125
+
1126
+ const armFirstMessageTimer = () => {
1127
+ if (!(SSE_FIRST_MESSAGE_TIMEOUT_MS > 0)) return;
1128
+ clearFirstMessageTimer();
1129
+ firstMessageTimer = setTimeout(() => {
1130
+ if (state?.sawMessageStart) return;
1131
+ firstMessageTimedOut = true;
1132
+ const err = firstMessageTimeoutError();
1133
+ try { abortStream?.(err); } catch (abortErr) {
1134
+ try { process.stderr.write(`[anthropic-oauth] sse first-message abortStream failed: ${abortErr?.message ?? String(abortErr)}\n`); } catch {}
1135
+ }
1136
+ try {
1137
+ const _c = reader.cancel('SSE first message timeout');
1138
+ if (_c && typeof _c.catch === 'function') _c.catch(() => {});
1139
+ } catch (cancelErr) {
1140
+ try { process.stderr.write(`[anthropic-oauth] sse first-message cancel failed: ${cancelErr?.message ?? String(cancelErr)}\n`); } catch {}
1141
+ }
1142
+ if (idleReject) {
1143
+ const r = idleReject; idleReject = null; r(err);
1144
+ }
1145
+ }, SSE_FIRST_MESSAGE_TIMEOUT_MS);
1146
+ try { firstMessageTimer.unref?.(); } catch {}
1147
+ };
1148
+
1149
+ // Attach the partial stream state to a mid-stream stall error so the agent
1150
+ // loop can decide SUCCESS vs FAILURE. The recurring "worker finished but
1151
+ // owner never notified" case is a FINAL no-tool summary stream that wedges
1152
+ // ping-only after the real work (tool calls) already completed in earlier
1153
+ // iterations: there is streamed `content`, no pending tool_use, and no
1154
+ // emitted tool call this iteration. The loop treats that as a successful
1155
+ // partial-final (deliver the summary we have) instead of dropping it. A
1156
+ // stall WITH a pending/emitted tool call stays a hard failure (a tool whose
1157
+ // input never completed must never be reported as done).
1158
+ const _attachStallPartial = (err) => {
1159
+ try {
1160
+ err.partialContent = content;
1161
+ err.partialToolCalls = toolCalls.length ? toolCalls.slice() : undefined;
1162
+ err.pendingToolUse = pendingToolInputs.size > 0;
1163
+ err.partialModel = model || undefined;
1164
+ err.partialUsage = usage;
1165
+ err.partialStopReason = stopReason || undefined;
1166
+ err.partialHasThinking = hasThinkingContent;
1167
+ } catch { /* best-effort enrichment */ }
1168
+ return err;
1169
+ };
1170
+
964
1171
  const resetIdleTimer = () => {
965
1172
  // OFF by default. When disabled the
966
1173
  // idle timer never arms, so the stream is never killed on inactivity;
@@ -983,13 +1190,14 @@ async function parseSSEStream(response, signal, abortStream, onStreamDelta, onTo
983
1190
  // pending forever and the SSE idle timeout never unblocks the loop —
984
1191
  // the 391s-hang root cause.
985
1192
  if (idleReject) {
986
- const e = new Error(`Anthropic OAuth SSE stream timed out after ${SSE_IDLE_TIMEOUT_MS}ms of inactivity`);
987
- e.code = 'ETIMEDOUT';
1193
+ const e = _attachStallPartial(streamStalledError('Anthropic OAuth SSE', SSE_IDLE_TIMEOUT_MS, { emittedToolCall: !!state?.emittedToolCall }));
988
1194
  const r = idleReject; idleReject = null; r(e);
989
1195
  }
990
- // Shared provider policy: short inter-chunk inactivity catches the
991
- // sess_9cfd11-class stuck pattern where SSE starts but then goes silent.
1196
+ // Shared provider policy: short SEMANTIC-event inactivity catches the
1197
+ // ping-only wedge where SSE starts, emits some deltas, then goes silent
1198
+ // while `:ping` keepalives keep the transport socket warm.
992
1199
  }, SSE_IDLE_TIMEOUT_MS);
1200
+ try { idleTimer.unref?.(); } catch {}
993
1201
  };
994
1202
 
995
1203
  const onAbort = () => {
@@ -1009,7 +1217,13 @@ async function parseSSEStream(response, signal, abortStream, onStreamDelta, onTo
1009
1217
  }
1010
1218
 
1011
1219
  try {
1012
- resetIdleTimer();
1220
+ // Part A / reviewer fix: do NOT arm the SEMANTIC idle timer before the
1221
+ // stream has produced its first event. A slow first response is governed
1222
+ // by armFirstMessageTimer() (first-byte window) alone; arming the
1223
+ // semantic idle here could let it win and mis-abort a legitimately slow
1224
+ // first response as a stall. The semantic idle is first armed at
1225
+ // `message_start` (see below), so it only ever guards MID-stream silence.
1226
+ armFirstMessageTimer();
1013
1227
  streamLoop: while (true) {
1014
1228
  let chunk;
1015
1229
  try {
@@ -1021,9 +1235,10 @@ async function parseSSEStream(response, signal, abortStream, onStreamDelta, onTo
1021
1235
  });
1022
1236
  } catch (err) {
1023
1237
  if (idleTimedOut) {
1024
- const idleErr = new Error(`Anthropic OAuth SSE stream timed out after ${SSE_IDLE_TIMEOUT_MS}ms of inactivity`);
1025
- idleErr.code = 'ETIMEDOUT';
1026
- throw idleErr;
1238
+ throw _attachStallPartial(streamStalledError('Anthropic OAuth SSE', SSE_IDLE_TIMEOUT_MS, { emittedToolCall: !!state?.emittedToolCall }));
1239
+ }
1240
+ if (firstMessageTimedOut) {
1241
+ throw firstMessageTimeoutError();
1027
1242
  }
1028
1243
  if (signal?.aborted) {
1029
1244
  _captureMidstreamAbort(state, signal.reason);
@@ -1036,21 +1251,25 @@ async function parseSSEStream(response, signal, abortStream, onStreamDelta, onTo
1036
1251
  const { done, value } = chunk;
1037
1252
  if (done) break;
1038
1253
 
1039
- resetIdleTimer();
1040
1254
  buffer += decoder.decode(value, { stream: true });
1041
1255
  const lines = buffer.split('\n');
1042
1256
  buffer = lines.pop() || '';
1043
1257
 
1044
1258
  for (const line of lines) {
1045
1259
  if (line.startsWith(':')) {
1046
- // SSE comment frame (Anthropic `:ping` keepalive). The HTML Standard SSE
1047
- // spec says comments are silently ignored, but we surface them here so
1048
- // the agent stall watchdog sees the stream is still alive during Opus
1049
- // extended-thinking pauses. No content is emitted — this only refreshes
1050
- // the runtime's lastStreamDeltaAt timestamp.
1051
- try { onStreamDelta?.(); } catch {}
1260
+ // SSE comment frame (Anthropic `:ping` keepalive). Keep it
1261
+ // at transport level only: comments must not refresh the
1262
+ // agent's semantic progress timestamp, or a ping-only 200
1263
+ // can look alive forever without message_start/content.
1264
+ // Crucially it also does NOT reset the SEMANTIC idle timer
1265
+ // below a ping-only wedge must trip the idle abort.
1052
1266
  continue;
1053
1267
  }
1268
+ // Blank lines are SSE record separators — emitted after EVERY
1269
+ // frame, including `:ping` keepalives — so they are NOT semantic
1270
+ // progress and must not reset the idle timer (else a ping frame's
1271
+ // trailing blank would keep a wedge alive forever).
1272
+ if (line === '') continue;
1054
1273
  if (line.startsWith('event: ')) {
1055
1274
  currentEvent = line.slice(7).trim();
1056
1275
  continue;
@@ -1062,11 +1281,24 @@ async function parseSSEStream(response, signal, abortStream, onStreamDelta, onTo
1062
1281
  try {
1063
1282
  const event = JSON.parse(data);
1064
1283
 
1284
+ // SEMANTIC idle reset (Part A): reset the idle timer ONLY for
1285
+ // real progress events, NOT for Anthropic keepalives. Anthropic
1286
+ // sends pings as a NAMED event (`event: ping` /
1287
+ // `data: {"type":"ping"}`), not just `:` comment frames, so a
1288
+ // named ping must be excluded here or a ping-only wedge keeps
1289
+ // the timer alive forever. Everything that is not a ping is a
1290
+ // genuine server event (message_start/content/tool/thinking
1291
+ // deltas, message_delta/stop, errors) and counts as progress.
1292
+ if (currentEvent !== 'ping' && event?.type !== 'ping') {
1293
+ resetIdleTimer();
1294
+ }
1295
+
1065
1296
  if (currentEvent === 'error' || event?.type === 'error' || event?.error) {
1066
1297
  throw _anthropicSseError(event);
1067
1298
  }
1068
1299
 
1069
1300
  if (event.type === 'message_start' && event.message) {
1301
+ clearFirstMessageTimer();
1070
1302
  if (state) state.sawMessageStart = true;
1071
1303
  if (event.message.model) model = event.message.model;
1072
1304
  if (event.message.usage) {
@@ -1091,8 +1323,13 @@ async function parseSSEStream(response, signal, abortStream, onStreamDelta, onTo
1091
1323
  if (event.type === 'content_block_delta') {
1092
1324
  const delta = event.delta;
1093
1325
  if (delta?.type) contentBlockTypes.add(delta.type);
1326
+ // Time-to-first-token: stamp the first content delta
1327
+ // (text / thinking / tool input_json) exactly once so
1328
+ // the SSE trace can separate first-byte latency from
1329
+ // total stream/generation time. Without this stamp
1330
+ // ttftMs was always null and reported as 0ms.
1331
+ if (state && !state.ttftAt) state.ttftAt = Date.now();
1094
1332
  if (delta?.type === 'text_delta') {
1095
- content += delta.text || '';
1096
1333
  try { onStreamDelta?.(); } catch {}
1097
1334
  // Live text relay (gateway): forward the explicit
1098
1335
  // text chunk. thinking/signature/input_json deltas
@@ -1101,9 +1338,21 @@ async function parseSSEStream(response, signal, abortStream, onStreamDelta, onTo
1101
1338
  // live it cannot be withdrawn, so flag the attempt so
1102
1339
  // the mid-stream retry loop treats any later failure
1103
1340
  // as final (a retry would concatenate attempts).
1104
- if (delta.text && onTextDelta) {
1105
- if (state) state.emittedText = true;
1106
- try { onTextDelta(delta.text); } catch {}
1341
+ if (_leakGuardEnabled) {
1342
+ // Route text through the leaked-tool-call guard.
1343
+ // It appends to `content`, forwards visible text
1344
+ // via onTextDelta, and synthesizes/dispatches any
1345
+ // recovered known-tool call — suppressing the
1346
+ // tags from the visible stream. A partial sentinel
1347
+ // is held in leakBuffer until the next chunk.
1348
+ leakBuffer += delta.text || '';
1349
+ pumpLeakBuffer(false);
1350
+ } else {
1351
+ content += delta.text || '';
1352
+ if (delta.text && onTextDelta) {
1353
+ if (state) state.emittedText = true;
1354
+ try { onTextDelta(delta.text); } catch {}
1355
+ }
1107
1356
  }
1108
1357
  }
1109
1358
  if (delta?.type === 'thinking_delta' || delta?.type === 'signature_delta') {
@@ -1159,13 +1408,25 @@ async function parseSSEStream(response, signal, abortStream, onStreamDelta, onTo
1159
1408
  name: pending.name,
1160
1409
  arguments: parsedArgs,
1161
1410
  };
1162
- toolCalls.push(call);
1163
1411
  pendingToolInputs.delete(event.index);
1164
- if (state) state.emittedToolCall = true;
1165
1412
  // Eager dispatch: let the loop start this tool
1166
1413
  // before message_stop arrives. The loop keys
1167
1414
  // pending promises by call.id so order is safe.
1168
- try { onToolCall?.(call); } catch {}
1415
+ // Fix 2: skip the ENTIRE call (push + dispatch) when a
1416
+ // text-leaked synthetic of the same (name,args) already
1417
+ // fired — otherwise the duplicate stays in `toolCalls`
1418
+ // and the loop executes the side-effecting tool twice.
1419
+ // An invalid-args marker never fingerprint-collides with
1420
+ // a real recovered call, so malformed native calls still
1421
+ // dispatch (the marker path is unaffected).
1422
+ if (_toolDedupe.shouldDispatch(call.name, call.arguments)) {
1423
+ toolCalls.push(call);
1424
+ if (state) state.emittedToolCall = true;
1425
+ // Eager dispatch: let the loop start this tool
1426
+ // before message_stop arrives. The loop keys
1427
+ // pending promises by call.id so order is safe.
1428
+ try { onToolCall?.(call); } catch {}
1429
+ }
1169
1430
  try { onStreamDelta?.(); } catch {}
1170
1431
  }
1171
1432
  }
@@ -1206,6 +1467,12 @@ async function parseSSEStream(response, signal, abortStream, onStreamDelta, onTo
1206
1467
  }
1207
1468
  }
1208
1469
 
1470
+ // Stream ended: flush any held-back leaked-tool-call buffer. `final`
1471
+ // holds nothing back, so a trailing partial sentinel that never
1472
+ // resolved into a real call is surfaced as ordinary text — legitimate
1473
+ // user-visible content is never lost on the failure path.
1474
+ pumpLeakBuffer(true);
1475
+
1209
1476
  // Truncated-stream guard: if the reader loop exited (EOF or break)
1210
1477
  // after message_start but without seeing message_stop / a tool_use
1211
1478
  // stop_reason, the assistant turn was cut off mid-flight. Returning
@@ -1242,6 +1509,7 @@ async function parseSSEStream(response, signal, abortStream, onStreamDelta, onTo
1242
1509
  };
1243
1510
  } finally {
1244
1511
  if (idleTimer) clearTimeout(idleTimer);
1512
+ clearFirstMessageTimer();
1245
1513
  if (signal) signal.removeEventListener('abort', onAbort);
1246
1514
  try { reader.releaseLock(); } catch (err) {
1247
1515
  try { process.stderr.write(`[anthropic-oauth] reader releaseLock failed: ${err?.message ?? String(err)}\n`); } catch {}
@@ -1365,21 +1633,13 @@ function buildRequestBody(messages, model, tools, sendOpts) {
1365
1633
  body.tools = [...nativeTools, ...toAnthropicTools([...(tools || []), ...deferredTools])];
1366
1634
  }
1367
1635
 
1368
- const thinkingBudgetTokens = Number(opts.thinkingBudgetTokens);
1369
- if (Number.isFinite(thinkingBudgetTokens) && thinkingBudgetTokens > 0) {
1370
- const budgetTokens = clampThinkingBudgetTokens(thinkingBudgetTokens, maxTokens);
1371
- if (budgetTokens) body.thinking = { type: 'enabled', budget_tokens: budgetTokens };
1372
- } else if (opts.effort) {
1373
- if (EFFORT_BUDGET[opts.effort]) {
1374
- const budgetTokens = clampThinkingBudgetTokens(EFFORT_BUDGET[opts.effort], maxTokens);
1375
- if (budgetTokens) body.thinking = { type: 'enabled', budget_tokens: budgetTokens };
1376
- } else if (!_LOGGED_UNKNOWN_EFFORT.has(opts.effort)) {
1377
- _LOGGED_UNKNOWN_EFFORT.add(opts.effort);
1378
- try {
1379
- process.stderr.write(`[anthropic-oauth] unknown effort=${opts.effort} ignored (known: ${Object.keys(EFFORT_BUDGET).join(',')})\n`);
1380
- } catch {}
1381
- }
1382
- }
1636
+ applyAnthropicEffortToBody(body, {
1637
+ model,
1638
+ opts,
1639
+ maxTokens,
1640
+ clampThinkingBudgetTokens,
1641
+ logTag: 'anthropic-oauth',
1642
+ });
1383
1643
 
1384
1644
  if (opts.fast === true && supportsAnthropicFastMode(model)) {
1385
1645
  body.speed = 'fast';
@@ -1530,6 +1790,15 @@ export class AnthropicOAuthProvider {
1530
1790
  if (body.speed === 'fast') {
1531
1791
  this.fastModeBetaHeaderLatched = true;
1532
1792
  }
1793
+ // Known tool names for the leaked-tool-call guard in parseSSEStream:
1794
+ // recovered leaked calls are only synthesized when they name a tool
1795
+ // actually offered to this request (native + lowered). Derived from the
1796
+ // final request body so it matches exactly what the model was given.
1797
+ const knownToolNames = new Set(
1798
+ (Array.isArray(body.tools) ? body.tools : [])
1799
+ .map((t) => (t && typeof t.name === 'string' ? t.name : null))
1800
+ .filter(Boolean),
1801
+ );
1533
1802
  const sessionId = opts.sessionId || null;
1534
1803
  const iteration = Number.isFinite(Number(opts.iteration)) ? Number(opts.iteration) : null;
1535
1804
  // Option A: no absolute wall-clock cap on streaming generation. A stream
@@ -1598,6 +1867,7 @@ export class AnthropicOAuthProvider {
1598
1867
  base: OAUTH_BETA_HEADERS,
1599
1868
  fastMode: this.fastModeBetaHeaderLatched,
1600
1869
  toolSearch: true,
1870
+ effort: shouldIncludeEffortBeta(useModel, opts),
1601
1871
  }),
1602
1872
  'anthropic-dangerous-direct-browser-access': 'true',
1603
1873
  'user-agent': `claude-cli/${resolveCliVersion()} (external, sdk-cli)`,
@@ -1758,11 +2028,12 @@ export class AnthropicOAuthProvider {
1758
2028
  const result = await parseSSEFn(
1759
2029
  response,
1760
2030
  controller.signal,
1761
- () => controller.abort(),
2031
+ (reason) => controller.abort(reason),
1762
2032
  onStreamDelta,
1763
2033
  onToolCall,
1764
2034
  midState,
1765
2035
  onTextDelta,
2036
+ knownToolNames,
1766
2037
  );
1767
2038
 
1768
2039
  const ttftMs = midState.ttftAt ? midState.ttftAt - sseStartedAt : null;
@@ -2214,3 +2485,7 @@ export async function loginOAuth() {
2214
2485
  // Lets the SSE parser be exercised in isolation against a synthetic
2215
2486
  // ReadableStream without needing a live OAuth session.
2216
2487
  export { parseSSEStream };
2488
+
2489
+ // Test-only escape hatch for scripts/tool-smoke.mjs to verify the
2490
+ // catalog-driven max-tokens resolution without duplicating its logic.
2491
+ export const _test = { resolveMaxTokens };