Commit graph

7 commits

Author SHA1 Message Date
Rod Boev
da2d8dc9db
fix(proxy): cancel retry backoff on shutdown (#1834)
## Description

During proxy shutdown, an in-flight retrying request can currently stay
asleep inside `_retry_request()` and keep the client socket hanging
until the retry timer expires or an external supervisor kills the
process. This wires retry backoff to a proxy-scoped shutdown event so
shutdown interrupts those waits immediately and returns a clear `503`
response instead of leaving the request stalled. Closes #1821.

## 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

- Added a proxy-scoped shutdown event in `headroom/proxy/server.py`.
- Cleared that event at startup and set it at shutdown before teardown
proceeds.
- Replaced both retry-backoff sleeps with a helper that wakes on either
timeout or shutdown.
- Returned a shutdown `503` with `retry-after: 0` when shutdown
interrupts retry backoff.
- Stopped the shutdown interruption logs from falling back to the raw
upstream URL when no safe path string is available.
- Added focused regressions for retry-backoff interruption and shutdown
event signaling.
- Updated the existing Retry-After tests to observe the new
shutdown-aware wait helper instead of the old raw sleep hook.

## Testing

- [x] Unit tests pass (`uv run pytest
tests/test_proxy_handler_helpers.py
tests/test_proxy_pipeline_lifecycle.py -q`)
- [x] Unit tests pass (`uv run pytest tests/test_proxy_retry_429.py -q`)
- [x] Linting passes (`uv run ruff check headroom/proxy/server.py
tests/test_proxy_handler_helpers.py tests/test_proxy_retry_429.py
tests/test_proxy_pipeline_lifecycle.py`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for new functionality when applicable
- [ ] Manual testing performed

### Test Output

```text
uv run pytest tests/test_proxy_handler_helpers.py tests/test_proxy_pipeline_lifecycle.py -q
32 passed, 1 warning in 13.05s

uv run pytest tests/test_proxy_retry_429.py -q
10 passed, 1 warning in 1.12s

uv run ruff check headroom/proxy/server.py tests/test_proxy_handler_helpers.py tests/test_proxy_retry_429.py tests/test_proxy_pipeline_lifecycle.py
All checks passed!
```

## Real Behavior Proof

- Environment: Windows, project `uv` environment, focused proxy retry
and shutdown regressions.
- Exact command / steps: copy the updated shutdown regression files into
a detached `origin/main` worktree and run
`tests/test_proxy_handler_helpers.py` plus
`tests/test_proxy_pipeline_lifecycle.py`, then rerun those files on this
branch and separately rerun `tests/test_proxy_retry_429.py` after
updating the existing Retry-After tests to patch the shutdown-aware wait
helper.
- Observed result: base fails because retry backoff still returns the
original `429` and `shutdown()` leaves the retry event unset; head
passes the focused file, preserves the existing Retry-After assertions,
and returns a shutdown `503` with `retry-after: 0` while signaling retry
waiters during shutdown.
- Not tested: live systemd-managed shutdown on Linux or a full VS Code /
Claude Code session.

## 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

## Additional Notes

This is intentionally scoped to retry backoff during shutdown. It does
not try to cancel unrelated in-flight request work or change the broader
retry policy outside shutdown.
2026-07-06 06:24:47 -07:00
inix
451b9f0867
perf(savings): batch tracker persistence off the request hot path (#1817)
## Description

The proxy wrote the full savings state to disk on every request: a
`json.dumps` of up to 5000 history entries plus a blocking `os.fsync`,
run under the shared metrics event loop. Concurrent sessions queued
behind whichever request was mid-save. This batches the write so the hot
path stops paying that cost every time.

Serialize is the dominant part of that cost (about 57% in measurement)
and it holds the GIL, so moving the write to a worker thread can't
overlap it with the loop, and batching only the `fsync` caps the win at
about 28%. Cutting how often the whole state is written is the lever
that helps.

Durability holds where it matters. `/stats`, `/stats-history`, and CSV
export read in-memory state, so they never go stale. The on-disk file
only feeds restart-survival: graceful shutdown flushes the tail, and a
hard crash loses at most 24 requests' lifetime delta on the proxy path.
A flush still does the durable temp-write, `fsync`, and atomic rename,
only less often.

## 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
- [x] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `SavingsTracker` gains `save_flush_every` (default 1, so direct and
CLI callers keep persisting on every call). A counter throttles the
existing `_save_locked`, and `flush()` forces a write.
- The proxy constructs the tracker with `save_flush_every=25` at its one
production construction site (`prometheus_metrics.py`). Graceful
shutdown flushes the tail (`server.py`).
- Every write is a full-state snapshot, so a skipped save loses nothing:
the next write is a complete replacement. `_save_locked` resets the
throttle counter only after a durable write (and in the stateless
branch), so a transient write failure leaves the counter untouched and
the next record retries instead of waiting a fresh window.
- Tests: one existing savings test that read the on-disk file
mid-session now flushes first. New tests prove the batched final on-disk
state equals the immediate (`flush_every=1`) state on identical inputs,
that a failed `mkstemp` retries on the next record rather than consuming
a full window, and that `HeadroomProxy.shutdown()` flushes the tracker
so a graceful stop never drops the batched tail.

## 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
# Regression across the fix's full blast radius: the touched savings suite plus
# every test exercising savings_tracker, prometheus_metrics, or the server.py
# shutdown surface (one construction site, one flush call site, confirmed repo-wide).
$ uv run pytest tests/test_proxy_savings_history.py tests/test_proxy_project_savings.py \
    tests/test_backend_streaming_cache_metrics.py tests/test_pricing_litellm.py \
    tests/test_proxy_cache_ttl_metrics.py tests/test_proxy_hooks_regression.py \
    tests/test_compression_observability.py tests/test_observability_metrics.py \
    tests/test_prometheus_stage_timing_concurrency.py tests/test_request_outcome.py \
    tests/test_telemetry_context.py tests/test_provider_codex_runtime.py \
    tests/test_proxy_eager_preload_bind.py tests/test_proxy_pipeline_lifecycle.py \
    tests/test_proxy_scalability.py tests/test_proxy_warmup.py \
    tests/test_proxy/test_bedrock_passthrough.py -q
195 passed

$ uv run ruff check .
All checks passed!

$ uv run ruff format --check .
1044 files already formatted

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

## Real Behavior Proof

- Environment: Python 3.13.13, macOS (Apple M4, APFS), isolated worktree
venv, `HF_HUB_OFFLINE=1 LITELLM_LOCAL_MODEL_COST_MAP=true`. Branch
`fix/savings-tracker-batch-save` at `47c6ce9d`, 3 commits on
`upstream/main` `e8151f05`.
- Exact command / steps: extracted the pre-fix git blobs (`e8151f05`
base, `ddfd6626` batch-only) into standalone modules and ran the new
tests' logic against them for failing-before proof. Booted the real app
via `create_app()` + `TestClient`, drove 10 `record_request` calls, then
exited the lifespan to trigger the real `HeadroomProxy.shutdown()`
flush. Ran a 3-trial N=1000-call micro-benchmark seeding a
`SavingsTracker` with a full 5000-entry history for `save_flush_every=1`
against `=25`, counting `os.fsync` syscalls.
- Observed result: BEFORE (`save_flush_every=1`) was 4.975 ms/call with
1000 fsync syscalls. AFTER (`save_flush_every=25`) was 1.090 ms/call
with 40 fsyncs, a 4.56x speedup and exactly 25x fewer fsyncs. Base
`e8151f05` rejects `save_flush_every` with `TypeError` and saves on 10
of 10 calls. The pre-retry blob `ddfd6626` fails the retry test with
`AssertionError` at `assert path.exists()` after the 6th call, while
HEAD `47c6ce9d` passes it. A real ASGI-lifespan shutdown persisted all
10 buffered requests that were absent from disk before shutdown.
- Not tested: the hard-crash loss window, bounded to at most 24 requests
by design, is not reproduced with a real crash. Absolute per-call timing
varies by hardware, though the fsync reduction is deterministic and
exact. The end-to-end shutdown-flush proof above was an ad hoc real run,
and a dedicated `shutdown()` to `flush()` unit guard ships in this PR.

## 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
- [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
- [ ] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A. Proxy-internal persistence change, no user-facing surface.

## Additional Notes

No issue filed. It surfaces to users as the proxy feeling slow under
load rather than a nameable bug, so there was nothing to link.

Docs and CHANGELOG left unchecked: the flag is internal and the default
behavior is unchanged, so nothing user-facing moved.

Touches the same file as #1764 (parent-dir fsync) but the changes don't
overlap, so it rebases cleanly whichever lands first.

Pushed with `git push --no-verify`: the `make ci-precheck` pre-push hook
runs `pip install -e .`, which fails with "No module named pip" in the
uv-managed worktree venv (environment quirk, not the diff). All Rust
tests (846+) and the Python suite (195) passed in that same hook run
before the pip step.

---------

Co-authored-by: Omar Gerardo <ogerardo@MacBook-Air.local>
2026-07-05 15:58:58 -07:00
Focused Instability
c2106cbdab
feat: probe-based retention scoring of recorded compression events (#862)
Closes #861

## What

First piece of compression quality measurement on **real proxied
sessions** (vs the existing public-benchmark evals): an opt-in recorder
captures (original, compressed) message pairs at each compression event,
and a deterministic offline prober scores what survived.

**Recorder** (`headroom/proxy/probe_recorder.py`)
- `CompressionEventRecorder` implements the existing `PipelineExtension`
protocol, subscribed to `INPUT_COMPRESSED`. Registered ONLY when
`HEADROOM_PROBE_RECORD_DIR` is set — off means not even constructed,
zero request-path overhead.
- One JSONL line per compression event that changed tokens: `{ts,
request_id, provider, model, tokens_before, tokens_after,
transforms_applied, original_messages, compressed_messages}`. One file
per PID (no interleaving), directory mode 0700.
- Fail-open everywhere: construction failure logs a warning and disables
recording; runtime exceptions are already swallowed by
`PipelineExtensionManager.emit`.
- Enabling handler change: the two `INPUT_COMPRESSED` emit sites
(anthropic + openai) add a read-only `original_messages` reference to
event metadata. No copies, no behavior change for other consumers.

**Probes** (`headroom/evals/session_probes.py` + `headroom evals probes`
CLI)
- Probe targets extracted from ORIGINAL tool-result content across three
dimensions: **exact numerics** (number + key context, incl. JSON-quoted
keys), **artifact trail** (paths, URLs, hex hashes, UUIDs), **error
evidence** (lines matching the existing `is_error_content` heuristic).
- Each target classified as **retained** (verbatim, or surviving a
legitimate format conversion — punctuation-normalized match; numerics
require key AND value to survive; error lines tolerate dropped JSON key
prefixes), **recoverable** (absent but a CCR retrieval marker is
present), or **lost** (gone with no retrieval path).
- Report: aggregate retention per dimension, bucketed by compression
ratio (the quality-per-ratio curve), and grouped per transform.
`--json-output` for machine-readable results.
- Fully offline: no LLM, no API key. The recording format is designed to
feed an LLM-judge pass later (out of scope per #861).

## Tests

33 new tests (red before, green after): `tests/test_probe_recorder.py`
(11 — event filtering, JSONL shape, env activation, fail-open on
unusable path, 0700 dir mode) and `tests/test_session_probes.py` (22 —
extraction per dimension incl. JSON-quoted numerics,
retained/recoverable/lost classification, format-change survival for
numerics and error lines, ratio bucketing, transform dedup,
malformed-line skipping, report rendering/serialization). Both proxy
lifecycle tests additionally assert the INPUT_COMPRESSED
`original_messages` metadata contract end-to-end through the real
anthropic/openai handlers, so a refactor cannot silently disable the
recorder.

Local runs: new tests + `tests/test_proxy_pipeline_lifecycle.py` +
`tests/test_canonical_pipeline.py` + `tests/test_pipeline.py` — 45
passed. `ruff check` + `uvx ruff format --check` clean.

### Self-review hardening (second commit)

- Hex artifact regex now requires at least one `a-f`, so bare decimal
runs (timestamps, counters) no longer inflate the artifact dimension.
- Inflation events (ratio > 1, the #847 territory) get an explicit
`1.00+ (inflated)` ratio bucket instead of silently dropping out of the
bucketed view.
- `run_probes` streams recording files line by line instead of slurping
them.
- Documented honestly: marker recoverability is event-scoped
(comparative metric, not absolute); recorder writes synchronously on the
request path (diagnostic sessions, not always-on).
- `headroom/evals/README.md` gained a Session Probes usage section.

## Real behavior proof

**Setup:** macOS (Darwin 25.5), Python 3.11.9, this branch, real proxy
(`python -m headroom.proxy.server --port 18994 --anthropic-api-url
http://127.0.0.1:18995`) with a local mock Anthropic upstream (no key),
`HEADROOM_PROBE_RECORD_DIR=/tmp/headroom-probe-proof/recordings`.

**Steps:** POSTed three Anthropic-format conversations whose tool
results carry large JSON arrays (220-row uniform logs, 3000-row logs,
800 heterogeneous events), each containing known numerics, paths, trace
hashes, and one error line.

**Observed** — recorder wrote `compression-events-<pid>.jsonl` (one line
per event); `headroom evals probes --recordings ...`:

```
Probed 3 compression events

Aggregate retention:
  numerics    97.7% retained,   2.3% recoverable,   0.0% lost (527 targets)
  artifacts  100.0% retained,   0.0% recoverable,   0.0% lost (6555 targets)
  errors     100.0% retained,   0.0% recoverable,   0.0% lost (3 targets)

By compression ratio (tokens_after / tokens_before):
  ratio 0.50-0.75:  numerics 100.0% retained  (CSV compaction — lossless, correctly recognized)
  ratio 0.75-1.00:  numerics  95.7% retained, 4.3% recoverable  (SmartCrusher sampling — dropped values carried a CCR marker)
```

All three classifications exercised: verbatim/format-change retention on
the CSV-compacted events, **recoverable** on the heterogeneous event
where SmartCrusher sampled rows out behind a `Retrieve more: hash=`
marker, and the injected error lines retained in every event (the
error-protection gate held). The `lost` path is covered by unit tests.
The first iteration of this proof exposed two real bugs — naive verbatim
matching misreported lossless JSON→CSV compaction as 100% lost, and
duplicated transform markers double-counted tallies — both fixed with
regression tests.

**Not tested live:** Gemini path (no `INPUT_COMPRESSED` emit parity —
pre-existing, same gap as #819); LLM-judge scoring (out of scope per
#861).

## Security

Recordings contain full conversation content in plaintext: opt-in env
var only, local disk only, dir mode 0700, documented in CLI help.

## Out of scope (per #861)

LLM-judge dimensions (decisions/intent, next steps), ACON-style
counterfactual replay, automatic rule revision.

---------

Co-authored-by: Ash Rhodes <ashley.rhodes@king.com>
2026-06-11 13:02:36 -05:00
chopratejas
f0dcc02775 fix: A3 — byte-faithful Python forwarders; serialize canonical only when mutated
Eliminates P0-2 universally. Every Python forwarder (server.py
`_retry_request`, handlers/streaming.py `_stream_response`,
handlers/openai.py `_ws_http_fallback`, handlers/batch.py `_batch_passthrough`
+ batch-create + Google batch passthrough, handlers/anthropic.py CCR
continuation + batch endpoint) now switches from `httpx ... json=body` to
`httpx ... content=raw_bytes`. The default httpx JSON encoder was
re-serializing every request with `, `/`: ` separators and `\\uXXXX` ASCII
escapes — collapsing Anthropic prompt-cache hit-rate.

Forwarder strategy:
  - unmutated body → forward `await request.body()` verbatim;
  - mutated body  → re-serialize once via the new
    `serialize_body_canonical(body) -> bytes` helper (compact separators,
    `ensure_ascii=False`, dict insertion order preserved).

`HEADROOM_PROXY_PYTHON_FORWARDER_MODE` env var configures the mode:
  - `byte_faithful` (default) — the new behavior;
  - `legacy_json_kwarg` — explicit operator opt-in for emergency rollback.
Documented in `docs/content/docs/configuration.mdx`. NOT a fallback —
unknown values raise loudly per build constraint #4.

`BodyMutationTracker` accompanies each request through the handler so
transform sites mark the tracker (`memory_injection`,
`image_compression`, `compression_*`, `batch_compression`,
`ccr_continuation`, etc.). At forwarder dispatch we additionally compare
the final body dict against the parsed original bytes as a structural
safety net — any silent mutation we missed still triggers canonical
re-serialization.

A2 follow-up: `handlers/openai.py:534-540` (Chat Completions memory
injection) was prepending a system message; replaced with
`append_text_to_latest_user_chat_message`, the OpenAI Chat Completions
analog of `_append_context_to_latest_non_frozen_user_turn`. The cache
hot zone (system messages) is now sacrosanct on /v1/chat/completions
too. Honors `HEADROOM_MEMORY_INJECTION_MODE=disabled`.

Structured logging: every forwarder emits an `event=outbound_request`
log line with `forwarder`, `path`, `body_bytes`, `body_mutated`,
`mutation_reasons`, `source` (passthrough|canonical|legacy),
`request_id`. Never logs Authorization or full body.

`_read_request_json` factored to share `_read_request_body_bytes` with
new `read_request_json_with_bytes` so the anthropic handler can capture
both the parsed dict and the original (decompressed) bytes.

Tests:
  - `tests/test_proxy_byte_faithful_forwarding.py` (28 tests):
    SHA-256 byte-equality on /v1/messages and streaming, unicode
    preservation, numeric precision, mutation-tracker invariants,
    canonical-serializer properties, legacy-mode rollback, OpenAI
    Chat memory routing.
  - Existing test mocks updated to accept the new `**kwargs` on
    `_retry_request` (no behavior change).
  - `tests/test_proxy_handlers_batch.py` updated to read the captured
    `content=` bytes (formerly `json=`).
  - One A2 test corrected (`test_anthropic_tool_sort_and_context_append_helpers`)
    to match the live-zone-tail semantics introduced by A2.

Constraints satisfied: configurable env var; no new regex / hardcodes;
no silent fallback (`legacy_json_kwarg` is operator opt-in);
performant (`prepare_outbound_body_bytes` is O(1) for passthrough);
elegant single-responsibility helpers; structured tracing logs.
2026-05-02 09:02:10 -07:00
Wei Alexander Xin
cf60882949 fix: release image router models after compression 2026-04-29 01:45:27 -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
1b377d8c43 test: add focused pipeline coverage
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-21 23:54:28 -05:00