claude-code-infinite 1.0.9 → 1.0.14
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/README.md +230 -37
- package/dist/cc-request.d.ts +94 -0
- package/dist/cc-request.d.ts.map +1 -0
- package/dist/cc-request.js +194 -0
- package/dist/cc-request.js.map +1 -0
- package/dist/claude-env.d.ts +30 -0
- package/dist/claude-env.d.ts.map +1 -0
- package/dist/claude-env.js +55 -0
- package/dist/claude-env.js.map +1 -0
- package/dist/cli-args.d.ts +32 -0
- package/dist/cli-args.d.ts.map +1 -0
- package/dist/cli-args.js +72 -0
- package/dist/cli-args.js.map +1 -0
- package/dist/cli-lifecycle.d.ts +17 -0
- package/dist/cli-lifecycle.d.ts.map +1 -0
- package/dist/cli-lifecycle.js +30 -0
- package/dist/cli-lifecycle.js.map +1 -0
- package/dist/cli-main.d.ts +15 -0
- package/dist/cli-main.d.ts.map +1 -0
- package/dist/cli-main.js +464 -0
- package/dist/cli-main.js.map +1 -0
- package/dist/cli.js +13 -177
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +3 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +29 -3
- package/dist/config.js.map +1 -1
- package/dist/count-tokens.d.ts +78 -0
- package/dist/count-tokens.d.ts.map +1 -0
- package/dist/count-tokens.js +277 -0
- package/dist/count-tokens.js.map +1 -0
- package/dist/final-index.d.ts +10 -0
- package/dist/final-index.d.ts.map +1 -0
- package/dist/final-index.js +37 -0
- package/dist/final-index.js.map +1 -0
- package/dist/hooks.d.ts +239 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +618 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/memtree-fetch.d.ts +24 -0
- package/dist/memtree-fetch.d.ts.map +1 -0
- package/dist/memtree-fetch.js +121 -0
- package/dist/memtree-fetch.js.map +1 -0
- package/dist/memtree-finder.d.ts +99 -0
- package/dist/memtree-finder.d.ts.map +1 -0
- package/dist/memtree-finder.js +235 -0
- package/dist/memtree-finder.js.map +1 -0
- package/dist/memtree-links.d.ts +16 -0
- package/dist/memtree-links.d.ts.map +1 -0
- package/dist/memtree-links.js +66 -0
- package/dist/memtree-links.js.map +1 -0
- package/dist/memtree-mcp-config.d.ts +28 -0
- package/dist/memtree-mcp-config.d.ts.map +1 -0
- package/dist/memtree-mcp-config.js +121 -0
- package/dist/memtree-mcp-config.js.map +1 -0
- package/dist/memtree-mcp.d.ts +265 -0
- package/dist/memtree-mcp.d.ts.map +1 -0
- package/dist/memtree-mcp.js +667 -0
- package/dist/memtree-mcp.js.map +1 -0
- package/dist/memtree-newest.d.ts +74 -0
- package/dist/memtree-newest.d.ts.map +1 -0
- package/dist/memtree-newest.js +217 -0
- package/dist/memtree-newest.js.map +1 -0
- package/dist/memtree-tools.d.ts +105 -0
- package/dist/memtree-tools.d.ts.map +1 -0
- package/dist/memtree-tools.js +408 -0
- package/dist/memtree-tools.js.map +1 -0
- package/dist/memtree.d.ts +398 -0
- package/dist/memtree.d.ts.map +1 -0
- package/dist/memtree.js +1231 -0
- package/dist/memtree.js.map +1 -0
- package/dist/notices.d.ts +177 -0
- package/dist/notices.d.ts.map +1 -0
- package/dist/notices.js +469 -0
- package/dist/notices.js.map +1 -0
- package/dist/payment-gate.d.ts +53 -0
- package/dist/payment-gate.d.ts.map +1 -0
- package/dist/payment-gate.js +97 -0
- package/dist/payment-gate.js.map +1 -0
- package/dist/project-meta.d.ts +15 -0
- package/dist/project-meta.d.ts.map +1 -0
- package/dist/project-meta.js +80 -0
- package/dist/project-meta.js.map +1 -0
- package/dist/prompt-accounting.d.ts +12 -0
- package/dist/prompt-accounting.d.ts.map +1 -0
- package/dist/prompt-accounting.js +70 -0
- package/dist/prompt-accounting.js.map +1 -0
- package/dist/proxy.d.ts +197 -0
- package/dist/proxy.d.ts.map +1 -0
- package/dist/proxy.js +4247 -0
- package/dist/proxy.js.map +1 -0
- package/dist/reqlog.d.ts +419 -0
- package/dist/reqlog.d.ts.map +1 -0
- package/dist/reqlog.js +146 -0
- package/dist/reqlog.js.map +1 -0
- package/dist/request-sizing.d.ts +52 -0
- package/dist/request-sizing.d.ts.map +1 -0
- package/dist/request-sizing.js +92 -0
- package/dist/request-sizing.js.map +1 -0
- package/dist/resume-repair-file.d.ts +17 -0
- package/dist/resume-repair-file.d.ts.map +1 -0
- package/dist/resume-repair-file.js +100 -0
- package/dist/resume-repair-file.js.map +1 -0
- package/dist/resume-repair.d.ts +27 -0
- package/dist/resume-repair.d.ts.map +1 -0
- package/dist/resume-repair.js +198 -0
- package/dist/resume-repair.js.map +1 -0
- package/dist/route-cache.d.ts +5 -0
- package/dist/route-cache.d.ts.map +1 -0
- package/dist/route-cache.js +63 -0
- package/dist/route-cache.js.map +1 -0
- package/dist/route-fallback.d.ts +29 -0
- package/dist/route-fallback.d.ts.map +1 -0
- package/dist/route-fallback.js +49 -0
- package/dist/route-fallback.js.map +1 -0
- package/dist/route-identity.d.ts +6 -0
- package/dist/route-identity.d.ts.map +1 -0
- package/dist/route-identity.js +100 -0
- package/dist/route-identity.js.map +1 -0
- package/dist/transcript-usage.d.ts +57 -0
- package/dist/transcript-usage.d.ts.map +1 -0
- package/dist/transcript-usage.js +570 -0
- package/dist/transcript-usage.js.map +1 -0
- package/dist/turns.d.ts +73 -0
- package/dist/turns.d.ts.map +1 -0
- package/dist/turns.js +298 -0
- package/dist/turns.js.map +1 -0
- package/dist/update-check.d.ts +38 -0
- package/dist/update-check.d.ts.map +1 -0
- package/dist/update-check.js +67 -0
- package/dist/update-check.js.map +1 -0
- package/package.json +6 -2
package/dist/proxy.js
ADDED
|
@@ -0,0 +1,4247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code Infinite local proxy (plans/2026-06-09_PLAN_local_proxy_app.md,
|
|
3
|
+
* refined by plans/2026-07-05_PLAN_first_user_turn_nonblocking.md).
|
|
4
|
+
*
|
|
5
|
+
* Claude Code points ANTHROPIC_BASE_URL at this 127.0.0.1 server. We never
|
|
6
|
+
* read, store, or refresh credentials: Claude Code keeps its native login and
|
|
7
|
+
* sends its own OAuth bearer here, and we forward its headers and query string
|
|
8
|
+
* verbatim to api.anthropic.com (the anthropic-beta flag list churns across CC
|
|
9
|
+
* versions — never reconstruct it). Only the `messages` body is ever altered
|
|
10
|
+
* (compression, plus defensive removal of legacy notice markers);
|
|
11
|
+
* auth, identity, and routing are never touched.
|
|
12
|
+
*
|
|
13
|
+
* Turn classification for POST /v1/messages:
|
|
14
|
+
* - Tool turn (last message isn't a real user input): background indexing,
|
|
15
|
+
* forward as-is — riding its lane's memory route (routes live in a small
|
|
16
|
+
* map keyed by request identity: session + main/away/agent-id), on the main
|
|
17
|
+
* thread the session's stable prefix, or verbatim — while the estimated
|
|
18
|
+
* size is under the budget, with no compress call. At the budget it
|
|
19
|
+
* compresses once to the target (planToolCompaction), the same rule human
|
|
20
|
+
* turns follow; on the main thread the result becomes the stable prefix
|
|
21
|
+
* later tool and human turns ride. Failure degrades to the old ride or the
|
|
22
|
+
* verbatim forward. Every identity installs into its own lane — isolation
|
|
23
|
+
* is structural, not defended.
|
|
24
|
+
* - First user turn (no earlier real user input): background indexing, forward
|
|
25
|
+
* as-is — nothing is indexed yet, so blocking would be a guaranteed no-op.
|
|
26
|
+
* - Followup user turn: blocking compress + substitute. The compressed body
|
|
27
|
+
* remains the prefix for that turn's tool loop. On the main thread a
|
|
28
|
+
* compaction also becomes the session's stable prefix (planEdgeCompaction):
|
|
29
|
+
* later human turns ride it byte for byte, with no compress call, until
|
|
30
|
+
* prefix + newer turns reach the budget or the covered messages change.
|
|
31
|
+
* - MemTree failure/timeout degrades to passthrough. A display-only success
|
|
32
|
+
* notice is queued only when the memory response is selected AND MemTree's
|
|
33
|
+
* index coverage grew since the last announcement (unchanged coverage means
|
|
34
|
+
* the turn was appended after an index that learned nothing new); degraded
|
|
35
|
+
* and unpaid states get their own notices.
|
|
36
|
+
*
|
|
37
|
+
* Every /v1/messages and count_tokens body is run through the legacy notice
|
|
38
|
+
* strip pass before hashing/forwarding. Live notices use Claude Code hooks and
|
|
39
|
+
* upstream response bytes pass through to the client unchanged.
|
|
40
|
+
*/
|
|
41
|
+
import { capCacheBreakpoints } from "./route-cache.js";
|
|
42
|
+
import { PromptAccounting } from "./prompt-accounting.js";
|
|
43
|
+
import { RouteFallbackFailures } from "./route-fallback.js";
|
|
44
|
+
import { createCountTokensSession, CountTokensCooldowns, plausibleSample, } from "./count-tokens.js";
|
|
45
|
+
import { RequestSizing, estimateRequestTokens, fitsNative } from "./request-sizing.js";
|
|
46
|
+
import { routeMessageHash, stablePrefixMessageHash } from "./route-identity.js";
|
|
47
|
+
import http from "node:http";
|
|
48
|
+
import https from "node:https";
|
|
49
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
50
|
+
import { brotliDecompressSync, createBrotliDecompress, createGunzip, createInflate, gunzipSync, inflateSync, } from "node:zlib";
|
|
51
|
+
import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
|
|
52
|
+
import { join } from "node:path";
|
|
53
|
+
import { cachedPromptTokenCount, checkCompressedHistory, didMemtreeCompress, MemtreeClient, modelBudgetTokens, normalizeMessagesForMemtree, serverFlattenedMessages, rawPromptTokenCount, } from "./memtree.js";
|
|
54
|
+
import { contextLimitForModel, hasEarlierNonToolUserMessage, isAwaySummaryUserMessage, isLocalBashCommandTurn, isNonToolUserMessage, isToolResultUserMessage, lastNonSystemMessage, messagesWithSystem, modelForMemtree, stripSystemReminderText, } from "./turns.js";
|
|
55
|
+
import { COMPRESSED_NOTICE, compressedTotalsText, DEGRADED_NOTICE, NOT_COMPRESSED_NOTE, recapLinkText, PAYMENT_REQUIRED_NOTICE, SseNoticeRewriter, sanitizeNoticeDetail, stripNoticeBlocks, stripNoticeSystem, } from "./notices.js";
|
|
56
|
+
import { NoticeDeliveryQueue, MEMTREE_COMPACT_COMMAND, MEMTREE_HELP_COMMAND, TRAILER_LABEL, LINK_LABEL, linkLines, terminalSupportsHyperlinks, isMemtreeViewCommand, sessionCommandArgs, parseNoticeHookInput, } from "./hooks.js";
|
|
57
|
+
import { hyperlink } from "./payment-gate.js";
|
|
58
|
+
import { MEMTREE_LINKS_MAX_SESSIONS } from "./memtree-links.js";
|
|
59
|
+
import { describeClaudeCodeRequest, inspectMonitorTranscript, isClaudeCodeSideRequest, memtreeClientMeta, sessionTag, } from "./cc-request.js";
|
|
60
|
+
import { approxTokensFromBytes, mergeUsageFromJsonBody, mergeUsageFromSseEvent, } from "./reqlog.js";
|
|
61
|
+
const DEFAULT_UPSTREAM = "https://api.anthropic.com";
|
|
62
|
+
const HOOK_BODY_LIMIT = 64 * 1024;
|
|
63
|
+
// After a recovery attempt returns null (MemTree down, 5xx, or a burned
|
|
64
|
+
// timeout budget), suppress the blocking attempt for this long, across every
|
|
65
|
+
// lane. A lane's own growth backoff alone would let each over-budget tool turn
|
|
66
|
+
// pay the failed call again once its history grew past the retry size, and
|
|
67
|
+
// every other lane would pay it too — history grows every turn, so
|
|
68
|
+
// compress()'s complete-request dedup never absorbs the repeat.
|
|
69
|
+
// Long enough that a real outage costs one stall, short enough that a
|
|
70
|
+
// transient blip does not disable recovery for a working session.
|
|
71
|
+
const TOOL_RECOVERY_FAILURE_COOLDOWN_MS = 60_000;
|
|
72
|
+
const SKIP_REQUEST_HEADERS = new Set([
|
|
73
|
+
"host",
|
|
74
|
+
"connection",
|
|
75
|
+
"keep-alive",
|
|
76
|
+
"transfer-encoding",
|
|
77
|
+
"te",
|
|
78
|
+
"upgrade",
|
|
79
|
+
"proxy-authorization",
|
|
80
|
+
"proxy-connection",
|
|
81
|
+
"content-length", // recomputed for buffered/modified bodies
|
|
82
|
+
]);
|
|
83
|
+
const SKIP_RESPONSE_HEADERS = new Set([
|
|
84
|
+
"connection",
|
|
85
|
+
"keep-alive",
|
|
86
|
+
"transfer-encoding",
|
|
87
|
+
]);
|
|
88
|
+
/**
|
|
89
|
+
* Budget fallback until the server reports `model_budget_tokens`: this share
|
|
90
|
+
* of the model's context window (800k of Opus 5.5's 1M, matching the server's
|
|
91
|
+
* large-context threshold).
|
|
92
|
+
*/
|
|
93
|
+
export const FALLBACK_BUDGET_WINDOW_RATIO = 0.8;
|
|
94
|
+
/**
|
|
95
|
+
* An explicit target is capped at this share of the budget: a compaction to
|
|
96
|
+
* the budget itself would be over budget again on the next turn and
|
|
97
|
+
* recompress on every turn.
|
|
98
|
+
*/
|
|
99
|
+
const MAX_EXPLICIT_TARGET_BUDGET_RATIO = 0.9;
|
|
100
|
+
/** The server rejects smaller compression targets and thresholds. */
|
|
101
|
+
const SERVER_MIN_TARGET_TOKENS = 10_000;
|
|
102
|
+
/** Sessions whose stable prefix / passthrough size is kept (LRU). */
|
|
103
|
+
const STABLE_PREFIX_MAX_SESSIONS = 16;
|
|
104
|
+
/**
|
|
105
|
+
* Background check of whether a lane's awaited tree exists: its page answers
|
|
106
|
+
* 200 once built (202 while building). Any other answer stops the wait, so a
|
|
107
|
+
* page that never resolves falls back to the growth backoff instead of
|
|
108
|
+
* blocking compaction for good.
|
|
109
|
+
*/
|
|
110
|
+
function checkAwaitedIndex(opts, attempt, shutdownSignal) {
|
|
111
|
+
const waiting = attempt.awaitingIndex;
|
|
112
|
+
if (!waiting || waiting.checking || shutdownSignal.aborted)
|
|
113
|
+
return;
|
|
114
|
+
waiting.checking = true;
|
|
115
|
+
// Bound both the individual body read and repeated 202 responses. A page
|
|
116
|
+
// that never completes must eventually return the lane to growth backoff.
|
|
117
|
+
const timeoutMs = Math.max(1, Math.min(opts.awaitedIndexProbeTimeoutMs ?? 5_000, waiting.deadline - Date.now()));
|
|
118
|
+
const signal = AbortSignal.any([shutdownSignal, AbortSignal.timeout(timeoutMs)]);
|
|
119
|
+
opts.memtree
|
|
120
|
+
.fetchMemTree(`/usage/memtree/${waiting.pageId}.json`, "application/json", signal)
|
|
121
|
+
.then((page) => {
|
|
122
|
+
if (page.status === 202 && Date.now() < waiting.deadline)
|
|
123
|
+
return;
|
|
124
|
+
if (attempt.awaitingIndex === waiting)
|
|
125
|
+
attempt.awaitingIndex = undefined;
|
|
126
|
+
// A finished tree is exactly what the backoff was waiting for.
|
|
127
|
+
if (page.status === 200)
|
|
128
|
+
attempt.retryAtTokens = undefined;
|
|
129
|
+
})
|
|
130
|
+
.catch(() => {
|
|
131
|
+
if (attempt.awaitingIndex === waiting)
|
|
132
|
+
attempt.awaitingIndex = undefined;
|
|
133
|
+
})
|
|
134
|
+
.finally(() => {
|
|
135
|
+
waiting.checking = false;
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
/** Growth (share of the budget) a lane waits for after an attempt that failed. */
|
|
139
|
+
const TOOL_COMPACTION_RETRY_BUDGET_RATIO = 0.05;
|
|
140
|
+
/**
|
|
141
|
+
* Bound on concurrently held route lanes. A route entry is 0.4–4 MB of heap,
|
|
142
|
+
* so 32 lanes is honestly ~128 MB worst case — accepted so a wide agent
|
|
143
|
+
* fan-out (main + away + dozens of live subagents) cannot LRU-evict main's
|
|
144
|
+
* route mid-turn; the cap is enforced by LRU eviction, not assumed.
|
|
145
|
+
*/
|
|
146
|
+
const MEMORY_ROUTE_MAX_LANES = 32;
|
|
147
|
+
const DEFERRED_MAIN_PROMPT_LIMIT = 32;
|
|
148
|
+
/**
|
|
149
|
+
* The agent id a request is attributed by, preferring its own id over its
|
|
150
|
+
* parent's. Absence of both is the definition of main-thread attribution, so
|
|
151
|
+
* hasAgentAttribution is this same extraction asked as a yes/no question.
|
|
152
|
+
*/
|
|
153
|
+
function agentAttributionId(req) {
|
|
154
|
+
return (firstNonEmptyHeader(req, "x-claude-code-agent-id") ??
|
|
155
|
+
firstNonEmptyHeader(req, "x-claude-code-parent-agent-id"));
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Collision-free identity of a memory-route lane: the label the reqlog reports
|
|
159
|
+
* and the key every map is addressed by, classified once from the same headers
|
|
160
|
+
* so the two can never disagree. The tuple tags reserved main/away lanes
|
|
161
|
+
* separately from agent ids, so an agent literally named "main" or "away"
|
|
162
|
+
* cannot alias either reserved lane; JSON array encoding also prevents
|
|
163
|
+
* delimiter collisions between arbitrary session and agent headers.
|
|
164
|
+
* Keying by identity is what makes lane isolation structural: a request can
|
|
165
|
+
* only ever reach its own lane, so foreign-route defense is unnecessary rather
|
|
166
|
+
* than implemented.
|
|
167
|
+
*/
|
|
168
|
+
function routeIdentity(req, isAwaySummary) {
|
|
169
|
+
const session = requestSessionId(req) ?? "";
|
|
170
|
+
const agent = agentAttributionId(req);
|
|
171
|
+
if (isAwaySummary) {
|
|
172
|
+
return { lane: "away", key: JSON.stringify([session, "away"]) };
|
|
173
|
+
}
|
|
174
|
+
if (agent) {
|
|
175
|
+
return { lane: "agent", key: JSON.stringify([session, "agent", agent]) };
|
|
176
|
+
}
|
|
177
|
+
return { lane: "main", key: JSON.stringify([session, "main"]) };
|
|
178
|
+
}
|
|
179
|
+
function firstNonEmptyHeader(req, name) {
|
|
180
|
+
const value = req.headers[name];
|
|
181
|
+
const text = Array.isArray(value)
|
|
182
|
+
? value.find((item) => item.trim() !== "")
|
|
183
|
+
: value;
|
|
184
|
+
return typeof text === "string" && text.trim() ? text.trim() : undefined;
|
|
185
|
+
}
|
|
186
|
+
/** Record notice context without changing any route. */
|
|
187
|
+
function recordMainPromptNotice(state, promptId, prompt) {
|
|
188
|
+
state.mainNoticePending = true;
|
|
189
|
+
state.mainPromptId = promptId;
|
|
190
|
+
state.mainPromptText = prompt;
|
|
191
|
+
state.mainPromptGeneration++;
|
|
192
|
+
state.notices.clearForUserRequest();
|
|
193
|
+
}
|
|
194
|
+
/** Snapshot display accounting before asynchronous work. It never controls forwarding. */
|
|
195
|
+
function capturePromptDelivery(state, isMainRequest, lastMsg, sessionId) {
|
|
196
|
+
return { settle: isMainRequest
|
|
197
|
+
? state.promptAccounting.capture(sessionId, lastMsg) : () => { } };
|
|
198
|
+
}
|
|
199
|
+
/** Lookup a validated route and update its LRU position. */
|
|
200
|
+
function getMemoryRoute(state, key) {
|
|
201
|
+
const route = state.memoryRoutes.get(key);
|
|
202
|
+
if (!route)
|
|
203
|
+
return undefined;
|
|
204
|
+
if (route.routeEpoch !== state.mainRouteEpoch) {
|
|
205
|
+
state.memoryRoutes.delete(key);
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
state.memoryRoutes.delete(key);
|
|
209
|
+
state.memoryRoutes.set(key, route);
|
|
210
|
+
return route;
|
|
211
|
+
}
|
|
212
|
+
function setMemoryRoute(state, key, route) {
|
|
213
|
+
state.memoryRoutes.delete(key);
|
|
214
|
+
state.memoryRoutes.set(key, route);
|
|
215
|
+
while (state.memoryRoutes.size > MEMORY_ROUTE_MAX_LANES) {
|
|
216
|
+
const oldest = state.memoryRoutes.keys().next().value;
|
|
217
|
+
if (oldest === undefined)
|
|
218
|
+
break;
|
|
219
|
+
state.memoryRoutes.delete(oldest);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
function resolveUpstream(opts) {
|
|
223
|
+
const url = new URL(opts.upstreamOrigin ?? DEFAULT_UPSTREAM);
|
|
224
|
+
const secure = url.protocol === "https:";
|
|
225
|
+
return {
|
|
226
|
+
module: secure ? https : http,
|
|
227
|
+
host: url.hostname,
|
|
228
|
+
port: url.port ? Number(url.port) : secure ? 443 : 80,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
export function startProxy(opts) {
|
|
232
|
+
const upstream = resolveUpstream(opts);
|
|
233
|
+
const hookPath = `/_ccc/hooks/${randomBytes(24).toString("hex")}`;
|
|
234
|
+
const activeRequests = new Set();
|
|
235
|
+
const acceptedRequests = new Set();
|
|
236
|
+
const shutdownAbort = new AbortController();
|
|
237
|
+
const state = {
|
|
238
|
+
paymentNoticeShown: false,
|
|
239
|
+
notices: new NoticeDeliveryQueue(),
|
|
240
|
+
mainNoticePending: false,
|
|
241
|
+
mainPromptGeneration: 0,
|
|
242
|
+
promptAccounting: new PromptAccounting(),
|
|
243
|
+
fallbackFailures: new RouteFallbackFailures(),
|
|
244
|
+
activeSubagents: new Set(),
|
|
245
|
+
memoryRoutes: new Map(),
|
|
246
|
+
mainRouteEpoch: 0,
|
|
247
|
+
mainRouteDecisionGeneration: 0,
|
|
248
|
+
routeDecisionsLive: new Map(),
|
|
249
|
+
retiredRouteDecisionFloor: 0,
|
|
250
|
+
toolRecoveryAttemptedLanes: new Map(),
|
|
251
|
+
toolRecoveryCooldownUntil: 0,
|
|
252
|
+
shutdownSignal: shutdownAbort.signal,
|
|
253
|
+
routeInstallFault: opts.routeInstallFault,
|
|
254
|
+
memtreePages: new Map(),
|
|
255
|
+
memtreeCallSeq: 0,
|
|
256
|
+
memtreeLinkStore: opts.memtreeLinkStore,
|
|
257
|
+
newestTrees: opts.newestTrees,
|
|
258
|
+
memtreeLinkPlacement: opts.memtreeLinkPlacement ?? "turn",
|
|
259
|
+
compactTargets: new Map(),
|
|
260
|
+
defaultCompactOff: opts.defaultCompactTarget === null,
|
|
261
|
+
compactNow: new Set(),
|
|
262
|
+
stablePrefixes: new Map(),
|
|
263
|
+
serverBudgets: new Map(),
|
|
264
|
+
serverReportsBudget: false,
|
|
265
|
+
countTokensCooldowns: new CountTokensCooldowns(),
|
|
266
|
+
passthroughSizes: new Map(),
|
|
267
|
+
laneSizes: new Map(),
|
|
268
|
+
};
|
|
269
|
+
installMemtreeLink(state, state.memtreeLinkPlacement);
|
|
270
|
+
const server = http.createServer((req, res) => {
|
|
271
|
+
const accepted = { req, res };
|
|
272
|
+
acceptedRequests.add(accepted);
|
|
273
|
+
const task = handleRequest(req, res, opts, upstream, state, hookPath).catch((err) => {
|
|
274
|
+
try {
|
|
275
|
+
sendAnthropicError(res, `local proxy error: ${err?.message ?? err}`);
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
// A failed error response must not strand shutdown bookkeeping.
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
activeRequests.add(task);
|
|
282
|
+
void task.then(() => {
|
|
283
|
+
activeRequests.delete(task);
|
|
284
|
+
acceptedRequests.delete(accepted);
|
|
285
|
+
}, () => {
|
|
286
|
+
activeRequests.delete(task);
|
|
287
|
+
acceptedRequests.delete(accepted);
|
|
288
|
+
});
|
|
289
|
+
if (shutdownAbort.signal.aborted)
|
|
290
|
+
cancelAcceptedRequest(accepted);
|
|
291
|
+
});
|
|
292
|
+
// Long-running SSE responses must not be cut by idle timeouts.
|
|
293
|
+
server.requestTimeout = 0;
|
|
294
|
+
server.headersTimeout = 60_000;
|
|
295
|
+
let closePromise;
|
|
296
|
+
const beginClose = () => {
|
|
297
|
+
if (closePromise)
|
|
298
|
+
return closePromise;
|
|
299
|
+
closePromise = new Promise((done) => {
|
|
300
|
+
if (!server.listening) {
|
|
301
|
+
done();
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
server.close(() => done());
|
|
305
|
+
});
|
|
306
|
+
return closePromise;
|
|
307
|
+
};
|
|
308
|
+
const drain = async (timeoutMs = 5_000) => {
|
|
309
|
+
const boundedMs = Number.isFinite(timeoutMs) && timeoutMs >= 0
|
|
310
|
+
? Math.floor(timeoutMs)
|
|
311
|
+
: 5_000;
|
|
312
|
+
const quiesced = (async () => {
|
|
313
|
+
// Once close completes, every accepted request has entered the tracked
|
|
314
|
+
// set.
|
|
315
|
+
await beginClose();
|
|
316
|
+
while (activeRequests.size > 0) {
|
|
317
|
+
await Promise.allSettled([...activeRequests]);
|
|
318
|
+
}
|
|
319
|
+
})();
|
|
320
|
+
let timer;
|
|
321
|
+
const completed = await Promise.race([
|
|
322
|
+
quiesced.then(() => true),
|
|
323
|
+
new Promise((resolve) => {
|
|
324
|
+
timer = setTimeout(() => resolve(false), boundedMs);
|
|
325
|
+
}),
|
|
326
|
+
]);
|
|
327
|
+
if (timer)
|
|
328
|
+
clearTimeout(timer);
|
|
329
|
+
if (completed)
|
|
330
|
+
return true;
|
|
331
|
+
// The grace period protects useful in-flight delivery. Once it
|
|
332
|
+
// expires, signal every forwarding path first so each can
|
|
333
|
+
// classify the close as proxy-owned, then tear down any request that was
|
|
334
|
+
// still reading/dispatching before it installed a path-specific listener.
|
|
335
|
+
shutdownAbort.abort();
|
|
336
|
+
for (const accepted of acceptedRequests)
|
|
337
|
+
cancelAcceptedRequest(accepted);
|
|
338
|
+
await quiesced;
|
|
339
|
+
return false;
|
|
340
|
+
};
|
|
341
|
+
return new Promise((resolve, reject) => {
|
|
342
|
+
server.once("error", reject);
|
|
343
|
+
server.listen(0, "127.0.0.1", () => {
|
|
344
|
+
const port = server.address().port;
|
|
345
|
+
resolve({
|
|
346
|
+
port,
|
|
347
|
+
hookUrl: `http://127.0.0.1:${port}${hookPath}`,
|
|
348
|
+
close: () => {
|
|
349
|
+
void beginClose();
|
|
350
|
+
},
|
|
351
|
+
drain,
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
/** Final safety net for handlers that have not reached an upstream path yet. */
|
|
357
|
+
function cancelAcceptedRequest(accepted) {
|
|
358
|
+
const { req, res } = accepted;
|
|
359
|
+
if (!req.complete && !req.destroyed) {
|
|
360
|
+
// A pass-through upload may not otherwise have an IncomingMessage error
|
|
361
|
+
// listener; consume this proxy-owned teardown error after body readers see it.
|
|
362
|
+
req.once("error", () => { });
|
|
363
|
+
req.destroy(new Error("proxy shutdown"));
|
|
364
|
+
}
|
|
365
|
+
if (!res.destroyed && !res.writableEnded)
|
|
366
|
+
res.destroy();
|
|
367
|
+
}
|
|
368
|
+
async function handleRequest(req, res, opts, upstream, state, hookPath) {
|
|
369
|
+
const url = new URL(req.url ?? "/", `http://127.0.0.1`);
|
|
370
|
+
if (!isLocalCaller(req)) {
|
|
371
|
+
res.writeHead(403, { "content-type": "application/json" });
|
|
372
|
+
res.end(JSON.stringify({ detail: "The ccc proxy only serves programs on this machine" }));
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
if (url.pathname === hookPath) {
|
|
376
|
+
return handleNoticeHook(req, res, state, opts.reqlog, (sessionId) => opts.memtree.scheduleFinalIndex(sessionId, (signal) => opts.transcriptUsage?.finalReplyAsync
|
|
377
|
+
? opts.transcriptUsage.finalReplyAsync(sessionId, signal)
|
|
378
|
+
: opts.transcriptUsage?.finalReply?.(sessionId)));
|
|
379
|
+
}
|
|
380
|
+
if (req.method === "GET" &&
|
|
381
|
+
(url.pathname === MEMTREE_CURRENT_PATH || url.pathname === `${MEMTREE_CURRENT_PATH}.json`)) {
|
|
382
|
+
return handleMemTreeCurrent(req, res, opts, state, url);
|
|
383
|
+
}
|
|
384
|
+
const finderPath = req.method === "GET" ? MEMTREE_FINDER_RELAY.get(url.pathname) : undefined;
|
|
385
|
+
if (finderPath) {
|
|
386
|
+
return handleMemTreeFinder(req, res, opts, finderPath, url);
|
|
387
|
+
}
|
|
388
|
+
if (req.method === "GET" && url.pathname.startsWith(MEMTREE_PASSTHROUGH_PREFIX)) {
|
|
389
|
+
return handleMemTreePassthrough(req, res, opts, url);
|
|
390
|
+
}
|
|
391
|
+
if (req.method === "POST" && url.pathname === "/v1/messages") {
|
|
392
|
+
return handleMessages(req, res, opts, upstream, state);
|
|
393
|
+
}
|
|
394
|
+
if (req.method === "POST" && url.pathname === "/v1/messages/count_tokens") {
|
|
395
|
+
return handleCountTokens(req, res, opts, upstream, state);
|
|
396
|
+
}
|
|
397
|
+
return passThroughStreaming(req, res, upstream, state.shutdownSignal);
|
|
398
|
+
}
|
|
399
|
+
const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "localhost", "[::1]"]);
|
|
400
|
+
const BROWSER_SAME_SITE = new Set(["same-origin", "none"]);
|
|
401
|
+
/**
|
|
402
|
+
* Every route acts with the user's MemTree key (the `/memtree/*` relay reads
|
|
403
|
+
* their trees; `/v1/messages` indexes and compresses on their account), so only
|
|
404
|
+
* callers on this machine may use the proxy: Claude Code, agents (curl, the MCP
|
|
405
|
+
* server) and the MemTree page itself when opened from the loopback. A web page
|
|
406
|
+
* on another site can reach the loopback too, either directly (its `origin` or
|
|
407
|
+
* `sec-fetch-site` says so) or by rebinding its own hostname to 127.0.0.1 (its
|
|
408
|
+
* `host` then names it). Same-origin means this exact host and port.
|
|
409
|
+
*/
|
|
410
|
+
function isLocalCaller(req) {
|
|
411
|
+
const host = req.headers.host;
|
|
412
|
+
if (!host || !isLoopbackHost(host))
|
|
413
|
+
return false;
|
|
414
|
+
const origin = req.headers.origin;
|
|
415
|
+
if (origin !== undefined && !isSameLoopbackOrigin(origin, host))
|
|
416
|
+
return false;
|
|
417
|
+
const site = req.headers["sec-fetch-site"];
|
|
418
|
+
return site === undefined || BROWSER_SAME_SITE.has(String(site));
|
|
419
|
+
}
|
|
420
|
+
function isLoopbackHost(host) {
|
|
421
|
+
try {
|
|
422
|
+
const url = new URL(`http://${host}`);
|
|
423
|
+
return url.host === host.toLowerCase() && LOOPBACK_HOSTNAMES.has(url.hostname);
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
function isSameLoopbackOrigin(origin, host) {
|
|
430
|
+
try {
|
|
431
|
+
const url = new URL(origin);
|
|
432
|
+
return url.protocol === "http:" && url.host === new URL(`http://${host}`).host;
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
return false;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
/** Loopback prefix for reading the user's own MemTree pages through this proxy. */
|
|
439
|
+
const MEMTREE_PASSTHROUGH_PREFIX = "/memtree/";
|
|
440
|
+
/**
|
|
441
|
+
* `<request id>`, `<request id>.json`, `<request id>/session.json` (the
|
|
442
|
+
* page's session pane), `<request id>/search` (the server's term search over
|
|
443
|
+
* that tree, `?q=&limit=`), `<request id>/messages` (the un-indexed messages
|
|
444
|
+
* after it, `?start=&end=`), or `sessions/<Claude Code session id>.json` (every
|
|
445
|
+
* page from one session, newest first); nothing that could walk the upstream
|
|
446
|
+
* path. The id is the request UUID or the server's short form of it (leading
|
|
447
|
+
* hex, as in the `/m/<id>` links it hands out) — the server accepts both.
|
|
448
|
+
*/
|
|
449
|
+
const MEMTREE_PASSTHROUGH_TARGET_RE = /^(?:[A-Za-z0-9-]+(\.json|\/session\.json|\/search|\/messages)?|sessions\/[A-Za-z0-9][A-Za-z0-9_-]{0,127}\.json)$/;
|
|
450
|
+
/**
|
|
451
|
+
* `GET /memtree/<id>[.json][?share=…]` on the loopback: read the user's own
|
|
452
|
+
* MemTree page with their key. Claude Code's child env already carries this
|
|
453
|
+
* server as ANTHROPIC_BASE_URL, so an agent inside a ccc session needs no key
|
|
454
|
+
* handling — the polychat page's 401 body points here first. The upstream
|
|
455
|
+
* response is relayed as-is (status, content type, body); the key never
|
|
456
|
+
* leaves this process.
|
|
457
|
+
*/
|
|
458
|
+
async function handleMemTreePassthrough(req, res, opts, url) {
|
|
459
|
+
const target = url.pathname.slice(MEMTREE_PASSTHROUGH_PREFIX.length);
|
|
460
|
+
if (!MEMTREE_PASSTHROUGH_TARGET_RE.test(target)) {
|
|
461
|
+
res.writeHead(404, { "content-type": "application/json" });
|
|
462
|
+
res.end(JSON.stringify({ detail: "Not Found" }));
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
try {
|
|
466
|
+
const upstream = await opts.memtree.fetchMemTree(`/usage/memtree/${target}${url.search}`, req.headers.accept ?? "application/json");
|
|
467
|
+
res.writeHead(upstream.status, { "content-type": upstream.contentType });
|
|
468
|
+
res.end(upstream.body);
|
|
469
|
+
}
|
|
470
|
+
catch (err) {
|
|
471
|
+
res.writeHead(502, { "content-type": "application/json" });
|
|
472
|
+
res.end(JSON.stringify({ detail: `MemTree fetch failed: ${String(err)}` }));
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Loopback routes for finding things across the user's own sessions, relayed
|
|
477
|
+
* to the server's owner-only endpoints with the user's key (which never
|
|
478
|
+
* leaves this process): `GET /memtree/sessions?…` lists sessions, `GET
|
|
479
|
+
* /memtree/search?…` searches their trees. Exact paths only; the query string
|
|
480
|
+
* goes on unchanged (the URL parser has already split off any path or
|
|
481
|
+
* fragment). Matched before the page relay, whose id pattern would otherwise
|
|
482
|
+
* take them for page ids.
|
|
483
|
+
*
|
|
484
|
+
* The project meta goes along in `x-client-meta`, and the caller's Claude
|
|
485
|
+
* Code session id when it sends one, so a charged (vector) search's usage row
|
|
486
|
+
* says where it came from.
|
|
487
|
+
*/
|
|
488
|
+
const MEMTREE_FINDER_RELAY = new Map([
|
|
489
|
+
[`${MEMTREE_PASSTHROUGH_PREFIX}sessions`, "/v1/memtree/sessions"],
|
|
490
|
+
[`${MEMTREE_PASSTHROUGH_PREFIX}search`, "/v1/memtree/search"],
|
|
491
|
+
]);
|
|
492
|
+
const SESSION_ID_HEADER_VALUE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
493
|
+
async function handleMemTreeFinder(req, res, opts, upstreamPath, url) {
|
|
494
|
+
const headers = {};
|
|
495
|
+
const meta = memtreeClientMeta({ project: opts.projectMeta });
|
|
496
|
+
if (Object.keys(meta).length)
|
|
497
|
+
headers["x-client-meta"] = JSON.stringify(meta);
|
|
498
|
+
const sessionId = firstNonEmptyHeader(req, "x-claude-code-session-id");
|
|
499
|
+
if (sessionId && SESSION_ID_HEADER_VALUE.test(sessionId)) {
|
|
500
|
+
headers["x-claude-code-session-id"] = sessionId;
|
|
501
|
+
}
|
|
502
|
+
try {
|
|
503
|
+
const upstream = await opts.memtree.fetchMemTree(`${upstreamPath}${url.search}`, req.headers.accept ?? "application/json", undefined, headers);
|
|
504
|
+
res.writeHead(upstream.status, { "content-type": upstream.contentType });
|
|
505
|
+
res.end(upstream.body);
|
|
506
|
+
}
|
|
507
|
+
catch (err) {
|
|
508
|
+
res.writeHead(502, { "content-type": "application/json" });
|
|
509
|
+
res.end(JSON.stringify({ detail: `MemTree fetch failed: ${String(err)}` }));
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* `GET /memtree/current[.json][?session=<Claude Code session id>]`: the page
|
|
514
|
+
* the session's newest main request was served from — what the `memtree` MCP server
|
|
515
|
+
* reads (memtree-mcp.ts). Bare `current` answers the pointer
|
|
516
|
+
* `{id, url, index, session_id, compressed}` without an upstream call, so the
|
|
517
|
+
* MCP server can keep its cached tree until the page changes; `current.json`
|
|
518
|
+
* relays the page JSON itself, like `/memtree/<id>.json`.
|
|
519
|
+
*
|
|
520
|
+
* Every pointer is scoped to the caller's exact session id. Missing session
|
|
521
|
+
* ids never fall back to another conversation served by the same proxy.
|
|
522
|
+
*/
|
|
523
|
+
async function handleMemTreeCurrent(req, res, opts, state, url) {
|
|
524
|
+
const sessionId = url.searchParams.get("session") || undefined;
|
|
525
|
+
const page = currentMemtreePage(state, sessionId);
|
|
526
|
+
const id = page ? memtreePageId(page.url) : undefined;
|
|
527
|
+
if (!page || !id) {
|
|
528
|
+
res.writeHead(404, { "content-type": "application/json" });
|
|
529
|
+
res.end(JSON.stringify({ detail: "No MemTree page for this session yet" }));
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
if (!url.pathname.endsWith(".json")) {
|
|
533
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
534
|
+
res.end(JSON.stringify({
|
|
535
|
+
id,
|
|
536
|
+
url: page.url,
|
|
537
|
+
index: page.index,
|
|
538
|
+
session_id: page.sessionId ?? null,
|
|
539
|
+
compressed: page.compressed,
|
|
540
|
+
}));
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
try {
|
|
544
|
+
const upstream = await opts.memtree.fetchMemTree(`/usage/memtree/${id}.json`, req.headers.accept ?? "application/json");
|
|
545
|
+
res.writeHead(upstream.status, {
|
|
546
|
+
"content-type": upstream.contentType,
|
|
547
|
+
"x-memtree-page": page.url,
|
|
548
|
+
});
|
|
549
|
+
res.end(upstream.body);
|
|
550
|
+
}
|
|
551
|
+
catch (err) {
|
|
552
|
+
res.writeHead(502, { "content-type": "application/json" });
|
|
553
|
+
res.end(JSON.stringify({ detail: `MemTree fetch failed: ${String(err)}` }));
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
const MEMTREE_CURRENT_PATH = `${MEMTREE_PASSTHROUGH_PREFIX}current`;
|
|
557
|
+
function currentMemtreePage(state, sessionId) {
|
|
558
|
+
if (!sessionId)
|
|
559
|
+
return undefined;
|
|
560
|
+
const latest = state.memtreePages.get(sessionId);
|
|
561
|
+
if (latest)
|
|
562
|
+
return latest;
|
|
563
|
+
const stored = state.memtreeLinkStore?.get(sessionId);
|
|
564
|
+
return stored ? { ...stored, sessionId } : undefined;
|
|
565
|
+
}
|
|
566
|
+
/** The page id in a server-stamped link (`…/m/<id>` or `…/usage/memtree/<id>`). */
|
|
567
|
+
export function memtreePageId(pageUrl) {
|
|
568
|
+
try {
|
|
569
|
+
const match = /\/(?:m|usage\/memtree)\/([A-Za-z0-9-]+?)(?:\.json)?$/.exec(new URL(pageUrl).pathname);
|
|
570
|
+
return match?.[1];
|
|
571
|
+
}
|
|
572
|
+
catch {
|
|
573
|
+
return undefined;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
/** Serve only validated Claude hook POSTs on the randomized localhost path. */
|
|
577
|
+
async function handleNoticeHook(req, res, state, reqlog,
|
|
578
|
+
/** A main-lane turn ended: the session may be over (its final index). */
|
|
579
|
+
onMainStop) {
|
|
580
|
+
if (req.method !== "POST") {
|
|
581
|
+
res.writeHead(405, { allow: "POST" });
|
|
582
|
+
res.end();
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
const declaredLength = Number(req.headers["content-length"] ?? 0);
|
|
586
|
+
if (Number.isFinite(declaredLength) && declaredLength > HOOK_BODY_LIMIT) {
|
|
587
|
+
res.writeHead(413);
|
|
588
|
+
res.end();
|
|
589
|
+
req.resume();
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
const raw = await readBody(req);
|
|
593
|
+
if (raw.length > HOOK_BODY_LIMIT) {
|
|
594
|
+
res.writeHead(413);
|
|
595
|
+
res.end();
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
let input;
|
|
599
|
+
try {
|
|
600
|
+
input = JSON.parse(raw.toString("utf-8"));
|
|
601
|
+
}
|
|
602
|
+
catch {
|
|
603
|
+
res.writeHead(400);
|
|
604
|
+
res.end();
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
const parsed = parseNoticeHookInput(input);
|
|
608
|
+
if (!parsed) {
|
|
609
|
+
res.writeHead(400);
|
|
610
|
+
res.end();
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
if (parsed.hook_event_name === "SessionStart") {
|
|
614
|
+
// A resume waits briefly for the newest tree; a fresh session has none to wait for.
|
|
615
|
+
if (parsed.agent_id === undefined && parsed.source === "resume") {
|
|
616
|
+
await state.newestTrees?.settle(parsed.session_id);
|
|
617
|
+
}
|
|
618
|
+
const line = resumeLinkLine(state, parsed);
|
|
619
|
+
if (!line) {
|
|
620
|
+
res.writeHead(204);
|
|
621
|
+
res.end();
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
const body = Buffer.from(JSON.stringify({ systemMessage: line }), "utf-8");
|
|
625
|
+
res.writeHead(200, {
|
|
626
|
+
"content-type": "application/json",
|
|
627
|
+
"content-length": String(body.length),
|
|
628
|
+
"cache-control": "no-store",
|
|
629
|
+
});
|
|
630
|
+
res.end(body);
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
if (parsed.hook_event_name === "UserPromptSubmit") {
|
|
634
|
+
// `/memtree-view`: answered here and blocked, so no model turn runs and
|
|
635
|
+
// the prompt never enters the conversation. Not a human turn either, so
|
|
636
|
+
// none of the turn state below is touched.
|
|
637
|
+
if (parsed.agent_id === undefined) {
|
|
638
|
+
// `/memtree-view` waits briefly for the newest tree; a prompt only warms it.
|
|
639
|
+
if (isMemtreeViewCommand(parsed.prompt))
|
|
640
|
+
await state.newestTrees?.settle(parsed.session_id);
|
|
641
|
+
else
|
|
642
|
+
state.newestTrees?.peek(parsed.session_id);
|
|
643
|
+
}
|
|
644
|
+
const commandReply = parsed.agent_id === undefined
|
|
645
|
+
? sessionCommandReply(state, parsed.session_id, parsed.prompt)
|
|
646
|
+
: undefined;
|
|
647
|
+
if (commandReply !== undefined) {
|
|
648
|
+
const body = Buffer.from(JSON.stringify({
|
|
649
|
+
decision: "block",
|
|
650
|
+
reason: commandReply,
|
|
651
|
+
// Claude Code otherwise repeats "Original prompt: /ccc:memtree-view"
|
|
652
|
+
// under the answer. Older releases ignore the flag.
|
|
653
|
+
hookSpecificOutput: {
|
|
654
|
+
hookEventName: "UserPromptSubmit",
|
|
655
|
+
suppressOriginalPrompt: true,
|
|
656
|
+
},
|
|
657
|
+
}), "utf-8");
|
|
658
|
+
res.writeHead(200, {
|
|
659
|
+
"content-type": "application/json",
|
|
660
|
+
"content-length": String(body.length),
|
|
661
|
+
"cache-control": "no-store",
|
|
662
|
+
});
|
|
663
|
+
res.end(body);
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
if (parsed.agent_id === undefined) {
|
|
667
|
+
state.promptAccounting.add(parsed.session_id, parsed.prompt_id, parsed.prompt);
|
|
668
|
+
recordMainPromptNotice(state, parsed.prompt_id, parsed.prompt);
|
|
669
|
+
}
|
|
670
|
+
res.writeHead(204);
|
|
671
|
+
res.end();
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
if (parsed.hook_event_name === "SubagentStart") {
|
|
675
|
+
state.activeSubagents.add(parsed.agent_id);
|
|
676
|
+
res.writeHead(204);
|
|
677
|
+
res.end();
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
if (parsed.hook_event_name === "SubagentStop") {
|
|
681
|
+
state.activeSubagents.delete(parsed.agent_id);
|
|
682
|
+
res.writeHead(204);
|
|
683
|
+
res.end();
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
const stopMatchesMainPrompt = parsed.hook_event_name === "Stop" &&
|
|
687
|
+
parsed.agent_id === undefined &&
|
|
688
|
+
(state.mainPromptId === undefined ||
|
|
689
|
+
parsed.prompt_id === undefined ||
|
|
690
|
+
state.mainPromptId === parsed.prompt_id ||
|
|
691
|
+
(parsed.prompt_id !== undefined && state.promptAccounting.hasId(parsed.prompt_id)));
|
|
692
|
+
const output = parsed.hook_event_name === "Stop" && !stopMatchesMainPrompt
|
|
693
|
+
? null
|
|
694
|
+
: state.notices.claim(parsed);
|
|
695
|
+
if (stopMatchesMainPrompt) {
|
|
696
|
+
onMainStop?.(typeof parsed.session_id === "string" ? parsed.session_id : undefined);
|
|
697
|
+
state.mainNoticePending = false;
|
|
698
|
+
state.mainPromptId = undefined;
|
|
699
|
+
state.mainPromptText = undefined;
|
|
700
|
+
// Invalidate a response still in flight at Stop. Otherwise its late
|
|
701
|
+
// delivery callback could enqueue a notice after Stop returned.
|
|
702
|
+
state.mainPromptGeneration++;
|
|
703
|
+
// Subagent lifetimes are independent; only their own stop events retire them.
|
|
704
|
+
}
|
|
705
|
+
if (!output) {
|
|
706
|
+
res.writeHead(204);
|
|
707
|
+
res.end();
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
if (parsed.hook_event_name === "MessageDisplay" ||
|
|
711
|
+
parsed.hook_event_name === "Stop") {
|
|
712
|
+
try {
|
|
713
|
+
reqlog?.log({
|
|
714
|
+
kind: "notice",
|
|
715
|
+
event: "claimed",
|
|
716
|
+
via: parsed.hook_event_name,
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
catch {
|
|
720
|
+
// Custom/test loggers get RequestLogger's never-break-hook policy.
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
const body = Buffer.from(JSON.stringify(output), "utf-8");
|
|
724
|
+
res.writeHead(200, {
|
|
725
|
+
"content-type": "application/json",
|
|
726
|
+
"content-length": String(body.length),
|
|
727
|
+
"cache-control": "no-store",
|
|
728
|
+
});
|
|
729
|
+
res.end(body);
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* Hook-prompt correlation: true only when the armed typed prompt appears as a
|
|
733
|
+
* deliberate top-level text block of the user message — the whole block, or
|
|
734
|
+
* its start/end (Claude Code may append/prepend ambient text around a merged
|
|
735
|
+
* queued prompt). Matching runs on reminder-stripped block text so a
|
|
736
|
+
* coincidental substring inside an appended <system-reminder> (or buried
|
|
737
|
+
* mid-sentence in unrelated text) can never claim or consume the arm.
|
|
738
|
+
*/
|
|
739
|
+
function messageCarriesPromptText(message, promptText) {
|
|
740
|
+
const prompt = promptText.trim();
|
|
741
|
+
if (!prompt)
|
|
742
|
+
return false;
|
|
743
|
+
if (!message || message.role !== "user")
|
|
744
|
+
return false;
|
|
745
|
+
const content = message.content;
|
|
746
|
+
const parts = typeof content === "string"
|
|
747
|
+
? [content]
|
|
748
|
+
: Array.isArray(content)
|
|
749
|
+
? content.map((part) => {
|
|
750
|
+
if (typeof part === "string")
|
|
751
|
+
return part;
|
|
752
|
+
return part?.type === "text" && typeof part.text === "string"
|
|
753
|
+
? part.text
|
|
754
|
+
: "";
|
|
755
|
+
})
|
|
756
|
+
: [];
|
|
757
|
+
return parts.some((part) => {
|
|
758
|
+
const text = stripSystemReminderText(part);
|
|
759
|
+
return (text === prompt || text.startsWith(prompt) || text.endsWith(prompt));
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
/** Buffer + inspect /v1/messages; classify the turn, strip notices, forward. */
|
|
763
|
+
async function handleMessages(req, res, opts, upstream, state) {
|
|
764
|
+
const received = Date.now();
|
|
765
|
+
const rawBody = await readBody(req);
|
|
766
|
+
// One request-log record per /v1/messages, filled in as the request flows
|
|
767
|
+
// through the forward path and written exactly once when the response is
|
|
768
|
+
// done (success or failure) — always on, so a stalled turn leaves a trace.
|
|
769
|
+
const rec = {
|
|
770
|
+
kind: "messages",
|
|
771
|
+
turnType: "unparseable",
|
|
772
|
+
requestBytes: rawBody.length,
|
|
773
|
+
};
|
|
774
|
+
const logged = async (forward) => {
|
|
775
|
+
try {
|
|
776
|
+
await forward;
|
|
777
|
+
}
|
|
778
|
+
finally {
|
|
779
|
+
if (rec.totalMs === undefined)
|
|
780
|
+
rec.totalMs = Date.now() - received;
|
|
781
|
+
opts.reqlog?.log(rec);
|
|
782
|
+
}
|
|
783
|
+
};
|
|
784
|
+
if (opts.claudeCodeOnly && requestSessionId(req) === undefined) {
|
|
785
|
+
rec.turnType = "foreign";
|
|
786
|
+
rec.forwardedBytes = rawBody.length;
|
|
787
|
+
const agent = firstNonEmptyHeader(req, "user-agent");
|
|
788
|
+
if (agent)
|
|
789
|
+
rec.userAgent = agent.slice(0, 80);
|
|
790
|
+
return logged(forwardRaw(req, res, rawBody, opts, upstream, state.shutdownSignal, rec));
|
|
791
|
+
}
|
|
792
|
+
let body;
|
|
793
|
+
try {
|
|
794
|
+
body = JSON.parse(rawBody.toString("utf-8"));
|
|
795
|
+
if (!Array.isArray(body.messages))
|
|
796
|
+
throw new Error("no messages array");
|
|
797
|
+
}
|
|
798
|
+
catch {
|
|
799
|
+
// Not a shape we understand — forward verbatim rather than break the session.
|
|
800
|
+
return logged(forwardRaw(req, res, rawBody, opts, upstream, state.shutdownSignal, rec));
|
|
801
|
+
}
|
|
802
|
+
// Defensive legacy strip pass first: old marker-wrapped notices (including
|
|
803
|
+
// one copied into an away-summary or top-level system prompt) must never
|
|
804
|
+
// reach Anthropic or MemTree. Hook-delivered notices never enter this body.
|
|
805
|
+
const stripped = stripNoticeBlocks(body.messages);
|
|
806
|
+
const strippedSystem = stripNoticeSystem(body.system);
|
|
807
|
+
let forwardBody = rawBody;
|
|
808
|
+
if (stripped.stripped || strippedSystem.stripped) {
|
|
809
|
+
body.messages = stripped.messages;
|
|
810
|
+
if (strippedSystem.system === undefined)
|
|
811
|
+
delete body.system;
|
|
812
|
+
else
|
|
813
|
+
body.system = strippedSystem.system;
|
|
814
|
+
forwardBody = Buffer.from(JSON.stringify(body), "utf-8");
|
|
815
|
+
if (opts.debug)
|
|
816
|
+
console.error("[ccc proxy] stripped legacy notice span(s) from request");
|
|
817
|
+
}
|
|
818
|
+
const messages = body.messages;
|
|
819
|
+
// CC 2.1.207 appends ambient role=system blocks after the typed prompt. Use
|
|
820
|
+
// the last non-system conversation message for classification while keeping
|
|
821
|
+
// every system block in the body sent to MemTree/Anthropic.
|
|
822
|
+
const lastMsg = lastNonSystemMessage(messages);
|
|
823
|
+
const isUserTurn = isNonToolUserMessage(lastMsg);
|
|
824
|
+
const isToolResultTurn = isToolResultUserMessage(lastMsg);
|
|
825
|
+
const isAwaySummary = isAwaySummaryUserMessage(lastMsg);
|
|
826
|
+
// Neither a prompt nor a tool result: a background task notification
|
|
827
|
+
// arrives as a trailing role=system block after the assistant's last reply,
|
|
828
|
+
// so the last conversation message is that reply. It carries the whole
|
|
829
|
+
// history like any turn and must live by the budget like a tool turn; it
|
|
830
|
+
// once fell through every branch and forwarded the uncompacted 4.4MB
|
|
831
|
+
// history (Prompt is too long, 2026-09-30).
|
|
832
|
+
const isContinuationTurn = lastMsg !== undefined && !isUserTurn && !isToolResultTurn;
|
|
833
|
+
// Claude Code's security monitor re-sends the whole session as one
|
|
834
|
+
// `<transcript>` message after most actions. Before this check it looked
|
|
835
|
+
// like a main-thread followup: it bumped the route epoch (wiping the main
|
|
836
|
+
// tool loop's compressed route mid-turn, forcing a blocking recompress),
|
|
837
|
+
// cost a MemTree passthrough, and started a from-scratch index of a
|
|
838
|
+
// conversation that already has one. It must touch none of that state, so
|
|
839
|
+
// it is handled here, before anything below mutates it. Anthropic already
|
|
840
|
+
// caches its append-only transcript well, so it goes out verbatim.
|
|
841
|
+
let clientInfo;
|
|
842
|
+
try {
|
|
843
|
+
clientInfo = describeClaudeCodeRequest(body);
|
|
844
|
+
if (clientInfo.suspectedSideRequest) {
|
|
845
|
+
clientInfo.sessionTag = sessionTag(requestSessionId(req));
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
catch {
|
|
849
|
+
// logging only; never affects the request
|
|
850
|
+
}
|
|
851
|
+
// The header rule alone is not enough: Claude Code releases before
|
|
852
|
+
// cc_turn_origin existed send main-thread requests with no turn origin, so
|
|
853
|
+
// the monitor's <transcript> block must also be recognised. A candidate
|
|
854
|
+
// whose transcript does not match keeps the ordinary handling (the
|
|
855
|
+
// pre-existing behaviour) and records why, so a format change in Claude
|
|
856
|
+
// Code shows up in requests.jsonl instead of silently misrouting.
|
|
857
|
+
let sideRequest = false;
|
|
858
|
+
if (clientInfo && isClaudeCodeSideRequest(clientInfo)) {
|
|
859
|
+
try {
|
|
860
|
+
rec.transcript = inspectMonitorTranscript(body);
|
|
861
|
+
sideRequest = rec.transcript.ok;
|
|
862
|
+
if (!rec.transcript.ok) {
|
|
863
|
+
console.error(`[ccc proxy] possible side request, transcript format not recognised ` +
|
|
864
|
+
`(${rec.transcript.reason}${rec.transcript.badLine ? ` at line ${rec.transcript.badLine}` : ""}); ` +
|
|
865
|
+
`handled as an ordinary request. Claude Code may have changed the monitor format.`);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
catch {
|
|
869
|
+
// never let the format check affect the request
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
if (sideRequest && clientInfo) {
|
|
873
|
+
if (typeof body.model === "string")
|
|
874
|
+
rec.model = body.model;
|
|
875
|
+
rec.stream = body.stream === true;
|
|
876
|
+
rec.client = clientInfo;
|
|
877
|
+
recordTurn(rec, "side-request", forwardBody);
|
|
878
|
+
capture(opts, "anthropic-request-side", forwardBody);
|
|
879
|
+
return logged(forwardRaw(req, res, forwardBody, opts, upstream, state.shutdownSignal, rec));
|
|
880
|
+
}
|
|
881
|
+
if (isAwaySummary && state.memtreeLinkPlacement !== "off") {
|
|
882
|
+
const sessionId = requestSessionId(req);
|
|
883
|
+
// Warm the newest-tree lookup now (never waits) so it can answer by the
|
|
884
|
+
// time the recap finishes streaming.
|
|
885
|
+
state.newestTrees?.peek(sessionId);
|
|
886
|
+
recapLinkAppenders.set(req, (streamedTextChars) => {
|
|
887
|
+
const link = recapLink(state, sessionId);
|
|
888
|
+
return link ? recapLinkText(link.link, streamedTextChars, link.note) : undefined;
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
const isLocalBashCommand = isLocalBashCommandTurn(messages);
|
|
892
|
+
// CC 2.1.207 identifies agent API calls explicitly. Use that wire-level
|
|
893
|
+
// attribution before lifecycle-hook state so an agent request cannot claim
|
|
894
|
+
// or consume a main prompt arm even if SubagentStart ordering is delayed.
|
|
895
|
+
const { lane: requestRouteLane, key: requestRouteKey } = routeIdentity(req, isAwaySummary);
|
|
896
|
+
const isSubagentRequest = requestRouteLane === "agent";
|
|
897
|
+
const isMainRequest = requestRouteLane === "main";
|
|
898
|
+
const requestSizeKey = laneSizeKey(req, requestRouteKey, body.model);
|
|
899
|
+
// Extended 1M context arrives as the `context-1m` beta header (Claude Code
|
|
900
|
+
// strips the `[1m]` model suffix on the wire). Current native-1M models send
|
|
901
|
+
// neither, so contextLimitForModel also needs the launcher's native setting.
|
|
902
|
+
const modelContextLimit = contextLimitForModel(body.model, headerText(req.headers, "anthropic-beta"), opts.nativeOneMillionContext !== false);
|
|
903
|
+
// Before anything reads the size sample: replace an unknown or stale
|
|
904
|
+
// estimate with an exact count where it would decide the outcome.
|
|
905
|
+
const requestSessionKey = requestSessionId(req);
|
|
906
|
+
const countSession = createCountTokensSession({
|
|
907
|
+
upstream, headers: forwardableRequestHeaders(req), requestUrl: req.url,
|
|
908
|
+
signal: state.shutdownSignal,
|
|
909
|
+
onAttempt: (result) => {
|
|
910
|
+
(rec.countTokens ??= []).push({ statusClass: result.outcome, ms: result.ms });
|
|
911
|
+
},
|
|
912
|
+
}, state.countTokensCooldowns);
|
|
913
|
+
const requestSizing = new RequestSizing(countSession, opts.countTokens === true);
|
|
914
|
+
const sizeSlot = wholeRequestSizeSlot(state, isMainRequest, requestSessionKey, requestSizeKey);
|
|
915
|
+
const originalCandidate = requestSizing.register(rawBody, "original", sizeSlot.get());
|
|
916
|
+
const forwardCandidate = requestSizing.register(forwardBody, "original", sizeSlot.get());
|
|
917
|
+
candidateSizeOwners.set(originalCandidate, sizeSlot);
|
|
918
|
+
candidateSizeOwners.set(forwardCandidate, sizeSlot);
|
|
919
|
+
const sizingLimits = () => ({
|
|
920
|
+
budget: resolveBudget(opts, state, body.model, modelContextLimit).tokens,
|
|
921
|
+
context: modelContextLimit,
|
|
922
|
+
output: typeof body.max_tokens === "number" ? Math.max(0, body.max_tokens) : 0,
|
|
923
|
+
});
|
|
924
|
+
const rides = state.memoryRoutes.has(requestRouteKey) ||
|
|
925
|
+
(isMainRequest && requestSessionKey !== undefined && state.stablePrefixes.has(requestSessionKey));
|
|
926
|
+
if (!rides) {
|
|
927
|
+
const measured = await requestSizing.measure(forwardCandidate, sizingLimits(), "plan");
|
|
928
|
+
if (measured.source === "exact") {
|
|
929
|
+
sizeSlot.set({ tokens: measured.tokens, forwardedBytes: forwardCandidate.body.length });
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
rec.routeLane = requestRouteLane;
|
|
933
|
+
// Stored by the server on the usage row (client_meta): which Claude Code,
|
|
934
|
+
// lane, agent and model produced this request.
|
|
935
|
+
const clientMeta = memtreeClientMeta({
|
|
936
|
+
info: clientInfo,
|
|
937
|
+
lane: requestRouteLane,
|
|
938
|
+
agentId: firstNonEmptyHeader(req, "x-claude-code-agent-id"),
|
|
939
|
+
parentAgentId: firstNonEmptyHeader(req, "x-claude-code-parent-agent-id"),
|
|
940
|
+
model: body.model,
|
|
941
|
+
project: opts.projectMeta,
|
|
942
|
+
});
|
|
943
|
+
// API identity and validated history alone govern route eligibility.
|
|
944
|
+
const rideableCandidate = getMemoryRoute(state, requestRouteKey);
|
|
945
|
+
const existingRide = rideableCandidate && memoryRoutedToolBody(body, messages, rideableCandidate, state.mainRouteEpoch, requestSessionId(req));
|
|
946
|
+
const isFollowupUserTurn = isUserTurn && hasEarlierNonToolUserMessage(messages);
|
|
947
|
+
const routeEpoch = state.mainRouteEpoch;
|
|
948
|
+
let routeDecisionGeneration = state.mainRouteDecisionGeneration;
|
|
949
|
+
let routeDecisionSettled = false;
|
|
950
|
+
const toolForwarding = capturePromptDelivery(state, isMainRequest, lastMsg, requestSessionId(req));
|
|
951
|
+
if (isFollowupUserTurn) {
|
|
952
|
+
routeDecisionGeneration = ++state.mainRouteDecisionGeneration;
|
|
953
|
+
reserveRouteDecision(state, requestRouteKey, routeDecisionGeneration);
|
|
954
|
+
}
|
|
955
|
+
const hookOwnedMainFollowup = isFollowupUserTurn &&
|
|
956
|
+
!isAwaySummary &&
|
|
957
|
+
!isSubagentRequest &&
|
|
958
|
+
state.mainNoticePending &&
|
|
959
|
+
state.mainPromptText !== undefined &&
|
|
960
|
+
messageCarriesPromptText(lastMsg, state.mainPromptText);
|
|
961
|
+
// Local `!command` turns do not consistently emit UserPromptSubmit, and the
|
|
962
|
+
// API history contains bash wrappers rather than the literal typed command.
|
|
963
|
+
// Their strict main-thread replay shape can safely own its own notice.
|
|
964
|
+
const localCommandMainFollowup = isFollowupUserTurn &&
|
|
965
|
+
!isAwaySummary &&
|
|
966
|
+
!isSubagentRequest &&
|
|
967
|
+
isLocalBashCommand;
|
|
968
|
+
const displayForThisTurn = (hookOwnedMainFollowup || localCommandMainFollowup) &&
|
|
969
|
+
state.activeSubagents.size === 0;
|
|
970
|
+
const noticePromptId = state.mainPromptId;
|
|
971
|
+
const noticePromptGeneration = state.mainPromptGeneration;
|
|
972
|
+
requestSendPolicies.set(req, {
|
|
973
|
+
original: rawBody,
|
|
974
|
+
failedRebuild: false,
|
|
975
|
+
allowed: new WeakSet(),
|
|
976
|
+
sizing: requestSizing,
|
|
977
|
+
select: (buffer) => selectForwardCandidate({
|
|
978
|
+
opts, state, res, rec, body, modelContextLimit, req, routeKey: requestRouteKey,
|
|
979
|
+
preferredBody: buffer, limits: sizingLimits(),
|
|
980
|
+
}),
|
|
981
|
+
fitsNative: async (buffer) => {
|
|
982
|
+
const candidate = requestSizing.register(buffer, rec.compress ? "replacement" : "ride");
|
|
983
|
+
const measured = await requestSizing.measure(candidate, sizingLimits());
|
|
984
|
+
// A validated ride is still eligible for bounded late recovery. Final
|
|
985
|
+
// selection considers it together with the original and any replacement.
|
|
986
|
+
return candidate.kind === "ride" || fitsNative(measured.tokens, sizingLimits());
|
|
987
|
+
},
|
|
988
|
+
recoverLate: async (buffer, tokens) => {
|
|
989
|
+
if (rec.compress || compactionMode(opts, state, requestSessionKey).mode === "off" ||
|
|
990
|
+
(rec.turnType.startsWith("tool") && opts.toolRouteRecovery === false))
|
|
991
|
+
return undefined;
|
|
992
|
+
return recoverLateCount({ opts, state, req, rec, bodyBuffer: buffer,
|
|
993
|
+
modelContextLimit, clientMeta, sizing: requestSizing });
|
|
994
|
+
},
|
|
995
|
+
delivered: () => { state.fallbackFailures.succeeded(requestRouteKey); toolForwarding.settle(true); },
|
|
996
|
+
});
|
|
997
|
+
const rawMsgsForMemtree = messagesWithSystem(messages, body.system);
|
|
998
|
+
const msgsForMemtree = normalizeMessagesForMemtree(rawMsgsForMemtree);
|
|
999
|
+
const hash = MemtreeClient.hashMessages(msgsForMemtree);
|
|
1000
|
+
// The main lane's latest conversation: what its final index sends, plus the
|
|
1001
|
+
// reply the session ends with (scheduleFinalIndex, on Stop).
|
|
1002
|
+
const mainSessionId = isMainRequest ? requestSessionId(req) : undefined;
|
|
1003
|
+
if (mainSessionId !== undefined) {
|
|
1004
|
+
opts.memtree.noteMainConversation(mainSessionId, {
|
|
1005
|
+
messages: msgsForMemtree,
|
|
1006
|
+
modelContextLimit,
|
|
1007
|
+
clientMeta,
|
|
1008
|
+
messageTimesFor: transcriptTimesFor(opts, mainSessionId),
|
|
1009
|
+
readReply: (signal) => opts.transcriptUsage?.finalReplyAsync
|
|
1010
|
+
? opts.transcriptUsage.finalReplyAsync(mainSessionId, signal)
|
|
1011
|
+
: opts.transcriptUsage?.finalReply?.(mainSessionId),
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
// Hook state controls notices only. Hidden requests cannot consume main notices.
|
|
1015
|
+
if (typeof body.model === "string")
|
|
1016
|
+
rec.model = body.model;
|
|
1017
|
+
rec.stream = body.stream === true;
|
|
1018
|
+
// Observation only: which requests carry Claude Code's turn origin.
|
|
1019
|
+
if (clientInfo)
|
|
1020
|
+
rec.client = clientInfo;
|
|
1021
|
+
if (!isFollowupUserTurn) {
|
|
1022
|
+
// Tool turn or FIRST user turn: keep the index fed off the response path
|
|
1023
|
+
// and forward as-is (on the lane's memory route or the session's stable
|
|
1024
|
+
// prefix when one applies) — except a tool turn whose estimated size
|
|
1025
|
+
// reached the budget, which compresses once to the target below
|
|
1026
|
+
// (planToolCompaction), exactly like a main human turn.
|
|
1027
|
+
// On the first user turn nothing is indexed yet, so a blocking compress
|
|
1028
|
+
// would be a guaranteed no-op costing first-token latency
|
|
1029
|
+
// (plans/2026-07-05_PLAN_first_user_turn_nonblocking.md).
|
|
1030
|
+
if (opts.debug && isUserTurn) {
|
|
1031
|
+
console.error("[ccc proxy] first user turn: index in background, forward verbatim");
|
|
1032
|
+
}
|
|
1033
|
+
let routedBody = forwardBody;
|
|
1034
|
+
let routedTool = false;
|
|
1035
|
+
let routedCandidate;
|
|
1036
|
+
let routeMiss;
|
|
1037
|
+
if (isToolResultTurn || isContinuationTurn || isUserTurn) {
|
|
1038
|
+
// Reuse only this API session/lane's independently validated prefix.
|
|
1039
|
+
const activeRoute = rideableCandidate;
|
|
1040
|
+
if (activeRoute) {
|
|
1041
|
+
const rewritten = memoryRoutedToolBody(body, messages, activeRoute, state.mainRouteEpoch, requestSessionId(req));
|
|
1042
|
+
if (rewritten) {
|
|
1043
|
+
const owner = activeRoute.stablePrefix ?? activeRoute;
|
|
1044
|
+
routedCandidate = requestSizing.register(rewritten, "ride", owner.lastSize);
|
|
1045
|
+
candidateSizeOwners.set(routedCandidate, owner);
|
|
1046
|
+
// Advisory window pressure can request recovery, but never removes a
|
|
1047
|
+
// structurally valid candidate before request-local counting/selection.
|
|
1048
|
+
if (!fitsNative(requestSizing.plan(routedCandidate).tokens, sizingLimits())) {
|
|
1049
|
+
routeMiss = "rejected";
|
|
1050
|
+
regrantSmallerWindowRecovery(state, requestRouteKey, modelContextLimit);
|
|
1051
|
+
}
|
|
1052
|
+
routedBody = rewritten;
|
|
1053
|
+
routedTool = true;
|
|
1054
|
+
if (opts.debug) {
|
|
1055
|
+
console.error("[ccc proxy] tool turn matched active memory route");
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
else {
|
|
1059
|
+
// A mismatch means a different/resumed conversation shape, or two
|
|
1060
|
+
// requesters colliding on one lane (children sharing only a
|
|
1061
|
+
// parent-agent-id land on the same key; their prefix hashes
|
|
1062
|
+
// disagree and the loser lands here). A route stored under this key
|
|
1063
|
+
// always carries this requester's session id — installMemoryRoute
|
|
1064
|
+
// derives both from the same request — so this is by construction a
|
|
1065
|
+
// same-session divergence: rebuild while retaining the previous route.
|
|
1066
|
+
routeMiss = "rejected";
|
|
1067
|
+
if (opts.debug) {
|
|
1068
|
+
console.error("[ccc proxy] tool turn rejected active memory route");
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
else {
|
|
1073
|
+
routeMiss = "missing";
|
|
1074
|
+
if (opts.debug) {
|
|
1075
|
+
console.error("[ccc proxy] tool turn has no active memory route");
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
const toolSessionId = requestSessionId(req);
|
|
1080
|
+
// A concurrently replaced stable prefix takes precedence over an older
|
|
1081
|
+
// route. The current prefix still undergoes its own history validation.
|
|
1082
|
+
if (routedTool &&
|
|
1083
|
+
isMainRequest &&
|
|
1084
|
+
toolSessionId !== undefined &&
|
|
1085
|
+
rideableCandidate?.stablePrefix !== state.stablePrefixes.get(toolSessionId)) {
|
|
1086
|
+
if (routedCandidate)
|
|
1087
|
+
requestSizing.excludeFallback(routedCandidate);
|
|
1088
|
+
routedTool = false;
|
|
1089
|
+
routedBody = forwardBody;
|
|
1090
|
+
routeMiss = "superseded";
|
|
1091
|
+
}
|
|
1092
|
+
if (routeMiss !== undefined)
|
|
1093
|
+
rec.routeMiss = routeMiss;
|
|
1094
|
+
if (isContinuationTurn)
|
|
1095
|
+
rec.continuation = true;
|
|
1096
|
+
// Cheap shape check: without an earlier real user message there is
|
|
1097
|
+
// nothing MemTree could compress, so no attempt is worth making.
|
|
1098
|
+
const canCompress = hasEarlierNonToolUserMessage(messages);
|
|
1099
|
+
let toolRide = routedTool
|
|
1100
|
+
? {
|
|
1101
|
+
raw: routedBody,
|
|
1102
|
+
turnType: "tool-memory",
|
|
1103
|
+
sizeHolder: rideableCandidate.stablePrefix ?? rideableCandidate,
|
|
1104
|
+
}
|
|
1105
|
+
: undefined;
|
|
1106
|
+
let toolNeedsOriginal = false;
|
|
1107
|
+
if (opts.toolRouteRecovery === false) {
|
|
1108
|
+
// Kill switch: no size check and no compress call on any tool turn.
|
|
1109
|
+
// A route still rides; a miss forwards whole and records "disabled".
|
|
1110
|
+
if ((routeMiss === "missing" || routeMiss === "rejected") && canCompress) {
|
|
1111
|
+
rec.routeRecovery = { outcome: "disabled" };
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
else if (isToolResultTurn || isContinuationTurn || (isUserTurn && !!existingRide)) {
|
|
1115
|
+
// Every lane's tool turn lives by the budget, like a main human turn:
|
|
1116
|
+
// under it, forward (on the lane's route or the session's stable
|
|
1117
|
+
// prefix, else whole) with no compress call; at it, compress once to
|
|
1118
|
+
// the target (planToolCompaction). A "replay" is a client retry of the
|
|
1119
|
+
// request that installed the route, forwarded verbatim.
|
|
1120
|
+
const plan = planToolCompaction({
|
|
1121
|
+
opts,
|
|
1122
|
+
state,
|
|
1123
|
+
body,
|
|
1124
|
+
messages,
|
|
1125
|
+
sessionId: toolSessionId,
|
|
1126
|
+
routeKey: requestRouteKey,
|
|
1127
|
+
sizeKey: requestSizeKey,
|
|
1128
|
+
sizing: requestSizing,
|
|
1129
|
+
isMainRequest,
|
|
1130
|
+
modelContextLimit,
|
|
1131
|
+
forwardBody,
|
|
1132
|
+
rec,
|
|
1133
|
+
ride: toolRide,
|
|
1134
|
+
canCompress,
|
|
1135
|
+
});
|
|
1136
|
+
if (plan.kind === "ride")
|
|
1137
|
+
toolRide = plan.ride;
|
|
1138
|
+
if (plan.kind === "pass" && plan.original) {
|
|
1139
|
+
toolRide = undefined;
|
|
1140
|
+
toolNeedsOriginal = true;
|
|
1141
|
+
}
|
|
1142
|
+
if (plan.kind === "compress") {
|
|
1143
|
+
requestSendPolicies.get(req).failedRebuild = true;
|
|
1144
|
+
// Clear the caller's old ride before any in-flight/backoff/cooldown
|
|
1145
|
+
// exit. An over-window prefix cannot remain the implicit fallback.
|
|
1146
|
+
toolRide = plan.fallback;
|
|
1147
|
+
toolNeedsOriginal = plan.overWindow;
|
|
1148
|
+
const prior = state.toolRecoveryAttemptedLanes.get(requestRouteKey);
|
|
1149
|
+
if (prior?.awaitingIndex && Date.now() >= prior.awaitingIndex.deadline) {
|
|
1150
|
+
prior.awaitingIndex = undefined;
|
|
1151
|
+
}
|
|
1152
|
+
if (prior?.inFlight || (!prior && state.toolRecoveryAttemptedLanes.size >= 128 &&
|
|
1153
|
+
[...state.toolRecoveryAttemptedLanes.values()].every((attempt) => attempt.inFlight))) {
|
|
1154
|
+
// A concurrent request of this lane is already compressing.
|
|
1155
|
+
rec.routeRecovery = { outcome: "in-flight" };
|
|
1156
|
+
}
|
|
1157
|
+
else if (prior?.awaitingIndex && !plan.overWindow) {
|
|
1158
|
+
// The last attempt found no tree for this conversation, so a
|
|
1159
|
+
// compress call could only pass everything through again. Forward
|
|
1160
|
+
// now; the page check lets the next tool turn try once it exists.
|
|
1161
|
+
rec.routeRecovery = { outcome: "awaiting-index" };
|
|
1162
|
+
checkAwaitedIndex(opts, prior, state.shutdownSignal);
|
|
1163
|
+
}
|
|
1164
|
+
else if (prior?.retryAtTokens !== undefined &&
|
|
1165
|
+
plan.estimateTokens < prior.retryAtTokens &&
|
|
1166
|
+
Date.now() < (prior.retryDeadline ?? 0)) {
|
|
1167
|
+
// This lane's last attempt this human turn produced nothing; wait
|
|
1168
|
+
// for growth instead of paying a blocking call on every tool turn.
|
|
1169
|
+
rec.routeRecovery = { outcome: "backoff" };
|
|
1170
|
+
}
|
|
1171
|
+
else if (Date.now() < state.toolRecoveryCooldownUntil) {
|
|
1172
|
+
// A recent attempt burned the full compress budget and still
|
|
1173
|
+
// failed. Every tool turn appends a tool_result and rehashes, so
|
|
1174
|
+
// compress() dedup can never absorb the repeat: without this
|
|
1175
|
+
// cooldown a MemTree outage would add the whole blocking budget to
|
|
1176
|
+
// every tool turn over the budget. A cooldown skip does not start
|
|
1177
|
+
// the lane's backoff.
|
|
1178
|
+
rec.routeRecovery = { outcome: "cooldown" };
|
|
1179
|
+
}
|
|
1180
|
+
else {
|
|
1181
|
+
const recoveryAttempt = { modelContextLimit, inFlight: true };
|
|
1182
|
+
if (!prior && state.toolRecoveryAttemptedLanes.size >= 128) {
|
|
1183
|
+
const idle = [...state.toolRecoveryAttemptedLanes].find(([, attempt]) => !attempt.inFlight);
|
|
1184
|
+
if (idle)
|
|
1185
|
+
state.toolRecoveryAttemptedLanes.delete(idle[0]);
|
|
1186
|
+
}
|
|
1187
|
+
state.toolRecoveryAttemptedLanes.set(requestRouteKey, recoveryAttempt);
|
|
1188
|
+
// Serialized non-system conversation bytes, for the record only —
|
|
1189
|
+
// computed only when an attempt is made.
|
|
1190
|
+
const conversationBytes = Buffer.byteLength(JSON.stringify(messages.filter((m) => m.role !== "system")), "utf-8");
|
|
1191
|
+
return logged(recoverToolRouteMiss({
|
|
1192
|
+
opts,
|
|
1193
|
+
state,
|
|
1194
|
+
req,
|
|
1195
|
+
res,
|
|
1196
|
+
upstream,
|
|
1197
|
+
body,
|
|
1198
|
+
messages,
|
|
1199
|
+
forwardBody: plan.overWindow ? rawBody : forwardBody,
|
|
1200
|
+
originalBody: rawBody,
|
|
1201
|
+
msgsForMemtree,
|
|
1202
|
+
hash,
|
|
1203
|
+
modelContextLimit,
|
|
1204
|
+
routeEpoch,
|
|
1205
|
+
rec,
|
|
1206
|
+
conversationBytes,
|
|
1207
|
+
routeKey: requestRouteKey,
|
|
1208
|
+
sizeKey: requestSizeKey,
|
|
1209
|
+
isMainRequest,
|
|
1210
|
+
recoveryAttempt,
|
|
1211
|
+
sizing: requestSizing,
|
|
1212
|
+
inputCandidate: forwardCandidate,
|
|
1213
|
+
toolForwarding,
|
|
1214
|
+
clientMeta,
|
|
1215
|
+
compaction: { target: plan.target, threshold: plan.threshold },
|
|
1216
|
+
...(plan.replaces !== undefined
|
|
1217
|
+
? {
|
|
1218
|
+
stable: {
|
|
1219
|
+
targetTokens: plan.targetTokens,
|
|
1220
|
+
explicitTarget: plan.explicitTarget,
|
|
1221
|
+
modelContextLimit,
|
|
1222
|
+
replaces: plan.replaces,
|
|
1223
|
+
},
|
|
1224
|
+
}
|
|
1225
|
+
: {}),
|
|
1226
|
+
...(plan.fallback ? { fallback: plan.fallback } : {}),
|
|
1227
|
+
retryAtTokens: plan.estimateTokens +
|
|
1228
|
+
Math.floor(plan.budgetTokens * TOOL_COMPACTION_RETRY_BUDGET_RATIO),
|
|
1229
|
+
}).finally(() => {
|
|
1230
|
+
recoveryAttempt.inFlight = false;
|
|
1231
|
+
}));
|
|
1232
|
+
}
|
|
1233
|
+
// No attempt: send the old ride when there is one, else the whole
|
|
1234
|
+
// history.
|
|
1235
|
+
if (plan.fallback) {
|
|
1236
|
+
toolRide = plan.fallback;
|
|
1237
|
+
rec.compaction.keptPrefix = true;
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
if (toolRide)
|
|
1242
|
+
requestSendPolicies.get(req)?.allowed.add(toolRide.raw);
|
|
1243
|
+
const sendBody = toolRide?.raw ?? (toolNeedsOriginal ? rawBody : forwardBody);
|
|
1244
|
+
recordTurn(rec, toolRide ? toolRide.turnType : isUserTurn ? "first-user" : "tool", sendBody);
|
|
1245
|
+
opts.memtree.indexInBackground(hash, msgsForMemtree, modelContextLimit, toolSessionId, clientMeta, transcriptTimesFor(opts, toolSessionId, agentAttributionId(req)));
|
|
1246
|
+
capture(opts, toolRide ? "anthropic-request-memory-tool" : "anthropic-request", sendBody);
|
|
1247
|
+
// The size Anthropic reports is the next budget check's anchor: a ride
|
|
1248
|
+
// reports to its prefix (or route), a whole request to the session's (or
|
|
1249
|
+
// lane's) passthrough size.
|
|
1250
|
+
return logged(forwardRaw(req, res, sendBody, opts, upstream, state.shutdownSignal, rec).then((delivered) => {
|
|
1251
|
+
toolForwarding.settle(delivered);
|
|
1252
|
+
if (toolRide)
|
|
1253
|
+
noteRideSize(toolRide.sizeHolder, rec, sendBody.length);
|
|
1254
|
+
else {
|
|
1255
|
+
noteWholeRequestSize(state, isMainRequest, toolSessionId, requestSizeKey, rec, sendBody.length);
|
|
1256
|
+
}
|
|
1257
|
+
}));
|
|
1258
|
+
}
|
|
1259
|
+
// The away recap is a fork of the main conversation: its history is the
|
|
1260
|
+
// main thread's plus one question. Ride the main thread's last compressed
|
|
1261
|
+
// prefix instead of compressing separately, so the request stays under the
|
|
1262
|
+
// window, hits the prompt cache the main thread warmed, and costs no
|
|
1263
|
+
// MemTree call. Any mismatch falls through to the recap's own compression.
|
|
1264
|
+
if (isAwaySummary) {
|
|
1265
|
+
const fork = forkRoutedBody(body, messages, state.lastMainRoute, requestSessionId(req));
|
|
1266
|
+
if ("body" in fork) {
|
|
1267
|
+
requestSizing.register(fork.body, "ride");
|
|
1268
|
+
requestSendPolicies.get(req)?.allowed.add(fork.body);
|
|
1269
|
+
releaseRouteDecision(state, requestRouteKey, routeDecisionGeneration);
|
|
1270
|
+
recordTurn(rec, "fork-memory", fork.body);
|
|
1271
|
+
capture(opts, "anthropic-request-memory-fork", fork.body);
|
|
1272
|
+
return logged(forwardRaw(req, res, fork.body, opts, upstream, state.shutdownSignal, rec));
|
|
1273
|
+
}
|
|
1274
|
+
rec.forkMiss = fork.miss;
|
|
1275
|
+
}
|
|
1276
|
+
const markMainPromptDelivered = () => toolForwarding.settle(true);
|
|
1277
|
+
// A newer same-lane replacement prevents older asynchronous installation.
|
|
1278
|
+
const routeDecisionCurrent = () => state.mainRouteEpoch === routeEpoch &&
|
|
1279
|
+
routeDecisionHolds(state, requestRouteKey, routeDecisionGeneration);
|
|
1280
|
+
const commitRouteDecision = () => {
|
|
1281
|
+
routeDecisionSettled = true;
|
|
1282
|
+
};
|
|
1283
|
+
const releaseUncommittedRouteDecision = () => {
|
|
1284
|
+
if (routeDecisionSettled)
|
|
1285
|
+
return;
|
|
1286
|
+
releaseRouteDecision(state, requestRouteKey, routeDecisionGeneration);
|
|
1287
|
+
routeDecisionSettled = true;
|
|
1288
|
+
};
|
|
1289
|
+
// An uncompressed fallback releases this replacement reservation. The old
|
|
1290
|
+
// validated route remains available to requests whose histories still match.
|
|
1291
|
+
const releaseForOriginalFallback = () => {
|
|
1292
|
+
requestSendPolicies.get(req).failedRebuild = true;
|
|
1293
|
+
releaseUncommittedRouteDecision();
|
|
1294
|
+
};
|
|
1295
|
+
const sessionId = requestSessionId(req);
|
|
1296
|
+
// Stable-prefix (edge) compaction: the main thread's human turns only. The
|
|
1297
|
+
// recap rides the main route above; subagents keep per-turn compression.
|
|
1298
|
+
const edge = isMainRequest && sessionId !== undefined
|
|
1299
|
+
? planEdgeCompaction({
|
|
1300
|
+
sizing: requestSizing,
|
|
1301
|
+
opts,
|
|
1302
|
+
state,
|
|
1303
|
+
body,
|
|
1304
|
+
messages,
|
|
1305
|
+
sessionId,
|
|
1306
|
+
modelContextLimit,
|
|
1307
|
+
forwardBody,
|
|
1308
|
+
rec,
|
|
1309
|
+
})
|
|
1310
|
+
: undefined;
|
|
1311
|
+
// An active subagent can repeat/embed the human prompt in its own request;
|
|
1312
|
+
// producer suppression must also preserve the arm for the later main call.
|
|
1313
|
+
if (displayForThisTurn)
|
|
1314
|
+
state.mainNoticePending = false;
|
|
1315
|
+
/**
|
|
1316
|
+
* Forward a compressed body — a fresh compaction, or a ride on the stable
|
|
1317
|
+
* prefix — and make it the route for the rest of this human turn's tool
|
|
1318
|
+
* loop (and, for a compaction, the session's new stable prefix).
|
|
1319
|
+
*/
|
|
1320
|
+
const forwardCompressed = async (compressedBody, compressedRaw, turnType, stable, result) => {
|
|
1321
|
+
if (!(await requestSendPolicies.get(req).fitsNative(compressedRaw))) {
|
|
1322
|
+
releaseUncommittedRouteDecision();
|
|
1323
|
+
recordTurn(rec, "followup-degraded", rawBody);
|
|
1324
|
+
return logged(forwardRaw(req, res, rawBody, opts, upstream, state.shutdownSignal, rec));
|
|
1325
|
+
}
|
|
1326
|
+
requestSendPolicies.get(req)?.allowed.add(compressedRaw);
|
|
1327
|
+
if (opts.debug) {
|
|
1328
|
+
console.error(`[ccc proxy] user turn ${turnType}: ${forwardBody.length} → ` +
|
|
1329
|
+
`${compressedRaw.length} body bytes`);
|
|
1330
|
+
}
|
|
1331
|
+
// Claude can consume message_stop, execute a fast local tool, and close the
|
|
1332
|
+
// SSE response before Node observes the downstream HTTP `finish` event.
|
|
1333
|
+
// Activate the route once the complete Anthropic response has been accepted
|
|
1334
|
+
// by the downstream response, so an immediate tool-result request cannot
|
|
1335
|
+
// race the later forwardRaw() delivery promise.
|
|
1336
|
+
let routeActivationAttempted = false;
|
|
1337
|
+
const activateMemoryRoute = () => {
|
|
1338
|
+
if (routeActivationAttempted)
|
|
1339
|
+
return;
|
|
1340
|
+
if (!requestSendPolicies.get(req)?.allowed.has(compressedRaw)) {
|
|
1341
|
+
releaseUncommittedRouteDecision();
|
|
1342
|
+
routeActivationAttempted = true;
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
if (!routeDecisionCurrent()) {
|
|
1346
|
+
releaseUncommittedRouteDecision();
|
|
1347
|
+
routeActivationAttempted = true;
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
1350
|
+
// Away lane: commit the decision but store nothing. Nothing can ever
|
|
1351
|
+
// read an away route — tool turns and count_tokens can never classify as
|
|
1352
|
+
// away — so storing it would only mislead and burn heap. All the
|
|
1353
|
+
// reservation bookkeeping above and around this stays exactly as for any
|
|
1354
|
+
// lane: it is what stops a stale slow away duplicate from stepping on a
|
|
1355
|
+
// newer one.
|
|
1356
|
+
if (requestRouteLane === "away") {
|
|
1357
|
+
commitRouteDecision();
|
|
1358
|
+
routeActivationAttempted = true;
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
1361
|
+
const installed = installMemoryRoute(state, requestRouteKey, req, body, messages, compressedBody, routeEpoch, routeDecisionGeneration, stable);
|
|
1362
|
+
commitRouteDecision();
|
|
1363
|
+
// Leave this false if installMemoryRoute unexpectedly throws: the
|
|
1364
|
+
// delivery-complete fallback then gets one safe retry.
|
|
1365
|
+
routeActivationAttempted = true;
|
|
1366
|
+
if (opts.debug) {
|
|
1367
|
+
console.error(`[ccc proxy] memory route activation: ${installed ? "installed" : "unavailable"}`);
|
|
1368
|
+
}
|
|
1369
|
+
};
|
|
1370
|
+
// If a newer same-lane decision is live, keep this request's reservation
|
|
1371
|
+
// until its protocol-complete activation (or terminal forward failure).
|
|
1372
|
+
// The newer request may still end without mutating anything and hand
|
|
1373
|
+
// ownership back while this response is in flight. Releasing here would let
|
|
1374
|
+
// this request install later with no committed generation protecting that
|
|
1375
|
+
// route from an even older completion.
|
|
1376
|
+
recordTurn(rec, turnType, compressedRaw);
|
|
1377
|
+
capture(opts, turnType === "followup-prefix" ? "anthropic-request-memory-prefix" : "anthropic-request", compressedRaw);
|
|
1378
|
+
// Queue the success notice before the stream starts: a long live stream may
|
|
1379
|
+
// claim its display notice before message_stop. A prefix ride made no
|
|
1380
|
+
// compress call, so there is nothing new to announce.
|
|
1381
|
+
if (result) {
|
|
1382
|
+
queueCompressionNotice({
|
|
1383
|
+
state,
|
|
1384
|
+
req,
|
|
1385
|
+
displayForThisTurn,
|
|
1386
|
+
noticePromptGeneration,
|
|
1387
|
+
noticePromptId,
|
|
1388
|
+
result,
|
|
1389
|
+
rec,
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
return logged(forwardRaw(req, res, compressedRaw, opts, upstream, state.shutdownSignal, rec,
|
|
1393
|
+
// Wrapped like the recovery twin's protocol-complete callback — not
|
|
1394
|
+
// for behavior (forwardRaw's notify catch swallows a throw either
|
|
1395
|
+
// way, and routeActivationAttempted stays false so the
|
|
1396
|
+
// delivered-settle retry below still gets its one safe attempt) but
|
|
1397
|
+
// for observability: a bare throw here otherwise leaves zero trace,
|
|
1398
|
+
// unlike the recovery path's "activation-error" install fate.
|
|
1399
|
+
() => {
|
|
1400
|
+
try {
|
|
1401
|
+
activateMemoryRoute();
|
|
1402
|
+
}
|
|
1403
|
+
catch (err) {
|
|
1404
|
+
if (opts.debug) {
|
|
1405
|
+
console.error(`[ccc proxy] followup route activation threw at protocol-complete: ${err}`);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
}).then((delivered) => {
|
|
1409
|
+
// The size Anthropic reported for this request is where the next
|
|
1410
|
+
// human turn's budget check starts.
|
|
1411
|
+
const sizePrefix = stable
|
|
1412
|
+
? "ride" in stable
|
|
1413
|
+
? stable.ride
|
|
1414
|
+
: stable.installed
|
|
1415
|
+
: undefined;
|
|
1416
|
+
if (sizePrefix)
|
|
1417
|
+
notePrefixSize(sizePrefix, rec, compressedRaw.length);
|
|
1418
|
+
// A route already installed at protocol-complete deliberately survives
|
|
1419
|
+
// delivered=false: that settle may be the fast-tool abort (client
|
|
1420
|
+
// consumed message_stop, closed the SSE response, and its immediate
|
|
1421
|
+
// tool request must ride the prefix). A socket death before flush
|
|
1422
|
+
// settles identically; an identical-body retry reuses the same
|
|
1423
|
+
// validated compressed prefix with an empty suffix.
|
|
1424
|
+
if (!delivered) {
|
|
1425
|
+
// Protocol-complete activation may already have installed the route
|
|
1426
|
+
// before a fast downstream close. Otherwise this forward made no
|
|
1427
|
+
// route decision, so return an uncommitted agent reservation.
|
|
1428
|
+
if (!routeActivationAttempted)
|
|
1429
|
+
releaseUncommittedRouteDecision();
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
markMainPromptDelivered();
|
|
1433
|
+
// Route the rest of this human turn's tool loop — and the count_tokens
|
|
1434
|
+
// calls Claude Code sizes its context with — through the same compressed
|
|
1435
|
+
// prefix. Without this the tool loop re-sends the full history, so
|
|
1436
|
+
// count_tokens reports the uncompressed conversation and Claude Code
|
|
1437
|
+
// auto-compacts a context that memory had already shrunk.
|
|
1438
|
+
// Retain delivery completion as a defensive retry if protocol-time
|
|
1439
|
+
// route bookkeeping failed unexpectedly. Guarded like the recovery
|
|
1440
|
+
// twin: a repeat throw here would reject inside this settle callback
|
|
1441
|
+
// and strand the uncommitted reservation, so release it instead.
|
|
1442
|
+
try {
|
|
1443
|
+
activateMemoryRoute();
|
|
1444
|
+
}
|
|
1445
|
+
catch (err) {
|
|
1446
|
+
if (!routeActivationAttempted)
|
|
1447
|
+
releaseUncommittedRouteDecision();
|
|
1448
|
+
if (opts.debug) {
|
|
1449
|
+
console.error(`[ccc proxy] followup route activation retry threw: ${err}`);
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
}));
|
|
1453
|
+
};
|
|
1454
|
+
if (existingRide && (!edge || edge.kind === "off")) {
|
|
1455
|
+
const owner = rideableCandidate.stablePrefix ?? rideableCandidate;
|
|
1456
|
+
const candidate = requestSizing.register(existingRide, "ride", owner.lastSize);
|
|
1457
|
+
candidateSizeOwners.set(candidate, owner);
|
|
1458
|
+
return forwardCompressed(JSON.parse(existingRide.toString("utf-8")), existingRide, "followup-prefix", undefined, undefined);
|
|
1459
|
+
}
|
|
1460
|
+
if (edge?.kind === "ride") {
|
|
1461
|
+
// The stable prefix still covers this conversation and prefix + newer
|
|
1462
|
+
// turns fit the budget: the same prefix bytes as the last request, so
|
|
1463
|
+
// Anthropic reads them from cache, and no compress call. MemTree still
|
|
1464
|
+
// gets the history to index.
|
|
1465
|
+
opts.memtree.indexInBackground(hash, msgsForMemtree, modelContextLimit, sessionId, clientMeta, transcriptTimesFor(opts, sessionId, agentAttributionId(req)));
|
|
1466
|
+
return forwardCompressed(edge.routed.body, edge.routed.raw, "followup-prefix", { ride: edge.prefix }, undefined);
|
|
1467
|
+
}
|
|
1468
|
+
/**
|
|
1469
|
+
* A compaction that produced no new prefix: send this turn on the old one
|
|
1470
|
+
* instead of the whole history when it is still valid. Undefined when there
|
|
1471
|
+
* is none, or when the server passed a `/memtree-compact` request through
|
|
1472
|
+
* (the conversation is under the asked-for target: send it whole).
|
|
1473
|
+
*/
|
|
1474
|
+
const rideOldPrefix = (passedThrough) => {
|
|
1475
|
+
if (edge?.kind !== "compress" || !edge.fallback)
|
|
1476
|
+
return undefined;
|
|
1477
|
+
if (passedThrough && edge.reason === "manual")
|
|
1478
|
+
return undefined;
|
|
1479
|
+
rec.compaction.keptPrefix = true;
|
|
1480
|
+
return forwardCompressed(edge.fallback.routed.body, edge.fallback.routed.raw, "followup-prefix", { ride: edge.fallback.prefix }, undefined);
|
|
1481
|
+
};
|
|
1482
|
+
/** A main request about to go out whole: size it and drop any stale prefix. */
|
|
1483
|
+
const sendingWhole = () => { requestSendPolicies.get(req).failedRebuild = true; };
|
|
1484
|
+
const noteWholeSize = () => {
|
|
1485
|
+
if (isMainRequest)
|
|
1486
|
+
notePassthroughSize(state, sessionId, rec, forwardBody.length);
|
|
1487
|
+
};
|
|
1488
|
+
// The complete request upload can outlive its downstream subscriber while
|
|
1489
|
+
// MemTree compression is in flight. Track that subscriber locally without
|
|
1490
|
+
// feeding its lifetime into MemtreeClient.compress(): compression promises
|
|
1491
|
+
// are hash-deduped and may still be serving another live retry.
|
|
1492
|
+
let downstreamClosedDuringCompression = res.destroyed && !res.writableFinished;
|
|
1493
|
+
const markDownstreamClosedDuringCompression = () => {
|
|
1494
|
+
if (!res.writableFinished)
|
|
1495
|
+
downstreamClosedDuringCompression = true;
|
|
1496
|
+
};
|
|
1497
|
+
res.once("close", markDownstreamClosedDuringCompression);
|
|
1498
|
+
// The hidden away-summary request compresses too, but its page is not the
|
|
1499
|
+
// conversation the user is looking at.
|
|
1500
|
+
const linkSeq = isMainRequest && !isAwaySummary ? nextMemtreeCallSeq(state) : undefined;
|
|
1501
|
+
let compression;
|
|
1502
|
+
try {
|
|
1503
|
+
compression = await runBlockingCompression({
|
|
1504
|
+
opts,
|
|
1505
|
+
state,
|
|
1506
|
+
body,
|
|
1507
|
+
msgsForMemtree,
|
|
1508
|
+
hash,
|
|
1509
|
+
modelContextLimit,
|
|
1510
|
+
rec,
|
|
1511
|
+
sessionId,
|
|
1512
|
+
agentId: agentAttributionId(req),
|
|
1513
|
+
clientMeta,
|
|
1514
|
+
sizing: requestSizing,
|
|
1515
|
+
inputCandidate: forwardCandidate,
|
|
1516
|
+
...(edge?.kind === "compress"
|
|
1517
|
+
? { compaction: { target: edge.target, threshold: edge.threshold } }
|
|
1518
|
+
: edge?.kind === "off"
|
|
1519
|
+
? { compaction: {} }
|
|
1520
|
+
: {}),
|
|
1521
|
+
});
|
|
1522
|
+
}
|
|
1523
|
+
catch {
|
|
1524
|
+
const kept = rideOldPrefix(false);
|
|
1525
|
+
if (kept)
|
|
1526
|
+
return kept;
|
|
1527
|
+
releaseUncommittedRouteDecision();
|
|
1528
|
+
recordTurn(rec, "followup-degraded", rawBody);
|
|
1529
|
+
capture(opts, "anthropic-request", rawBody);
|
|
1530
|
+
return logged(forwardRaw(req, res, rawBody, opts, upstream, state.shutdownSignal, rec)
|
|
1531
|
+
.then((delivered) => {
|
|
1532
|
+
if (isMainRequest)
|
|
1533
|
+
notePassthroughSize(state, sessionId, rec, rawBody.length);
|
|
1534
|
+
if (delivered)
|
|
1535
|
+
markMainPromptDelivered();
|
|
1536
|
+
}));
|
|
1537
|
+
}
|
|
1538
|
+
finally {
|
|
1539
|
+
res.off("close", markDownstreamClosedDuringCompression);
|
|
1540
|
+
}
|
|
1541
|
+
const { result } = compression;
|
|
1542
|
+
noteMemtreeHealth(state, compression);
|
|
1543
|
+
if (linkSeq !== undefined) {
|
|
1544
|
+
noteMemtreePage(state, linkSeq, sessionId, result);
|
|
1545
|
+
}
|
|
1546
|
+
// Any answer settles a `/memtree-compact`: a compressed one became the
|
|
1547
|
+
// prefix, a passthrough means there was nothing to compact. A failure keeps
|
|
1548
|
+
// it pending for the next turn.
|
|
1549
|
+
if (result && sessionId !== undefined)
|
|
1550
|
+
state.compactNow.delete(sessionId);
|
|
1551
|
+
if (downstreamClosedDuringCompression ||
|
|
1552
|
+
(res.destroyed && !res.writableFinished)) {
|
|
1553
|
+
releaseUncommittedRouteDecision();
|
|
1554
|
+
recordTurn(rec, "followup-client-closed", Buffer.alloc(0));
|
|
1555
|
+
return logged(Promise.resolve());
|
|
1556
|
+
}
|
|
1557
|
+
// No await occurs between this check and the selected forwarder's call.
|
|
1558
|
+
// Each forwarder installs its own close listener synchronously, so this is
|
|
1559
|
+
// an event-loop-atomic handoff of downstream-close ownership.
|
|
1560
|
+
if (!result) {
|
|
1561
|
+
const kept = rideOldPrefix(false);
|
|
1562
|
+
if (kept)
|
|
1563
|
+
return kept;
|
|
1564
|
+
// MemTree down/slow/402: the user's own Anthropic call is never gated on
|
|
1565
|
+
// it. Degrade to passthrough and queue a display-only hook notice for a
|
|
1566
|
+
// visible turn. The hidden away-summary request deliberately stays quiet.
|
|
1567
|
+
// Unpaid key (402, from this compress OR an earlier background index) gets
|
|
1568
|
+
// a payment-specific notice instead of the generic degraded one, at most
|
|
1569
|
+
// once per proxy process after it has actually been delivered.
|
|
1570
|
+
recordTurn(rec, "followup-degraded", forwardBody);
|
|
1571
|
+
releaseForOriginalFallback();
|
|
1572
|
+
sendingWhole();
|
|
1573
|
+
capture(opts, "anthropic-request", forwardBody);
|
|
1574
|
+
const paymentDetail = opts.memtree.paymentRequiredDetail;
|
|
1575
|
+
const mayQueueNotice = displayForThisTurn && state.mainPromptGeneration === noticePromptGeneration;
|
|
1576
|
+
if (mayQueueNotice && paymentDetail !== null && !state.paymentNoticeShown) {
|
|
1577
|
+
const detailFirstLine = sanitizeNoticeDetail(paymentDetail.split(/[\r\n]/, 1)[0]);
|
|
1578
|
+
state.notices.queueSuffix(detailFirstLine
|
|
1579
|
+
? `${PAYMENT_REQUIRED_NOTICE}\n${detailFirstLine}`
|
|
1580
|
+
: PAYMENT_REQUIRED_NOTICE, () => {
|
|
1581
|
+
state.paymentNoticeShown = true;
|
|
1582
|
+
}, noticePromptId);
|
|
1583
|
+
}
|
|
1584
|
+
else if (mayQueueNotice && paymentDetail === null) {
|
|
1585
|
+
state.notices.queueSuffix(DEGRADED_NOTICE, undefined, noticePromptId);
|
|
1586
|
+
}
|
|
1587
|
+
return logged(forwardRaw(req, res, forwardBody, opts, upstream, state.shutdownSignal, rec).then((delivered) => {
|
|
1588
|
+
noteWholeSize();
|
|
1589
|
+
if (delivered)
|
|
1590
|
+
markMainPromptDelivered();
|
|
1591
|
+
}));
|
|
1592
|
+
}
|
|
1593
|
+
const actuallyCompressed = didMemtreeCompress(result);
|
|
1594
|
+
const historyCheck = checkCompressedHistory(result, msgsForMemtree);
|
|
1595
|
+
rec.history = {
|
|
1596
|
+
retainedChars: historyCheck.retainedChars,
|
|
1597
|
+
priorHistoryChars: historyCheck.priorHistoryChars,
|
|
1598
|
+
usable: historyCheck.usable,
|
|
1599
|
+
};
|
|
1600
|
+
if (!actuallyCompressed || !historyCheck.usable) {
|
|
1601
|
+
const kept = rideOldPrefix(!actuallyCompressed);
|
|
1602
|
+
if (kept)
|
|
1603
|
+
return kept;
|
|
1604
|
+
// Two distinct ways to get an unusable answer, one recovery.
|
|
1605
|
+
//
|
|
1606
|
+
// No cached/indexed tokens means the server is still warming an index and
|
|
1607
|
+
// returned the messages as-is. Preserve true passthrough semantics:
|
|
1608
|
+
// flattening that no-op response changes Anthropic's structured
|
|
1609
|
+
// conversation and made the first request disagree with the full-history
|
|
1610
|
+
// tool loop that followed it.
|
|
1611
|
+
//
|
|
1612
|
+
// A fully indexed response that carries no prior conversation is the more
|
|
1613
|
+
// dangerous case: it looks like a perfect compression by every usage-based
|
|
1614
|
+
// measure, so nothing downstream would notice that the model is about to be
|
|
1615
|
+
// asked to continue a conversation it can no longer see. Forwarding the
|
|
1616
|
+
// real history costs context but never silently amnesias the session.
|
|
1617
|
+
releaseForOriginalFallback();
|
|
1618
|
+
sendingWhole();
|
|
1619
|
+
if (actuallyCompressed && opts.debug) {
|
|
1620
|
+
console.error(`[ccc proxy] memory response dropped the conversation ` +
|
|
1621
|
+
`(retained ${historyCheck.retainedChars} of ` +
|
|
1622
|
+
`${historyCheck.priorHistoryChars} prior chars); forwarding history`);
|
|
1623
|
+
}
|
|
1624
|
+
recordTurn(rec, actuallyCompressed ? "followup-empty-memory" : "followup-noop", forwardBody);
|
|
1625
|
+
capture(opts, "anthropic-request", forwardBody);
|
|
1626
|
+
return logged(forwardRaw(req, res, forwardBody, opts, upstream, state.shutdownSignal, rec).then((delivered) => {
|
|
1627
|
+
noteWholeSize();
|
|
1628
|
+
if (delivered)
|
|
1629
|
+
markMainPromptDelivered();
|
|
1630
|
+
}));
|
|
1631
|
+
}
|
|
1632
|
+
// Invariant from the early return above: past this point the MemTree result
|
|
1633
|
+
// actually compressed (actuallyCompressed is true) and the retained history
|
|
1634
|
+
// is usable — every remaining path forwards the compressed body.
|
|
1635
|
+
let built;
|
|
1636
|
+
try {
|
|
1637
|
+
built = buildCompressedBody(body, result);
|
|
1638
|
+
}
|
|
1639
|
+
catch (err) {
|
|
1640
|
+
releaseUncommittedRouteDecision();
|
|
1641
|
+
throw err;
|
|
1642
|
+
}
|
|
1643
|
+
if (built === null) {
|
|
1644
|
+
const kept = rideOldPrefix(false);
|
|
1645
|
+
if (kept)
|
|
1646
|
+
return kept;
|
|
1647
|
+
// The server compressed but returned no usable `flattened_messages`
|
|
1648
|
+
// (pre-flatten server, or a malformed field). The flatten format lives
|
|
1649
|
+
// server-side only — the client deliberately has no local fallback, that
|
|
1650
|
+
// drift is what caused the append/adherence regressions — so forward the
|
|
1651
|
+
// real history instead, exactly like the unusable branch above.
|
|
1652
|
+
releaseForOriginalFallback();
|
|
1653
|
+
sendingWhole();
|
|
1654
|
+
if (opts.debug) {
|
|
1655
|
+
console.error("[ccc proxy] compressed result carried no server flatten; " +
|
|
1656
|
+
"forwarding history");
|
|
1657
|
+
}
|
|
1658
|
+
recordTurn(rec, "followup-no-flatten", forwardBody);
|
|
1659
|
+
capture(opts, "anthropic-request", forwardBody);
|
|
1660
|
+
return logged(forwardRaw(req, res, forwardBody, opts, upstream, state.shutdownSignal, rec).then((delivered) => {
|
|
1661
|
+
noteWholeSize();
|
|
1662
|
+
if (delivered)
|
|
1663
|
+
markMainPromptDelivered();
|
|
1664
|
+
}));
|
|
1665
|
+
}
|
|
1666
|
+
// A compaction: this result becomes the session's stable prefix once the
|
|
1667
|
+
// response completes. The server may also have compressed on its own
|
|
1668
|
+
// (over its budget with no threshold from us): that is a budget compaction.
|
|
1669
|
+
let stable;
|
|
1670
|
+
if (edge?.kind === "compress") {
|
|
1671
|
+
const compaction = rec.compaction;
|
|
1672
|
+
compaction.reason ??= compaction.prefixMiss ? "prefix-mismatch" : "budget";
|
|
1673
|
+
stable = {
|
|
1674
|
+
targetTokens: edge.targetTokens,
|
|
1675
|
+
explicitTarget: edge.explicitTarget,
|
|
1676
|
+
modelContextLimit,
|
|
1677
|
+
};
|
|
1678
|
+
}
|
|
1679
|
+
return forwardCompressed(built.compressedBody, built.compressedRaw, "followup-compressed", stable, result);
|
|
1680
|
+
}
|
|
1681
|
+
function queueCompressionNotice(args) {
|
|
1682
|
+
const { state, req, displayForThisTurn, noticePromptGeneration, noticePromptId, result, rec, } = args;
|
|
1683
|
+
if (!displayForThisTurn ||
|
|
1684
|
+
state.mainPromptGeneration !== noticePromptGeneration) {
|
|
1685
|
+
return;
|
|
1686
|
+
}
|
|
1687
|
+
// Announce only actual (re)indexing, measured as growth in how much of the
|
|
1688
|
+
// original prompt the index covers (`cached_tokens`). Unchanged coverage
|
|
1689
|
+
// means this turn rode the existing index with the new messages appended
|
|
1690
|
+
// after it — MemTree did no new indexing work worth reporting.
|
|
1691
|
+
//
|
|
1692
|
+
// Deliberately NOT keyed on the memory text: the server unfolds the index
|
|
1693
|
+
// against the current question, so its rendering (and length) changes on
|
|
1694
|
+
// nearly every turn regardless of indexing. Coverage is missing only on
|
|
1695
|
+
// servers old enough that `didMemtreeCompress` would have degraded this
|
|
1696
|
+
// turn already; announce rather than suppress on an unknown quantity.
|
|
1697
|
+
//
|
|
1698
|
+
// A newly ready MemTree page also earns the line: the link rides only the
|
|
1699
|
+
// success line (never its own message), so a finished index is announced
|
|
1700
|
+
// on the next compressed turn even when coverage stayed flat.
|
|
1701
|
+
const indexedTokens = cachedPromptTokenCount(result);
|
|
1702
|
+
const sessionId = requestSessionId(req);
|
|
1703
|
+
if (indexedTokens !== undefined) {
|
|
1704
|
+
const last = state.lastNoticedIndexCoverage;
|
|
1705
|
+
// Record every observed coverage, announced or not, so a server-side
|
|
1706
|
+
// index rebuild that shrinks coverage re-announces once it grows past
|
|
1707
|
+
// its own new baseline rather than staying silent until it beats the old.
|
|
1708
|
+
state.lastNoticedIndexCoverage = { sessionId, indexedTokens };
|
|
1709
|
+
if (last &&
|
|
1710
|
+
last.sessionId === sessionId &&
|
|
1711
|
+
indexedTokens <= last.indexedTokens &&
|
|
1712
|
+
!state.notices.linkPending(sessionId)) {
|
|
1713
|
+
return;
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
state.notices.queuePrefix(() => compressedTotalsText(...compressionTotals(rec, result)), undefined, noticePromptId);
|
|
1717
|
+
}
|
|
1718
|
+
/**
|
|
1719
|
+
* Before and after sizes for the success line, read when the line is shown.
|
|
1720
|
+
* Before: the estimated request that would otherwise have been sent (the
|
|
1721
|
+
* old prefix plus suffix on recompaction), else the server's raw_prompt_tokens
|
|
1722
|
+
* for the original history. After: Anthropic's reported compressed input once
|
|
1723
|
+
* usage arrives, else "before" scaled using the bytes of that same estimate.
|
|
1724
|
+
*/
|
|
1725
|
+
function compressionTotals(rec, result) {
|
|
1726
|
+
const original = rec?.compaction?.estimatedTokens ?? rawPromptTokenCount(result);
|
|
1727
|
+
if (!rec)
|
|
1728
|
+
return [original, undefined];
|
|
1729
|
+
const reported = reportedInputTokens(rec);
|
|
1730
|
+
if (reported !== undefined)
|
|
1731
|
+
return [original, reported];
|
|
1732
|
+
const fwd = rec.forwardedBytes;
|
|
1733
|
+
const basisBytes = rec.compaction?.estimatedTokens !== undefined
|
|
1734
|
+
? rec.compaction.estimatedBytes
|
|
1735
|
+
: rec.requestBytes;
|
|
1736
|
+
if (original === undefined || !fwd || !basisBytes)
|
|
1737
|
+
return [original, undefined];
|
|
1738
|
+
return [original, Math.round((original * fwd) / basisBytes)];
|
|
1739
|
+
}
|
|
1740
|
+
/**
|
|
1741
|
+
* The link on the success line: the newest page for the hook's session,
|
|
1742
|
+
* exactly as the server stamped it, keyed by the index it was compressed
|
|
1743
|
+
* against so it is announced once per newly finished index. The server hands
|
|
1744
|
+
* out its short spelling (`/m/<leading hex of the request id>`), which is
|
|
1745
|
+
* permanent — it outlives this proxy — and fits a terminal line.
|
|
1746
|
+
*/
|
|
1747
|
+
function installMemtreeLink(state, placement) {
|
|
1748
|
+
const resolve = (sessionId) => sessionLink(state, sessionId, currentMemtreePage(state, sessionId));
|
|
1749
|
+
switch (placement) {
|
|
1750
|
+
case "success":
|
|
1751
|
+
state.notices.setLink(resolve);
|
|
1752
|
+
break;
|
|
1753
|
+
case "turn":
|
|
1754
|
+
// The success line carries the current page on the line below it; the
|
|
1755
|
+
// end-of-turn trailer then skips a link that line already showed.
|
|
1756
|
+
state.notices.setLink(resolve);
|
|
1757
|
+
state.notices.setTrailer(resolve, placement);
|
|
1758
|
+
break;
|
|
1759
|
+
case "message":
|
|
1760
|
+
case "stop":
|
|
1761
|
+
// These trailers show on every message or every Stop; no second copy.
|
|
1762
|
+
state.notices.setTrailer(resolve, placement);
|
|
1763
|
+
break;
|
|
1764
|
+
case "off":
|
|
1765
|
+
break;
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
/**
|
|
1769
|
+
* The link line for a resumed main session: its newest page, from memory when
|
|
1770
|
+
* this proxy served it (an in-app `/resume` back to an earlier conversation),
|
|
1771
|
+
* else from the on-disk store (a new `ccc --resume` process). Adopts it as the
|
|
1772
|
+
* session's current page so trailers continue from it, at a sequence number
|
|
1773
|
+
* any later call beats.
|
|
1774
|
+
*/
|
|
1775
|
+
function resumeLinkLine(state, input) {
|
|
1776
|
+
if (input.agent_id !== undefined || input.source === "compact")
|
|
1777
|
+
return undefined;
|
|
1778
|
+
if (state.memtreeLinkPlacement === "off")
|
|
1779
|
+
return undefined;
|
|
1780
|
+
const sessionId = input.session_id;
|
|
1781
|
+
const inMemory = state.memtreePages.get(sessionId);
|
|
1782
|
+
const page = inMemory && inMemory.sessionId === sessionId
|
|
1783
|
+
? inMemory
|
|
1784
|
+
: state.memtreeLinkStore?.get(sessionId);
|
|
1785
|
+
const link = sessionLink(state, sessionId, page);
|
|
1786
|
+
if (!link)
|
|
1787
|
+
return undefined;
|
|
1788
|
+
if (page && page !== inMemory) {
|
|
1789
|
+
rememberMemtreePage(state, sessionId, {
|
|
1790
|
+
sessionId,
|
|
1791
|
+
url: page.url,
|
|
1792
|
+
index: page.index,
|
|
1793
|
+
compressed: page.compressed,
|
|
1794
|
+
seq: state.memtreeCallSeq,
|
|
1795
|
+
});
|
|
1796
|
+
}
|
|
1797
|
+
return state.notices.resumeLine(link, sessionId);
|
|
1798
|
+
}
|
|
1799
|
+
export const MEMTREE_COMPACT_MIN_TOKENS = 20_000;
|
|
1800
|
+
/** The reply to a ccc slash command, or undefined for an ordinary prompt. */
|
|
1801
|
+
function sessionCommandReply(state, sessionId, prompt) {
|
|
1802
|
+
if (sessionCommandArgs(prompt, MEMTREE_HELP_COMMAND) !== undefined) {
|
|
1803
|
+
return [
|
|
1804
|
+
"• /memtree-view · show this session's MemTree page link",
|
|
1805
|
+
`• /memtree-compact [tokens | off] · compact this session on your next message (default half the budget, at least ${MEMTREE_COMPACT_MIN_TOKENS / 1000}k)`,
|
|
1806
|
+
].join("\n");
|
|
1807
|
+
}
|
|
1808
|
+
if (isMemtreeViewCommand(prompt))
|
|
1809
|
+
return memtreeViewLine(state, sessionId);
|
|
1810
|
+
const args = sessionCommandArgs(prompt, MEMTREE_COMPACT_COMMAND);
|
|
1811
|
+
if (args === undefined)
|
|
1812
|
+
return undefined;
|
|
1813
|
+
const keep = "then that compressed history is reused unchanged until the conversation reaches the budget again. /memtree-compact off to stop.";
|
|
1814
|
+
if (/^off$/i.test(args)) {
|
|
1815
|
+
state.compactTargets.set(sessionId, null);
|
|
1816
|
+
state.memoryRoutes.delete(JSON.stringify([sessionId, "main"]));
|
|
1817
|
+
state.compactNow.delete(sessionId);
|
|
1818
|
+
state.stablePrefixes.delete(sessionId);
|
|
1819
|
+
return `${TRAILER_LABEL} compaction off: the conversation is sent whole, and MemTree compresses only when it outgrows the model's budget.`;
|
|
1820
|
+
}
|
|
1821
|
+
if (args === "") {
|
|
1822
|
+
// Back to the automatic target (half the budget, or CCC_COMPACT_TARGET).
|
|
1823
|
+
// Under CCC_COMPACT_TARGET=off the default is off, so pin the automatic
|
|
1824
|
+
// target for this session instead of falling back to that default.
|
|
1825
|
+
if (state.defaultCompactOff)
|
|
1826
|
+
state.compactTargets.set(sessionId, undefined);
|
|
1827
|
+
else
|
|
1828
|
+
state.compactTargets.delete(sessionId);
|
|
1829
|
+
state.compactNow.add(sessionId);
|
|
1830
|
+
return `${TRAILER_LABEL} compacting: your next message is sent compressed to about half the budget, ${keep}`;
|
|
1831
|
+
}
|
|
1832
|
+
const target = parseTokenCount(args);
|
|
1833
|
+
if (target === undefined || target < MEMTREE_COMPACT_MIN_TOKENS) {
|
|
1834
|
+
return `${TRAILER_LABEL} usage: /memtree-compact [tokens, e.g. 400k, at least ${MEMTREE_COMPACT_MIN_TOKENS / 1000}k | off]`;
|
|
1835
|
+
}
|
|
1836
|
+
state.compactTargets.set(sessionId, target);
|
|
1837
|
+
state.compactNow.add(sessionId);
|
|
1838
|
+
return `${TRAILER_LABEL} compacting: your next message is sent compressed to about ${Math.round(target / 1000)}k tokens, ${keep}`;
|
|
1839
|
+
}
|
|
1840
|
+
/** "50k", "50000", "1.5m" → tokens; undefined when not a positive count. */
|
|
1841
|
+
export function parseTokenCount(text) {
|
|
1842
|
+
const match = /^(\d+(?:\.\d+)?)\s*([km])?$/i.exec(text.trim());
|
|
1843
|
+
if (!match)
|
|
1844
|
+
return undefined;
|
|
1845
|
+
const scale = { k: 1_000, m: 1_000_000 }[match[2]?.toLowerCase()] ?? 1;
|
|
1846
|
+
const value = Math.round(Number(match[1]) * scale);
|
|
1847
|
+
return Number.isFinite(value) && value > 0 ? value : undefined;
|
|
1848
|
+
}
|
|
1849
|
+
/** The `/memtree-view` answer: this session's newest page, or why there is none. */
|
|
1850
|
+
function memtreeViewLine(state, sessionId) {
|
|
1851
|
+
const inMemory = state.memtreePages.get(sessionId);
|
|
1852
|
+
const page = inMemory && (inMemory.sessionId === undefined || inMemory.sessionId === sessionId)
|
|
1853
|
+
? inMemory
|
|
1854
|
+
: state.memtreeLinkStore?.get(sessionId);
|
|
1855
|
+
const link = sessionLink(state, sessionId, page);
|
|
1856
|
+
if (!link) {
|
|
1857
|
+
return `${TRAILER_LABEL} no page yet: this session has not been indexed. The link appears once it has.`;
|
|
1858
|
+
}
|
|
1859
|
+
// Blocked-prompt reasons need an explicit hyperlink in styled terminals.
|
|
1860
|
+
const url = terminalSupportsHyperlinks() ? hyperlink(link.link, link.link) : link.link;
|
|
1861
|
+
const lines = linkLines(LINK_LABEL, url, link.note);
|
|
1862
|
+
// The memory the agent works from can be an older tree than the newest one.
|
|
1863
|
+
return page?.compressed && page.url !== link.link
|
|
1864
|
+
? `${lines}\n memory from ${page.url}`
|
|
1865
|
+
: lines;
|
|
1866
|
+
}
|
|
1867
|
+
/**
|
|
1868
|
+
* The link shown for a session: its newest completed tree when the server
|
|
1869
|
+
* named one (newestTrees), else `source`, the page its memory came from. The
|
|
1870
|
+
* key is the tree, so each newly finished tree is announced once.
|
|
1871
|
+
*/
|
|
1872
|
+
function sessionLink(state, sessionId, source) {
|
|
1873
|
+
const newest = state.newestTrees?.peek(sessionId);
|
|
1874
|
+
const note = source && !source.compressed ? { note: NOT_COMPRESSED_NOTE } : {};
|
|
1875
|
+
if (newest)
|
|
1876
|
+
return { key: newest.key, link: newest.url, ...note };
|
|
1877
|
+
return source ? { key: source.index, link: source.url, ...note } : undefined;
|
|
1878
|
+
}
|
|
1879
|
+
/**
|
|
1880
|
+
* The recap's link: the session's newest tree like the Stop trailer, since the
|
|
1881
|
+
* recap rides the stable compressed prefix and its compress page goes stale.
|
|
1882
|
+
* Falls back to that page; never another session's.
|
|
1883
|
+
*/
|
|
1884
|
+
function recapLink(state, sessionId) {
|
|
1885
|
+
const page = currentMemtreePage(state, sessionId);
|
|
1886
|
+
const foreign = page !== undefined && sessionId !== undefined &&
|
|
1887
|
+
page.sessionId !== undefined && page.sessionId !== sessionId;
|
|
1888
|
+
return sessionLink(state, sessionId, foreign ? undefined : page);
|
|
1889
|
+
}
|
|
1890
|
+
function nextMemtreeCallSeq(state) {
|
|
1891
|
+
return ++state.memtreeCallSeq;
|
|
1892
|
+
}
|
|
1893
|
+
/**
|
|
1894
|
+
* Adopt a compress response's MemTree page as the conversation's newest,
|
|
1895
|
+
* unless a call submitted later already reported one. Only a response that
|
|
1896
|
+
* names the index it was compressed against counts: that index is complete,
|
|
1897
|
+
* so the page shows a tree right away, and its identity is what makes the
|
|
1898
|
+
* link worth announcing. A response without it (pre-view server, failed or
|
|
1899
|
+
* uncompressed call) changes nothing.
|
|
1900
|
+
*/
|
|
1901
|
+
function noteMemtreePage(state, seq, sessionId, result) {
|
|
1902
|
+
const url = result?.memtreeUrl;
|
|
1903
|
+
const index = result?.memtreeIndex;
|
|
1904
|
+
if (!sessionId || !url || !index)
|
|
1905
|
+
return;
|
|
1906
|
+
const latest = state.memtreePages.get(sessionId);
|
|
1907
|
+
if (latest && latest.seq >= seq)
|
|
1908
|
+
return;
|
|
1909
|
+
const compressed = didMemtreeCompress(result);
|
|
1910
|
+
rememberMemtreePage(state, sessionId, { sessionId, url, index, compressed, seq });
|
|
1911
|
+
state.memtreeLinkStore?.put(sessionId, { url, index, compressed });
|
|
1912
|
+
// A cached newest tree may predate this page; ask again.
|
|
1913
|
+
state.newestTrees?.invalidate(sessionId);
|
|
1914
|
+
}
|
|
1915
|
+
/** Bound optional page state independently of how many sessions use the proxy. */
|
|
1916
|
+
function rememberMemtreePage(state, sessionId, page) {
|
|
1917
|
+
state.memtreePages.delete(sessionId);
|
|
1918
|
+
state.memtreePages.set(sessionId, page);
|
|
1919
|
+
while (state.memtreePages.size > MEMTREE_LINKS_MAX_SESSIONS) {
|
|
1920
|
+
state.memtreePages.delete(state.memtreePages.keys().next().value);
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
/**
|
|
1924
|
+
* Record what a blocking operation just proved about MemTree's health: an
|
|
1925
|
+
* unopposed live failure arms the tool-recovery cooldown, any live success
|
|
1926
|
+
* clears it, and a cached-only result leaves the prior state untouched.
|
|
1927
|
+
*
|
|
1928
|
+
* Called from BOTH blocking paths and, critically, BEFORE either one checks
|
|
1929
|
+
* whether its client is still listening. A null says something about the
|
|
1930
|
+
* server, not about the downstream socket — and the full-budget stall that
|
|
1931
|
+
* produces a null is itself the likeliest reason a client gives up, so
|
|
1932
|
+
* learning only from attempts that outlived their client would blind the
|
|
1933
|
+
* cooldown to exactly the outage it exists to bound. Sharing it with the
|
|
1934
|
+
* followup path matters in both directions: an outage first seen on a human
|
|
1935
|
+
* turn must not cost another full stall on the next tool turn, and a
|
|
1936
|
+
* recovered MemTree must not stay locked out for the rest of the window.
|
|
1937
|
+
*
|
|
1938
|
+
* A live no-op, unusable, or non-shrinking answer still proves the server is
|
|
1939
|
+
* responsive and merely unhelpful for this history, and the next turn's
|
|
1940
|
+
* larger tail may well succeed — so those clear the outage fuse and keep
|
|
1941
|
+
* paying the fast, already-indexed round trip rather than locking recovery
|
|
1942
|
+
* out. The asymmetry trades a bounded repeat cost against never suppressing a
|
|
1943
|
+
* recovery a warming index is about to make possible.
|
|
1944
|
+
*
|
|
1945
|
+
* Both directions require evidence about MemTree's health RIGHT NOW; anything
|
|
1946
|
+
* weaker leaves the fuse untouched rather than guessing:
|
|
1947
|
+
*
|
|
1948
|
+
* - Every lane contributes the same shared evidence. A live success clears
|
|
1949
|
+
* the cooldown; a live failure arms it.
|
|
1950
|
+
* - Cache-served calls contribute no evidence. compress() memoizes successes
|
|
1951
|
+
* by hash and returns them with zero server contact, so replaying an
|
|
1952
|
+
* identical body cannot "prove" MemTree is up.
|
|
1953
|
+
* - A shutdown abort arms nothing. compress() maps abort to the same null as
|
|
1954
|
+
* a server failure, but a draining proxy says nothing about MemTree; it
|
|
1955
|
+
* would only misattribute the drain in the last records written.
|
|
1956
|
+
*/
|
|
1957
|
+
function noteMemtreeHealth(state, compression) {
|
|
1958
|
+
if (compression.liveHealth === "failure") {
|
|
1959
|
+
if (state.shutdownSignal.aborted)
|
|
1960
|
+
return;
|
|
1961
|
+
state.toolRecoveryCooldownUntil =
|
|
1962
|
+
Date.now() + TOOL_RECOVERY_FAILURE_COOLDOWN_MS;
|
|
1963
|
+
return;
|
|
1964
|
+
}
|
|
1965
|
+
if (compression.liveHealth === "success") {
|
|
1966
|
+
state.toolRecoveryCooldownUntil = 0;
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
/**
|
|
1970
|
+
* Whether the holder of `generation` still owns the route decision: true while
|
|
1971
|
+
* no STRICTLY NEWER reservation is live or committed. The holder's own
|
|
1972
|
+
* reservation never blocks itself, and a newer reservation that ended up
|
|
1973
|
+
* mutating nothing has already removed itself, handing ownership back rather
|
|
1974
|
+
* than stranding everyone older.
|
|
1975
|
+
*/
|
|
1976
|
+
function routeDecisionHolds(state, key, generation) {
|
|
1977
|
+
if (generation <= state.retiredRouteDecisionFloor)
|
|
1978
|
+
return false;
|
|
1979
|
+
const live = state.routeDecisionsLive.get(key);
|
|
1980
|
+
if (!live)
|
|
1981
|
+
return true;
|
|
1982
|
+
for (const reserved of live) {
|
|
1983
|
+
if (reserved > generation)
|
|
1984
|
+
return false;
|
|
1985
|
+
}
|
|
1986
|
+
return true;
|
|
1987
|
+
}
|
|
1988
|
+
function reserveRouteDecision(state, key, generation) {
|
|
1989
|
+
let live = state.routeDecisionsLive.get(key);
|
|
1990
|
+
if (!live) {
|
|
1991
|
+
if (state.routeDecisionsLive.size >= 128) {
|
|
1992
|
+
const oldest = state.routeDecisionsLive.keys().next().value;
|
|
1993
|
+
const retired = state.routeDecisionsLive.get(oldest);
|
|
1994
|
+
state.retiredRouteDecisionFloor = Math.max(state.retiredRouteDecisionFloor, ...retired);
|
|
1995
|
+
state.routeDecisionsLive.delete(oldest);
|
|
1996
|
+
}
|
|
1997
|
+
live = new Set();
|
|
1998
|
+
state.routeDecisionsLive.set(key, live);
|
|
1999
|
+
}
|
|
2000
|
+
live.add(generation);
|
|
2001
|
+
}
|
|
2002
|
+
function releaseRouteDecision(state, key, generation) {
|
|
2003
|
+
const live = state.routeDecisionsLive.get(key);
|
|
2004
|
+
if (!live)
|
|
2005
|
+
return;
|
|
2006
|
+
live.delete(generation);
|
|
2007
|
+
if (live.size === 0)
|
|
2008
|
+
state.routeDecisionsLive.delete(key);
|
|
2009
|
+
}
|
|
2010
|
+
function installMemoryRoute(state, key, req, originalBody, originalMessages, compressedBody, routeEpoch, decisionGeneration,
|
|
2011
|
+
/**
|
|
2012
|
+
* Main-thread human turns only: the stable prefix these compressed messages
|
|
2013
|
+
* start with. `register` stores it as the session's prefix (a compaction);
|
|
2014
|
+
* a prefix ride passes the prefix it rode, already stored.
|
|
2015
|
+
*/
|
|
2016
|
+
stable) {
|
|
2017
|
+
// Same-lane generations order asynchronous installs. Hooks never mutate
|
|
2018
|
+
// these guards; a stale completion cannot overwrite a validated replacement.
|
|
2019
|
+
if (state.mainRouteEpoch !== routeEpoch ||
|
|
2020
|
+
!routeDecisionHolds(state, key, decisionGeneration)) {
|
|
2021
|
+
return undefined;
|
|
2022
|
+
}
|
|
2023
|
+
const sessionId = requestSessionId(req);
|
|
2024
|
+
if (!sessionId || !Array.isArray(compressedBody.messages)) {
|
|
2025
|
+
state.memoryRoutes.delete(key);
|
|
2026
|
+
return undefined;
|
|
2027
|
+
}
|
|
2028
|
+
// Test-only fault-injection seam (undefined in production): placed after
|
|
2029
|
+
// every guard and before the store, exactly where the route object's
|
|
2030
|
+
// cloneJson/hash construction could throw, so tests can reach the
|
|
2031
|
+
// "activation-error" settle label. See ProxyOptions.routeInstallFault.
|
|
2032
|
+
state.routeInstallFault?.();
|
|
2033
|
+
const route = {
|
|
2034
|
+
sessionId,
|
|
2035
|
+
originalSystemHash: routeValueHash(normalizeRouteSystem(originalBody.system)),
|
|
2036
|
+
// Include trailing ambient role=system blocks: MemTree consolidated them
|
|
2037
|
+
// into compressedBody.system, so treating them as suffix would duplicate
|
|
2038
|
+
// those instructions on every tool request.
|
|
2039
|
+
originalPrefixHashes: originalMessages.map(routeMessageHash),
|
|
2040
|
+
compressedMessages: cloneJson(compressedBody.messages),
|
|
2041
|
+
compressedSystem: cloneJson(compressedBody.system),
|
|
2042
|
+
hasCompressedSystem: Object.prototype.hasOwnProperty.call(compressedBody, "system"),
|
|
2043
|
+
routeEpoch,
|
|
2044
|
+
};
|
|
2045
|
+
if (stable && "ride" in stable) {
|
|
2046
|
+
route.stablePrefix = stable.ride;
|
|
2047
|
+
}
|
|
2048
|
+
else if (stable) {
|
|
2049
|
+
// The route's own fields are the prefix: the hashes of every message this
|
|
2050
|
+
// request carried, and the compressed bytes sent for them. Later requests
|
|
2051
|
+
// reuse these bytes unchanged, so the prefix stays a cache read.
|
|
2052
|
+
route.stablePrefix = storeStablePrefix(state, sessionId, originalMessages, route, stable);
|
|
2053
|
+
}
|
|
2054
|
+
setMemoryRoute(state, key, route);
|
|
2055
|
+
state.routeDecisionsLive.set(key, new Set([decisionGeneration]));
|
|
2056
|
+
if (!state.toolRecoveryAttemptedLanes.get(key)?.inFlight)
|
|
2057
|
+
state.toolRecoveryAttemptedLanes.delete(key);
|
|
2058
|
+
if (key === JSON.stringify([sessionId, "main"]))
|
|
2059
|
+
state.lastMainRoute = route;
|
|
2060
|
+
return route;
|
|
2061
|
+
}
|
|
2062
|
+
/**
|
|
2063
|
+
* Store a compaction's compressed bytes as the session's stable prefix. For a
|
|
2064
|
+
* tool-turn compaction (`replaces` set) only while the session's prefix is
|
|
2065
|
+
* still the one the compaction replaced; undefined when skipped.
|
|
2066
|
+
*/
|
|
2067
|
+
function storeStablePrefix(state, sessionId, originalMessages, compressed, stable) {
|
|
2068
|
+
if (stable.replaces !== undefined &&
|
|
2069
|
+
(state.stablePrefixes.get(sessionId) ?? null) !== stable.replaces) {
|
|
2070
|
+
return undefined;
|
|
2071
|
+
}
|
|
2072
|
+
const prefix = {
|
|
2073
|
+
sessionId,
|
|
2074
|
+
originalSystemHash: compressed.originalSystemHash,
|
|
2075
|
+
originalPrefixHashes: originalMessages.map(stablePrefixMessageHash),
|
|
2076
|
+
compressedMessages: compressed.compressedMessages,
|
|
2077
|
+
compressedSystem: compressed.compressedSystem,
|
|
2078
|
+
hasCompressedSystem: compressed.hasCompressedSystem,
|
|
2079
|
+
targetTokens: stable.targetTokens,
|
|
2080
|
+
explicitTarget: stable.explicitTarget,
|
|
2081
|
+
modelContextLimit: stable.modelContextLimit,
|
|
2082
|
+
};
|
|
2083
|
+
stable.installed = prefix;
|
|
2084
|
+
boundedSet(state.stablePrefixes, sessionId, prefix);
|
|
2085
|
+
state.compactNow.delete(sessionId);
|
|
2086
|
+
return prefix;
|
|
2087
|
+
}
|
|
2088
|
+
/**
|
|
2089
|
+
* A fork of the main conversation (the away recap) sent on the main thread's
|
|
2090
|
+
* last compressed prefix: that prefix, then the fork's own messages after it.
|
|
2091
|
+
* Only session, system and every prefix message are checked; unlike a tool
|
|
2092
|
+
* turn, the suffix may end in a plain user message (the fork's question).
|
|
2093
|
+
*/
|
|
2094
|
+
function forkRoutedBody(body, messages, route, sessionId) {
|
|
2095
|
+
if (!route)
|
|
2096
|
+
return { miss: "no-route" };
|
|
2097
|
+
const miss = prefixMismatch(body, messages, route, sessionId);
|
|
2098
|
+
if (miss)
|
|
2099
|
+
return { miss };
|
|
2100
|
+
const routed = prefixRoutedBody(body, messages, route);
|
|
2101
|
+
if (!routed)
|
|
2102
|
+
return { miss: "prefix" };
|
|
2103
|
+
return { body: routed.raw };
|
|
2104
|
+
}
|
|
2105
|
+
/**
|
|
2106
|
+
* Why `messages` cannot continue from `prefix`, or null when they can: same
|
|
2107
|
+
* session, same system prompt, every message the prefix covers unchanged, and
|
|
2108
|
+
* at least one message after them. The suffix may be anything, human turns
|
|
2109
|
+
* included.
|
|
2110
|
+
*/
|
|
2111
|
+
function prefixMismatch(body, messages, prefix, sessionId, hashMessage = routeMessageHash) {
|
|
2112
|
+
if (!sessionId || prefix.sessionId !== sessionId)
|
|
2113
|
+
return "session";
|
|
2114
|
+
if (routeValueHash(normalizeRouteSystem(body.system)) !== prefix.originalSystemHash) {
|
|
2115
|
+
return "system";
|
|
2116
|
+
}
|
|
2117
|
+
const prefixLength = prefix.originalPrefixHashes.length;
|
|
2118
|
+
if (messages.length < prefixLength)
|
|
2119
|
+
return "prefix";
|
|
2120
|
+
for (let i = 0; i < prefixLength; i++) {
|
|
2121
|
+
if (hashMessage(messages[i]) !== prefix.originalPrefixHashes[i]) {
|
|
2122
|
+
return "prefix";
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
return null;
|
|
2126
|
+
}
|
|
2127
|
+
/**
|
|
2128
|
+
* Message identity for a stable prefix: routeMessageHash without assistant
|
|
2129
|
+
* thinking blocks. A stable prefix spans human turns, and Claude Code does not
|
|
2130
|
+
* reliably replay earlier turns' thinking (model switch, resume); those blocks
|
|
2131
|
+
* are never sent on the prefix anyway (the compressed bytes stand in for them),
|
|
2132
|
+
* so they must not decide whether it still matches.
|
|
2133
|
+
*/
|
|
2134
|
+
/**
|
|
2135
|
+
* The request sent on a compressed prefix: the prefix's compressed messages
|
|
2136
|
+
* (a JSON round trip of the stored bytes, so byte-identical every time), then
|
|
2137
|
+
* the messages after the part it covers, verbatim, with Claude Code's current
|
|
2138
|
+
* billing header grafted into the compressed system and the cache breakpoints
|
|
2139
|
+
* capped at Anthropic's limit (the prefix's own marker always kept).
|
|
2140
|
+
*/
|
|
2141
|
+
function prefixRoutedBody(body, messages, prefix) {
|
|
2142
|
+
const routed = {
|
|
2143
|
+
...body,
|
|
2144
|
+
messages: [
|
|
2145
|
+
...cloneJson(prefix.compressedMessages),
|
|
2146
|
+
...messages.slice(prefix.originalPrefixHashes.length),
|
|
2147
|
+
],
|
|
2148
|
+
};
|
|
2149
|
+
if (prefix.hasCompressedSystem) {
|
|
2150
|
+
routed.system = currentRouteSystem(prefix.compressedSystem, body.system);
|
|
2151
|
+
}
|
|
2152
|
+
else {
|
|
2153
|
+
delete routed.system;
|
|
2154
|
+
}
|
|
2155
|
+
if (!capCacheBreakpoints(routed, prefix.compressedMessages.length, { preserveSystem: true }))
|
|
2156
|
+
return null;
|
|
2157
|
+
if (!validCacheTtlOrder(routed))
|
|
2158
|
+
return null;
|
|
2159
|
+
return { body: routed, raw: Buffer.from(JSON.stringify(routed), "utf-8") };
|
|
2160
|
+
}
|
|
2161
|
+
function serverBudgetKey(model, modelContextLimit) {
|
|
2162
|
+
return JSON.stringify([typeof model === "string" ? model : "", modelContextLimit]);
|
|
2163
|
+
}
|
|
2164
|
+
/**
|
|
2165
|
+
* The session's whole-request budget: CCC_BUDGET_TOKENS when set (tests),
|
|
2166
|
+
* else the model budget the server last reported for this model and window,
|
|
2167
|
+
* else the window times FALLBACK_BUDGET_WINDOW_RATIO (servers that predate
|
|
2168
|
+
* `model_budget_tokens`, and the first request before any response).
|
|
2169
|
+
*/
|
|
2170
|
+
function resolveBudget(opts, state, model, modelContextLimit) {
|
|
2171
|
+
if (opts.budgetTokensOverride !== undefined) {
|
|
2172
|
+
return { tokens: opts.budgetTokensOverride, source: "override" };
|
|
2173
|
+
}
|
|
2174
|
+
const reported = state.serverBudgets.get(serverBudgetKey(model, modelContextLimit));
|
|
2175
|
+
if (reported !== undefined)
|
|
2176
|
+
return { tokens: reported, source: "server" };
|
|
2177
|
+
return {
|
|
2178
|
+
tokens: Math.floor(modelContextLimit * FALLBACK_BUDGET_WINDOW_RATIO),
|
|
2179
|
+
source: "window-ratio",
|
|
2180
|
+
};
|
|
2181
|
+
}
|
|
2182
|
+
/** Remember the model budget a compress response reported. */
|
|
2183
|
+
function noteServerBudget(state, model, modelContextLimit, result) {
|
|
2184
|
+
const tokens = result ? modelBudgetTokens(result) : undefined;
|
|
2185
|
+
if (tokens === undefined)
|
|
2186
|
+
return;
|
|
2187
|
+
state.serverReportsBudget = true;
|
|
2188
|
+
boundedSet(state.serverBudgets, serverBudgetKey(model, modelContextLimit), tokens);
|
|
2189
|
+
}
|
|
2190
|
+
/** `/memtree-compact` for this session, else CCC_COMPACT_TARGET, else automatic. */
|
|
2191
|
+
function compactionMode(opts, state, sessionId) {
|
|
2192
|
+
const configured = sessionId !== undefined && state.compactTargets.has(sessionId)
|
|
2193
|
+
? state.compactTargets.get(sessionId)
|
|
2194
|
+
: opts.defaultCompactTarget;
|
|
2195
|
+
if (configured === null)
|
|
2196
|
+
return { mode: "off" };
|
|
2197
|
+
if (configured === undefined)
|
|
2198
|
+
return { mode: "auto" };
|
|
2199
|
+
return { mode: "explicit", tokens: configured };
|
|
2200
|
+
}
|
|
2201
|
+
/** What a compaction aims at: the explicit N (capped under the budget), else budget/2. */
|
|
2202
|
+
function compactionTarget(mode, budgetTokens) {
|
|
2203
|
+
const target = mode.mode === "explicit"
|
|
2204
|
+
? Math.min(mode.tokens, Math.floor(budgetTokens * MAX_EXPLICIT_TARGET_BUDGET_RATIO))
|
|
2205
|
+
: Math.floor(budgetTokens / 2);
|
|
2206
|
+
return Math.max(SERVER_MIN_TARGET_TOKENS, target);
|
|
2207
|
+
}
|
|
2208
|
+
/**
|
|
2209
|
+
* Target and threshold for compress calls other than a main-thread human turn
|
|
2210
|
+
* or a tool turn (subagent and recap followups). They never build a
|
|
2211
|
+
* stable prefix, so they only need to stay under the budget: a server that
|
|
2212
|
+
* understands the threshold gets both, an older one gets neither and uses its
|
|
2213
|
+
* own model budget. Never a bare target, which would force a compression on
|
|
2214
|
+
* every call.
|
|
2215
|
+
*/
|
|
2216
|
+
function laneCompaction(opts, state, sessionId, model, modelContextLimit) {
|
|
2217
|
+
const mode = compactionMode(opts, state, sessionId);
|
|
2218
|
+
if (mode.mode === "off" || !state.serverReportsBudget)
|
|
2219
|
+
return {};
|
|
2220
|
+
const budget = resolveBudget(opts, state, model, modelContextLimit);
|
|
2221
|
+
return {
|
|
2222
|
+
target: compactionTarget(mode, budget.tokens),
|
|
2223
|
+
threshold: Math.max(SERVER_MIN_TARGET_TOKENS, budget.tokens),
|
|
2224
|
+
};
|
|
2225
|
+
}
|
|
2226
|
+
/** Calibrated input must leave room for the requested output as well. */
|
|
2227
|
+
function calibratedSizeExceedsWindow(record, body, modelContextLimit) {
|
|
2228
|
+
const outputTokens = typeof body.max_tokens === "number" && Number.isFinite(body.max_tokens)
|
|
2229
|
+
? Math.max(0, body.max_tokens) : 0;
|
|
2230
|
+
return record.sizeSource === "reported" &&
|
|
2231
|
+
(record.estimatedTokens ?? 0) + outputTokens > modelContextLimit;
|
|
2232
|
+
}
|
|
2233
|
+
function calibratedSizeExceedsLimits(record, body, modelContextLimit) {
|
|
2234
|
+
return (record.sizeSource === "reported" &&
|
|
2235
|
+
(record.estimatedTokens ?? 0) >= record.budgetTokens) ||
|
|
2236
|
+
calibratedSizeExceedsWindow(record, body, modelContextLimit);
|
|
2237
|
+
}
|
|
2238
|
+
/** Only calibrated usage may force an estimate-driven compaction. */
|
|
2239
|
+
function budgetCompaction(state, record, target, body, modelContextLimit) {
|
|
2240
|
+
if (calibratedSizeExceedsLimits(record, body, modelContextLimit))
|
|
2241
|
+
return { target };
|
|
2242
|
+
// Older servers may ignore a threshold, so do not send a forcing target
|
|
2243
|
+
// until support is known. They can still compact against their own budget.
|
|
2244
|
+
if (!state.serverReportsBudget)
|
|
2245
|
+
return {};
|
|
2246
|
+
const threshold = Math.max(SERVER_MIN_TARGET_TOKENS, record.budgetTokens);
|
|
2247
|
+
record.thresholdTokens = threshold;
|
|
2248
|
+
return { target, threshold };
|
|
2249
|
+
}
|
|
2250
|
+
/** Anthropic's whole input size for a request: uncached + cache read + cache write. */
|
|
2251
|
+
function reportedInputTokens(rec) {
|
|
2252
|
+
const usage = rec.usage;
|
|
2253
|
+
if (!usage || typeof usage.input_tokens !== "number")
|
|
2254
|
+
return undefined;
|
|
2255
|
+
return (usage.input_tokens +
|
|
2256
|
+
(usage.cache_read_input_tokens ?? 0) +
|
|
2257
|
+
(usage.cache_creation_input_tokens ?? 0));
|
|
2258
|
+
}
|
|
2259
|
+
/** Keep the newest (largest-body) reported size of a request on this prefix. */
|
|
2260
|
+
function notePrefixSize(prefix, rec, bytes) {
|
|
2261
|
+
const sample = reportedSample(rec, bytes);
|
|
2262
|
+
if (sample === undefined)
|
|
2263
|
+
return;
|
|
2264
|
+
if (prefix.lastSize && bytes < prefix.lastSize.forwardedBytes)
|
|
2265
|
+
return;
|
|
2266
|
+
prefix.lastSize = sample;
|
|
2267
|
+
}
|
|
2268
|
+
/** Record the reported size of a main-thread request forwarded whole. */
|
|
2269
|
+
function notePassthroughSize(state, sessionId, rec, bytes) {
|
|
2270
|
+
const sample = reportedSample(rec, bytes);
|
|
2271
|
+
if (sessionId === undefined || sample === undefined)
|
|
2272
|
+
return;
|
|
2273
|
+
boundedSet(state.passthroughSizes, sessionId, sample);
|
|
2274
|
+
}
|
|
2275
|
+
/**
|
|
2276
|
+
* The size Anthropic reported for a body of `bytes`, if it can describe one:
|
|
2277
|
+
* an implausible ratio would mis-size every later request it anchors, so it
|
|
2278
|
+
* is dropped and the previous sample kept.
|
|
2279
|
+
*/
|
|
2280
|
+
function reportedSample(rec, bytes) {
|
|
2281
|
+
if (changedDeliveryRecords.has(rec))
|
|
2282
|
+
return undefined;
|
|
2283
|
+
const tokens = reportedInputTokens(rec);
|
|
2284
|
+
if (tokens === undefined)
|
|
2285
|
+
return undefined;
|
|
2286
|
+
const sample = { tokens, forwardedBytes: bytes };
|
|
2287
|
+
return plausibleSample(sample) ? sample : undefined;
|
|
2288
|
+
}
|
|
2289
|
+
/**
|
|
2290
|
+
* Key of a non-main request's size sample (ProxyState.laneSizes): its lane,
|
|
2291
|
+
* its own agent id (helpers that carry only a parent id stay apart from the
|
|
2292
|
+
* agent), and its model, whose tokenizer the ratio belongs to.
|
|
2293
|
+
*/
|
|
2294
|
+
function laneSizeKey(req, routeKey, model) {
|
|
2295
|
+
return JSON.stringify([
|
|
2296
|
+
routeKey,
|
|
2297
|
+
firstNonEmptyHeader(req, "x-claude-code-agent-id") ?? null,
|
|
2298
|
+
typeof model === "string" ? model : null,
|
|
2299
|
+
]);
|
|
2300
|
+
}
|
|
2301
|
+
/** The session's passthrough size on the main thread, else the lane's. */
|
|
2302
|
+
function wholeRequestSizeSlot(state, isMainRequest, sessionId, sizeKey) {
|
|
2303
|
+
const main = isMainRequest && sessionId !== undefined;
|
|
2304
|
+
return {
|
|
2305
|
+
get: () => (main ? state.passthroughSizes.get(sessionId) : state.laneSizes.get(sizeKey)),
|
|
2306
|
+
set: (sample) => {
|
|
2307
|
+
if (!plausibleSample(sample))
|
|
2308
|
+
return;
|
|
2309
|
+
if (main)
|
|
2310
|
+
boundedSet(state.passthroughSizes, sessionId, sample);
|
|
2311
|
+
else
|
|
2312
|
+
boundedSet(state.laneSizes, sizeKey, sample, MEMORY_ROUTE_MAX_LANES);
|
|
2313
|
+
},
|
|
2314
|
+
};
|
|
2315
|
+
}
|
|
2316
|
+
/** Size of a tool turn sent on a ride, recorded on its prefix or route. */
|
|
2317
|
+
const noteRideSize = notePrefixSize;
|
|
2318
|
+
/**
|
|
2319
|
+
* Record the reported size of a request forwarded whole: the session's
|
|
2320
|
+
* passthrough size on the main thread, else its lane's.
|
|
2321
|
+
*/
|
|
2322
|
+
function noteWholeRequestSize(state, isMainRequest, sessionId, sizeKey, rec, bytes) {
|
|
2323
|
+
if (isMainRequest && sessionId !== undefined) {
|
|
2324
|
+
notePassthroughSize(state, sessionId, rec, bytes);
|
|
2325
|
+
return;
|
|
2326
|
+
}
|
|
2327
|
+
const sample = reportedSample(rec, bytes);
|
|
2328
|
+
if (sample === undefined)
|
|
2329
|
+
return;
|
|
2330
|
+
boundedSet(state.laneSizes, sizeKey, sample, MEMORY_ROUTE_MAX_LANES);
|
|
2331
|
+
}
|
|
2332
|
+
function boundedSet(map, key, value, limit = STABLE_PREFIX_MAX_SESSIONS) {
|
|
2333
|
+
map.delete(key);
|
|
2334
|
+
map.set(key, value);
|
|
2335
|
+
while (map.size > limit) {
|
|
2336
|
+
const oldest = map.keys().next().value;
|
|
2337
|
+
if (oldest === undefined)
|
|
2338
|
+
break;
|
|
2339
|
+
map.delete(oldest);
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
/**
|
|
2343
|
+
* Stable-prefix (edge) compaction for a main-thread human turn:
|
|
2344
|
+
*
|
|
2345
|
+
* - With a stored prefix that still matches the conversation and a size
|
|
2346
|
+
* (prefix + newer turns) under the budget: ride it, no compress call.
|
|
2347
|
+
* - Otherwise compress: forced to the target when the prefix outgrew the
|
|
2348
|
+
* budget, the window/target changed, or `/memtree-compact` asked; while the
|
|
2349
|
+
* session is still passing through, let the server decide against the
|
|
2350
|
+
* budget (threshold), or — on a server that predates the threshold — force
|
|
2351
|
+
* only once the proxy's own estimate reaches the budget.
|
|
2352
|
+
*
|
|
2353
|
+
* Fills rec.compaction with the budget, estimate and reason.
|
|
2354
|
+
*/
|
|
2355
|
+
function planEdgeCompaction(args) {
|
|
2356
|
+
const { opts, state, body, messages, sessionId, modelContextLimit, forwardBody, rec } = args;
|
|
2357
|
+
const mode = compactionMode(opts, state, sessionId);
|
|
2358
|
+
const budget = resolveBudget(opts, state, body.model, modelContextLimit);
|
|
2359
|
+
const compaction = {
|
|
2360
|
+
mode: mode.mode,
|
|
2361
|
+
budgetTokens: budget.tokens,
|
|
2362
|
+
budgetSource: budget.source,
|
|
2363
|
+
};
|
|
2364
|
+
rec.compaction = compaction;
|
|
2365
|
+
if (mode.mode === "off") {
|
|
2366
|
+
state.stablePrefixes.delete(sessionId);
|
|
2367
|
+
state.compactNow.delete(sessionId);
|
|
2368
|
+
return { kind: "off" };
|
|
2369
|
+
}
|
|
2370
|
+
const targetTokens = compactionTarget(mode, budget.tokens);
|
|
2371
|
+
const explicitTarget = mode.mode === "explicit";
|
|
2372
|
+
compaction.targetTokens = targetTokens;
|
|
2373
|
+
const forced = (reason, fallback) => {
|
|
2374
|
+
compaction.reason = reason;
|
|
2375
|
+
return {
|
|
2376
|
+
kind: "compress",
|
|
2377
|
+
...(reason === "budget"
|
|
2378
|
+
? budgetCompaction(state, compaction, targetTokens, body, modelContextLimit)
|
|
2379
|
+
: { target: targetTokens }),
|
|
2380
|
+
targetTokens,
|
|
2381
|
+
explicitTarget,
|
|
2382
|
+
reason,
|
|
2383
|
+
...(fallback ? { fallback } : {}),
|
|
2384
|
+
};
|
|
2385
|
+
};
|
|
2386
|
+
let prefix = state.stablePrefixes.get(sessionId);
|
|
2387
|
+
if (prefix) {
|
|
2388
|
+
const miss = prefixMismatch(body, messages, prefix, sessionId, stablePrefixMessageHash);
|
|
2389
|
+
if (miss) {
|
|
2390
|
+
// Rewind, edit, fork, /clear, or a changed system prompt: the prefix no
|
|
2391
|
+
// longer stands for this conversation. Start over from passthrough.
|
|
2392
|
+
compaction.prefixMiss = miss;
|
|
2393
|
+
prefix = undefined;
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
if (prefix) {
|
|
2397
|
+
boundedSet(state.stablePrefixes, sessionId, prefix);
|
|
2398
|
+
const routed = prefixRoutedBody(body, messages, prefix);
|
|
2399
|
+
if (!routed) {
|
|
2400
|
+
// Cache TTLs are not conversation identity, but a stored 5m prefix
|
|
2401
|
+
// cannot precede a new 1h suffix. Rebuild without an invalid fallback;
|
|
2402
|
+
// never rewrite the prefix's bytes or the user's marker TTLs.
|
|
2403
|
+
return forced("prefix-mismatch");
|
|
2404
|
+
}
|
|
2405
|
+
const prefixCandidate = args.sizing.register(routed.raw, "ride", prefix.lastSize);
|
|
2406
|
+
candidateSizeOwners.set(prefixCandidate, prefix);
|
|
2407
|
+
const size = args.sizing.plan(prefixCandidate);
|
|
2408
|
+
compaction.estimatedTokens = size.tokens;
|
|
2409
|
+
compaction.estimatedBytes = routed.raw.length;
|
|
2410
|
+
compaction.sizeSource = size.source;
|
|
2411
|
+
const overWindow = routedBodyExceedsContext(body, routed.raw, modelContextLimit) ||
|
|
2412
|
+
calibratedSizeExceedsWindow(compaction, body, modelContextLimit);
|
|
2413
|
+
const fallback = overWindow ? undefined : { prefix, routed };
|
|
2414
|
+
if (state.compactNow.has(sessionId))
|
|
2415
|
+
return forced("manual", fallback);
|
|
2416
|
+
if (prefix.modelContextLimit !== modelContextLimit ||
|
|
2417
|
+
(explicitTarget && prefix.targetTokens !== targetTokens)) {
|
|
2418
|
+
return forced("target-change", fallback);
|
|
2419
|
+
}
|
|
2420
|
+
if (size.tokens >= budget.tokens || overWindow)
|
|
2421
|
+
return forced("budget", fallback);
|
|
2422
|
+
return { kind: "ride", prefix, routed };
|
|
2423
|
+
}
|
|
2424
|
+
if (state.compactNow.has(sessionId))
|
|
2425
|
+
return forced("manual");
|
|
2426
|
+
const size = args.sizing.plan(args.sizing.register(forwardBody, "original"));
|
|
2427
|
+
compaction.estimatedTokens = size.tokens;
|
|
2428
|
+
compaction.estimatedBytes = forwardBody.length;
|
|
2429
|
+
compaction.sizeSource = size.source;
|
|
2430
|
+
if (calibratedSizeExceedsLimits(compaction, body, modelContextLimit)) {
|
|
2431
|
+
return forced(compaction.prefixMiss ? "prefix-mismatch" : "budget");
|
|
2432
|
+
}
|
|
2433
|
+
if (state.serverReportsBudget) {
|
|
2434
|
+
// The server measures the request and compresses only past the budget.
|
|
2435
|
+
const threshold = Math.max(SERVER_MIN_TARGET_TOKENS, budget.tokens);
|
|
2436
|
+
compaction.thresholdTokens = threshold;
|
|
2437
|
+
return { kind: "compress", target: targetTokens, threshold, targetTokens, explicitTarget };
|
|
2438
|
+
}
|
|
2439
|
+
// Without calibrated usage or threshold support, let the server decide
|
|
2440
|
+
// against its own model budget rather than forcing from transport bytes.
|
|
2441
|
+
return { kind: "compress", targetTokens, explicitTarget };
|
|
2442
|
+
}
|
|
2443
|
+
/**
|
|
2444
|
+
* Stable-prefix compaction for a tool turn, on every lane: the rule human
|
|
2445
|
+
* turns follow (planEdgeCompaction), minus the server-side threshold call —
|
|
2446
|
+
* a tool turn under the budget makes no compress call at all.
|
|
2447
|
+
*
|
|
2448
|
+
* - The ride: the lane's route (`ride`, already matched), else on the main
|
|
2449
|
+
* thread the session's stable prefix when it still covers this history.
|
|
2450
|
+
* - Its size: estimateRequestTokens anchored on the size Anthropic reported
|
|
2451
|
+
* for the previous request of the same shape (the ride's prefix or route,
|
|
2452
|
+
* else the session's / lane's whole-request size), bytes/4 without one.
|
|
2453
|
+
* - Under the budget (and within the window): ride, or forward whole.
|
|
2454
|
+
* - At the budget: compress, forcing the target only with calibrated usage; on the
|
|
2455
|
+
* main thread the result becomes the stable prefix later tool AND human
|
|
2456
|
+
* turns ride.
|
|
2457
|
+
*
|
|
2458
|
+
* Fills rec.compaction.
|
|
2459
|
+
*/
|
|
2460
|
+
function planToolCompaction(args) {
|
|
2461
|
+
const { opts, state, body, messages, sessionId, modelContextLimit, forwardBody, rec } = args;
|
|
2462
|
+
const mode = compactionMode(opts, state, sessionId);
|
|
2463
|
+
const budget = resolveBudget(opts, state, body.model, modelContextLimit);
|
|
2464
|
+
const compaction = {
|
|
2465
|
+
mode: mode.mode,
|
|
2466
|
+
budgetTokens: budget.tokens,
|
|
2467
|
+
budgetSource: budget.source,
|
|
2468
|
+
};
|
|
2469
|
+
rec.compaction = compaction;
|
|
2470
|
+
if (mode.mode === "off")
|
|
2471
|
+
return { kind: "off" };
|
|
2472
|
+
const targetTokens = compactionTarget(mode, budget.tokens);
|
|
2473
|
+
compaction.targetTokens = targetTokens;
|
|
2474
|
+
const stableLane = args.isMainRequest && sessionId !== undefined;
|
|
2475
|
+
const current = stableLane ? state.stablePrefixes.get(sessionId) : undefined;
|
|
2476
|
+
let ride = args.ride;
|
|
2477
|
+
let overWindow = false;
|
|
2478
|
+
if (!ride && current && !prefixMismatch(body, messages, current, sessionId, stablePrefixMessageHash)) {
|
|
2479
|
+
const routed = prefixRoutedBody(body, messages, current);
|
|
2480
|
+
if (routed) {
|
|
2481
|
+
const prefixCandidate = args.sizing.register(routed.raw, "ride", current.lastSize);
|
|
2482
|
+
candidateSizeOwners.set(prefixCandidate, current);
|
|
2483
|
+
if (routedBodyExceedsContext(body, routed.raw, modelContextLimit))
|
|
2484
|
+
overWindow = true;
|
|
2485
|
+
else
|
|
2486
|
+
ride = { raw: routed.raw, turnType: "tool-prefix", sizeHolder: current };
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
if (!ride) {
|
|
2490
|
+
const originalSize = args.sizing.plan(args.sizing.register(forwardBody, "original"));
|
|
2491
|
+
overWindow ||= originalSize.tokens + Math.max(0, body.max_tokens ?? 0) > modelContextLimit;
|
|
2492
|
+
}
|
|
2493
|
+
const sample = ride
|
|
2494
|
+
? ride.sizeHolder.lastSize
|
|
2495
|
+
: stableLane
|
|
2496
|
+
? state.passthroughSizes.get(sessionId)
|
|
2497
|
+
: state.laneSizes.get(args.sizeKey);
|
|
2498
|
+
const candidate = args.sizing.register(ride?.raw ?? forwardBody, ride ? "ride" : "original", sample);
|
|
2499
|
+
if (ride)
|
|
2500
|
+
candidateSizeOwners.set(candidate, ride.sizeHolder);
|
|
2501
|
+
const size = args.sizing.plan(candidate);
|
|
2502
|
+
compaction.estimatedTokens = size.tokens;
|
|
2503
|
+
compaction.estimatedBytes = (ride?.raw ?? forwardBody).length;
|
|
2504
|
+
compaction.sizeSource = size.source;
|
|
2505
|
+
overWindow ||= !fitsNative(size.tokens, { budget: budget.tokens, context: modelContextLimit,
|
|
2506
|
+
output: typeof body.max_tokens === "number" ? Math.max(0, body.max_tokens) : 0 });
|
|
2507
|
+
if (!args.canCompress) {
|
|
2508
|
+
if (overWindow)
|
|
2509
|
+
return { kind: "pass", original: true };
|
|
2510
|
+
return ride ? { kind: "ride", ride } : { kind: "pass" };
|
|
2511
|
+
}
|
|
2512
|
+
if (size.tokens < budget.tokens && !overWindow) {
|
|
2513
|
+
return ride ? { kind: "ride", ride } : { kind: "pass" };
|
|
2514
|
+
}
|
|
2515
|
+
compaction.reason = "budget";
|
|
2516
|
+
return {
|
|
2517
|
+
kind: "compress",
|
|
2518
|
+
...budgetCompaction(state, compaction, targetTokens, body, modelContextLimit),
|
|
2519
|
+
targetTokens,
|
|
2520
|
+
explicitTarget: mode.mode === "explicit",
|
|
2521
|
+
estimateTokens: size.tokens,
|
|
2522
|
+
budgetTokens: budget.tokens,
|
|
2523
|
+
overWindow,
|
|
2524
|
+
...(ride && !overWindow ? { fallback: ride } : {}),
|
|
2525
|
+
...(stableLane ? { replaces: current ?? null } : {}),
|
|
2526
|
+
};
|
|
2527
|
+
}
|
|
2528
|
+
function memoryRoutedToolBody(body, messages, route, routeEpoch, sessionId) {
|
|
2529
|
+
// Model is deliberately not route identity: Claude Code switches models
|
|
2530
|
+
// mid-loop (overload fallback, /model, /fast), and the compressed prefix is
|
|
2531
|
+
// plain message content valid for any model. Session + prefix hashes pin the
|
|
2532
|
+
// conversation; requiring model equality dropped the whole tool loop to
|
|
2533
|
+
// full-history passthrough on every mid-turn switch.
|
|
2534
|
+
if (!sessionId ||
|
|
2535
|
+
route.sessionId !== sessionId ||
|
|
2536
|
+
route.routeEpoch !== routeEpoch ||
|
|
2537
|
+
routeValueHash(normalizeRouteSystem(body.system)) !==
|
|
2538
|
+
route.originalSystemHash) {
|
|
2539
|
+
return null;
|
|
2540
|
+
}
|
|
2541
|
+
const prefixLength = route.originalPrefixHashes.length;
|
|
2542
|
+
if (messages.length < prefixLength)
|
|
2543
|
+
return null;
|
|
2544
|
+
for (let i = 0; i < prefixLength; i++) {
|
|
2545
|
+
if (routeMessageHash(messages[i]) !== route.originalPrefixHashes[i]) {
|
|
2546
|
+
return null;
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
const suffix = messages.slice(prefixLength);
|
|
2550
|
+
if (suffix.length && !validToolRouteSuffix(suffix) && !isNonToolUserMessage(suffix[suffix.length - 1]))
|
|
2551
|
+
return null;
|
|
2552
|
+
const routed = {
|
|
2553
|
+
...body,
|
|
2554
|
+
messages: [...cloneJson(route.compressedMessages), ...suffix],
|
|
2555
|
+
};
|
|
2556
|
+
if (route.hasCompressedSystem) {
|
|
2557
|
+
routed.system = currentRouteSystem(route.compressedSystem, body.system);
|
|
2558
|
+
}
|
|
2559
|
+
else {
|
|
2560
|
+
delete routed.system;
|
|
2561
|
+
}
|
|
2562
|
+
if (!capCacheBreakpoints(routed, route.compressedMessages.length, { preserveSystem: true }))
|
|
2563
|
+
return null;
|
|
2564
|
+
if (!validCacheTtlOrder(routed))
|
|
2565
|
+
return null;
|
|
2566
|
+
return Buffer.from(JSON.stringify(routed), "utf-8");
|
|
2567
|
+
}
|
|
2568
|
+
/** Size the assembled input (including system/tools) and reserve output room. */
|
|
2569
|
+
function routedBodyExceedsContext(body, routedBody, modelContextLimit) {
|
|
2570
|
+
const outputTokens = typeof body.max_tokens === "number" &&
|
|
2571
|
+
Number.isFinite(body.max_tokens) ? Math.max(0, body.max_tokens) : 0;
|
|
2572
|
+
// This is the existing local byte estimate, not a tokenizer or a hard cap.
|
|
2573
|
+
// Final forwarding checks can replace this estimate with Count Tokens.
|
|
2574
|
+
return approxTokensFromBytes(routedBody.length) + outputTokens > modelContextLimit;
|
|
2575
|
+
}
|
|
2576
|
+
function regrantSmallerWindowRecovery(state, key, modelContextLimit) {
|
|
2577
|
+
const previous = state.toolRecoveryAttemptedLanes.get(key);
|
|
2578
|
+
if (previous && !previous.inFlight && modelContextLimit < previous.modelContextLimit) {
|
|
2579
|
+
// The route has already been evicted. Dropping the attempt mark lifts its
|
|
2580
|
+
// backoff/awaiting-index, and an outage cooldown can defer the new
|
|
2581
|
+
// attempt without losing it; the next actual attempt records the smaller
|
|
2582
|
+
// capacity before it awaits anything.
|
|
2583
|
+
state.toolRecoveryAttemptedLanes.delete(key);
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2586
|
+
/** Deterministic system identity. Message identities use cached per-message hashes. */
|
|
2587
|
+
function routeValueHash(value) {
|
|
2588
|
+
return createHash("sha256")
|
|
2589
|
+
.update(JSON.stringify({
|
|
2590
|
+
present: value !== undefined,
|
|
2591
|
+
value: stableRouteValue(value),
|
|
2592
|
+
}))
|
|
2593
|
+
.digest("hex");
|
|
2594
|
+
}
|
|
2595
|
+
/** Canonicalize semantically identical Anthropic content representations. */
|
|
2596
|
+
function normalizeRouteContent(content) {
|
|
2597
|
+
const blocks = typeof content === "string"
|
|
2598
|
+
? [{ type: "text", text: content }]
|
|
2599
|
+
: content;
|
|
2600
|
+
return withoutContentBlockCacheControl(normalizeReminderContent(blocks));
|
|
2601
|
+
}
|
|
2602
|
+
/**
|
|
2603
|
+
* Claude Code changes request-attribution fields such as `cch` and
|
|
2604
|
+
* `cc_prev_req` during a tool loop. That synthetic top-level system block is
|
|
2605
|
+
* not conversation identity. Ignore exactly one standalone billing block
|
|
2606
|
+
* there, while keeping header-like text in messages fully identity-bearing.
|
|
2607
|
+
*/
|
|
2608
|
+
function normalizeRouteSystem(system) {
|
|
2609
|
+
const normalized = normalizeRouteContent(system);
|
|
2610
|
+
const headers = routeBillingHeaders(normalized);
|
|
2611
|
+
if (headers.length !== 1)
|
|
2612
|
+
return normalized;
|
|
2613
|
+
return replaceSingleRouteBillingHeader(normalized, ROUTE_BILLING_HEADER_PLACEHOLDER);
|
|
2614
|
+
}
|
|
2615
|
+
function normalizeReminderContent(content) {
|
|
2616
|
+
if (typeof content === "string")
|
|
2617
|
+
return normalizeRouteText(content);
|
|
2618
|
+
if (!Array.isArray(content))
|
|
2619
|
+
return content;
|
|
2620
|
+
return content.map((part) => {
|
|
2621
|
+
if (typeof part === "string")
|
|
2622
|
+
return normalizeRouteText(part);
|
|
2623
|
+
if (!part || typeof part !== "object")
|
|
2624
|
+
return part;
|
|
2625
|
+
const copy = { ...part };
|
|
2626
|
+
if (copy.type === "text" && typeof copy.text === "string") {
|
|
2627
|
+
copy.text = normalizeRouteText(copy.text);
|
|
2628
|
+
}
|
|
2629
|
+
else if (copy.type === "tool_result") {
|
|
2630
|
+
copy.content = normalizeReminderContent(copy.content);
|
|
2631
|
+
}
|
|
2632
|
+
return copy;
|
|
2633
|
+
});
|
|
2634
|
+
}
|
|
2635
|
+
const ROUTE_BILLING_HEADER_PREFIX = "x-anthropic-billing-header:";
|
|
2636
|
+
const ROUTE_BILLING_HEADER_PLACEHOLDER = "x-anthropic-billing-header: <dynamic>";
|
|
2637
|
+
function normalizeRouteText(text) {
|
|
2638
|
+
return stripSystemReminderText(text);
|
|
2639
|
+
}
|
|
2640
|
+
/** Preserve Claude's current per-request billing metadata after prefix grafting. */
|
|
2641
|
+
function currentRouteSystem(compressedSystem, currentSystem) {
|
|
2642
|
+
const currentHeaders = routeBillingHeaders(currentSystem);
|
|
2643
|
+
const compressedHeaders = routeBillingHeaders(compressedSystem);
|
|
2644
|
+
if (currentHeaders.length === 1 && compressedHeaders.length === 1) {
|
|
2645
|
+
return replaceSingleRouteBillingHeader(cloneJson(compressedSystem), currentHeaders[0]);
|
|
2646
|
+
}
|
|
2647
|
+
// The one-synthetic-header invariant broke (Claude reordered the header
|
|
2648
|
+
// fields, or the compressed system carries duplicate header-like blocks).
|
|
2649
|
+
// Never replay the FIRST request's stale cch/cc_prev_req attribution for
|
|
2650
|
+
// every tool call in the turn: drop the recognizable billing headers from
|
|
2651
|
+
// the routed system instead. Missing attribution is safer than wrong
|
|
2652
|
+
// attribution.
|
|
2653
|
+
const stripped = withoutRouteBillingHeaders(cloneJson(compressedSystem));
|
|
2654
|
+
// JSON.stringify omits undefined-valued keys, so an all-header system is
|
|
2655
|
+
// sent with no `system` field rather than an empty block list.
|
|
2656
|
+
return Array.isArray(stripped) && stripped.length === 0
|
|
2657
|
+
? undefined
|
|
2658
|
+
: stripped;
|
|
2659
|
+
}
|
|
2660
|
+
function routeBillingHeaders(value) {
|
|
2661
|
+
if (typeof value === "string") {
|
|
2662
|
+
return isRouteBillingHeader(value) ? [value] : [];
|
|
2663
|
+
}
|
|
2664
|
+
if (!Array.isArray(value))
|
|
2665
|
+
return [];
|
|
2666
|
+
const headers = [];
|
|
2667
|
+
for (const item of value) {
|
|
2668
|
+
if (typeof item === "string") {
|
|
2669
|
+
if (isRouteBillingHeader(item))
|
|
2670
|
+
headers.push(item);
|
|
2671
|
+
continue;
|
|
2672
|
+
}
|
|
2673
|
+
if (item &&
|
|
2674
|
+
typeof item === "object" &&
|
|
2675
|
+
item.type === "text") {
|
|
2676
|
+
const text = item.text;
|
|
2677
|
+
if (typeof text === "string" && isRouteBillingHeader(text)) {
|
|
2678
|
+
headers.push(text);
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
}
|
|
2682
|
+
return headers;
|
|
2683
|
+
}
|
|
2684
|
+
function isRouteBillingHeader(text) {
|
|
2685
|
+
if (/[\r\n]/.test(text) ||
|
|
2686
|
+
!text.startsWith(ROUTE_BILLING_HEADER_PREFIX)) {
|
|
2687
|
+
return false;
|
|
2688
|
+
}
|
|
2689
|
+
const fields = text.slice(ROUTE_BILLING_HEADER_PREFIX.length).trim();
|
|
2690
|
+
return (/^cc_version=[^;]+;/.test(fields) &&
|
|
2691
|
+
/(?:^|;\s*)cc_entrypoint=[^;]+;/.test(fields));
|
|
2692
|
+
}
|
|
2693
|
+
function replaceSingleRouteBillingHeader(value, replacement) {
|
|
2694
|
+
if (typeof value === "string") {
|
|
2695
|
+
return isRouteBillingHeader(value) ? replacement : value;
|
|
2696
|
+
}
|
|
2697
|
+
if (Array.isArray(value)) {
|
|
2698
|
+
return value.map((item) => {
|
|
2699
|
+
if (typeof item === "string") {
|
|
2700
|
+
return isRouteBillingHeader(item) ? replacement : item;
|
|
2701
|
+
}
|
|
2702
|
+
if (!item ||
|
|
2703
|
+
typeof item !== "object" ||
|
|
2704
|
+
item.type !== "text") {
|
|
2705
|
+
return item;
|
|
2706
|
+
}
|
|
2707
|
+
const block = item;
|
|
2708
|
+
return typeof block.text === "string" &&
|
|
2709
|
+
isRouteBillingHeader(block.text)
|
|
2710
|
+
? { ...block, text: replacement }
|
|
2711
|
+
: item;
|
|
2712
|
+
});
|
|
2713
|
+
}
|
|
2714
|
+
return value;
|
|
2715
|
+
}
|
|
2716
|
+
/** Remove every recognizable synthetic billing-header block from a system value. */
|
|
2717
|
+
function withoutRouteBillingHeaders(value) {
|
|
2718
|
+
if (typeof value === "string") {
|
|
2719
|
+
return isRouteBillingHeader(value) ? undefined : value;
|
|
2720
|
+
}
|
|
2721
|
+
if (!Array.isArray(value))
|
|
2722
|
+
return value;
|
|
2723
|
+
return value.filter((item) => {
|
|
2724
|
+
if (typeof item === "string")
|
|
2725
|
+
return !isRouteBillingHeader(item);
|
|
2726
|
+
if (!item ||
|
|
2727
|
+
typeof item !== "object" ||
|
|
2728
|
+
item.type !== "text") {
|
|
2729
|
+
return true;
|
|
2730
|
+
}
|
|
2731
|
+
const text = item.text;
|
|
2732
|
+
return !(typeof text === "string" && isRouteBillingHeader(text));
|
|
2733
|
+
});
|
|
2734
|
+
}
|
|
2735
|
+
/** Anthropic's limit on cache_control breakpoints per request. */
|
|
2736
|
+
/**
|
|
2737
|
+
* Claude Code marks its own blocks with cache_control, but the server's
|
|
2738
|
+
* flatten turns the compressed conversation into one plain-string user
|
|
2739
|
+
* message, so a compressed request carried no breakpoint at all: Anthropic
|
|
2740
|
+
* billed every compressed first request in full and cached nothing
|
|
2741
|
+
* (requests.jsonl, 2026-09-26: followup-compressed 51.8M uncached input,
|
|
2742
|
+
* 0.06M cache write; tool-recompressed 133.5M uncached, 0 either way).
|
|
2743
|
+
* Marking the flattened message caches system + tools + compressed history,
|
|
2744
|
+
* and every tool turn that rides the route reads it back.
|
|
2745
|
+
*/
|
|
2746
|
+
function withFlattenCacheBreakpoint(body, ttl) {
|
|
2747
|
+
const messages = body.messages;
|
|
2748
|
+
if (messages.length !== 1)
|
|
2749
|
+
return messages;
|
|
2750
|
+
const only = messages[0];
|
|
2751
|
+
const text = typeof only.content === "string" ? only.content : undefined;
|
|
2752
|
+
if (text === undefined)
|
|
2753
|
+
return messages;
|
|
2754
|
+
const cacheControl = { type: "ephemeral" };
|
|
2755
|
+
if (ttl !== undefined)
|
|
2756
|
+
cacheControl.ttl = ttl;
|
|
2757
|
+
return [{ ...only, content: [{ type: "text", text, cache_control: cacheControl }] }];
|
|
2758
|
+
}
|
|
2759
|
+
/**
|
|
2760
|
+
* Match the last existing marker in Anthropic's tools/system/messages order.
|
|
2761
|
+
* For a valid request it is the shortest TTL, so a new final marker cannot
|
|
2762
|
+
* put 1h after a retained 5m marker. Preserve omitted TTLs (default 5m).
|
|
2763
|
+
*/
|
|
2764
|
+
function cacheTtlOf(body) {
|
|
2765
|
+
let last;
|
|
2766
|
+
forEachCacheControl(body, (cc) => { last = cc.ttl; });
|
|
2767
|
+
return last;
|
|
2768
|
+
}
|
|
2769
|
+
/** A transformed request must not place a 1h breakpoint after a 5m one. */
|
|
2770
|
+
function validCacheTtlOrder(body) {
|
|
2771
|
+
let sawShort = false;
|
|
2772
|
+
let valid = true;
|
|
2773
|
+
forEachCacheControl(body, (cc) => {
|
|
2774
|
+
if (cc.ttl === "1h") {
|
|
2775
|
+
if (sawShort)
|
|
2776
|
+
valid = false;
|
|
2777
|
+
}
|
|
2778
|
+
else {
|
|
2779
|
+
sawShort = true;
|
|
2780
|
+
}
|
|
2781
|
+
});
|
|
2782
|
+
return valid;
|
|
2783
|
+
}
|
|
2784
|
+
function forEachCacheControl(body, visit) {
|
|
2785
|
+
const blocks = (value) => {
|
|
2786
|
+
if (!Array.isArray(value))
|
|
2787
|
+
return;
|
|
2788
|
+
for (const item of value) {
|
|
2789
|
+
if (item && typeof item === "object" && item.cache_control) {
|
|
2790
|
+
visit(item.cache_control);
|
|
2791
|
+
}
|
|
2792
|
+
}
|
|
2793
|
+
};
|
|
2794
|
+
// Anthropic prompt order matters for mixed cache TTLs.
|
|
2795
|
+
blocks(body.tools);
|
|
2796
|
+
blocks(body.system);
|
|
2797
|
+
if (Array.isArray(body.messages)) {
|
|
2798
|
+
for (const m of body.messages)
|
|
2799
|
+
blocks(m?.content);
|
|
2800
|
+
}
|
|
2801
|
+
}
|
|
2802
|
+
/** Ignore only Anthropic content-block cache metadata, never user/tool data. */
|
|
2803
|
+
function withoutContentBlockCacheControl(content) {
|
|
2804
|
+
if (Array.isArray(content)) {
|
|
2805
|
+
return content.map((part) => withoutContentBlockCacheControl(part));
|
|
2806
|
+
}
|
|
2807
|
+
if (!content || typeof content !== "object")
|
|
2808
|
+
return content;
|
|
2809
|
+
const { cache_control: _cacheControl, ...block } = content;
|
|
2810
|
+
return block;
|
|
2811
|
+
}
|
|
2812
|
+
function stableRouteValue(value) {
|
|
2813
|
+
if (Array.isArray(value))
|
|
2814
|
+
return value.map(stableRouteValue);
|
|
2815
|
+
if (!value || typeof value !== "object")
|
|
2816
|
+
return value;
|
|
2817
|
+
const out = {};
|
|
2818
|
+
for (const key of Object.keys(value).sort()) {
|
|
2819
|
+
out[key] = stableRouteValue(value[key]);
|
|
2820
|
+
}
|
|
2821
|
+
return out;
|
|
2822
|
+
}
|
|
2823
|
+
function validToolRouteSuffix(messages) {
|
|
2824
|
+
if (!messages.length || messages.some(isNonToolUserMessage))
|
|
2825
|
+
return false;
|
|
2826
|
+
const toolUses = new Set();
|
|
2827
|
+
const toolResults = [];
|
|
2828
|
+
for (const message of messages) {
|
|
2829
|
+
if (!Array.isArray(message?.content))
|
|
2830
|
+
continue;
|
|
2831
|
+
for (const part of message.content) {
|
|
2832
|
+
if (!part || typeof part !== "object")
|
|
2833
|
+
continue;
|
|
2834
|
+
if (part.type === "tool_use" && typeof part.id === "string") {
|
|
2835
|
+
toolUses.add(part.id);
|
|
2836
|
+
}
|
|
2837
|
+
else if (part.type === "tool_result" &&
|
|
2838
|
+
typeof part.tool_use_id === "string") {
|
|
2839
|
+
toolResults.push(part.tool_use_id);
|
|
2840
|
+
}
|
|
2841
|
+
}
|
|
2842
|
+
}
|
|
2843
|
+
return (toolUses.size > 0 &&
|
|
2844
|
+
toolResults.length > 0 &&
|
|
2845
|
+
toolResults.every((id) => toolUses.has(id)));
|
|
2846
|
+
}
|
|
2847
|
+
function requestSessionId(req) {
|
|
2848
|
+
const value = req.headers["x-claude-code-session-id"];
|
|
2849
|
+
const text = Array.isArray(value) ? value[0] : value;
|
|
2850
|
+
return typeof text === "string" && text.trim() ? text.trim() : undefined;
|
|
2851
|
+
}
|
|
2852
|
+
function hasAgentAttribution(req) {
|
|
2853
|
+
return agentAttributionId(req) !== undefined;
|
|
2854
|
+
}
|
|
2855
|
+
function cloneJson(value) {
|
|
2856
|
+
if (value === undefined)
|
|
2857
|
+
return value;
|
|
2858
|
+
return JSON.parse(JSON.stringify(value));
|
|
2859
|
+
}
|
|
2860
|
+
/**
|
|
2861
|
+
* The blocking-compression step, shared by the main followup path and
|
|
2862
|
+
* tool-route miss recovery: the compress call plus the `compress` telemetry
|
|
2863
|
+
* record. Callers own downstream-close tracking (compression promises are
|
|
2864
|
+
* hash-deduped and may serve another live retry, so a per-request disconnect
|
|
2865
|
+
* signal must never feed compress()) and all decisions about the returned
|
|
2866
|
+
* result: both `didMemtreeCompress(result)` and
|
|
2867
|
+
* `checkCompressedHistory(result, msgsForMemtree).usable` must hold before
|
|
2868
|
+
* the result may be forwarded or become a route.
|
|
2869
|
+
*/
|
|
2870
|
+
async function runBlockingCompression(args) {
|
|
2871
|
+
const { opts, state, body, msgsForMemtree, hash, modelContextLimit, rec } = args;
|
|
2872
|
+
const compaction = args.compaction ??
|
|
2873
|
+
laneCompaction(opts, state, args.sessionId, body.model, modelContextLimit);
|
|
2874
|
+
const messageUsage = args.sessionId !== undefined && opts.transcriptUsage
|
|
2875
|
+
? opts.transcriptUsage.usageFor(args.sessionId, msgsForMemtree, args.agentId)
|
|
2876
|
+
: undefined;
|
|
2877
|
+
const messageTimes = transcriptTimesFor(opts, args.sessionId, args.agentId)?.(msgsForMemtree);
|
|
2878
|
+
const compressStarted = Date.now();
|
|
2879
|
+
const compressMeta = {
|
|
2880
|
+
// Model + tools drive the server's model-based memory budget
|
|
2881
|
+
// (e.g. 800k whole-request target for Fable / Opus 5). Omitting
|
|
2882
|
+
// them silently downgrades to the server's static 50k fallback.
|
|
2883
|
+
// `[1m]` is re-attached when the session is 1M-context so the
|
|
2884
|
+
// server's budget telemetry names the variant it actually served.
|
|
2885
|
+
model: modelForMemtree(typeof body.model === "string" ? body.model : undefined, modelContextLimit),
|
|
2886
|
+
tools: Array.isArray(body.tools) ? body.tools : undefined,
|
|
2887
|
+
...(compaction.target !== undefined
|
|
2888
|
+
? { compressionTargetTokens: compaction.target }
|
|
2889
|
+
: {}),
|
|
2890
|
+
...(compaction.threshold !== undefined
|
|
2891
|
+
? { compressionThresholdTokens: compaction.threshold }
|
|
2892
|
+
: {}),
|
|
2893
|
+
// The size this request was judged by, when a plausible sample (reported
|
|
2894
|
+
// usage or an exact count) calibrated it. Sent so the server compresses
|
|
2895
|
+
// whatever the fallback gate would refuse to send uncompressed; a bytes/4
|
|
2896
|
+
// guess is not sent, as it would force needless compression.
|
|
2897
|
+
...(args.inputCandidate && args.sizing.clientInputTokens(args.inputCandidate) !== undefined
|
|
2898
|
+
? { clientInputTokens: args.sizing.clientInputTokens(args.inputCandidate) }
|
|
2899
|
+
: {}),
|
|
2900
|
+
...(messageUsage && Object.keys(messageUsage).length ? { messageUsage } : {}),
|
|
2901
|
+
...(messageTimes && Object.keys(messageTimes).length ? { messageTimes } : {}),
|
|
2902
|
+
...(args.sessionId !== undefined ? { sessionId: args.sessionId } : {}),
|
|
2903
|
+
...(args.clientMeta ? { clientMeta: args.clientMeta } : {}),
|
|
2904
|
+
};
|
|
2905
|
+
// Sampled BEFORE the call, while it still describes this call: after the
|
|
2906
|
+
// await the hash is in the cache regardless of who put it there.
|
|
2907
|
+
const cached = opts.memtree.hasCachedCompress(hash, modelContextLimit, compressMeta);
|
|
2908
|
+
// compress() maps every failure to a resolved null (it never rejects).
|
|
2909
|
+
// Keep foreground work finite even if a transport ignores its abort signal.
|
|
2910
|
+
let deadline;
|
|
2911
|
+
const maximumWait = Math.min(30_000, Math.max(1, Number.isFinite(opts.memtree.compressBudgetMs) ? opts.memtree.compressBudgetMs : 15_000));
|
|
2912
|
+
const result = await Promise.race([
|
|
2913
|
+
opts.memtree.compress(hash, msgsForMemtree, modelContextLimit, state.shutdownSignal, compressMeta),
|
|
2914
|
+
new Promise((resolve) => { deadline = setTimeout(() => resolve(null), maximumWait); }),
|
|
2915
|
+
]).finally(() => { if (deadline)
|
|
2916
|
+
clearTimeout(deadline); });
|
|
2917
|
+
const compressMs = Date.now() - compressStarted;
|
|
2918
|
+
noteServerBudget(state, body.model, modelContextLimit, result);
|
|
2919
|
+
// Sampled at this call's settle, before a concurrent same-request failure of
|
|
2920
|
+
// another class can overwrite the complete-key entry.
|
|
2921
|
+
const failureArming = result === null &&
|
|
2922
|
+
opts.memtree.lastCompressFailureArming(hash, modelContextLimit, compressMeta);
|
|
2923
|
+
const hadLiveSuccess = !cached && result !== null;
|
|
2924
|
+
// Only arming-class live failures (network error, timeout, 5xx, 402) are
|
|
2925
|
+
// fuse evidence. A responsive server's other 4xx failed THIS call but says
|
|
2926
|
+
// nothing about MemTree's health, so it contributes neither failure nor
|
|
2927
|
+
// success — the fuse is untouched.
|
|
2928
|
+
const hadLiveFailure = !cached && result === null && failureArming;
|
|
2929
|
+
rec.compress = {
|
|
2930
|
+
ms: compressMs,
|
|
2931
|
+
ok: result !== null,
|
|
2932
|
+
// Budget-consumed heuristic: the client maps every failure to null, so a
|
|
2933
|
+
// null that took (roughly) the whole abort budget was almost certainly
|
|
2934
|
+
// the AbortSignal timeout, not a fast server error.
|
|
2935
|
+
timedOut: result === null && compressMs >= opts.memtree.compressBudgetMs,
|
|
2936
|
+
};
|
|
2937
|
+
return {
|
|
2938
|
+
result,
|
|
2939
|
+
// Cached-only operations leave the existing fuse state untouched.
|
|
2940
|
+
liveHealth: hadLiveSuccess
|
|
2941
|
+
? "success"
|
|
2942
|
+
: hadLiveFailure
|
|
2943
|
+
? "failure"
|
|
2944
|
+
: "none",
|
|
2945
|
+
};
|
|
2946
|
+
}
|
|
2947
|
+
/**
|
|
2948
|
+
* Shared final request shape for a validated compression result: lift any
|
|
2949
|
+
* returned system message to `body.system`, and forward the SERVER's flatten
|
|
2950
|
+
* of the compressed conversation as the single user message Anthropic
|
|
2951
|
+
* receives. The flatten format (closed transcript container, per-human-turn
|
|
2952
|
+
* headers, live-tail framing, header escaping) lives server-side only; the
|
|
2953
|
+
* client forwards `flattened_messages` verbatim and never re-derives it.
|
|
2954
|
+
* Returns null when the server provided no usable flatten (a pre-flatten
|
|
2955
|
+
* server, or a malformed field) — callers degrade to forwarding the original
|
|
2956
|
+
* history. Route candidates must be created from this `compressedBody` —
|
|
2957
|
+
* never from raw `result.messages` — so tool-turn rewrites extend exactly
|
|
2958
|
+
* the bytes that were sent.
|
|
2959
|
+
*/
|
|
2960
|
+
function buildCompressedBody(body, result) {
|
|
2961
|
+
const flattened = serverFlattenedMessages(result);
|
|
2962
|
+
if (flattened === null)
|
|
2963
|
+
return null;
|
|
2964
|
+
const systemMsg = result.messages.find((m) => m.role === "system");
|
|
2965
|
+
const compressedBody = {
|
|
2966
|
+
...body,
|
|
2967
|
+
messages: flattened,
|
|
2968
|
+
};
|
|
2969
|
+
if (systemMsg?.content != null) {
|
|
2970
|
+
compressedBody.system = systemMsg.content;
|
|
2971
|
+
}
|
|
2972
|
+
compressedBody.messages = withFlattenCacheBreakpoint(compressedBody, cacheTtlOf(body));
|
|
2973
|
+
// Reserve a slot for the prefix even when all four original markers were
|
|
2974
|
+
// on retained tools/system blocks. Only this transformed body is capped.
|
|
2975
|
+
if (!capCacheBreakpoints(compressedBody, compressedBody.messages.length))
|
|
2976
|
+
return null;
|
|
2977
|
+
if (!validCacheTtlOrder(compressedBody))
|
|
2978
|
+
return null;
|
|
2979
|
+
return {
|
|
2980
|
+
compressedBody,
|
|
2981
|
+
compressedRaw: Buffer.from(JSON.stringify(compressedBody), "utf-8"),
|
|
2982
|
+
};
|
|
2983
|
+
}
|
|
2984
|
+
/**
|
|
2985
|
+
* A tool turn's compaction (planToolCompaction said its estimated size
|
|
2986
|
+
* reached the budget): one blocking compression forced to the target,
|
|
2987
|
+
* sharing the followup path's complete selection pipeline. This is a soft
|
|
2988
|
+
* fuse — any failure, no-op, unusable, or non-shrinking result degrades to
|
|
2989
|
+
* the body the turn would otherwise have sent (`fallback`: the old route or
|
|
2990
|
+
* prefix ride, recorded as keptPrefix; else the whole history). A validated
|
|
2991
|
+
* smaller result forwards exactly one compressed Anthropic leg, claims no
|
|
2992
|
+
* human-turn state (no notice, no prompt arm/delivery mutation), and at
|
|
2993
|
+
* protocol-complete installs the lane's route and, on the main thread, the
|
|
2994
|
+
* session's new stable prefix (`stable`), so later tool and human turns ride
|
|
2995
|
+
* it with no compress call. An attempt that produced no prefix sets the
|
|
2996
|
+
* lane's backoff (`retryAtTokens`).
|
|
2997
|
+
*/
|
|
2998
|
+
async function recoverToolRouteMiss(args) {
|
|
2999
|
+
const { opts, state, req, res, upstream, body, messages, forwardBody, msgsForMemtree, hash, modelContextLimit, routeEpoch, rec, conversationBytes, routeKey, isMainRequest, recoveryAttempt, toolForwarding, stable, fallback, } = args;
|
|
3000
|
+
const backOff = () => {
|
|
3001
|
+
if (args.retryAtTokens !== undefined) {
|
|
3002
|
+
recoveryAttempt.retryAtTokens = args.retryAtTokens;
|
|
3003
|
+
recoveryAttempt.retryDeadline = Date.now() + TOOL_RECOVERY_FAILURE_COOLDOWN_MS;
|
|
3004
|
+
}
|
|
3005
|
+
};
|
|
3006
|
+
const sessionId = requestSessionId(req);
|
|
3007
|
+
const routeOwning = sessionId !== undefined;
|
|
3008
|
+
// Only a route-owning recovery reserves the decision generation. A
|
|
3009
|
+
// transform-only attempt must not advance it — and, reciprocally, its late
|
|
3010
|
+
// completion can never install over (or clear) a route someone else
|
|
3011
|
+
// installed after this miss began, because it never activates at all.
|
|
3012
|
+
const decisionGeneration = routeOwning
|
|
3013
|
+
? ++state.mainRouteDecisionGeneration
|
|
3014
|
+
: state.mainRouteDecisionGeneration;
|
|
3015
|
+
if (routeOwning)
|
|
3016
|
+
reserveRouteDecision(state, routeKey, decisionGeneration);
|
|
3017
|
+
// A reservation that ends up installing nothing must be RETURNED. It was
|
|
3018
|
+
// taken before the outcome was known, and while held it marks stale every
|
|
3019
|
+
// concurrent install and post-await clear that captured the previous
|
|
3020
|
+
// generation. Failing to release it means a failed/no-op/no-gain/
|
|
3021
|
+
// client-closed recovery silently suppresses a concurrent followup's
|
|
3022
|
+
// install, leaving the conversation with no route at all. Dropping our own
|
|
3023
|
+
// entry is unconditional and order-independent: it never moves anyone
|
|
3024
|
+
// else's reservation, so releasing before or after a newer sibling makes no
|
|
3025
|
+
// difference to who holds the decision.
|
|
3026
|
+
const releaseOwnReservation = () => {
|
|
3027
|
+
if (routeOwning)
|
|
3028
|
+
releaseRouteDecision(state, routeKey, decisionGeneration);
|
|
3029
|
+
};
|
|
3030
|
+
/**
|
|
3031
|
+
* The attempt produced nothing: send what the turn would have sent without
|
|
3032
|
+
* it — the old ride (kept prefix) or the whole history — and back off.
|
|
3033
|
+
*/
|
|
3034
|
+
const forwardOriginal = () => {
|
|
3035
|
+
requestSendPolicies.get(req).failedRebuild = true;
|
|
3036
|
+
backOff();
|
|
3037
|
+
if (fallback) {
|
|
3038
|
+
requestSendPolicies.get(req)?.allowed.add(fallback.raw);
|
|
3039
|
+
if (rec.compaction)
|
|
3040
|
+
rec.compaction.keptPrefix = true;
|
|
3041
|
+
recordTurn(rec, fallback.turnType, fallback.raw);
|
|
3042
|
+
capture(opts, "anthropic-request-memory-tool", fallback.raw);
|
|
3043
|
+
return forwardRaw(req, res, fallback.raw, opts, upstream, state.shutdownSignal, rec).then((delivered) => {
|
|
3044
|
+
toolForwarding.settle(delivered);
|
|
3045
|
+
noteRideSize(fallback.sizeHolder, rec, fallback.raw.length);
|
|
3046
|
+
});
|
|
3047
|
+
}
|
|
3048
|
+
recordTurn(rec, "tool", args.originalBody);
|
|
3049
|
+
capture(opts, "anthropic-request", args.originalBody);
|
|
3050
|
+
return forwardRaw(req, res, args.originalBody, opts, upstream, state.shutdownSignal, rec).then((delivered) => {
|
|
3051
|
+
toolForwarding.settle(delivered);
|
|
3052
|
+
noteWholeRequestSize(state, isMainRequest, sessionId, args.sizeKey, rec, args.originalBody.length);
|
|
3053
|
+
});
|
|
3054
|
+
};
|
|
3055
|
+
// Same downstream-close tracking as the followup path: compression
|
|
3056
|
+
// promises are hash-deduped and may serve another live retry, so the
|
|
3057
|
+
// subscriber's lifetime is tracked locally, never fed into compress().
|
|
3058
|
+
let downstreamClosedDuringCompression = res.destroyed && !res.writableFinished;
|
|
3059
|
+
const markDownstreamClosed = () => {
|
|
3060
|
+
if (!res.writableFinished)
|
|
3061
|
+
downstreamClosedDuringCompression = true;
|
|
3062
|
+
};
|
|
3063
|
+
res.once("close", markDownstreamClosed);
|
|
3064
|
+
const linkSeq = isMainRequest ? nextMemtreeCallSeq(state) : undefined;
|
|
3065
|
+
let compression;
|
|
3066
|
+
try {
|
|
3067
|
+
compression = await runBlockingCompression({
|
|
3068
|
+
opts,
|
|
3069
|
+
state,
|
|
3070
|
+
body,
|
|
3071
|
+
msgsForMemtree,
|
|
3072
|
+
hash,
|
|
3073
|
+
modelContextLimit,
|
|
3074
|
+
rec,
|
|
3075
|
+
sessionId: requestSessionId(req),
|
|
3076
|
+
agentId: agentAttributionId(req),
|
|
3077
|
+
clientMeta: args.clientMeta,
|
|
3078
|
+
sizing: args.sizing,
|
|
3079
|
+
inputCandidate: args.inputCandidate,
|
|
3080
|
+
...(args.compaction ? { compaction: args.compaction } : {}),
|
|
3081
|
+
});
|
|
3082
|
+
}
|
|
3083
|
+
catch {
|
|
3084
|
+
releaseOwnReservation();
|
|
3085
|
+
rec.routeRecovery = { conversationBytes, outcome: "failed" };
|
|
3086
|
+
return forwardOriginal();
|
|
3087
|
+
}
|
|
3088
|
+
finally {
|
|
3089
|
+
res.off("close", markDownstreamClosed);
|
|
3090
|
+
}
|
|
3091
|
+
const { result } = compression;
|
|
3092
|
+
// Every lane writes the shared cooldown now: with subagents on the same
|
|
3093
|
+
// recovery path, a subagent's compress failure is the same evidence about
|
|
3094
|
+
// MemTree's health as main's, and its successes clear the cooldown too.
|
|
3095
|
+
noteMemtreeHealth(state, compression);
|
|
3096
|
+
if (linkSeq !== undefined) {
|
|
3097
|
+
noteMemtreePage(state, linkSeq, requestSessionId(req), result);
|
|
3098
|
+
}
|
|
3099
|
+
if (downstreamClosedDuringCompression ||
|
|
3100
|
+
(res.destroyed && !res.writableFinished)) {
|
|
3101
|
+
// The MemTree work keeps its cache/index value, but a dead client gets
|
|
3102
|
+
// no Anthropic request and no route.
|
|
3103
|
+
rec.routeRecovery = { conversationBytes, outcome: "client-closed" };
|
|
3104
|
+
releaseOwnReservation();
|
|
3105
|
+
// Same hazard class as the post-forward fates "upstream-failed" /
|
|
3106
|
+
// "client-aborted": no route was installed and the client's identical
|
|
3107
|
+
// retry is imminent, so no backoff is set and the attempt mark is
|
|
3108
|
+
// dropped; the retry compresses again, a compress-cache hit (client
|
|
3109
|
+
// closes are deliberately never fed into compress()). Retire only this
|
|
3110
|
+
// attempt so a late settle cannot remove a newer attempt.
|
|
3111
|
+
if (state.toolRecoveryAttemptedLanes.get(routeKey) === recoveryAttempt) {
|
|
3112
|
+
state.toolRecoveryAttemptedLanes.delete(routeKey);
|
|
3113
|
+
}
|
|
3114
|
+
recordTurn(rec, "tool", Buffer.alloc(0));
|
|
3115
|
+
return;
|
|
3116
|
+
}
|
|
3117
|
+
if (!result) {
|
|
3118
|
+
rec.routeRecovery = { conversationBytes, outcome: "failed" };
|
|
3119
|
+
releaseOwnReservation();
|
|
3120
|
+
// An ordinary server/network failure or timeout retains the background
|
|
3121
|
+
// submission: its longer independent budget can still warm the index for
|
|
3122
|
+
// a later turn. An unpaid key (402, possibly set by this very compress)
|
|
3123
|
+
// or a shutting-down proxy gets no retry.
|
|
3124
|
+
if (!state.shutdownSignal.aborted &&
|
|
3125
|
+
opts.memtree.paymentRequiredDetail === null) {
|
|
3126
|
+
opts.memtree.indexInBackground(hash, msgsForMemtree, modelContextLimit, requestSessionId(req), args.clientMeta, transcriptTimesFor(opts, requestSessionId(req), agentAttributionId(req)));
|
|
3127
|
+
}
|
|
3128
|
+
return forwardOriginal();
|
|
3129
|
+
}
|
|
3130
|
+
// Any non-null response already submitted this history to the server; an
|
|
3131
|
+
// extra indexInBackground for the same request would be a duplicate.
|
|
3132
|
+
const actuallyCompressed = didMemtreeCompress(result);
|
|
3133
|
+
const historyCheck = checkCompressedHistory(result, msgsForMemtree);
|
|
3134
|
+
rec.history = {
|
|
3135
|
+
retainedChars: historyCheck.retainedChars,
|
|
3136
|
+
priorHistoryChars: historyCheck.priorHistoryChars,
|
|
3137
|
+
usable: historyCheck.usable,
|
|
3138
|
+
};
|
|
3139
|
+
if (!actuallyCompressed || !historyCheck.usable) {
|
|
3140
|
+
// Index-warming no-op (flattening it would change structured history for
|
|
3141
|
+
// nothing) or an indexed answer that dropped the conversation (amnesia —
|
|
3142
|
+
// fatal for a route). Both preserve the original body.
|
|
3143
|
+
rec.routeRecovery = {
|
|
3144
|
+
conversationBytes,
|
|
3145
|
+
outcome: actuallyCompressed ? "unusable" : "noop",
|
|
3146
|
+
};
|
|
3147
|
+
// No tree yet (nothing indexed): wait for it to be built rather than
|
|
3148
|
+
// retrying on growth, which on a fast-growing loop is every tool turn.
|
|
3149
|
+
const pageId = result.memtreeUrl ? memtreePageId(result.memtreeUrl) : undefined;
|
|
3150
|
+
if (!actuallyCompressed && !(cachedPromptTokenCount(result) ?? 0) && pageId) {
|
|
3151
|
+
recoveryAttempt.awaitingIndex = {
|
|
3152
|
+
pageId,
|
|
3153
|
+
checking: false,
|
|
3154
|
+
deadline: Date.now() + (opts.awaitedIndexWaitTimeoutMs ?? 60_000),
|
|
3155
|
+
};
|
|
3156
|
+
rec.routeRecovery.awaitingIndex = true;
|
|
3157
|
+
}
|
|
3158
|
+
releaseOwnReservation();
|
|
3159
|
+
return forwardOriginal();
|
|
3160
|
+
}
|
|
3161
|
+
// Serializing the compressed body is the one synchronous step here that can
|
|
3162
|
+
// realistically throw: these are the multi-megabyte payloads that court
|
|
3163
|
+
// V8's string-length ceiling, which is exactly why this fuse exists. A
|
|
3164
|
+
// throw would otherwise escape as a proxy 500 AND strand the reservation,
|
|
3165
|
+
// turning a recoverable tool turn into a failed one. Degrade instead — the
|
|
3166
|
+
// original body is still forwardable.
|
|
3167
|
+
let built;
|
|
3168
|
+
try {
|
|
3169
|
+
built = buildCompressedBody(body, result);
|
|
3170
|
+
}
|
|
3171
|
+
catch (err) {
|
|
3172
|
+
rec.routeRecovery = { conversationBytes, outcome: "build-failed" };
|
|
3173
|
+
releaseOwnReservation();
|
|
3174
|
+
if (opts.debug) {
|
|
3175
|
+
console.error(`[ccc proxy] recovered body build failed: ${err?.message ?? err}`);
|
|
3176
|
+
}
|
|
3177
|
+
return forwardOriginal();
|
|
3178
|
+
}
|
|
3179
|
+
if (built === null) {
|
|
3180
|
+
// The server compressed but returned no usable `flattened_messages`
|
|
3181
|
+
// (pre-flatten server, or a malformed field). The client never re-derives
|
|
3182
|
+
// the flatten locally — that drifted from the server's canonical format
|
|
3183
|
+
// once already — so degrade to the original body, same envelope as every
|
|
3184
|
+
// other non-forwardable recovery outcome.
|
|
3185
|
+
rec.routeRecovery = { conversationBytes, outcome: "no-flatten" };
|
|
3186
|
+
releaseOwnReservation();
|
|
3187
|
+
if (opts.debug) {
|
|
3188
|
+
console.error("[ccc proxy] recovered result carried no server flatten; " +
|
|
3189
|
+
"forwarding original body");
|
|
3190
|
+
}
|
|
3191
|
+
return forwardOriginal();
|
|
3192
|
+
}
|
|
3193
|
+
const { compressedBody, compressedRaw } = built;
|
|
3194
|
+
if (!(await requestSendPolicies.get(req).fitsNative(compressedRaw))) {
|
|
3195
|
+
rec.routeRecovery = { conversationBytes, outcome: "unusable" };
|
|
3196
|
+
releaseOwnReservation();
|
|
3197
|
+
return forwardOriginal();
|
|
3198
|
+
}
|
|
3199
|
+
if (compressedRaw.length >= (fallback?.raw ?? forwardBody).length) {
|
|
3200
|
+
// The final transformed body is the proof of payload recovery; a result
|
|
3201
|
+
// with no byte gain over what the turn would send anyway is not worth a
|
|
3202
|
+
// route built on it.
|
|
3203
|
+
rec.routeRecovery = { conversationBytes, outcome: "no-gain" };
|
|
3204
|
+
releaseOwnReservation();
|
|
3205
|
+
return forwardOriginal();
|
|
3206
|
+
}
|
|
3207
|
+
rec.routeRecovery = { conversationBytes, outcome: "compressed" };
|
|
3208
|
+
let activationAttempted = false;
|
|
3209
|
+
let installFate;
|
|
3210
|
+
let installedRoute;
|
|
3211
|
+
const activateRecoveredRoute = () => {
|
|
3212
|
+
if (activationAttempted)
|
|
3213
|
+
return;
|
|
3214
|
+
if (!requestSendPolicies.get(req)?.allowed.has(compressedRaw)) {
|
|
3215
|
+
releaseOwnReservation();
|
|
3216
|
+
activationAttempted = true;
|
|
3217
|
+
return;
|
|
3218
|
+
}
|
|
3219
|
+
if (!routeOwning) {
|
|
3220
|
+
// A sessionless request can use this response but cannot store a reusable route.
|
|
3221
|
+
activationAttempted = true;
|
|
3222
|
+
return;
|
|
3223
|
+
}
|
|
3224
|
+
// Installation checks the same lane-generation guard before any mutation.
|
|
3225
|
+
const installed = installMemoryRoute(state, routeKey, req, body, messages, compressedBody, routeEpoch, decisionGeneration, stable);
|
|
3226
|
+
// Leave this false if installMemoryRoute unexpectedly throws: the
|
|
3227
|
+
// delivery-complete fallback then gets one safe retry.
|
|
3228
|
+
activationAttempted = true;
|
|
3229
|
+
installedRoute = installed;
|
|
3230
|
+
installFate = installed ? "installed" : "stale";
|
|
3231
|
+
// A tool continuation can arrive after message_stop while this HTTP
|
|
3232
|
+
// stream is still draining. Its route is already complete, so a smaller
|
|
3233
|
+
// window may buy recovery now. Mutate only this attempt's object: a late
|
|
3234
|
+
// transport settle must not release a newer attempt in the same lane.
|
|
3235
|
+
if (installed)
|
|
3236
|
+
recoveryAttempt.inFlight = false;
|
|
3237
|
+
if (opts.debug) {
|
|
3238
|
+
console.error(`[ccc proxy] recovered memory route activation: ${installed ? "installed" : "unavailable"}`);
|
|
3239
|
+
}
|
|
3240
|
+
};
|
|
3241
|
+
if (opts.debug) {
|
|
3242
|
+
console.error(`[ccc proxy] tool-route miss recovered: ${forwardBody.length} → ` +
|
|
3243
|
+
`${compressedRaw.length} body bytes`);
|
|
3244
|
+
}
|
|
3245
|
+
recordTurn(rec, "tool-recompressed", compressedRaw);
|
|
3246
|
+
// Tagged as a TOOL memory leg, not a followup one: capture-based tooling
|
|
3247
|
+
// must be able to tell a recovered tool request from a normal compressed
|
|
3248
|
+
// human turn without cross-referencing reqlog.
|
|
3249
|
+
capture(opts, "anthropic-request-memory-tool", compressedRaw);
|
|
3250
|
+
// Set when either activation attempt throws. Protocol-complete firing
|
|
3251
|
+
// means the upstream served the complete turn and the proxy accepted the
|
|
3252
|
+
// message_stop bytes into the response — not that the client received
|
|
3253
|
+
// them. A throw there must NOT fall through to "client-aborted" or
|
|
3254
|
+
// "upstream-failed" at settle: in the common sub-case (fast-tool abort)
|
|
3255
|
+
// the client consumed its answer and no identical-body retry is coming.
|
|
3256
|
+
// The close is not always client-owned — an upstream socket error after
|
|
3257
|
+
// the data chunk carrying message_stop lands here too — and a socket
|
|
3258
|
+
// that dies before the queued bytes flush DOES retry the identical body.
|
|
3259
|
+
// Those rarer closes keep the lane's backoff — an accepted, bounded
|
|
3260
|
+
// degradation (the retry forwards uncompressed until the history grows
|
|
3261
|
+
// past the retry size or the next human turn clears it), because the
|
|
3262
|
+
// closes are indistinguishable at settle time and lifting the backoff
|
|
3263
|
+
// would fund one blocking recompress per tool turn under a deterministic
|
|
3264
|
+
// activation throw.
|
|
3265
|
+
let activationThrew = false;
|
|
3266
|
+
requestSendPolicies.get(req)?.allowed.add(compressedRaw);
|
|
3267
|
+
const delivered = await forwardRaw(req, res, compressedRaw, opts, upstream, state.shutdownSignal, rec,
|
|
3268
|
+
// Protocol-complete (accepted SSE message_stop or a complete 2xx JSON
|
|
3269
|
+
// response) is the real activation point, exactly as on the followup
|
|
3270
|
+
// path, so a fast tool request right after message_stop can ride.
|
|
3271
|
+
() => {
|
|
3272
|
+
try {
|
|
3273
|
+
activateRecoveredRoute();
|
|
3274
|
+
}
|
|
3275
|
+
catch {
|
|
3276
|
+
// forwardRaw's own guard would swallow this anyway; catching here
|
|
3277
|
+
// records that a route was earned but its bookkeeping threw, which
|
|
3278
|
+
// the settle label below must be able to tell apart from a
|
|
3279
|
+
// mid-stream abort or an upstream failure.
|
|
3280
|
+
activationThrew = true;
|
|
3281
|
+
}
|
|
3282
|
+
});
|
|
3283
|
+
toolForwarding.settle(delivered);
|
|
3284
|
+
// Defensive delivery-complete fallback, mirroring the followup path. A
|
|
3285
|
+
// candidate already activated at message_stop deliberately survives a
|
|
3286
|
+
// delivered=false settle (fast-tool abort); an upstream 500/529 or an
|
|
3287
|
+
// incomplete response never reached protocol-complete and never installs.
|
|
3288
|
+
// Swallow a repeat throw: if it escaped here, the settle logic below
|
|
3289
|
+
// would be skipped and the lane's reservation stranded for the rest of
|
|
3290
|
+
// the epoch. With no install fate the fallback below still labels
|
|
3291
|
+
// ("activation-error" for this corner) and releases; the backoff stays,
|
|
3292
|
+
// since the delivered client will not retry.
|
|
3293
|
+
if (delivered) {
|
|
3294
|
+
try {
|
|
3295
|
+
activateRecoveredRoute();
|
|
3296
|
+
}
|
|
3297
|
+
catch {
|
|
3298
|
+
// Fate/release handled by the settle logic below. Remember the throw:
|
|
3299
|
+
// this is a fully delivered response with no route, which must not be
|
|
3300
|
+
// labeled (or have its backoff lifted) as an upstream failure.
|
|
3301
|
+
activationThrew = true;
|
|
3302
|
+
}
|
|
3303
|
+
}
|
|
3304
|
+
rec.routeRecovery.install = !routeOwning
|
|
3305
|
+
? "no-session"
|
|
3306
|
+
: // No install fate normally means protocol-complete never fired —
|
|
3307
|
+
// split by who owned the close: a client mid-stream abort is not
|
|
3308
|
+
// evidence about upstream health, and the attempt-rate tripwire needs
|
|
3309
|
+
// to count the two separately. The one exception is a turn the
|
|
3310
|
+
// upstream served to protocol-complete whose route bookkeeping threw
|
|
3311
|
+
// at an activation attempt (the protocol-complete attempt, the
|
|
3312
|
+
// delivered retry, or both): label it distinctly so it neither
|
|
3313
|
+
// pollutes the upstream-failure metric nor lifts the backoff. In
|
|
3314
|
+
// the common sub-case the client got its answer (a fast-tool abort
|
|
3315
|
+
// consumed message_stop first) and no identical-body retry is coming;
|
|
3316
|
+
// the rarer closes — a pre-flush socket death (which does retry, into
|
|
3317
|
+
// the lane's backoff) or an upstream-owned error after the accepted
|
|
3318
|
+
// message_stop chunk — land here too. See the activationThrew comment
|
|
3319
|
+
// above for why that trade-off is deliberate.
|
|
3320
|
+
installFate ??
|
|
3321
|
+
(activationThrew
|
|
3322
|
+
? "activation-error"
|
|
3323
|
+
: rec.clientAborted
|
|
3324
|
+
? "client-aborted"
|
|
3325
|
+
: "upstream-failed");
|
|
3326
|
+
if (stable)
|
|
3327
|
+
rec.routeRecovery.prefix = stable.installed ? "installed" : "not-installed";
|
|
3328
|
+
// The compacted request's reported size anchors the next budget check.
|
|
3329
|
+
const sizeHolder = stable?.installed ?? installedRoute;
|
|
3330
|
+
if (sizeHolder)
|
|
3331
|
+
notePrefixSize(sizeHolder, rec, compressedRaw.length);
|
|
3332
|
+
// Nothing to ride next turn, or a result that still does not fit the
|
|
3333
|
+
// window (the next turn would reject it and compress again): wait for
|
|
3334
|
+
// growth before the lane tries again.
|
|
3335
|
+
if (!sizeHolder || routedBodyExceedsContext(body, compressedRaw, modelContextLimit))
|
|
3336
|
+
backOff();
|
|
3337
|
+
// A reservation that lost its race (stale) or never reached
|
|
3338
|
+
// protocol-complete (upstream 5xx, truncated stream) installed nothing, so
|
|
3339
|
+
// it must stop suppressing whoever is still trying to install.
|
|
3340
|
+
if (rec.routeRecovery.install !== "installed")
|
|
3341
|
+
releaseOwnReservation();
|
|
3342
|
+
// A failed forward AFTER a healthy compress — upstream 5xx/529 or a client
|
|
3343
|
+
// mid-stream abort — left no route and the client retries the identical
|
|
3344
|
+
// body, so drop the lane's attempt mark, lifting the backoff just set —
|
|
3345
|
+
// identity-guarded so a late settle cannot remove a newer lane attempt.
|
|
3346
|
+
// The retry's recompress is a compress-cache hit, so the re-attempt is
|
|
3347
|
+
// cheap. A later route mismatch deliberately does NOT lift a backoff:
|
|
3348
|
+
// siblings sharing a parent-agent fallback lane genuinely mismatch each
|
|
3349
|
+
// other every turn, and lifting it there would be one real blocking
|
|
3350
|
+
// compress per tool step. The two fates are split in reqlog so the
|
|
3351
|
+
// attempt-rate tripwire can tell client behavior from upstream health;
|
|
3352
|
+
// the backoff lift treats them alike.
|
|
3353
|
+
if ((rec.routeRecovery.install === "upstream-failed" ||
|
|
3354
|
+
rec.routeRecovery.install === "client-aborted") &&
|
|
3355
|
+
state.toolRecoveryAttemptedLanes.get(routeKey) === recoveryAttempt) {
|
|
3356
|
+
state.toolRecoveryAttemptedLanes.delete(routeKey);
|
|
3357
|
+
}
|
|
3358
|
+
}
|
|
3359
|
+
/** Stamp the classified turn type and forwarded-size fields on the record. */
|
|
3360
|
+
function recordTurn(rec, turnType, forwardBody) {
|
|
3361
|
+
rec.turnType = turnType;
|
|
3362
|
+
rec.forwardedBytes = forwardBody.length;
|
|
3363
|
+
rec.approxInputTokens = approxTokensFromBytes(forwardBody.length);
|
|
3364
|
+
}
|
|
3365
|
+
function headerText(headers, name) {
|
|
3366
|
+
const value = headers[name];
|
|
3367
|
+
return Array.isArray(value) ? value.join(",") : value ?? "";
|
|
3368
|
+
}
|
|
3369
|
+
/**
|
|
3370
|
+
* count_tokens: strip notices and mirror an active memory route during its
|
|
3371
|
+
* tool loop. The response itself remains byte-transparent.
|
|
3372
|
+
*/
|
|
3373
|
+
async function handleCountTokens(req, res, opts, upstream, state) {
|
|
3374
|
+
const rawBody = await readBody(req);
|
|
3375
|
+
let forwardBody = rawBody;
|
|
3376
|
+
try {
|
|
3377
|
+
const body = JSON.parse(rawBody.toString("utf-8"));
|
|
3378
|
+
if (Array.isArray(body.messages)) {
|
|
3379
|
+
const stripped = stripNoticeBlocks(body.messages);
|
|
3380
|
+
const strippedSystem = stripNoticeSystem(body.system);
|
|
3381
|
+
if (stripped.stripped || strippedSystem.stripped) {
|
|
3382
|
+
body.messages = stripped.messages;
|
|
3383
|
+
if (strippedSystem.system === undefined)
|
|
3384
|
+
delete body.system;
|
|
3385
|
+
else
|
|
3386
|
+
body.system = strippedSystem.system;
|
|
3387
|
+
forwardBody = Buffer.from(JSON.stringify(body), "utf-8");
|
|
3388
|
+
}
|
|
3389
|
+
const lastMsg = lastNonSystemMessage(body.messages);
|
|
3390
|
+
// A tool_result tail is never an away-summary probe, so the lane key
|
|
3391
|
+
// only distinguishes main vs agent identity here.
|
|
3392
|
+
const countRoute = isToolResultUserMessage(lastMsg)
|
|
3393
|
+
? getMemoryRoute(state, routeIdentity(req, false).key)
|
|
3394
|
+
: undefined;
|
|
3395
|
+
if (countRoute) {
|
|
3396
|
+
const routed = memoryRoutedToolBody(body, body.messages, countRoute, state.mainRouteEpoch, requestSessionId(req));
|
|
3397
|
+
if (routed)
|
|
3398
|
+
forwardBody = routed;
|
|
3399
|
+
}
|
|
3400
|
+
}
|
|
3401
|
+
}
|
|
3402
|
+
catch {
|
|
3403
|
+
// Unknown shape: forward verbatim.
|
|
3404
|
+
}
|
|
3405
|
+
return forwardRaw(req, res, forwardBody, opts, upstream, state.shutdownSignal, undefined).then(() => undefined);
|
|
3406
|
+
}
|
|
3407
|
+
/**
|
|
3408
|
+
* Forward a buffered request and pipe the response back byte-for-byte. A
|
|
3409
|
+
* passive observer parses copies of SSE/JSON chunks for request logging and
|
|
3410
|
+
* completion validation; its output is discarded and can never change the
|
|
3411
|
+
* client response.
|
|
3412
|
+
*/
|
|
3413
|
+
/**
|
|
3414
|
+
* Requests whose streamed response gets a text block appended: the hidden
|
|
3415
|
+
* away-summary (recap) request, so the recap Claude Code shows ends with the
|
|
3416
|
+
* MemTree link. Keyed by the incoming request so every forwarding path below
|
|
3417
|
+
* picks it up without threading a parameter through each one.
|
|
3418
|
+
*/
|
|
3419
|
+
const recapLinkAppenders = new WeakMap();
|
|
3420
|
+
/** One delivery-time retry, against the assembled body whose exact count exceeded the budget. */
|
|
3421
|
+
async function recoverLateCount(args) {
|
|
3422
|
+
try {
|
|
3423
|
+
const body = JSON.parse(args.bodyBuffer.toString("utf-8"));
|
|
3424
|
+
const messages = normalizeMessagesForMemtree(messagesWithSystem(body.messages, body.system));
|
|
3425
|
+
const budget = resolveBudget(args.opts, args.state, body.model, args.modelContextLimit).tokens;
|
|
3426
|
+
const outcome = await runBlockingCompression({
|
|
3427
|
+
opts: args.opts, state: args.state, body, msgsForMemtree: messages,
|
|
3428
|
+
hash: MemtreeClient.hashMessages(messages), modelContextLimit: args.modelContextLimit,
|
|
3429
|
+
rec: args.rec, sessionId: requestSessionId(args.req), agentId: agentAttributionId(args.req),
|
|
3430
|
+
clientMeta: args.clientMeta, sizing: args.sizing,
|
|
3431
|
+
inputCandidate: args.sizing.register(args.bodyBuffer, "ride"),
|
|
3432
|
+
compaction: { target: compactionTarget(compactionMode(args.opts, args.state, requestSessionId(args.req)), budget) },
|
|
3433
|
+
});
|
|
3434
|
+
noteMemtreeHealth(args.state, outcome);
|
|
3435
|
+
if (!outcome.result || !didMemtreeCompress(outcome.result) ||
|
|
3436
|
+
!checkCompressedHistory(outcome.result, messages).usable)
|
|
3437
|
+
return undefined;
|
|
3438
|
+
const built = buildCompressedBody(body, outcome.result);
|
|
3439
|
+
if (!built)
|
|
3440
|
+
return undefined;
|
|
3441
|
+
return built.compressedRaw;
|
|
3442
|
+
}
|
|
3443
|
+
catch {
|
|
3444
|
+
return undefined;
|
|
3445
|
+
}
|
|
3446
|
+
}
|
|
3447
|
+
/** Select a measured eligible body before delivery; refusing one candidate never
|
|
3448
|
+
* prevents considering the original or a validated prefix retained by planning. */
|
|
3449
|
+
async function selectForwardCandidate(args) {
|
|
3450
|
+
const policy = requestSendPolicies.get(args.req);
|
|
3451
|
+
const intended = policy.failedRebuild && !policy.allowed.has(args.preferredBody)
|
|
3452
|
+
? policy.original : args.preferredBody;
|
|
3453
|
+
const preferred = policy.sizing.register(intended, policy.allowed.has(intended) ? args.rec.compress ? "replacement" : "ride" : "original");
|
|
3454
|
+
const measured = await policy.sizing.measure(preferred, args.limits);
|
|
3455
|
+
let target = preferred;
|
|
3456
|
+
if (preferred.kind === "ride" && measured.source === "exact" &&
|
|
3457
|
+
measured.tokens > args.limits.budget && !args.rec.compress && !args.rec.lateCountRecovery) {
|
|
3458
|
+
args.rec.lateCountRecovery = { outcome: "forwarded" };
|
|
3459
|
+
const replacement = await policy.recoverLate(preferred.body, measured.tokens);
|
|
3460
|
+
if (replacement)
|
|
3461
|
+
target = policy.sizing.register(replacement, "replacement");
|
|
3462
|
+
}
|
|
3463
|
+
const selection = await policy.sizing.select(target, args.limits);
|
|
3464
|
+
if (!selection) {
|
|
3465
|
+
if (args.rec.lateCountRecovery)
|
|
3466
|
+
args.rec.lateCountRecovery.outcome = "refused";
|
|
3467
|
+
refuseWholeRequest(args, measured.tokens, args.limits.budget);
|
|
3468
|
+
return undefined;
|
|
3469
|
+
}
|
|
3470
|
+
const { candidate, measurement } = selection;
|
|
3471
|
+
const changed = candidate !== preferred || intended !== args.preferredBody;
|
|
3472
|
+
if (changed) {
|
|
3473
|
+
policy.allowed.delete(args.preferredBody);
|
|
3474
|
+
changedDeliveryRecords.add(args.rec);
|
|
3475
|
+
selectedCandidates.set(args.rec, candidate);
|
|
3476
|
+
recordTurn(args.rec, args.rec.turnType, candidate.body);
|
|
3477
|
+
}
|
|
3478
|
+
if (args.rec.lateCountRecovery)
|
|
3479
|
+
args.rec.lateCountRecovery.outcome =
|
|
3480
|
+
changed && candidate.kind === "replacement" ? "compressed" : "forwarded";
|
|
3481
|
+
delete args.rec.overBudgetForward;
|
|
3482
|
+
if (measurement.tokens > args.limits.budget ||
|
|
3483
|
+
(measurement.source !== "exact" && candidate.kind === "original" &&
|
|
3484
|
+
measurement.estimatedTokens > args.limits.budget)) {
|
|
3485
|
+
args.rec.overBudgetForward = { reason: measurement.source === "exact"
|
|
3486
|
+
? "exact-count-fits-window" : measurement.source === "bytes"
|
|
3487
|
+
? "bytes-fallback-fits-window" : "estimate-fits-window" };
|
|
3488
|
+
}
|
|
3489
|
+
return changed ? candidate.body : intended;
|
|
3490
|
+
}
|
|
3491
|
+
/** Write the bounded fallback refusal (route-fallback.ts); always false. */
|
|
3492
|
+
function refuseWholeRequest(args, inputTokens, budgetTokens) {
|
|
3493
|
+
const failure = args.state.fallbackFailures.fail(args.routeKey);
|
|
3494
|
+
args.rec.forwardedBytes = 0;
|
|
3495
|
+
args.rec.approxInputTokens = 0;
|
|
3496
|
+
console.error("[ccc proxy] request exceeds native context window; recovery failed", {
|
|
3497
|
+
lane: args.routeKey, attempt: failure.attempt, status: failure.status,
|
|
3498
|
+
inputTokens, outputTokens: args.body.max_tokens ?? 0,
|
|
3499
|
+
budgetTokens, modelContextLimit: args.modelContextLimit,
|
|
3500
|
+
});
|
|
3501
|
+
if (args.res.headersSent || args.res.destroyed)
|
|
3502
|
+
return false;
|
|
3503
|
+
args.res.writeHead(failure.status, { "content-type": "application/json", ...failure.headers });
|
|
3504
|
+
args.res.end(JSON.stringify(failure.body));
|
|
3505
|
+
return false;
|
|
3506
|
+
}
|
|
3507
|
+
const changedDeliveryRecords = new WeakSet();
|
|
3508
|
+
const selectedCandidates = new WeakMap();
|
|
3509
|
+
const candidateSizeOwners = new WeakMap();
|
|
3510
|
+
const requestSendPolicies = new WeakMap();
|
|
3511
|
+
async function forwardRaw(req, res, bodyBuffer, opts, upstream, shutdownSignal, rec, onProtocolComplete) {
|
|
3512
|
+
const policy = requestSendPolicies.get(req);
|
|
3513
|
+
const selected = policy ? await policy.select(bodyBuffer) : bodyBuffer;
|
|
3514
|
+
if (!selected)
|
|
3515
|
+
return false;
|
|
3516
|
+
const delivered = await forwardAccepted(req, res, selected, opts, upstream, shutdownSignal, rec, selected === bodyBuffer ? onProtocolComplete : undefined);
|
|
3517
|
+
if (delivered && rec)
|
|
3518
|
+
noteSelectedCandidateSize(rec);
|
|
3519
|
+
return delivered;
|
|
3520
|
+
}
|
|
3521
|
+
/** Attribute a fallback response only to the body and size owner actually sent. */
|
|
3522
|
+
function noteSelectedCandidateSize(rec) {
|
|
3523
|
+
const candidate = selectedCandidates.get(rec);
|
|
3524
|
+
const owner = candidate && candidateSizeOwners.get(candidate);
|
|
3525
|
+
const tokens = reportedInputTokens(rec);
|
|
3526
|
+
if (!candidate || !owner || tokens === undefined)
|
|
3527
|
+
return;
|
|
3528
|
+
const sample = { tokens, forwardedBytes: candidate.body.length };
|
|
3529
|
+
if (!plausibleSample(sample))
|
|
3530
|
+
return;
|
|
3531
|
+
if ("set" in owner)
|
|
3532
|
+
owner.set(sample);
|
|
3533
|
+
else if (!owner.lastSize || sample.forwardedBytes >= owner.lastSize.forwardedBytes) {
|
|
3534
|
+
owner.lastSize = sample;
|
|
3535
|
+
}
|
|
3536
|
+
}
|
|
3537
|
+
/** forwardRaw once the request's send policy (if any) accepted the body. */
|
|
3538
|
+
function forwardAccepted(req, res, bodyBuffer, opts, upstream, shutdownSignal, rec, onProtocolComplete) {
|
|
3539
|
+
const policy = requestSendPolicies.get(req);
|
|
3540
|
+
return new Promise((resolve) => {
|
|
3541
|
+
const headers = forwardableRequestHeaders(req);
|
|
3542
|
+
headers["content-length"] = String(bodyBuffer.length);
|
|
3543
|
+
const appendRecapText = recapLinkAppenders.get(req);
|
|
3544
|
+
// The passive observer must be able to decode its copy to verify complete
|
|
3545
|
+
// delivery (message_stop for SSE, complete JSON otherwise). Constrain the
|
|
3546
|
+
// negotiated coding to what the observation decoders support, so an
|
|
3547
|
+
// upstream choice like zstd cannot mark a byte-perfectly delivered
|
|
3548
|
+
// response as failed.
|
|
3549
|
+
// The bytes written to the client stay exact.
|
|
3550
|
+
headers["accept-encoding"] = observableAcceptEncoding(headers["accept-encoding"]);
|
|
3551
|
+
// Appending needs plain SSE text; the recap is tiny, so skip compression.
|
|
3552
|
+
if (appendRecapText)
|
|
3553
|
+
headers["accept-encoding"] = "identity";
|
|
3554
|
+
const forwardStarted = Date.now();
|
|
3555
|
+
let settled = false;
|
|
3556
|
+
let upstreamCompleted = false;
|
|
3557
|
+
let responseFinished = false;
|
|
3558
|
+
let protocolComplete = false;
|
|
3559
|
+
let successfulStatus = false;
|
|
3560
|
+
let clientAborted = false;
|
|
3561
|
+
let shutdownCancelled = false;
|
|
3562
|
+
let protocolCompleteNotified = false;
|
|
3563
|
+
let upstreamReq;
|
|
3564
|
+
let activeUpstreamRes;
|
|
3565
|
+
const notifyProtocolComplete = () => {
|
|
3566
|
+
if (protocolCompleteNotified ||
|
|
3567
|
+
!successfulStatus ||
|
|
3568
|
+
onProtocolComplete === undefined) {
|
|
3569
|
+
return;
|
|
3570
|
+
}
|
|
3571
|
+
protocolCompleteNotified = true;
|
|
3572
|
+
try {
|
|
3573
|
+
onProtocolComplete();
|
|
3574
|
+
}
|
|
3575
|
+
catch {
|
|
3576
|
+
// Route/observer bookkeeping can never affect byte delivery.
|
|
3577
|
+
}
|
|
3578
|
+
};
|
|
3579
|
+
const settle = (ok) => {
|
|
3580
|
+
if (settled)
|
|
3581
|
+
return;
|
|
3582
|
+
settled = true;
|
|
3583
|
+
if (ok)
|
|
3584
|
+
policy?.delivered();
|
|
3585
|
+
res.off("finish", onResponseFinish);
|
|
3586
|
+
res.off("close", onResponseClose);
|
|
3587
|
+
shutdownSignal.removeEventListener("abort", cancelForShutdown);
|
|
3588
|
+
resolve(ok);
|
|
3589
|
+
};
|
|
3590
|
+
const maybeSettle = () => {
|
|
3591
|
+
if (upstreamCompleted && responseFinished) {
|
|
3592
|
+
settle(successfulStatus && protocolComplete && !clientAborted);
|
|
3593
|
+
}
|
|
3594
|
+
};
|
|
3595
|
+
const onResponseFinish = () => {
|
|
3596
|
+
responseFinished = true;
|
|
3597
|
+
maybeSettle();
|
|
3598
|
+
};
|
|
3599
|
+
const onResponseClose = () => {
|
|
3600
|
+
if (res.writableFinished || shutdownCancelled)
|
|
3601
|
+
return;
|
|
3602
|
+
clientAborted = true;
|
|
3603
|
+
// Observability only: lets callers (and reqlog consumers) tell a
|
|
3604
|
+
// client-owned close from an upstream failure after the settle.
|
|
3605
|
+
if (rec)
|
|
3606
|
+
rec.clientAborted = true;
|
|
3607
|
+
activeUpstreamRes?.destroy();
|
|
3608
|
+
upstreamReq?.destroy();
|
|
3609
|
+
settle(false);
|
|
3610
|
+
};
|
|
3611
|
+
const completeUpstream = (complete) => {
|
|
3612
|
+
upstreamCompleted = true;
|
|
3613
|
+
protocolComplete = complete;
|
|
3614
|
+
if (complete && !res.destroyed)
|
|
3615
|
+
notifyProtocolComplete();
|
|
3616
|
+
if (res.destroyed && !res.writableFinished && !shutdownCancelled) {
|
|
3617
|
+
// The destroy is client-owned here: upstream ended cleanly (its
|
|
3618
|
+
// error/aborted handlers settle synchronously before this can run)
|
|
3619
|
+
// and the shutdownCancelled guard excludes the proxy-owned destroy —
|
|
3620
|
+
// on the decoder path, decoder.end() defers finish() a tick, and a
|
|
3621
|
+
// shutdown landing in that gap would otherwise be stamped as a
|
|
3622
|
+
// client abort (onResponseClose has the same guard). Stamp directly —
|
|
3623
|
+
// this settle detaches onResponseClose before the 'close' event that
|
|
3624
|
+
// normally stamps can fire, and losing that race would misclassify a
|
|
3625
|
+
// client abort as "upstream-failed" in the recovery settle.
|
|
3626
|
+
clientAborted = true;
|
|
3627
|
+
if (rec)
|
|
3628
|
+
rec.clientAborted = true;
|
|
3629
|
+
settle(false);
|
|
3630
|
+
return;
|
|
3631
|
+
}
|
|
3632
|
+
maybeSettle();
|
|
3633
|
+
};
|
|
3634
|
+
const cancelForShutdown = () => {
|
|
3635
|
+
if (settled || shutdownCancelled)
|
|
3636
|
+
return;
|
|
3637
|
+
shutdownCancelled = true;
|
|
3638
|
+
// Teardown is proxy-owned. Detach the ordinary client-close classifier
|
|
3639
|
+
// before destroying either side of the pipe.
|
|
3640
|
+
res.off("close", onResponseClose);
|
|
3641
|
+
activeUpstreamRes?.destroy();
|
|
3642
|
+
upstreamReq?.destroy();
|
|
3643
|
+
if (!res.destroyed && !res.writableEnded)
|
|
3644
|
+
res.destroy();
|
|
3645
|
+
settle(false);
|
|
3646
|
+
};
|
|
3647
|
+
res.once("finish", onResponseFinish);
|
|
3648
|
+
res.once("close", onResponseClose);
|
|
3649
|
+
shutdownSignal.addEventListener("abort", cancelForShutdown, { once: true });
|
|
3650
|
+
if (shutdownSignal.aborted) {
|
|
3651
|
+
cancelForShutdown();
|
|
3652
|
+
return;
|
|
3653
|
+
}
|
|
3654
|
+
upstreamReq = upstream.module.request({
|
|
3655
|
+
host: upstream.host,
|
|
3656
|
+
port: upstream.port,
|
|
3657
|
+
method: req.method,
|
|
3658
|
+
path: req.url, // path + query string verbatim (?beta=true etc.)
|
|
3659
|
+
headers,
|
|
3660
|
+
}, (upstreamRes) => {
|
|
3661
|
+
activeUpstreamRes = upstreamRes;
|
|
3662
|
+
const contentType = String(upstreamRes.headers["content-type"] ?? "");
|
|
3663
|
+
const isSse = contentType.includes("text/event-stream");
|
|
3664
|
+
const contentEncoding = String(upstreamRes.headers["content-encoding"] ?? "identity").trim().toLowerCase();
|
|
3665
|
+
const compressed = contentEncoding !== "" && contentEncoding !== "identity";
|
|
3666
|
+
let sawFirstByte = false;
|
|
3667
|
+
let sawMessageStop = false;
|
|
3668
|
+
let observerFailed = false;
|
|
3669
|
+
const observeSseEvent = (data) => {
|
|
3670
|
+
if (rec) {
|
|
3671
|
+
mergeUsageFromSseEvent(data, rec);
|
|
3672
|
+
if (rec.firstContentMs === undefined &&
|
|
3673
|
+
data?.type === "content_block_delta") {
|
|
3674
|
+
rec.firstContentMs = Date.now() - forwardStarted;
|
|
3675
|
+
}
|
|
3676
|
+
}
|
|
3677
|
+
if (data?.type === "message_stop") {
|
|
3678
|
+
sawMessageStop = true;
|
|
3679
|
+
}
|
|
3680
|
+
};
|
|
3681
|
+
// Rewrite only a plain, successful SSE stream; anything else passes
|
|
3682
|
+
// through untouched and simply carries no link.
|
|
3683
|
+
const appendText = appendRecapText &&
|
|
3684
|
+
isSse &&
|
|
3685
|
+
!compressed &&
|
|
3686
|
+
(upstreamRes.statusCode ?? 0) >= 200 &&
|
|
3687
|
+
(upstreamRes.statusCode ?? 0) < 300
|
|
3688
|
+
? appendRecapText
|
|
3689
|
+
: undefined;
|
|
3690
|
+
const sseObserver = isSse
|
|
3691
|
+
? new SseNoticeRewriter({
|
|
3692
|
+
onEvent: observeSseEvent,
|
|
3693
|
+
...(appendText ? { endOfTurnText: appendText } : {}),
|
|
3694
|
+
})
|
|
3695
|
+
: null;
|
|
3696
|
+
const incrementalDecoder = sseObserver && compressed
|
|
3697
|
+
? createObservationDecoder(contentEncoding)
|
|
3698
|
+
: null;
|
|
3699
|
+
const observedChunks = !isSse || (compressed && !incrementalDecoder) ? [] : null;
|
|
3700
|
+
const observeRawChunk = (chunk) => {
|
|
3701
|
+
if (rec && !sawFirstByte) {
|
|
3702
|
+
sawFirstByte = true;
|
|
3703
|
+
rec.ttfbMs = Date.now() - forwardStarted;
|
|
3704
|
+
}
|
|
3705
|
+
if (sseObserver && !compressed)
|
|
3706
|
+
sseObserver.push(chunk);
|
|
3707
|
+
if (observedChunks)
|
|
3708
|
+
observedChunks.push(Buffer.from(chunk));
|
|
3709
|
+
};
|
|
3710
|
+
if (rec) {
|
|
3711
|
+
rec.upstreamStatus = upstreamRes.statusCode ?? 502;
|
|
3712
|
+
}
|
|
3713
|
+
successfulStatus =
|
|
3714
|
+
typeof upstreamRes.statusCode === "number" &&
|
|
3715
|
+
upstreamRes.statusCode >= 200 &&
|
|
3716
|
+
upstreamRes.statusCode < 300;
|
|
3717
|
+
res.writeHead(upstreamRes.statusCode ?? 502, forwardableResponseHeaders(upstreamRes));
|
|
3718
|
+
if (incrementalDecoder && sseObserver) {
|
|
3719
|
+
// Gate each encoded SSE chunk on locally decoding its copy. This
|
|
3720
|
+
// guarantees message_start usage is recorded before the identical
|
|
3721
|
+
// gzip/Brotli/deflate bytes can trigger Claude's MessageDisplay hook.
|
|
3722
|
+
// Only the original bytes are written to the client.
|
|
3723
|
+
let decoderFailed = false;
|
|
3724
|
+
let pendingForward = null;
|
|
3725
|
+
let pendingFinish = null;
|
|
3726
|
+
incrementalDecoder.on("data", (chunk) => {
|
|
3727
|
+
if (!decoderFailed)
|
|
3728
|
+
sseObserver.push(chunk);
|
|
3729
|
+
});
|
|
3730
|
+
incrementalDecoder.on("error", () => {
|
|
3731
|
+
decoderFailed = true;
|
|
3732
|
+
observerFailed = true;
|
|
3733
|
+
const forward = pendingForward;
|
|
3734
|
+
pendingForward = null;
|
|
3735
|
+
forward?.();
|
|
3736
|
+
const finish = pendingFinish;
|
|
3737
|
+
pendingFinish = null;
|
|
3738
|
+
finish?.();
|
|
3739
|
+
});
|
|
3740
|
+
res.once("close", () => incrementalDecoder.destroy());
|
|
3741
|
+
const forwardEncoded = (chunk) => {
|
|
3742
|
+
if (res.destroyed || res.writableEnded) {
|
|
3743
|
+
upstreamRes.destroy();
|
|
3744
|
+
return false;
|
|
3745
|
+
}
|
|
3746
|
+
if (res.write(chunk))
|
|
3747
|
+
upstreamRes.resume();
|
|
3748
|
+
else
|
|
3749
|
+
res.once("drain", () => upstreamRes.resume());
|
|
3750
|
+
return true;
|
|
3751
|
+
};
|
|
3752
|
+
upstreamRes.on("data", (chunk) => {
|
|
3753
|
+
upstreamRes.pause();
|
|
3754
|
+
observeRawChunk(chunk);
|
|
3755
|
+
if (decoderFailed) {
|
|
3756
|
+
forwardEncoded(chunk);
|
|
3757
|
+
return;
|
|
3758
|
+
}
|
|
3759
|
+
let forwarded = false;
|
|
3760
|
+
let accepted = false;
|
|
3761
|
+
const forwardOnce = () => {
|
|
3762
|
+
if (forwarded)
|
|
3763
|
+
return accepted;
|
|
3764
|
+
forwarded = true;
|
|
3765
|
+
accepted = forwardEncoded(chunk);
|
|
3766
|
+
return accepted;
|
|
3767
|
+
};
|
|
3768
|
+
pendingForward = forwardOnce;
|
|
3769
|
+
try {
|
|
3770
|
+
incrementalDecoder.write(chunk, (err) => {
|
|
3771
|
+
if (err) {
|
|
3772
|
+
decoderFailed = true;
|
|
3773
|
+
observerFailed = true;
|
|
3774
|
+
}
|
|
3775
|
+
if (pendingForward === forwardOnce)
|
|
3776
|
+
pendingForward = null;
|
|
3777
|
+
const chunkAccepted = forwardOnce();
|
|
3778
|
+
if (chunkAccepted &&
|
|
3779
|
+
!decoderFailed &&
|
|
3780
|
+
sawMessageStop) {
|
|
3781
|
+
notifyProtocolComplete();
|
|
3782
|
+
}
|
|
3783
|
+
});
|
|
3784
|
+
}
|
|
3785
|
+
catch {
|
|
3786
|
+
decoderFailed = true;
|
|
3787
|
+
observerFailed = true;
|
|
3788
|
+
if (pendingForward === forwardOnce)
|
|
3789
|
+
pendingForward = null;
|
|
3790
|
+
forwardOnce();
|
|
3791
|
+
}
|
|
3792
|
+
});
|
|
3793
|
+
upstreamRes.on("end", () => {
|
|
3794
|
+
let finished = false;
|
|
3795
|
+
const finish = () => {
|
|
3796
|
+
if (finished)
|
|
3797
|
+
return;
|
|
3798
|
+
finished = true;
|
|
3799
|
+
pendingFinish = null;
|
|
3800
|
+
sseObserver.flush();
|
|
3801
|
+
if (!res.destroyed && !res.writableEnded)
|
|
3802
|
+
res.end();
|
|
3803
|
+
completeUpstream(!observerFailed && sawMessageStop);
|
|
3804
|
+
};
|
|
3805
|
+
if (decoderFailed) {
|
|
3806
|
+
finish();
|
|
3807
|
+
return;
|
|
3808
|
+
}
|
|
3809
|
+
try {
|
|
3810
|
+
pendingFinish = finish;
|
|
3811
|
+
incrementalDecoder.end(finish);
|
|
3812
|
+
}
|
|
3813
|
+
catch {
|
|
3814
|
+
finish();
|
|
3815
|
+
}
|
|
3816
|
+
});
|
|
3817
|
+
upstreamRes.on("error", () => {
|
|
3818
|
+
incrementalDecoder.destroy();
|
|
3819
|
+
res.destroy();
|
|
3820
|
+
settle(false);
|
|
3821
|
+
});
|
|
3822
|
+
upstreamRes.on("aborted", () => {
|
|
3823
|
+
incrementalDecoder.destroy();
|
|
3824
|
+
res.destroy();
|
|
3825
|
+
settle(false);
|
|
3826
|
+
});
|
|
3827
|
+
return;
|
|
3828
|
+
}
|
|
3829
|
+
if (appendText && sseObserver) {
|
|
3830
|
+
// Same observation as below, but the client gets the rewriter's
|
|
3831
|
+
// output (original frames plus the appended block) instead of the
|
|
3832
|
+
// raw bytes. Backpressure mirrors pipe().
|
|
3833
|
+
upstreamRes.on("data", (chunk) => {
|
|
3834
|
+
if (rec && !sawFirstByte) {
|
|
3835
|
+
sawFirstByte = true;
|
|
3836
|
+
rec.ttfbMs = Date.now() - forwardStarted;
|
|
3837
|
+
}
|
|
3838
|
+
const out = sseObserver.push(chunk);
|
|
3839
|
+
if (!out || res.destroyed || res.writableEnded)
|
|
3840
|
+
return;
|
|
3841
|
+
if (!res.write(out)) {
|
|
3842
|
+
upstreamRes.pause();
|
|
3843
|
+
res.once("drain", () => upstreamRes.resume());
|
|
3844
|
+
}
|
|
3845
|
+
if (sawMessageStop && !observerFailed && !res.destroyed) {
|
|
3846
|
+
notifyProtocolComplete();
|
|
3847
|
+
}
|
|
3848
|
+
});
|
|
3849
|
+
upstreamRes.on("end", () => {
|
|
3850
|
+
const rest = sseObserver.flush();
|
|
3851
|
+
if (rest && !res.destroyed && !res.writableEnded)
|
|
3852
|
+
res.write(rest);
|
|
3853
|
+
if (!res.destroyed && !res.writableEnded)
|
|
3854
|
+
res.end();
|
|
3855
|
+
completeUpstream(!observerFailed && sawMessageStop);
|
|
3856
|
+
});
|
|
3857
|
+
upstreamRes.on("error", () => {
|
|
3858
|
+
res.destroy();
|
|
3859
|
+
settle(false);
|
|
3860
|
+
});
|
|
3861
|
+
upstreamRes.on("aborted", () => {
|
|
3862
|
+
res.destroy();
|
|
3863
|
+
settle(false);
|
|
3864
|
+
});
|
|
3865
|
+
return;
|
|
3866
|
+
}
|
|
3867
|
+
upstreamRes.on("data", observeRawChunk);
|
|
3868
|
+
upstreamRes.pipe(res);
|
|
3869
|
+
if (isSse && !compressed) {
|
|
3870
|
+
// Registered after pipe(), so this runs only after the raw chunk
|
|
3871
|
+
// containing the complete message_stop frame has been accepted by
|
|
3872
|
+
// ServerResponse. The callback still runs synchronously before a
|
|
3873
|
+
// client can issue the resulting tool request.
|
|
3874
|
+
upstreamRes.on("data", () => {
|
|
3875
|
+
if (sawMessageStop && !observerFailed && !res.destroyed) {
|
|
3876
|
+
notifyProtocolComplete();
|
|
3877
|
+
}
|
|
3878
|
+
});
|
|
3879
|
+
}
|
|
3880
|
+
upstreamRes.on("end", () => {
|
|
3881
|
+
let observed = null;
|
|
3882
|
+
if (observedChunks) {
|
|
3883
|
+
observed = decodeForObservation(Buffer.concat(observedChunks), contentEncoding);
|
|
3884
|
+
if (rec && observed && !isSse) {
|
|
3885
|
+
mergeUsageFromJsonBody(observed, rec);
|
|
3886
|
+
}
|
|
3887
|
+
}
|
|
3888
|
+
if (isSse) {
|
|
3889
|
+
if (compressed && !incrementalDecoder) {
|
|
3890
|
+
if (observed)
|
|
3891
|
+
sseObserver?.push(observed);
|
|
3892
|
+
else
|
|
3893
|
+
observerFailed = true;
|
|
3894
|
+
}
|
|
3895
|
+
sseObserver?.flush();
|
|
3896
|
+
completeUpstream(!observerFailed && sawMessageStop);
|
|
3897
|
+
}
|
|
3898
|
+
else {
|
|
3899
|
+
completeUpstream(observed !== null && isCompleteJsonResponse(req.url, observed));
|
|
3900
|
+
}
|
|
3901
|
+
});
|
|
3902
|
+
upstreamRes.on("error", () => {
|
|
3903
|
+
res.destroy();
|
|
3904
|
+
settle(false);
|
|
3905
|
+
});
|
|
3906
|
+
upstreamRes.on("aborted", () => {
|
|
3907
|
+
res.destroy();
|
|
3908
|
+
settle(false);
|
|
3909
|
+
});
|
|
3910
|
+
});
|
|
3911
|
+
upstreamReq.on("error", (err) => {
|
|
3912
|
+
if (!shutdownCancelled) {
|
|
3913
|
+
sendAnthropicError(res, `upstream connection failed: ${err.message}`);
|
|
3914
|
+
}
|
|
3915
|
+
settle(false);
|
|
3916
|
+
});
|
|
3917
|
+
upstreamReq.end(bodyBuffer);
|
|
3918
|
+
});
|
|
3919
|
+
}
|
|
3920
|
+
function isCompleteJsonResponse(requestUrl, body) {
|
|
3921
|
+
try {
|
|
3922
|
+
const parsed = JSON.parse(body.toString("utf-8"));
|
|
3923
|
+
const pathname = new URL(requestUrl ?? "/", "http://127.0.0.1").pathname;
|
|
3924
|
+
if (pathname.endsWith("/count_tokens")) {
|
|
3925
|
+
return (typeof parsed?.input_tokens === "number" &&
|
|
3926
|
+
Number.isFinite(parsed.input_tokens) &&
|
|
3927
|
+
parsed.input_tokens >= 0);
|
|
3928
|
+
}
|
|
3929
|
+
return parsed?.type === "message" && Array.isArray(parsed.content);
|
|
3930
|
+
}
|
|
3931
|
+
catch {
|
|
3932
|
+
return false;
|
|
3933
|
+
}
|
|
3934
|
+
}
|
|
3935
|
+
/** Transparent streaming passthrough for everything else. */
|
|
3936
|
+
function passThroughStreaming(req, res, upstream, shutdownSignal) {
|
|
3937
|
+
return new Promise((resolve) => {
|
|
3938
|
+
const headers = forwardableRequestHeaders(req);
|
|
3939
|
+
// The body is piped unmodified here, so keep the client's original
|
|
3940
|
+
// content-length (SKIP_REQUEST_HEADERS strips it for the buffered paths,
|
|
3941
|
+
// which recompute it); dropping it would silently convert the request to
|
|
3942
|
+
// chunked transfer-encoding.
|
|
3943
|
+
if (req.headers["content-length"] !== undefined) {
|
|
3944
|
+
headers["content-length"] = req.headers["content-length"];
|
|
3945
|
+
}
|
|
3946
|
+
let settled = false;
|
|
3947
|
+
let shutdownCancelled = false;
|
|
3948
|
+
let incomingUploadEnded = req.readableEnded;
|
|
3949
|
+
let upstreamUploadFinished = false;
|
|
3950
|
+
let upstreamResponseEnded = false;
|
|
3951
|
+
let downstreamFinished = res.writableFinished;
|
|
3952
|
+
let pendingErrorResponse = false;
|
|
3953
|
+
let upstreamReq;
|
|
3954
|
+
let activeUpstreamRes;
|
|
3955
|
+
const settle = () => {
|
|
3956
|
+
if (settled)
|
|
3957
|
+
return;
|
|
3958
|
+
settled = true;
|
|
3959
|
+
shutdownSignal.removeEventListener("abort", cancelForShutdown);
|
|
3960
|
+
resolve();
|
|
3961
|
+
};
|
|
3962
|
+
/** Clean completion owns all four independently asynchronous seams. */
|
|
3963
|
+
const settleIfComplete = () => {
|
|
3964
|
+
if (incomingUploadEnded &&
|
|
3965
|
+
upstreamUploadFinished &&
|
|
3966
|
+
upstreamResponseEnded &&
|
|
3967
|
+
downstreamFinished) {
|
|
3968
|
+
settle();
|
|
3969
|
+
}
|
|
3970
|
+
};
|
|
3971
|
+
/** Every abnormal exit tears down the whole duplex exchange exactly once. */
|
|
3972
|
+
const tearDown = () => {
|
|
3973
|
+
if (settled)
|
|
3974
|
+
return;
|
|
3975
|
+
// An early upstream response can mark ClientRequest/IncomingMessage as
|
|
3976
|
+
// destroyed while their keep-alive socket still awaits the rest of the
|
|
3977
|
+
// upload. Capture both transports before stream teardown and close them
|
|
3978
|
+
// explicitly so server.close() cannot inherit a half-owned connection.
|
|
3979
|
+
const downstreamSocket = req.socket;
|
|
3980
|
+
const upstreamSocket = activeUpstreamRes?.socket ?? upstreamReq?.socket;
|
|
3981
|
+
activeUpstreamRes?.unpipe(res);
|
|
3982
|
+
if (upstreamReq)
|
|
3983
|
+
req.unpipe(upstreamReq);
|
|
3984
|
+
// Mark settled before destroy(): close/error events can fire reentrantly.
|
|
3985
|
+
settle();
|
|
3986
|
+
if (!req.destroyed)
|
|
3987
|
+
req.destroy();
|
|
3988
|
+
if (upstreamReq && !upstreamReq.destroyed)
|
|
3989
|
+
upstreamReq.destroy();
|
|
3990
|
+
if (activeUpstreamRes && !activeUpstreamRes.destroyed) {
|
|
3991
|
+
activeUpstreamRes.destroy();
|
|
3992
|
+
}
|
|
3993
|
+
if (!res.destroyed)
|
|
3994
|
+
res.destroy();
|
|
3995
|
+
if (upstreamSocket && !upstreamSocket.destroyed)
|
|
3996
|
+
upstreamSocket.destroy();
|
|
3997
|
+
if (!downstreamSocket.destroyed)
|
|
3998
|
+
downstreamSocket.destroy();
|
|
3999
|
+
};
|
|
4000
|
+
const onIncomingEnd = () => {
|
|
4001
|
+
incomingUploadEnded = true;
|
|
4002
|
+
settleIfComplete();
|
|
4003
|
+
};
|
|
4004
|
+
const onIncomingClose = () => {
|
|
4005
|
+
if (settled)
|
|
4006
|
+
return;
|
|
4007
|
+
if (req.complete) {
|
|
4008
|
+
incomingUploadEnded = true;
|
|
4009
|
+
settleIfComplete();
|
|
4010
|
+
}
|
|
4011
|
+
else {
|
|
4012
|
+
tearDown();
|
|
4013
|
+
}
|
|
4014
|
+
};
|
|
4015
|
+
const onDownstreamFinish = () => {
|
|
4016
|
+
downstreamFinished = true;
|
|
4017
|
+
if (pendingErrorResponse)
|
|
4018
|
+
tearDown();
|
|
4019
|
+
else
|
|
4020
|
+
settleIfComplete();
|
|
4021
|
+
};
|
|
4022
|
+
const onResponseClose = () => {
|
|
4023
|
+
if (settled)
|
|
4024
|
+
return;
|
|
4025
|
+
if (res.writableFinished) {
|
|
4026
|
+
downstreamFinished = true;
|
|
4027
|
+
if (pendingErrorResponse)
|
|
4028
|
+
tearDown();
|
|
4029
|
+
else
|
|
4030
|
+
settleIfComplete();
|
|
4031
|
+
}
|
|
4032
|
+
else {
|
|
4033
|
+
tearDown();
|
|
4034
|
+
}
|
|
4035
|
+
};
|
|
4036
|
+
const cancelForShutdown = () => {
|
|
4037
|
+
if (settled || shutdownCancelled)
|
|
4038
|
+
return;
|
|
4039
|
+
shutdownCancelled = true;
|
|
4040
|
+
tearDown();
|
|
4041
|
+
};
|
|
4042
|
+
req.once("end", onIncomingEnd);
|
|
4043
|
+
req.once("aborted", tearDown);
|
|
4044
|
+
req.once("error", tearDown);
|
|
4045
|
+
req.once("close", onIncomingClose);
|
|
4046
|
+
res.once("finish", onDownstreamFinish);
|
|
4047
|
+
res.once("error", tearDown);
|
|
4048
|
+
res.once("close", onResponseClose);
|
|
4049
|
+
shutdownSignal.addEventListener("abort", cancelForShutdown, { once: true });
|
|
4050
|
+
if (shutdownSignal.aborted) {
|
|
4051
|
+
cancelForShutdown();
|
|
4052
|
+
return;
|
|
4053
|
+
}
|
|
4054
|
+
upstreamReq = upstream.module.request({
|
|
4055
|
+
host: upstream.host,
|
|
4056
|
+
port: upstream.port,
|
|
4057
|
+
method: req.method,
|
|
4058
|
+
path: req.url,
|
|
4059
|
+
headers,
|
|
4060
|
+
}, (upstreamRes) => {
|
|
4061
|
+
if (settled) {
|
|
4062
|
+
upstreamRes.destroy();
|
|
4063
|
+
return;
|
|
4064
|
+
}
|
|
4065
|
+
activeUpstreamRes = upstreamRes;
|
|
4066
|
+
const onUpstreamResponseEnd = () => {
|
|
4067
|
+
upstreamResponseEnded = true;
|
|
4068
|
+
settleIfComplete();
|
|
4069
|
+
};
|
|
4070
|
+
const onUpstreamResponseClose = () => {
|
|
4071
|
+
if (!settled && !upstreamResponseEnded)
|
|
4072
|
+
tearDown();
|
|
4073
|
+
};
|
|
4074
|
+
upstreamRes.once("end", onUpstreamResponseEnd);
|
|
4075
|
+
upstreamRes.once("error", tearDown);
|
|
4076
|
+
upstreamRes.once("aborted", tearDown);
|
|
4077
|
+
upstreamRes.once("close", onUpstreamResponseClose);
|
|
4078
|
+
try {
|
|
4079
|
+
res.writeHead(upstreamRes.statusCode ?? 502, forwardableResponseHeaders(upstreamRes));
|
|
4080
|
+
}
|
|
4081
|
+
catch {
|
|
4082
|
+
tearDown();
|
|
4083
|
+
return;
|
|
4084
|
+
}
|
|
4085
|
+
upstreamRes.pipe(res);
|
|
4086
|
+
});
|
|
4087
|
+
upstreamReq.once("finish", () => {
|
|
4088
|
+
upstreamUploadFinished = true;
|
|
4089
|
+
settleIfComplete();
|
|
4090
|
+
});
|
|
4091
|
+
upstreamReq.once("close", () => {
|
|
4092
|
+
if (!settled && !upstreamUploadFinished && !pendingErrorResponse) {
|
|
4093
|
+
tearDown();
|
|
4094
|
+
}
|
|
4095
|
+
});
|
|
4096
|
+
upstreamReq.on("error", (err) => {
|
|
4097
|
+
if (settled)
|
|
4098
|
+
return;
|
|
4099
|
+
if (shutdownCancelled || res.headersSent || res.destroyed) {
|
|
4100
|
+
tearDown();
|
|
4101
|
+
return;
|
|
4102
|
+
}
|
|
4103
|
+
// Preserve the existing Anthropic-shaped 502 when no upstream bytes
|
|
4104
|
+
// were committed. Ownership remains until that response flushes, then
|
|
4105
|
+
// the still-open incoming upload/socket is torn down as one exchange.
|
|
4106
|
+
pendingErrorResponse = true;
|
|
4107
|
+
if (upstreamReq)
|
|
4108
|
+
req.unpipe(upstreamReq);
|
|
4109
|
+
activeUpstreamRes?.unpipe(res);
|
|
4110
|
+
sendAnthropicError(res, `upstream connection failed: ${err.message}`);
|
|
4111
|
+
if (res.writableFinished)
|
|
4112
|
+
onDownstreamFinish();
|
|
4113
|
+
});
|
|
4114
|
+
try {
|
|
4115
|
+
req.pipe(upstreamReq);
|
|
4116
|
+
}
|
|
4117
|
+
catch {
|
|
4118
|
+
tearDown();
|
|
4119
|
+
}
|
|
4120
|
+
});
|
|
4121
|
+
}
|
|
4122
|
+
function forwardableRequestHeaders(req) {
|
|
4123
|
+
const out = {};
|
|
4124
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
4125
|
+
if (SKIP_REQUEST_HEADERS.has(key.toLowerCase()))
|
|
4126
|
+
continue;
|
|
4127
|
+
if (value === undefined)
|
|
4128
|
+
continue;
|
|
4129
|
+
out[key] = Array.isArray(value) ? value.join(", ") : value;
|
|
4130
|
+
}
|
|
4131
|
+
return out;
|
|
4132
|
+
}
|
|
4133
|
+
function forwardableResponseHeaders(upstreamRes) {
|
|
4134
|
+
const out = {};
|
|
4135
|
+
for (const [key, value] of Object.entries(upstreamRes.headers)) {
|
|
4136
|
+
if (SKIP_RESPONSE_HEADERS.has(key.toLowerCase()))
|
|
4137
|
+
continue;
|
|
4138
|
+
if (value === undefined)
|
|
4139
|
+
continue;
|
|
4140
|
+
out[key] = value;
|
|
4141
|
+
}
|
|
4142
|
+
return out;
|
|
4143
|
+
}
|
|
4144
|
+
/** Well-formed Anthropic-shaped error body so Claude Code fails fast, not weird. */
|
|
4145
|
+
function sendAnthropicError(res, message) {
|
|
4146
|
+
if (res.headersSent) {
|
|
4147
|
+
res.destroy();
|
|
4148
|
+
return;
|
|
4149
|
+
}
|
|
4150
|
+
const payload = JSON.stringify({
|
|
4151
|
+
type: "error",
|
|
4152
|
+
error: { type: "api_error", message },
|
|
4153
|
+
});
|
|
4154
|
+
res.writeHead(502, {
|
|
4155
|
+
"content-type": "application/json",
|
|
4156
|
+
"content-length": String(Buffer.byteLength(payload)),
|
|
4157
|
+
});
|
|
4158
|
+
res.end(payload);
|
|
4159
|
+
}
|
|
4160
|
+
let captureCounter = 0;
|
|
4161
|
+
/** Test-only diagnostics: dump forwarded bodies for smoke-test inspection. */
|
|
4162
|
+
function capture(opts, kind, body) {
|
|
4163
|
+
const dir = opts.captureDir;
|
|
4164
|
+
if (!dir)
|
|
4165
|
+
return;
|
|
4166
|
+
try {
|
|
4167
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
4168
|
+
// mkdir does not tighten a directory reused from an earlier run.
|
|
4169
|
+
chmodSync(dir, 0o700);
|
|
4170
|
+
const name = `${String(++captureCounter).padStart(4, "0")}-${kind}-${randomBytes(8).toString("hex")}.json`;
|
|
4171
|
+
// Never reuse an existing file (which may have permissive old modes).
|
|
4172
|
+
writeFileSync(join(dir, name), body, { mode: 0o600, flag: "wx" });
|
|
4173
|
+
}
|
|
4174
|
+
catch {
|
|
4175
|
+
// diagnostics only — never break the proxy path
|
|
4176
|
+
}
|
|
4177
|
+
}
|
|
4178
|
+
function readBody(req) {
|
|
4179
|
+
return readAll(req);
|
|
4180
|
+
}
|
|
4181
|
+
const OBSERVABLE_ENCODINGS = new Set(["gzip", "br", "deflate", "identity"]);
|
|
4182
|
+
/**
|
|
4183
|
+
* Restrict a client Accept-Encoding value to codings the passive observer can
|
|
4184
|
+
* decode (see createObservationDecoder/decodeForObservation). Client tokens
|
|
4185
|
+
* are kept verbatim (q-values included) so negotiation semantics survive —
|
|
4186
|
+
* except q=0 tokens, which the client explicitly refuses and so cannot count
|
|
4187
|
+
* as an acceptable coding; an absent header means "anything", so advertise
|
|
4188
|
+
* the full supported set, and if nothing supported and acceptable remains
|
|
4189
|
+
* fall back to identity, which every client accepts.
|
|
4190
|
+
*/
|
|
4191
|
+
const REFUSED_Q_ZERO = /;\s*q\s*=\s*0(?:\.0{0,3})?\s*(?:;|$)/i;
|
|
4192
|
+
function observableAcceptEncoding(clientValue) {
|
|
4193
|
+
if (clientValue === undefined)
|
|
4194
|
+
return "gzip, br, deflate";
|
|
4195
|
+
const kept = clientValue
|
|
4196
|
+
.split(",")
|
|
4197
|
+
.map((token) => token.trim())
|
|
4198
|
+
.filter((token) => OBSERVABLE_ENCODINGS.has(token.split(";", 1)[0].trim().toLowerCase()) &&
|
|
4199
|
+
!REFUSED_Q_ZERO.test(token));
|
|
4200
|
+
return kept.length > 0 ? kept.join(", ") : "identity";
|
|
4201
|
+
}
|
|
4202
|
+
/** Incremental decoder used only to observe a copy of encoded SSE bytes. */
|
|
4203
|
+
function createObservationDecoder(encoding) {
|
|
4204
|
+
if (encoding === "gzip")
|
|
4205
|
+
return createGunzip();
|
|
4206
|
+
if (encoding === "br")
|
|
4207
|
+
return createBrotliDecompress();
|
|
4208
|
+
if (encoding === "deflate")
|
|
4209
|
+
return createInflate();
|
|
4210
|
+
return null;
|
|
4211
|
+
}
|
|
4212
|
+
/** Decode a response copy for diagnostics without ever touching forwarded bytes. */
|
|
4213
|
+
function decodeForObservation(body, encoding) {
|
|
4214
|
+
try {
|
|
4215
|
+
if (!encoding || encoding === "identity")
|
|
4216
|
+
return body;
|
|
4217
|
+
if (encoding === "gzip")
|
|
4218
|
+
return gunzipSync(body);
|
|
4219
|
+
if (encoding === "br")
|
|
4220
|
+
return brotliDecompressSync(body);
|
|
4221
|
+
if (encoding === "deflate")
|
|
4222
|
+
return inflateSync(body);
|
|
4223
|
+
}
|
|
4224
|
+
catch {
|
|
4225
|
+
// Diagnostics only. Unknown/corrupt encodings do not affect proxying.
|
|
4226
|
+
}
|
|
4227
|
+
return null;
|
|
4228
|
+
}
|
|
4229
|
+
function readAll(stream) {
|
|
4230
|
+
return new Promise((resolve, reject) => {
|
|
4231
|
+
const chunks = [];
|
|
4232
|
+
stream.on("data", (c) => chunks.push(c));
|
|
4233
|
+
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
|
4234
|
+
stream.on("error", reject);
|
|
4235
|
+
});
|
|
4236
|
+
}
|
|
4237
|
+
/** Test seam. */
|
|
4238
|
+
export const __testCapCacheBreakpoints = capCacheBreakpoints;
|
|
4239
|
+
export const __testEstimateRequestTokens = estimateRequestTokens;
|
|
4240
|
+
/** Reads message times from the session's transcript, when there is one. */
|
|
4241
|
+
function transcriptTimesFor(opts, sessionId, agentId) {
|
|
4242
|
+
const source = opts.transcriptUsage;
|
|
4243
|
+
if (sessionId === undefined || !source?.timesFor)
|
|
4244
|
+
return undefined;
|
|
4245
|
+
return (messages) => source.timesFor(sessionId, messages, agentId);
|
|
4246
|
+
}
|
|
4247
|
+
//# sourceMappingURL=proxy.js.map
|