claude-code-cache-fix 3.8.0 → 4.0.0
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 +95 -8
- package/README.zh.md +691 -159
- package/bin/claude-via-proxy.mjs +1 -0
- package/bin/install-service.mjs +15 -0
- package/hooks/README.md +36 -0
- package/hooks/examples/worktree-edit-guard.py +93 -0
- package/package.json +2 -1
- package/proxy/extensions/auto-1m-guard.mjs +117 -0
- package/proxy/extensions/cache-telemetry.mjs +20 -3
- package/proxy/extensions/signature-surface-hash.mjs +60 -0
- package/proxy/extensions/thinking-block-sanitize.mjs +233 -19
- package/proxy/pipeline.mjs +22 -1
- package/proxy/server.mjs +44 -2
- package/templates/cache-fix-proxy.service.template +1 -0
- package/templates/com.cnighswonger.cache-fix-proxy.plist.template +1 -0
- package/tools/MANUAL-COMPACT.md +31 -9
- package/tools/manual-compact.sh +17 -11
- package/tools/quota-statusline.sh +4 -2
package/bin/claude-via-proxy.mjs
CHANGED
|
@@ -55,6 +55,7 @@ async function dispatch() {
|
|
|
55
55
|
" CACHE_FIX_PROXY_PORT Port for the proxy server\n" +
|
|
56
56
|
" CACHE_FIX_PROXY_UPSTREAM Upstream URL\n" +
|
|
57
57
|
" CACHE_FIX_DEBUG=1 Verbose proxy logging\n" +
|
|
58
|
+
" CACHE_FIX_HOT_RELOAD=on Enable in-process extension hot-reload (off by default; see #196)\n" +
|
|
58
59
|
" CACHE_FIX_CLAUDE_CMD Override the `claude` command for the wrapper\n",
|
|
59
60
|
);
|
|
60
61
|
return 0;
|
package/bin/install-service.mjs
CHANGED
|
@@ -23,6 +23,11 @@ function getDefaults() {
|
|
|
23
23
|
port: validatePort(process.env.CACHE_FIX_PROXY_PORT || "9801"),
|
|
24
24
|
upstream: process.env.CACHE_FIX_PROXY_UPSTREAM || "",
|
|
25
25
|
debug: process.env.CACHE_FIX_DEBUG || "",
|
|
26
|
+
// Hot-reload is opt-in as of v4.0.0 (#196). Capture from env at install
|
|
27
|
+
// time so the operator can bake `CACHE_FIX_HOT_RELOAD=on` into the
|
|
28
|
+
// generated unit/plist via `CACHE_FIX_HOT_RELOAD=on cache-fix-proxy
|
|
29
|
+
// install-service`. Strict "on" match — anything else renders nothing.
|
|
30
|
+
hotReload: process.env.CACHE_FIX_HOT_RELOAD === "on" ? "on" : "",
|
|
26
31
|
workingDir: resolve(__dirname, ".."),
|
|
27
32
|
};
|
|
28
33
|
}
|
|
@@ -93,6 +98,9 @@ function renderSystemdTemplate(template, vars) {
|
|
|
93
98
|
const debugLine = vars.debug
|
|
94
99
|
? `Environment=CACHE_FIX_DEBUG=${vars.debug}`
|
|
95
100
|
: "";
|
|
101
|
+
const hotReloadLine = vars.hotReload
|
|
102
|
+
? `Environment=CACHE_FIX_HOT_RELOAD=${vars.hotReload}`
|
|
103
|
+
: "";
|
|
96
104
|
// Allow callers to wire a Requires= line (e.g. another service the proxy
|
|
97
105
|
// chains to). Empty string by default so the unit has no extra deps.
|
|
98
106
|
const requiresLine = vars.requires
|
|
@@ -104,6 +112,7 @@ function renderSystemdTemplate(template, vars) {
|
|
|
104
112
|
.replaceAll("{{PORT}}", vars.port)
|
|
105
113
|
.replaceAll("{{UPSTREAM_LINE}}", upstreamLine)
|
|
106
114
|
.replaceAll("{{DEBUG_LINE}}", debugLine)
|
|
115
|
+
.replaceAll("{{HOT_RELOAD_LINE}}", hotReloadLine)
|
|
107
116
|
.replaceAll("{{REQUIRES_LINE}}", requiresLine)
|
|
108
117
|
.replaceAll("{{WORKING_DIR}}", vars.workingDir)
|
|
109
118
|
// Collapse triple newlines from empty optional lines down to single blank
|
|
@@ -117,12 +126,16 @@ function renderLaunchdTemplate(template, vars) {
|
|
|
117
126
|
const debugPlist = vars.debug
|
|
118
127
|
? ` <key>CACHE_FIX_DEBUG</key>\n <string>${vars.debug}</string>`
|
|
119
128
|
: "";
|
|
129
|
+
const hotReloadPlist = vars.hotReload
|
|
130
|
+
? ` <key>CACHE_FIX_HOT_RELOAD</key>\n <string>${vars.hotReload}</string>`
|
|
131
|
+
: "";
|
|
120
132
|
return template
|
|
121
133
|
.replaceAll("{{NODE}}", vars.node)
|
|
122
134
|
.replaceAll("{{SERVER_PATH}}", vars.serverPath)
|
|
123
135
|
.replaceAll("{{PORT}}", vars.port)
|
|
124
136
|
.replaceAll("{{UPSTREAM_PLIST}}", upstreamPlist)
|
|
125
137
|
.replaceAll("{{DEBUG_PLIST}}", debugPlist)
|
|
138
|
+
.replaceAll("{{HOT_RELOAD_PLIST}}", hotReloadPlist)
|
|
126
139
|
.replaceAll("{{WORKING_DIR}}", vars.workingDir)
|
|
127
140
|
.replaceAll("{{LOG_DIR}}", vars.logDir)
|
|
128
141
|
.replace(/\n\n+/g, "\n");
|
|
@@ -176,6 +189,7 @@ async function installSystemd({ paths, defaults, force = false } = {}) {
|
|
|
176
189
|
port: defaults.port,
|
|
177
190
|
upstream: defaults.upstream,
|
|
178
191
|
debug: defaults.debug,
|
|
192
|
+
hotReload: defaults.hotReload,
|
|
179
193
|
workingDir: defaults.workingDir,
|
|
180
194
|
requires: "",
|
|
181
195
|
});
|
|
@@ -275,6 +289,7 @@ async function installLaunchd({ paths, defaults, force = false } = {}) {
|
|
|
275
289
|
port: defaults.port,
|
|
276
290
|
upstream: defaults.upstream,
|
|
277
291
|
debug: defaults.debug,
|
|
292
|
+
hotReload: defaults.hotReload,
|
|
278
293
|
workingDir: defaults.workingDir,
|
|
279
294
|
logDir: paths.logDir,
|
|
280
295
|
});
|
package/hooks/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# cache-fix hook examples
|
|
2
|
+
|
|
3
|
+
Standalone `PreToolUse` / `PostToolUse` / `SessionStart` hook scripts that address specific Claude Code behaviors. These are **examples** — you install them by pointing at them from your own `~/.claude/settings.json` (or per-project `.claude/settings.json`). cache-fix does not register them automatically.
|
|
4
|
+
|
|
5
|
+
Independent of the proxy. Hooks run client-side via CC's hooks contract; they don't touch the API request path.
|
|
6
|
+
|
|
7
|
+
## Available examples
|
|
8
|
+
|
|
9
|
+
| Script | Event | Purpose | Docs |
|
|
10
|
+
|---|---|---|---|
|
|
11
|
+
| `examples/worktree-edit-guard.py` | `PreToolUse` | Block `Edit`/`Write`/`MultiEdit`/`NotebookEdit` calls whose target path falls outside the active git worktree root. Addresses [CC#59628](https://github.com/anthropics/claude-code/issues/59628). | [`docs/hooks/worktree-edit-guard.md`](../docs/hooks/worktree-edit-guard.md) |
|
|
12
|
+
|
|
13
|
+
## Installing a hook
|
|
14
|
+
|
|
15
|
+
Each script's docs page has its own settings.json snippet. The general shape:
|
|
16
|
+
|
|
17
|
+
```jsonc
|
|
18
|
+
{
|
|
19
|
+
"hooks": {
|
|
20
|
+
"<EventName>": [
|
|
21
|
+
{
|
|
22
|
+
"matcher": "<ToolName1>|<ToolName2>",
|
|
23
|
+
"hooks": [
|
|
24
|
+
{ "type": "command", "command": "/abs/path/to/hooks/examples/<script>" }
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The `command` field must be an absolute path per CC's hooks contract. Make sure the script is executable.
|
|
33
|
+
|
|
34
|
+
## CC hooks reference
|
|
35
|
+
|
|
36
|
+
https://code.claude.com/docs/en/hooks — exit-code semantics, structured output schema, matcher patterns, the full event taxonomy.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook: refuse Edit/Write/MultiEdit/NotebookEdit calls whose
|
|
3
|
+
target path falls outside the active git worktree root.
|
|
4
|
+
|
|
5
|
+
Addresses anthropics/claude-code#59628 (worktree sessions can corrupt the
|
|
6
|
+
parent main checkout). See docs/hooks/worktree-edit-guard.md for install.
|
|
7
|
+
|
|
8
|
+
Exit codes (per CC PreToolUse hook contract):
|
|
9
|
+
0 pass-through (allow)
|
|
10
|
+
2 block (CC feeds stderr back to the agent)
|
|
11
|
+
Posture: environmental failures fail open (exit 0); protocol-shape failures
|
|
12
|
+
(missing expected path field on an in-scope tool) fail closed (exit 2)."""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
IN_SCOPE = {"Edit", "Write", "MultiEdit", "NotebookEdit"}
|
|
20
|
+
PATH_FIELD = {"Edit": "file_path", "Write": "file_path",
|
|
21
|
+
"MultiEdit": "file_path", "NotebookEdit": "notebook_path"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def git(*args, cwd):
|
|
25
|
+
"""Run git; return stripped stdout on success, None on any failure."""
|
|
26
|
+
try:
|
|
27
|
+
r = subprocess.run(("git",) + args, cwd=cwd, timeout=2,
|
|
28
|
+
capture_output=True, text=True, check=False)
|
|
29
|
+
return r.stdout.strip() if r.returncode == 0 else None
|
|
30
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def worktree_root(cwd):
|
|
35
|
+
"""Return the worktree root if cwd is inside a linked worktree, else None.
|
|
36
|
+
|
|
37
|
+
Detection: realpath-equality of --git-dir and --git-common-dir. They are
|
|
38
|
+
equal in a regular checkout (from any depth) and differ inside a linked
|
|
39
|
+
worktree. Compare realpaths because --git-common-dir returns paths
|
|
40
|
+
relative to cwd, so raw string compare breaks below the repo root."""
|
|
41
|
+
top = git("rev-parse", "--show-toplevel", cwd=cwd)
|
|
42
|
+
gd = git("rev-parse", "--git-dir", cwd=cwd)
|
|
43
|
+
gcd = git("rev-parse", "--git-common-dir", cwd=cwd)
|
|
44
|
+
if not (top and gd and gcd):
|
|
45
|
+
return None
|
|
46
|
+
if os.path.realpath(os.path.join(cwd, gd)) == os.path.realpath(os.path.join(cwd, gcd)):
|
|
47
|
+
return None
|
|
48
|
+
return os.path.realpath(top)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def resolved_target(target):
|
|
52
|
+
"""Realpath the target. If the target exists (including as a broken
|
|
53
|
+
symlink), realpath it directly so a target that IS a symlink resolves
|
|
54
|
+
to its destination (not back to itself). If it doesn't exist, fall
|
|
55
|
+
back to realpath(parent_dir) + basename so a symlinked PARENT still
|
|
56
|
+
gets caught even when the leaf will be created by the tool."""
|
|
57
|
+
if os.path.lexists(target):
|
|
58
|
+
return os.path.realpath(target)
|
|
59
|
+
return os.path.join(os.path.realpath(os.path.dirname(target)),
|
|
60
|
+
os.path.basename(target))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def main():
|
|
64
|
+
try:
|
|
65
|
+
payload = json.load(sys.stdin)
|
|
66
|
+
except (json.JSONDecodeError, ValueError):
|
|
67
|
+
return 0 # fail-open: malformed input is an environmental fault
|
|
68
|
+
tool = payload.get("tool_name")
|
|
69
|
+
if tool not in IN_SCOPE:
|
|
70
|
+
return 0
|
|
71
|
+
field = PATH_FIELD[tool]
|
|
72
|
+
target = (payload.get("tool_input") or {}).get(field)
|
|
73
|
+
if not isinstance(target, str) or not target:
|
|
74
|
+
sys.stderr.write(f"worktree-edit-guard: refusing {tool} — "
|
|
75
|
+
f"missing tool_input.{field}.\n")
|
|
76
|
+
return 2 # fail-closed: protocol-shape mismatch
|
|
77
|
+
cwd = payload.get("cwd") or os.getcwd()
|
|
78
|
+
root = worktree_root(cwd)
|
|
79
|
+
if root is None:
|
|
80
|
+
return 0 # not in a linked worktree; nothing to enforce
|
|
81
|
+
if not os.path.isabs(target):
|
|
82
|
+
target = os.path.join(cwd, target)
|
|
83
|
+
abs_target = resolved_target(target)
|
|
84
|
+
if abs_target == root or abs_target.startswith(root + os.sep):
|
|
85
|
+
return 0
|
|
86
|
+
sys.stderr.write(f"worktree-edit-guard: refusing {tool} on {abs_target} — "
|
|
87
|
+
f"outside worktree {root}. Use a path inside the worktree, "
|
|
88
|
+
f"or disable this hook in settings.json.\n")
|
|
89
|
+
return 2
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
if __name__ == "__main__":
|
|
93
|
+
sys.exit(main())
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-cache-fix",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
4
|
"description": "Cache optimization proxy and interceptor for Claude Code. Fixes prompt cache bugs, stabilizes prefix, reduces quota burn.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"preload.mjs",
|
|
16
16
|
"postinstall.js",
|
|
17
17
|
"tools/",
|
|
18
|
+
"hooks/",
|
|
18
19
|
"claude-fixed.bat",
|
|
19
20
|
"proxy/",
|
|
20
21
|
"bin/",
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// auto-1m-guard — detect/warn/strip the 1M-context beta token on outbound
|
|
2
|
+
// requests. Addresses anthropics/claude-code#64919 (VS Code Extension forcing
|
|
3
|
+
// 1M context on Pro Plan).
|
|
4
|
+
//
|
|
5
|
+
// Binary-walk (CC v2.1.148 / v2.1.161 — same code body, names churned):
|
|
6
|
+
// sL→kJ: function strips /\[(1|2)m\]/gi from the model string
|
|
7
|
+
// W2→bZ: gates 1M-beta inclusion on /\[1m\]/i.test(model)
|
|
8
|
+
// xKH→E9H: kill switch keys off CLAUDE_CODE_DISABLE_1M_CONTEXT
|
|
9
|
+
// CC always applies the sanitizer at messages.create call sites:
|
|
10
|
+
// messages.create({...J, model: kJ(J.model)})
|
|
11
|
+
// So req.body.model NEVER carries [1m] on the wire — the proxy-visible
|
|
12
|
+
// signal is the anthropic-beta REQUEST HEADER carrying context-1m-2025-08-07.
|
|
13
|
+
//
|
|
14
|
+
// Three modes (env: CACHE_FIX_AUTO_1M_GUARD):
|
|
15
|
+
// off no-op
|
|
16
|
+
// warn (default) stash _auto1mGuard annotation + stderr line; no mutation
|
|
17
|
+
// strip also remove context-1m-2025-08-07 from the anthropic-beta header
|
|
18
|
+
//
|
|
19
|
+
// Order 520: after ttl-management (500) and before thinking-block-sanitize
|
|
20
|
+
// (550) / session-health (590) / cache-telemetry (600). The stashed flat
|
|
21
|
+
// object at ctx.meta._auto1mGuard is spread top-level into the per-session
|
|
22
|
+
// JSON by cache-telemetry, matching the _sessionHealth / _thinkingSanitize
|
|
23
|
+
// pattern.
|
|
24
|
+
//
|
|
25
|
+
// See docs/directives/proxy-auto-1m-guard.md.
|
|
26
|
+
|
|
27
|
+
const BETA_TOKEN_1M = "context-1m-2025-08-07";
|
|
28
|
+
const HEADER_NAME = "anthropic-beta";
|
|
29
|
+
const ADVICE =
|
|
30
|
+
"Outbound request carries the context-1m-2025-08-07 beta header, which enables 1M context. " +
|
|
31
|
+
"On Pro plans this consumes overage credits immediately. To prevent CC from auto-selecting 1M: " +
|
|
32
|
+
"set CLAUDE_CODE_DISABLE_1M_CONTEXT=1 in your env, or use /model with a non-[1m] model variant " +
|
|
33
|
+
"in-session. Strip mode (CACHE_FIX_AUTO_1M_GUARD=strip) intercepts the header at the proxy.";
|
|
34
|
+
|
|
35
|
+
function modeFromEnv() {
|
|
36
|
+
const v = process.env.CACHE_FIX_AUTO_1M_GUARD;
|
|
37
|
+
if (v === "off" || v === "strip") return v;
|
|
38
|
+
return "warn";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Case-insensitive read of the anthropic-beta header. Mirrors
|
|
42
|
+
// upstream-change-detection.mjs:200-207. Returns { key, raw } where key is
|
|
43
|
+
// the actual property name found (so the rewrite can replace in-place),
|
|
44
|
+
// or null if absent.
|
|
45
|
+
export function findBetaHeader(headers) {
|
|
46
|
+
if (!headers) return null;
|
|
47
|
+
for (const k of Object.keys(headers)) {
|
|
48
|
+
if (k.toLowerCase() === HEADER_NAME) {
|
|
49
|
+
return { key: k, raw: headers[k] };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Parse the comma-separated header value into a trimmed token array.
|
|
56
|
+
// Tolerates string or array input.
|
|
57
|
+
export function parseBetaTokens(raw) {
|
|
58
|
+
if (!raw) return [];
|
|
59
|
+
if (Array.isArray(raw)) return raw.map(String).map((s) => s.trim()).filter(Boolean);
|
|
60
|
+
if (typeof raw === "string") return raw.split(",").map((s) => s.trim()).filter(Boolean);
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Pure planner: returns { detected, stripped, tokensAfter } given the
|
|
65
|
+
// parsed token array. Strip removes ALL occurrences (defensive against
|
|
66
|
+
// duplicates introduced by intermediaries).
|
|
67
|
+
export function planSanitizeBetaHeader(tokens, mode) {
|
|
68
|
+
const detected = tokens.includes(BETA_TOKEN_1M);
|
|
69
|
+
if (!detected || mode !== "strip") {
|
|
70
|
+
return { detected, stripped: false, tokensAfter: tokens };
|
|
71
|
+
}
|
|
72
|
+
const tokensAfter = tokens.filter((t) => t !== BETA_TOKEN_1M);
|
|
73
|
+
return { detected, stripped: true, tokensAfter };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Rejoin tokens with the CC-canonical ", " separator. Empty array → "".
|
|
77
|
+
export function joinBetaTokens(tokens) {
|
|
78
|
+
return tokens.join(", ");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export default {
|
|
82
|
+
name: "auto-1m-guard",
|
|
83
|
+
description:
|
|
84
|
+
"Detect (warn) or remove (strip) the context-1m-2025-08-07 token from the outbound anthropic-beta header. " +
|
|
85
|
+
"Addresses CC#64919 (VS Code Extension forcing 1M context on Pro Plan). " +
|
|
86
|
+
"Modes via CACHE_FIX_AUTO_1M_GUARD: off | warn (default) | strip.",
|
|
87
|
+
order: 520,
|
|
88
|
+
|
|
89
|
+
async onRequest(ctx) {
|
|
90
|
+
const mode = modeFromEnv();
|
|
91
|
+
if (mode === "off") return;
|
|
92
|
+
|
|
93
|
+
const found = findBetaHeader(ctx.headers);
|
|
94
|
+
if (!found) return;
|
|
95
|
+
|
|
96
|
+
const tokens = parseBetaTokens(found.raw);
|
|
97
|
+
const plan = planSanitizeBetaHeader(tokens, mode);
|
|
98
|
+
if (!plan.detected) return;
|
|
99
|
+
|
|
100
|
+
if (plan.stripped) {
|
|
101
|
+
ctx.headers[found.key] = joinBetaTokens(plan.tokensAfter);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
ctx.meta._auto1mGuard = {
|
|
105
|
+
auto_1m_detected: true,
|
|
106
|
+
auto_1m_action: plan.stripped ? "stripped" : "warn",
|
|
107
|
+
auto_1m_advice: ADVICE,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
process.stderr.write(
|
|
111
|
+
`[auto-1m-guard] ${BETA_TOKEN_1M} detected in outbound betas` +
|
|
112
|
+
(plan.stripped ? " — stripped" : "") +
|
|
113
|
+
` — see CACHE_FIX_AUTO_1M_GUARD=strip to intercept. ` +
|
|
114
|
+
`Set CLAUDE_CODE_DISABLE_1M_CONTEXT=1 to prevent CC from sending it.\n`,
|
|
115
|
+
);
|
|
116
|
+
},
|
|
117
|
+
};
|
|
@@ -56,7 +56,12 @@ export function sessionFilePath(rawId) {
|
|
|
56
56
|
return join(paths().sessionsDir, `${sessionFilename(rawId)}.json`);
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
// Exported so sibling extensions can read the canonical session id from
|
|
60
|
+
// REQUEST headers at their own onRequest time — they can't rely on
|
|
61
|
+
// ctx.meta._sessionId being set, because this writer's onRequest is the
|
|
62
|
+
// thing that populates it (and runs at order 600, after most extensions).
|
|
63
|
+
// thinking-block-sanitize v2 (order 550) uses this for the same reason.
|
|
64
|
+
export function resolveSessionId(headers) {
|
|
60
65
|
if (!headers) return null;
|
|
61
66
|
const sid =
|
|
62
67
|
headers["x-claude-code-session-id"] ||
|
|
@@ -233,9 +238,21 @@ export default {
|
|
|
233
238
|
// 590, stashes these before this writer runs). Optional — absent if
|
|
234
239
|
// that extension is disabled or produced nothing this request.
|
|
235
240
|
...(ctx.meta._sessionHealth || {}),
|
|
236
|
-
// Additive thinking-block-sanitize drop count (order 550
|
|
237
|
-
//
|
|
241
|
+
// Additive thinking-block-sanitize drop count (order 550). On by
|
|
242
|
+
// default since v4.0.0; present (possibly with thinking_blocks_dropped:0)
|
|
243
|
+
// whenever sanitize ran. Absent when CACHE_FIX_THINKING_SANITIZE=off
|
|
244
|
+
// or when the extension returned early before reaching the planner
|
|
245
|
+
// (e.g., body.messages not an array).
|
|
238
246
|
...(ctx.meta._thinkingSanitize || {}),
|
|
247
|
+
// Additive thinking-block-sanitize v2 fields (order 550, opt-in via
|
|
248
|
+
// CACHE_FIX_THINKING_SANITIZE=v2). Optional — absent unless v2 is
|
|
249
|
+
// enabled. Keys: thinking_blocks_dropped_v2 / tools_hash_baseline.
|
|
250
|
+
...(ctx.meta._thinkingSanitizeV2 || {}),
|
|
251
|
+
// Additive auto-1m-guard annotation (order 520). Optional — absent
|
|
252
|
+
// unless the outbound request carried context-1m-2025-08-07 and the
|
|
253
|
+
// mode wasn't off. Keys: auto_1m_detected / auto_1m_action /
|
|
254
|
+
// auto_1m_advice.
|
|
255
|
+
...(ctx.meta._auto1mGuard || {}),
|
|
239
256
|
timestamp,
|
|
240
257
|
session_id: rawSid,
|
|
241
258
|
},
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// signature-surface-hash — hash helper for thinking-block-sanitize v2.
|
|
2
|
+
//
|
|
3
|
+
// Computes a deterministic 16-hex-char fingerprint of the inputs that
|
|
4
|
+
// participate in the API's thinking-block signature: the tools surface,
|
|
5
|
+
// and (forward-compat) optionally the system block or anthropic-beta
|
|
6
|
+
// header value.
|
|
7
|
+
//
|
|
8
|
+
// v2 only passes `{ tools }`. The signature is left forward-compatible so
|
|
9
|
+
// a future v3 directive can extend coverage without renaming this helper.
|
|
10
|
+
//
|
|
11
|
+
// Canonicalization rules (per directive proxy-thinking-block-sanitize-v2.md):
|
|
12
|
+
// - Each tool object: recursive stable JSON stringify with recursive key
|
|
13
|
+
// sorting at every nesting level. Nested JSON-schema objects (in
|
|
14
|
+
// input_schema, parameters, etc.) have their own keys, which also
|
|
15
|
+
// sort stably.
|
|
16
|
+
// - Preserve tools[] array order. Reordering tools changes which slot
|
|
17
|
+
// which tool occupies in the API's view; the hash MUST reflect that.
|
|
18
|
+
// (Note: sort-stabilization at order 200 currently locks the array
|
|
19
|
+
// order before v2 fires, so this rule is forward-compatibility against
|
|
20
|
+
// any future change in upstream ordering.)
|
|
21
|
+
// - Sentinel for empty/absent: if tools is undefined, null, or [], the
|
|
22
|
+
// hash input is the literal string "none". Rules out collision with
|
|
23
|
+
// other empty-shaped inputs in a future extension.
|
|
24
|
+
//
|
|
25
|
+
// Output: sha256(canonical_input).slice(0, 16) — 16 hex chars matches the
|
|
26
|
+
// existing _sessionHealth / _thinkingSanitize precedent for in-JSON identifiers.
|
|
27
|
+
|
|
28
|
+
import { createHash } from "node:crypto";
|
|
29
|
+
|
|
30
|
+
// Recursive stable stringify: object keys sort, arrays preserve order,
|
|
31
|
+
// primitives go through JSON.stringify as-is. Handles nested objects and
|
|
32
|
+
// arrays to arbitrary depth.
|
|
33
|
+
export function canonicalStringify(value) {
|
|
34
|
+
if (value === null || typeof value !== "object") {
|
|
35
|
+
return JSON.stringify(value);
|
|
36
|
+
}
|
|
37
|
+
if (Array.isArray(value)) {
|
|
38
|
+
return "[" + value.map(canonicalStringify).join(",") + "]";
|
|
39
|
+
}
|
|
40
|
+
const keys = Object.keys(value).sort();
|
|
41
|
+
const parts = keys.map((k) => JSON.stringify(k) + ":" + canonicalStringify(value[k]));
|
|
42
|
+
return "{" + parts.join(",") + "}";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Compute the signature-surface hash. v2 passes only { tools }; system and
|
|
46
|
+
// anthropic_beta are reserved for future versions.
|
|
47
|
+
export function computeSignatureSurfaceHash({ tools, system, anthropic_beta } = {}) {
|
|
48
|
+
// Empty/absent tools → "none" sentinel (not the canonical-stringify of [],
|
|
49
|
+
// which would be "[]" and could collide with other empty-shaped inputs).
|
|
50
|
+
const toolsPart =
|
|
51
|
+
tools == null || (Array.isArray(tools) && tools.length === 0)
|
|
52
|
+
? "none"
|
|
53
|
+
: canonicalStringify(tools);
|
|
54
|
+
// Reserved inputs — passed by future versions; v2 always omits them, so
|
|
55
|
+
// they contribute nothing to the hash today. Kept in the signature so
|
|
56
|
+
// existing call sites don't need to change when v3 adds them.
|
|
57
|
+
void system;
|
|
58
|
+
void anthropic_beta;
|
|
59
|
+
return createHash("sha256").update(toolsPart).digest("hex").slice(0, 16);
|
|
60
|
+
}
|