Commit graph

7 commits

Author SHA1 Message Date
Connor Campbell
021a762bf8
feat(compress): expose frozen_message_count in library-mode compress() (#2178)
## Description

`read_lifecycle.apply()` already supports a frozen message prefix
(`frozen_message_count`) — stale-Read replacements inside the prefix are
skipped so compression never rewrites messages the provider's prompt
cache has anchored. But only the proxy handlers can pass it:
`ContentRouter` reads it from transform kwargs, `CompressConfig` has no
such field, and the public `compress()` never forwards it.

Library-mode callers that manage their own conversation loop (SDK
integrations, offline evaluation, sidecar scoring) therefore can't stop
transforms from rewriting already-sent history. On cached Anthropic
traffic that's expensive: every byte after the first rewritten one stops
billing as a 0.1× cache read and re-bills as a cache write (1.25× at the
5-minute TTL, 2× at the 1-hour TTL) — measured on live coding-agent
traffic, retroactive stale-Read rewrites were the dominant cache-bust
source once tool injection went session-sticky (PR-B7).

Relates to #809 (cache-bust economics discussion); does not close it.

## Type of Change

- [ ] Bug fix (non-breaking change that fixes an issue)
- [x] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `CompressConfig.frozen_message_count: int = 0` — documented field;
default `0` preserves existing behavior exactly.
- `compress()` forwards it through `pipeline.apply()` to the transforms,
matching what the proxy handlers already do.
- `compress()` docstring: added to the kwargs shorthand list.
- CHANGELOG entry under Unreleased → Features.
- Four tests in `tests/test_compress_api.py` (`TestFrozenMessageCount`).

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ uv run pytest tests/test_compress_api.py tests/test_transforms/test_read_lifecycle.py \
    tests/test_compression_safety_rails.py tests/test_compress_failure.py -q
59 passed, 1 warning in 3.05s

$ uv run ruff check headroom/compress.py tests/test_compress_api.py
All checks passed!

$ uv run mypy headroom
Success: no issues found in 471 source files
```

## Real Behavior Proof

- Environment: Linux, Python 3.12.3, this branch installed via `uv sync
--extra dev`
- Exact command / steps: build an Anthropic-format conversation with a
stale Read (file read at message 2, edited at message 3), then:

  ```python
  r0 = compress(msgs, model="claude-sonnet-4-5-20250929")
r5 = compress(msgs, model="claude-sonnet-4-5-20250929",
frozen_message_count=5)
  ```

- Observed result: without frozen prefix the stale Read is rewritten;
with frozen_message_count=5 the Read remains byte-identical.

  ```text
  without frozen prefix: stale Read rewritten: True
    transforms: ['read_lifecycle:stale:/app/config.py']
  with frozen_message_count=5: Read byte-identical: True
    transforms: []
  ```

- Not tested: proxy-mode code paths (untouched — they already pass
`frozen_message_count` their own way); Rust crates (untouched).

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A — library API change, no UI.

## Additional Notes

Default `0` makes this a strict superset of current behavior — no caller
sees any change without opting in. The motivation data comes from a
proxy-side measurement tool that prices compression's cache effects on
live Anthropic agent traffic (per-request cache-adjusted dollars); happy
to share methodology in #809 if useful.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-14 16:07:21 -04:00
thejesh23
ecdcf13f3f
fix(compress): don't mutate the caller's CompressConfig via kwargs (#2134)
## Description

`compress(messages, config=my_cfg, protect_recent=0, target_ratio=0.2)`
used to write those kwarg values onto the caller's `my_cfg` object — so
a shared per-agent `CompressConfig` was silently rewritten every time a
call passed a single override. The next call that did NOT override that
field then saw the previous request's value instead of the original
default.

Copy the config once at entry with `dataclasses.replace` before applying
kwarg overrides (and before the savings-profile pass, which also mutates
in place). Existing behavior for callers that pass **only** kwargs, or
**only** a config, is unchanged.

Issue #2133 has the root-cause walkthrough.

Closes #2133

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `headroom/compress.py`: copy the incoming `CompressConfig` once at
entry with `dataclasses.replace` before applying kwarg overrides, so the
caller's object is no longer mutated. The savings-profile branch already
did a defensive `replace(cfg)`; that copy is now hoisted up front so
both the kwarg and profile paths share the same guarantee.
- `tests/test_compress_api.py`: added
`test_kwargs_do_not_mutate_caller_config`, which fails on unpatched
`main` and passes on this branch, covering the previously broken kwarg
leg.
- `CHANGELOG.md`: noted the fix.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ uv run pytest tests/test_compress_api.py -q
.................                                                        [100%]
17 passed in 2.88s

$ uv run ruff check headroom/compress.py tests/test_compress_api.py
All checks passed!

$ uv run ruff format --check headroom/compress.py tests/test_compress_api.py
2 files already formatted
```

## Real Behavior Proof

- Environment: macOS 25.4 (Darwin arm64), Python 3.12.13, `uv 0.11.28`,
branch `fix/compress-mutates-caller-config`, model
`claude-sonnet-4-5-20250929` used for token counting.
- Exact command / steps: build `c = CompressConfig(protect_recent=4,
target_ratio=0.8)`, call `compress(msgs,
model="claude-sonnet-4-5-20250929", config=c, protect_recent=0,
target_ratio=0.2)` on a 3000-char user message, then read
`c.protect_recent` and `c.target_ratio` back (full snippet run via `uv
run python <<'PY' ... PY` — see the code block below).
- Observed result: before the patch, `c.protect_recent` became `0` and
`c.target_ratio` became `0.2` (caller's config silently rewritten).
After the patch, `c.protect_recent` stays `4` and `c.target_ratio` stays
`0.8`; caller's config unchanged. `uv run pytest
tests/test_compress_api.py` reports 17 passed including the new
`test_kwargs_do_not_mutate_caller_config` case.
- Not tested: end-to-end proxy path with `savings_profile` set (the
pre-fix code already did a defensive `replace(cfg)` on that branch, so
the profile leg was safe; this change hoists that copy up front and the
added unit test covers the kwarg leg that was broken — I did not spin up
the proxy to reconfirm the profile branch end-to-end). No
concurrent-caller / threading regression test was added — the fix
removes the mutation entirely which sidesteps the race, but there is no
explicit multi-thread reproducer.

### Reproducer

**Before the patch (unpatched `main`)**

```text
before: protect_recent=4, target_ratio=0.8
after : protect_recent=0, target_ratio=0.2       # <-- caller's cfg silently rewritten
caller's config MUTATED
```

**After the patch (this branch)**

```text
$ uv run python <<'PY'
from headroom.compress import compress, CompressConfig
c = CompressConfig(protect_recent=4, target_ratio=0.8)
print(f"before: protect_recent={c.protect_recent}, target_ratio={c.target_ratio}")
msgs = [{"role":"user","content":"x"*3000}]
compress(msgs, model="claude-sonnet-4-5-20250929", config=c, protect_recent=0, target_ratio=0.2)
print(f"after : protect_recent={c.protect_recent}, target_ratio={c.target_ratio}")
print("caller's config", "unchanged" if (c.protect_recent, c.target_ratio) == (4, 0.8) else "MUTATED")
PY
before: protect_recent=4, target_ratio=0.8
after : protect_recent=4, target_ratio=0.8
caller's config unchanged
```

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation <!-- N/A:
no user-facing doc covers the CompressConfig / kwargs contract; see
Additional Notes -->

- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

- **Documentation checklist item** — left unchecked as N/A. The behavior
being fixed is internal to `headroom.compress.compress()`; the mutation
contract of `CompressConfig` + kwargs is not covered in any user-facing
doc (`wiki/compression.md`, `wiki/text-compression.md`,
`wiki/image-compression.md`, and `docs/content/docs/shared-context.mdx`
document a different / higher-level API surface). The `CHANGELOG.md`
entry is the appropriate place for this fix.
- **`mypy headroom` checklist item** — left unchecked because I did not
run it in this workflow; the change is a two-line refactor within a
well-typed function and no signatures moved.
- The prior body's `## Summary`, `## Test plan`, and `## Real behavior
proof` sections were reorganized into the six template-required headings
so the PR-governance check passes. All technical content (root-cause,
before/after reproducer, and test output) is preserved above; no code
changes were made in this update.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-13 19:57:04 -04:00
Garm
efd2ac1ca4 chore: renormalize line endings to LF
`.gitattributes` declares `*.py text eol=lf` and `*.sh text eol=lf`, but
74 files (73 .py, 1 .sh) are stored in the index with CRLF line endings,
violating that contract. Every macOS/Linux clone reports these files as
"modified" on fresh checkout because git's diff engine sees the stored
bytes don't match the attribute contract, even though the working tree
and index match byte-for-byte.

Running `git add --renormalize .` rewrites each affected blob so the
stored form matches the attribute declaration. No semantic changes —
every affected file's diff is "N insertions, N deletions" with inserts
and deletes being the same lines modulo line endings.

Follow-up commit adds `.git-blame-ignore-revs` so `git blame` / GitHub
blame skip this mechanical commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:33:30 +02:00
JerrettDavis
3ddc7ff33a fix: restore release and compress regressions
Fix workflow validation failures by wiring detect-version outputs into all
release publish jobs, renaming the GitHub Packages skip variable to a
valid Actions variable name, and adjusting the macOS PATH export for
actionlint.

Also make min_tokens_to_compress use token counting instead of whitespace
splits so compact JSON tool outputs still compress after merging the
latest main branch changes, and add a regression test for that path.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-18 16:01:57 -05:00
chopratejas
0adc39ab7a Fix CI: guard starlette imports, asyncio.run(), deprecate datetime.utcnow()
- Guard starlette imports in test_compress_api.py (skip ASGI tests without proxy deps)
- Replace asyncio.get_event_loop().run_until_complete() with asyncio.run() (Python 3.13)
- Replace datetime.utcnow() with datetime.now(timezone.utc).replace(tzinfo=None) everywhere
2026-02-19 11:03:24 -08:00
chopratejas
0a434531d8 Fix: guard starlette imports in test_compress_api.py for CI without proxy deps 2026-02-19 10:48:39 -08:00
chopratejas
dde2f9f848 Add one-function compress() API, ASGI middleware, LiteLLM callback
Three new integration paths — no proxy needed:

1. headroom.compress(messages, model) → CompressResult
   One function, auto-detects tokenizer per model, works with any client.

2. headroom.integrations.asgi.CompressionMiddleware
   Drop-in ASGI middleware for LiteLLM proxy, FastAPI, or any ASGI app.

3. headroom.integrations.litellm_callback.HeadroomCallback
   LiteLLM callback: litellm.callbacks = [HeadroomCallback()]

Fix: TransformPipeline._get_tokenizer() no longer requires a Provider.
Falls back to tokenizer registry which auto-detects per model:
- OpenAI → tiktoken (exact)
- Anthropic → calibrated estimation (3.5 chars/token)
- Open models → HuggingFace (if installed)

15 tests covering compress(), ASGI middleware, LiteLLM callback.
2026-02-19 10:00:31 -08:00