Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
9a9499e
directive: prior-turn signed-but-empty thinking-block strip (mitigati…
vsits-team-lead-agent[bot] May 28, 2026
970d6f0
review: request changes on PR 162 thinking sanitize directive
May 28, 2026
217dd77
directive(reframe): omitted prior-turn thinking is optional history, …
vsits-team-lead-agent[bot] May 28, 2026
3f2eec5
directive: #162 ships opt-in for v1 (Chris release-safety call)
May 28, 2026
55429ca
docs: add PR 162 thinking-sanitize directive rereview
May 28, 2026
754ea03
directive: scope #162 v1 to `thinking` only; defer redacted_thinking …
May 28, 2026
2e3cb4a
docs: approve PR #162 directive review
May 28, 2026
c3f9108
directive(thinking-sanitize): resolve Open Question 1 + Behavior #3 f…
vsits-team-lead-agent[bot] May 29, 2026
3f42a53
docs(review): codex re-review thinking sanitize directive
May 29, 2026
b6ccd64
feat(thinking-sanitize): opt-in request-path mitigation for the think…
vsits-team-lead-agent[bot] May 29, 2026
f228f9b
docs(review): codex implementation review for PR 162
May 29, 2026
ac4b110
fix(thinking-sanitize): match terminal tool_use.id to pair the contin…
vsits-team-lead-agent[bot] May 29, 2026
84dbb0c
docs(review): approve PR 162 implementation rereview
May 29, 2026
6051f85
docs(thinking-sanitize): correct user-side env lever + carry the valu…
vsits-team-lead-agent[bot] May 29, 2026
d915953
docs(thinking-sanitize): cite public #63147 anchor for the env/trigge…
vsits-team-lead-agent[bot] May 29, 2026
f325ab0
docs(review): codex docs-correction re-confirm for PR 162
May 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@

**Token-gated warning.** `thinking_desync_risk` is computed from `context_tokens` against `CACHE_FIX_THINKING_RISK_HIGH_TOKENS` (default `340000`, just under the observed ~382K trip) and `CACHE_FIX_THINKING_RISK_WARN_TOKENS` (default `250000`). On first crossing into `high`, a one-time content-free stderr line is emitted. Block-count is recorded but does not yet gate the warning (calibrated fast-follow). `CACHE_FIX_THINKING_RISK=off` suppresses the warning signal (stderr line + risk field) while raw count telemetry keeps recording.

- **thinking-block-sanitize mitigation extension (#162), opt-in.** A new request-path extension (`proxy/extensions/thinking-block-sanitize.mjs`, order 550) that drops the *omitted* (`thinking:""` + signature) extended-thinking blocks CC re-sends on history-replay paths, before the request is forwarded — heading off the permanent `400 ... thinking blocks ... cannot be modified` wedge (upstream `anthropics/claude-code#63147`). This is the *mitigate* half (the *warn-before* half is session-health above). **Opt-in:** only runs when `CACHE_FIX_THINKING_SANITIZE=on` (default off) — it mutates request bodies and full live-coverage validation is pending.

**Turn-selection rule (empirically resolved).** Drops omitted thinking from all prior assistant turns **and** the latest assistant turn — *unless* the latest turn is an active tool-continuation (its last block is a `tool_use` with a following `tool_result`), where the API requires the signed thinking intact and the proxy must not strip it (that case is uncoverable here — no env var both preserves thinking and avoids the wedge; `CLAUDE_CODE_DISABLE_THINKING=1`/`MAX_THINKING_TOKENS=0` stop it only by disabling thinking entirely, `DISABLE_INTERLEAVED_THINKING=1` does not stop the 400, so the answer there is heal/retire). Never touches non-empty thinking; `redacted_thinking` is out of scope for v1 (a full scan of the worst-case wedged transcript found zero). Deterministic and cache-prefix-stable. Emits a per-request `thinking_blocks_dropped` count into the per-session JSON (counts only — never content), via the existing `cache-telemetry` writer.

### Fixed

- **`ttl-management`: never inject a TTL into `thinking` / `redacted_thinking` blocks (#157).** `injectTtl` iterated every block in the request; if a `cache_control: {type: "ephemeral"}` breakpoint landed on a thinking block (possible on Opus 4.7 interleaved-thinking turns), it rewrote the block to add `ttl`, which mutates a signed thinking block — the API rejects that with `400 ... thinking blocks ... cannot be modified`. The injector now skips `thinking`/`redacted_thinking` blocks entirely (the chokepoint covers both the system-block and message-block paths). Defensive hardening: this was not the cause of the 2026-05-28 interleaved-thinking incident (that was CC-side, `anthropics/claude-code#63172`), but it's a real latent mutation path with zero upside to keeping. Regression tests pin the skip and the still-inject-on-non-thinking happy path.
Expand Down
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ That's it. The proxy applies all 7 cache-fix extensions automatically. No wrappe

### What the proxy does

On every `/v1/messages` request, 8 extensions run in order:
On every `/v1/messages` request, 9 extensions run in order (one opt-in):

| Extension | What it fixes |
|-----------|--------------|
Expand All @@ -41,6 +41,7 @@ On every `/v1/messages` request, 8 extensions run in order:
| `cache-control-normalize` | Normalizes cache_control markers across messages |
| `cache-telemetry` | Extracts cache stats from response headers → `~/.claude/quota-status/{account.json,sessions/<id>.json}` |
| `session-health` | Observes per-session thinking-desync risk (context size + thinking-block count) and warns before a session reaches the danger zone. Read-only |
| `thinking-block-sanitize` | Drops omitted (empty-text) thinking blocks to head off the CC thinking-desync `400` (#63147). **Opt-in** (`CACHE_FIX_THINKING_SANITIZE=on`) |

Extensions are hot-reloadable — add, remove, or modify `.mjs` files in `proxy/extensions/` and changes apply to the next request without restarting. Configuration in `proxy/extensions.json`.

Expand Down Expand Up @@ -746,6 +747,18 @@ Token thresholds are anchored to the observed ~382K-token trip with margin; the
| `CACHE_FIX_THINKING_RISK_HIGH_TOKENS` | `340000` | Context-token level at which risk becomes `high` and the one-time stderr warn fires. |
| `CACHE_FIX_THINKING_RISK` | unset (on) | Set to `off` to suppress the warning signal (stderr line + `thinking_desync_risk` field). Raw count telemetry keeps recording. |

## Thinking-block sanitize (proxy mode, opt-in, thinking-desync mitigation)

The *mitigate* half of the thinking-desync response (the *warn-before* half is session-health above). On history-replay paths (resume / `--continue` / auto-compaction / parallel-tool-cancel), Claude Code re-sends prior assistant turns' extended thinking in the **omitted** shape `{ "type":"thinking", "thinking":"", "signature":"<intact>" }`. The API rejects modified thinking in the **latest** assistant message with a permanent `400 … thinking … blocks cannot be modified`, which wedges the session on every subsequent turn (upstream root cause: [anthropics/claude-code#63147](https://github.com/anthropics/claude-code/issues/63147)).

The `thinking-block-sanitize` extension drops those omitted blocks — which the API treats as optional history — from the request before it is forwarded. Empirically-resolved turn-selection rule: drop omitted thinking from **all prior assistant turns and the latest assistant turn, unless the latest turn is an active tool-continuation** (its last block is a `tool_use` answered by a following `tool_result`). In that one case the API requires the signed thinking intact and the proxy cannot restore the emptied text, so it leaves the turn untouched. **No env var both preserves thinking and avoids the wedge for that case:** `CLAUDE_CODE_DISABLE_THINKING=1` / `MAX_THINKING_TOKENS=0` stop the wedge only by disabling thinking entirely (lossy — no reasoning), and `DISABLE_INTERLEAVED_THINKING=1` does *not* stop the `400` — so there the answer is don't-resume + heal/retire the session. That is exactly why the proxy mitigation matters: **it is the only path that preserves reasoning while avoiding the wedge** for the history-replay paths it covers. Non-empty thinking is never touched; `redacted_thinking` is out of scope for v1.

**Opt-in.** v1 ships behind `CACHE_FIX_THINKING_SANITIZE=on` (default off): it mutates request bodies and full live-coverage validation is pending. The transform is deterministic and cache-prefix-stable, and emits a per-request `thinking_blocks_dropped` count into the per-session JSON (counts only — never content) that complements the session-health signal.

| Env var | Default | Purpose |
|---------|---------|---------|
| `CACHE_FIX_THINKING_SANITIZE` | unset (off) | Set to `on` to enable the request-path drop of omitted thinking blocks. Off = no-op (no mutation, no telemetry). |

## System prompt rewrite (preload mode, optional)

The interceptor can rewrite Claude Code's `# Output efficiency` system-prompt section. Disabled by default. Enable with `CACHE_FIX_OUTPUT_EFFICIENCY_REPLACEMENT`. See [docs/output-efficiency-prompts.md](docs/output-efficiency-prompts.md) for the three known prompt variants and usage instructions.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Review: proxy-thinking-block-sanitize directive

Date: 2026-05-28
Reviewed: PR #162 directive (`docs/directives/proxy-thinking-block-sanitize.md`)
Label applied: reviewed-by-codex-agent

## What Is Correct

- The remaining schema blocker is cleared. `redacted_thinking` is no longer part of the active v1 strip rule in either the threat model or behavior section, and the directive now explicitly defers it to Out of scope with the correct opaque `{ "type":"redacted_thinking", "data":"..." }` schema rationale ([directive lines 21, 28, 43](../directives/proxy-thinking-block-sanitize.md)).
- The v1 behavior is now internally consistent: the transform is scoped to prior-turn omitted `thinking` blocks only, non-empty `thinking` stays untouched, the latest assistant message remains protected pending empirical coverage validation, and empty-content assistant messages are dropped rather than rewritten ([directive lines 28-32](../directives/proxy-thinking-block-sanitize.md)).
- The non-functional framing is still sound. `Load-bearing? yes` remains correct for a request-path body mutator, the Chris-review gate is present, the determinism requirement is explicit, and the v1 default-off posture is still the right safety call until Open Question 1 is answered with a real captured repro ([directive lines 18-24, 32, 47-49](../directives/proxy-thinking-block-sanitize.md)).
- Open Question 1 remains correctly framed as the pre-implementation empirical gate: prove whether prior-turn dropping alone clears the latest-message-named 400, and widen only if a captured repro shows the latest completed non-continuation turn also needs stripping. The no-touch boundary for an active tool-continuation latest turn is preserved ([directive lines 30, 47-49](../directives/proxy-thinking-block-sanitize.md)).

## Blockers

None.

## What Needs Attention

- Resolve Open Question 1 against a captured wedged request before implementation locks. Approval here is for directive precision and scope, not for skipping the live coverage check.
- Resolve Open Question 2 toward dropping the now-empty assistant message, which the current behavior section already states. Keep implementation and tests aligned with that choice.
- When implementation starts, keep telemetry counts-only as specified and do not let the request walker expand past the directive's stated small-extension budget.

## Bloat / Non-Functional

None. The directive is now tighter than the previous pass and removes the only remaining schema overreach instead of adding a special-case abstraction.

## Size Baseline

- `docs/directives/proxy-thinking-block-sanitize.md` — 54 LOC — compact directive with a clear v1 boundary; the remaining work is empirical coverage validation, not spec expansion.
- `preload.mjs` — 2881 LOC — existing implementation surface; the directive still sets the right expectation that this should land as a small extension reusing current body-walk patterns.

## Recommendations

- Start implementation only after the captured-request validation in Open Question 1 settles the exact turn-selection rule.
- Keep `redacted_thinking` out of v1 unless a real repro demonstrates it participates in the rejection and a separate schema-accurate rule is added.
- Preserve the opt-in release posture for the first implementation cut.

## Bottom Line

Approve the directive for implementation. The one remaining blocker from the last pass is resolved: `redacted_thinking` is cleanly out of the v1 empty-text predicate, and the spec now hands implementation a precise, internally consistent v1 scope while keeping the load-bearing coverage question as an explicit pre-implementation gate.
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Review: proxy-thinking-block-sanitize directive

Date: 2026-05-28
Reviewed: PR #162 directive (`docs/directives/proxy-thinking-block-sanitize.md`)
Label applied: changes-requested

## What Is Correct

- Blocker 1 is cleared. The directive now explicitly states that the omitted `{"type":"thinking","thinking":"","signature":"..."}` shape is normal, not corruption, and it reframes the transform as dropping prior-turn optional history rather than claiming a uniquely broken wire shape ([directive lines 8-16](../directives/proxy-thinking-block-sanitize.md)).
- The NFR section is still valid: it is present and non-empty, `Load-bearing? yes` is the correct classification for a shared request-path body mutator, and the Chris-review gate remains appropriate for this risk class ([directive lines 18-24](../directives/proxy-thinking-block-sanitize.md)).
- The v1 opt-in posture is the right release-safety call. A request-body mutator with unresolved live-coverage validation should not ship default-on, and the directive now reflects that clearly with `CACHE_FIX_THINKING_SANITIZE=on` defaulting to off ([directive lines 30-32, 46-48](../directives/proxy-thinking-block-sanitize.md)).
- Open Question 1 is the right place to hold the remaining coverage uncertainty. The spec no longer pretends that a prior-turn-only drop is already proven to clear every latest-message-named 400, and the "never touch an active tool-continuation turn" boundary is sound ([directive lines 30, 46-48](../directives/proxy-thinking-block-sanitize.md)).

## Blockers

- `redacted_thinking` is still specified with the wrong predicate. The directive's threat-model and behavior text still groups `redacted_thinking` under the same omitted/empty-text rule as regular `thinking` blocks ([directive lines 21, 28](../directives/proxy-thinking-block-sanitize.md)), but Anthropic's current extended-thinking docs define `redacted_thinking` as a distinct opaque block, `{ "type":"redacted_thinking", "data":"..." }`, and explicitly distinguish it from omitted `thinking` blocks with empty `thinking` text. If `redacted_thinking` is meant to be in scope as optional prior-turn history, it needs its own schema-aware rule and justification; otherwise it should be removed from v1 scope. As written, blocker 2 is not resolved and the directive is still not precise enough to hand to implementation. Source: https://platform.claude.com/docs/en/build-with-claude/extended-thinking

## What Needs Attention

- Resolve Open Question 1 into a concrete turn-selection rule before implementation starts. I agree with the directive's gating posture: validate against a captured wedged request whether dropping prior completed turns is sufficient, and only widen to the latest completed non-continuation turn if the capture proves that is the failing case. Do not generalize this to an active tool-continuation turn.
- Open Question 2 should resolve to dropping the now-empty assistant message, not synthesizing placeholder text. A placeholder mutates conversation bytes and semantics for no benefit, while the proxy is already operating on a wire-format message list rather than transcript node IDs.
- The testing section should regain an explicit `redacted_thinking` case once the rule is corrected, because the current unit list only names omitted `thinking` coverage ([directive lines 52-53](../directives/proxy-thinking-block-sanitize.md)).

## Bloat / Non-Functional

None. The directive is tighter than the prior version, and the remaining problem is rule precision, not size or abstraction.

## Size Baseline

- `docs/directives/proxy-thinking-block-sanitize.md` — 53 LOC — compact directive; the main remaining risk is schema precision around `redacted_thinking` and final turn coverage, not sprawl.
- `preload.mjs` — 2881 LOC — existing implementation surface; the directive still sets the right expectation that this should stay a small extension rather than grow a new subsystem.

## Recommendations

- Remove `redacted_thinking` from the v1 behavior unless you can define a separate, documented predicate for it that matches the actual `{type:"redacted_thinking", data:"..."}` schema.
- Keep v1 opt-in until Open Question 1 is answered with a real captured replay request and the exact turn-selection rule is written into the behavior and testing sections.
- Once coverage is validated, encode the rule explicitly in behavior/tests rather than leaving "latest completed non-continuation" as an implementation-time inference.

## Bottom Line

Changes requested again for directive stage, but the scope is much narrower now. The prior "normal omitted shape vs corruption" blocker is cleared, the opt-in posture is correct, and the coverage question is being handled in the right place. The remaining blocker is that `redacted_thinking` is still written as if it participated in an empty-text omitted-shape matcher, even though Anthropic documents it as a separate opaque `data` block. Fix that schema mismatch and then this is ready for another pass.
Loading
Loading