mixdog 0.9.3 → 0.9.4
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 +7 -3
- package/scripts/bench/lead-review-tasks-r3.json +20 -0
- package/scripts/bench/lead-review-tasks.json +20 -0
- package/scripts/bench/r4-mixed-tasks.json +20 -0
- package/scripts/bench/review-tasks.json +20 -0
- package/scripts/bench/round-codex.json +114 -0
- package/scripts/bench/round-mixdog-lead-r3.json +269 -0
- package/scripts/bench/round-mixdog-lead.json +269 -0
- package/scripts/bench/round-mixdog.json +126 -0
- package/scripts/bench/round-r10-bigsample.json +679 -0
- package/scripts/bench/round-r11-codexalign.json +257 -0
- package/scripts/bench/round-r4-codex.json +114 -0
- package/scripts/bench/round-r4-mixed.json +225 -0
- package/scripts/bench/round-r5-gpt-lead.json +259 -0
- package/scripts/bench/round-r6-codex.json +114 -0
- package/scripts/bench/round-r6-solo.json +257 -0
- package/scripts/bench/round-r7-full.json +254 -0
- package/scripts/bench/round-r8-fulldefault.json +255 -0
- package/scripts/bench-run.mjs +215 -29
- package/scripts/freevar-smoke.mjs +95 -0
- package/scripts/internal-comms-bench.mjs +1 -0
- package/scripts/internal-comms-smoke.mjs +10 -9
- package/scripts/mouse-probe.mjs +45 -0
- package/scripts/output-style-bench.mjs +13 -6
- package/scripts/output-style-smoke.mjs +4 -4
- package/scripts/provider-toolcall-test.mjs +7 -3
- package/scripts/recall-usecase-cases.json +18 -0
- package/scripts/recall-usecase-probe.json +6 -0
- package/scripts/session-bench.mjs +152 -6
- package/scripts/tool-smoke.mjs +23 -63
- package/scripts/tui-render-smoke.mjs +90 -0
- package/scripts/webhook-smoke.mjs +208 -0
- package/src/agents/debugger/AGENT.md +4 -1
- package/src/agents/heavy-worker/AGENT.md +6 -5
- package/src/agents/maintainer/AGENT.md +4 -0
- package/src/agents/reviewer/AGENT.md +2 -1
- package/src/agents/worker/AGENT.md +8 -4
- package/src/lib/rules-builder.cjs +4 -0
- package/src/mixdog-session-runtime.mjs +632 -2042
- package/src/output-styles/default.md +34 -9
- package/src/output-styles/{oneline.md → extreme-minimal.md} +5 -4
- package/src/output-styles/minimal.md +4 -1
- package/src/output-styles/simple.md +22 -7
- package/src/rules/agent/00-common.md +2 -0
- package/src/rules/lead/lead-brief.md +12 -0
- package/src/rules/lead/lead-tool.md +0 -11
- package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +25 -0
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +100 -23
- package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +6 -15
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +362 -0
- package/src/runtime/agent/orchestrator/agent-trace-io.mjs +410 -0
- package/src/runtime/agent/orchestrator/agent-trace.mjs +16 -735
- package/src/runtime/agent/orchestrator/config.mjs +69 -2
- package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +62 -20
- package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +209 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +489 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +81 -1281
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +607 -0
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +32 -3
- package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +81 -0
- package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +248 -0
- package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +303 -0
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +505 -0
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +43 -1013
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +17 -3
- package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +86 -11
- package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +348 -0
- package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +108 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-trace.mjs +58 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +368 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +760 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +40 -1143
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +732 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-login.mjs +193 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +297 -2123
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +130 -1002
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +227 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-events.mjs +67 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +436 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1105 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +2 -1
- package/src/runtime/agent/orchestrator/session/compact/budget.mjs +288 -0
- package/src/runtime/agent/orchestrator/session/compact/constants.mjs +85 -0
- package/src/runtime/agent/orchestrator/session/compact/engine.mjs +749 -0
- package/src/runtime/agent/orchestrator/session/compact/messages.mjs +82 -0
- package/src/runtime/agent/orchestrator/session/compact/summary-schema.mjs +315 -0
- package/src/runtime/agent/orchestrator/session/compact/summary.mjs +643 -0
- package/src/runtime/agent/orchestrator/session/compact/text-utils.mjs +326 -0
- package/src/runtime/agent/orchestrator/session/compact.mjs +40 -2282
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +14 -2
- package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +61 -0
- package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +1 -3
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +182 -0
- package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +173 -0
- package/src/runtime/agent/orchestrator/session/loop/termination.mjs +58 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +239 -0
- package/src/runtime/agent/orchestrator/session/loop.mjs +251 -397
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +471 -0
- package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +7 -4
- package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +12 -0
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +406 -0
- package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +80 -0
- package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +210 -0
- package/src/runtime/agent/orchestrator/session/manager.mjs +166 -1087
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +189 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +74 -179
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +70 -20
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +22 -2
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +32 -41
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +40 -0
- package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +29 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +8 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +126 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +81 -92
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +161 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +108 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +28 -265
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +0 -6
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +57 -3
- package/src/runtime/agent/orchestrator/tools/code-graph/keyword-match.mjs +82 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +10 -122
- package/src/runtime/agent/orchestrator/tools/code-graph/text-columns.mjs +45 -0
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +6 -6
- package/src/runtime/agent/orchestrator/tools/graph-binary-fetcher.mjs +6 -3
- package/src/runtime/agent/orchestrator/tools/patch/constants.mjs +9 -0
- package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +171 -0
- package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +471 -0
- package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +436 -0
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +342 -0
- package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +359 -0
- package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +340 -0
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +643 -0
- package/src/runtime/agent/orchestrator/tools/patch.mjs +36 -2959
- package/src/runtime/agent/orchestrator/tools/progress-message.mjs +0 -21
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -72
- package/src/runtime/agent/orchestrator/tools/shell-powershell.mjs +77 -0
- package/src/runtime/agent/orchestrator/tools/shell-state.mjs +154 -0
- package/src/runtime/channels/backends/discord-access.mjs +32 -0
- package/src/runtime/channels/backends/discord-attachments.mjs +65 -0
- package/src/runtime/channels/backends/discord-gateway.mjs +233 -0
- package/src/runtime/channels/backends/discord.mjs +12 -292
- package/src/runtime/channels/index.mjs +229 -663
- package/src/runtime/channels/lib/backend-dispatch.mjs +44 -0
- package/src/runtime/channels/lib/event-pipeline.mjs +18 -1
- package/src/runtime/channels/lib/event-queue.mjs +63 -4
- package/src/runtime/channels/lib/inbound-routing.mjs +111 -0
- package/src/runtime/channels/lib/output-forwarder.mjs +1 -1
- package/src/runtime/channels/lib/owner-heartbeat.mjs +75 -0
- package/src/runtime/channels/lib/parent-bridge.mjs +88 -0
- package/src/runtime/channels/lib/runtime-paths.mjs +14 -4
- package/src/runtime/channels/lib/session-discovery.mjs +56 -4
- package/src/runtime/channels/lib/tool-dispatch.mjs +158 -0
- package/src/runtime/channels/lib/tool-format.mjs +1 -1
- package/src/runtime/channels/lib/transcript-discovery.mjs +4 -4
- package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +6 -3
- package/src/runtime/channels/lib/voice-transcription.mjs +179 -0
- package/src/runtime/channels/lib/webhook/deliveries.mjs +312 -0
- package/src/runtime/channels/lib/webhook/log.mjs +42 -0
- package/src/runtime/channels/lib/webhook/ngrok.mjs +181 -0
- package/src/runtime/channels/lib/webhook/signature.mjs +60 -0
- package/src/runtime/channels/lib/webhook.mjs +36 -570
- package/src/runtime/channels/tool-defs.mjs +11 -130
- package/src/runtime/memory/index.mjs +201 -1948
- package/src/runtime/memory/lib/cycle-llm-adapters.mjs +58 -0
- package/src/runtime/memory/lib/cycle-scheduler.mjs +497 -0
- package/src/runtime/memory/lib/embedding-warmup.mjs +58 -0
- package/src/runtime/memory/lib/memory-config-flags.mjs +91 -0
- package/src/runtime/memory/lib/memory-cycle.mjs +1 -1
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +515 -0
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +324 -0
- package/src/runtime/memory/lib/memory-cycle2-shared.mjs +18 -0
- package/src/runtime/memory/lib/memory-cycle2.mjs +24 -842
- package/src/runtime/memory/lib/memory-embed.mjs +149 -0
- package/src/runtime/memory/lib/memory-process-lock.mjs +162 -0
- package/src/runtime/memory/lib/memory-recall-store.mjs +22 -2
- package/src/runtime/memory/lib/pg/supervisor.mjs +1 -1
- package/src/runtime/memory/lib/query-handlers.mjs +780 -0
- package/src/runtime/memory/lib/recall-format.mjs +55 -0
- package/src/runtime/memory/lib/runtime-fetcher.mjs +8 -3
- package/src/runtime/memory/lib/transcript-ingest.mjs +425 -0
- package/src/runtime/memory/tool-defs.mjs +5 -13
- package/src/runtime/search/lib/http-fetch.mjs +274 -0
- package/src/runtime/search/lib/ssrf-guard.mjs +333 -0
- package/src/runtime/search/lib/web-tools.mjs +24 -602
- package/src/runtime/shared/atomic-file.mjs +26 -1
- package/src/runtime/shared/launcher-control.mjs +2 -2
- package/src/runtime/shared/tool-primitives.mjs +308 -0
- package/src/runtime/shared/tool-result-summary.mjs +515 -0
- package/src/runtime/shared/tool-surface.mjs +80 -898
- package/src/runtime/shared/transcript-writer.mjs +23 -0
- package/src/runtime/shared/update-checker.mjs +7 -4
- package/src/session-runtime/config-helpers.mjs +84 -2
- package/src/session-runtime/config-lifecycle.mjs +232 -0
- package/src/session-runtime/cwd-plugins.mjs +226 -0
- package/src/session-runtime/mcp-glue.mjs +177 -0
- package/src/session-runtime/model-recency.mjs +111 -0
- package/src/session-runtime/native-search.mjs +247 -0
- package/src/session-runtime/output-styles.mjs +11 -9
- package/src/session-runtime/prewarm.mjs +142 -0
- package/src/session-runtime/provider-models.mjs +278 -0
- package/src/session-runtime/provider-usage.mjs +120 -0
- package/src/session-runtime/quick-model-rows.mjs +170 -0
- package/src/session-runtime/quick-search-models.mjs +46 -0
- package/src/session-runtime/session-hooks.mjs +93 -0
- package/src/session-runtime/settings-api.mjs +319 -0
- package/src/session-runtime/tool-catalog.mjs +29 -29
- package/src/session-runtime/tool-defs.mjs +84 -0
- package/src/session-runtime/warmup-schedulers.mjs +201 -0
- package/src/standalone/agent-tool/helpers.mjs +237 -0
- package/src/standalone/agent-tool/notify.mjs +107 -0
- package/src/standalone/agent-tool/provider-init.mjs +143 -0
- package/src/standalone/agent-tool/render.mjs +152 -0
- package/src/standalone/agent-tool/tool-def.mjs +55 -0
- package/src/standalone/agent-tool.mjs +110 -671
- package/src/standalone/channel-worker.mjs +4 -7
- package/src/standalone/explore-tool.mjs +30 -9
- package/src/standalone/hook-bus/config.mjs +207 -0
- package/src/standalone/hook-bus/constants.mjs +90 -0
- package/src/standalone/hook-bus/handlers.mjs +481 -0
- package/src/standalone/hook-bus/payload.mjs +31 -0
- package/src/standalone/hook-bus/rules.mjs +77 -0
- package/src/standalone/hook-bus.mjs +77 -870
- package/src/standalone/memory-runtime-proxy.mjs +7 -0
- package/src/standalone/opencode-go-login.mjs +5 -1
- package/src/standalone/provider-admin.mjs +1 -16
- package/src/standalone/usage-dashboard.mjs +3 -1
- package/src/tui/App.jsx +945 -8094
- package/src/tui/app/app-format.mjs +206 -0
- package/src/tui/app/channel-pickers.mjs +510 -0
- package/src/tui/app/clipboard.mjs +67 -0
- package/src/tui/app/core-memory-picker.mjs +210 -0
- package/src/tui/app/extension-pickers.mjs +506 -0
- package/src/tui/app/input-parsers.mjs +193 -0
- package/src/tui/app/maintenance-pickers.mjs +324 -0
- package/src/tui/app/model-options.mjs +330 -0
- package/src/tui/app/model-picker.mjs +365 -0
- package/src/tui/app/onboarding-steps.mjs +400 -0
- package/src/tui/app/project-picker.mjs +247 -0
- package/src/tui/app/provider-setup-picker.mjs +580 -0
- package/src/tui/app/resume-picker.mjs +55 -0
- package/src/tui/app/route-pickers.mjs +419 -0
- package/src/tui/app/settings-picker.mjs +490 -0
- package/src/tui/app/slash-commands.mjs +101 -0
- package/src/tui/app/slash-dispatch.mjs +427 -0
- package/src/tui/app/text-layout.mjs +46 -0
- package/src/tui/app/theme-effort-pickers.mjs +154 -0
- package/src/tui/app/transcript-window.mjs +671 -0
- package/src/tui/app/use-mouse-input.mjs +460 -0
- package/src/tui/app/use-prompt-handlers.mjs +310 -0
- package/src/tui/app/use-transcript-scroll.mjs +510 -0
- package/src/tui/app/use-transcript-window.mjs +589 -0
- package/src/tui/components/ConfirmBar.jsx +1 -1
- package/src/tui/components/Picker.jsx +32 -4
- package/src/tui/components/PromptInput.jsx +23 -101
- package/src/tui/components/SlashCommandPalette.jsx +8 -1
- package/src/tui/components/StatusLine.jsx +63 -12
- package/src/tui/components/TextEntryPanel.jsx +11 -0
- package/src/tui/components/ToolExecution.jsx +52 -594
- package/src/tui/components/TranscriptItem.jsx +105 -0
- package/src/tui/components/UsagePanel.jsx +18 -4
- package/src/tui/components/prompt-input/edit-helpers.mjs +72 -0
- package/src/tui/components/prompt-input/voice-indicator.mjs +39 -0
- package/src/tui/components/tool-execution/ResultBody.jsx +56 -0
- package/src/tui/components/tool-execution/surface-detail.mjs +405 -0
- package/src/tui/components/tool-execution/text-format.mjs +161 -0
- package/src/tui/display-width.mjs +20 -3
- package/src/tui/dist/index.mjs +19652 -18630
- package/src/tui/engine/agent-job-feed.mjs +133 -0
- package/src/tui/engine/notification-plan.mjs +76 -0
- package/src/tui/engine/render-timing.mjs +17 -0
- package/src/tui/engine/tool-approval.mjs +94 -0
- package/src/tui/engine/tool-card-results.mjs +234 -0
- package/src/tui/engine/tool-result-status.mjs +135 -0
- package/src/tui/engine.mjs +122 -562
- package/src/tui/figures.mjs +5 -0
- package/src/tui/index.jsx +105 -0
- package/src/tui/input-editing.mjs +2 -2
- package/src/tui/markdown/format-token.mjs +4 -1
- package/src/tui/statusline-ansi-bridge.mjs +11 -3
- package/src/tui/theme.mjs +6 -0
- package/src/ui/statusline-agents.mjs +213 -0
- package/src/ui/statusline-format.mjs +146 -0
- package/src/ui/statusline-segments.mjs +148 -0
- package/src/ui/statusline.mjs +67 -501
- package/src/ui/tool-card.mjs +0 -1
- package/src/vendor/statusline/bin/statusline-route.mjs +15 -2
- package/src/workflows/default/WORKFLOW.md +1 -1
- package/src/workflows/sequential/WORKFLOW.md +1 -1
- package/vendor/ink/build/display-width.js +19 -3
- package/vendor/ink/build/ink.js +103 -6
- package/vendor/ink/build/log-update.js +17 -3
- package/vendor/ink/build/wrap-text.js +125 -0
- package/scripts/_test-folder-dialog.mjs +0 -30
- package/scripts/fix-brief-fn.mjs +0 -35
- package/scripts/fix-format-tool-surface.mjs +0 -24
- package/scripts/fix-tool-exec-visible.mjs +0 -42
- package/scripts/patch-agent-brief.mjs +0 -48
- package/scripts/patch-app.mjs +0 -21
- package/scripts/patch-app2.mjs +0 -18
- package/scripts/patch-dist-brief.mjs +0 -96
- package/scripts/patch-tool-exec.mjs +0 -70
- package/src/examples/schedules/SCHEDULE.example.md +0 -32
- package/src/examples/webhooks/WEBHOOK.example.md +0 -40
- package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +0 -107
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +0 -143
- package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -285
- package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.test.mjs +0 -162
- package/src/runtime/agent/orchestrator/tools/builtin/open-config-tool.mjs +0 -26
- package/src/runtime/shared/channel-notification-routing.test.mjs +0 -45
- package/src/runtime/shared/task-notification-envelope.test.mjs +0 -107
- package/src/runtime/shared/tool-execution-contract.test.mjs +0 -183
- package/src/standalone/agent-task-status.test.mjs +0 -76
- package/src/tui/components/tool-output-format.test.mjs +0 -399
- package/src/tui/display-width.test.mjs +0 -35
- package/src/tui/engine-runtime-notification.test.mjs +0 -115
- package/src/tui/engine-tool-result-text.test.mjs +0 -75
- package/src/tui/input-editing.selection.test.mjs +0 -75
- package/src/tui/markdown/format-token.test.mjs +0 -354
- package/src/tui/markdown/render-ansi.test.mjs +0 -108
- package/src/tui/markdown/stream-fence.test.mjs +0 -26
- package/src/tui/markdown/streaming-markdown.test.mjs +0 -70
- package/src/tui/paste-fix.test.mjs +0 -119
- package/src/tui/prompt-history-store.test.mjs +0 -52
- package/src/tui/statusline-ansi-bridge.test.mjs +0 -159
- package/src/tui/transcript-tool-failures.test.mjs +0 -111
- package/src/ui/markdown.test.mjs +0 -70
- package/src/ui/statusline-context-label.test.mjs +0 -15
- package/src/vendor/statusline/bin/statusline-lib.mjs +0 -186
- package/src/vendor/statusline/bin/statusline-route.test.mjs +0 -80
|
@@ -5,41 +5,58 @@
|
|
|
5
5
|
* Raw HTTP + SSE streaming, reuses message/tool conversion patterns
|
|
6
6
|
* from anthropic.mjs. agent-trace instrumented.
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
9
|
-
import { join } from 'path';
|
|
10
|
-
import { createServer } from 'http';
|
|
11
|
-
import { randomBytes, createHash } from 'crypto';
|
|
8
|
+
import { randomBytes } from 'crypto';
|
|
12
9
|
import {
|
|
13
10
|
traceAgentFetch,
|
|
14
11
|
traceAgentSse,
|
|
15
12
|
traceAgentUsage,
|
|
16
13
|
} from '../agent-trace.mjs';
|
|
17
14
|
import { createAbortController } from '../../../shared/abort-controller.mjs';
|
|
18
|
-
import { writeJsonAtomicSync } from '../../../shared/atomic-file.mjs';
|
|
19
|
-
import { resolvePluginData } from '../../../shared/plugin-paths.mjs';
|
|
20
|
-
import { enrichModels } from './model-catalog.mjs';
|
|
21
|
-
import { makeModelCache } from './model-cache.mjs';
|
|
22
15
|
import { resolveAnthropicMaxTokens } from './anthropic-max-tokens.mjs';
|
|
16
|
+
import {
|
|
17
|
+
_loadModelCache,
|
|
18
|
+
_setInMemoryCatalog,
|
|
19
|
+
_catalogHas,
|
|
20
|
+
_displayModel,
|
|
21
|
+
_catalogOutputTokens,
|
|
22
|
+
normalizeAndSaveCatalog,
|
|
23
|
+
resolveLatestAnthropicModel,
|
|
24
|
+
resolveAnthropicModelAfter404,
|
|
25
|
+
ensureLatestAnthropicModel,
|
|
26
|
+
} from './anthropic-model-resolve.mjs';
|
|
23
27
|
import { sanitizeToolPairs, sanitizeAnthropicContentPairs } from '../session/context-utils.mjs';
|
|
28
|
+
import {
|
|
29
|
+
TOKEN_REFRESH_SKEW_MS,
|
|
30
|
+
resolveCliVersion,
|
|
31
|
+
loadCredentials,
|
|
32
|
+
hasAnthropicOAuthCredentials,
|
|
33
|
+
describeAnthropicOAuthCredentials,
|
|
34
|
+
forgetAnthropicOAuthCredentials,
|
|
35
|
+
_scrubTokens,
|
|
36
|
+
_credentialsMaxMtime,
|
|
37
|
+
refreshOAuthCredentials,
|
|
38
|
+
beginOAuthLogin,
|
|
39
|
+
loginOAuth,
|
|
40
|
+
} from './anthropic-oauth-credentials.mjs';
|
|
24
41
|
import {
|
|
25
42
|
PROVIDER_FIRST_BYTE_TIMEOUT_MS,
|
|
26
43
|
PROVIDER_HTTP_RESPONSE_TIMEOUT_MS,
|
|
27
44
|
PROVIDER_RETRY_BACKOFF_MS,
|
|
28
45
|
PROVIDER_RETRY_MAX_ATTEMPTS,
|
|
29
|
-
PROVIDER_SSE_IDLE_WATCHDOG_ENABLED,
|
|
30
|
-
PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS,
|
|
31
|
-
streamStalledError,
|
|
32
46
|
createPassthroughSignal,
|
|
33
47
|
} from '../stall-policy.mjs';
|
|
34
48
|
import {
|
|
35
49
|
classifyError,
|
|
36
|
-
classifyMidstreamError,
|
|
37
50
|
midstreamBackoffFor,
|
|
38
|
-
MIDSTREAM_RETRY_POLICY,
|
|
39
51
|
retryAfterMsFromError,
|
|
40
|
-
sleepWithAbort,
|
|
41
52
|
withRetry,
|
|
42
53
|
} from './retry-classifier.mjs';
|
|
54
|
+
import {
|
|
55
|
+
ANTHROPIC_MAX_MIDSTREAM_RETRIES,
|
|
56
|
+
parseSSEStream,
|
|
57
|
+
_classifyMidstreamError,
|
|
58
|
+
_midstreamSleepWithAbort,
|
|
59
|
+
} from './anthropic-sse.mjs';
|
|
43
60
|
import { buildAnthropicBetaHeaders, supportsAnthropicFastMode } from './anthropic-betas.mjs';
|
|
44
61
|
import {
|
|
45
62
|
applyAnthropicEffortToBody,
|
|
@@ -48,33 +65,11 @@ import {
|
|
|
48
65
|
} from './anthropic-effort.mjs';
|
|
49
66
|
import { getLlmDispatcher, preconnect } from '../../../shared/llm/http-agent.mjs';
|
|
50
67
|
import { normalizeContentForAnthropic } from './media-normalization.mjs';
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
// --- Model catalog cache helpers ---
|
|
55
|
-
// Disk-backed cache so repeated process starts (cron, tool calls) don't
|
|
56
|
-
// hammer /v1/models. 24h TTL matches the upstream client cadence.
|
|
57
|
-
const MODEL_CACHE_TTL_MS = 24 * 60 * 60_000;
|
|
58
|
-
// Bump when the on-disk cache shape changes so stale-shape entries are
|
|
59
|
-
// discarded instead of misread.
|
|
60
|
-
const ANTHROPIC_MODEL_CACHE_SCHEMA_VERSION = 1;
|
|
68
|
+
|
|
69
|
+
// --- Model catalog cache helpers: extracted to anthropic-model-resolve.mjs ---
|
|
61
70
|
// SSE progress emits (per-request "Response …" and "Done:" lines). Off by default.
|
|
62
71
|
const SSE_VERBOSE = process.env.MIXDOG_SSE_VERBOSE === '1';
|
|
63
72
|
|
|
64
|
-
/** Bounded mid-stream SSE retries (transient stream loss); shared with anthropic.mjs.
|
|
65
|
-
* Sourced from the single shared retry-budget table (MIDSTREAM_RETRY_POLICY.sse). */
|
|
66
|
-
export const ANTHROPIC_MAX_MIDSTREAM_RETRIES = MIDSTREAM_RETRY_POLICY.sse.defaultRetries;
|
|
67
|
-
|
|
68
|
-
// Policy passed to the shared classifyMidstreamError for the SSE path. The
|
|
69
|
-
// top-of-function attempt-budget gate uses defaultRetries (3); perClassifierGate
|
|
70
|
-
// is false so the classifier returns raw bucket strings (the loop owns the
|
|
71
|
-
// MAX_MIDSTREAM_RETRIES bound), matching the former _classifyMidstreamError.
|
|
72
|
-
const SSE_MIDSTREAM_POLICY = {
|
|
73
|
-
mode: 'sse',
|
|
74
|
-
defaultRetries: MIDSTREAM_RETRY_POLICY.sse.defaultRetries,
|
|
75
|
-
perClassifierGate: false,
|
|
76
|
-
};
|
|
77
|
-
|
|
78
73
|
function formatRetryAfter(ms) {
|
|
79
74
|
if (ms == null) return '';
|
|
80
75
|
const n = Number(ms);
|
|
@@ -103,26 +98,6 @@ function anthropicQuotaError(status, headers, bodyText = '') {
|
|
|
103
98
|
return err;
|
|
104
99
|
}
|
|
105
100
|
|
|
106
|
-
const _modelCache = makeModelCache({
|
|
107
|
-
fileName: 'anthropic-oauth-models.json',
|
|
108
|
-
ttlMs: MODEL_CACHE_TTL_MS,
|
|
109
|
-
version: ANTHROPIC_MODEL_CACHE_SCHEMA_VERSION,
|
|
110
|
-
onSave: (m) => { _inMemoryCatalog = Array.isArray(m) ? m.slice() : null; },
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
// Async wrappers so callers can keep awaiting; the shared cache CRUD is sync.
|
|
114
|
-
async function _loadModelCache() {
|
|
115
|
-
return _modelCache.loadSync();
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
async function _saveModelCache(models) {
|
|
119
|
-
_modelCache.save(models);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// In-memory mirror of the disk catalog — populated on first listModels() and
|
|
123
|
-
// refreshed after every _saveModelCache. Used by _catalogHas and _displayModel
|
|
124
|
-
// so hot paths don't hit disk on every response.
|
|
125
|
-
let _inMemoryCatalog = null;
|
|
126
101
|
let _modelRefreshInFlight = null;
|
|
127
102
|
let _oauthRefreshInFlight = null;
|
|
128
103
|
// No in-memory credential cache: the canonical credentials file is the
|
|
@@ -132,190 +107,14 @@ let _oauthRefreshInFlight = null;
|
|
|
132
107
|
// disk on demand is cheap (one stat + one small JSON parse) and removes
|
|
133
108
|
// the cache-vs-disk skew entirely.
|
|
134
109
|
|
|
135
|
-
|
|
136
|
-
function _catalogHas(id) {
|
|
137
|
-
if (!id || !Array.isArray(_inMemoryCatalog)) return false;
|
|
138
|
-
return _inMemoryCatalog.some(m => m.id === id);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// Display-name normalization for trace / usage. Turns dated or version-alias
|
|
142
|
-
// ids into the version alias form: claude-opus-4-7 → claude-opus-4.7,
|
|
143
|
-
// claude-haiku-4-5-20251001 → claude-haiku-4.5. Falls back to the raw id.
|
|
144
|
-
function _displayModel(id) {
|
|
145
|
-
if (!id || typeof id !== 'string') return id;
|
|
146
|
-
const m = id.match(/^claude-([a-z]+)-(\d+)(?:-(\d+))?(?:-\d{8})?$/i);
|
|
147
|
-
if (!m) return id;
|
|
148
|
-
return `claude-${m[1].toLowerCase()}-${m[2]}${m[3] ? `.${m[3]}` : ''}`;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function _capabilitySupported(capability) {
|
|
152
|
-
return capability === true || capability?.supported === true;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// Classify a model id into our common tier/family shape. Anthropic's catalog
|
|
156
|
-
// mixes dated ids (claude-opus-4-5-20251101), versioned aliases
|
|
157
|
-
// (claude-opus-4-6), and the raw family tokens resolved via env vars.
|
|
158
|
-
function _normalizeAnthropicModel(raw) {
|
|
159
|
-
const id = raw?.id || raw?.name;
|
|
160
|
-
if (!id) return null;
|
|
161
|
-
const familyMatch = id.match(/^claude-([a-z]+)/i);
|
|
162
|
-
const family = familyMatch ? familyMatch[1].toLowerCase() : 'other';
|
|
163
|
-
// Dated: trailing -YYYYMMDD (8 digits).
|
|
164
|
-
const dated = /-\d{8}$/.test(id);
|
|
165
|
-
// Versioned alias: claude-<family>-<major>-<minor>[-...] with no dated suffix.
|
|
166
|
-
const versioned = !dated && /^claude-[a-z]+-\d+(?:-\d+)?$/i.test(id);
|
|
167
|
-
const tier = dated ? 'dated' : versioned ? 'version' : 'family';
|
|
168
|
-
const releaseDate = dated
|
|
169
|
-
? id.match(/-(\d{4})(\d{2})(\d{2})$/)
|
|
170
|
-
: null;
|
|
171
|
-
const effortValues = effortValuesForModel(raw?.capabilities, id);
|
|
172
|
-
return {
|
|
173
|
-
id,
|
|
174
|
-
display: raw?.display_name || _prettyName(id, family),
|
|
175
|
-
family,
|
|
176
|
-
provider: 'anthropic-oauth',
|
|
177
|
-
contextWindow: raw?.context_window || raw?.max_context_window || raw?.max_input_tokens || _defaultContextForModel(id, family),
|
|
178
|
-
outputTokens: raw?.max_tokens || raw?.max_output_tokens || null,
|
|
179
|
-
tier,
|
|
180
|
-
latest: false, // assigned in a second pass once full list is known
|
|
181
|
-
releaseDate: releaseDate ? `${releaseDate[1]}-${releaseDate[2]}-${releaseDate[3]}` : null,
|
|
182
|
-
supportsReasoning: effortValues.length > 0 || _capabilitySupported(raw?.capabilities?.thinking),
|
|
183
|
-
reasoningOptions: effortValues.length ? [{ type: 'effort', values: effortValues }] : [],
|
|
184
|
-
};
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function _prettyName(id, family) {
|
|
188
|
-
const v = id.match(/^claude-[a-z]+-(\d+)(?:-(\d+))?/i);
|
|
189
|
-
const base = family[0].toUpperCase() + family.slice(1);
|
|
190
|
-
return v ? `${base} ${v[1]}${v[2] ? `.${v[2]}` : ''}` : base;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function _defaultContextForModel(id, family) {
|
|
194
|
-
const text = String(id || '');
|
|
195
|
-
const version = text.match(/^claude-[a-z]+-(\d+)(?:-(\d+))?/i);
|
|
196
|
-
if (Number(version?.[1] || 0) >= 5) return 1000000;
|
|
197
|
-
if (/^claude-(opus|sonnet)-4-(6|7|8)(?:$|-)/i.test(text)) return 1000000;
|
|
198
|
-
if (family && family !== 'other') return 200000;
|
|
199
|
-
return 200000;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// Mark the highest-numbered version per family as `latest: true`. Uses a simple
|
|
203
|
-
// lexicographic comparison on the numeric parts embedded in the id.
|
|
204
|
-
function _markLatestByFamily(models) {
|
|
205
|
-
const byFamily = new Map();
|
|
206
|
-
for (const m of models) {
|
|
207
|
-
if (m.tier !== 'version') continue;
|
|
208
|
-
const cur = byFamily.get(m.family);
|
|
209
|
-
if (!cur || _compareVersion(m.id, cur.id) > 0) {
|
|
210
|
-
byFamily.set(m.family, m);
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
for (const m of byFamily.values()) m.latest = true;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function _compareVersion(a, b) {
|
|
217
|
-
const na = (a.match(/^claude-[a-z]+-(\d+)(?:-(\d+))?/i) || []).slice(1).map(Number);
|
|
218
|
-
const nb = (b.match(/^claude-[a-z]+-(\d+)(?:-(\d+))?/i) || []).slice(1).map(Number);
|
|
219
|
-
for (let i = 0; i < Math.max(na.length, nb.length); i++) {
|
|
220
|
-
if ((na[i] || 0) !== (nb[i] || 0)) return (na[i] || 0) - (nb[i] || 0);
|
|
221
|
-
}
|
|
222
|
-
return a.localeCompare(b);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// Newest HIGH-TIER chat model by version, read from the SYNC in-memory catalog
|
|
226
|
-
// mirror. Anthropic ships three families: opus / sonnet / haiku. "Latest" is the
|
|
227
|
-
// highest version across opus + sonnet only — haiku is the cheap tier and is
|
|
228
|
-
// never the flagship default. Returns null until listModels() populates the
|
|
229
|
-
// mirror; callers must warm the catalog (ensureLatestAnthropicModel) when null.
|
|
230
|
-
export function resolveLatestAnthropicModel() {
|
|
231
|
-
if (!Array.isArray(_inMemoryCatalog)) return null;
|
|
232
|
-
let best = null;
|
|
233
|
-
for (const m of _inMemoryCatalog) {
|
|
234
|
-
if (!m?.id || (m.family !== 'opus' && m.family !== 'sonnet')) continue;
|
|
235
|
-
if (!best || _compareVersion(m.id, best.id) > 0) best = m;
|
|
236
|
-
}
|
|
237
|
-
return best?.id || null;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
function resolveAnthropicModelAfter404(requested) {
|
|
241
|
-
if (!Array.isArray(_inMemoryCatalog)) return null;
|
|
242
|
-
const wanted = String(requested || '');
|
|
243
|
-
const family = (wanted.match(/^claude-([a-z]+)/i) || [])[1]?.toLowerCase() || null;
|
|
244
|
-
let best = null;
|
|
245
|
-
for (const m of _inMemoryCatalog) {
|
|
246
|
-
if (!m?.id) continue;
|
|
247
|
-
if (family && m.family !== family) continue;
|
|
248
|
-
if (!family && m.family !== 'opus' && m.family !== 'sonnet') continue;
|
|
249
|
-
if (!best || _compareVersion(m.id, best.id) > 0) best = m;
|
|
250
|
-
}
|
|
251
|
-
if (best?.id && best.id !== wanted) return best.id;
|
|
252
|
-
if (family === 'opus') {
|
|
253
|
-
const flagship = resolveLatestAnthropicModel();
|
|
254
|
-
if (flagship && flagship !== wanted) return flagship;
|
|
255
|
-
}
|
|
256
|
-
return null;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
export async function ensureLatestAnthropicModel(provider) {
|
|
260
|
-
let m = resolveLatestAnthropicModel();
|
|
261
|
-
if (m) return m;
|
|
262
|
-
await provider._refreshModelCache();
|
|
263
|
-
m = resolveLatestAnthropicModel();
|
|
264
|
-
if (m) return m;
|
|
265
|
-
throw new Error('[anthropic-oauth] model catalog unavailable after warmup — cannot resolve default model');
|
|
266
|
-
}
|
|
267
|
-
|
|
268
110
|
const API_URL = 'https://api.anthropic.com/v1/messages';
|
|
269
|
-
// SSRF guard for the OAuth token endpoint override. Env-supplied URLs must be
|
|
270
|
-
// https with a valid http(s) URL shape; reject file:/data:/ftp:/etc. and any
|
|
271
|
-
// http override so a hostile env cannot redirect refresh-token requests.
|
|
272
|
-
function assertSafeTokenURL(rawURL) {
|
|
273
|
-
let parsed;
|
|
274
|
-
try {
|
|
275
|
-
parsed = new URL(String(rawURL));
|
|
276
|
-
} catch {
|
|
277
|
-
throw new Error(`[anthropic-oauth] invalid ANTHROPIC_OAUTH_TOKEN_URL: ${rawURL}`);
|
|
278
|
-
}
|
|
279
|
-
if (parsed.protocol.toLowerCase() !== 'https:') {
|
|
280
|
-
throw new Error(`[anthropic-oauth] ANTHROPIC_OAUTH_TOKEN_URL must use https (got ${parsed.protocol})`);
|
|
281
|
-
}
|
|
282
|
-
return rawURL;
|
|
283
|
-
}
|
|
284
|
-
const TOKEN_URL = assertSafeTokenURL(process.env.ANTHROPIC_OAUTH_TOKEN_URL || 'https://platform.claude.com/v1/oauth/token');
|
|
285
111
|
const ANTHROPIC_VERSION = '2023-06-01';
|
|
286
|
-
const DEFAULT_CREDENTIALS_PATH = join(resolvePluginData(), 'anthropic-oauth-credentials.json');
|
|
287
|
-
const CLAUDE_CODE_CLIENT_ID = process.env.ANTHROPIC_OAUTH_CLIENT_ID || '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
|
288
|
-
const TOKEN_REFRESH_SKEW_MS = 5 * 60_000;
|
|
289
|
-
const CLAUDE_AI_AUTHORIZE_URL = 'https://claude.com/cai/oauth/authorize';
|
|
290
|
-
const ALL_OAUTH_SCOPES = [
|
|
291
|
-
'org:create_api_key',
|
|
292
|
-
'user:profile',
|
|
293
|
-
'user:inference',
|
|
294
|
-
'user:sessions:claude_code',
|
|
295
|
-
'user:mcp_servers',
|
|
296
|
-
'user:file_upload',
|
|
297
|
-
];
|
|
298
|
-
const OAUTH_LOGIN_SCOPE = ALL_OAUTH_SCOPES.join(' ');
|
|
299
|
-
const OAUTH_CALLBACK_HOST = 'localhost';
|
|
300
|
-
const OAUTH_CALLBACK_PORT = 54545;
|
|
301
|
-
const OAUTH_CALLBACK_PATH = '/callback';
|
|
302
|
-
const OAUTH_REDIRECT_URI = `http://${OAUTH_CALLBACK_HOST}:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}`;
|
|
303
|
-
const OAUTH_MANUAL_REDIRECT_URI = process.env.ANTHROPIC_OAUTH_MANUAL_REDIRECT_URI || 'https://platform.claude.com/oauth/code/callback';
|
|
304
|
-
const OAUTH_SUCCESS_REDIRECT_URL = process.env.ANTHROPIC_OAUTH_SUCCESS_REDIRECT_URL || 'https://platform.claude.com/oauth/code/success?app=claude-code';
|
|
305
|
-
const OAUTH_LOGIN_TIMEOUT_MS = 5 * 60_000;
|
|
306
|
-
const OAUTH_TOKEN_TIMEOUT_MS = 30_000;
|
|
307
112
|
|
|
308
113
|
// Anthropic OAuth contract for first-party OAuth clients: Opus/Sonnet
|
|
309
114
|
// requests are gated on this exact system-prompt prefix. Haiku is not
|
|
310
115
|
// gated and ignores this prefix.
|
|
311
116
|
const CLAUDE_CODE_SYSTEM_PREFIX = "You are Claude Code, Anthropic's official CLI for Claude.";
|
|
312
117
|
const OAUTH_BETA_HEADERS = 'oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,extended-cache-ttl-2025-04-11';
|
|
313
|
-
const DEFAULT_CLI_VERSION = '2.1.77';
|
|
314
|
-
|
|
315
|
-
function resolveCliVersion() {
|
|
316
|
-
return process.env.MIXDOG_CLI_VERSION
|
|
317
|
-
|| DEFAULT_CLI_VERSION;
|
|
318
|
-
}
|
|
319
118
|
|
|
320
119
|
function requiresSystemPrefix(model) {
|
|
321
120
|
// High-tier Claude OAuth models require the first-party system prefix for
|
|
@@ -363,40 +162,29 @@ function buildSystemBlocks(systemMsgs, model, systemTtl, tier3Ttl) {
|
|
|
363
162
|
// Apply per-tier cache_control. BP1/BP2 -> systemTtl, BP3 -> tier3Ttl. The
|
|
364
163
|
// gating prefix block is never cached (Anthropic routes on its exact bytes).
|
|
365
164
|
// tier3Ttl === null leaves the 3rd block uncached (e.g. maintenance roles).
|
|
165
|
+
// Anthropic caps cache_control breakpoints at 4 per request; defensively
|
|
166
|
+
// cap it here too so an unexpectedly large systemMsgs array can never
|
|
167
|
+
// mark more than 4 blocks (extras keep their text, just lose the
|
|
168
|
+
// cache_control breakpoint, not the block itself).
|
|
169
|
+
const MAX_SYSTEM_BREAKPOINTS = 4;
|
|
170
|
+
let bpCount = 0;
|
|
366
171
|
for (const b of blocks) {
|
|
367
172
|
const tier = b._tier;
|
|
368
173
|
delete b._tier;
|
|
369
174
|
if (b.text === CLAUDE_CODE_SYSTEM_PREFIX) continue;
|
|
370
175
|
const ttl = tier === 'tier3' ? tier3Ttl : systemTtl;
|
|
371
|
-
if (ttl
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
// Catalog-reported outputTokens for a model id, read from the in-memory
|
|
377
|
-
// catalog mirror (lazily populated from the disk cache if the mirror hasn't
|
|
378
|
-
// been warmed yet by listModels()). Never throws — any failure just means
|
|
379
|
-
// "no catalog data", and callers fall back to the static heuristics below.
|
|
380
|
-
function _catalogOutputTokens(model) {
|
|
381
|
-
if (!model) return null;
|
|
382
|
-
try {
|
|
383
|
-
if (!Array.isArray(_inMemoryCatalog)) {
|
|
384
|
-
const cached = _modelCache.loadSync();
|
|
385
|
-
if (Array.isArray(cached)) _inMemoryCatalog = cached.slice();
|
|
176
|
+
if (ttl && bpCount < MAX_SYSTEM_BREAKPOINTS) {
|
|
177
|
+
b.cache_control = ttl;
|
|
178
|
+
bpCount++;
|
|
386
179
|
}
|
|
387
|
-
if (!Array.isArray(_inMemoryCatalog)) return null;
|
|
388
|
-
const entry = _inMemoryCatalog.find(m => m?.id === model);
|
|
389
|
-
const out = Number(entry?.outputTokens);
|
|
390
|
-
return Number.isFinite(out) && out > 0 ? out : null;
|
|
391
|
-
} catch {
|
|
392
|
-
return null;
|
|
393
180
|
}
|
|
181
|
+
return blocks;
|
|
394
182
|
}
|
|
395
183
|
|
|
396
184
|
// resolveMaxTokens: catalog-driven max_tokens for a model id. Thin wrapper
|
|
397
185
|
// around the shared anthropic-max-tokens helper (also used by the API-key
|
|
398
186
|
// twin in anthropic.mjs) — this provider supplies its own in-memory-mirror-
|
|
399
|
-
// first catalog lookup strategy.
|
|
187
|
+
// first catalog lookup strategy (see anthropic-model-resolve.mjs).
|
|
400
188
|
// 1. MIXDOG_ANTHROPIC_MAX_OUTPUT_TOKENS env override, if set, wins outright.
|
|
401
189
|
// 2. Catalog outputTokens (trusted over hardcoded heuristics when present),
|
|
402
190
|
// clamped to [MAX_TOKENS_FLOOR, safetyCap].
|
|
@@ -423,249 +211,6 @@ function clampThinkingBudgetTokens(value, maxTokens) {
|
|
|
423
211
|
const CACHE_TTL_STABLE = { type: 'ephemeral', ttl: '1h' }; // tools, system, tier3, messages
|
|
424
212
|
const CACHE_TTL_VOLATILE = { type: 'ephemeral' }; // explicit 5m override
|
|
425
213
|
|
|
426
|
-
// --- Credential helpers ---
|
|
427
|
-
|
|
428
|
-
function _pushUnique(list, value) {
|
|
429
|
-
if (!value || typeof value !== 'string') return;
|
|
430
|
-
if (!list.includes(value)) list.push(value);
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
function credentialCandidates() {
|
|
434
|
-
const paths = [];
|
|
435
|
-
_pushUnique(paths, process.env.ANTHROPIC_OAUTH_CREDENTIALS_PATH);
|
|
436
|
-
_pushUnique(paths, DEFAULT_CREDENTIALS_PATH);
|
|
437
|
-
return paths;
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
// Fallback expiry from the access_token's JWT `exp` claim (epoch ms) when the
|
|
441
|
-
// credentials file carries no explicit expiresAt — without it expiresAt stays 0,
|
|
442
|
-
// which ensureAuth reads as "never expires", disabling proactive refresh. Claude
|
|
443
|
-
// OAuth tokens are opaque so this returns 0 and the file's expiresAt governs.
|
|
444
|
-
// JWT `exp` is epoch SECONDS (RFC 7519).
|
|
445
|
-
function _expiryFromAccessToken(token) {
|
|
446
|
-
try {
|
|
447
|
-
const parts = String(token || '').split('.');
|
|
448
|
-
if (parts.length !== 3) return 0;
|
|
449
|
-
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString('utf-8'));
|
|
450
|
-
const exp = Number(payload?.exp);
|
|
451
|
-
return Number.isFinite(exp) && exp > 0 ? exp * 1000 : 0;
|
|
452
|
-
} catch { return 0; }
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
function _loadCredentialsFile(path) {
|
|
456
|
-
if (!existsSync(path)) return null;
|
|
457
|
-
try {
|
|
458
|
-
const stat = statSync(path);
|
|
459
|
-
const raw = JSON.parse(readFileSync(path, 'utf-8'));
|
|
460
|
-
const oauth = raw?.claudeAiOauth;
|
|
461
|
-
if (!oauth?.accessToken) return null;
|
|
462
|
-
return {
|
|
463
|
-
path,
|
|
464
|
-
mtimeMs: stat.mtimeMs,
|
|
465
|
-
accessToken: oauth.accessToken,
|
|
466
|
-
refreshToken: oauth.refreshToken || null,
|
|
467
|
-
expiresAt: _normalizeExpiresAt(oauth.expiresAt ?? oauth.expires_at) || _expiryFromAccessToken(oauth.accessToken),
|
|
468
|
-
scopes: Array.isArray(oauth.scopes) ? oauth.scopes : [],
|
|
469
|
-
subscriptionType: oauth.subscriptionType || null,
|
|
470
|
-
};
|
|
471
|
-
} catch {
|
|
472
|
-
return null;
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
// Cross-process safe credential save. Lockfile (O_EXCL) prevents two Mixdog
|
|
477
|
-
// refreshers from clobbering each other; atomic rename guarantees readers see
|
|
478
|
-
// either the old or new file, never a half-written one. Used so refresh_token
|
|
479
|
-
// rotation propagates to other Mixdog readers of the same credentials file
|
|
480
|
-
// instead of leaving them stuck on the previous refresh_token.
|
|
481
|
-
function _saveCredentialsFile(path, raw) {
|
|
482
|
-
// Secret file, not parent-dir ACL mutation. `secret: true` clamps the file
|
|
483
|
-
// itself on Windows; it deliberately leaves the data dir inheritance alone.
|
|
484
|
-
writeJsonAtomicSync(path, raw, { lock: true, fsyncDir: true, mode: 0o600, secret: true });
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
// Cheap stat-only probe so ensureAuth can detect Mixdog-updated credentials
|
|
488
|
-
// without paying a full JSON read every call.
|
|
489
|
-
function _credentialsMaxMtime() {
|
|
490
|
-
let max = 0;
|
|
491
|
-
for (const p of credentialCandidates()) {
|
|
492
|
-
try {
|
|
493
|
-
const s = statSync(p);
|
|
494
|
-
if (s.mtimeMs > max) max = s.mtimeMs;
|
|
495
|
-
} catch { /* not present — skip */ }
|
|
496
|
-
}
|
|
497
|
-
return max;
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
function loadCredentials() {
|
|
501
|
-
const loaded = credentialCandidates()
|
|
502
|
-
.map(_loadCredentialsFile)
|
|
503
|
-
.filter(Boolean);
|
|
504
|
-
if (!loaded.length) return null;
|
|
505
|
-
loaded.sort((a, b) => (Number(b.expiresAt) || 0) - (Number(a.expiresAt) || 0));
|
|
506
|
-
return loaded[0];
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
// Public predicate used by config.buildDefaultConfig — provider is enabled
|
|
510
|
-
// when on-disk credentials exist AND carry the inference scope. Single
|
|
511
|
-
// truth: same loader the runtime uses, no parallel hard-coded path probe.
|
|
512
|
-
export function hasAnthropicOAuthCredentials() {
|
|
513
|
-
const creds = loadCredentials();
|
|
514
|
-
if (!creds?.accessToken) return false;
|
|
515
|
-
return Array.isArray(creds.scopes) && creds.scopes.includes('user:inference');
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
export function describeAnthropicOAuthCredentials() {
|
|
519
|
-
try {
|
|
520
|
-
const creds = loadCredentials();
|
|
521
|
-
if (!creds?.accessToken) {
|
|
522
|
-
return { authenticated: false, status: 'Not Set', detail: 'Mixdog OAuth credentials' };
|
|
523
|
-
}
|
|
524
|
-
const hasInferenceScope = Array.isArray(creds.scopes) && creds.scopes.includes('user:inference');
|
|
525
|
-
const hasRefresh = Boolean(creds.refreshToken);
|
|
526
|
-
const expiresAt = _normalizeExpiresAt(creds.expiresAt);
|
|
527
|
-
const expiring = expiresAt > 0 && expiresAt < Date.now() + TOKEN_REFRESH_SKEW_MS;
|
|
528
|
-
const expired = expiresAt > 0 && expiresAt <= Date.now();
|
|
529
|
-
const detail = creds.path || DEFAULT_CREDENTIALS_PATH;
|
|
530
|
-
if (!hasInferenceScope) {
|
|
531
|
-
return { authenticated: false, status: 'Missing Scope', detail, expiresAt };
|
|
532
|
-
}
|
|
533
|
-
if (!hasRefresh) {
|
|
534
|
-
return {
|
|
535
|
-
authenticated: expiresAt === 0 || !expired,
|
|
536
|
-
status: expired ? 'Reauth Required' : 'Access Only',
|
|
537
|
-
detail: `${detail}; no refresh token`,
|
|
538
|
-
expiresAt,
|
|
539
|
-
};
|
|
540
|
-
}
|
|
541
|
-
if (expired) return { authenticated: true, status: 'Refresh Required', detail, expiresAt };
|
|
542
|
-
if (expiring) return { authenticated: true, status: 'Refresh Soon', detail, expiresAt };
|
|
543
|
-
return { authenticated: true, status: 'Valid', detail, expiresAt };
|
|
544
|
-
} catch (err) {
|
|
545
|
-
return { authenticated: false, status: 'Error', detail: String(err?.message || err).slice(0, 200) };
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
export function forgetAnthropicOAuthCredentials() {
|
|
550
|
-
let removed = false;
|
|
551
|
-
for (const path of credentialCandidates()) {
|
|
552
|
-
if (!existsSync(path)) continue;
|
|
553
|
-
try {
|
|
554
|
-
const raw = JSON.parse(readFileSync(path, 'utf-8'));
|
|
555
|
-
if (raw?.claudeAiOauth) {
|
|
556
|
-
delete raw.claudeAiOauth;
|
|
557
|
-
_saveCredentialsFile(path, raw);
|
|
558
|
-
removed = true;
|
|
559
|
-
}
|
|
560
|
-
} catch (err) {
|
|
561
|
-
throw new Error(`Anthropic OAuth reset failed for ${path}: ${err?.message || err}`);
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
return { removed };
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
function _normalizeExpiresAt(value) {
|
|
568
|
-
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return 0;
|
|
569
|
-
return value < 1e12 ? value * 1000 : value;
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
function _scrubTokens(text) {
|
|
573
|
-
return String(text || '')
|
|
574
|
-
.replace(/Bearer [A-Za-z0-9._\-]+/g, 'Bearer [REDACTED]')
|
|
575
|
-
.replace(/sk-ant-[A-Za-z0-9._\-]+/g, '[REDACTED]')
|
|
576
|
-
.replace(/"access[Tt]oken"\s*:\s*"[^"]+"/g, '"accessToken":"[REDACTED]"')
|
|
577
|
-
.replace(/"refresh[Tt]oken"\s*:\s*"[^"]+"/g, '"refreshToken":"[REDACTED]"')
|
|
578
|
-
.replace(/"access_token"\s*:\s*"[^"]+"/g, '"access_token":"[REDACTED]"')
|
|
579
|
-
.replace(/"refresh_token"\s*:\s*"[^"]+"/g, '"refresh_token":"[REDACTED]"');
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
async function refreshOAuthCredentials(creds) {
|
|
583
|
-
if (!creds?.refreshToken) {
|
|
584
|
-
throw new Error('Anthropic OAuth refresh token not available. Open /providers in mixdog to sign in again.');
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
const controller = new AbortController();
|
|
588
|
-
const timeout = setTimeout(() => controller.abort(), 30_000);
|
|
589
|
-
try {
|
|
590
|
-
const res = await fetch(TOKEN_URL, {
|
|
591
|
-
method: 'POST',
|
|
592
|
-
headers: {
|
|
593
|
-
'Content-Type': 'application/json',
|
|
594
|
-
'anthropic-dangerous-direct-browser-access': 'true',
|
|
595
|
-
'user-agent': `claude-cli/${resolveCliVersion()} (external, sdk-cli)`,
|
|
596
|
-
},
|
|
597
|
-
body: JSON.stringify({
|
|
598
|
-
grant_type: 'refresh_token',
|
|
599
|
-
refresh_token: creds.refreshToken,
|
|
600
|
-
client_id: CLAUDE_CODE_CLIENT_ID,
|
|
601
|
-
}),
|
|
602
|
-
// Never follow a redirect on a secret-bearing request: a token
|
|
603
|
-
// endpoint that 307/308-redirects would replay the refresh_token to
|
|
604
|
-
// the redirect target. Fail loud instead.
|
|
605
|
-
redirect: 'error',
|
|
606
|
-
signal: controller.signal,
|
|
607
|
-
dispatcher: getLlmDispatcher(),
|
|
608
|
-
});
|
|
609
|
-
|
|
610
|
-
const text = await res.text();
|
|
611
|
-
let json = null;
|
|
612
|
-
try { json = text ? JSON.parse(text) : null; } catch { /* handled below */ }
|
|
613
|
-
if (!res.ok) {
|
|
614
|
-
const isInvalidGrant = text.includes('invalid_grant') || json?.error === 'invalid_grant';
|
|
615
|
-
throw Object.assign(new Error(`token refresh ${res.status}: ${_scrubTokens(text).slice(0, 200)}`), { isInvalidGrant });
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
const accessToken = json?.access_token || json?.accessToken;
|
|
619
|
-
if (!accessToken) throw new Error('token refresh returned no access token');
|
|
620
|
-
const expiresAt = _normalizeExpiresAt(json?.expires_at ?? json?.expiresAt)
|
|
621
|
-
|| (typeof json?.expires_in === 'number' ? Date.now() + json.expires_in * 1000 : 0);
|
|
622
|
-
const refreshed = {
|
|
623
|
-
path: creds.path,
|
|
624
|
-
accessToken,
|
|
625
|
-
refreshToken: json?.refresh_token || json?.refreshToken || creds.refreshToken,
|
|
626
|
-
expiresAt,
|
|
627
|
-
scopes: Array.isArray(json?.scope) ? json.scope : creds.scopes,
|
|
628
|
-
subscriptionType: creds.subscriptionType,
|
|
629
|
-
};
|
|
630
|
-
// Persist rotated tokens back so any other Mixdog reader of the same
|
|
631
|
-
// credentials file picks up the new refresh_token. Without this, a
|
|
632
|
-
// later process can replay an old single-use refresh token and loop on
|
|
633
|
-
// invalid_grant.
|
|
634
|
-
if (creds.path && existsSync(creds.path)) {
|
|
635
|
-
try {
|
|
636
|
-
const raw = JSON.parse(readFileSync(creds.path, 'utf-8'));
|
|
637
|
-
raw.claudeAiOauth = {
|
|
638
|
-
...(raw.claudeAiOauth || {}),
|
|
639
|
-
accessToken: refreshed.accessToken,
|
|
640
|
-
refreshToken: refreshed.refreshToken,
|
|
641
|
-
expiresAt: refreshed.expiresAt,
|
|
642
|
-
scopes: refreshed.scopes,
|
|
643
|
-
};
|
|
644
|
-
_saveCredentialsFile(creds.path, raw);
|
|
645
|
-
} catch (err) {
|
|
646
|
-
process.stderr.write(`[anthropic-oauth] credential save failed: ${_scrubTokens(err?.message || String(err)).slice(0, 200)}\n`);
|
|
647
|
-
throw new Error(`[oauth] credentials save failed: ${err?.message ?? String(err)}`);
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
return refreshed;
|
|
651
|
-
} catch (err) {
|
|
652
|
-
if (err?.name === 'AbortError') {
|
|
653
|
-
throw new Error('Anthropic OAuth token refresh timed out after 30000ms');
|
|
654
|
-
}
|
|
655
|
-
throw err;
|
|
656
|
-
} finally {
|
|
657
|
-
clearTimeout(timeout);
|
|
658
|
-
}
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
// Exported so callers can detect re-auth-required scenarios and prompt the user.
|
|
662
|
-
export class ReauthRequired extends Error {
|
|
663
|
-
constructor(message) {
|
|
664
|
-
super(message);
|
|
665
|
-
this.name = 'ReauthRequired';
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
|
|
669
214
|
// --- Message conversion ---
|
|
670
215
|
|
|
671
216
|
function withCacheControl(block, ttl = CACHE_TTL_VOLATILE) {
|
|
@@ -913,576 +458,7 @@ function applyAnthropicCacheMarkers(sanitizedMessages, { messageTtl = CACHE_TTL_
|
|
|
913
458
|
return sanitizedMessages;
|
|
914
459
|
}
|
|
915
460
|
|
|
916
|
-
// --- SSE parser ---
|
|
917
|
-
|
|
918
|
-
function _captureMidstreamAbort(state, reason) {
|
|
919
|
-
if (!state) return;
|
|
920
|
-
const reasonName = reason?.name || '';
|
|
921
|
-
if (reasonName === 'AgentStallAbortError' || reasonName === 'StreamStalledAbortError') {
|
|
922
|
-
state.watchdogAbort = reasonName;
|
|
923
|
-
} else {
|
|
924
|
-
state.userAbort = true;
|
|
925
|
-
}
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
// Abort-aware mid-stream backoff sleep → shared sleepWithAbort
|
|
929
|
-
// (retry-classifier.mjs). abortMessage preserves the prior fallback text.
|
|
930
|
-
function _midstreamSleepWithAbort(ms, signal) {
|
|
931
|
-
return sleepWithAbort(ms, signal, undefined, 'Anthropic OAuth mid-stream retry backoff aborted');
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
function _statusForAnthropicSseError(type, message) {
|
|
935
|
-
const kind = String(type || '').toLowerCase();
|
|
936
|
-
const text = String(message || '').toLowerCase();
|
|
937
|
-
if (kind.includes('overload') || text.includes('overload')) return 503;
|
|
938
|
-
if (kind.includes('rate_limit') || text.includes('rate limit') || text.includes('quota')) return 429;
|
|
939
|
-
if (kind.includes('authentication') || text.includes('authentication') || text.includes('unauthorized')) return 401;
|
|
940
|
-
if (kind.includes('permission') || text.includes('forbidden')) return 403;
|
|
941
|
-
if (kind.includes('not_found') || text.includes('not found')) return 404;
|
|
942
|
-
if (kind.includes('invalid_request')) return 400;
|
|
943
|
-
return 0;
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
function _anthropicSseError(event) {
|
|
947
|
-
const payload = event?.error && typeof event.error === 'object' ? event.error : event;
|
|
948
|
-
const type = payload?.type || event?.type || 'error';
|
|
949
|
-
const message = payload?.message || 'Anthropic SSE error';
|
|
950
|
-
const err = new Error(`Anthropic OAuth SSE error ${type}: ${message}`);
|
|
951
|
-
err.name = 'AnthropicSseError';
|
|
952
|
-
err.code = 'EANTHROPIC_SSE_ERROR';
|
|
953
|
-
err.providerErrorType = type;
|
|
954
|
-
err.requestId = event?.request_id || event?.requestId || null;
|
|
955
|
-
const status = _statusForAnthropicSseError(type, message);
|
|
956
|
-
if (status) {
|
|
957
|
-
err.httpStatus = status;
|
|
958
|
-
err.status = status;
|
|
959
|
-
}
|
|
960
|
-
return err;
|
|
961
|
-
}
|
|
962
|
-
|
|
963
|
-
async function parseSSEStream(response, signal, abortStream, onStreamDelta, onToolCall, state, onTextDelta, knownToolNames) {
|
|
964
|
-
const reader = response.body.getReader();
|
|
965
|
-
const decoder = new TextDecoder();
|
|
966
|
-
// SEMANTIC idle window: reset only by real model events (message/content/
|
|
967
|
-
// tool deltas), NOT by raw keepalive bytes. A ping-only wedge therefore
|
|
968
|
-
// trips this within the window instead of hanging until the 30-min agent
|
|
969
|
-
// watchdog. See resetIdleTimer + the per-event reset in the loop below.
|
|
970
|
-
// state.semanticIdleTimeoutMs is a test/override seam (same shape as
|
|
971
|
-
// firstMessageTimeoutMs); production uses the shared env-backed default.
|
|
972
|
-
const SSE_IDLE_TIMEOUT_MS = Number.isFinite(Number(state?.semanticIdleTimeoutMs))
|
|
973
|
-
&& Number(state.semanticIdleTimeoutMs) > 0
|
|
974
|
-
? Number(state.semanticIdleTimeoutMs)
|
|
975
|
-
: PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS;
|
|
976
|
-
const SSE_FIRST_MESSAGE_TIMEOUT_MS = Number.isFinite(Number(state?.firstMessageTimeoutMs))
|
|
977
|
-
&& Number(state.firstMessageTimeoutMs) > 0
|
|
978
|
-
? Number(state.firstMessageTimeoutMs)
|
|
979
|
-
: PROVIDER_FIRST_BYTE_TIMEOUT_MS;
|
|
980
|
-
let content = '';
|
|
981
|
-
let hasThinkingContent = false;
|
|
982
|
-
const contentBlockTypes = new Set();
|
|
983
|
-
let model = '';
|
|
984
|
-
let toolCalls = [];
|
|
985
|
-
let usage = { inputTokens: 0, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, raw: null };
|
|
986
|
-
let stopReason = null;
|
|
987
|
-
let buffer = '';
|
|
988
|
-
let idleTimedOut = false;
|
|
989
|
-
let firstMessageTimedOut = false;
|
|
990
|
-
let idleTimer = null;
|
|
991
|
-
let firstMessageTimer = null;
|
|
992
|
-
let currentEvent = '';
|
|
993
|
-
|
|
994
|
-
const pendingToolInputs = new Map();
|
|
995
|
-
|
|
996
|
-
// Leaked tool-call guard. The model (esp. Opus via OAuth) occasionally
|
|
997
|
-
// emits a tool call as plain text tags inside `text_delta` instead of a
|
|
998
|
-
// native `tool_use` block. `leakBuffer` is a minimal rolling window that
|
|
999
|
-
// only holds back text when a partial sentinel prefix is present, so a
|
|
1000
|
-
// tag split across chunk boundaries is still detected while ordinary text
|
|
1001
|
-
// still streams promptly. The guard is additive: the native tool_use path
|
|
1002
|
-
// (content_block_start/input_json_delta/content_block_stop) is untouched.
|
|
1003
|
-
const _knownTools = knownToolNames instanceof Set
|
|
1004
|
-
? knownToolNames
|
|
1005
|
-
: new Set(Array.isArray(knownToolNames) ? knownToolNames : []);
|
|
1006
|
-
const _leakGuardEnabled = _knownTools.size > 0;
|
|
1007
|
-
const _isKnownTool = (name) => _knownTools.has(name);
|
|
1008
|
-
let leakBuffer = '';
|
|
1009
|
-
// Running markdown fence/inline-code state threaded across text_delta
|
|
1010
|
-
// chunks (Fix 1): a tool-call tag inside a ```code fence``` or inline span
|
|
1011
|
-
// is a doc example, not a real call — the guard emits it as text.
|
|
1012
|
-
let leakFenceState = undefined;
|
|
1013
|
-
// Cross-path fingerprint dedupe (Fix 2): a synthesized text-leaked call and
|
|
1014
|
-
// an identical native tool_use block must dispatch onToolCall exactly once.
|
|
1015
|
-
const _toolDedupe = createToolCallDedupe();
|
|
1016
|
-
|
|
1017
|
-
// Synthesize + dispatch a recovered leaked call exactly like the native
|
|
1018
|
-
// content_block_stop path (push into toolCalls, flag state, eager
|
|
1019
|
-
// onToolCall). A generated id uses the same `toolu_`-prefixed shape as
|
|
1020
|
-
// Anthropic's native tool-call ids.
|
|
1021
|
-
const dispatchLeakedCall = (recovered) => {
|
|
1022
|
-
let args = recovered?.arguments;
|
|
1023
|
-
if (args === null || typeof args !== 'object' || Array.isArray(args)) args = {};
|
|
1024
|
-
// Skip if an identical native (or prior synthetic) call already fired.
|
|
1025
|
-
if (!_toolDedupe.shouldDispatch(recovered.name, args)) return;
|
|
1026
|
-
const call = {
|
|
1027
|
-
id: `toolu_leaked_${randomBytes(8).toString('hex')}`,
|
|
1028
|
-
name: recovered.name,
|
|
1029
|
-
arguments: args,
|
|
1030
|
-
};
|
|
1031
|
-
toolCalls.push(call);
|
|
1032
|
-
if (state) state.emittedToolCall = true;
|
|
1033
|
-
try { onToolCall?.(call); } catch {}
|
|
1034
|
-
try { onStreamDelta?.(); } catch {}
|
|
1035
|
-
};
|
|
1036
|
-
|
|
1037
|
-
// Feed accumulated text through the scanner. On `final` nothing is held
|
|
1038
|
-
// back so legitimate text is never lost at stream end.
|
|
1039
|
-
const pumpLeakBuffer = (final) => {
|
|
1040
|
-
if (!_leakGuardEnabled) return;
|
|
1041
|
-
if (!leakBuffer && !final) return;
|
|
1042
|
-
const { emit, calls, rest, fenceState } = scanLeakedToolCalls(leakBuffer, { isKnownTool: _isKnownTool, final, fenceState: leakFenceState });
|
|
1043
|
-
leakBuffer = rest;
|
|
1044
|
-
leakFenceState = fenceState;
|
|
1045
|
-
if (emit) {
|
|
1046
|
-
content += emit;
|
|
1047
|
-
if (onTextDelta) {
|
|
1048
|
-
if (state) state.emittedText = true;
|
|
1049
|
-
try { onTextDelta(emit); } catch {}
|
|
1050
|
-
}
|
|
1051
|
-
}
|
|
1052
|
-
for (const c of calls) dispatchLeakedCall(c);
|
|
1053
|
-
};
|
|
1054
|
-
|
|
1055
|
-
// Holds the in-flight reader.read() race rejector so the idle timer can
|
|
1056
|
-
// force-unblock the loop even when reader.cancel() fails to settle the
|
|
1057
|
-
// pending read (undici half-open socket). See resetIdleTimer below.
|
|
1058
|
-
let idleReject = null;
|
|
1059
|
-
|
|
1060
|
-
const firstMessageTimeoutError = () => {
|
|
1061
|
-
const err = new Error(`Anthropic OAuth SSE stream produced no message_start within ${SSE_FIRST_MESSAGE_TIMEOUT_MS}ms`);
|
|
1062
|
-
err.code = 'EEMPTYSTREAM';
|
|
1063
|
-
err.isEmptyStream = true;
|
|
1064
|
-
err.firstByteTimeout = true;
|
|
1065
|
-
return err;
|
|
1066
|
-
};
|
|
1067
|
-
|
|
1068
|
-
const clearFirstMessageTimer = () => {
|
|
1069
|
-
if (firstMessageTimer) {
|
|
1070
|
-
clearTimeout(firstMessageTimer);
|
|
1071
|
-
firstMessageTimer = null;
|
|
1072
|
-
}
|
|
1073
|
-
};
|
|
1074
|
-
|
|
1075
|
-
const armFirstMessageTimer = () => {
|
|
1076
|
-
if (!(SSE_FIRST_MESSAGE_TIMEOUT_MS > 0)) return;
|
|
1077
|
-
clearFirstMessageTimer();
|
|
1078
|
-
firstMessageTimer = setTimeout(() => {
|
|
1079
|
-
if (state?.sawMessageStart) return;
|
|
1080
|
-
firstMessageTimedOut = true;
|
|
1081
|
-
const err = firstMessageTimeoutError();
|
|
1082
|
-
try { abortStream?.(err); } catch (abortErr) {
|
|
1083
|
-
try { process.stderr.write(`[anthropic-oauth] sse first-message abortStream failed: ${abortErr?.message ?? String(abortErr)}\n`); } catch {}
|
|
1084
|
-
}
|
|
1085
|
-
try {
|
|
1086
|
-
const _c = reader.cancel('SSE first message timeout');
|
|
1087
|
-
if (_c && typeof _c.catch === 'function') _c.catch(() => {});
|
|
1088
|
-
} catch (cancelErr) {
|
|
1089
|
-
try { process.stderr.write(`[anthropic-oauth] sse first-message cancel failed: ${cancelErr?.message ?? String(cancelErr)}\n`); } catch {}
|
|
1090
|
-
}
|
|
1091
|
-
if (idleReject) {
|
|
1092
|
-
const r = idleReject; idleReject = null; r(err);
|
|
1093
|
-
}
|
|
1094
|
-
}, SSE_FIRST_MESSAGE_TIMEOUT_MS);
|
|
1095
|
-
try { firstMessageTimer.unref?.(); } catch {}
|
|
1096
|
-
};
|
|
1097
|
-
|
|
1098
|
-
// Attach the partial stream state to a mid-stream stall error so the agent
|
|
1099
|
-
// loop can decide SUCCESS vs FAILURE. The recurring "worker finished but
|
|
1100
|
-
// owner never notified" case is a FINAL no-tool summary stream that wedges
|
|
1101
|
-
// ping-only after the real work (tool calls) already completed in earlier
|
|
1102
|
-
// iterations: there is streamed `content`, no pending tool_use, and no
|
|
1103
|
-
// emitted tool call this iteration. The loop treats that as a successful
|
|
1104
|
-
// partial-final (deliver the summary we have) instead of dropping it. A
|
|
1105
|
-
// stall WITH a pending/emitted tool call stays a hard failure (a tool whose
|
|
1106
|
-
// input never completed must never be reported as done).
|
|
1107
|
-
const _attachStallPartial = (err) => {
|
|
1108
|
-
try {
|
|
1109
|
-
err.partialContent = content;
|
|
1110
|
-
err.partialToolCalls = toolCalls.length ? toolCalls.slice() : undefined;
|
|
1111
|
-
err.pendingToolUse = pendingToolInputs.size > 0;
|
|
1112
|
-
err.partialModel = model || undefined;
|
|
1113
|
-
err.partialUsage = usage;
|
|
1114
|
-
err.partialStopReason = stopReason || undefined;
|
|
1115
|
-
err.partialHasThinking = hasThinkingContent;
|
|
1116
|
-
} catch { /* best-effort enrichment */ }
|
|
1117
|
-
return err;
|
|
1118
|
-
};
|
|
1119
|
-
|
|
1120
|
-
const resetIdleTimer = () => {
|
|
1121
|
-
// OFF by default. When disabled the
|
|
1122
|
-
// idle timer never arms, so the stream is never killed on inactivity;
|
|
1123
|
-
// the agent stall watchdog (600s) remains the dead-stream backstop.
|
|
1124
|
-
if (!PROVIDER_SSE_IDLE_WATCHDOG_ENABLED) return;
|
|
1125
|
-
if (idleTimer) clearTimeout(idleTimer);
|
|
1126
|
-
idleTimer = setTimeout(() => {
|
|
1127
|
-
idleTimedOut = true;
|
|
1128
|
-
try { abortStream?.(); } catch (err) {
|
|
1129
|
-
try { process.stderr.write(`[anthropic-oauth] sse idle abortStream failed: ${err?.message ?? String(err)}\n`); } catch {}
|
|
1130
|
-
}
|
|
1131
|
-
try {
|
|
1132
|
-
const _c = reader.cancel('SSE idle timeout');
|
|
1133
|
-
if (_c && typeof _c.catch === 'function') _c.catch(() => {});
|
|
1134
|
-
} catch (err) {
|
|
1135
|
-
try { process.stderr.write(`[anthropic-oauth] sse idle cancel failed: ${err?.message ?? String(err)}\n`); } catch {}
|
|
1136
|
-
}
|
|
1137
|
-
// Force-reject the in-flight reader.read() race even when reader.cancel()
|
|
1138
|
-
// fails to settle the pending read: without this the await below stays
|
|
1139
|
-
// pending forever and the SSE idle timeout never unblocks the loop —
|
|
1140
|
-
// the 391s-hang root cause.
|
|
1141
|
-
if (idleReject) {
|
|
1142
|
-
const e = _attachStallPartial(streamStalledError('Anthropic OAuth SSE', SSE_IDLE_TIMEOUT_MS, { emittedToolCall: !!state?.emittedToolCall }));
|
|
1143
|
-
const r = idleReject; idleReject = null; r(e);
|
|
1144
|
-
}
|
|
1145
|
-
// Shared provider policy: short SEMANTIC-event inactivity catches the
|
|
1146
|
-
// ping-only wedge where SSE starts, emits some deltas, then goes silent
|
|
1147
|
-
// while `:ping` keepalives keep the transport socket warm.
|
|
1148
|
-
}, SSE_IDLE_TIMEOUT_MS);
|
|
1149
|
-
try { idleTimer.unref?.(); } catch {}
|
|
1150
|
-
};
|
|
1151
|
-
|
|
1152
|
-
const onAbort = () => {
|
|
1153
|
-
try {
|
|
1154
|
-
const _c = reader.cancel('SSE aborted');
|
|
1155
|
-
if (_c && typeof _c.catch === 'function') _c.catch(() => {});
|
|
1156
|
-
} catch {}
|
|
1157
|
-
};
|
|
1158
|
-
if (signal) {
|
|
1159
|
-
if (signal.aborted) {
|
|
1160
|
-
_captureMidstreamAbort(state, signal.reason);
|
|
1161
|
-
throw signal.reason instanceof Error
|
|
1162
|
-
? signal.reason
|
|
1163
|
-
: new Error('Anthropic OAuth SSE stream aborted');
|
|
1164
|
-
}
|
|
1165
|
-
signal.addEventListener('abort', onAbort, { once: true });
|
|
1166
|
-
}
|
|
1167
|
-
|
|
1168
|
-
try {
|
|
1169
|
-
// Part A / reviewer fix: do NOT arm the SEMANTIC idle timer before the
|
|
1170
|
-
// stream has produced its first event. A slow first response is governed
|
|
1171
|
-
// by armFirstMessageTimer() (first-byte window) alone; arming the
|
|
1172
|
-
// semantic idle here could let it win and mis-abort a legitimately slow
|
|
1173
|
-
// first response as a stall. The semantic idle is first armed at
|
|
1174
|
-
// `message_start` (see below), so it only ever guards MID-stream silence.
|
|
1175
|
-
armFirstMessageTimer();
|
|
1176
|
-
streamLoop: while (true) {
|
|
1177
|
-
let chunk;
|
|
1178
|
-
try {
|
|
1179
|
-
// Race the read against the idle timer's rejector so a stuck
|
|
1180
|
-
// reader.read() (cancel did not settle it) still unblocks here.
|
|
1181
|
-
chunk = await new Promise((resolve, reject) => {
|
|
1182
|
-
idleReject = reject;
|
|
1183
|
-
reader.read().then(resolve, reject);
|
|
1184
|
-
});
|
|
1185
|
-
} catch (err) {
|
|
1186
|
-
if (idleTimedOut) {
|
|
1187
|
-
throw _attachStallPartial(streamStalledError('Anthropic OAuth SSE', SSE_IDLE_TIMEOUT_MS, { emittedToolCall: !!state?.emittedToolCall }));
|
|
1188
|
-
}
|
|
1189
|
-
if (firstMessageTimedOut) {
|
|
1190
|
-
throw firstMessageTimeoutError();
|
|
1191
|
-
}
|
|
1192
|
-
if (signal?.aborted) {
|
|
1193
|
-
_captureMidstreamAbort(state, signal.reason);
|
|
1194
|
-
throw signal.reason instanceof Error
|
|
1195
|
-
? signal.reason
|
|
1196
|
-
: new Error('Anthropic OAuth SSE stream aborted');
|
|
1197
|
-
}
|
|
1198
|
-
throw err;
|
|
1199
|
-
}
|
|
1200
|
-
const { done, value } = chunk;
|
|
1201
|
-
if (done) break;
|
|
1202
|
-
|
|
1203
|
-
buffer += decoder.decode(value, { stream: true });
|
|
1204
|
-
const lines = buffer.split('\n');
|
|
1205
|
-
buffer = lines.pop() || '';
|
|
1206
|
-
|
|
1207
|
-
for (const line of lines) {
|
|
1208
|
-
if (line.startsWith(':')) {
|
|
1209
|
-
// SSE comment frame (Anthropic `:ping` keepalive). Keep it
|
|
1210
|
-
// at transport level only: comments must not refresh the
|
|
1211
|
-
// agent's semantic progress timestamp, or a ping-only 200
|
|
1212
|
-
// can look alive forever without message_start/content.
|
|
1213
|
-
// Crucially it also does NOT reset the SEMANTIC idle timer
|
|
1214
|
-
// below — a ping-only wedge must trip the idle abort.
|
|
1215
|
-
continue;
|
|
1216
|
-
}
|
|
1217
|
-
// Blank lines are SSE record separators — emitted after EVERY
|
|
1218
|
-
// frame, including `:ping` keepalives — so they are NOT semantic
|
|
1219
|
-
// progress and must not reset the idle timer (else a ping frame's
|
|
1220
|
-
// trailing blank would keep a wedge alive forever).
|
|
1221
|
-
if (line === '') continue;
|
|
1222
|
-
if (line.startsWith('event: ')) {
|
|
1223
|
-
currentEvent = line.slice(7).trim();
|
|
1224
|
-
continue;
|
|
1225
|
-
}
|
|
1226
|
-
if (!line.startsWith('data: ')) continue;
|
|
1227
|
-
const data = line.slice(6).trim();
|
|
1228
|
-
if (!data) continue;
|
|
1229
|
-
|
|
1230
|
-
try {
|
|
1231
|
-
const event = JSON.parse(data);
|
|
1232
|
-
|
|
1233
|
-
// SEMANTIC idle reset (Part A): reset the idle timer ONLY for
|
|
1234
|
-
// real progress events, NOT for Anthropic keepalives. Anthropic
|
|
1235
|
-
// sends pings as a NAMED event (`event: ping` /
|
|
1236
|
-
// `data: {"type":"ping"}`), not just `:` comment frames, so a
|
|
1237
|
-
// named ping must be excluded here or a ping-only wedge keeps
|
|
1238
|
-
// the timer alive forever. Everything that is not a ping is a
|
|
1239
|
-
// genuine server event (message_start/content/tool/thinking
|
|
1240
|
-
// deltas, message_delta/stop, errors) and counts as progress.
|
|
1241
|
-
if (currentEvent !== 'ping' && event?.type !== 'ping') {
|
|
1242
|
-
resetIdleTimer();
|
|
1243
|
-
}
|
|
1244
|
-
|
|
1245
|
-
if (currentEvent === 'error' || event?.type === 'error' || event?.error) {
|
|
1246
|
-
throw _anthropicSseError(event);
|
|
1247
|
-
}
|
|
1248
|
-
|
|
1249
|
-
if (event.type === 'message_start' && event.message) {
|
|
1250
|
-
clearFirstMessageTimer();
|
|
1251
|
-
if (state) state.sawMessageStart = true;
|
|
1252
|
-
if (event.message.model) model = event.message.model;
|
|
1253
|
-
if (event.message.usage) {
|
|
1254
|
-
usage.inputTokens = event.message.usage.input_tokens || 0;
|
|
1255
|
-
usage.cachedTokens = event.message.usage.cache_read_input_tokens || 0;
|
|
1256
|
-
usage.cacheWriteTokens = event.message.usage.cache_creation_input_tokens || 0;
|
|
1257
|
-
usage.raw = { ...event.message.usage };
|
|
1258
|
-
}
|
|
1259
|
-
}
|
|
1260
|
-
|
|
1261
|
-
if (event.type === 'content_block_start') {
|
|
1262
|
-
const block = event.content_block;
|
|
1263
|
-
if (block?.type === 'tool_use') {
|
|
1264
|
-
pendingToolInputs.set(event.index, {
|
|
1265
|
-
id: block.id || '',
|
|
1266
|
-
name: block.name || '',
|
|
1267
|
-
inputJson: '',
|
|
1268
|
-
});
|
|
1269
|
-
}
|
|
1270
|
-
}
|
|
1271
|
-
|
|
1272
|
-
if (event.type === 'content_block_delta') {
|
|
1273
|
-
const delta = event.delta;
|
|
1274
|
-
if (delta?.type) contentBlockTypes.add(delta.type);
|
|
1275
|
-
// Time-to-first-token: stamp the first content delta
|
|
1276
|
-
// (text / thinking / tool input_json) exactly once so
|
|
1277
|
-
// the SSE trace can separate first-byte latency from
|
|
1278
|
-
// total stream/generation time. Without this stamp
|
|
1279
|
-
// ttftMs was always null and reported as 0ms.
|
|
1280
|
-
if (state && !state.ttftAt) state.ttftAt = Date.now();
|
|
1281
|
-
if (delta?.type === 'text_delta') {
|
|
1282
|
-
try { onStreamDelta?.(); } catch {}
|
|
1283
|
-
// Live text relay (gateway): forward the explicit
|
|
1284
|
-
// text chunk. thinking/signature/input_json deltas
|
|
1285
|
-
// intentionally stay off this path.
|
|
1286
|
-
// Invariant: once a non-empty chunk has been relayed
|
|
1287
|
-
// live it cannot be withdrawn, so flag the attempt so
|
|
1288
|
-
// the mid-stream retry loop treats any later failure
|
|
1289
|
-
// as final (a retry would concatenate attempts).
|
|
1290
|
-
if (_leakGuardEnabled) {
|
|
1291
|
-
// Route text through the leaked-tool-call guard.
|
|
1292
|
-
// It appends to `content`, forwards visible text
|
|
1293
|
-
// via onTextDelta, and synthesizes/dispatches any
|
|
1294
|
-
// recovered known-tool call — suppressing the
|
|
1295
|
-
// tags from the visible stream. A partial sentinel
|
|
1296
|
-
// is held in leakBuffer until the next chunk.
|
|
1297
|
-
leakBuffer += delta.text || '';
|
|
1298
|
-
pumpLeakBuffer(false);
|
|
1299
|
-
} else {
|
|
1300
|
-
content += delta.text || '';
|
|
1301
|
-
if (delta.text && onTextDelta) {
|
|
1302
|
-
if (state) state.emittedText = true;
|
|
1303
|
-
try { onTextDelta(delta.text); } catch {}
|
|
1304
|
-
}
|
|
1305
|
-
}
|
|
1306
|
-
}
|
|
1307
|
-
if (delta?.type === 'thinking_delta' || delta?.type === 'signature_delta') {
|
|
1308
|
-
// Extended-thinking block: provider reasoning without
|
|
1309
|
-
// user-visible text. Track presence so a final turn
|
|
1310
|
-
// that emitted ONLY thinking (no text_delta, no
|
|
1311
|
-
// tool_use) can be classified by the loop as
|
|
1312
|
-
// synthesis-stalled rather than silent empty.
|
|
1313
|
-
hasThinkingContent = true;
|
|
1314
|
-
try { onStreamDelta?.(); } catch {}
|
|
1315
|
-
}
|
|
1316
|
-
if (delta?.type === 'input_json_delta') {
|
|
1317
|
-
const pending = pendingToolInputs.get(event.index);
|
|
1318
|
-
if (pending) {
|
|
1319
|
-
pending.inputJson += delta.partial_json || '';
|
|
1320
|
-
}
|
|
1321
|
-
try { onStreamDelta?.(); } catch {}
|
|
1322
|
-
}
|
|
1323
|
-
}
|
|
1324
|
-
|
|
1325
|
-
if (event.type === 'content_block_stop') {
|
|
1326
|
-
const pending = pendingToolInputs.get(event.index);
|
|
1327
|
-
if (pending) {
|
|
1328
|
-
// Bare JSON.parse threw straight up into the
|
|
1329
|
-
// surrounding broad catch, which swallowed the
|
|
1330
|
-
// whole tool_call — the loop never saw it and
|
|
1331
|
-
// the assistant turn ended with an unmatched
|
|
1332
|
-
// tool_use id. Wrap the parse so a malformed
|
|
1333
|
-
// input still produces a tool_call (with an
|
|
1334
|
-
// invalid-args marker and a logged error) instead
|
|
1335
|
-
// of a silent drop or accidental `{}` dispatch.
|
|
1336
|
-
let parsedArgs = {};
|
|
1337
|
-
if (pending.inputJson) {
|
|
1338
|
-
try { parsedArgs = JSON.parse(pending.inputJson); }
|
|
1339
|
-
catch (parseErr) {
|
|
1340
|
-
process.stderr.write(`[anthropic-oauth] tool args JSON.parse failed (id=${pending.id}, name=${pending.name}): ${parseErr?.message || parseErr}\n`);
|
|
1341
|
-
parsedArgs = makeInvalidToolArgsMarker(pending.inputJson, parseErr instanceof Error ? parseErr.message : String(parseErr));
|
|
1342
|
-
}
|
|
1343
|
-
}
|
|
1344
|
-
// Tool arguments must be a plain object. Anthropic's
|
|
1345
|
-
// tool_use input is always a JSON object, but a
|
|
1346
|
-
// malformed stream could parse to an array/string/
|
|
1347
|
-
// number — wrap those as {} to keep the contract
|
|
1348
|
-
// (invariant-based, no heuristic coercion).
|
|
1349
|
-
if (parsedArgs === null
|
|
1350
|
-
|| typeof parsedArgs !== 'object'
|
|
1351
|
-
|| Array.isArray(parsedArgs)) {
|
|
1352
|
-
process.stderr.write(`[anthropic-oauth] tool args not a plain object (id=${pending.id}, name=${pending.name}, type=${Array.isArray(parsedArgs) ? 'array' : typeof parsedArgs}); using {}\n`);
|
|
1353
|
-
parsedArgs = {};
|
|
1354
|
-
}
|
|
1355
|
-
const call = {
|
|
1356
|
-
id: pending.id,
|
|
1357
|
-
name: pending.name,
|
|
1358
|
-
arguments: parsedArgs,
|
|
1359
|
-
};
|
|
1360
|
-
pendingToolInputs.delete(event.index);
|
|
1361
|
-
// Eager dispatch: let the loop start this tool
|
|
1362
|
-
// before message_stop arrives. The loop keys
|
|
1363
|
-
// pending promises by call.id so order is safe.
|
|
1364
|
-
// Fix 2: skip the ENTIRE call (push + dispatch) when a
|
|
1365
|
-
// text-leaked synthetic of the same (name,args) already
|
|
1366
|
-
// fired — otherwise the duplicate stays in `toolCalls`
|
|
1367
|
-
// and the loop executes the side-effecting tool twice.
|
|
1368
|
-
// An invalid-args marker never fingerprint-collides with
|
|
1369
|
-
// a real recovered call, so malformed native calls still
|
|
1370
|
-
// dispatch (the marker path is unaffected).
|
|
1371
|
-
if (_toolDedupe.shouldDispatch(call.name, call.arguments)) {
|
|
1372
|
-
toolCalls.push(call);
|
|
1373
|
-
if (state) state.emittedToolCall = true;
|
|
1374
|
-
// Eager dispatch: let the loop start this tool
|
|
1375
|
-
// before message_stop arrives. The loop keys
|
|
1376
|
-
// pending promises by call.id so order is safe.
|
|
1377
|
-
try { onToolCall?.(call); } catch {}
|
|
1378
|
-
}
|
|
1379
|
-
try { onStreamDelta?.(); } catch {}
|
|
1380
|
-
}
|
|
1381
|
-
}
|
|
1382
|
-
|
|
1383
|
-
if (event.type === 'message_delta') {
|
|
1384
|
-
if (event.delta?.stop_reason) {
|
|
1385
|
-
stopReason = event.delta.stop_reason;
|
|
1386
|
-
}
|
|
1387
|
-
if (event.usage) {
|
|
1388
|
-
usage.outputTokens = event.usage.output_tokens || 0;
|
|
1389
|
-
usage.raw = { ...(usage.raw || {}), ...event.usage };
|
|
1390
|
-
}
|
|
1391
|
-
if (stopReason === 'tool_use' && toolCalls.length > 0 && pendingToolInputs.size === 0) {
|
|
1392
|
-
if (state) state.sawCompleted = true;
|
|
1393
|
-
break streamLoop;
|
|
1394
|
-
}
|
|
1395
|
-
}
|
|
1396
|
-
if (event.type === 'message_stop') {
|
|
1397
|
-
if (state) state.sawCompleted = true;
|
|
1398
|
-
// Anthropic streams can keep emitting `:ping` keepalive
|
|
1399
|
-
// frames after `message_stop`; if we wait for EOF the
|
|
1400
|
-
// outer reader.read() loop hangs indefinitely. Break
|
|
1401
|
-
// out of streamLoop the moment the message ends.
|
|
1402
|
-
break streamLoop;
|
|
1403
|
-
}
|
|
1404
|
-
// Unified prompt volume — what the model actually ingested.
|
|
1405
|
-
// Anthropic splits input into three billable slots (uncached
|
|
1406
|
-
// input + cache_read + cache_create); keep them separate for
|
|
1407
|
-
// cost math but also expose the sum so cross-provider logs
|
|
1408
|
-
// have a consistent `promptTokens` meaning.
|
|
1409
|
-
usage.promptTokens = (usage.inputTokens || 0)
|
|
1410
|
-
+ (usage.cachedTokens || 0)
|
|
1411
|
-
+ (usage.cacheWriteTokens || 0);
|
|
1412
|
-
} catch (err) {
|
|
1413
|
-
if (err?.code === 'EANTHROPIC_SSE_ERROR') throw err;
|
|
1414
|
-
/* skip malformed events */
|
|
1415
|
-
}
|
|
1416
|
-
}
|
|
1417
|
-
}
|
|
1418
|
-
|
|
1419
|
-
// Stream ended: flush any held-back leaked-tool-call buffer. `final`
|
|
1420
|
-
// holds nothing back, so a trailing partial sentinel that never
|
|
1421
|
-
// resolved into a real call is surfaced as ordinary text — legitimate
|
|
1422
|
-
// user-visible content is never lost on the failure path.
|
|
1423
|
-
pumpLeakBuffer(true);
|
|
1424
|
-
|
|
1425
|
-
// Truncated-stream guard: if the reader loop exited (EOF or break)
|
|
1426
|
-
// after message_start but without seeing message_stop / a tool_use
|
|
1427
|
-
// stop_reason, the assistant turn was cut off mid-flight. Returning
|
|
1428
|
-
// success here would silently surface partial content (or a partially
|
|
1429
|
-
// streamed tool_use whose input_json never completed) as final.
|
|
1430
|
-
// Throw a typed truncated-stream error so the loop can decide whether
|
|
1431
|
-
// to retry, surface, or escalate instead of accepting the partial.
|
|
1432
|
-
if (state?.sawMessageStart && !state?.sawCompleted) {
|
|
1433
|
-
const pendingToolUse = pendingToolInputs.size > 0;
|
|
1434
|
-
const err = Object.assign(
|
|
1435
|
-
new Error(
|
|
1436
|
-
`Anthropic OAuth SSE stream truncated: message_start without message_stop`
|
|
1437
|
-
+ (pendingToolUse ? ` (pending tool_use input)` : ''),
|
|
1438
|
-
),
|
|
1439
|
-
{
|
|
1440
|
-
name: 'TruncatedStreamError',
|
|
1441
|
-
code: 'TRUNCATED_STREAM',
|
|
1442
|
-
truncatedStream: true,
|
|
1443
|
-
pendingToolUse,
|
|
1444
|
-
stopReason,
|
|
1445
|
-
},
|
|
1446
|
-
);
|
|
1447
|
-
throw err;
|
|
1448
|
-
}
|
|
1449
|
-
|
|
1450
|
-
return {
|
|
1451
|
-
content,
|
|
1452
|
-
model,
|
|
1453
|
-
toolCalls: toolCalls.length ? toolCalls : undefined,
|
|
1454
|
-
usage,
|
|
1455
|
-
stopReason,
|
|
1456
|
-
hasThinkingContent,
|
|
1457
|
-
contentBlockTypes: Array.from(contentBlockTypes),
|
|
1458
|
-
};
|
|
1459
|
-
} finally {
|
|
1460
|
-
if (idleTimer) clearTimeout(idleTimer);
|
|
1461
|
-
clearFirstMessageTimer();
|
|
1462
|
-
if (signal) signal.removeEventListener('abort', onAbort);
|
|
1463
|
-
try { reader.releaseLock(); } catch (err) {
|
|
1464
|
-
try { process.stderr.write(`[anthropic-oauth] reader releaseLock failed: ${err?.message ?? String(err)}\n`); } catch {}
|
|
1465
|
-
}
|
|
1466
|
-
}
|
|
1467
|
-
}
|
|
1468
|
-
|
|
1469
|
-
/**
|
|
1470
|
-
* Classify an Anthropic SSE failure for single-shot mid-stream retry.
|
|
1471
|
-
*
|
|
1472
|
-
* Retry is allowed only after `message_start` and before `message_stop`,
|
|
1473
|
-
* and only when no tool call has already been surfaced to the loop.
|
|
1474
|
-
* That keeps recovery limited to transport/stream stalls without risking
|
|
1475
|
-
* duplicate eager tool execution.
|
|
1476
|
-
*/
|
|
1477
|
-
// Thin wrapper: the SSE mid-stream decision tree now lives in the shared
|
|
1478
|
-
// classifyMidstreamError (retry-classifier.mjs, policy.mode='sse'). Kept as a
|
|
1479
|
-
// named export so internal call sites AND anthropic.mjs (which imports this
|
|
1480
|
-
// symbol) keep resolving it. Behavior is byte-identical — the shared function
|
|
1481
|
-
// is the relocated original, gated by SSE_MIDSTREAM_POLICY (defaultRetries=3,
|
|
1482
|
-
// perClassifierGate:false).
|
|
1483
|
-
export function _classifyMidstreamError(err, state) {
|
|
1484
|
-
return classifyMidstreamError(err, state, SSE_MIDSTREAM_POLICY);
|
|
1485
|
-
}
|
|
461
|
+
// --- SSE parser + midstream retry policy: extracted to anthropic-sse.mjs ---
|
|
1486
462
|
|
|
1487
463
|
// --- Build request body ---
|
|
1488
464
|
|
|
@@ -1511,12 +487,30 @@ function resolveCacheTtls(opts) {
|
|
|
1511
487
|
// free for sessions that don't carry one. Previously null here meant any
|
|
1512
488
|
// caller that skipped agent runtime resolve (CLI, raw agent spawn)
|
|
1513
489
|
// silently lost the tier3 cache layer even though it supported one.
|
|
1514
|
-
|
|
490
|
+
const resolved = {
|
|
1515
491
|
tools: pick('tools', null),
|
|
1516
492
|
system: pick('system', CACHE_TTL_STABLE),
|
|
1517
493
|
tier3: pick('tier3', CACHE_TTL_STABLE),
|
|
1518
494
|
messages: pick('messages', CACHE_TTL_STABLE),
|
|
1519
495
|
};
|
|
496
|
+
// A partial cacheStrategy override (e.g. {system:'5m'} while tier3/
|
|
497
|
+
// messages default to '1h') can put a longer TTL after a shorter one in
|
|
498
|
+
// request order, which Anthropic rejects: 1h breakpoints must all appear
|
|
499
|
+
// before any 5m breakpoint. Normalize left-to-right in wire order
|
|
500
|
+
// (system -> tier3 -> messages; tools is emitted before system and is
|
|
501
|
+
// excluded from the run) so a later layer is downgraded to the earliest
|
|
502
|
+
// shorter TTL seen so far — never re-promoted. Layers set to null ('none')
|
|
503
|
+
// emit no breakpoint at all, so they neither violate nor constrain
|
|
504
|
+
// ordering and are skipped.
|
|
505
|
+
const ttlRank = (ttl) => (ttl === CACHE_TTL_STABLE ? 2 : 1); // 1h=2, 5m=1
|
|
506
|
+
let minRank = Infinity;
|
|
507
|
+
for (const layer of ['system', 'tier3', 'messages']) {
|
|
508
|
+
if (!resolved[layer]) continue;
|
|
509
|
+
const rank = ttlRank(resolved[layer]);
|
|
510
|
+
if (rank > minRank) resolved[layer] = CACHE_TTL_VOLATILE;
|
|
511
|
+
else minRank = rank;
|
|
512
|
+
}
|
|
513
|
+
return resolved;
|
|
1520
514
|
}
|
|
1521
515
|
|
|
1522
516
|
// BP3 (tier3) is injected by session/manager as its own `system` role block —
|
|
@@ -2149,7 +1143,7 @@ export class AnthropicOAuthProvider {
|
|
|
2149
1143
|
// works offline or when Anthropic's /v1/models is momentarily down.
|
|
2150
1144
|
const cached = await _loadModelCache();
|
|
2151
1145
|
if (cached) {
|
|
2152
|
-
|
|
1146
|
+
_setInMemoryCatalog(cached);
|
|
2153
1147
|
return cached;
|
|
2154
1148
|
}
|
|
2155
1149
|
try {
|
|
@@ -2170,13 +1164,8 @@ export class AnthropicOAuthProvider {
|
|
|
2170
1164
|
if (!res.ok) throw new Error(`list_models ${res.status}`);
|
|
2171
1165
|
const data = await res.json();
|
|
2172
1166
|
const items = Array.isArray(data?.data) ? data.data : [];
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
.filter(Boolean);
|
|
2176
|
-
_markLatestByFamily(normalized);
|
|
2177
|
-
// Enrich with LiteLLM catalog metadata (context, pricing, capabilities)
|
|
2178
|
-
const enriched = await enrichModels(normalized);
|
|
2179
|
-
await _saveModelCache(enriched);
|
|
1167
|
+
// Normalize + mark-latest + LiteLLM-enrich + persist (shared helper).
|
|
1168
|
+
const enriched = await normalizeAndSaveCatalog(items);
|
|
2180
1169
|
return enriched;
|
|
2181
1170
|
} catch (err) {
|
|
2182
1171
|
if (!process.env.MIXDOG_QUIET_PROVIDER_LOG) process.stderr.write(`[anthropic-oauth] listModels fetch failed (${err.message})\n`);
|
|
@@ -2218,12 +1207,7 @@ export class AnthropicOAuthProvider {
|
|
|
2218
1207
|
if (!res.ok) throw new Error(`list_models ${res.status}`);
|
|
2219
1208
|
const data = await res.json();
|
|
2220
1209
|
const items = Array.isArray(data?.data) ? data.data : [];
|
|
2221
|
-
const
|
|
2222
|
-
.map(m => _normalizeAnthropicModel(m))
|
|
2223
|
-
.filter(Boolean);
|
|
2224
|
-
_markLatestByFamily(normalized);
|
|
2225
|
-
const enriched = await enrichModels(normalized);
|
|
2226
|
-
await _saveModelCache(enriched);
|
|
1210
|
+
const enriched = await normalizeAndSaveCatalog(items);
|
|
2227
1211
|
if (!process.env.MIXDOG_QUIET_PROVIDER_LOG) process.stderr.write(`[anthropic-oauth] catalog refreshed (${enriched.length} models)\n`);
|
|
2228
1212
|
return enriched;
|
|
2229
1213
|
} catch (err) {
|
|
@@ -2241,204 +1225,20 @@ export class AnthropicOAuthProvider {
|
|
|
2241
1225
|
}
|
|
2242
1226
|
}
|
|
2243
1227
|
|
|
2244
|
-
//
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
if (existsSync(p)) return p;
|
|
2255
|
-
}
|
|
2256
|
-
return DEFAULT_CREDENTIALS_PATH;
|
|
2257
|
-
}
|
|
2258
|
-
|
|
2259
|
-
function _oauthParseScopeField(scope) {
|
|
2260
|
-
if (Array.isArray(scope)) return scope;
|
|
2261
|
-
return String(scope || '').split(' ').filter(Boolean);
|
|
2262
|
-
}
|
|
2263
|
-
|
|
2264
|
-
function _parseOAuthCodeInput(input) {
|
|
2265
|
-
const value = String(input || '').trim();
|
|
2266
|
-
if (!value) return { code: '', state: '' };
|
|
2267
|
-
try {
|
|
2268
|
-
const url = new URL(value);
|
|
2269
|
-
const code = url.searchParams.get('code') || '';
|
|
2270
|
-
const state = url.searchParams.get('state') || '';
|
|
2271
|
-
if (code || state) return { code, state, redirectUri: `${url.origin}${url.pathname}` };
|
|
2272
|
-
} catch { /* not a URL */ }
|
|
2273
|
-
if (value.includes('#')) {
|
|
2274
|
-
const [code, state] = value.split('#', 2);
|
|
2275
|
-
return { code: String(code || '').trim(), state: String(state || '').trim() };
|
|
2276
|
-
}
|
|
2277
|
-
if (value.includes('code=')) {
|
|
2278
|
-
const params = new URLSearchParams(value.startsWith('?') ? value.slice(1) : value);
|
|
2279
|
-
return { code: params.get('code') || '', state: params.get('state') || '' };
|
|
2280
|
-
}
|
|
2281
|
-
return { code: value, state: '' };
|
|
2282
|
-
}
|
|
2283
|
-
|
|
2284
|
-
async function exchangeAuthorizationCode({ pkce, code, state, redirectUri }) {
|
|
2285
|
-
const cleanCode = String(code || '').trim();
|
|
2286
|
-
if (!cleanCode) throw new Error('[anthropic-oauth] authorization code is required');
|
|
2287
|
-
const tokenRes = await fetch(TOKEN_URL, {
|
|
2288
|
-
method: 'POST',
|
|
2289
|
-
headers: {
|
|
2290
|
-
'Content-Type': 'application/json',
|
|
2291
|
-
'anthropic-dangerous-direct-browser-access': 'true',
|
|
2292
|
-
'user-agent': `claude-cli/${resolveCliVersion()} (external, sdk-cli)`,
|
|
2293
|
-
},
|
|
2294
|
-
body: JSON.stringify({
|
|
2295
|
-
grant_type: 'authorization_code',
|
|
2296
|
-
code: cleanCode,
|
|
2297
|
-
redirect_uri: redirectUri,
|
|
2298
|
-
client_id: CLAUDE_CODE_CLIENT_ID,
|
|
2299
|
-
code_verifier: pkce.verifier,
|
|
2300
|
-
state,
|
|
2301
|
-
}),
|
|
2302
|
-
redirect: 'error',
|
|
2303
|
-
signal: AbortSignal.timeout(OAUTH_TOKEN_TIMEOUT_MS),
|
|
2304
|
-
dispatcher: getLlmDispatcher(),
|
|
2305
|
-
});
|
|
2306
|
-
if (!tokenRes.ok) {
|
|
2307
|
-
const text = await tokenRes.text().catch(() => '');
|
|
2308
|
-
throw new Error(`[anthropic-oauth] token exchange ${tokenRes.status}: ${_scrubTokens(text).slice(0, 500)}`);
|
|
2309
|
-
}
|
|
2310
|
-
const json = await tokenRes.json();
|
|
2311
|
-
const accessToken = json?.access_token || json?.accessToken;
|
|
2312
|
-
const refreshToken = json?.refresh_token || json?.refreshToken;
|
|
2313
|
-
if (!accessToken || !refreshToken) {
|
|
2314
|
-
throw new Error('[anthropic-oauth] token exchange response missing access_token or refresh_token');
|
|
2315
|
-
}
|
|
2316
|
-
const expiresAt = _normalizeExpiresAt(json?.expires_at ?? json?.expiresAt)
|
|
2317
|
-
|| (typeof json?.expires_in === 'number' ? Date.now() + json.expires_in * 1000 : 0);
|
|
2318
|
-
const scopes = _oauthParseScopeField(json?.scope);
|
|
2319
|
-
const credPath = _oauthCredentialsWritePath();
|
|
2320
|
-
let raw = {};
|
|
2321
|
-
if (existsSync(credPath)) {
|
|
2322
|
-
raw = JSON.parse(readFileSync(credPath, 'utf-8'));
|
|
2323
|
-
}
|
|
2324
|
-
const existingOauth = raw.claudeAiOauth || {};
|
|
2325
|
-
raw.claudeAiOauth = {
|
|
2326
|
-
...existingOauth,
|
|
2327
|
-
accessToken,
|
|
2328
|
-
refreshToken,
|
|
2329
|
-
expiresAt,
|
|
2330
|
-
scopes,
|
|
2331
|
-
subscriptionType: existingOauth.subscriptionType ?? null,
|
|
2332
|
-
};
|
|
2333
|
-
_saveCredentialsFile(credPath, raw);
|
|
2334
|
-
return {
|
|
2335
|
-
path: credPath,
|
|
2336
|
-
accessToken,
|
|
2337
|
-
refreshToken,
|
|
2338
|
-
expiresAt,
|
|
2339
|
-
scopes,
|
|
2340
|
-
subscriptionType: raw.claudeAiOauth.subscriptionType,
|
|
2341
|
-
};
|
|
2342
|
-
}
|
|
2343
|
-
|
|
2344
|
-
export async function beginOAuthLogin() {
|
|
2345
|
-
const pkce = _oauthGeneratePKCE();
|
|
2346
|
-
const state = randomBytes(32).toString('base64url');
|
|
2347
|
-
const buildUrl = (redirectUri) => {
|
|
2348
|
-
const url = new URL(CLAUDE_AI_AUTHORIZE_URL);
|
|
2349
|
-
url.searchParams.set('code', 'true');
|
|
2350
|
-
url.searchParams.set('client_id', CLAUDE_CODE_CLIENT_ID);
|
|
2351
|
-
url.searchParams.set('response_type', 'code');
|
|
2352
|
-
url.searchParams.set('redirect_uri', redirectUri);
|
|
2353
|
-
url.searchParams.set('scope', OAUTH_LOGIN_SCOPE);
|
|
2354
|
-
url.searchParams.set('code_challenge', pkce.challenge);
|
|
2355
|
-
url.searchParams.set('code_challenge_method', 'S256');
|
|
2356
|
-
url.searchParams.set('state', state);
|
|
2357
|
-
return url;
|
|
2358
|
-
};
|
|
2359
|
-
const url = buildUrl(OAUTH_REDIRECT_URI);
|
|
2360
|
-
const manualUrl = buildUrl(OAUTH_MANUAL_REDIRECT_URI);
|
|
2361
|
-
|
|
2362
|
-
let server = null;
|
|
2363
|
-
let timeout = null;
|
|
2364
|
-
let finish = null;
|
|
2365
|
-
const waitForCallback = new Promise((resolve, reject) => {
|
|
2366
|
-
let settled = false;
|
|
2367
|
-
finish = (value, error = null) => {
|
|
2368
|
-
if (settled) return;
|
|
2369
|
-
settled = true;
|
|
2370
|
-
if (timeout) clearTimeout(timeout);
|
|
2371
|
-
try { server?.close(); } catch { /* already closed */ }
|
|
2372
|
-
if (error) reject(error);
|
|
2373
|
-
else resolve(value);
|
|
2374
|
-
};
|
|
2375
|
-
server = createServer(async (req, res) => {
|
|
2376
|
-
const u = new URL(req.url || '/', `http://${OAUTH_CALLBACK_HOST}:${OAUTH_CALLBACK_PORT}`);
|
|
2377
|
-
if (u.pathname !== OAUTH_CALLBACK_PATH) {
|
|
2378
|
-
res.writeHead(404);
|
|
2379
|
-
res.end();
|
|
2380
|
-
return;
|
|
2381
|
-
}
|
|
2382
|
-
const code = u.searchParams.get('code');
|
|
2383
|
-
if (!code || u.searchParams.get('state') !== state) {
|
|
2384
|
-
res.writeHead(400);
|
|
2385
|
-
res.end('Invalid');
|
|
2386
|
-
finish(null);
|
|
2387
|
-
return;
|
|
2388
|
-
}
|
|
2389
|
-
try {
|
|
2390
|
-
const tokens = await exchangeAuthorizationCode({ pkce, code, state, redirectUri: OAUTH_REDIRECT_URI });
|
|
2391
|
-
res.writeHead(302, { Location: OAUTH_SUCCESS_REDIRECT_URL });
|
|
2392
|
-
res.end();
|
|
2393
|
-
finish(tokens);
|
|
2394
|
-
} catch (err) {
|
|
2395
|
-
const error = err instanceof Error ? err : new Error(String(err));
|
|
2396
|
-
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
2397
|
-
res.end(`Claude login failed: ${error.message}`);
|
|
2398
|
-
finish(null, error);
|
|
2399
|
-
}
|
|
2400
|
-
});
|
|
2401
|
-
timeout = setTimeout(() => finish(null), OAUTH_LOGIN_TIMEOUT_MS);
|
|
2402
|
-
server.listen(OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_HOST, async () => {
|
|
2403
|
-
process.stderr.write(`\n[anthropic-oauth] Open this URL to log in with Claude:\n${url.toString()}\n\nIf the localhost callback cannot complete, open this manual URL and paste the shown code#state:\n${manualUrl.toString()}\n\n`);
|
|
2404
|
-
try {
|
|
2405
|
-
const { openInBrowser } = await import('../../../shared/open-url.mjs');
|
|
2406
|
-
openInBrowser(url.toString());
|
|
2407
|
-
} catch (err) {
|
|
2408
|
-
process.stderr.write(`[anthropic-oauth] browser open failed: ${String(err?.message || err).slice(0, 200)}\n`);
|
|
2409
|
-
}
|
|
2410
|
-
});
|
|
2411
|
-
server.on('error', (err) => finish(null, new Error(`[anthropic-oauth] callback server failed on ${OAUTH_CALLBACK_HOST}:${OAUTH_CALLBACK_PORT}: ${err?.message || err}`)));
|
|
2412
|
-
});
|
|
2413
|
-
|
|
2414
|
-
return {
|
|
2415
|
-
provider: 'anthropic-oauth',
|
|
2416
|
-
url: url.toString(),
|
|
2417
|
-
manualUrl: manualUrl.toString(),
|
|
2418
|
-
waitForCallback,
|
|
2419
|
-
completeCode: async (input) => {
|
|
2420
|
-
const parsed = _parseOAuthCodeInput(input);
|
|
2421
|
-
if (parsed.state && parsed.state !== state) throw new Error('[anthropic-oauth] OAuth state mismatch');
|
|
2422
|
-
const redirectUri = parsed.redirectUri || (parsed.state ? OAUTH_MANUAL_REDIRECT_URI : OAUTH_REDIRECT_URI);
|
|
2423
|
-
const tokens = await exchangeAuthorizationCode({ pkce, code: parsed.code, state, redirectUri });
|
|
2424
|
-
finish?.(tokens);
|
|
2425
|
-
return tokens;
|
|
2426
|
-
},
|
|
2427
|
-
cancel: () => {
|
|
2428
|
-
finish?.(null);
|
|
2429
|
-
},
|
|
2430
|
-
};
|
|
2431
|
-
}
|
|
2432
|
-
|
|
2433
|
-
export async function loginOAuth() {
|
|
2434
|
-
const login = await beginOAuthLogin();
|
|
2435
|
-
return await login.waitForCallback;
|
|
2436
|
-
}
|
|
1228
|
+
// Re-exports so external callers of anthropic-oauth.mjs keep their existing
|
|
1229
|
+
// import path after the credential/login-flow extraction into
|
|
1230
|
+
// anthropic-oauth-credentials.mjs.
|
|
1231
|
+
export {
|
|
1232
|
+
hasAnthropicOAuthCredentials,
|
|
1233
|
+
describeAnthropicOAuthCredentials,
|
|
1234
|
+
forgetAnthropicOAuthCredentials,
|
|
1235
|
+
beginOAuthLogin,
|
|
1236
|
+
loginOAuth,
|
|
1237
|
+
};
|
|
2437
1238
|
|
|
2438
|
-
//
|
|
2439
|
-
//
|
|
2440
|
-
|
|
2441
|
-
export { parseSSEStream };
|
|
1239
|
+
// Re-exports so anthropic.mjs and the test harnesses keep their existing
|
|
1240
|
+
// import path after the SSE-parser extraction into anthropic-sse.mjs.
|
|
1241
|
+
export { parseSSEStream, _classifyMidstreamError, ANTHROPIC_MAX_MIDSTREAM_RETRIES };
|
|
2442
1242
|
|
|
2443
1243
|
// Test-only escape hatch for scripts/tool-smoke.mjs to verify the
|
|
2444
1244
|
// catalog-driven max-tokens resolution without duplicating its logic.
|