Commit graph

2630 commits

Author SHA1 Message Date
Abhay Singh
f669149769
fix(proxy/openai): feed Codex WS traffic into the traffic learner (#2334)
## Description

Follow-up to the chat/completions ingestion work — this wires the Codex
`/v1/responses` **WebSocket** path into the traffic learner, the
remaining gap in #2060.

`handle_openai_responses_ws` (the transport newer Codex versions default
to) had no traffic-learner ingestion, so Codex subscription traffic
produced no learned patterns even with Learn enabled. Unlike the
one-shot HTTP path, a long-lived Codex WebSocket:

- resends the **full transcript** on every `response.create` frame, and
- replays it **wholesale on reconnect/resume**.

So naive per-turn ingestion would count the same tool result as evidence
over and over, and every reconnect would re-ingest the whole history.

## Fix

Add `_observe_openai_ws_response_create`, which dedups per connection by
tool-call id:

- A per-connection `ws_learner_seen_call_ids: set[str]` tracks which
tool-call ids have been observed on this WebSocket.
- The **first** `response.create` frame is a **baseline**: its
already-present transcript is recorded as seen but **not learned**, and
preference extraction is skipped. This is the replayed/initial history,
which may already have been learned on a prior connection.
- **Later** frames learn only the tool results whose call id first
appears after the baseline, then mark them seen. Preference extraction
(`on_messages`) runs on these frames (it already looks only at the most
recent messages).

On reconnect the client opens a fresh WebSocket and replays the
transcript in its first frame, which is baselined again, so it adds no
spurious evidence. It hooks both frame paths: the first-frame handler
seeds the baseline from the original client frame (parsed before memory
injection / compression), and `_maybe_compress_response_create_frame`
observes each subsequent frame.

To dedup by identity,
`TrafficLearner.extract_tool_results_from_messages` now also returns the
`call_id` (the `tool_use`/`tool_result` id, which
`_responses_input_to_learner_messages` already sets from the Responses
`call_id`). This is additive — existing callers that don't read it are
unaffected.

Relationship to the chat path: the `/v1/chat/completions` ingestion is a
separate change; together they cover HTTP chat, HTTP Responses (already
wired), and Codex WS. This PR is independent and branches off `main`.

## Type of Change

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

## Changes Made

- `headroom/memory/traffic_learner.py`:
`extract_tool_results_from_messages` now returns `call_id` for per-turn
dedup (additive).
- `headroom/proxy/handlers/openai.py`: add
`_observe_openai_ws_response_create` (per-connection dedup + baseline);
initialise `ws_learner_seen_call_ids`; observe the first frame as a
baseline and each subsequent `response.create` frame.
- `tests/test_openai_responses_traffic_learner.py`: add WS
dedup/baseline coverage (baseline records-not-learns, later frames learn
only new results, reconnect replay adds no evidence); update the
existing extractor-equality assertion to include `call_id`.
- `CHANGELOG.md`: Bug Fixes entry.

## Testing

- [ ] 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
$ uvx ruff@0.15.17 check headroom/memory/traffic_learner.py headroom/proxy/handlers/openai.py tests/test_openai_responses_traffic_learner.py
All checks passed!
$ uvx ruff@0.15.17 format --check <same files + test_memory/test_traffic_learner.py>
all files already formatted
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/proxy/handlers/openai.py
# no errors in the changed files (the one reported error is a pre-existing
# headroom/_subprocess.py:18 no-any-return, present on main with these edits stashed)
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17` / `uvx
mypy@1.20.2`. A full `pytest` OOMs this box (ML-stack import), so I
reproduced the dedup/baseline loop with a dependency-free asyncio script
and left the full pytest to CI.
- Exact command / steps: simulated a connection where the baseline frame
carries tool-call ids A,B; later frames replay A,B and append C, then D;
plus a reconnect whose first frame replays A,B,C,D.
- Observed result: baseline recorded A,B without learning; frame 2
learned only C; frame 3 learned only D (A/B/C never re-counted); the
reconnect's replayed transcript was baselined and learned nothing. The
added unit tests assert the same through the real handler method with a
recording learner.
- Not tested: a live Codex WebSocket session end to end; the added tests
drive `_observe_openai_ws_response_create` directly with a recording
learner and the real `_responses_input_to_learner_messages` + extractor.

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

## Additional Notes

The "unit tests pass locally" box is unchecked because a local pytest
run imports the ML stack and OOMs this box; the added tests reuse the
existing `_RecordingLearner` harness (no real backend) and run under the
normal CI pytest job, and the dedup/baseline behavior is corroborated by
the standalone proof above. Design note: baselining the first frame
means a brand-new conversation's first-turn tool results are not learned
on that connection (subsequent turns are); this is the deliberate
trade-off the issue calls for to keep reconnect/resume from inflating
evidence.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-08-11 23:54:41 -05:00
Matt Haitana
64cb46e24b
fix(proxy): pass through cross-region prefixed Bedrock model IDs directly (#2330)
## Description

When a model ID with a cross-region prefix (`au.`, `us.`, `eu.`,
`apac.`, `global.`) is sent to the Bedrock backend, `map_model_id` was
normalising it (e.g. `au.anthropic.claude-opus-4-8` → `claude-opus-4-8`)
then re-looking it up in the discovery map. If an APPLICATION inference
profile wrapping the same foundation model existed in the account, it
would be returned — routing the request to a profile the caller is not
authorised to invoke, resulting in a 403 from Bedrock even though the
system-defined profile is reachable directly.

Cross-region prefixed IDs are already fully-qualified system-defined
profile IDs; they must pass through unchanged.

Closes #

## Type of Change

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

## Changes Made

- `headroom/backends/litellm.py`: added early-exit in `map_model_id` —
model IDs starting with `au.`, `us.`, `eu.`, `apac.`, or `global.` are
returned as `bedrock/<model_id>` without any discovery lookup
- `tests/test_bedrock_region.py`: two new regression tests covering the
exact failure mode and all five prefix families

## Testing

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

### Test Output

```text
tests/test_bedrock_region.py ........................................... [ 95%]
..                                                                       [100%]

45 passed in 4.45s
```

## Real Behavior Proof

- Environment: headroom 0.32.0-dev, `--backend bedrock`, `--region
ap-southeast-2`, `--bedrock-profile <BEDROCK_PROFILE>`, proxy on port
8788
- Exact command / steps: Output
  ```
# Before fix — old map_model_id logic with a contaminated discovery map:
  # Input:      au.anthropic.claude-opus-4-8
  # Normalized: claude-opus-4-8
  # Resolved:   bedrock/<application-inference-profile-arn>  <-- 403

  # After fix — cross-region prefix detected, passed through directly:
  curl -s -X POST http://localhost:8788/v1/messages \
    -H "Content-Type: application/json" \
    -H "x-api-key: sk-ant-dummy" \
    -H "anthropic-version: 2023-06-01" \
-d
'{"model":"au.anthropic.claude-opus-4-8","max_tokens":64,"messages":[{"role":"user","content":"Reply
with just: fix works"}]}'
  ```
- Observed result:
`{"type":"message","role":"assistant","content":[{"type":"text","text":"fix
works"}],"model":"au.anthropic.claude-opus-4-8","stop_reason":"end_turn",...}`
— HTTP 200, routed to `bedrock/au.anthropic.claude-opus-4-8`
(system-defined profile) rather than the APPLICATION profile ARN
- Not tested: `apac.` and `global.` prefixes against a live AWS account
(covered by unit tests only)

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

## Additional Notes

The existing `_fetch_bedrock_inference_profiles` already filters to
`typeEquals="SYSTEM_DEFINED"` so APPLICATION profiles are not added to
the discovery map during normal startup. This fix closes the remaining
gap where a caller passes a cross-region prefixed ID directly —
previously that ID was normalised before lookup, which could
accidentally match a stale or externally-injected map entry.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: JD Davis <jd@jds-macbook-air.tail2a279.ts.net>
2026-08-11 23:54:03 -05:00
AxelRay
dc163bcd1c
fix(proxy): preserve signed Anthropic thinking blocks on outbound re-serialize (#2254)
## Description

When multi-turn Anthropic requests include signed `thinking` or
`redacted_thinking` blocks in conversation history, the proxy
re-serializes the body through `serialize_body_canonical` whenever
`body_mutated` is true. That re-encode changes the byte representation
of signed blocks and upstream rejects the turn with 400 "blocks cannot
be modified".

This detects those content blocks and, when original request bytes are
available, forwards them byte-for-byte instead of re-encoding. That
matches the preferred option from the issue and mirrors the existing
Agno skip for thinking-bearing histories.

Closes #2251

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

- Add `has_signed_thinking_blocks()` in
`headroom/proxy/body_forwarding.py`
- Prefer original-byte passthrough in `select_outbound_body` when signed
thinking blocks are present and original bytes exist
- Unit tests for thinking and redacted_thinking passthrough,
missing-original canonical fallback, legacy override, and unchanged
non-thinking behavior

## Testing

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

### Test Output

```text
uv run pytest tests/test_proxy_byte_faithful_forwarding.py -q --tb=short
# 43 passed, 1 skipped

uv run ruff check / format on touched files
# passed
```

## Real Behavior Proof

- Environment: unit-level body forwarding with multi-turn
Anthropic-shaped payloads containing signed `thinking` /
`redacted_thinking` blocks
- Exact command / steps: focused pytest suite above
- Observed result: with `body_mutated=True` and original bytes present,
outbound source is `passthrough` and content equals original bytes;
without original bytes, behavior remains canonical; non-thinking mutated
bodies still use canonical
- Not tested: full `headroom wrap claude` multi-turn session against
Anthropic / Claude Code (no live Claude credentials here)

## 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
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A

## Additional Notes

- Requests without thinking blocks keep existing
passthrough/canonical/legacy selection
- When original bytes are unavailable, signed-thinking requests still
re-serialize

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-08-11 23:48:56 -05:00
Abhay Singh
12f9f58cb3
fix(backends/litellm): None-guard core token counts in OpenAI usage block (#2324)
## Description

`LiteLLMBackend.send_openai_message` builds the OpenAI-shape response
body. The core token counts are copied straight off LiteLLM's `Usage`
object with no guard, even though the cache fields immediately below
already use the defensive `int(getattr(..., 0) or 0)` form:

```python
usage_block: dict[str, Any] = {
    "prompt_tokens": response.usage.prompt_tokens,
    "completion_tokens": response.usage.completion_tokens,
    "total_tokens": response.usage.total_tokens,
}

# Defensive getattr right below:
cache_read = int(getattr(response.usage, "cache_read_input_tokens", 0) or 0)
cache_write = int(getattr(response.usage, "cache_creation_input_tokens", 0) or 0)
```

A provider can leave any of `prompt_tokens` / `completion_tokens` /
`total_tokens` as `None` on the `Usage` object. That `None` then lands
in `response.body["usage"]`, and the backend-routed OpenAI handler reads
it straight into arithmetic and the outcome ledger:

```python
usage = backend_response.body.get("usage", {})
output_tokens = usage.get("completion_tokens", 0)            # present key -> None, not the default
total_input_tokens = usage.get("prompt_tokens", optimized_tokens)  # present key -> None
...
uncached_input_tokens = max(0, total_input_tokens - cache_read_tokens - cache_write_tokens)  # None - int -> TypeError
...
RequestOutcome(..., output_tokens=output_tokens, ...)       # declared int; None crashes recording (e.g. prometheus += )
```

So an OpenAI-format request routed through a `--backend` (Bedrock /
Vertex / LiteLLM) whose provider returns a `None` count crashes on the
`max(0, None - ...)` subtraction, or later in outcome recording.
`.get(key, default)` does not help here because the key is present with
a `None` value, so the default never applies. This is the same class of
bug as the Anthropic-shape mapping and is fixed the same way.

## Fix

Coerce the three counts to `int` with the same defensive form already
used for the cache fields two lines down:

```python
usage_block: dict[str, Any] = {
    "prompt_tokens": int(getattr(response.usage, "prompt_tokens", 0) or 0),
    "completion_tokens": int(getattr(response.usage, "completion_tokens", 0) or 0),
    "total_tokens": int(getattr(response.usage, "total_tokens", 0) or 0),
}
```

No change for a normal integer usage; only a `None` (or absent) value
now becomes `0`.

## 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/backends/litellm.py`: `int`-coerce `prompt_tokens` /
`completion_tokens` / `total_tokens` in the `send_openai_message` usage
block.
- `tests/test_backends/test_litellm_cache_stats.py`: add
`test_none_core_counts_coerced_to_zero`, driving `send_openai_message`
with a `None`-count usage and asserting the block emits `int` `0`s.
- `CHANGELOG.md`: Bug Fixes entry.

## Testing

- [ ] 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
$ uvx ruff@0.15.17 check headroom/backends/litellm.py tests/test_backends/test_litellm_cache_stats.py
All checks passed!
$ uvx ruff@0.15.17 format --check headroom/backends/litellm.py tests/test_backends/test_litellm_cache_stats.py
2 files already formatted
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/backends/litellm.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17` / `uvx
mypy@1.20.2`. A full `pytest` OOMs this box (ML-stack import), so I
reproduced the field logic with a dependency-free script and left the
full pytest to CI.
- Exact command / steps: modeled the OLD (bare copy) and NEW
(`int(getattr(..., 0) or 0)`) field derivations for a `None` count, an
integer, and zero, then simulated the two downstream operations the
handler performs: `output_tokens += ...` and `max(0, prompt_tokens -
read - write)`.
- Observed result: OLD produced `None` and both downstream operations
raised `TypeError`; NEW produced `0` and both succeeded; an integer
count passed through unchanged. The added unit test drives
`send_openai_message` end to end (mocked `acompletion`) and asserts the
block emits `int` `0`s.
- Not tested: a live LiteLLM/Bedrock request that returns `None` counts;
the added test reuses the existing `_FakeUsage` / `_make_response` /
mocked-`acompletion` harness in `test_litellm_cache_stats.py`.

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

## Additional Notes

The "unit tests pass locally" box is unchecked because a local pytest
run imports the ML stack and OOMs this box; the added test reuses the
existing mocked-`acompletion` harness in `test_litellm_cache_stats.py`
and runs under the normal CI pytest job, and the behavior is
corroborated by the standalone proof above.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-08-11 23:48:43 -05:00
Abhay Singh
8a90523209
fix(transforms/adaptive-sizer): honor max_k on small-input fast path (#2319)
## Description

`compute_optimal_k` in the adaptive sizer takes a `max_k` argument
documented as "Never return more than this (None = no cap)". Every tier
honors that contract except the small-input fast path.

```python
n = len(items)
effective_max = max_k if max_k is not None else n

# Tier 1: Fast path
if n <= 8:
    return n
```

The near-total-redundancy branch returns `min(k, effective_max)`, the
standard tier ends with `k = max(min_k, min(k, effective_max))`, and the
zlib validator clamps to `max_k` too. Only the `n <= 8` fast path
returns the raw item count, ignoring the cap.

So a caller that passes a tight budget on a small list gets back more
items than it asked for. For example `compute_optimal_k(items_of_len_8,
max_k=5)` returns `8`, not `5`. The downstream compressor then keeps 8
items when it budgeted for 5, over-filling whatever search/log budget
the cap represented.

## Fix

Return `min(n, effective_max)` on the fast path, matching what the other
tiers already do:

```python
if n <= 8:
    return min(n, effective_max)
```

When `max_k` is `None`, `effective_max` is `n`, so `min(n, n) == n` and
the existing "return n unchanged" behavior is preserved. Only the capped
case changes.

## 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/transforms/adaptive_sizer.py`: clamp the `n <= 8` fast path
to `effective_max` so `max_k` is honored on small inputs.
- `tests/test_adaptive_sizer.py`: add `test_small_array_respects_max_k`
asserting a small array honors a tight `max_k` and is unchanged when the
cap is loose.
- `CHANGELOG.md`: Bug Fixes entry.

## Testing

- [ ] 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
$ uvx ruff@0.15.17 check headroom/transforms/adaptive_sizer.py tests/test_adaptive_sizer.py
All checks passed!
$ uvx ruff@0.15.17 format --check headroom/transforms/adaptive_sizer.py tests/test_adaptive_sizer.py
2 files already formatted
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/transforms/adaptive_sizer.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17` / `uvx
mypy@1.20.2`. A full `pytest` OOMs this box (ML-stack import), so I
reproduced the tier-1 logic with a dependency-free script and left the
full pytest to CI.
- Exact command / steps: ran both the OLD (`return n`) and NEW (`return
min(n, effective_max)`) fast-path logic for `n=8` across `max_k` in `{3,
5, 20, None}` in a standalone script.
- Observed result: OLD returned `8` for every case (ignoring the cap);
NEW returned `3, 5, 8, 8` respectively, matching the documented contract
and leaving the uncapped case unchanged.
- Not tested: the end-to-end search/log compressor path that supplies
`max_k`; the added unit test exercises `compute_optimal_k` directly, and
the standalone proof pins the fast-path arithmetic.

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

## Additional Notes

The "unit tests pass locally" box is unchecked because this box's
ML-stack import OOMs a local pytest run; the added test is a pure
dataclass-free check that runs under the normal CI pytest job, and the
behavior is corroborated by the standalone proof above.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-08-11 23:48:22 -05:00
Abhay Singh
c19e412b33
fix(proxy/bedrock): report uncached input tokens from backend usage, not the live-zone count (#2318)
## Description

On the buffered Anthropic-backend path (Bedrock / Vertex /
LiteLLM-anthropic, non-streaming) the proxy reports
`uncached_input_tokens` as `0` for essentially every cached multi-turn
request.

The handler reads the backend's Anthropic-shaped `usage` and then
re-derives the uncached count from a re-tokenized live-zone count:

```python
usage = backend_response.body.get("usage", {})
...
attempted_input_tokens = tokenizer.count_messages(
    original_client_messages[frozen_message_count:]   # the LIVE ZONE only
)
...
uncached_input_tokens = max(0, attempted_input_tokens - cr_tokens - cw_tokens)
```

`attempted_input_tokens` is deliberately the **live-zone** token count
(the new-turn messages after the frozen prefix), kept as the denominator
for the active-compression ratio. It is not the full request size.
Subtracting the whole-request cache metrics (`cache_read` +
`cache_creation`) from it is nonsensical: on any turn whose cached
prefix is larger than the new turn -- the normal multi-turn case --
`attempted_input_tokens - cr - cw` goes negative and `max(0, ...)`
clamps it to `0`. So the uncached input, which feeds the cost/uncached
dashboards, is reported as `0`.

Meanwhile the backend already computes the correct value.
`_anthropic_usage_from_litellm` (added in #1345) sets:

```python
"input_tokens": max(prompt_tokens - cache_read - cache_write, 0),
```

i.e. `usage.input_tokens` is exactly the uncached input, in Anthropic
semantics. The direct-API path already uses it (`uncached_input_tokens =
usage.get("input_tokens", 0)`); the backend path was the one re-deriving
it.

## Fix

Prefer the backend's `usage.input_tokens`, matching the direct-API path
-- but guard on the backend actually reporting it, so a backend that
omits `input_tokens` (or sends `null`) does not silently record
`uncached=0`:

```python
_reported_input_tokens = usage.get("input_tokens")
if _reported_input_tokens is not None:
    uncached_input_tokens = int(_reported_input_tokens)
else:
    # Backend did not report it: fall back to the live-zone derivation,
    # which is never worse than the previous behaviour.
    uncached_input_tokens = max(0, attempted_input_tokens - cr_tokens - cw_tokens)
```

A plain `usage.get("input_tokens", 0)` would have re-introduced the `0`
on any backend that doesn't translate the prompt-token field; the guard
keeps the authoritative value when present and the old estimate
otherwise. `attempted_input_tokens` is unchanged and still used as the
compression-ratio denominator.

## 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/anthropic.py`: set `uncached_input_tokens`
from `usage.input_tokens` on the buffered anthropic-backend path when
the backend reports it; otherwise fall back to the prior live-zone
derivation.
- `tests/test_backend_nonstreaming_cache_metrics.py`: added two tests
driving the buffered path -- one asserting the recorded
`RequestOutcome.uncached_input_tokens == usage.input_tokens` (with a
live zone far smaller than the cache), and one asserting that when the
backend omits `input_tokens` the value falls back to the non-zero
live-zone derivation instead of collapsing to `0`.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check`)
- [x] Type checking passes (`mypy`)
- [x] New tests added for the fix and the fallback guard

### Test Output

```text
# Fail-before, primary fix (old max(0, attempted - cr - cw)):
tests/..::test_anthropic_backend_nonstreaming_uncached_from_usage_input_tokens
  -> uncached=0, expected 1000  (FAIL)

# Fail-before, safety guard (naive usage.get("input_tokens", 0)):
tests/..::test_anthropic_backend_nonstreaming_uncached_falls_back_when_input_tokens_absent
  -> assert 0 > 0  (FAIL)

# Pass-after (guarded fix):
tests/test_backend_nonstreaming_cache_metrics.py  6 passed

# uvx ruff@0.15.17 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.17 and mypy 1.20.2 via uvx.
- Steps: drove the buffered anthropic-backend path end to end via
`create_app` + FastAPI `TestClient` with a mock `AnyLLMBackend`
returning an Anthropic-shaped body, and spied on
`HeadroomProxy._record_request_outcome` to capture the recorded
`RequestOutcome`. With `usage.input_tokens=1000`, `cache_read=500`,
`cache_write=200` and a two-token live zone, the old derivation recorded
`uncached=0`; the fix records `1000`. With `input_tokens` omitted from
`usage`, the naive default records `0` while the guarded fallback
records the non-zero live-zone count.
- Observed result: `RequestOutcome.uncached_input_tokens` now reflects
the real uncached input on cached backend turns, and never regresses
below the previous estimate when a backend omits the field.
- Not tested: a live Bedrock/Vertex call (no cloud credentials here).
The value flows through the same `RequestOutcome` funnel the proxy uses
for cost/telemetry, exercised directly.

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

Rebased onto current `main` and squashed to a single commit. The
fallback guard is the only behavioural difference from a plain "use
`usage.input_tokens`" change: it ensures the fix cannot regress a
backend that doesn't report the field back to `uncached=0`.
2026-08-11 23:47:33 -05:00
Abhay Singh
1f2c681c0b
fix(proxy/batch): don't crash an OpenAI batch on a valid-JSON non-object line (#2316)
## Description

A single malformed line can abort compression for an entire OpenAI batch
upload.

`_compress_batch_jsonl` parses each JSONL line and immediately reads the
request body:

```python
request_obj = json.loads(line)
body = request_obj.get("body", {})
messages = body.get("messages", [])
...
except json.JSONDecodeError as e:
    ...
    compressed_lines.append(line)  # keep original on error
```

`json.loads` returns a valid JSON *value*, which isn't necessarily an
object. A line like `[1, 2, 3]`, `"hello"`, or `null` parses fine, but
`request_obj.get(...)` on a list/str/None raises `AttributeError`.
Likewise a request object whose `body` is present but not a dict
(`{"body": "..."}`) makes `body.get("messages", ...)` raise. The
surrounding `except json.JSONDecodeError` doesn't catch
`AttributeError`, so the exception propagates out of
`_compress_batch_jsonl` and the whole batch-create request fails.

This is the OpenAI batch upload path; the file is user-supplied, so a
single stray non-object line takes the batch down instead of just being
passed through like the other non-compressible cases already are.

## Fix

Guard for non-object shapes and pass them through unchanged:

```python
request_obj = json.loads(line)
if not isinstance(request_obj, dict):
    compressed_lines.append(line)
    total_requests += 1
    continue
body = request_obj.get("body", {})
if not isinstance(body, dict):
    body = {}
```

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

- `headroom/proxy/handlers/batch.py`: in `_compress_batch_jsonl`, pass
through a non-dict parsed line and coalesce a non-dict `body` to `{}`.
- `tests/test_proxy_handlers_batch.py`: new test that array / string /
null lines and a non-dict `body` pass through without crashing and are
preserved.
- `CHANGELOG.md`: Bug Fixes entry.

## Testing

- [ ] 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
$ uvx ruff@0.15.17 check headroom/proxy/handlers/batch.py tests/test_proxy_handlers_batch.py
All checks passed!
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/proxy/handlers/batch.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17` / `uvx
mypy@1.20.2`. A full `pytest` OOM-kills this box (ML stack import), so I
reproduced the per-line handling with a dependency-free script and left
the full pytest to CI.
- Exact command / steps: ran lines `[1,2,3]`, `"hello"`, `null`, and
`{"body": "not-a-dict"}` through the OLD (bare `.get`) and NEW
(isinstance-guarded) logic, plus a normal request and a `not-json` line
as controls.
- Observed result: OLD raises `AttributeError` on each non-object line
and on the non-dict body; NEW passes the non-object lines through
unchanged, coalesces the non-dict body to `{}`, still processes a normal
request, and still passes `not-json` through as a JSON-decode error.
- Not tested: a live OpenAI batch upload end-to-end; full local `pytest`
deferred to CI (OOM).

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

## Additional Notes

The "unit tests pass locally" box is unchecked because the full suite
imports the ML stack, which I can't run here. The new test reuses the
existing `DummyBatchHandler` / `install_batch_support_modules` harness
in `tests/test_proxy_handlers_batch.py` (the same one the neighbouring
invalid-line test uses), so it runs under the normal CI pytest job;
behaviour is additionally verified by the standalone proof above.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-08-11 23:47:09 -05:00
Chester
c471800e8e
fix(memory): keep vector metadata in sync (#2295)
## Description

Fixes #2296.

Metadata-only memory updates can leave the primary store, vector-index
metadata, and cache inconsistent. TrafficLearner also performs an atomic
SQLite evidence increment that bypasses normal secondary-index refresh.

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

- Refresh vector metadata for HierarchicalMemory metadata-only,
importance, and entity-reference updates.
- Add a LocalBackend path that reloads a memory from the primary store
and refreshes vector metadata plus cache state.
- Preserve the atomic TrafficLearner SQL evidence increment, then
refresh secondary state only when a row was updated.
- Keep refresh failures fail-open and distinguish them from
primary-store increment failures in logs.
- Add backend-neutral contract tests instead of inspecting a specific
vector adapter private field.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`) — verified locally: mypy
1.20.2, no issues in 504 source files
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
141 passed, 1 skipped
ruff check: passed
ruff format --check: passed
```

The first CI run exposed one backend-specific test assertion against
HNSW private state while CI used SQLiteVectorIndex. Commit a1aff399
removes that assertion and keeps the backend-neutral mock contract test.

## Real Behavior Proof

- Environment: macOS, Python 3.13, current Headroom main.
- Exact command / steps: update a Memory with metadata only through
HierarchicalMemory, assert the vector index receives the updated Memory,
then perform a TrafficLearner evidence bump and assert LocalBackend
refresh is called only for an existing row.
- Observed result: metadata-only update refreshes vector metadata
without re-embedding content; evidence bump remains atomic and refreshes
vector/cache state; an unknown memory ID triggers no refresh.
- Not tested: remote memory backends, full repository suite, or live
multi-process writers against one SQLite database.

## Review Readiness

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

## Checklist

- [x] My code follows the project style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented code where the behavior is not self-explanatory
- [x] I have made corresponding documentation changes (N/A — internal
bug fix, no user-facing docs/changelog impact)
- [x] My changes generate no new warnings
- [x] I have added tests that prove the fix is effective
- [x] New and existing focused unit tests pass locally
- [x] I have updated CHANGELOG.md if applicable (N/A — internal bug fix,
no user-facing docs/changelog impact)

## Screenshots (if applicable)

Not applicable.

## Additional Notes

Draft for storage-owner feedback on the refresh API and write overhead.
The refresh reuses the existing embedding and does not invoke the
embedder.
2026-08-11 23:46:13 -05:00
Abhay Singh
a24fe7dcbf
fix(learn): stop classifying a successful exit code 0 as an error (#2289)
## Description

`is_error_content` classifies successful shell commands as errors,
inflating the failure stats that `headroom learn` reports.

The heuristic flags a tool result as an error when it contains any of a
list of substrings, one of which is the bare `"exit code"`:

```python
indicators = [
    ..., "timed out", "exit code", "FileNotFoundError",
]
return any(ind in snippet for ind in indicators)
```

But agent harnesses (Codex, Grok, opencode, ...) append `exit code 0` to
the output of every **successful** shell command. `"exit code" in
snippet` is `True` for `exit code 0`, so those successes are counted as
failures.

That is not cosmetic: `is_error_content` sets `ToolCall.is_error`, which
feeds:
- the per-project failure rate the digest shows the LLM
(`_build_digest`: "N failures (X%)"), and
- loop classification (`detect_loops` treats a group as an *error loop*
when ≥ half its calls are errors),

so a project where most shell commands succeed can read as one riddled
with failures, biasing the learned recommendations.

## Fix

Match a **nonzero** exit code instead of the bare substring:

```python
_NONZERO_EXIT_RE = re.compile(r"exit code:?\s*(?!0\b)\d", re.IGNORECASE)
...
if any(ind in snippet for ind in indicators):
    return True
return bool(_NONZERO_EXIT_RE.search(snippet))
```

`exit code 0` no longer matches. A nonzero code still does — and, as a
small bonus, the case-insensitive regex now also catches `Exit code: 1`
(colon + capitalized), which the old case-sensitive lowercase substring
missed.

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

- `headroom/learn/_shared.py`: replace the `"exit code"` substring
indicator with a nonzero-exit-code regex (`_NONZERO_EXIT_RE`) checked
after the other indicators.
- `tests/test_learn/test_integration.py`: new tests that `exit code 0`
is not an error and a nonzero code (any casing / with a colon) still is.
- `CHANGELOG.md`: Bug Fixes entry.

## Testing

- [ ] 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
$ uvx ruff@0.15.17 check headroom/learn/_shared.py tests/test_learn/test_integration.py
All checks passed!
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/learn/_shared.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17` / `uvx
mypy@1.20.2`. A full `pytest` OOM-kills this box (ML stack import), so I
reproduced the classifier with a dependency-free script and left the
full pytest to CI.
- Exact command / steps: ran a successful output ending `Process
finished with exit code 0`, plus several nonzero-code failures (`exit
code 1`, `Exit code: 127`, `exit code 137`) and control strings, through
the OLD substring form and the NEW regex form.
- Observed result: OLD flags `exit code 0` as an error; NEW returns
`False` for it, still returns `True` for every nonzero code (including
the colon/capitalized form the old lowercase substring missed), and
leaves the other indicators unchanged.
- Not tested: a full `learn` run over a real history; full local
`pytest` deferred to CI (OOM).

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

## Additional Notes

The "unit tests pass locally" box is unchecked because the full suite
imports the ML stack, which I can't run here. The new tests live
alongside the existing `is_error_content` false-positive/true-positive
tests in `tests/test_learn/test_integration.py`, so they run under the
normal CI pytest job; behaviour is additionally verified by the
standalone proof above.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-08-11 23:45:32 -05:00
Abhay Singh
e240df2b69
fix(learn/grok): detect a Windows absolute project path (#2283)
## Description

The Grok `learn` plugin can't detect a Windows project path, so on
Windows it attributes every project's learnings to the wrong directory.

`discover_projects` decodes the URL-encoded workspace directory name
(which is the recorded absolute cwd) and decides whether it's absolute
with a `startswith("/")` check:

```python
decoded = unquote(workspace_dir.name)
project_path = Path(decoded) if decoded.startswith("/") else Path.cwd()
```

A Windows absolute path (e.g. `C:\Users\me\proj`, URL-encoded as
`C%3A%5CUsers%5Cme%5Cproj`) does not start with `/`, so the check fails
and `project_path` silently falls back to `Path.cwd()`. The learnings
are then attributed to whatever directory `headroom learn` happened to
run in, and the plugin looks for `GROK.md` / `AGENTS.md` under the wrong
path (so it never finds them).

The rest of the codebase already handles Windows drive-letter paths:
`memory/traffic_learner.py` guards with `ref.startswith("/") or
(len(ref) > 2 and ref[1] == ":")`, and the Claude plugin has a full
Windows-aware decode plus a session-cwd fallback. The Grok plugin's
naive `startswith("/")` is the outlier.

## Fix

Use `Path(decoded).is_absolute()`, which recognises both POSIX (`/...`)
and Windows drive-letter (`C:\...`) absolute paths on their respective
platforms:

```python
decoded_path = Path(decoded)
project_path = decoded_path if decoded_path.is_absolute() else Path.cwd()
```

On POSIX this is equivalent to the old check (no behavior change); on
Windows the drive-letter path now resolves correctly instead of
collapsing to cwd.

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

- `headroom/learn/plugins/grok.py`: use `Path(decoded).is_absolute()`
instead of `decoded.startswith("/")` in `discover_projects`.
- `tests/test_learn_grok_plugin.py`: new test that an absolute workspace
path resolves to that path (platform-aware: the Windows branch is the
real guard, the POSIX branch confirms no regression).
- `CHANGELOG.md`: Bug Fixes entry.

## Testing

- [ ] 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
$ uvx ruff@0.15.17 check headroom/learn/plugins/grok.py tests/test_learn_grok_plugin.py
All checks passed!
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/learn/plugins/grok.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: **Windows 11**, Python 3.12, `uvx ruff@0.15.17` / `uvx
mypy@1.20.2`. This bug is Windows-specific, and I ran the proof on
Windows where it actually reproduces. A full `pytest` OOM-kills this box
(ML stack import), so I reproduced the decode+resolve with a
dependency-free script and left the full pytest to CI.
- Exact command / steps: took the URL-encoded workspace name
`C%3A%5Cproj%5Capp`, decoded it, and ran it through the OLD
`startswith("/")` and NEW `is_absolute()` resolution. Confirmed directly
that `Path(r"C:\proj\app").is_absolute()` is `True` while
`r"C:\proj\app".startswith("/")` is `False`.
- Observed result: OLD → `cwd-fallback` (wrong); NEW → `C:\proj\app`
(correct). A relative workspace name still falls back to cwd under both;
a POSIX abs path resolves identically under both.
- Not tested: a live Grok CLI history on Windows end-to-end; full local
`pytest` deferred to CI (OOM).

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

## Additional Notes

The "unit tests pass locally" box is unchecked because the full suite
imports the ML stack, which I can't run here. Because the bug is
Windows-specific and `Path.is_absolute()` is platform-dependent, the
added test is platform-aware: on Windows (where I verified the fix) its
drive-letter branch is the real regression guard; on the Linux CI runner
it exercises the POSIX branch, confirming the change doesn't regress the
existing behavior. The standalone proof above covers the Windows fix
directly.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-08-11 23:45:13 -05:00
Abhay Singh
a30db2cae4
fix(proxy/openai): don't crash the Responses memory tool loops on null arguments (#2273)
## Description

The OpenAI Responses memory tool-execution loops crash when a
`function_call` item has `"arguments": null`.

Both loops parse the arguments the same way:

```python
args_str = fc.get("arguments", "{}")
try:
    args = json.loads(args_str)
except json.JSONDecodeError:
    args = {}
```

`dict.get("arguments", "{}")` only substitutes `"{}"` when the key is
*missing*. A `function_call` item with a present-but-null `arguments`
(which upstreams emit for a tool call with no arguments, or a
partial/streamed item) makes `args_str` be `None`, and
`json.loads(None)` raises `TypeError` — not `JSONDecodeError`, so the
`except` doesn't catch it and the streaming request handler blows up.

`parse_tool_call` in `headroom/ccr/tool_injection.py` already catches
this exact case (`except (json.JSONDecodeError, TypeError)`, with a
comment noting `json.loads(None)`), so the hazard is known in the
codebase; these two loops just weren't hardened.

## Fix

Coalesce the arguments string with `or "{}"` (so a null value becomes
`"{}"`) and add `TypeError` to the `except` for defence in depth, at
both sites:

```python
args_str = fc.get("arguments") or "{}"
try:
    args = json.loads(args_str)
except (json.JSONDecodeError, TypeError):
    args = {}
```

Real arguments parse exactly as before.

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

- `headroom/proxy/handlers/openai.py`: coalesce `fc.get("arguments")`
with `or "{}"` and catch `TypeError` in both OpenAI Responses memory
tool-execution loops.
- `tests/test_openai_responses_null_arguments.py`: source-level
regression guard that the vulnerable form is gone and both loops use the
null-safe form.
- `CHANGELOG.md`: Bug Fixes entry.

## Testing

- [ ] 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
$ uvx ruff@0.15.17 check headroom/proxy/handlers/openai.py tests/test_openai_responses_null_arguments.py
All checks passed!
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/proxy/handlers/openai.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17` / `uvx
mypy@1.20.2`. A full `pytest` OOM-kills this box (ML stack import), so I
reproduced the parse with a dependency-free script and left the full
pytest to CI.
- Exact command / steps: ran a `function_call` item with `"arguments":
null` (plus real args and a missing-key case) through the OLD
`get("arguments", "{}")` + `json.loads` and the NEW `get("arguments") or
"{}"` + `(JSONDecodeError, TypeError)` logic.
- Observed result: OLD raises `TypeError` (`json.loads(None)`); NEW
returns `{}` for the null case, parses real args to `{"content": "hi"}`,
and returns `{}` for the missing key.
- Not tested: a live Responses stream emitting a null-arguments tool
call; full local `pytest` deferred to CI (OOM).

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

## Additional Notes

The "unit tests pass locally" box is unchecked because the full suite
imports the ML stack, which I can't run here. Both fixed sites are deep
inside streaming request handlers, so the added test is a source-level
guard (it reads the file, without importing the ML stack) and runs under
the normal CI pytest job; the behaviour is verified by the standalone
proof above. This is the OpenAI-Responses sibling of the same
null-`arguments` `json.loads(None)` hazard the memory tool adapter also
had.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-08-11 23:44:54 -05:00
Zhenjia ZHOU
6840153473
fix(tokenizer): price CJK in the Rust fixed-ratio estimator (Python parity) (#2260)
## Description

The Rust `EstimatingCounter` priced every character at the Latin
`chars_per_token` (default 4.0), but the Python `EstimatingTokenCounter`
it explicitly mirrors already prices dense scripts (CJK / Kana / Hangul
/ full-width) at `CHARS_PER_TOKEN_CJK = 1.5` — so Rust under-counted CJK
by ~2.5× and the two implementations diverged. #2080 fixed only the
Python path; the Rust module doc still says "Mirrors
…EstimatingTokenCounter" while it no longer did.

This is the live count path for every provider-calibrated fixed-ratio
counter (Anthropic 3.5, Google / Cohere 4.0, Moonshot 3.1), so CJK
traffic was mis-budgeted (savings/estimates skewed). This counts
dense-script codepoints — the same 8 `CJK_PATTERN` Unicode ranges Python
uses — and prices them separately: `int(other / ratio + cjk / 1.5 +
0.5)`. Non-CJK output is byte-identical.

## Type of Change

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

## Changes Made

- `crates/headroom-core/src/tokenizer/estimator.rs`: add
`is_dense_script(c)` (8 ranges byte-mirroring Python `CJK_PATTERN`) and
`CHARS_PER_TOKEN_CJK = 1.5`; `count_text` prices dense-script chars
separately from Latin.
- Reference tests for CJK / kana / full-width / mixed — values
cross-checked against Python.

## Testing

- [x] Unit tests pass (`cargo test`)
- [x] Linting passes (`cargo clippy` / `cargo fmt`)
- [x] New tests added
- [x] Verified against Python (see Real Behavior Proof)

### Test Output

```text
$ cargo test -p headroom-core --lib tokenizer
test result: ok. 45 passed; 0 failed
$ cargo clippy / fmt   # clean
```

## Real Behavior Proof

- Environment: macOS (Darwin), Rust via cargo + Python in a uv venv,
branch `feat/tokenizer-estimator-cjk` off `main`.
- Exact command / steps: ran the same inputs through Python
`EstimatingTokenCounter(4.0).count_text` and the Rust
`EstimatingCounter::default().count_text`, comparing outputs.
- Observed result: identical on every input — `数据库` → 2, `数据库连接失败` → 5
(was 2 under the old flat 7/4), `ひらが` → 2, full-width `API` → 2 vs plain
`API` → 1, mixed `api数据` → 2. The existing non-CJK reference tests
(`a`×40 → 10, Claude-3.5 densities, `héllo`/emoji char-count) are
unchanged, confirming no ASCII regression.
- Not tested: nothing further — parity is verified directly against the
Python reference (same values on both sides).

## Review Readiness

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

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation — N/A
(internal estimator)
- [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
- [ ] I have updated the CHANGELOG.md — happy to add an entry if
preferred.

## Additional Notes

- Completes #2080 (which priced CJK in the Python fixed-ratio estimator)
on the Rust side, restoring Rust↔Python parity for the density
estimator.

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-08-11 23:44:31 -05:00
Rod Boev
2483f57002
fix(gemini): resolve native CCR retrieval calls (#2253)
## Description

Buffered native Gemini requests currently return `headroom_retrieve`
function calls to the client because `GeminiHandlerMixin` never invokes
the shared CCR response handler. This wires native Gemini request and
response translation into the provider handler while reusing the
existing Google CCR extraction, retrieval, round-limit, mixed-tool, and
`functionResponse` machinery.

Streaming native Gemini and Gemini's OpenAI-compatible
`MALFORMED_FUNCTION_CALL` behavior remain separate surfaces.

This follows the current support boundary documented in
https://github.com/headroomlabs-ai/headroom/pull/2044.

Closes #2041

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

## Changes Made

- Invoke `CCRResponseHandler` for successful buffered native Gemini
responses containing `headroom_retrieve`.
- Build Gemini-native continuation requests with the model
`functionCall` and matching user `functionResponse`.
- Inject the existing Google CCR function declaration while preserving
sibling Gemini tool configurations.
- Preserve mixed client-tool responses, streaming requests, non-CCR
responses, and upstream error bodies.
- Preserve Google `functionCall.id` as `functionResponse.id` through the
shared CCR identity contract.
- Leave streaming requests outside buffered CCR injection.
- Fail closed when an exclusive CCR call remains unresolved after
continuation.
- Update the CCR documentation to describe buffered native Gemini
support and the mixed-tool boundary.

## Testing

- [x] Unit tests pass (`uv run pytest tests/test_proxy_handlers_batch.py
-k "gemini_native_ccr or gemini_stream" -q`)
- [x] Linting passes (`uv run ruff check
headroom/proxy/handlers/gemini.py tests/test_proxy_handlers_batch.py`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for new functionality when applicable
- [ ] Manual testing performed

### Test Output

```text
Focused tests: `14 passed, 22 deselected` for `uv run pytest tests/test_proxy_handlers_batch.py -k "gemini_native_ccr or gemini_stream" -q`; `43 passed` for `uv run pytest tests/test_ccr_response_handler.py tests/test_ccr_response_handler_extra.py -q`. Scoped Ruff check and format check passed for the changed Python files. Full repository format remains blocked by pre-existing formatting outside this target.
```

## Real Behavior Proof

- Environment: Windows, Python 3.12, commit `7b3a92a8`; local
native-shape behavioral harness with no Gemini credentials.
- Exact command / steps: run the focused native Gemini handler tests,
then capture a live `generateContent` request and continuation after a
Gemini credential is available.
- Observed result: local tests prove the buffered `functionCall` to
`functionResponse` continuation, mixed-tool preservation, declaration
preservation, error forwarding, and retrieval-result shapes.
- Not tested: owner-reaching live Gemini continuation and native Gemini
streaming CCR continuation.

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

## Additional Notes

The patch keeps Gemini wire translation in `GeminiHandlerMixin` and
extends the provider-neutral CCR identity fields for Google call ids.
Native streaming continuation and the OpenAI-compatible Gemini round-two
failure are outside this PR.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-08-11 23:40:49 -05:00
Rod Boev
5568d738af
fix(ci): publish latest from the root Docker manifest (#2252)
## Description

A successful root Docker image can miss `:latest` when any optional
variant manifest fails. The release workflow currently gates the
standalone `promote-latest` job on the aggregate `docker-manifest`
matrix, so one sibling failure skips promotion even when the signed root
amd64+arm64 manifest exists.

This moves `:latest` promotion into the successful root manifest cell.
Optional variant failures remain visible and continue to fail their
jobs, but they no longer suppress the image used by `headroom install`,
which defaults to `ghcr.io/headroomlabs-ai/headroom:latest`.

Refs #1583

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

- Publish `:latest` from the root `docker-manifest` matrix cell after
its versioned multi-architecture manifest is created and signed.
- Remove the aggregate `promote-latest` dependency that allowed
unrelated variant failures to suppress publication.
- Keep all existing root, slim, code, and nonroot variants.
- Preserve native linux/amd64 and linux/arm64 manifest assembly.
- Add a focused workflow-contract regression test.

## Testing

- [x] Unit tests pass (`uv run pytest tests/test_release_workflows.py -q
-k "docker or latest"`)
- [x] Linting passes (`uv run ruff check
tests/test_release_workflows.py`)
- [x] Formatting passes (`uv run ruff format --check
tests/test_release_workflows.py`)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
Focused checks pass: `5 passed, 34 deselected` for `uv run pytest tests/test_release_workflows.py -q -k "docker or latest"`; the full test file has one unrelated Windows `FileNotFoundError` in `test_no_native_tls_in_wheel_build_tree` because its external command is unavailable. `uv run ruff check tests/test_release_workflows.py` and `uv run ruff format tests/test_release_workflows.py --check` pass. The repository-wide format check reports eight pre-existing files outside this target. Proof report: `D:\Repos\.claude\pr-sweep\headroom-PR-TARGET-1583-PROOF.md`.
```

## Real Behavior Proof

- Environment: Windows, Python managed by `uv`, repository
workflow-contract tests; production publication owned by GitHub Actions
and GHCR.
- Exact command / steps: run the focused release-workflow tests; after
merge, inspect the next Docker release run and execute `docker buildx
imagetools inspect ghcr.io/headroomlabs-ai/headroom:latest` without
registry login.
- Observed result: local workflow-contract proof passes for root-owned
promotion and both native architecture inputs; live GHCR publication
remains unverified until the next release.
- Not tested: production GHCR publication before merge.

## Review Readiness

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

## Checklist

- [ ] My code follows the project's style guidelines
- [ ] I have performed a self-review of my code
- [x] Workflow comments explain the non-obvious root-only promotion
boundary
- [x] Documentation outside the changelog is unchanged because the CLI
image reference is already correct
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective
- [x] New and existing focused tests pass locally with my changes

## Screenshots (if applicable)

Not applicable.

## Additional Notes

Release run
https://github.com/headroomlabs-ai/headroom/actions/runs/28404020512
demonstrated the cascade: the root manifest succeeded, a nonroot
manifest failed during Buildx setup, and `promote-latest` was skipped.

PR CI can prove the workflow dependency and architecture-preservation
contracts. GHCR availability and anonymous package visibility require
the next production release plus an unauthenticated registry inspection.
2026-08-11 23:40:02 -05:00
Abhay Singh
3bb02f8f75
fix(transforms/smart_crusher): don't crash on a tool call with a null function (#2232)
## Description

A tool call whose `function` field is explicitly `null` crashes
SmartCrusher's per-request context extraction.

`_extract_context_from_messages` (called at the top of `apply()`) walks
recent assistant tool calls:

```python
for tc in msg.get("tool_calls", []):
    if isinstance(tc, dict):
        func = tc.get("function", {})
        args = func.get("arguments", "")
```

`dict.get("function", {})` only substitutes `{}` when the key is
**missing**. When the key is present but `null` — `{"id": "1", "type":
"function", "function": null}`, which clients emit for a partial or
streamed tool call — `func` is `None`, and `None.get("arguments")`
raises `AttributeError`. That propagates out of
`_extract_context_from_messages` and crashes `apply()` for the entire
request, so the request either errors or has to fail open to
uncompressed with a logged traceback.

The sibling `_build_tool_name_index` in the same file already guards
this exact shape with `(tc.get("function") or {})` — this call site just
wasn't updated to match.

## Fix

Use the same null-safe form:

```python
func = tc.get("function") or {}
```

`None` (and any other falsy value) now collapses to `{}`, the null tool
call contributes no context, and extraction continues to the next call.

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

- `headroom/transforms/smart_crusher.py`: `tc.get("function", {})` →
`tc.get("function") or {}` in `_extract_context_from_messages`.
- `tests/test_transforms/test_smart_crusher_bugs.py`: new test asserting
a `{"function": null}` tool call doesn't crash extraction and later
calls are still read.
- `CHANGELOG.md`: Bug Fixes entry.

## Testing

- [ ] 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
$ uvx ruff@0.15.17 check headroom/transforms/smart_crusher.py tests/test_transforms/test_smart_crusher_bugs.py
All checks passed!
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/transforms/smart_crusher.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17` / `uvx
mypy@1.20.2`. A full `pytest` OOM-kills this box (ML stack import), so I
reproduced the extraction loop with a dependency-free script and left
the full pytest to CI.
- Exact command / steps: ran an assistant message with tool calls
`[{"function": null}, {"function": {"arguments": "keep-me"}}]` through
the OLD `get("function", {})` loop and the NEW `get("function") or {}`
loop.
- Observed result: OLD raises `AttributeError` on the null function; NEW
skips it and returns `"keep-me"` from the following call.
- Not tested: a live proxy request carrying a null-function tool call;
full local `pytest` deferred to CI (OOM).

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

## Additional Notes

The "unit tests pass locally" box is unchecked because the full suite
imports the ML stack, which I can't run here. The new test uses the
existing `_make_crusher` helper in
`tests/test_transforms/test_smart_crusher_bugs.py`, so it runs under the
normal CI pytest job; behaviour is additionally verified by the
standalone proof above.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-08-11 23:39:37 -05:00
Abhay Singh
f840d5f2fe
fix(memory): make explicit-project and user store keys collision-resistant (#2231)
## Description

Two of the memory storage router's key-derivation paths can pool
distinct identities into one store.

`ProjectResolver._identity_from_cwd` builds a collision-resistant key by
appending a `sha256` digest to the sanitized basename:

```python
safe_basename = cls._sanitize_basename(basename) or "project"
digest = hashlib.sha256(normalised.encode("utf-8")).hexdigest()[:16]
key = f"{safe_basename}-{digest}"
```

But the two non-cwd paths use the bare sanitized basename as the key:

```python
# Tier 1 — explicit x-headroom-project-id
safe = self._sanitize_basename(explicit)
if safe:
    return safe, explicit           # <-- no digest

# USER mode
user_safe = ProjectResolver._sanitize_basename(ctx.base_user_id) or "default"
db_path = self._config.root_dir / "users" / user_safe / "memory.db"   # <-- no digest
```

`_sanitize_basename` maps every disallowed character to a single dash,
so distinct inputs collapse to the same basename:

- `acme/api` and `acme api` (and `acme@api`) all → `acme-api`
- user ids `alice/qa` and `alice qa` → `alice-qa`

Both the project key (`root/projects/<key>/memory.db`) and the USER key
(`root/users/<key>/memory.db`) are derived directly from that basename,
so two distinct project ids — or, in USER mode, two distinct **users** —
resolve to the same `memory.db` and share each other's memories. USER
mode exists specifically to isolate users, so this is a cross-user
data-isolation leak; the explicit-project-id path is the same leak
across projects. Both are client-controlled (`x-headroom-project-id` /
`x-headroom-user-id` headers), so the collision is easy to hit and could
even be provoked deliberately.

## Fix

Append the same digest of the raw id to both keys, exactly as
`_identity_from_cwd` does, keeping the sanitized basename as a
human-readable prefix:

```python
digest = hashlib.sha256(explicit.encode("utf-8")).hexdigest()[:16]
return f"{safe}-{digest}", explicit
```

```python
digest = hashlib.sha256(ctx.base_user_id.encode("utf-8")).hexdigest()[:16]
user_key = f"{user_safe}-{digest}"
db_path = self._config.root_dir / "users" / user_key / "memory.db"
```

Distinct ids now always land on distinct stores; the same id remains
stable across calls.

**Migration note:** this changes the on-disk key format for the
explicit-project and USER stores (`<basename>` → `<basename>-<digest>`).
Memories written under the old bare-basename paths are not migrated; the
router will start a fresh store at the new path. GLOBAL and cwd-derived
PROJECT stores (which already carried the digest) are unaffected.
Flagging this explicitly so you can decide whether a migration shim is
wanted before merge.

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

- `headroom/memory/storage_router.py`: append a `sha256` digest to the
explicit-project-id key (Tier 1) and the USER-mode key, matching
`_identity_from_cwd`.
- `tests/test_memory_storage_router.py`: update the Tier-1 key assertion
to the prefix+digest form; add collision regression tests for the
explicit-project and USER paths.
- `CHANGELOG.md`: Bug Fixes entry (including the migration note).

## Testing

- [ ] 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
$ uvx ruff@0.15.17 check headroom/memory/storage_router.py tests/test_memory_storage_router.py
All checks passed!
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/memory/storage_router.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17` / `uvx
mypy@1.20.2`. A full `pytest` OOM-kills this box (ML stack import), so I
reproduced the key derivation with a dependency-free script mirroring
`_sanitize_basename` + the digest, and left the full pytest to CI.
- Exact command / steps: derived keys for `alice/qa` and `alice qa`
under the OLD bare-basename scheme and the NEW digest scheme.
- Observed result: OLD → both `alice-qa` (identical → shared store); NEW
→ `alice-qa-7e02fc2dfbc447b4` vs `alice-qa-4c9241514a374ba3` (distinct),
stable per input, with the `alice-qa-` prefix retained.
- Not tested: a live proxy with two colliding tenants; full local
`pytest` deferred to CI (OOM).

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

## Additional Notes

The "unit tests pass locally" box is unchecked because the full suite
imports the ML stack, which I can't run here. The changed/added tests
use the existing `tests/test_memory_storage_router.py` harness so they
run under the normal CI pytest job; behaviour is additionally verified
by the standalone proof above. I updated
`test_resolver_tier1_explicit_project_id_wins` to assert the new
prefix+digest key. Happy to add a migration shim (read the old path if
the new one is empty) if you'd prefer that over the fresh-store
behavior.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-08-11 23:39:15 -05:00
Abhay Singh
29d8a5e563
fix(learn/gemini): stop double-counting session tokens (#2230)
## Description

The Gemini `learn` scanner inflates every session's token totals by
double-counting.

In `_parse_messages` the per-message usage accumulation is:

```python
usage = msg.get("usageMetadata", msg.get("usage", {}))
if isinstance(usage, dict):
    total_input_tokens += usage.get("promptTokenCount", 0)
    total_input_tokens += usage.get("cachedContentTokenCount", 0)
    total_output_tokens += usage.get("candidatesTokenCount", 0)
    total_output_tokens += (
        usage.get("totalTokenCount", 0) - usage.get("promptTokenCount", 0)
        if usage.get("totalTokenCount")
        else 0
    )
```

Both additions on each side double-count, per Gemini's `usageMetadata`
semantics:

- `cachedContentTokenCount` is the cached **subset** of
`promptTokenCount`, not tokens on top of it. Adding both counts the
cached input twice.
- `totalTokenCount == promptTokenCount + candidatesTokenCount`, so
`totalTokenCount - promptTokenCount` is just `candidatesTokenCount`
again. Adding it on top of `candidatesTokenCount` counts the output
twice.

For a turn with 1000 prompt tokens (300 cached) and 500 output tokens
(`totalTokenCount` 1500), the scanner records input 1300 and output 1000
instead of 1000 / 500 — so both totals are materially inflated for any
Gemini session that carries usage metadata.

## Fix

Count the prompt as input and the candidates as output, once each:

```python
total_input_tokens += usage.get("promptTokenCount", 0)
total_output_tokens += usage.get("candidatesTokenCount", 0)
```

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

- `headroom/learn/plugins/gemini.py`: drop the `cachedContentTokenCount`
and `totalTokenCount - promptTokenCount` additions in `_parse_messages`.
- `tests/test_learn/test_gemini_scanner.py`: new test asserting the
input/output totals equal `promptTokenCount` / `candidatesTokenCount`.
- `CHANGELOG.md`: Bug Fixes entry.

## Testing

- [ ] 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
$ uvx ruff@0.15.17 check headroom/learn/plugins/gemini.py tests/test_learn/test_gemini_scanner.py
All checks passed!
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/learn/plugins/gemini.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17` / `uvx
mypy@1.20.2`. A full `pytest` OOM-kills this box (ML stack import), so I
reproduced the arithmetic with a dependency-free script and left the
full pytest to CI.
- Exact command / steps: fed a usage dict of `promptTokenCount=1000,
cachedContentTokenCount=300, candidatesTokenCount=500,
totalTokenCount=1500` through the OLD accumulation and the NEW one.
- Observed result: OLD → input 1300, output 1000 (cached and candidates
both counted twice); NEW → input 1000, output 500 (the true figures).
- Not tested: a full `learn` run over a real Gemini history; full local
`pytest` deferred to CI (OOM).

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

## Additional Notes

The "unit tests pass locally" box is unchecked because the full suite
imports the ML stack, which I can't run here. The new test uses the
existing `GeminiScanner` harness in
`tests/test_learn/test_gemini_scanner.py` so it runs under the normal CI
pytest job; behaviour is additionally verified by the standalone proof
above.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-08-11 23:37:58 -05:00
Abhay Singh
7e83b8da3c
fix(learn/gemini): detect the project path for JSONL sessions (#2229)
## Description

The Gemini `learn` plugin can't detect the project path for JSONL
sessions, so it writes its insights to the wrong project.

`discover_projects` globs both `session-*.json` and `session-*.jsonl`
and calls `_detect_project_path`, which reads the file with a single
whole-file `json.load`:

```python
def _detect_project_path(self, session_path: Path) -> Path | None:
    try:
        with open(session_path, encoding="utf-8", errors="replace") as f:
            data = json.load(f)
    except (OSError, json.JSONDecodeError):
        return None
    ...
```

A `.jsonl` session is one JSON object per line, so `json.load` on the
whole file raises `json.JSONDecodeError` ("Extra data") on the second
line. The method swallows that and returns `None`, and the caller falls
back to `Path.cwd()`:

```python
project_path = self._detect_project_path(session_files[0])
...
ProjectInfo(
    name=project_path.name if project_path else project_dir.name,
    project_path=project_path or Path.cwd(),   # wrong project
    context_file=gemini_md,                    # None: GEMINI.md never found
    ...
)
```

So for the JSONL format (Gemini CLI's newer session format — the one
that carries `type: "session_metadata"` records), detection never works:
the learned tool/verbosity insights are attributed to the current
working directory instead of the real project, and the project's
`GEMINI.md` is never located. The sibling `_scan_jsonl_session` already
reads this format line-by-line, and the Claude plugin recovers the
project path from session `cwd` the same way.

## Fix

Route `.jsonl` sessions through a line-by-line reader and share the
field extraction (`projectPath` / `project_path` / `cwd` /
`workingDirectory`) between both formats:

```python
if session_path.suffix == ".jsonl":
    return self._detect_project_path_jsonl(session_path)
```

`_detect_project_path_jsonl` parses each line (skipping blanks and
unparseable lines, exactly like `_scan_jsonl_session`) and returns the
first record that yields an existing path. The JSON path is unchanged.

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

- `headroom/learn/plugins/gemini.py`: dispatch `.jsonl` sessions to a
new line-by-line `_detect_project_path_jsonl`; factor the field
extraction into `_project_path_from_entry` shared by both paths.
- `tests/test_learn/test_gemini_scanner.py`: new test asserting a JSONL
session's `cwd` is recovered.
- `CHANGELOG.md`: Bug Fixes entry.

## Testing

- [ ] 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
$ uvx ruff@0.15.17 check headroom/learn/plugins/gemini.py tests/test_learn/test_gemini_scanner.py
All checks passed!
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/learn/plugins/gemini.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17` / `uvx
mypy@1.20.2`. A full `pytest` OOM-kills this box (ML stack import), so I
reproduced the behavior with a dependency-free script mirroring both
detection paths and left the full pytest to CI.
- Exact command / steps: wrote a `.jsonl` session whose first record is
`{"type":"session_metadata","cwd":"<project>"}`, then ran the OLD
whole-file `json.load` reader and the NEW line-by-line reader; also
checked a single-object `.json` session still resolves under both.
- Observed result: OLD returns `None` for the JSONL file (the caller
would fall back to cwd); NEW returns the project path; the `.json` case
resolves identically under both.
- Not tested: a full `learn` run over a real Gemini history; full local
`pytest` deferred to CI (OOM).

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

## Additional Notes

The "unit tests pass locally" box is unchecked because the full suite
imports the ML stack, which I can't run here. The new test uses the
existing `GeminiScanner` harness in
`tests/test_learn/test_gemini_scanner.py` so it runs under the normal CI
pytest job; behaviour is additionally verified by the standalone proof
above.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-08-11 23:37:37 -05:00
Rod Boev
d02df10758
fix(proxy): give each Codex /v1/responses WS turn a unique request_id (#2164)
## Description

After any Codex traffic, the dashboard "Recent Requests" table goes
blank — including the unrelated Anthropic/Claude rows — even though the
proxy is actively handling and compressing Codex `/v1/responses`
WebSocket turns and aggregate counters keep moving. The feed isn't
stale; it is being wiped client-side.

Root cause: the Codex WebSocket handler
`OpenAIHandlerMixin.handle_openai_responses_ws`
(`headroom/proxy/handlers/openai.py`) mints a single `request_id` per
WebSocket **session** (`_next_request_id()` near the top of the handler)
and reuses it for every per-turn `RequestOutcome` in
`_record_ws_response_metrics`, the session-residual outcome, and the
session-summary `RequestLog`. Those all flow through
`emit_request_outcome` (`headroom/proxy/outcome.py`), which writes a
`RequestLog` per outcome into the request logger that backs
`/stats.recent_requests` and `/transformations/feed` — so one session
with N turns produces N+ feed rows sharing one `request_id`. The
dashboard renders that feed with `<template x-for="req in
(stats.recent_requests || [])" :key="req.request_id">`
(`headroom/dashboard/templates/dashboard.html:1298`); Alpine requires
unique `:key`s, so duplicate ids abort the entire `x-for` render and
blank the whole table. Anthropic/HTTP requests each get a unique
incrementing id from `_next_request_id()` and are unaffected — which is
why only Codex traffic triggers the blanking.

This PR gives each Codex WS feed emission a fresh unique id from the
same authoritative `_next_request_id()` counter (per-turn, residual, and
summary sites), restoring the "one unique id per feed row" invariant
that Anthropic already satisfies. With unique ids the Alpine `:key`s no
longer collide and the table renders Codex turns like any other request.
Feed-row counts, per-turn token and savings values, ordering, and
per-session metrics/cost bookkeeping are unchanged; the `[{session
request_id}]` log prefixes still use the session id so a session's log
lines stay greppable together.

Scope: this is the backend root-cause fix. Hardening the dashboard
`:key` against duplicate/`null` keys is a separate render-robustness
change and is deliberately left to a follow-up (`Refs #310`); once the
backend guarantees unique ids, the collision that blanks the table is
gone. The comment's secondary `savings_percent.toFixed(0)` concern is
already resolved on `main` (the row uses `formatOptionalPercent`).

Closes #310. The concrete duplicate-`request_id` diagnosis and the live
`/stats?cached=1` capture came from @sphynxttl's comment on the issue.

## 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`: in `handle_openai_responses_ws`,
mint a fresh `request_id` from `_next_request_id()` at each request-feed
emission — the per-turn `RequestOutcome` in
`_record_ws_response_metrics`, the session-residual `RequestOutcome`,
and the session-summary `RequestLog` — instead of reusing the one
session id. The per-turn id is minted after the existing all-deltas-≤0
early-return, so no-op turns still emit nothing. The `[{request_id}]`
PERF/log prefixes keep the session id for operator correlation.
- `tests/test_openai_codex_ws_lifecycle.py`: new tests driving a
two-turn Codex WS session through the `_FakeWebSocket`/`_FakeUpstream`
harness with a capturing request logger and an incrementing
`_next_request_id`, asserting distinct per-row `request_id`s without
relying on local repro artifacts, unchanged per-turn token/savings
values, no phantom row for a no-op turn, and session-prefixed logs.
- `CHANGELOG.md`: `Unreleased → Fixed` entry.

## Testing

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

### Test Output

```text
$ uv run pytest tests/test_openai_codex_ws_lifecycle.py -q
.............................                                             [100%]
29 passed in 1.69s

$ uv run ruff check .
All checks passed!
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12 via `uv`, no live provider — the
WS handler is exercised through the in-process
`_FakeWebSocket`/`_FakeUpstream` harness that mirrors the production
wire shape.
- Exact command / steps: ran `uv run pytest
tests/test_openai_codex_ws_lifecycle.py::test_ws_multi_turn_request_ids_are_unique
-q` on this branch and `uv run pytest
tests/test_openai_codex_ws_lifecycle.py -q` for the focused file; on
`origin/main`, the new regression node is absent and the WS emit sites
still use `request_id=request_id` in
`headroom/proxy/handlers/openai.py`.
- Observed result: a two-turn Codex WS session now yields
`recent_requests` rows with unique `request_id`s, so the dashboard's
Alpine `:key` no longer collides; token/savings values and row counts
are unchanged; a no-op turn still emits no row. On `origin/main`, the
handler still reuses the session `request_id` at the WS feed emit sites.
- Not tested: live dashboard browser render of the fixed feed.

## Review Readiness

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

## Checklist

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

## Additional Notes

- Type checking (`mypy`) left unchecked: not run in this pass; the
change only swaps the source of an existing `request_id` string field.
- Non-goal (out of scope): hardening the dashboard `x-for` `:key`
against duplicate/`null` keys is a separate render-robustness fix for a
follow-up (`Refs #310`); this PR removes the source of the duplicates.
The comment's `savings_percent.toFixed(0)` concern is already fixed on
`main` (`formatOptionalPercent`).
- Prior art: an earlier change (issue #399 era) added the per-turn Codex
WS `RequestLog`/PERF emission but reused the session id; this PR makes
those ids unique.
2026-08-11 23:37:08 -05:00
TenderDeve
a4bd2e62a5
fix(proxy): gate mid-turn message coalescing to Claude Code clients (#1643)
## Description

`headroom wrap opencode` (and any other `@ai-sdk/anthropic` client)
can't use subagents. The subagent is spawned, receives the prompt, and
never responds; OpenCode throws `invalid_union / "No matching
discriminator" / discriminator: "type"`.

Root cause is headroom's mid-turn message coalescing. It keys concurrent
streaming requests by `md5(model:system[:500])` (`_get_session_key`,
`handlers/streaming.py`). An OpenCode subagent runs concurrently with
the main agent on the same model and same first-500-char system prefix,
so it produces the **same** session key and collides with the
still-active main stream. Two things then break it:

1. `handlers/anthropic.py` sees the key in `_active_streams` and answers
the subagent's request with a bare `202 headroom_queued` instead of
forwarding it — so the subagent never gets a response.
2. When the main stream ends, `handlers/streaming.py` emits a
non-standard `event: headroom_pending_messages` SSE event.
`@ai-sdk/anthropic`'s SSE parser keys its Zod union on `type`, and
`headroom_pending_messages` isn't a valid Anthropic event type — hence
the error.

The 202 reply and the `headroom_pending_messages` event are a Claude
Code-only protocol (nothing else consumes them). This gates coalescing
to Claude Code clients; every other harness streams normally.

Closes #1608

## Type of Change

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

## Changes Made

- `handlers/streaming.py`: only register a stream in `_active_streams`
when `classify_client(headers) == "claude-code"`, and only emit the
`headroom_pending_messages` SSE event for Claude Code.
- `handlers/anthropic.py`: only take the queue-and-`202` branch when the
client is Claude Code (in addition to the existing `session_key in
_active_streams` check).
- Regression tests in `tests/test_mid_turn_steering.py` for all four
cases (active-stream registration and pending-event emission, each for a
Claude Code vs. a non-Claude-Code client).

## 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
$ pytest tests/test_mid_turn_steering.py -q
9 passed in 0.46s

$ ruff check headroom/proxy/handlers/streaming.py headroom/proxy/handlers/anthropic.py tests/test_mid_turn_steering.py
All checks passed!

$ ruff format --check <same files>
3 files already formatted

$ mypy headroom/proxy/handlers/streaming.py headroom/proxy/handlers/anthropic.py --ignore-missing-imports
Success: no issues found in 2 source files
```

## Real Behavior Proof

- Environment: macOS (arm64), Python 3.14 venv, editable install of this
branch.
- Exact command / steps: ran `pytest tests/test_mid_turn_steering.py` —
the new tests drive `_stream_response` with a queued mid-turn message
under an `opencode/1.0` User-Agent vs. a `claude-code/1.2.3` User-Agent
and assert the streamed bytes. Also ran the streaming + anthropic
handler suites (`pytest tests/test_mid_turn_steering.py
tests/test_proxy_streaming_* tests/test_anthropic_*
tests/test_streaming_usage_parser.py`).
- Observed result: with the `opencode/1.0` client the session is never
added to `_active_streams` and the response contains no
`headroom_pending_messages` event; with `claude-code/1.2.3` both still
happen (protocol preserved). Handler suites: 155 passed, 3 skipped.
Before this change the non-Claude client received the
`headroom_pending_messages` event (the exact byte string the OpenCode
parser rejects).
- Not tested: end-to-end against a live OpenCode + real subagent run —
reproduced deterministically at the proxy layer instead (the emitted SSE
bytes are the direct source of the reported `invalid_union` error).

## Review Readiness

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

## Additional Notes

Gating on `classify_client == "claude-code"` (User-Agent `claude-code/`
/ `claude-cli/`) is the same client identification used elsewhere in the
proxy. Unidentified clients (no recognized User-Agent) are treated as
non-Claude-Code and stream normally, which is the safe default for this
feature.


## Maintainer Update (2026-07-21)

- Removed the manual `CHANGELOG.md` entry so release-please remains the
source of changelog updates; pushed `d8e36540`.
- Validation: `tests/test_mid_turn_steering.py` passed (12 tests), the
related streaming/Anthropic suite passed (72 tests), Ruff check passed
for touched files, Ruff format check passed, and `git diff --check
upstream/main...HEAD` passed.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-08-11 23:36:39 -05:00
Radhakrishnan Pachyappan
89493714d2
fix(health): label kompress as degraded/optional when not yet loaded (#2865)
## Description

`/readyz` reports kompress as `"status": "unhealthy"` while the
top-level payload simultaneously reports `"status": "healthy"` and
`"ready": true`. This is a visible contradiction — kompress is
intentionally excluded from the aggregate readiness gate, but it still
receives the harshest label when it hasn't finished loading.

This PR is a superset of #2829: it makes the same `degraded` status
change **and** adds an `"optional": true` field to the component dict so
API consumers can distinguish optional components from gating ones
without parsing the `status` string.

Fixes #2813.

## 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/server.py` — `_component_health()` accepts `optional:
bool = False`; when `optional=True` and not-ready, status is
`"degraded"` instead of `"unhealthy"`; `"optional": True` is added to
the returned dict so callers can identify optional components without
parsing the status string. Kompress call passes `optional=True`.
- `tests/test_proxy_health.py` — All 11 kompress assertion dicts
updated: `"status": "degraded"` for not-ready cases and `"optional":
True` for all kompress cases (covering disabled/healthy/degraded states
in the full parametrized matrix).

## Schema diff

**Before** (kompress not yet loaded):
```json
{
  "enabled": true,
  "ready": false,
  "status": "unhealthy",
  "backend": null
}
```

**After**:
```json
{
  "enabled": true,
  "ready": false,
  "status": "degraded",
  "optional": true,
  "backend": null
}
```

The `"optional": true` field is additive — existing consumers that only
check `status` are unaffected. The field gives consumers a stable
machine-readable signal without requiring them to enumerate which
component names are optional.

## Testing

- [x] Unit tests pass (`pytest`) — CI only; `headroom._core` (compiled
Rust extension) is not available locally, blocking direct `pytest
tests/test_proxy_health.py` locally. All tests that don't import through
`headroom.proxy.server → headroom.transforms → headroom._core` run
locally.
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [ ] New tests added for new functionality (existing tests updated to
cover the new status value and the new `"optional"` field)
- [ ] Manual testing performed

### Test Output

```
$ uv run ruff check headroom/proxy/server.py tests/test_proxy_health.py
All checks passed!

$ uv run mypy headroom/proxy/server.py
Success: no issues found in 1 source file
```

Full test suite (`tests/test_proxy_health.py`) is verified by CI; local
run blocked by missing `headroom._core` native extension.

## Real Behavior Proof

- Environment: local dev checkout, Windows 11, Python 3.14.3
- Ruff + mypy pass locally on both changed files (see Test Output above)
- `tests/test_proxy_health.py` test suite requires `headroom._core`
(compiled Rust extension not available locally) — CI run covers this
- Diff is a mechanical expansion of the same `optional` flag already
approved in #2829's head, plus the additive `"optional": true` response
field

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

---------

Signed-off-by: Radhakrishnan Pachyappan <radhakrishnan.p@op.tech>
Co-authored-by: JD Davis <mxjerrett@gmail.com>
2026-08-11 23:35:28 -05:00
Alex Sun
685ebe457d
fix(ccr): report embedded hashes from compress endpoint (#717)
## Description

Fixes `/v1/compress` so its `ccr_hashes` response includes retrievable
CCR hashes embedded in compressed message content, including row-drop
and recursive JSON markers that may not be present in
`TransformResult.markers_inserted`.

The original PR also changed query-based JSON row search. Current `main`
intentionally made CCR retrieval a hash-only, full-content lookup in
#1532, so that obsolete half is not restored. This reconciliation
preserves the reporting bug fix without reversing the current retrieval
contract.

## 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 causes existing functionality
to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- extract 12–24 character CCR hashes from `Retrieve more`, `Retrieve
original`, and `<<ccr:...>>` markers
- scan both transform marker metadata and nested rendered message values
- preserve stable encounter order and deduplicate case-insensitively
- exclude non-retrieval transform metadata such as tool digests and
stable-prefix hashes
- return the normalized hashes from `/v1/compress`
- add helper-level and endpoint-level regression coverage

## Testing

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

### Test Output

```text
$ uv run --extra dev pytest tests/test_proxy_compress_endpoint.py -q
50 passed, 1 warning in 6.54s

$ uv run --extra dev ruff check headroom/proxy/handlers/openai.py tests/test_proxy_compress_endpoint.py
All checks passed!

$ uv run --extra dev ruff format --check headroom/proxy/handlers/openai.py tests/test_proxy_compress_endpoint.py
2 files already formatted

$ git diff --check
# no output
```

## Real Behavior Proof

- Environment: macOS, Python 3.12.13, current `main` at `7940c05e`,
project native extension built by `uv`
- Exact command / steps: ran the complete `/v1/compress` endpoint test
module, including a mocked pipeline response containing an embedded
row-drop marker but only unrelated tool-digest marker metadata
- Observed result: endpoint returned exactly the embedded retrievable
hash; helper coverage also proved nested markers, case normalization,
deduplication, stable ordering, and exclusion of unrelated metadata
- Not tested: full repository test and CI matrix; GitHub CI covers the
broader matrix

## 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 the non-obvious marker filtering behavior
- [x] Documentation is unchanged because the public response contract is
corrected, not expanded
- [x] My changes generate no new warnings
- [x] I have added tests that prove the fix is effective
- [x] New and existing endpoint tests pass locally
- [x] I did **not** edit `CHANGELOG.md` — it is generated by
release-please from the Conventional Commit PR title

## Screenshots (if applicable)

Not applicable. This changes a JSON API response and tests, with no
graphical UI changes.

## Additional Notes

The query-based JSON row-search changes from the original branch were
made obsolete by #1532 and are deliberately excluded rather than
reviving a retired API behavior. The original contributor remains the
commit author for the reconciled fix.

---------

Co-authored-by: JD Davis <jd@jds-macbook-air.tail2a279.ts.net>
2026-08-11 23:27:49 -05:00
Parideboy
a5b0a8f4cc
fix(proxy): allow settings routes for trusted gateway/dashboard clients (#2491)
## Description

`/settings`, `/settings/schema`, `/settings/apply`, and
`/dashboard/settings` were gated by `_require_loopback`, which checks
`request.client.host` directly and 404s for any non-loopback caller.
When headroom-proxy runs behind a reverse-proxy/gateway (e.g. in a
container), `request.client.host` is the gateway's IP, so these routes
404 unconditionally — even with
`HEADROOM_PROXY_TRUSTED_GATEWAY_CIDRS`/`HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS`
configured, a trust chain `/stats` and `/stats-lifetime` already use.

Fixes #2466.

## Type of Change

- [x] Bug fix

## Changes Made

- Added `_require_loopback_or_trusted_dashboard_client` dependency in
`headroom/proxy/server.py`, reusing the existing
`_request_can_view_dashboard_metadata` trust chain (loopback check,
IP-literal Host header check, same-origin check, trusted-gateway CIDR
check).
- Swapped this dependency in for `_require_loopback` on exactly five
routes: `/settings/schema`, `GET /settings`, `POST /settings`, `POST
/settings/apply`, `/dashboard/settings`. All other loopback-only
admin/debug routes (`/admin/*`, `/debug/*`, `/cache/clear`,
`/v1/retrieve*`) are untouched.
- Added test coverage in `tests/test_proxy_loopback_gating.py`:
non-loopback without trusted CIDR still 404s, loopback still allowed,
trusted-gateway dashboard client is now allowed, and CIDR mismatch still
404s.

## Testing

- [x] Added/updated tests
- [x] Ran full test suite locally

```
$ python -m pytest tests/test_proxy_loopback_gating.py tests/test_proxy_settings_endpoints.py -q
73 passed, 1 warning in 28.80s

$ ruff check headroom/proxy/server.py tests/test_proxy_loopback_gating.py
All checks passed!

$ ruff format --check headroom/proxy/server.py tests/test_proxy_loopback_gating.py
1 file already formatted, 1 file already formatted

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

## Real Behavior Proof

- Environment: Windows 11, Python 3.13.11, pytest 9.1.1, headroom repo
local checkout
- Exact command / steps: `python -m pytest
tests/test_proxy_loopback_gating.py -q` after adding parametrized tests
that set `HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS` and hit
`/settings`, `/settings/schema`, `/dashboard/settings` from a simulated
gateway-forwarded peer IP
- Observed result: all 51 tests in the file pass, including new cases
confirming trusted-gateway clients get 200 (previously 404) while
unlisted/mismatched clients still get 404
- Not tested: did not manually deploy a real Docker container behind an
actual reverse-proxy (e.g. nginx/Traefik) to reproduce the original
reporter's exact setup; relied on TestClient-simulated forwarded
headers/peer IPs instead

## Review Readiness

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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 23:05:22 -05:00
Yossi Ovadia
eb5b5e4198
fix: Vertex model pricing shows $0.00 for versioned model names and vertex:anthropic provider (#2517)
## Description

Two bugs cause `$0.00` cost display for Vertex AI users in headroom's
dashboard:

1. **Model name resolution** — Vertex appends `@YYYYMMDD` version tags
at runtime (e.g. `claude-haiku-4-5@20251001`). LiteLLM's database stores
bare names without version suffixes, so every versioned model missed the
lookup.

2. **Prefix cache savings** — the provider match checks `provider ==
"anthropic"` but Vertex traffic is tagged `provider ==
"vertex:anthropic"`, so cache read savings computed as $0.00. This bug
is **not** addressed by #2516.

Fixes #2515

## 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/pricing/litellm_model_resolution.py`: strip `@YYYYMMDD`
suffix before lookup; add `vertex_ai/` to `MODEL_PREFIX_RULES` for
Claude models; apply prefix rules to both original and bare names
- `headroom/proxy/cost.py`: extend provider match to include
`vertex:anthropic` alongside `anthropic` for prefix cache savings
- `tests/test_pricing_litellm_model_resolution.py`: 4 new tests covering
suffix stripping, versioned model resolution, pricing lookup, and
end-to-end resolve

## 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
$ python -m pytest tests/test_pricing_litellm_model_resolution.py -v
collected 10 items

tests/test_pricing_litellm_model_resolution.py::test_prefix_rule_matches_case_insensitively PASSED
tests/test_pricing_litellm_model_resolution.py::test_resolution_candidates_try_bare_then_matching_prefix_then_alias PASSED
tests/test_pricing_litellm_model_resolution.py::test_pricing_lookup_candidates_include_provider_prefixes_and_aliases PASSED
tests/test_pricing_litellm_model_resolution.py::test_retired_claude_3_sonnet_aliases_to_sonnet_tier_not_haiku PASSED
tests/test_pricing_litellm_model_resolution.py::test_resolve_litellm_model_name_returns_first_known_candidate PASSED
tests/test_pricing_litellm_model_resolution.py::test_resolve_litellm_model_name_returns_original_when_unknown PASSED
tests/test_pricing_litellm_model_resolution.py::test_strip_vertex_version_suffix PASSED
tests/test_pricing_litellm_model_resolution.py::test_resolution_candidates_vertex_versioned_models PASSED
tests/test_pricing_litellm_model_resolution.py::test_pricing_lookup_candidates_vertex_versioned_models PASSED
tests/test_pricing_litellm_model_resolution.py::test_vertex_versioned_model_resolves_to_known_key PASSED

10 passed in 1.23s
```

## Real Behavior Proof

- Environment: macOS arm64, Python 3.11.13, headroom 0.32.1, Claude Code
2.1.211, `CLAUDE_CODE_USE_VERTEX=1`, persistent local proxy
- Exact command / steps: `python3 -c "from
headroom.pricing.litellm_model_resolution import resolution_candidates;
import litellm; m='claude-haiku-4-5@20251001'; [print(c,
litellm.model_cost.get(c,{}).get('input_cost_per_token',0)*1e6) for c in
resolution_candidates(m)]"`
- Observed result: before fix all versioned Vertex models returned
$0.00; after fix `claude-haiku-4-5@20251001`→$1.00/MTok,
`claude-opus-4@20250514`→$15.00/MTok, dashboard "Prefix Cache Impact"
shows Net savings $6.31 (was $0.00). Screenshots in issue #2515.
- Not tested: non-Vertex paths (direct Anthropic, Bedrock, OpenAI) —
changes are additive and guarded by `vertex:anthropic` provider check

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

---------

Co-authored-by: JD Davis <jd@jds-macbook-air.tail2a279.ts.net>
2026-08-11 23:03:09 -05:00
Abhay Singh
12149f7446
fix(proxy): include tool_search_deferral savings in the savings ledger
Include tool-search deferral in savings accounting (#2795).
2026-08-11 18:16:08 -07:00
Abhay Singh
0951663562
fix(proxy): close the upstream stream when a streaming body is never consumed
Close unconsumed upstream streaming bodies.
2026-08-11 18:16:04 -07:00
gglucass
d7bc1e275f
fix(content-router): protect custom-tag blocks before mixed-content section split
Protect custom-tag blocks during mixed-content routing.
2026-08-11 18:16:00 -07:00
Abhay Singh
e4904e23a6
fix(backends/anyllm): stream tool_use blocks and map finish_reason on the streaming path
Preserve AnyLLM streaming tool calls and finish reasons.
2026-08-11 18:15:57 -07:00
Abhay Singh
0d6866b91a
fix(backends/anyllm): convert Anthropic tools and tool_choice to OpenAI shape
Convert Anthropic tool requests for AnyLLM OpenAI-compatible backends.
2026-08-11 18:15:47 -07:00
gglucass
def3d76e5a
fix(cache): mirror client cache_control positions instead of single-marker consolidation
Preserve client cache-control breakpoint positions.
2026-08-11 18:15:44 -07:00
Abhay Singh
c093bf11eb
fix(wrap/claude): keep --1m effective when an explicit --model is passed through
Ensure explicit Claude model arguments retain the 1M context suffix (#2915).
2026-08-11 18:15:40 -07:00
Abhay Singh
ae384862a4
fix(wrap/opencode): verify the opencode binary before mutating config
Verify the OpenCode executable before changing configuration.
2026-08-11 18:15:37 -07:00
Tejas Chopra
d0c1f5b8ad
fix(ccr): avoid injecting tool on chat streaming
Avoid unsupported CCR tool injection on OpenAI chat streaming (#2924).
2026-08-11 16:18:57 -07:00
Tejas Chopra
cde1513c91
fix(proxy): guard telemetry and TOIN endpoints
Harden telemetry and TOIN routes and detail payloads (#2927).
2026-08-11 16:18:53 -07:00
Tejas Chopra
8cd138039e
fix(toin): bound private query and pattern retention
Fix TOIN privacy leakage and unbounded retention (#2926, #2886).
2026-08-11 16:10:39 -07:00
Abhay Singh
7092b53c46
fix(cli/update): let install ownership win over bare /.dockerenv so venv installs self-update (#2830)
## Description

`headroom update` refuses to self-update for any install that happens to
run inside a container, including a plain `pip install` into a venv,
because `detect_install_method` checks `_in_docker()` before the pipx /
uv-tool / venv / user-site branches. The guidance it prints does not
apply: there is no Headroom image in the picture, the container is the
environment and Headroom was pip-installed into a venv inside it.

```console
$ headroom update --check
Update available: 0.32.0 -> 0.34.0
Running inside a container - pull a newer Headroom image instead of self-updating.
```

`_in_docker()` is purely environmental (`/.dockerenv` exists, or
`HEADROOM_IN_DOCKER` is set), with no reference to how the package was
installed, so `/.dockerenv` alone shadows a venv that clearly owns the
install. This hits devcontainers, GitHub Codespaces, docker/LXC
self-hosting, and dev images.

The fix splits the check by intent. An EXPLICIT `HEADROOM_IN_DOCKER`
(which the official image can set) is a deliberate opt-out and still
refuses up front, even over a venv, so the real-image behavior is
preserved. The bare `/.dockerenv` heuristic now runs after ownership
detection, so a venv / pipx / uv / user-site install self-updates and
only a container whose own system interpreter owns the install still
gets the pull-a-new-image guidance.

Fixes #2816

## 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/cli/update.py` (`detect_install_method`): replaced the
up-front `_in_docker()` refusal with an explicit
`os.environ.get("HEADROOM_IN_DOCKER")` refusal (the official image
opt-out), and added the bare `_in_docker()` refusal after the pipx /
uv-tool / venv / user-site branches so ownership wins over environment.
Updated the resolution-order docstring.
- `tests/test_update_helpers.py`: added
`test_venv_inside_bare_dockerenv_still_self_updates` (the fix),
`test_explicit_headroom_in_docker_still_refuses_over_venv` (image
opt-out preserved), and `test_bare_dockerenv_without_owner_refuses`
(system-interpreter container still refuses).
- `tests/test_cli_update.py` (`test_detect_docker`): updated to drive
the bare-`/.dockerenv`-no-owner path deterministically (mock ownership
to absent), since a real venv underneath now correctly wins.

## 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
# Fail-before (source fix stashed, new test kept):
tests/test_update_helpers.py::test_venv_inside_bare_dockerenv_still_self_updates FAILED
  assert method.kind == "pip"
  AssertionError: assert 'docker' == 'pip'

# Pass-after (fix applied), all update suites:
tests/test_update_helpers.py tests/test_cli_update.py tests/test_update_check.py
95 passed

# uvx ruff@0.15.17 check  -> All checks passed!
# uvx mypy@1.20.2 headroom/cli/update.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.17 and mypy 1.20.2 via uvx.
- Exact command / steps: read `detect_install_method` to confirm
`_in_docker()` (line 354) preceded the pipx (377) / uv-tool (385) / venv
(392) branches, reproduced the issue's environment in a test (bare
`/.dockerenv` via `_in_docker` monkeypatched True, `HEADROOM_IN_DOCKER`
unset, a venv layout under `sys.prefix`), fail-before with `git stash
push headroom/cli/update.py` and `python -m pytest
tests/test_update_helpers.py -k venv_inside_bare_dockerenv` (the venv is
refused with `kind == "docker"`), then pass-after with `git stash pop`
and rerunning the full update suites (95 passed).
- Observed result: a venv/pip install inside a bare `/.dockerenv`
container now resolves to `kind="pip"`, `can_self_update=True`,
`argv=[sys.executable, "-m", "pip", "install", "-U", ...]`, matching the
manual command the issue reporter confirmed works. An explicit
`HEADROOM_IN_DOCKER=1` still resolves to `kind="docker"` even over a
venv, and a container whose system interpreter owns the install still
resolves to `kind="docker"`.
- Not tested: an end-to-end `headroom update` run inside a real
devcontainer against live PyPI (no container in this environment). The
resolution is a pure classification function verified directly, and the
actual upgrade command it builds is the existing, already-tested venv
path.

## Review Readiness

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

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md`: it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Additional Notes

The official image opt-out is preserved by design: the issue notes
`_in_docker()` already honors `HEADROOM_IN_DOCKER`, so the image can
keep refusing self-update by setting it, which this PR routes to the
explicit up-front check that wins even over a venv. Only the bare
`/.dockerenv` auto-detection was demoted below ownership.
2026-08-11 17:23:29 -05:00
AxelRay
de9e0523da
fix(settings): accept documented HEADROOM_* env names as settings keys (#2833)
## Description

Settings validation only accepted short JSON/API keys, so documented
HEADROOM_* env names were rejected as unknown. Users following the docs
(for example HEADROOM_LOSSLESS) hit SettingsValidationError / PUT
/settings 400 even though those names are already on each registry
field.

This normalizes known env aliases to their short keys before
validate/save, keeps existing short-key behavior, and rejects
conflicting env+key pairs for the same field.

Closes #2812

## Type of Change

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

## Changes Made

- Added _BY_ENV and _normalize_values() in settings_store to map
documented env names to short keys
- Call normalization at the start of validate() and save() so
clear/retain paths also accept env aliases
- Reject payloads that supply both an env alias and its short key with
different values
- Add unit coverage for accept/clear/conflict/same-value paths and
update registry monkeypatches to rebuild _BY_ENV

## Testing

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

### Test Output

```text
python -m pytest tests/test_proxy/test_settings_store.py -q -k "env_alias or validate_accepts or save_rejects or same_env or conflicting or save_accepts or env_alias_clear or anthropic_extra_headers_retain or TestValidation"
23 passed, 11 deselected

ruff check headroom/settings_store.py tests/test_proxy/test_settings_store.py tests/test_proxy_settings_endpoints.py
All checks passed!

ruff format --check headroom/settings_store.py tests/test_proxy/test_settings_store.py tests/test_proxy_settings_endpoints.py
3 files already formatted
```

## Real Behavior Proof

- Environment: Linux x86_64, Python 3.14.5 via contributor venv,
worktree of headroom main at 7940c05e plus commit e4c87edf
- Exact command / steps: pytest tests/test_proxy/test_settings_store.py
focused selection; ruff check and ruff format --check on the three
touched files; settings_store.validate({"HEADROOM_LOSSLESS": True})
returns {"lossless": True}
- Observed result: Env aliases coerce and persist under short keys;
unknown short keys still error; conflicting env+key pairs raise
SettingsValidationError; ruff clean on touched files
- Not tested: Live dashboard PUT /settings through a running proxy (HTTP
suite needs native headroom._core); mypy; full monorepo CI

## Review Readiness

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

## Checklist

- [x] My code follows the project style guidelines
- [x] I have performed a self-review of my code
- [ ] 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

## Additional Notes

- Scoped to settings key normalization only
- Registry drift Click test was not exercised here because this
environment lacks tomlkit for an unrelated import path
2026-08-11 17:22:27 -05:00
Radhakrishnan Pachyappan
fd4628d821
fix(memory): sync FTS5 and vector indexes on CLI delete/edit/prune/purge
## Problem

`headroom memory delete`, `prune`, `edit`, and `purge` all operate on
the bare `SQLiteMemoryStore` — they update the primary `memories` table
but never touch the FTS5 full-text index (`memory_fts` in `memory.db`)
or the vector index (`vec_metadata` / `vec_embeddings` in
`memory_vectors.db`). The index maintenance path lives in
`HierarchicalMemory.delete()` / `.update()`, which the CLI never
instantiates.

**Symptoms (from #2856):**
```sql
-- After deleting 16 of 46 memories via CLI:
SELECT COUNT(*) FROM memories;    -- 30
SELECT COUNT(*) FROM memory_fts;  -- 46  ← orphans
-- memory_vectors.db
SELECT COUNT(*) FROM vec_metadata;  -- 46  ← orphans
```
Deleted memories keep surfacing in `memory_search` results even after a
full server restart, because server startup only re-embeds memories
whose `embedding IS NULL` — it never removes orphaned index entries.

Fixes #2856.

## Solution

Add two best-effort helpers to `headroom/cli/memory.py` that use
**direct SQLite** (no `sqlite-vec` extension, no embedder):

- **`_remove_from_search_indexes(db_path, memory_ids)`**: removes
specific IDs from `memory_fts` and from `vec_metadata` /
`vec_embeddings`. Skips silently if an index doesn't exist.
- **`_clear_all_search_indexes(db_path)`**: truncates both indexes
completely (for purge).

Wire these up in four commands:
| Command | Change |
|---|---|
| `delete` | `_remove_from_search_indexes` after `store.delete_batch()`
|
| `prune` | `_remove_from_search_indexes` after `store.delete_batch()` |
| `purge` | `_clear_all_search_indexes` after `store.clear_all()` |
| `edit` | If content changed: remove stale entries, clear `embedding`
(server re-embeds on next startup), re-add FTS5 entry with new content
immediately |

The edit path re-adds the FTS5 entry right away so keyword search
reflects the new content without requiring a server restart. Vector
search is deferred to the next startup re-embed cycle (same as what the
server already does for missing embeddings).

## Changes

- `headroom/cli/memory.py` — two new helpers; four command call sites
- `tests/test_cli_memory_index_sync.py` (new) — 9 unit tests covering
both helpers with FTS5 and a stub vector DB. No `sqlite-vec` or embedder
required; tests run locally.

## Testing

```
$ python -m pytest tests/test_cli_memory_index_sync.py -v
...
9 passed in 2.38s
```

---------

Signed-off-by: Radhakrishnan Pachyappan <radhakrishnan.p@op.tech>
Signed-off-by: Radhakrishnan Pachyappan <gingeekrishna@gmail.com>
Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
2026-08-11 14:25:36 -07:00
Abhinav Kumar Singh
65961827cf
fix(memory): close DirectMem0 resources
## Description

`DirectMem0Adapter.close()` now deterministically drains or cancels
background writes and releases every initialized client/driver.

Fixes #2897

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

- Initialize the OpenAI client field to `None` so cleanup is safe before
or after initialization.
- Drain background tasks within a configurable 60-second default, cancel
tasks that exceed the timeout, await cancellation, and retain
completed/cancelled task status.
- Close Mem0, OpenAI, Qdrant, Neo4j, embedder, and graph resources
independently, including async close methods, while continuing cleanup
if one resource fails.
- Clear task and client references and keep `close()` idempotent.
- Add regression tests for task draining, timeout cancellation, all
resource cleanup, and repeated close calls.

## 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 -q tests/test_memory/test_direct_mem0.py tests/test_memory/test_qdrant_env.py
52 passed

ruff check .
All checks passed!

ruff format --check .
1383 files already formatted

python -m mypy headroom
Success: no issues found in 515 source files

python -m pytest -q
Collection blocked in this Windows environment by 174 errors, primarily missing compiled headroom._core; 18 tests skipped.
```

## Real Behavior Proof

- Environment: Windows, Python 3.12, local DirectMem0Adapter instance
using real `httpx.Client` resources.
- Exact command / steps: Assigned real `httpx.Client()` instances to the
adapter's OpenAI and Qdrant resource slots, registered an asynchronous
background task, awaited `adapter.close(timeout=1.0)`, then checked both
clients' `is_closed` state and the task status.
- Observed result: `real httpx clients closed and background task
drained`; both clients reported closed, no pending task IDs remained,
and the task status was `completed`.
- Who maintains it: Headroom Labs maintains this active upstream
repository and memory backend.
- Install surface: No dependencies or install behavior changed. The fix
uses the standard-library asyncio/inspect modules and existing resource
close methods; no native code or runtime network access is introduced.
- Not tested: The complete test suite could not run past collection
because this Windows environment lacks the compiled `headroom._core`
extension.

## 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
- [ ] New and existing unit tests pass locally with my changes (full
suite blocked by missing native extension; targeted tests pass)
- [x] I did not edit `CHANGELOG.md` - it is generated by release-please
from my Conventional Commit PR title.

## Screenshots (if applicable)

Not applicable.

## Additional Notes

The default close timeout is 60 seconds and can be overridden by callers
that need a shorter shutdown budget.

---------

Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
2026-08-11 14:25:32 -07:00
Suliman Abdulrazzaq
e044139001
fix(install): trust Docker bridge for dashboard metadata
## Summary

Closes #2909.

The `persistent-docker` installer now discovers Docker's default bridge
gateway and passes the exact `/32` gateway CIDR to the proxy's dashboard
metadata allowlist when no explicit
`HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS` value is configured.
This keeps the existing metadata gate intact while allowing the
first-party loopback-published container to see its own Recent Requests
and Per-Project Savings data. Explicit user configuration continues to
take precedence.

Both native wrappers (POSIX and PowerShell) use the same behavior, and
installer integration coverage verifies the generated Docker command.

## Validation

- `python -m pytest tests/test_install/test_native_installers.py -q -k
bash` (1 skipped on Windows because Bash is unavailable)
- PowerShell wrapper smoke test with the repository fake Docker shim:
verified `docker network inspect bridge` is called and
`HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS=172.17.0.1/32` is passed
to `docker run`
- Explicit allowlist smoke test: verified an existing
`HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS` is preserved without
adding a discovered default
- `git diff --check`

## Real behavior proof

Setup tested: Windows 11 host, PowerShell wrapper, repository fake
Docker shim (Docker CLI is not installed in this environment).

Exact command: `headroom.ps1 install apply --profile smoke --port 18999
--image fake/headroom:test`.

Observed result: the generated Docker invocation included `docker
network inspect bridge --format ...` and `--env
HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS=172.17.0.1/32`, and the
installer completed successfully.

Not tested: a live Docker daemon/dashboard request on this host.

---------

Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
2026-08-11 14:25:29 -07:00
Abhinav Kumar Singh
c85abf7a87
fix(oauth2): make repository lint checks pass
## Description

Fixes #2895

The repository-wide Ruff command failed on the bundled OAuth2 plugin.
This change sorts the public export list, narrows the optional LiteLLM
setup exception handling to expected failures, and replaces the silent
HTTP error-body drain with explicit handling and debug logging.

## Type of Change

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

## Changes Made

- Sorted headroom_oauth2.__all__ according to Ruff RUF022.
- Replaced the blind install-time Exception catch with explicit
ImportError, AttributeError, OSError, TypeError, and ValueError
handling.
- Replaced the silent HTTPError body-drain pass with explicit
HTTPException, OSError, and ValueError handling plus debug logging.
- Added regression coverage for body-drain failures and invalid LiteLLM
header state.

## Testing

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

### Test Output

    ruff 0.15.17
    ruff check .
    All checks passed!

    ruff format --check .
    1382 files already formatted

    python -m mypy headroom
    Success: no issues found in 515 source files

PYTHONPATH=plugins/headroom-oauth2/src python -m pytest -q
plugins/headroom-oauth2/tests
    39 passed in 11.12s

Full Python pytest was attempted: 8,878 tests were collected, but
collection stopped with 174 environment errors because the required
compiled headroom._core extension is unavailable in this Windows
checkout. 18 tests were skipped.

## Real Behavior Proof

- Environment: Windows PowerShell, Python 3.12, Ruff 0.15.17.
- Exact command / steps: Ran the OAuth2 test suite with PYTHONPATH
pointing to plugins/headroom-oauth2/src. Its local HTTPServer fixture
exercised real urllib token minting, cached refresh, HTTP error
handling, and middleware injection.
- Observed result: 39 tests passed, including real loopback token
minting and the new failure-path tests; repository-wide Ruff completed
with no diagnostics.
- Not tested: External identity-provider traffic and the full Python
suite after native extension build, because the local Windows toolchain
cannot build headroom._core.

## 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 the code
- [x] I have commented my code where needed
- [ ] I have made corresponding changes to the documentation (not
needed; behavior and lint handling are covered by existing
comments/tests)
- [x] My changes generate no new warnings
- [x] I have added tests that prove the fix is effective
- [ ] New and existing full-repository unit tests pass locally (blocked
by missing native headroom._core)
- [x] I did not edit CHANGELOG.md

## Additional Notes

No dependencies or public API behavior changed. Expected environment and
transport failures remain handled; unexpected programmer errors now
propagate instead of being silently swallowed. The OAuth2 plugin remains
standard-library-only.

---------

Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
2026-08-11 14:25:25 -07:00
Abhinav Kumar Singh
07d89a751d
fix(litellm): close shared cloud client
## Description

Adds an explicit, idempotent async cleanup lifecycle for the LiteLLM
callback's shared cloud HTTP client.

Fixes #2894

## Type of Change

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

## Changes Made

- Added `HeadroomCallback.aclose()` to close the lazily-created
`httpx.AsyncClient` and clear its reference.
- Made cleanup safe when cloud mode was never used and when shutdown
cleanup is invoked more than once.
- Added regression coverage for initialized-client cleanup, reference
clearing, and repeated/no-op cleanup.

## 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 -q tests/test_integrations/test_litellm_callback.py
5 passed

ruff check .
All checks passed!

ruff format --check .
1382 files already formatted

python -m mypy headroom
Success: no issues found in 515 source files

python -m pytest -q
Collection blocked in this Windows environment by 174 errors, primarily missing compiled headroom._core; one unrelated test also lacks respx.
```

## Real Behavior Proof

- Environment: Windows, Python 3.12, loopback HTTP server, real
`httpx.AsyncClient`.
- Exact command / steps: Started a local HTTP server, configured
`HeadroomCallback(api_key="hdr_test",
api_url="http://127.0.0.1:<port>")`, ran `_cloud_compress()` against it,
saved the created client, awaited `callback.aclose()`, then awaited
`callback.aclose()` again.
- Observed result: The real cloud request succeeded; the client was open
during the request, reported closed after `aclose()`, the callback
reference became `None`, and repeated cleanup was harmless.
- Who maintains it: Headroom Labs maintains this active upstream
repository and its LiteLLM integration.
- Install surface: No dependencies or install behavior changed. Cloud
mode continues to use the existing optional `httpx` dependency; no
native code or runtime network access is introduced by this fix.
- Not tested: The complete test suite could not run past collection
because the local Windows environment lacks the compiled
`headroom._core` extension.

## 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
- [ ] Documentation changes are not required; `aclose()` is documented
in its public docstring and the host owns shutdown sequencing
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes (full
suite blocked by missing native extension; targeted tests pass)
- [x] I did not edit `CHANGELOG.md` - it is generated by release-please
from my Conventional Commit PR title.

## Screenshots (if applicable)

Not applicable.

## Additional Notes

The callback exposes `aclose()` for the host application's async
shutdown lifecycle, matching the existing ASGI integration pattern.
2026-08-11 10:13:10 -07:00
Sudhindra Desai
99f07e7bbd
fix(proxy): cache litellm model resolution to stop repeated Provider List spam
## Description

The proxy repeatedly prints LiteLLM's `Provider List:
https://docs.litellm.ai/docs/providers` banner during normal operation,
with no explanation or way to suppress it (#2851).

Root cause: `_resolve_litellm_model()` in
`headroom/proxy/savings_tracker.py` runs on every savings-tracking
update (i.e. every request). For any model LiteLLM can't price (a
custom/local/gateway model name — e.g. the reporter's local oMLX setup),
the uncached fallback path calls `litellm.cost_per_token(...)` purely to
probe resolvability. When that probe fails, LiteLLM prints the banner as
an internal side effect before raising, and since the probe was never
cached, it re-fires on every single request for the same unresolvable
model.

**Update:** review flagged that the first version of this fix cached
into a plain, unbounded `dict` keyed by the (client-controlled) model
name — a memory-retention path on a request-facing proxy, since a caller
can grow it without limit by sending a new model string on every
request. Replaced with a bounded `functools.lru_cache`; see Changes Made
below.

Closes #2851

## 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/savings_tracker.py`: `_resolve_litellm_model()` is now
decorated with `@lru_cache(maxsize=256)` instead of backing onto a
hand-rolled unbounded `dict`. An evicted model name simply re-probes
LiteLLM on next use — never a correctness issue, only whether the noisy
failure banner reruns for that specific name.
- `tests/conftest.py`: added a global `autouse` fixture,
`_reset_litellm_model_resolution_cache`, that clears the cache before
and after every test. It's process-lifetime and module-global, and
several existing tests monkeypatch `savings_tracker.litellm` with
different behavior per test while reusing common model names like
`"gpt-4o"` — without a reset, whichever test resolves a name first
silently wins that cache slot for the rest of the run and later tests
stop exercising their own fake.
- `tests/test_savings_tracker_litellm_resolution_cache.py` (new):
regression tests for the three properties that actually matter —
repeated resolution of one unknown model only probes LiteLLM once,
resolving far more distinct names than the bound never grows the cache
past it, and an evicted name is transparently re-probed rather than
reusing a slot it no longer owns.
- No behavior change for models LiteLLM can already price (fast path via
`model_cost` lookup) — only the noisy uncached probe path is memoized,
same as before.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`) — not run; `mypy` isn't
installed in this environment
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ python3 -m pytest tests/test_proxy_savings_history.py tests/test_savings_tracker_zero_price.py \
    tests/test_savings_tracker_litellm_resolution_cache.py -q
tests/test_proxy_savings_history.py .................................... [ 73%]
...                                                                       [ 79%]
tests/test_savings_tracker_zero_price.py .......                         [ 93%]
tests/test_savings_tracker_litellm_resolution_cache.py ...               [100%]
49 passed, 1 warning in 1.26s

# Re-run in reversed file order to check for the exact order-dependence the
# review flagged — same 49 passed, no failures either direction:
$ python3 -m pytest tests/test_savings_tracker_litellm_resolution_cache.py \
    tests/test_savings_tracker_zero_price.py tests/test_proxy_savings_history.py -q
49 passed, 1 warning in 1.11s

$ python3 -m ruff check headroom/proxy/savings_tracker.py tests/conftest.py \
    tests/test_savings_tracker_litellm_resolution_cache.py
All checks passed!
```

## Real Behavior Proof

- Environment: macOS, Python 3.12.3, this repo checked out locally.
- What changed since the last review pass: I got the compiled
`headroom._core` Rust extension in hand (by installing the published
`headroom-ai[all]` wheel into a separate venv and copying its
`_core.abi3.so` next to this local source tree — same Python ABI,
pure-Python edits in `savings_tracker.py` don't touch the compiled
boundary). That unblocked the full test files this fix touches,
including `tests/test_proxy_savings_history.py`, which was previously
reported as untestable here.
- Exact command / steps: three properties asserted directly against the
real (now-bounded) cache in
`tests/test_savings_tracker_litellm_resolution_cache.py`:
1. Resolve the same unresolvable model 5 times → assert the underlying
`litellm.cost_per_token` probe fired exactly once.
2. Resolve `_MODEL_RESOLUTION_CACHE_MAXSIZE + 50` distinct model names →
assert `_resolve_litellm_model.cache_info().currsize` stays at exactly
`_MODEL_RESOLUTION_CACHE_MAXSIZE` (256), never higher — this is the
actual memory-retention fix the review asked for.
3. Resolve one model, push exactly `maxsize` other distinct names
through to evict it via LRU, then resolve it again → assert it re-probed
(call count went 1 → 2), proving eviction is real and not just an
untested cache_info number.
- Observed result: all three pass; full affected-file suite (49 tests)
passes in both forward and reversed run order, confirming the new
`conftest.py` fixture actually fixes the cross-test leakage risk
(verified by literally reordering the files, not just by inspection).
- Not tested: a live HTTP request against a running `headroom proxy`
process specifically re-exercising this bounded-cache commit — the
earlier "20 simulated requests" proof against the previous
(unbounded-dict) version of this fix was via a standalone script, not a
real server; I have not repeated that specific live-server pass against
this commit. The unit-level proof above exercises the exact same
function (`_resolve_litellm_model`) the real proxy calls per-request
from `headroom/proxy/server.py`, so I'm confident it generalizes, but
flagging the gap rather than implying I re-ran it live.

## 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
— the bound/eviction rationale is commented above
`_resolve_litellm_model`, and the cross-test leakage rationale is
commented above the new `conftest.py` fixture
- [ ] I have made corresponding changes to the documentation — N/A,
internal implementation detail with no user-facing API/doc surface
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md`

## Additional Notes

- `mypy` still hasn't been run — not installed in this sandbox, and I
didn't want to widen the PR further by installing/configuring it just
for this. Flagging rather than silently skipping.
- The earlier "Additional Notes" gap about
`test_proxy_savings_history.py` being untestable in this environment is
resolved (see Real Behavior Proof) — it now runs and passes, including
the pre-existing
`test_litellm_resolution_and_savings_estimation_fallbacks` test that
exercises `_resolve_litellm_model` with a mutated `model_cost` dict
across several assertions in one test.
- Deliberately did not also bound
`headroom/pricing/litellm_pricing.py`'s sibling `_resolved_model_cache`
— same shape of cache, arguably the same exposure — since it's outside
this PR's diff and touching it wasn't asked for. Flagging in case a
maintainer wants it as a fast follow-up rather than silently leaving it
unmentioned.

---------

Co-authored-by: connectsudhindra-gif <connectsudhindra-gif@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 09:55:24 -07:00
Abhinav Kumar Singh
4bd8ecd1e3
fix(memory): close MCP backend on shutdown
## Description

Closes the initialized LocalBackend and cancels in-flight initialization
whenever the memory MCP stdio transport exits.

Fixes #2898

## Type of Change

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

## Changes Made

- Added an explicit server cleanup callback that cancels and awaits
pending backend initialization.
- Closes an initialized backend exactly once and clears the backend/task
references.
- Runs cleanup in `_run()` through a `finally` block after the stdio
transport exits, including transport errors.
- Added regression coverage for initialized cleanup, pending
initialization cancellation, idempotence, and `_run()` shutdown
behavior.

## 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 -q tests/test_memory/test_mcp_server.py
15 passed, 20 warnings

ruff check .
All checks passed!

ruff format --check .
1382 files already formatted

python -m mypy headroom
Success: no issues found in 515 source files

python -m pytest -q
Collected 8881 items / 174 errors / 18 skipped.
Interrupted during collection because this Windows environment lacks the compiled headroom._core extension.
```

## Real Behavior Proof

- Environment: Windows, Python 3.12, async MCP server lifecycle test
with the real `create_memory_server()` closure and an embedded server
transport stub.
- Exact command / steps: Ran `python -m pytest -q
tests/test_memory/test_mcp_server.py`; the regression tests initialized
a backend through the server's registered tool lifecycle, returned the
stdio transport, and invoked the cleanup callback from `_run()`'s
`finally` path.
- Observed result: 15 tests passed. Initialized backends were closed
once, pending initialization was cancelled and awaited, and transport
exit invoked cleanup even when the server run returned.
- Who maintains it: Headroom Labs maintains this active upstream
repository and memory MCP server.
- Install surface: No dependencies or install behavior changed. The fix
uses existing asyncio lifecycle handling and `LocalBackend.close()`; no
native code or runtime network access is introduced.
- Not tested: The complete repository suite could not run past
collection because this Windows environment lacks the compiled
`headroom._core` extension.

## 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
- [ ] New and existing unit tests pass locally with my changes (full
suite blocked by missing native extension; targeted tests pass)
- [x] I did not edit `CHANGELOG.md` - it is generated by release-please
from my Conventional Commit PR title.

## Screenshots (if applicable)

Not applicable.

## Additional Notes

Cleanup is attached to each created memory MCP server and is idempotent,
so embedded callers can invoke the same lifecycle callback safely if
needed.
2026-08-11 09:55:20 -07:00
Suliman Abdulrazzaq
620028fa18
fix(proxy): emit request log timestamps in UTC
## Description

`RequestLog.timestamp` was serialized with `datetime.now().isoformat()`,
which omits timezone information. Browsers then interpret the value as
local time, so requests from a UTC container can display negative ages
in non-UTC dashboards.

Closes #2910

## Type of Change

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

## Changes Made

- Emit request-log timestamps from `datetime.now(timezone.utc)` so the
ISO-8601 value includes `+00:00`.
- Add a regression test that parses the emitted timestamp and requires a
UTC offset.

## Testing

- [x] New tests added for the regression
- [x] `python -m compileall -q headroom/proxy/outcome.py
tests/test_request_outcome.py`
- [x] `git diff --check`
- [ ] Unit tests pass (`pytest`) — the repository's Rust extension
cannot build in this Windows environment because `link.exe` (MSVC) is
unavailable; the focused test is included for CI.

### Test Output

```text
python -m compileall -q headroom/proxy/outcome.py tests/test_request_outcome.py
(pass)

git diff --check
(pass)

uv run pytest tests/test_request_outcome.py -q
blocked while building headroom-py: linker `link.exe` not found
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.11; proxy timestamps are generated
in `headroom/proxy/outcome.py`.
- Exact command / steps: traced the Recent Requests write path and added
a timestamp assertion in `tests/test_request_outcome.py` (CI will run
with the project's Rust toolchain).
- Observed result: the production call now emits an ISO-8601 timestamp
with `+00:00`; the regression assertion requires an offset-aware UTC
value, preventing browser timezone skew.
- Not tested: full pytest suite locally because the MSVC linker is
unavailable.

## 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
- [x] I have added tests that prove my fix is effective
- [x] I did not edit `CHANGELOG.md`

Signed-off-by: Suliman Abdulrazzaq <suliman9000a@gmail.com>
2026-08-11 09:53:53 -07:00
Joseph Benno
0ae948c151
fix(cache): bound compression cache bookkeeping
## Description

`CompressionCache.max_entries` bounded the main compression cache, but
not `_stable_hashes` or `_first_seen`. A long-lived session could
therefore retain every unique tool-result hash even while `_cache`
stayed empty.

This change applies the same bounded retention to both side tables. It
also cleans up expired first-seen entries and resets the timing window
when compression occurs near the TTL boundary.

Fixes #2874

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

- Store stable hashes and first-seen timestamps in ordered mappings.
- Evict oldest entries when either side table exceeds `max_entries`.
- Keep all bookkeeping under the existing reentrant lock.
- Reset first-seen timing after compression near the TTL boundary.
- Add tests covering size limits, TTL behavior, frozen-prefix safety,
and concurrency.

## 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 ruff format --check .
Passed

uv run ruff check .
All checks passed!

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

uv run pytest
Passed
```

Focused cache tests on macOS 26.5.2 arm64 with Python 3.11.14:

```text
uv run pytest tests/test_compression_cache.py::TestCompressionCacheRetention -v
5 passed in 0.30s

uv run pytest tests/test_compression_cache.py -q
38 passed in 5.76s
```

After the final formatting-only commit, the cache test file was also run
on Linux with Python 3.12.13:

```text
37 passed, 1 skipped in 32.70s
```

## Real Behavior Proof

- Environment: Linux 6.18 x86_64, Python 3.12.13,
`CompressionCache(max_entries=100)`.
- Exact command / steps: Created a `CompressionCache(max_entries=100)`,
generated 20,000 unique content hashes, and passed each hash through
`mark_stable()` and `should_defer_compression()`. Store sizes were
sampled after 100, 1,000, 5,000, and 20,000 results.
- Observed result: `_cache=0`, `_stable_hashes=100`, and
`_first_seen=100` at every sample after reaching the configured limit.
At 20,000 results, traced memory was approximately 0.03 MB current and
0.04 MB peak. Before the fix, the same workload retained all 20,000
hashes and timestamps.
- Not tested: A live multi-hour proxy/provider session.

## Review Readiness

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

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented the code where retention behavior is not obvious
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove the fix is effective
- [x] New and existing unit tests pass locally
- [x] I did **not** edit `CHANGELOG.md`

## Screenshots

N/A — internal cache bookkeeping change.

## Additional Notes

No changes to dependencies, public APIs, or configuration.

No user-facing behavior changes.
2026-08-11 09:52:27 -07:00
Abhinav Kumar Singh
739fdef423
fix(proxy): cancel periodic TOIN task on shutdown
## Description

Retains the periodic TOIN statistics task on application state and reaps
it during proxy lifespan shutdown.

Fixes #2896

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

- Store the periodic TOIN task as `app.state.periodic_toin_stats_task`
when enabled.
- Cancel and await the task with the existing bounded shutdown helper
before stopping proxy resources.
- Clear the application state reference after shutdown.
- Add regression coverage proving the task is canceled and reaped when
the FastAPI lifespan exits.

## 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 -q tests/test_proxy_telemetry_env.py
0 items / 1 error
ModuleNotFoundError: No module named 'headroom._core'

Temporary in-process native-core stub + real FastAPI TestClient:
python -m pytest -q tests/test_proxy_telemetry_env.py
8 passed

ruff check .
All checks passed!

ruff format --check .
1382 files already formatted

python -m mypy headroom
Success: no issues found in 515 source files

python -m pytest -q
Collected 8878 items / 174 errors / 18 skipped.
Interrupted during collection because this Windows environment lacks the compiled headroom._core extension.
```

## Real Behavior Proof

- Environment: Windows, Python 3.12, real FastAPI `TestClient` lifespan;
only the unavailable native `headroom._core` import was replaced with an
in-process test stub.
- Exact command / steps: Ran the telemetry test module with the
temporary core stub. The new test enabled periodic TOIN stats, held the
real lifespan open, observed the stored task, exited the `TestClient`
context, and checked that the task was canceled and the state reference
cleared.
- Observed result: 8 telemetry tests passed, including the new shutdown
regression test; the periodic task reported canceled after lifespan exit
and no task reference remained on application state.
- Who maintains it: Headroom Labs maintains this active upstream
repository and proxy lifecycle.
- Install surface: No dependencies or install behavior changed. The fix
uses existing asyncio and FastAPI lifecycle APIs; no native code or
runtime network access is introduced.
- Not tested: The complete suite and the unmodified proxy test command
cannot run in this Windows environment without the compiled
`headroom._core` extension.

## 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
- [ ] New and existing unit tests pass locally with my changes (full
suite blocked by missing native extension; stubbed focused tests pass)
- [x] I did not edit `CHANGELOG.md` - it is generated by release-please
from my Conventional Commit PR title.

## Screenshots (if applicable)

Not applicable.

## Additional Notes

The shutdown uses the existing three-second `_timed()` bound and handles
the disabled configuration without creating a task.
2026-08-11 09:49:07 -07:00
Suliman Abdulrazzaq
5e53b8aa0a
fix(opencode): keep Claude models off OpenAI provider
## Description

The injected `headroom` OpenCode provider uses
`@ai-sdk/openai-compatible` and the proxy's `/v1/chat/completions`
route. It currently advertises Claude model IDs in that provider, so
OpenCode sends Claude requests to the OpenAI upstream and receives
`invalid_api_key` errors. Keep Claude on OpenCode's native `anthropic`
provider, which Headroom already redirects to the proxy.

Closes #2911

## Type of Change

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

## Changes Made

- Remove Claude IDs from the injected OpenAI-compatible provider model
map.
- Keep GPT models available through the `headroom/<id>` namespace.
- Add regression assertions that generated config never advertises
Claude models on this endpoint.

## Testing

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

### Test Output

```text
python -m pytest tests/test_providers_opencode_config.py -q -k "not build_launch_env_with_project"
40 passed, 1 deselected

python -m ruff check headroom/providers/opencode/config.py tests/test_providers_opencode_config.py
All checks passed!

python -m compileall -q headroom/providers/opencode/config.py tests/test_providers_opencode_config.py
(pass)
```

The full config test module also exposes an unrelated pre-existing
Windows path assertion failure in `test_build_launch_env_with_project`;
the failure is caused by comparing a native `Path` string with
JSON-escaped backslashes and is outside this change.

## Real Behavior Proof

- Environment: Windows 11, Python 3.11; no external API credentials
used.
- Exact command / steps: `python -c "from
headroom.providers.opencode.config import headroom_provider_entry;
print(sorted(headroom_provider_entry(8787)['models']))"`
- Observed result: `['gpt-4.1', 'gpt-4o']`; the generated
OpenAI-compatible provider no longer advertises any `claude-*` IDs.
- Not tested: live OpenCode request routing or a vendor API call,
because they require external credentials. The regression suite verifies
the generated configuration consumed by OpenCode.

## 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
- [ ] I have commented my code, particularly in hard-to-understand areas
(not needed; the provider routing rationale is documented inline)
- [ ] I have made corresponding changes to the documentation (the
generated provider behavior is documented in code; existing docs
describe the separate npm provider)
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective
- [x] New and existing relevant unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md`

## Additional Notes

The native `anthropic` and `openai` provider entries both continue to
point at the Headroom proxy, so this change only removes an invalid
duplicate Claude route and does not affect native Claude traffic.
2026-08-11 09:46:03 -07:00
Abhay Singh
702dbc5902
fix(opencode): ship the transport hook-shim so wheel installs route Node child traffic
## Description

The OpenCode transport plugin injects
`NODE_OPTIONS=--import=<...>/hook-shim/handler.js` into every spawned
Node child so its `fetch`/`http` traffic routes through the proxy
(`transport.ts` wraps those globals only in the plugin's own process; a
spawned `npx` MCP server or `tokensave serve` is a fresh process). That
shim was never shipped in the wheel:

- Only `headroom/providers/opencode/_dist/entry.opencode.js` is
committed and packaged.
- The shim source at `plugins/opencode/hook-shim/handler.js` imports the
non-bundled `../dist/index.js`, which a pip install (no `node_modules`)
cannot resolve.

Before #2806, the missing file crashed every Node MCP under `headroom
wrap opencode` with `ERR_MODULE_NOT_FOUND` at the ESM loader, before the
stdio handshake. #2806 added an `existsSync` guard so the loader is not
injected when the shim is absent, which stopped the crash but left
child-process routing silently disabled for all wheel installs (#2850).

This ships the shim. It builds a self-contained variant in the
standalone tsup config (`src/hook-shim.ts`, with the transport bundled
inline like the entry, since site-packages has no `node_modules`), and
commits it to `headroom/providers/opencode/hook-shim/handler.js` -- the
sibling of `_dist/` that `transport.ts`'s `shimImportSpecifier()`
resolves via `../hook-shim/handler.js`. maturin packages every file
under `headroom/`, so the wheel now carries it, and `existsSync` finds
it, so the loader routes spawned Node children again.

Fixes #2850

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

- `plugins/opencode/src/hook-shim.ts` (new): self-contained Node
`--import` loader that installs the transport from the inlined
`./transport.js`.
- `plugins/opencode/tsup.standalone.config.ts`: add `hook-shim/handler`
as a second standalone entry.
- `headroom/providers/opencode/hook-shim/handler.js` (new): the
committed self-contained shim (output of `npm run build:standalone`),
shipped by maturin.
- `.github/workflows/opencode-plugin.yml`: byte-compare the committed
shim against a fresh build (mirrors the existing `entry.opencode.js`
guard), and add the shim path to the workflow triggers.
- `tests/test_providers_opencode_plugin_path.py`: added
`test_hook_shim_is_committed_next_to_the_entry_bundle` asserting the
shim ships as a sibling of `_dist/` and is the self-contained build.

## Testing

- [x] Unit tests pass (`pytest` + `vitest`)
- [x] Type checking passes (`tsc --noEmit`)
- [x] New tests added for new functionality
- [x] Committed shim rebuilt and byte-matches the standalone build
- [ ] Manual testing performed

### Test Output

```text
# Fail-before (shim removed from the package):
tests/test_providers_opencode_plugin_path.py::test_hook_shim_is_committed_next_to_the_entry_bundle FAILED

# Pass-after:
tests/test_providers_opencode_plugin_path.py tests/test_providers_opencode_install.py
tests/test_providers_opencode_config.py            49 passed, 1 pre-existing failure
#   the 1 failure (test_build_launch_env_with_project) fails identically on pristine main:
#   a Windows path-escaping quirk in OPENCODE_CONFIG_CONTENT, unrelated to this diff.

# TypeScript: npm run typecheck (clean), npm test -> 14 passed
# Standalone build: entry.opencode.js byte-unchanged vs the committed blob;
#   dist-standalone/hook-shim/handler.js cmp-matches the committed shim.

# Shim runtime sanity (node):
#   with HEADROOM_OPENCODE_TRANSPORT_PROXY_URL set -> loads, exit 0, wraps globalThis.fetch
#   without it -> throws "loaded without HEADROOM_OPENCODE_TRANSPORT_PROXY_URL", exit 1
```

## Real Behavior Proof

- Environment: Windows 11, Node v24.11.0, npm 11.5.2, tsup 8.5.1 /
esbuild 0.28.1 (pinned via `npm ci`), Python 3.12.11, pytest 9.1.1, ruff
0.15.17.
- Exact command / steps: confirmed `transport.ts` resolves
`../hook-shim/handler.js` next to the loaded entry (so the wheel needs
it at `providers/opencode/hook-shim/handler.js`), that the current wheel
ships only `_dist/entry.opencode.js`, and that maturin packages every
file under `headroom/`. Added the standalone shim entry, ran `npm run
typecheck` and `npm test` (clean), `npm run build:standalone`, verified
`entry.opencode.js` is byte-identical to the committed git blob (the
standalone build is reproducible; my working copy was only
autocrlf-inflated), copied the built shim to the wheel path, and
exercised it in Node: it installs the transport (wraps `fetch`) with the
proxy env set and throws without it. Fail-before by removing the shim
(the new Python test fails); pass-after restored.
- Observed result: `headroom/providers/opencode/hook-shim/handler.js`
now ships in the package as a self-contained module, so a pip-installed
`headroom wrap opencode` routes spawned Node children (npx MCPs,
`tokensave serve`) through the proxy instead of leaving them unrouted,
and never crashes them.
- Not tested: a full pip-install-and-spawn on Linux with a live OpenCode
session (no OpenCode client here). The shim is verified to load and wrap
`fetch` under Node, the bundle is reproducible and byte-checked by CI,
and the packaging path is maturin's standard file inclusion under
`headroom/`.

## Review Readiness

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

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md`: it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Additional Notes

The checkout keeps using `plugins/opencode/hook-shim/handler.js` (which
imports `../dist/index.js` from the regular build), so dev behavior is
unchanged; only the wheel gains the self-contained sibling.
`entry.opencode.js` is byte-unchanged, so its existing CI guard still
passes. The committed shim is stored with LF endings so the Linux CI
byte-compare matches.
2026-08-11 09:10:38 -07:00
Abhay Singh
d7b25ae3bb
fix(wrap/serena): install Serena from the serena-agent PyPI wheel, not the git source
## Description

`headroom/mcp_registry/install.py` (`build_serena_spec`) and the
wrap-time Serena pre-index in `headroom/cli/wrap.py` both ran:

```
uvx --from git+https://github.com/oraios/serena serena ...
```

The git source forces a from-source build. On proot-based filesystems
(Termux + proot-distro on Android, some restricted Linux) `uv` cannot
hardlink build dependencies into a fresh build venv, so the build fails
immediately and Serena's MCP server fails to start on every `headroom
wrap codex` launch:

```
× Failed to download and build `serena-agent @ git+https://github.com/oraios/serena@<commit>`
╰─▶ failed to hardlink file ... Operation not permitted (os error 1)
```

Setting `UV_LINK_MODE=copy` fixes it in an interactive shell, but Codex
strips most env vars from the MCP subprocesses it spawns, so that
workaround does not reliably reach Serena's launch.

Serena publishes the official `serena-agent` package to PyPI with
prebuilt wheels, and it exposes the same `serena` console script
(`serena = "serena.cli:top_level"` in the project's `pyproject.toml`),
so `uvx --from serena-agent serena ...` runs the identical command
without a build step. On platforms where the git build already worked
there is no functional difference.

Fixes #2871

## 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/mcp_registry/install.py` (`build_serena_spec`): `--from
git+https://github.com/oraios/serena` -> `--from serena-agent`.
- `headroom/cli/wrap.py` (Serena `project index` pre-warm): same swap.
- `tests/test_mcp_registry/test_install.py`: updated the spec assertion
and added `test_build_serena_spec_uses_pypi_not_git_source` (asserts
`serena-agent` is used and no `git+` source remains).
- `tests/test_cli/test_wrap_serena_boost.py`: the pre-index test now
asserts `serena-agent` is in the command and the git source is not.

## 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
# Fail-before (source swap stashed, updated tests kept):
tests/test_mcp_registry/test_install.py::test_build_serena_spec_uses_agent_context FAILED
tests/test_mcp_registry/test_install.py::test_build_serena_spec_uses_pypi_not_git_source FAILED
tests/test_cli/test_wrap_serena_boost.py::test_preindex_runs_serena_in_cwd FAILED

# Pass-after:
tests/test_mcp_registry/ tests/test_cli/test_wrap_serena_boost.py
tests/test_cli/test_serena_migrate.py tests/test_cli/test_serena_disable.py   135 passed

# uvx ruff@0.15.17 check  -> All checks passed!
# uvx mypy@1.20.2 headroom/mcp_registry/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.17 and mypy 1.20.2 via uvx.
- Exact command / steps: confirmed `serena-agent` exists on PyPI
(v1.6.1, homepage github.com/oraios/serena) and that its
`pyproject.toml` declares `[project.scripts] serena =
"serena.cli:top_level"`, so the `serena start-mcp-server ...` invocation
is unchanged. Swapped both `--from` sources, then fail-before with `git
stash push headroom/mcp_registry/install.py headroom/cli/wrap.py` (the
two production-asserting tests fail on the old git source) and
pass-after with `git stash pop` (135 serena-suite tests pass). Verified
no `git+https://github.com/oraios/serena` references remain in
`headroom/`.
- Observed result: `build_serena_spec` and the pre-index command now
install Serena from the `serena-agent` PyPI wheel, so a proot
environment gets the prebuilt wheel instead of a from-source build that
cannot hardlink. The migration/ledger tests, which use the old git spec
as a deliberately-stale fixture, are unaffected.
- Not tested: a live `headroom wrap codex` on a real proot/Termux device
(not available here). The change is a package-source swap verified
against Serena's own published package metadata and the existing
spec/command tests.

## Review Readiness

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

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md`: it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Additional Notes

The git source was unpinned (tracked the repo default branch), so
switching to `serena-agent` from PyPI does not lose a version pin; if
anything it is more reproducible. The issue reporter also noted that
`headroom wrap codex` force-rewrites the Serena block in
`~/.codex/config.toml` from this template on every launch, which is why
the fix has to live in the package source rather than a user config edit
-- this PR puts it there.
2026-08-11 09:10:32 -07:00