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.
- package/package.json +8 -1
- package/scripts/_bench-cwc.json +20 -0
- package/scripts/agent-loop-policy-test.mjs +37 -0
- package/scripts/agent-parallel-smoke.mjs +54 -10
- package/scripts/background-task-meta-smoke.mjs +1 -1
- package/scripts/bench-run.mjs +262 -0
- package/scripts/compact-smoke.mjs +12 -0
- package/scripts/compact-trigger-migration-smoke.mjs +67 -1
- package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
- package/scripts/internal-comms-bench.mjs +727 -0
- package/scripts/internal-comms-smoke.mjs +75 -0
- package/scripts/lead-workflow-smoke.mjs +4 -4
- package/scripts/live-worker-smoke.mjs +9 -9
- package/scripts/output-style-bench.mjs +285 -0
- package/scripts/output-style-smoke.mjs +13 -10
- package/scripts/patch-replay.mjs +90 -0
- package/scripts/provider-stream-stall-test.mjs +276 -0
- package/scripts/provider-toolcall-test.mjs +599 -1
- package/scripts/routing-corpus.mjs +281 -0
- package/scripts/session-bench.mjs +1526 -0
- package/scripts/session-diag.mjs +595 -0
- package/scripts/task-bench.mjs +207 -0
- package/scripts/tool-failures.mjs +6 -6
- package/scripts/tool-smoke.mjs +306 -66
- package/scripts/toolcall-args-test.mjs +81 -0
- package/src/agents/debugger/AGENT.md +4 -4
- package/src/agents/heavy-worker/AGENT.md +4 -2
- package/src/agents/reviewer/AGENT.md +4 -4
- package/src/agents/worker/AGENT.md +4 -2
- package/src/app.mjs +10 -6
- package/src/defaults/{hidden-roles.json → agents.json} +7 -7
- package/src/examples/schedules/SCHEDULE.example.md +32 -0
- package/src/examples/webhooks/WEBHOOK.example.md +40 -0
- package/src/headless-role.mjs +14 -14
- package/src/help.mjs +1 -0
- package/src/lib/rules-builder.cjs +32 -54
- package/src/mixdog-session-runtime.mjs +710 -318
- package/src/output-styles/default.md +12 -7
- package/src/output-styles/minimal.md +25 -0
- package/src/output-styles/oneline.md +21 -0
- package/src/output-styles/simple.md +10 -9
- package/src/repl.mjs +12 -2
- package/src/rules/agent/00-common.md +7 -5
- package/src/rules/agent/30-explorer.md +7 -8
- package/src/rules/lead/01-general.md +3 -1
- package/src/rules/lead/lead-tool.md +7 -0
- package/src/rules/shared/01-tool.md +17 -12
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
- package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
- package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
- package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
- package/src/runtime/agent/orchestrator/config.mjs +3 -0
- package/src/runtime/agent/orchestrator/context/collect.mjs +131 -67
- package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
- package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
- package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -16
- package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +332 -57
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +59 -32
- package/src/runtime/agent/orchestrator/providers/api-usage.mjs +27 -20
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
- package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +210 -21
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +78 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +202 -98
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +183 -20
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
- package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +11 -5
- package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +15 -9
- package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
- package/src/runtime/agent/orchestrator/session/loop.mjs +394 -132
- package/src/runtime/agent/orchestrator/session/manager.mjs +217 -170
- package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
- package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
- package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
- package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -24
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
- package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +0 -0
- package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
- package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
- package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +13 -4
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +18 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
- package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -1
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
- package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4076 -3985
- package/src/runtime/agent/orchestrator/tools/patch.mjs +116 -2
- package/src/runtime/channels/backends/discord.mjs +99 -9
- package/src/runtime/channels/backends/telegram.mjs +501 -0
- package/src/runtime/channels/index.mjs +441 -1224
- package/src/runtime/channels/lib/config.mjs +54 -2
- package/src/runtime/channels/lib/format.mjs +4 -2
- package/src/runtime/channels/lib/output-forwarder.mjs +80 -67
- package/src/runtime/channels/lib/runtime-paths.mjs +29 -0
- package/src/runtime/channels/lib/scheduler.mjs +1 -1
- package/src/runtime/channels/lib/telegram-format.mjs +283 -0
- package/src/runtime/channels/lib/tool-format.mjs +1 -1
- package/src/runtime/channels/lib/transcript-discovery.mjs +19 -1
- package/src/runtime/channels/lib/webhook.mjs +59 -31
- package/src/runtime/channels/tool-defs.mjs +1 -1
- package/src/runtime/memory/index.mjs +184 -19
- package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
- package/src/runtime/memory/lib/core-memory-store.mjs +1 -1
- package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
- package/src/runtime/memory/lib/memory-cycle2.mjs +9 -6
- package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
- package/src/runtime/memory/lib/memory.mjs +101 -4
- package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
- package/src/runtime/memory/lib/session-ingest.mjs +107 -0
- package/src/runtime/memory/lib/trace-store.mjs +69 -22
- package/src/runtime/memory/tool-defs.mjs +6 -3
- package/src/runtime/shared/channel-notification-routing.mjs +12 -0
- package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
- package/src/runtime/shared/config.mjs +9 -0
- package/src/runtime/shared/llm/http-agent.mjs +12 -5
- package/src/runtime/shared/schedules-store.mjs +21 -19
- package/src/runtime/shared/tool-surface.mjs +98 -13
- package/src/runtime/shared/transcript-writer.mjs +129 -0
- package/src/runtime/shared/update-checker.mjs +214 -0
- package/src/standalone/agent-tool.mjs +255 -109
- package/src/standalone/channel-admin.mjs +133 -40
- package/src/standalone/channel-worker.mjs +8 -291
- package/src/standalone/explore-tool.mjs +2 -2
- package/src/standalone/memory-runtime-proxy.mjs +3 -1
- package/src/standalone/provider-admin.mjs +11 -0
- package/src/standalone/usage-dashboard.mjs +1 -1
- package/src/tui/App.jsx +2096 -732
- package/src/tui/components/ConfirmBar.jsx +47 -0
- package/src/tui/components/ContextPanel.jsx +5 -3
- package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
- package/src/tui/components/Markdown.jsx +22 -98
- package/src/tui/components/Message.jsx +14 -35
- package/src/tui/components/Picker.jsx +87 -12
- package/src/tui/components/PromptInput.jsx +83 -7
- package/src/tui/components/QueuedCommands.jsx +1 -1
- package/src/tui/components/SlashCommandPalette.jsx +8 -5
- package/src/tui/components/Spinner.jsx +7 -7
- package/src/tui/components/StatusLine.jsx +40 -21
- package/src/tui/components/TextEntryPanel.jsx +51 -7
- package/src/tui/components/ToolExecution.jsx +170 -98
- package/src/tui/components/TurnDone.jsx +4 -4
- package/src/tui/components/UsagePanel.jsx +1 -1
- package/src/tui/components/tool-output-format.mjs +159 -21
- package/src/tui/components/tool-output-format.test.mjs +87 -0
- package/src/tui/display-width.mjs +69 -0
- package/src/tui/display-width.test.mjs +35 -0
- package/src/tui/dist/index.mjs +6965 -2391
- package/src/tui/engine.mjs +287 -126
- package/src/tui/index.jsx +117 -7
- package/src/tui/keyboard-protocol.mjs +42 -0
- package/src/tui/lib/voice-recorder.mjs +453 -0
- package/src/tui/markdown/format-token.mjs +129 -76
- package/src/tui/markdown/format-token.test.mjs +61 -19
- package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
- package/src/tui/markdown/render-ansi.test.mjs +1 -1
- package/src/tui/markdown/streaming-markdown.mjs +167 -0
- package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
- package/src/tui/markdown/table-layout.mjs +9 -9
- package/src/tui/paste-attachments.mjs +0 -11
- package/src/tui/prompt-history-store.mjs +129 -0
- package/src/tui/prompt-history-store.test.mjs +52 -0
- package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
- package/src/tui/theme.mjs +41 -657
- package/src/tui/themes/base.mjs +86 -0
- package/src/tui/themes/basic.mjs +85 -0
- package/src/tui/themes/catppuccin.mjs +72 -0
- package/src/tui/themes/dracula.mjs +70 -0
- package/src/tui/themes/everforest.mjs +71 -0
- package/src/tui/themes/gruvbox.mjs +71 -0
- package/src/tui/themes/index.mjs +71 -0
- package/src/tui/themes/indigo.mjs +78 -0
- package/src/tui/themes/kanagawa.mjs +80 -0
- package/src/tui/themes/light.mjs +81 -0
- package/src/tui/themes/nord.mjs +72 -0
- package/src/tui/themes/onedark.mjs +16 -0
- package/src/tui/themes/rosepine.mjs +70 -0
- package/src/tui/themes/teal.mjs +81 -0
- package/src/tui/themes/tokyonight.mjs +79 -0
- package/src/tui/themes/utils.mjs +106 -0
- package/src/tui/themes/warm.mjs +79 -0
- package/src/tui/transcript-tool-failures.mjs +13 -2
- package/src/ui/markdown.mjs +1 -1
- package/src/ui/model-display.mjs +2 -2
- package/src/ui/statusline.mjs +26 -27
- package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
- package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
- package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
- package/src/workflows/default/WORKFLOW.md +39 -12
- package/src/workflows/sequential/WORKFLOW.md +46 -0
- package/src/workflows/solo/WORKFLOW.md +7 -0
- package/vendor/ink/build/display-width.js +62 -0
- package/vendor/ink/build/ink.js +100 -12
- package/vendor/ink/build/measure-text.js +4 -1
- package/vendor/ink/build/output.js +115 -9
- package/vendor/ink/build/render-node-to-output.js +4 -1
- package/vendor/ink/build/render.js +4 -0
- package/src/output-styles/extreme-simple.md +0 -20
- package/src/rules/lead/04-workflow.md +0 -51
- package/src/workflows/default/workflow.json +0 -13
- 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
|
-
//
|
|
302
|
-
//
|
|
303
|
-
|
|
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
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
991
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
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).
|
|
1047
|
-
//
|
|
1048
|
-
//
|
|
1049
|
-
//
|
|
1050
|
-
// the
|
|
1051
|
-
|
|
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 (
|
|
1105
|
-
|
|
1106
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
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 };
|