Commit graph

1553 commits

Author SHA1 Message Date
JD Davis
0f846e5a8f
refactor(proxy): extract tool injection config (#2010)
## Description

Extracts memory tool-injection operator config parsing from
`headroom.proxy.helpers` into a focused config policy module. Existing
helper functions and imports remain available while the environment
parsing is now directly testable.

Closes #

## Type of Change

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

## Changes Made

- Added `headroom.proxy.tool_injection_config` for
`HEADROOM_TOOL_INJECTION_STICKY` and
`HEADROOM_TOOL_TRACKER_MAX_SESSIONS` parsing.
- Updated `helpers.get_tool_injection_sticky_mode` and
`helpers.get_tool_tracker_max_sessions` to delegate to the config module
while preserving existing import paths.
- Added direct tests for defaults, valid values, invalid values, and
helper wrapper compatibility.

## 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
python -m pytest tests/test_tool_injection_config.py tests/test_memory_tool_session_sticky.py tests/test_issue_728_empty_tools_injection.py
46 passed in 0.53s

python -m ruff check .
All checks passed!

python -m ruff format --check .
1078 files already formatted

python -m mypy headroom --ignore-missing-imports
Success: no issues found in 415 source files

gitleaks protect --staged --no-banner --redact
no leaks found
```

## Real Behavior Proof

- Environment: Windows, Python 3.13.13, clean worktree from
`headroomlabs/main` at `cb38f793`.
- Exact command / steps: Ran targeted tool-injection config, memory
session sticky, and empty-tool regression tests plus ruff, ruff-format,
mypy, and staged gitleaks scan.
- Observed result: All targeted tests and local gates passed; staged
secret scan found no leaks.
- Not tested: Full Docker/native wrapper CI locally; covered by
repository 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's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A.

## Additional Notes

Documentation and changelog updates are not applicable for this internal
refactor. The push reported existing default-branch Dependabot
vulnerabilities; this PR's staged gitleaks scan passed and CI security
checks are expected to validate the branch.
2026-07-11 00:03:15 -05:00
Ztkent
b3a559ba56
fix(savings): cap ledger retention at 30 days (#1985)
## Description
The durable savings ledger (`headroom savings`) retained up to 365 days
of history with an unbounded-sounding "All time" window. Long-lived
installs accumulate an ever-growing `~/.headroom/savings_events.jsonl`,
and `--days` had no upper bound so a caller could request an arbitrarily
large lookback. This caps retention at 30 days everywhere it's read,
shrinks the compaction threshold to match, and renames the "All time"
window to reflect what it actually is now: `Last 30 days`.

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

## Changes Made
- `headroom/savings_ledger.py`: `DEFAULT_RETENTION_DAYS` 365 → 30; add
`MAX_RETENTION_DAYS = 30` and hard-clamp the lookback inside
`aggregate_savings` so no caller (CLI or programmatic) can read back
further than 30 days, regardless of the `retention_days` argument passed
in.
- `headroom/savings_ledger.py`: report window `all_time` →
`last_30_days` (the bucket is exactly 30-day-bounded now, so it doubles
as the lifetime view too). `_COMPACT_SIZE_BYTES` 8 MiB → 1 MiB, since a
30-day-bounded ledger should never need to grow large.
- `headroom/cli/savings.py`: `--days` is now `click.IntRange(min=1,
max=30)` (was unbounded); help text states the max. Window label `"All
time"` → `"Last 30 days"`, and the label column width bumped 11 → 12 so
the longer label stays aligned with the other rows' progress bars.
- `tests/test_savings_ledger.py`: updated window-label assertions; added
a hard-cap regression test (`retention_days=365` passed explicitly still
excludes a 60-day-old event) and a `--days` range-rejection test
(31/60/365 all rejected).

## 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
$ ruff check headroom/savings_ledger.py headroom/cli/savings.py tests/test_savings_ledger.py
All checks passed!

$ ruff format --check headroom/savings_ledger.py headroom/cli/savings.py tests/test_savings_ledger.py
3 files already formatted

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

$ pytest tests/test_savings_ledger.py -q
............ss....                                                     [100%]
16 passed, 2 skipped in 6.11s
```

(ruff `0.15.17`, mypy `1.20.2` — pinned to match
`.github/workflows/ci.yml`'s `lint` job. Full multi-shard suite left to
CI; ran the full touched-module suite locally.)

## Real Behavior Proof
- Environment: macOS (Darwin 25.5.0), Python 3.13.14, local `uv` venv;
branch built and installed via `uv tool install --force`.
- Exact command / steps: ran `headroom savings` against a ledger holding
multiple models' events (claude-opus-4-8, claude-sonnet-5,
claude-haiku-4-5) recorded across the retention window, then ran
`headroom savings --days 60` to exercise the new upper bound.
- Observed result: all three windows (Today / Last 7 days / Last 30
days) populate and are each bounded to at most 30 days; cost-avoided
breaks down per model; `--days 60` is rejected by the new `1..30` range
instead of silently accepted.
- Not tested: Windows/macOS native-wrapper e2e jobs — left to CI.

```text
$ headroom savings

Today        █████░░░░░░░░░░░  33.8%  saved 8,702,348 / 25,781,326 tokens  $25.5830
Last 7 days  ██████░░░░░░░░░░  36.3%  saved 11,289,737 / 31,072,254 tokens  $34.8287
Last 30 days ██████░░░░░░░░░░  38.2%  saved 14,449,516 / 37,821,634 tokens  $48.5385

Cost avoided per model:
  claude-opus-4-8          $33.0494
  claude-sonnet-5          $15.2989
  claude-haiku-4-5-20251001 $0.1902

$ headroom savings --days 60
Usage: headroom savings [OPTIONS]
Try 'headroom savings --help' for help.

Error: Invalid value for '--days': 60 is not in the range 1<=x<=30.
```

- Not tested: Windows/macOS native-wrapper e2e jobs — left to CI.

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

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

## Screenshots (if applicable)
N/A — CLI text output only, see Real Behavior Proof above.
2026-07-11 00:02:23 -05:00
JD Davis
82af5cdfe2
refactor(proxy): isolate proxy mode policy (#1965)
## Description

Extracts proxy mode normalization into a pure `proxy_mode_policy`
module. `modes.py` keeps the existing public API and logging, while
alias/default/unknown-mode decisions are now represented by a
deterministic value object with direct tests.

Closes #

## Type of Change

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

## Changes Made

- Added `headroom.proxy.proxy_mode_policy` with canonical mode
constants, alias mapping, `ProxyModeDecision`, and pure normalization
helpers.
- Updated `headroom.proxy.modes` to delegate normalization decisions
while preserving existing constants, predicates, fallback behavior, and
logging.
- Added direct policy tests for canonical modes, legacy aliases, blank
values, unknown values, and value-only normalization.
- Included the current LiteLLM callback signature compatibility shim
required for repo-wide mypy on main-based slices.

## 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
python -m pytest tests/test_proxy_mode_policy.py tests/test_proxy_modes.py tests/test_litellm_callback.py -q
17 passed in 7.84s

python -m ruff check .
All checks passed!

python -m ruff format --check .
1095 files already formatted

python -m mypy headroom --ignore-missing-imports
Success: no issues found in 409 source files

gitleaks protect --staged --no-banner --redact
no leaks found
```

## Real Behavior Proof

- Environment: Windows, Python 3.13.13, clean worktree based on
`headroomlabs/main`.
- Exact command / steps: targeted pytest, ruff, format check, repo-wide
mypy, staged gitleaks scan.
- Observed result: proxy mode policy/modes/callback tests pass; static
checks pass; no staged secrets detected.
- Not tested: live proxy run; this slice preserves existing public mode
helpers and only moves pure normalization policy.

## Review Readiness

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

## Checklist

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

## Screenshots (if applicable)

N/A

## Additional Notes

Documentation and changelog updates are not applicable for this internal
architecture slice. PR-specific GHAS checks will be monitored after
opening.
2026-07-11 00:00:03 -05:00
Abhay Singh
27ddde1f5e
fix(transforms/code): coerce language aliases instead of raising (#1975)
## Description

`CodeAwareCompressor.compress()` picks the language for AST-based
compression like this
(`headroom/transforms/code_compressor.py`):

```python
if language:
    detected_lang = CodeLanguage(language.lower())   # <-- raises on anything not an exact enum value
    confidence = 1.0
elif self.config.language_hint:
    detected_lang = CodeLanguage(self.config.language_hint.lower())
    confidence = 1.0
else:
    detected_lang, confidence = detect_language(code)
```

`CodeLanguage` only accepts
`python`/`javascript`/`typescript`/`go`/`rust`/`java`/`c`/`cpp`/`perl`.
The very common markdown fence tags and hints — `js`, `ts`, `py`, `jsx`,
`tsx`, `node`, `rs`,
`c++` — are **not** enum values, so `CodeLanguage("js")` raises
`ValueError`. That construction
is *above* the method's own `try/except`, so:

- **Direct callers** — `CodeAwareCompressor().compress(code,
language="js")` and the module-level
`compress_code(code, language="js")` — crash with an uncaught
`ValueError`.
- **In the router (mixed content):** `split_into_sections` extracts the
raw fence tag
(`_CODE_FENCE_PATTERN` captures `\w*`, e.g. `js`) into
`ContentSection.language`, and that string
is passed straight into `compress(...)`. The `ValueError` is swallowed
by the outer `try/except`
in the strategy dispatch, so a ` ```js ` / ` ```ts ` / ` ```py ` block
silently **skips
code-aware compression** even when `enable_code_aware=True`, falling
back to the generic path.

So the three most common web/scripting languages, written with their
usual fence tags, never get
the structure-aware compressor.

Closes: no issue filed — found while auditing the code-compression
language path.

## Fix

Add a `coerce_language()` helper that maps common aliases/fence tags to
the canonical
`CodeLanguage` and returns `CodeLanguage.UNKNOWN` (never raises) for
anything unrecognized.
`compress()` now coerces the hint and, when the result is `UNKNOWN`,
falls back to
content-based `detect_language(code)` instead of constructing the enum
directly:

```python
if language:
    detected_lang = coerce_language(language)
    if detected_lang == CodeLanguage.UNKNOWN:
        detected_lang, confidence = detect_language(code)
    else:
        confidence = 1.0
```

## Type of Change

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

## Changes Made

- `headroom/transforms/code_compressor.py`: add `_LANGUAGE_ALIASES` and
`coerce_language()`; use them in `compress()` for both the `language`
argument and `config.language_hint`, with a content-detection fallback
on `UNKNOWN`.
- `tests/test_code_compressor_language_alias.py`: cover alias mapping,
canonical passthrough, case/whitespace handling, unknown-returns-UNKNOWN
(no `ValueError`), and that `compress(language="js")` no longer raises.

## Testing

- [x] New regression tests added
(`tests/test_code_compressor_language_alias.py`)
- [x] Linting passes (`ruff check`) and formatting is clean (`ruff
format --check`)
- [ ] Full `pytest` deferred to CI (local-OOM reason below).

```text
$ uv run ruff check headroom/transforms/code_compressor.py tests/test_code_compressor_language_alias.py
All checks passed!
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.10, headroom from this branch.
Importing `headroom` pulls in the torch/transformers stack and a full
`pytest` gets OOM-killed on this box, so I verified the coercion logic
with a dependency-free script (replicating the enum + helper) and left
the full pytest to CI.
- Exact command / steps: ran the common aliases and the canonical values
through both the old `CodeLanguage(value.lower())` construction and the
new `coerce_language()`.
- Observed result: the old construction raises `ValueError` on every
alias (the crash / silent-skip); the new helper maps them and never
raises:

```text
OK alias 'js': old raised ValueError -> new maps to javascript
OK alias 'ts': old raised ValueError -> new maps to typescript
OK alias 'py': old raised ValueError -> new maps to python
OK alias 'jsx': old raised ValueError -> new maps to javascript
OK alias 'node': old raised ValueError -> new maps to javascript
OK canonical values pass through
OK case-insensitive + trimmed
OK unknown -> UNKNOWN (no ValueError)
LANGUAGE COERCION VERIFIED
```

- Not tested: running a full mixed-content document with ` ```js `
fences through a booted compression pipeline (needs the heavy stack).
The unit tests exercise the coercion directly and the
`compress(language="js")` entry point. Full local `pytest` deferred to
CI (OOM, per above).

## 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 — ran
lint + a standalone logic check; full pytest deferred to CI (local OOM,
disclosed above)
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

- No new dependencies; a small lookup table plus a helper and a
call-site change.
- @JerrettDavis tagging you — this one silently disables code-aware
compression for the most common fence tags (`js`/`ts`/`py`), so it may
be worth a look when you have a moment.
2026-07-10 23:57:33 -05:00
JD Davis
69fd2189a3
Extract request limit policy (#1982)
## Description

Extracts request/stream limit validation from `helpers.py` into
`headroom.proxy.request_limit_policy`. The helpers still read
environment variables at request time, but validation of SSE event size
and body-too-large status values is now pure and directly tested.

Closes #

## Type of Change

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

## Changes Made

- Added `request_limit_policy.py` for resolving SSE event max bytes and
body-too-large HTTP status values.
- Kept `helpers.get_sse_event_max_bytes` and
`helpers.get_body_too_large_status` reading env vars and delegating to
the pure policy.
- Added direct tests for defaults, valid override values, and invalid
values.
- Carried forward the LiteLLM callback compatibility shim needed for
current mypy on `main`.

## 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
python -m pytest tests\test_request_limit_policy.py
10 passed in 0.17s

python -m ruff check .
All checks passed!

python -m ruff format --check .
1095 files already formatted

python -m mypy headroom --ignore-missing-imports
Success: no issues found in 409 source files

gitleaks protect --staged --no-banner --redact
no leaks found
```

## Real Behavior Proof

- Environment: Windows, Python 3.13.13, branch
`jd/architecture-slice-31`.
- Exact command / steps: ran focused request-limit policy tests, ruff,
ruff format check, mypy, and staged gitleaks scan.
- Observed result: limit validation behavior is directly covered and
local lint/type/security checks pass.
- Not tested: live proxy request rejection; existing helper entry points
remain intact.

## Review Readiness

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

## Checklist

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

## Screenshots (if applicable)

N/A

## Additional Notes

Documentation and changelog updates are N/A for this internal
architecture-only refactor. The push reported existing default-branch
Dependabot alerts; no staged secret leaks were found for this PR.
2026-07-10 23:55:11 -05:00
JD Davis
094a53c047
refactor(proxy): isolate output effort policy (#1961)
## Description

Extracts provider-neutral output effort decisions into a pure
`output_effort_policy` module. `output_shaper` still owns request
mutation and labels, while the rank comparisons, legacy thinking clamp,
and OpenAI text verbosity eligibility now live behind small
deterministic functions.

Closes #

## Type of Change

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

## Changes Made

- Added `headroom.proxy.output_effort_policy` for effort lowering,
legacy thinking budget clamping, and OpenAI text verbosity decisions.
- Updated `output_shaper` to delegate those pure decisions while
preserving existing labels and request mutation behavior.
- Added focused policy tests for effort rank transitions, thinking clamp
boundaries, and verbosity creation/lowering.
- Included the current LiteLLM callback signature compatibility shim
required for repo-wide mypy on main-based slices.

## 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
python -m pytest tests/test_output_effort_policy.py tests/test_output_shaper.py tests/test_litellm_callback.py -q
56 passed in 6.34s

python -m ruff check .
All checks passed!

python -m ruff format --check .
1095 files already formatted

python -m mypy headroom --ignore-missing-imports
Success: no issues found in 409 source files

gitleaks protect --staged --no-banner --redact
no leaks found
```

## Real Behavior Proof

- Environment: Windows, Python 3.13.13, clean worktree based on
`headroomlabs/main`.
- Exact command / steps: targeted pytest, ruff, format check, repo-wide
mypy, staged gitleaks scan.
- Observed result: output effort policy/shaper/callback tests pass;
static checks pass; no staged secrets detected.
- Not tested: live provider calls; this slice preserves existing request
mutation behavior and only moves pure policy decisions.

## Review Readiness

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

## Checklist

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

## Screenshots (if applicable)

N/A

## Additional Notes

Documentation and changelog updates are not applicable for this internal
architecture slice. PR-specific GHAS checks will be monitored after
opening.
2026-07-10 23:53:00 -05:00
JD Davis
b1e871d51c
refactor(proxy): isolate memory rank policy (#1960)
## Description

Extracts the proxy memory ranking formulas into a pure
`memory_rank_policy` module and keeps `MemoryCandidate` /
`RecencyBoostRanker` as the public adapter-facing API. Also preserves
backend memory IDs when ranked candidates are rebuilt, so downstream
memory update/delete handles survive the ranking boundary.

Closes #

## Type of Change

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

## Changes Made

- Added `headroom.proxy.memory_rank_policy` for timestamp parsing,
recency factor calculation, and score boosting.
- Updated `RecencyBoostRanker` to delegate policy math while preserving
the existing public API.
- Preserved `MemoryCandidate.id` when rank output candidates are
rebuilt.
- Added focused policy tests plus an ID-preservation regression test.
- Included the current LiteLLM callback signature compatibility shim
required for repo-wide mypy on main-based slices.

## 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
python -m pytest tests/test_memory_rank_policy.py tests/test_memory_ranker.py tests/test_litellm_callback.py -q
32 passed in 6.22s

python -m ruff check .
All checks passed!

python -m ruff format --check .
1095 files already formatted

python -m mypy headroom --ignore-missing-imports
Success: no issues found in 409 source files

gitleaks protect --staged --no-banner --redact
no leaks found
```

## Real Behavior Proof

- Environment: Windows, Python 3.13.13, local worktree based on
`headroomlabs/main`.
- Exact command / steps: targeted pytest, ruff, ruff format check,
repo-wide mypy, staged gitleaks scan.
- Observed result: memory rank policy/ranker/callback tests pass; static
checks pass; no staged secrets detected.
- Not tested: full provider/API integration; this slice only changes
pure policy delegation and candidate shape preservation.

## Review Readiness

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

## Checklist

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

## Screenshots (if applicable)

N/A

## Additional Notes

Documentation and changelog updates are not applicable for this internal
architecture slice. PR-specific GHAS checks will be monitored after
opening.
2026-07-10 23:51:51 -05:00
JD Davis
1c1e360112
refactor(proxy): isolate project attribution policy (#1957)
## Description

Extracts pure project attribution policy from the runtime project
context holder. Header classification, project path splitting, and
project-prefixed base URL construction now live in a policy module while
`project_context` keeps the ContextVar and ASGI scope adapter
responsibilities.

Closes #

## Type of Change

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

## Changes Made

- Added `headroom.proxy.project_policy` for pure project attribution
header/path/base-URL helpers.
- Updated `headroom.proxy.project_context` to re-export the pure helpers
and retain only request context binding and ASGI scope mutation.
- Added direct tests for the extracted project attribution policy
boundary.
- Kept the LiteLLM callback compatibility shim required for repo-wide
type checking on fresh branches.

## 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
python -m pytest tests/test_project_policy.py tests/test_proxy_project_savings.py tests/test_litellm_callback.py tests/test_compress_api.py::TestLiteLLMCallback -q
29 passed in 13.70s

python -m ruff check .
All checks passed!

python -m ruff format --check .
1095 files already formatted

python -m mypy headroom --ignore-missing-imports
Success: no issues found in 409 source files

gitleaks protect --staged --no-banner --redact
no leaks found
```

## Real Behavior Proof

- Environment: Windows, Python 3.13.13, local worktree based on
`headroomlabs/main`.
- Exact command / steps: ran focused project policy tests, project
savings tests, LiteLLM callback tests, Ruff lint/format checks, mypy
over `headroom`, and staged gitleaks scan.
- Observed result: all local checks passed; staged secret scan found no
leaks.
- Not tested: full CI matrix and deployment flows; those are covered by
GitHub Actions.

## Review Readiness

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

## Checklist

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

## Screenshots (if applicable)

N/A

## Additional Notes

Documentation and changelog updates are not applicable for this internal
refactor. GitHub reported existing Dependabot alerts on the default
branch during push; this PR does not change dependencies, and the staged
secret scan is clean.
2026-07-10 23:50:27 -05:00
JD Davis
740fb9bc16
refactor(cache): isolate semantic key policy (#1953)
## Description

Extracts proxy semantic-cache key normalization and hashing into a pure
policy module while preserving `SemanticCache._compute_key` for existing
callers and tests. This separates deterministic cache-key construction
from the async cache adapter and LRU storage concerns.

Closes #

## Type of Change

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

## Changes Made

- Added `headroom.proxy.semantic_cache_key` for pure cache-control
stripping and semantic cache key construction.
- Updated `SemanticCache._compute_key` to delegate to the extracted
policy while preserving the local `_strip_cache_control` compatibility
alias.
- Added direct tests for the extracted semantic-cache key policy.
- Kept the LiteLLM callback compatibility shim required for repo-wide
type checking on fresh branches.

## 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
python -m pytest tests/test_proxy_semantic_cache_key_policy.py tests/test_proxy_semantic_cache_key.py tests/test_proxy_semantic_cache_key_integration.py tests/test_proxy_openai_cache_key_integration.py tests/test_litellm_callback.py tests/test_compress_api.py::TestLiteLLMCallback -q
46 passed in 11.83s

python -m ruff check .
All checks passed!

python -m ruff format --check .
1095 files already formatted

python -m mypy headroom --ignore-missing-imports
Success: no issues found in 409 source files

gitleaks protect --staged --no-banner --redact
no leaks found
```

## Real Behavior Proof

- Environment: Windows, Python 3.13.13, local worktree based on
`headroomlabs/main`.
- Exact command / steps: ran focused semantic cache key tests, handler
cache-key integration tests, LiteLLM callback tests, Ruff lint/format
checks, mypy over `headroom`, and staged gitleaks scan.
- Observed result: all local checks passed; staged secret scan found no
leaks.
- Not tested: full CI matrix and deployment flows; those are covered by
GitHub Actions.

## Review Readiness

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

## Checklist

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

## Screenshots (if applicable)

N/A

## Additional Notes

Documentation and changelog updates are not applicable for this internal
refactor. GitHub reported existing Dependabot alerts on the default
branch during push; this PR does not change dependencies, and the staged
secret scan is clean.
2026-07-10 23:49:27 -05:00
JD Davis
ea1951508b
refactor(proxy): isolate rate limit policy (#1954)
## Description

Extracts token-bucket refill, consume, wait-time, and stale-bucket
selection formulas into a pure rate-limit policy module while preserving
the async `TokenBucketRateLimiter` adapter for locks and mutable bucket
storage.

Closes #

## Type of Change

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

## Changes Made

- Added `headroom.proxy.rate_limit_policy` for pure token-bucket
calculations.
- Updated `TokenBucketRateLimiter` to delegate refill, consume, and
stale-key selection to the extracted policy.
- Added direct tests for the rate-limit policy boundary.
- Kept the LiteLLM callback compatibility shim required for repo-wide
type checking on fresh branches.

## 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
python -m pytest tests/test_rate_limit_policy.py tests/test_proxy_healthchecks.py tests/test_litellm_callback.py tests/test_compress_api.py::TestLiteLLMCallback -q
27 passed in 17.82s

python -m ruff check .
All checks passed!

python -m ruff format --check .
1095 files already formatted

python -m mypy headroom --ignore-missing-imports
Success: no issues found in 409 source files

gitleaks protect --staged --no-banner --redact
no leaks found
```

## Real Behavior Proof

- Environment: Windows, Python 3.13.13, local worktree based on
`headroomlabs/main`.
- Exact command / steps: ran focused rate-limit policy tests, proxy
health checks, LiteLLM callback tests, Ruff lint/format checks, mypy
over `headroom`, and staged gitleaks scan.
- Observed result: all local checks passed; staged secret scan found no
leaks.
- Not tested: full CI matrix and deployment flows; those are covered by
GitHub Actions.

## Review Readiness

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

## Checklist

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

## Screenshots (if applicable)

N/A

## Additional Notes

Documentation and changelog updates are not applicable for this internal
refactor. GitHub reported existing Dependabot alerts on the default
branch during push; this PR does not change dependencies, and the staged
secret scan is clean.
2026-07-10 23:47:33 -05:00
JD Davis
c20f3b1c04
refactor(memory): isolate injection decision policy (#1952)
## Description

Extracts the memory injection decision precedence and skip-reason tag
stamping into a pure policy module while preserving the public
`MemoryDecision.decide` API used by handlers. This keeps the frozen
decision value type separate from the gate policy it wraps.

Closes #

## Type of Change

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

## Changes Made

- Added `headroom.proxy.memory_decision_policy` for pure
memory-injection precedence and tag stamping helpers.
- Updated `MemoryDecision.decide` and `MemoryDecision.apply_to_tags` to
delegate to the extracted policy.
- Added direct tests for the extracted policy boundary.
- Kept the LiteLLM callback compatibility shim required for repo-wide
type checking on fresh branches.

## 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
python -m pytest tests/test_memory_decision_policy.py tests/test_memory_decision.py tests/test_memory_invariants.py tests/test_litellm_callback.py tests/test_compress_api.py::TestLiteLLMCallback -q
37 passed in 6.74s

python -m ruff check .
All checks passed!

python -m ruff format --check .
1095 files already formatted

python -m mypy headroom --ignore-missing-imports
Success: no issues found in 409 source files

gitleaks protect --staged --no-banner --redact
no leaks found
```

## Real Behavior Proof

- Environment: Windows, Python 3.13.13, local worktree based on
`headroomlabs/main`.
- Exact command / steps: ran focused memory decision tests, memory
invariant tests, LiteLLM callback tests, Ruff lint/format checks, mypy
over `headroom`, and staged gitleaks scan.
- Observed result: all local checks passed; staged secret scan found no
leaks.
- Not tested: full CI matrix and deployment flows; those are covered by
GitHub Actions.

## Review Readiness

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

## Checklist

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

## Screenshots (if applicable)

N/A

## Additional Notes

Documentation and changelog updates are not applicable for this internal
refactor. GitHub reported existing Dependabot alerts on the default
branch during push; this PR does not change dependencies, and the staged
secret scan is clean.
2026-07-10 23:45:44 -05:00
JD Davis
235c986c9c
refactor(memory): isolate query construction policy (#1950)
## Description

Extracts memory retrieval query construction policy into a pure helper
module while preserving `MemoryQuery` as the public frozen value type.
The dataclass now delegates source extraction and embedding-input
rendering to policy helpers, keeping query construction separate from
the value wrapper.

Closes #

## Type of Change

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

## Changes Made

- Added `headroom.proxy.memory_query_policy` for pure retrieval query
source extraction and rendering.
- Updated `MemoryQuery.to_embedding_input` and
`MemoryQuery.from_messages` to delegate to the extracted policy.
- Added direct tests for the policy boundary.
- Kept the LiteLLM callback compatibility shim required for repo-wide
type checking on fresh branches.

## 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
python -m pytest tests/test_memory_query_policy.py tests/test_memory_query.py tests/test_memory_invariants.py tests/test_litellm_callback.py tests/test_compress_api.py::TestLiteLLMCallback -q
30 passed in 6.69s

python -m ruff check .
All checks passed!

python -m ruff format --check .
1095 files already formatted

python -m mypy headroom --ignore-missing-imports
Success: no issues found in 409 source files

gitleaks protect --staged --no-banner --redact
no leaks found
```

## Real Behavior Proof

- Environment: Windows, Python 3.13.13, local worktree based on
`headroomlabs/main`.
- Exact command / steps: ran focused memory query tests, memory
invariant tests, LiteLLM callback tests, Ruff lint/format checks, mypy
over `headroom`, and staged gitleaks scan.
- Observed result: all local checks passed; staged secret scan found no
leaks.
- Not tested: full CI matrix and deployment flows; those are covered by
GitHub Actions.

## Review Readiness

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

## Checklist

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

## Screenshots (if applicable)

N/A

## Additional Notes

Documentation and changelog updates are not applicable for this internal
refactor. GitHub reported existing Dependabot alerts on the default
branch during push; this PR does not change dependencies, and the staged
secret scan is clean.
2026-07-10 23:44:27 -05:00
JD Davis
c29b4ba84f
refactor(output): isolate savings policy (#1947)
## Description

Extracts the output-savings stratification, holdout assignment,
conversation key, and transform-label helpers into a pure policy module
while preserving the existing `headroom.proxy.output_savings` public
imports. This keeps the estimator/ledger adapter focused on statistics
and persistence.

Closes #

## Type of Change

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

## Changes Made

- Added `headroom.proxy.output_savings_policy` for pure savings policy
helpers.
- Re-exported the moved helpers from `headroom.proxy.output_savings` to
keep callers stable.
- Added direct tests for the extracted policy boundary.
- Kept the LiteLLM callback compatibility shim required for repo-wide
type checking on fresh branches.

## 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
python -m pytest tests/test_output_savings_policy.py tests/test_output_savings.py tests/test_litellm_callback.py tests/test_compress_api.py::TestLiteLLMCallback -q
54 passed in 6.42s

python -m ruff check .
All checks passed!

python -m ruff format --check .
1095 files already formatted

python -m mypy headroom --ignore-missing-imports
Success: no issues found in 409 source files

gitleaks protect --staged --no-banner --redact
no leaks found
```

## Real Behavior Proof

- Environment: Windows, Python 3.13.13, local worktree based on
`headroomlabs/main`.
- Exact command / steps: ran the focused pytest set, Ruff lint/format
checks, mypy over `headroom`, and staged gitleaks scan.
- Observed result: all local checks passed; staged secret scan found no
leaks.
- Not tested: full CI matrix and deployment flows; those are covered by
GitHub Actions.

## Review Readiness

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

## Checklist

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

## Screenshots (if applicable)

N/A

## Additional Notes

Documentation and changelog updates are not applicable for this internal
refactor. GitHub reported existing Dependabot alerts on the default
branch during push; this PR does not change dependencies, and the staged
secret scan is clean.
2026-07-10 23:42:11 -05:00
Abhay Singh
b699bedf95
fix(models): version-boundary longest-prefix match in ModelRegistry.get (#1658)
## Description

`ModelRegistry.get()` has a prefix fallback for versioned model ids. It
accepted
**any** registered name as a bare `str.startswith` prefix and returned
the
**first** match in dict-insertion order:

```python
for name, info in _MODELS.items():
    if model_lower.startswith(name):
        return info
```

Two concrete failures fall out of that:

- `gpt-4` is registered before `gpt-4-32k`, so `get("gpt-4-32k-0613")`
matches
`gpt-4` first and returns an **8192**-token window instead of
`gpt-4-32k`'s
  **32768**.
- `gpt-4.1` / `gpt-4.5-preview` aren't registered, so they also match
`gpt-4`
and inherit its **8192**-token window — even though they're much larger,
  distinct models.

`get_context_limit()` reads straight from `get()` (no LiteLLM fallback),
so both
cases make the proxy believe a nearly-empty context is almost full and
compress
far too aggressively — or reject — on requests that are actually small.
This is
silent: no error, just a wrong number driving every downstream
compression
decision for those models.

## Fix

The fallback now:

1. Only matches when the registered name ends at a **version boundary**
in the
query — the next character must be a separator (`-`, `/`, `:`, `@`, `_`)
— so
`gpt-4.1`'s `.` no longer matches `gpt-4` (it falls through to the
caller's
   default instead of a wrong 8192).
2. Picks the **longest** qualifying name, so `gpt-4-32k-0613` →
`gpt-4-32k`.

Exact and alias lookups are unchanged, and boundary-separated variants
like
`gpt-4o-new-version` still resolve to `gpt-4o`.

## Type of Change

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

## Changes Made

- `headroom/models/registry.py`: replace the first-match `startswith`
prefix loop in `ModelRegistry.get` with a
longest-prefix-at-a-version-boundary match.
- `tests/test_models.py`: add regression tests — `gpt-4-32k-0613` →
`gpt-4-32k` (32768), and `gpt-4.1`/`gpt-4.5-preview` no longer resolve
to gpt-4's 8192 window.
- `CHANGELOG.md`: Bug Fixes entry under Unreleased.

## Testing

- [x] New tests added for the fixed behavior (`tests/test_models.py`)
- [x] Linting passes (`ruff check`) and formatting is clean (`ruff
format --check`)
- [ ] Full `pytest` run deferred to CI — see Real Behavior Proof for why
I verify the logic with a dependency-free script locally.

```text
$ uv run ruff check headroom/models/registry.py tests/test_models.py
All checks passed!
$ uv run ruff format --check headroom/models/registry.py tests/test_models.py
2 files already formatted
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12.11, headroom built from this
branch (`uv sync --extra dev`). Importing `headroom` pulls in the
torch/transformers stack; a full `pytest` run exhausts memory and gets
OOM-killed on this box, so I verify the matching logic with a
dependency-free script (only stdlib) and leave the full pytest to CI.
- Exact command / steps: replicated the relevant `_MODELS` registration
order (`gpt-4o`, `gpt-4-turbo`, `gpt-4`, `gpt-4-32k`) and the new
longest-prefix-with-boundary loop in a standalone script (no `headroom`
import), then asserted the resolved context windows.
- Observed result: `gpt-4-32k-0613` resolves to 32768 (was 8192 under
first-match), `gpt-4.1`/`gpt-4.5-preview` fall through to the caller
default (no longer 8192), and `gpt-4o-new-version` / `gpt-4` /
`gpt-4-0613` resolve exactly as before:

```text
OK: gpt-4-32k-0613 -> 32768 (was 8192 under old first-prefix-wins)
OK: gpt-4.1 / gpt-4.5-preview -> default (not 8192)
OK: gpt-4o-new-version, gpt-4, gpt-4-0613 still resolve as before
REGISTRY LOGIC VERIFIED
```

- Not tested: I did not add explicit registry entries for
`gpt-4.1`/`gpt-4.5` (their real windows) — that's a data addition,
separate from this matching-logic fix; today they fall back to the
caller's default, which is honest for an unregistered model and strictly
better than the previous wrong 8192. Full local `pytest` deferred to CI
(OOM, per above).

## 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 — ran
lint + a standalone logic check; full pytest deferred to CI (local OOM,
disclosed above)
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

- No new dependencies; pure logic change in one function plus tests.
- Found via a read-through of the registry while looking at how context
limits drive compression decisions.
2026-07-10 23:07:31 -05:00
Rod Boev
d1db00ab86
fix(proxy): keep PRE_SEND from reintroducing empty tool arrays (#2015)
## Description

The direct body-write fix for empty `tools: []` already landed, but the
later OpenAI PRE_SEND write-back path still reintroduces the empty
array. This aligns that guard with the existing direct-assignment
contract so tools-free requests stay tools-free while explicit client
`tools: []` stays preserved. Anthropic's current-main PRE_SEND path
already had the equivalent empty-tools protection and needed no code
change.

Closes #1983

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

- Mirror the direct `tools or _original_tools is not None` guard in the
OpenAI PRE_SEND write-back path.
- Leave Anthropic unchanged because current `main` already protects the
empty-tools case there.
- Extend the focused #728 regression file with PRE_SEND-specific
coverage.
- Add a changelog note for providers that reject empty `tools` arrays.

## Testing

- [x] Unit tests pass
- [x] Linting passes
- [ ] Type checking passes
- [x] New tests added for new functionality when applicable
- [ ] Manual testing performed

### Test Output

```text
uv run pytest tests/test_issue_728_empty_tools_injection.py -q
11 passed

uv run ruff check headroom/proxy/handlers/openai.py tests/test_issue_728_empty_tools_injection.py
All checks passed

uv run ruff format --check headroom/proxy/handlers/openai.py tests/test_issue_728_empty_tools_injection.py
2 files already formatted
```

## Real Behavior Proof

- Environment: OpenAI-compatible provider that rejects empty `tools`
arrays
- Exact command / steps: send a request without `tools`, then repeat
with explicit `tools: []`
- Observed result: the OpenAI PRE_SEND path now skips `tools: []` when
the client omitted tools, while the focused regression still preserves
explicit client `tools: []` and deliberate clearing of a previously
present tool list
- Not tested: live provider run on this host
- Scope: PRE_SEND request-body write-back

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

The change is intentionally narrow. It only brings PRE_SEND write-back
into parity with the direct-assignment guard that already exists.
2026-07-10 22:38:27 -05:00
JD Davis
9bacf4810f
refactor(transforms): isolate mixed content parsing (#1939)
## Description

Extracts mixed-content parsing out of the large `ContentRouter` module
into a pure transform-domain module. The router still exports the
existing compatibility names, but section typing, mixed-content
indicators, section splitting, and JSON block extraction now live in a
focused domain object/function layer.

Closes #

## Type of Change

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

## Changes Made

- Added `headroom.transforms.mixed_content` with `ContentSection`,
`mixed_content_indicators`, `is_mixed_content`, `split_into_sections`,
and JSON block extraction.
- Updated `ContentRouter` to delegate mixed-content debug indicators and
parsing to the new module while preserving legacy imports from
`content_router.py`.
- Added direct unit coverage for mixed-content detection, section
boundaries, and JSON delimiters inside string literals.
- Included the LiteLLM callback signature compatibility shim needed for
repo-wide mypy while the earlier architecture PRs are still open.

## 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
python -m pytest tests/test_mixed_content_sections.py tests/test_transforms_content_router.py tests/test_litellm_callback.py tests/test_compress_api.py::TestLiteLLMCallback -q
50 passed in 6.82s

python -m ruff check .
All checks passed!

python -m ruff format --check .
1095 files already formatted

python -m mypy headroom --ignore-missing-imports
headroom\proxy\server.py:1457: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs  [annotation-unchecked]
headroom\proxy\server.py:1468: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs  [annotation-unchecked]
Success: no issues found in 409 source files
```

## Real Behavior Proof

- Environment: Windows, Python 3.13.13, clean worktree
`C:\git\headroom-pr-slice6`
- Exact command / steps: ran the pytest, Ruff, format, and mypy commands
listed above.
- Observed result: mixed-content parsing behavior remains covered
through existing router tests and new direct tests; repo-wide lint/type
checks pass.
- Not tested: full pytest suite and Docker/native CI jobs are left to
GitHub Actions.

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

- Documentation, changelog, and screenshots are N/A for this internal
refactor.
- Manual UI testing is N/A; this is pure transform parsing logic.
- Comment checklist is unchecked because the extracted functions are
small and covered by direct tests.
2026-07-10 19:28:21 -05:00
JD Davis
5a7265daa8
refactor(proxy): isolate auth classification policy (#1945)
## Description

Extract auth-mode and client-harness classification rules into
`headroom.proxy.auth_policy`, leaving `auth_mode` as the
header-reading/logging adapter. This gives the proxy a pure
`AuthSignals` value object and deterministic policy functions for auth
mode, client classification, and Codex Responses stamping.

Closes #

## Type of Change

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

## Changes Made

- Added `AuthSignals` as the normalized input model for pure auth/client
policy.
- Moved `AuthMode`, subscription UA prefixes, client UA map, Codex
Responses path, auth-mode classification, client classification, and
Codex stamping rules into `headroom.proxy.auth_policy`.
- Kept `headroom.proxy.auth_mode` public API stable by adapting headers
into `AuthSignals` and delegating to policy functions.
- Added direct pure-policy tests for subscription precedence, OAuth/PAYG
token shapes, explicit client override, and Codex Responses stamping.
- Included the LiteLLM callback hook compatibility shim needed for
repo-wide mypy on branches based on `main`.

## 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
python -m pytest tests/test_auth_policy.py tests/test_auth_mode.py tests/test_codex_client_stamp.py tests/test_litellm_callback.py tests/test_compress_api.py::TestLiteLLMCallback -q
48 passed in 6.63s

python -m ruff check .
All checks passed!

python -m ruff format --check .
1095 files already formatted

python -m mypy headroom --ignore-missing-imports
Success: no issues found in 409 source files
```

## Real Behavior Proof

- Environment: Windows, Python 3.13.13, local worktree
`C:\git\headroom-pr-slice9`.
- Exact command / steps: Ran the focused pytest suite plus repo-wide
Ruff, format check, and mypy commands listed above.
- Observed result: Existing adapter behavior remains covered by
`tests/test_auth_mode.py` and `tests/test_codex_client_stamp.py`, while
the extracted pure policy is covered by `tests/test_auth_policy.py`.
- Not tested: Full test suite locally; CI will run the full 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 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

Documentation and changelog updates are not applicable for this internal
refactor. The LiteLLM shim is repeated here because this branch is
intentionally independent from the other open architecture slices and
must stay green against current `main`.
2026-07-10 19:27:08 -05:00
JD Davis
b5aa8a358e
refactor(cache): isolate compression strategy outcomes (#1938)
## Description

Extracts local compression strategy accounting out of
`CompressionFeedback` into a pure cache-domain object. This keeps
strategy counters, retrieval-rate math, pruning, and best-strategy
selection independently testable while preserving the existing
`LocalToolPattern` public API.

Closes #

## Type of Change

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

## Changes Made

- Added `CompressionStrategyOutcomes` as the strategy-outcome domain for
compression/retrieval counters, pruning, retrieval rates, and
recommendation selection.
- Updated `LocalToolPattern` and `CompressionFeedback` to delegate
strategy accounting to that domain while keeping existing fields and
methods intact.
- Added direct unit coverage for strategy outcome math and bounded
pruning behavior.
- Updated the LiteLLM callback hook signature to remain compatible with
current LiteLLM typing and the existing three-argument call shape.

## 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
python -m ruff check .
All checks passed!

python -m ruff format --check .
1095 files already formatted

python -m mypy headroom --ignore-missing-imports
headroom\proxy\server.py:1457: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs  [annotation-unchecked]
headroom\proxy\server.py:1468: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs  [annotation-unchecked]
Success: no issues found in 409 source files

python -m pytest tests/test_compression_strategy_outcomes.py tests/test_ccr_feedback.py tests/test_toin_fixes.py tests/test_litellm_callback.py tests/test_compress_api.py::TestLiteLLMCallback -q
collected 54 items
46 passed, 8 skipped in 6.58s
```

## Real Behavior Proof

- Environment: Windows, Python 3.13.13, clean worktree
`C:\git\headroom-pr-slice5`
- Exact command / steps: ran the lint, format, type-check, and focused
pytest commands listed above.
- Observed result: strategy outcome tests and existing
feedback/TOIN/LiteLLM compatibility tests pass; repo-wide lint/type
validation passes.
- Not tested: full pytest suite and Docker/native CI jobs are left to
GitHub Actions.

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

- Documentation and changelog are N/A for this internal refactor.
- Manual UI testing is N/A; this is cache feedback and integration
callback logic.
- Comment checklist is unchecked because the extracted object is
intentionally straightforward and covered by tests.
2026-07-10 19:21:47 -05:00
Rod Boev
41af39d769
fix(proxy): preserve terminal tool on Codex Responses (#2000)
## Description

Cache-mode optimization can make a client-defined Responses function
named `terminal` invalid by treating it as a deferrable tool. On
supported models with a large tool set, Headroom adds `defer_loading`
and tool search; the Codex endpoint then rejects the request as
`terminal.terminal` in a reserved namespace.

This keeps the exact `terminal` function resident in the OpenAI
Responses deferral helper. Other non-core functions and MCP tools remain
eligible for deferral, and unsupported models or small tool sets keep
their existing no-op behavior.

Closes #1946

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

- Keep the exact OpenAI Responses function name `terminal` resident
during server-side tool-search deferral.
- Preserve deferral for adjacent and unrelated function names, MCP
tools, and the existing model and tool-count gates.
- Add issue-shaped regression and negative-space coverage.
- Document the user-visible fix in `CHANGELOG.md`.

## Testing

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

### Test Output

```text
uv sync --extra dev
OK

uv run pytest tests/test_openai_tool_search_deferral.py -q
25 passed

uv run ruff check headroom/proxy/helpers.py tests/test_openai_tool_search_deferral.py
All checks passed

uv run ruff format --check headroom/proxy/helpers.py tests/test_openai_tool_search_deferral.py
2 files already formatted
```

## Real Behavior Proof

- Environment: credentialed Codex Responses endpoint, `gpt-5.6-terra`,
Headroom cache mode with lossless compression
- Exact command / steps: start `headroom proxy --mode cache --lossless`,
then send a Responses request with at least 12 tools including the bare
client-defined `terminal` function
- Observed result: local proof now locks the emitted request shape,
`terminal` stays resident, adjacent names such as `terminal_helper`
still defer, and the input remains unchanged; live upstream acceptance
on `gpt-5.6-terra` still needs a credentialed run
- Not tested: live upstream acceptance on a credentialed `gpt-5.6-terra`
Responses request with the exact issue-shaped tool set.
- Scope: OpenAI Responses tool-search deferral in the optimized request
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
- [ ] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A

## Additional Notes

The change is scoped to the exact `terminal` function name in OpenAI
Responses tool-search deferral. It does not change ContentRouter policy,
Anthropic tool deferral, tool schema compaction, or unrelated function
names. Live endpoint acceptance is still an external proof item and is
called out in Real Behavior Proof.
2026-07-10 16:17:25 -07:00
Dima Solodukha
75d786117a
fix(proxy): cache_savings_usd silently zeroes when litellm is unavailable (#2005)
## Description

On any install where `litellm` cannot be imported, `SavingsTracker`'s
`lifetime.cache_savings_usd` and `display_session.cache_savings_usd`
stay pinned at exactly `0.0` forever — while `cache_read_tokens`
accumulates correctly and `total_input_cost_usd` stays nonzero, so the
tracker looks alive and the zero is easy to miss.

This hits every Python 3.14 install out of the box: the project's own
dependency spec is `litellm>=1.86.2,<2.0 ; python_full_version <
'3.14'`, so on 3.14 `LITELLM_AVAILABLE` is `False` and cache savings
silently read as $0. Observed in the wild with 45.8M lifetime
`cache_read_tokens` and `cache_savings_usd: 0.0` in
`proxy_savings.json`.

Root cause: `_estimate_cache_savings_usd` is the only one of the three
USD estimators with no fallback when litellm is missing —
`_estimate_input_cost_usd` and `_estimate_compression_savings_usd` both
fall back to `DEFAULT_FALLBACK_INPUT_COST_PER_TOKEN`, while
`_estimate_cache_savings_usd` returns `0.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/proxy/savings_tracker.py`: when litellm is unavailable,
`_estimate_cache_savings_usd` now estimates at
`DEFAULT_FALLBACK_INPUT_COST_PER_TOKEN` per cache-read token — mirroring
the fallback its two sibling estimators already use (approximate over
zero). Unknown-model behaviour with litellm present is unchanged (still
fails open to `0.0`).
- `tests/test_proxy_savings_history.py`: new regression test
`test_cache_savings_usd_falls_back_when_litellm_unavailable` (unit +
through `SavingsTracker.record_request`);
`test_cache_savings_edge_cases_zero_and_unpriced` now pins a fake
litellm price table so it keeps testing the unpriced-model path on every
environment (without litellm installed it would otherwise exercise the
fallback path instead).

## 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_proxy_savings_history.py -k "cache_savings" -q
========================= 3 passed, 1 warning in 0.34s =========================

$ ruff check headroom/proxy/savings_tracker.py tests/test_proxy_savings_history.py
All checks passed!
$ ruff format --check headroom/proxy/savings_tracker.py tests/test_proxy_savings_history.py
2 files already formatted

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

## Real Behavior Proof

- Environment: macOS, Python 3.14.6 venv, headroom installed editable —
litellm absent (excluded by the project's own `python_full_version <
'3.14'` marker), i.e. the exact environment the bug ships in.
- Exact command / steps: ran the one-liner below twice in that venv —
once with `headroom/proxy/savings_tracker.py` checked out from main
(`d2170b19`), once from this branch — output pasted verbatim:
  ```text
  $ python -c 'import headroom.proxy.savings_tracker as st;
    print("litellm importable:", st._get_litellm_module() is not None);
    print("cache_savings_usd for 1M cache-read tokens:",
st._estimate_cache_savings_usd("claude-sonnet-4-6", 1_000_000))'

  # on main (d2170b19):
  litellm importable: False
  cache_savings_usd for 1M cache-read tokens: 0.0

  # on this branch:
  litellm importable: False
  cache_savings_usd for 1M cache-read tokens: 3.0
  ```
- Observed result: with litellm missing, main reports $0 saved for 1M
cache-read tokens; this branch reports the blended-rate estimate ($3.00
at the default fallback rate), consistent with what
`_estimate_input_cost_usd` already does for input cost.
- Not tested: proxy end-to-end on Python < 3.14 with litellm installed
(that path is unchanged — the litellm branch of the function is
untouched, covered by the existing
`test_cache_savings_usd_uses_litellm_discount_delta`).

## Review Readiness

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

## Checklist

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

## Additional Notes

- Documentation / CHANGELOG: no user-facing docs describe the
per-function fallback behaviour, and I didn't find a maintained
CHANGELOG.md at the repo root — happy to add an entry if there's a
preferred place.
- Full `tests/test_proxy_savings_history.py` run in my venv shows 7
failures that are identical on current main (missing optional dashboard
deps in my environment, unrelated to this change); every test touching
this change passes.
- `mypy headroom/proxy/savings_tracker.py` also prints a pre-existing
`pyproject.toml: note: unused section(s)` notice unrelated to this diff.
2026-07-10 16:12:32 -07:00
JD Davis
cb38f79377
refactor(proxy): isolate forwarded header policy (#1942)
## Description

Extract the trusted forwarded-header trust policy into
`headroom.proxy.forwarded_policy`, leaving `forwarded_headers` as the
FastAPI/request-state adapter. This makes CIDR parsing, peer trust,
leftmost forwarded-for handling, and rejection decisions deterministic
and directly testable without request/logging side effects.

Closes #

## Type of Change

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

## Changes Made

- Added `ForwardedHeaderInputs` and `ForwardedHeaderResolution` as pure
policy value objects.
- Moved CIDR parsing, IP normalization, trust membership, header
splitting, and forwarded-header resolution into
`headroom.proxy.forwarded_policy`.
- Kept `headroom.proxy.forwarded_headers` as the request adapter with
the same public API and compatibility helper names.
- Added direct tests for trusted, rejected, direct-client, IPv4-mapped
IPv6, and leftmost `X-Forwarded-For` policy behavior.
- Included the LiteLLM callback hook compatibility shim needed for
repo-wide mypy on branches based on `main`.

## 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
python -m pytest tests/test_forwarded_policy.py tests/test_forwarded_headers.py tests/test_litellm_callback.py tests/test_compress_api.py::TestLiteLLMCallback -q
51 passed in 6.36s

python -m ruff check .
All checks passed!

python -m ruff format --check .
1095 files already formatted

python -m mypy headroom --ignore-missing-imports
Success: no issues found in 409 source files
```

## Real Behavior Proof

- Environment: Windows, Python 3.13.13, local worktree
`C:\git\headroom-pr-slice8`.
- Exact command / steps: Ran the focused pytest suite plus repo-wide
Ruff, format check, and mypy commands listed above.
- Observed result: The existing request-facing forwarded-header behavior
remains covered by `tests/test_forwarded_headers.py`, while the
extracted pure policy is covered by `tests/test_forwarded_policy.py`.
- Not tested: Full test suite locally; CI will run the full 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 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

Documentation and changelog updates are not applicable for this internal
refactor. The LiteLLM shim is repeated here because this branch is
intentionally independent from the other open architecture slices and
must stay green against current `main`.
2026-07-10 17:41:38 -05:00
JD Davis
0ce09fb63f
refactor(output): isolate verbosity steering (#1940)
## Description

Extract byte-stable output verbosity steering into
`headroom.proxy.output_steering` so `output_shaper` can focus on turn
classification and effort routing while preserving the existing public
import surface.

Closes #

## Type of Change

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

## Changes Made

- Added `headroom.proxy.output_steering` for Anthropic system steering
and OpenAI Responses instruction steering.
- Kept existing `headroom.proxy.output_shaper` imports compatible by
re-exporting the moved helpers.
- Added direct tests for replacement, cache-prefix preservation, and
idempotent OpenAI Responses steering.
- Included the LiteLLM callback hook compatibility shim needed for
repo-wide mypy on branches based on `main`.

## 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
python -m pytest tests/test_output_steering.py tests/test_output_shaper.py tests/test_litellm_callback.py tests/test_compress_api.py::TestLiteLLMCallback -q
57 passed in 6.17s

python -m ruff check .
All checks passed!

python -m ruff format --check .
1095 files already formatted

python -m mypy headroom --ignore-missing-imports
Success: no issues found in 409 source files
```

## Real Behavior Proof

- Environment: Windows, Python 3.13.13, local worktree
`C:\git\headroom-pr-slice7`.
- Exact command / steps: Ran the focused pytest suite plus repo-wide
Ruff, format check, and mypy commands listed above.
- Observed result: Steering behavior remains covered through the
existing `output_shaper` tests and the new direct `output_steering`
tests.
- Not tested: Full test suite locally; CI will run the full 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 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

Documentation and changelog updates are not applicable for this internal
refactor. The LiteLLM shim is repeated here because this branch is
intentionally independent from the other open architecture slices and
must stay green against current `main`.
2026-07-10 17:39:39 -05:00
JD Davis
fd5b9e75ad
refactor(ccr): isolate tool call classification (#1937)
## Description
Extracts provider-shaped CCR tool-call extraction and classification
into `headroom.ccr.tool_calls`. `CCRResponseHandler` now delegates
detection/parsing to a pure domain module and stays focused on retrieval
execution and continuation orchestration.

Closes #

## Type of Change
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to not work as expected)
- [ ] Documentation update
- [ ] Performance improvement
- [x] Code refactoring (no functional changes)

## Changes Made
- Added `headroom.ccr.tool_calls` with provider-native extraction, CCR
detection, provider-specific tool result IDs, and CCR/other-tool
splitting.
- Re-exported the pure CCR tool-call helpers from `headroom.ccr`.
- Kept `CCRResponseHandler` private compatibility methods while
delegating to the new module.
- Added focused tests for Anthropic, OpenAI, Google, and OpenAI
Responses tool-call shapes.

## 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
python -m pytest tests/test_ccr_tool_calls.py tests/test_ccr_response_handler.py tests/test_ccr_response_handler_extra.py tests/test_ccr_response_handler_openai_responses.py tests/test_ccr_batch_processor.py::TestCCRToolCallDetectionInBatch -q
============================= 64 passed in 0.57s =============================

python -m ruff check headroom/ccr/tool_calls.py headroom/ccr/response_handler.py headroom/ccr/__init__.py tests/test_ccr_tool_calls.py tests/test_ccr_response_handler.py tests/test_ccr_response_handler_extra.py tests/test_ccr_response_handler_openai_responses.py tests/test_ccr_batch_processor.py
All checks passed!

python -m mypy headroom/ccr/tool_calls.py headroom/ccr/response_handler.py
Success: no issues found in 2 source files

python -m compileall -q headroom\ccr\tool_calls.py headroom\ccr\response_handler.py headroom\ccr\__init__.py
# no output; exited 0

git commit -m "refactor(ccr): isolate tool call classification"
Sync plugin versions.....................................................Passed
check for merge conflicts................................................Passed
ruff.....................................................................Passed
ruff-format..............................................................Passed
mypy.....................................................................Passed
```

## Real Behavior Proof
- Environment: Windows PowerShell, Python 3.13.13, branch
`jd/architecture-slice-4` based on `headroomlabs/main`.
- Exact command / steps: Ran CCR tool-call tests, existing CCR response
handler tests, OpenAI Responses CCR tests, CCR batch detection tests,
focused ruff, targeted mypy, compileall, and commit hooks.
- Observed result: Existing handler behavior remains covered while
provider-shaped CCR classification is now directly testable as a pure
module.
- Not tested: Full pytest suite, live upstream provider traffic, and
manual streaming clients.

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

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

## Screenshots (if applicable)
N/A

## Additional Notes
Documentation and CHANGELOG updates are N/A for this internal refactor.
Full pytest was not run; validation is focused on CCR tool-call
detection/parsing and response-handler compatibility.
2026-07-10 17:36:24 -05:00
JD Davis
4210d6e609
refactor(pricing): isolate litellm model resolution (#1936)
## Description
Extracts LiteLLM model-name resolution rules into a pure pricing-domain
module. `litellm_pricing.py` now acts as the adapter that asks LiteLLM
whether candidate keys exist, while `litellm_model_resolution.py` owns
prefix rules, alias rules, lookup candidate ordering, and deterministic
resolution.

Closes #

## Type of Change
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to not work as expected)
- [ ] Documentation update
- [ ] Performance improvement
- [x] Code refactoring (no functional changes)

## Changes Made
- Added `headroom.pricing.litellm_model_resolution` with explicit prefix
rules, alias rules, pricing lookup candidates, and a pure resolver
function.
- Simplified `headroom.pricing.litellm_pricing` to delegate model-name
selection to the pure resolver while keeping its public API and cache
behavior intact.
- Added focused tests for candidate ordering, case-insensitive MiniMax
matching, aliases, and unknown-model fallback.

## 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
python -m pytest tests/test_pricing_litellm_model_resolution.py tests/test_pricing_litellm.py tests/test_proxy_streaming_resilience.py::TestModelResolutionCaching -q
============================= 22 passed in 2.18s =============================

python -m ruff check headroom/pricing/litellm_model_resolution.py headroom/pricing/litellm_pricing.py tests/test_pricing_litellm_model_resolution.py tests/test_pricing_litellm.py tests/test_proxy_streaming_resilience.py
All checks passed!

python -m mypy headroom/pricing/litellm_model_resolution.py headroom/pricing/litellm_pricing.py
Success: no issues found in 2 source files

python -m compileall -q headroom\pricing\litellm_model_resolution.py headroom\pricing\litellm_pricing.py
# no output; exited 0

git commit -m "refactor(pricing): isolate litellm model resolution"
Sync plugin versions.....................................................Passed
check for merge conflicts................................................Passed
ruff.....................................................................Passed
ruff-format..............................................................Passed
mypy.....................................................................Passed
```

## Real Behavior Proof
- Environment: Windows PowerShell, Python 3.13.13, branch
`jd/pricing-model-resolution` based on `headroomlabs/main`.
- Exact command / steps: Ran pure resolver tests, LiteLLM pricing
adapter tests, model-resolution caching tests, focused ruff, targeted
mypy, compileall, and commit hooks.
- Observed result: Existing pricing behavior and cache behavior passed
while model resolution is now isolated and directly testable.
- Not tested: Full pytest suite and live LiteLLM network or package
update behavior beyond the local installed dependency/fakes.

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

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

## Screenshots (if applicable)
N/A

## Additional Notes
Documentation and CHANGELOG updates are N/A for this internal refactor.
Full pytest was not run; validation is focused on
pricing/model-resolution behavior touched by this slice.
2026-07-10 17:35:31 -05:00
JD Davis
1f3696a3d0
refactor(proxy): isolate body forwarding policy (#1935)
## Description
Extracts the byte-faithful Python forwarder policy out of the broad
proxy helpers module into a dedicated `headroom.proxy.body_forwarding`
domain. The new module owns the outbound body algebra: passthrough
original bytes, canonical JSON bytes for mutated bodies, and explicit
legacy JSON rollback mode.

Closes #

## Type of Change
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to not work as expected)
- [ ] Documentation update
- [ ] Performance improvement
- [x] Code refactoring (no functional changes)

## Changes Made
- Added `headroom.proxy.body_forwarding` with `OutboundBody`,
`OutboundBodySource`, `BodyMutationTracker`, mode resolution, canonical
serialization, and body selection helpers.
- Kept `headroom.proxy.helpers` compatibility exports for existing
callers.
- Updated Python forwarder call sites to import body-forwarding policy
from the dedicated module.
- Added tests for the new value object and compatibility exports.

## 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
python -m pytest tests/test_proxy_byte_faithful_forwarding.py -q
============================= 40 passed in 3.61s =============================

python -m ruff check headroom/proxy/body_forwarding.py headroom/proxy/helpers.py headroom/proxy/server.py headroom/proxy/handlers/streaming.py headroom/proxy/handlers/openai.py headroom/proxy/handlers/anthropic.py headroom/proxy/handlers/batch.py tests/test_proxy_byte_faithful_forwarding.py
All checks passed!

python -m mypy headroom/proxy/body_forwarding.py
Success: no issues found in 1 source file

python -m compileall -q headroom\proxy\body_forwarding.py headroom\proxy\helpers.py headroom\proxy\server.py headroom\proxy\handlers\streaming.py headroom\proxy\handlers\openai.py headroom\proxy\handlers\anthropic.py headroom\proxy\handlers\batch.py
# no output; exited 0

git commit -m "refactor(proxy): isolate body forwarding policy"
Sync plugin versions.....................................................Passed
check for merge conflicts................................................Passed
ruff.....................................................................Passed
ruff-format..............................................................Passed
mypy.....................................................................Passed
```

## Real Behavior Proof
- Environment: Windows PowerShell, Python 3.13.13, branch
`jd/architecture-slice-2` based on `headroomlabs/main`.
- Exact command / steps: Ran the focused byte-faithful forwarding suite,
focused ruff command, targeted mypy, compileall over touched modules,
and commit hooks.
- Observed result: Forwarding behavior stayed byte-faithful;
compatibility exports remain intact; lint, formatting, and mypy passed.
- Not tested: Full pytest suite, live upstream proxy traffic, and manual
end-to-end clients.

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

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

## Screenshots (if applicable)
N/A

## Additional Notes
Documentation and CHANGELOG updates are N/A for this internal refactor.
The full pytest suite was not run; validation is focused on the
body-forwarding domain and existing byte-faithful forwarding coverage.
2026-07-10 17:27:26 -05:00
Fabien Culpo
d2170b1922
fix(learn): parse fenced JSON even with a prose preamble (#1988)
## Description

`_strip_fenced_json` only stripped a markdown fence when the string
*started with* ```` ``` ````. When the model prefixed prose before the
fence (e.g. `Here is the JSON:\n\n```json ...`) despite being told to
return JSON only, the guard was skipped and `json.loads` ran on the
prose, raising `JSONDecodeError`. The claude-cli streaming path surfaced
this as `returned unparseable output`, and `headroom learn` silently
discarded the LLM analysis, degrading to "No actionable patterns found".
This is the parsing-side cousin of the silent-degradation issue fixed in
#373.

Closes #1989. Related: #373.

## 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/analyzer.py`: rewrote `_strip_fenced_json` to locate
the fenced block wherever it appears, then fall back to the whole text,
then to a first-`{` / last-`}` slice, only re-raising `JSONDecodeError`
if nothing parses as a JSON object. Preserves the prior "first opening /
last closing fence" behaviour and triple-backtick content inside the
payload. Fixes all three call sites (non-streaming CLI, claude-cli
streaming, litellm).
- `tests/test_learn/test_analyzer.py`: added regression cases to
`TestStripFencedJson` for preamble-before-fence, prose around a bare
object, and triple-backticks inside the payload.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`) — scoped to the changed
module (see Additional Notes)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ pytest tests/test_learn/test_analyzer.py -q
........................................................................ [ 86%]
...........                                                              [100%]
83 passed, 1 warning in 2.18s

$ ruff check headroom/learn/analyzer.py tests/test_learn/test_analyzer.py
All checks passed!
$ ruff format --check headroom/learn/analyzer.py tests/test_learn/test_analyzer.py
2 files already formatted

$ mypy --ignore-missing-imports --follow-imports=silent headroom/learn/analyzer.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: macOS, Python 3.13.12, headroom-ai at this branch
(runtime deps from an installed 0.30.0 env).
- Exact command / steps: ran the old vs new `_strip_fenced_json` on the
exact failing model output (a prose preamble followed by a ```json
fence), then applied the fix over an installed 0.30.0 and re-ran the
previously failing `headroom learn --apply`. Input sample: `'The JSON is
my deliverable for this analysis task. Here it
is:\n\n```json\n{"context_file_rules": [], "memory_file_rules":
[]}\n```'`
- Observed result: OLD raised `JSONDecodeError: Expecting value: line 1
column 1 (char 0)`; NEW returned `{'context_file_rules': [],
'memory_file_rules': []}`. The real `headroom learn --apply` run that
had been failing with `returned unparseable output` then completed and
consumed the LLM analysis instead of dropping it. Full transcript:
  ```text
  OLD: JSONDecodeError -> Expecting value: line 1 column 1 (char 0)
  NEW: {'context_file_rules': [], 'memory_file_rules': []}
  ```
- Not tested: full end-to-end `headroom learn --apply` was not re-run
inside CI here (it shells out to a live `claude` CLI); the parser is
exercised deterministically by the added unit tests and the before/after
repro above.

## 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 —
updated the function docstring only; no external docs affected)
- [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 (N/A — no CHANGELOG
entry convention observed for this fix; happy to add if maintainers
prefer)

## Additional Notes

- `mypy` was run against the changed module in isolation
(`--ignore-missing-imports --follow-imports=silent`) rather than the
full project, because I validated in an ad-hoc environment; the change
keeps the existing `-> dict` signature and annotations, so it is
type-neutral.
- Not addressed here (possible follow-up): the failure is swallowed as a
warning in `analyze()`, so users only see "No actionable patterns found"
with no signal the LLM pass produced nothing — the same
silent-degradation class as #373, on the parsing side.
2026-07-10 11:15:36 -07:00
Abhay Singh
5e14b8c0f2
fix(memory/sync): don't clobber memories sharing a first line (#1976)
## Description

`ClaudeCodeAdapter.write_memories`
(`headroom/memory/sync_adapters/claude_code.py`) picks
each memory's file name from the **first line of its content only**:

```python
first_line = content.split("\n")[0][:60].strip()
slug = _sanitize_for_filename(first_line)
filename = f"headroom_{slug}.md"
```

So two *distinct* DB memories whose first lines slugify to the same
value map to the same
file. The existing "already on disk?" guard only skips when the on-disk
content hash equals
this memory's hash — for a genuine collision (same slug, different body)
it falls through and
`target.write_text(...)` overwrites the other memory. Data loss.

It also never converges. The overwritten memory never lands on disk, so
on the next
`sync_export` the adapter reads back the agent's files, doesn't find
that memory's hash in
`agent_hashes`, and re-exports it — overwriting the other one this time.
The pair ping-pongs
on every sync, and each round appends a fresh line to `MEMORY.md`.

This is realistic for headed/structured memories (e.g. several entries
that begin
`# Project conventions` or `The user prefers …`).

Closes: no issue filed — found while auditing the memory sync adapters.

## Fix

When the slug is already taken by a **different** memory (a distinct
`headroom_id` in the
existing file's frontmatter), disambiguate the file name with a short
content-hash suffix so
both survive. A matching `headroom_id` means it's an update of the same
memory, so the plain
slug file is rewritten as before — existing file names don't change, so
there's no migration
churn for the common (no-collision) case:

```python
existing_id = existing_fm.get("headroom_id", "")
if headroom_id and existing_id and existing_id != headroom_id:
    suffix = (content_hash or hashlib.sha256(content.encode()).hexdigest()[:16])[:8]
    filename = f"headroom_{slug}_{suffix}.md"
    ...
```

## Type of Change

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

## Changes Made

- `headroom/memory/sync_adapters/claude_code.py`: in `write_memories`,
disambiguate the file name with a content-hash suffix when the slug
already belongs to a different `headroom_id`; same-id updates still
rewrite the slug file in place.
- `tests/test_memory_sync.py`: add
`test_write_distinct_memories_sharing_first_line_do_not_clobber` (two
files survive) and `test_write_same_memory_updates_in_place` (no
duplicate on update).

## Testing

- [x] New regression tests added (`tests/test_memory_sync.py`)
- [x] Linting passes (`ruff check`) and formatting is clean (`ruff
format --check`)
- [ ] Full `pytest` deferred to CI (local-OOM reason below).

```text
$ uv run ruff check headroom/memory/sync_adapters/claude_code.py tests/test_memory_sync.py
All checks passed!
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.10, headroom from this branch.
Importing `headroom` pulls in the torch/transformers stack and a full
`pytest` gets OOM-killed on this box, so I verified the write logic with
a dependency-free script (replicating `_sanitize_for_filename` /
`_parse_frontmatter` / the write loop against a real temp dir) and left
the full pytest to CI.
- Exact command / steps: wrote two memories that share the first line `#
Project conventions` but differ in body (distinct `headroom_id`),
through both the old and new logic, then wrote a same-id update.
- Observed result: the old logic reports `written=2` but leaves **one**
file (the first memory's body is gone); the new logic keeps both, and a
same-id update rewrites in place instead of duplicating:

```text
OLD: written=2 files=1 tabs=False fridays=True
NEW: written=2 files=2 tabs=True fridays=True
UPDATE: files=1 second=True
MEMORY COLLISION FIX VERIFIED
```

- Not tested: a full `sync_export`/`sync_import` round-trip through the
DB backend (needs the heavy stack). The fix is confined to the
file-naming decision in `write_memories`, and the new tests drive that
method directly. Full local `pytest` deferred to CI (OOM, per above).

## 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 — ran
lint + a standalone logic check; full pytest deferred to CI (local OOM,
disclosed above)
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

- No new dependencies; a small, migration-safe naming guard plus tests.
- @JerrettDavis tagging you — this one is a quiet data-loss path in the
Claude memory sync (a collision drops one memory and then thrashes on
every sync), so it may be worth a look when you get a chance.
2026-07-10 11:47:31 -04:00
Abhay Singh
4cb33cd9e3
fix(proxy): strip inbound Content-Encoding on messages/chat forward (#1970)
## Description

The Anthropic `/v1/messages` and OpenAI `/v1/chat/completions` handlers
decode the
inbound request body before forwarding it upstream.
`read_request_json_with_bytes`
(helpers.py) inflates `zstd`/`gzip`/`deflate`/`br` bodies, and the
handler forwards
the resulting plain JSON. But both handlers build the upstream-bound
header dict and
pop only `host`, `content-length`, and `accept-encoding` — they leave
the original
`Content-Encoding` header in place:

```python
headers = dict(request.headers.items())
headers.pop("host", None)
headers.pop("content-length", None)
headers.pop("accept-encoding", None)   # content-encoding NOT popped
```

So when a client — or an edge proxy like a Cloudflare Worker — sends a
request with
`Content-Encoding: gzip` (or `zstd`/`br`/`deflate`) and a compressed
body, Headroom
decompresses it, then forwards plain JSON that still advertises
`content-encoding: gzip`.
The upstream provider tries to gunzip already-decoded JSON and rejects
the request with
HTTP 400. Every such request fails.

This is a known class of bug: the `/v1/responses` handler already fixes
exactly this at
`openai.py` with the comment *"Leaving a stale content-encoding header
makes the upstream
try to decompress already-decoded JSON and reject it with HTTP 400
(#1542)."* That fix
landed only on the `/responses` path — the messages and chat paths were
missed.

Closes: no issue filed — found while auditing request-header forwarding
across the handlers.

## Fix

Pop `content-encoding` and `transfer-encoding` in both handlers, right
after the existing
`content-length` pop, mirroring the `/v1/responses` handler:

```python
headers.pop("content-encoding", None)
headers.pop("transfer-encoding", None)
```

## Type of Change

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

## Changes Made

- `headroom/proxy/handlers/anthropic.py`: strip
`content-encoding`/`transfer-encoding` from the upstream-bound request
headers in `handle_anthropic_messages`.
- `headroom/proxy/handlers/openai.py`: same strip in the
`/v1/chat/completions` handler.
- `tests/test_proxy_compression_headers.py`: add
`TestRequestContentEncodingStripping` covering gzip/zstd/deflate/br,
`transfer-encoding`, and the absent-header (plain curl) case.

## Testing

- [x] New regression tests added
(`tests/test_proxy_compression_headers.py`)
- [x] Linting passes (`ruff check`) and formatting is clean (`ruff
format --check`)
- [ ] Full `pytest` deferred to CI (local-OOM reason below).

```text
$ uv run ruff check headroom/proxy/handlers/anthropic.py headroom/proxy/handlers/openai.py tests/test_proxy_compression_headers.py
All checks passed!
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.10, headroom from this branch.
Importing `headroom` pulls in the torch/transformers stack and a full
`pytest` gets OOM-killed on this box, so I verified the header logic
with a dependency-free script (the same pure-dict pattern the existing
tests in this file use) and left the full pytest to CI.
- Exact command / steps: replicated the handler's request-header
stripping (old vs new) in a standalone script and ran a
`gzip`/`zstd`/`deflate`/`br` request through both.
- Observed result: the old logic keeps `content-encoding` (which is what
makes the upstream 400); the new logic strips it while preserving
`authorization` and `content-type`:

```text
OK gzip: old leaks 'gzip' -> upstream 400 ; new strips it
OK zstd: old leaks 'zstd' -> upstream 400 ; new strips it
OK deflate: old leaks 'deflate' -> upstream 400 ; new strips it
OK br: old leaks 'br' -> upstream 400 ; new strips it
OK transfer-encoding stripped
OK safe when absent
CONTENT-ENCODING STRIP VERIFIED
```

- Not tested: an end-to-end POST of a real gzip body through a booted
proxy to a live upstream (needs the heavy stack + a provider key). The
header now matches the byte-faithful forwarding the `/responses` path
already does, and the new tests exercise the exact strip logic. Full
local `pytest` deferred to CI (OOM, per above).

## 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 — ran
lint + a standalone logic check; full pytest deferred to CI (local OOM,
disclosed above)
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

- Small, contained parity fix — two `pop()` calls plus tests, no new
dependencies.
- @JerrettDavis tagging you since you've been triaging the
proxy-forwarding fixes (this is the sibling of the #1542 `/responses`
fix) — should be a quick one if you have a moment.
2026-07-10 11:45:58 -04:00
Tejas Chopra
10e4829201
fix(dedup): shorter fold pointer + MIN_LINES=3, dedup Anthropic list-… (#1932)
…content tool_results, line-number reanchoring

cross_turn_dedup:
- Compact the fold pointer (~35c vs ~100c) and lower MIN_LINES=3 /
MIN_CHARS=40 so the abundant short (2-4 line) re-read repeats pay off
(~4.3% -> ~6% lossless observation-byte reduction on Opus SWE-bench,
measured on 73 real trajectories).
- Add uniform-offset line-number reanchoring: a re-read whose line
numbers all shifted by a constant after an edit now still folds.
Matching keys on the number-stripped content; the offset is carried in
the pointer so recovery of the original numbered bytes stays exact
(unpadded grep/sed/rg -n form only). Cache-safe (prefix-monotonic) and
information-preserving (earliest copy kept verbatim). Small real-data
gain, but closes the renumbering blind spot.

content_router:
- Dedup Anthropic list-content tool_result blocks (the single
{"type":"text"} sub-block), which the string-only extraction path
previously skipped entirely, so cross-turn dedup never fired on
Anthropic-native tool results.

## Description

<!-- Briefly explain the change and why it is needed. -->

Closes #

## Type of Change

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

## Changes Made

- 

## Testing

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

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

### Test Output

```text
# Paste relevant command output or artifact links here
```

## Real Behavior Proof

- Environment:
- Exact command / steps:
- Observed result:
- Not tested:

## Review Readiness

- [ ] I have performed a self-review
- [ ] 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
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] 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
- [ ] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

Add screenshots to help explain your changes.

## Additional Notes

<!-- Mention any N/A checklist items, tradeoffs, follow-ups, or
maintainer context. -->

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 11:38:08 -04:00
JD Davis
88e41b65a1
Extract request log redaction policy (#1968)
## Description

Extracts the pure image-base64 request-log redaction decision/transform
logic from `request_logger.py` into a dedicated policy module.
`RequestLogger` remains the owner of the Prometheus-facing redaction
counter and existing request_logger constants remain available for
compatibility.

Closes #

## Type of Change

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

## Changes Made

- Added `headroom.proxy.request_log_redaction_policy` with a pure
`RedactionResult` outcome.
- Kept global redaction metrics/counter side effects in
`request_logger.py`.
- Added direct policy tests for count reporting, nested image paths, and
data URL threshold behavior.
- Carried forward the LiteLLM callback compatibility shim needed for
current mypy on `main`.

## 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
python -m pytest tests\test_request_log_redaction_policy.py tests\test_image_log_redaction.py
20 passed in 0.31s

python -m ruff check .
All checks passed!

python -m ruff format --check .
1095 files already formatted

python -m mypy headroom --ignore-missing-imports
Success: no issues found in 409 source files

gitleaks protect --staged --no-banner --redact
no leaks found
```

## Real Behavior Proof

- Environment: Windows, Python 3.13.13, branch
`jd/architecture-slice-23`.
- Exact command / steps: ran targeted request-log redaction tests, ruff,
ruff format check, mypy, and staged gitleaks scan.
- Observed result: redaction behavior remains covered through existing
logger tests and new pure policy tests; local lint/type/security checks
pass.
- Not tested: full proxy runtime; this slice only moves pure redaction
policy and keeps the logger entry point intact.

## Review Readiness

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

## Checklist

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

## Screenshots (if applicable)

N/A

## Additional Notes

Documentation and changelog updates are N/A for this internal
architecture-only refactor. The push reported existing default-branch
Dependabot alerts; no staged secret leaks were found for this PR.
2026-07-10 06:43:04 -07:00
JD Davis
1d2b76e72e
fix: harden persistent install startup (#1851)
## Description

Hardens persistent install startup and proxy compression behavior for
issue #1843. Repeated `headroom install start` / scheduled ensure calls
no longer spawn duplicate runtimes by default, and `/v1/compress` now
fails open on compression timeout instead of returning a 503. The PR
also adds a machine-readable platform feature matrix and app-level
stabilization tests for health, compression functionality, timeout
behavior, and matrix evidence.

Refs #1843

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

## Changes Made

- Wrapped direct persistent deployment starts with the existing
profile-local runtime start lock.
- Made `headroom install start` idempotent when the deployment is
already healthy.
- Added wedged-runtime handling: if a PID is running but `/readyz` does
not recover inside the grace window, stop it before starting again.
- Kept `install agent ensure` inside the already-held lock while
delegating to the shared start helper.
- Changed `/v1/compress` timeout behavior from `503 compression_timeout`
to fail-open `200` with original messages, `compression_skipped: true`,
and `skip_reason: compression_timeout`.
- Added `tests/test_platform_stabilization_functional.py` covering real
FastAPI health/compression routes, successful compression metrics,
timeout fail-open speed, and a real JSON tool payload that reduces
tokens.
- Added `docs/platform-feature-matrix.json` and
`docs/platform-stabilization.md` for Linux/macOS/Windows hardening
coverage and known gaps.
- Strengthened matrix tests so cited local test/workflow paths must
exist.

## 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
# Clean source tree without copied Rust extension: functional module is skipped locally, as CI copies _core from the built wheel.
> python -m pytest tests/test_install tests/test_cli/test_install_cli.py tests/test_platform_feature_matrix.py tests/test_platform_stabilization_functional.py -q
collected 120 items / 1 skipped
119 passed, 2 skipped in 17.08s

> python -m ruff check headroom/proxy/handlers/openai.py tests/test_platform_stabilization_functional.py tests/test_platform_feature_matrix.py
All checks passed!

# Local Windows compiled-core proof:
> python -m maturin build --profile ci --out dist-local
Built wheel for abi3 Python >= 3.10 to dist-local\headroom_ai-0.29.0-cp310-abi3-win_amd64.whl

# Copied _core.pyd from the wheel into headroom/ for local route execution, then:
> python -m pytest tests/test_platform_stabilization_functional.py -q
collected 4 items
4 passed in 6.71s

> python -m pytest tests/test_install tests/test_cli/test_install_cli.py tests/test_platform_feature_matrix.py -q
collected 120 items
119 passed, 1 skipped in 17.16s

Commit hooks:
Sync plugin versions.....................................................Passed
check for merge conflicts................................................Passed
ruff.....................................................................Passed
ruff-format..............................................................Passed
mypy.....................................................................Passed
```

## Real Behavior Proof

- Environment: Windows 11, PowerShell, Python 3.13.13, worktree
`C:\git\headroom-stabilization` on branch
`jd/cross-platform-stabilization`.
- Exact command / steps: built the Windows wheel with `maturin`,
extracted `_core.pyd`, ran the new FastAPI route tests and
install/matrix tests listed above, then removed generated artifacts
before committing.
- Observed result: direct start paths now no-op when healthy, skip
spawning when the start lock is contended, and stop a wedged runtime
before restart. `/v1/compress` now returns original messages quickly on
timeout instead of a 503. The real JSON tool-payload smoke test returns
`tokens_before > tokens_after`, `tokens_saved > 0`, `compression_ratio <
1.0`, and non-empty transforms through the public route.
- Not tested: full native Windows persistent process e2e remains blocked
by the upstream CRT/wheel issue already documented in workflows and in
the matrix. No real OS service was installed locally; service manager
behavior is covered by argument-level unit 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
- [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
- [ ] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A

## Additional Notes

CHANGELOG is not updated because this is an unreleased
hardening/test/documentation pass. The platform matrix intentionally
records partial/blocked Windows/macOS e2e gaps instead of claiming full
coverage where the repo cannot currently run it.
2026-07-10 00:40:34 -04:00
Matt Van Horn
d05802b620
fix: emit unknown Anthropic content blocks verbatim in buffered-to-SSE conversion (#1825)
## Description

Unknown Anthropic content block types are now emitted verbatim inside
`content_block_start` during buffered-to-SSE conversion instead of
raising `ValueError`. The block-start loop in
`StreamingMixin._response_to_sse`
(`headroom/proxy/handlers/streaming.py`) previously handled only `text`,
`tool_use`, `thinking`, and `redacted_thinking`; any other type fell
through to a hard raise, which turned a fully-generated upstream
response into an HTTP 502.

Closes #1806

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

- Emit unknown content block types, including `server_tool_use`,
`server_tool_result`, `mcp_tool_use`, and future Anthropic block types,
verbatim in `content_block_start` with no delta.
- Preserve main's explicit `server_tool_use` support and newer buffered
CCR/thinking regression coverage after merging current main.
- Keep `content_block_delta` generation gated on known delta-capable
block types, so unknown blocks do not produce spurious deltas.

## 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 --with pytest python -m pytest tests/test_sse_thinking_blocks.py -q
12 passed, 1 warning

uv run --with ruff==0.15.17 ruff check headroom/proxy/handlers/streaming.py tests/test_sse_thinking_blocks.py
All checks passed!
```

## Real Behavior Proof

- Environment: Windows worktree `C:\git\headroom-governance-main`, PR
head `45934b94`.
- Exact command / steps: Merged current `headroomlabs/main`, then ran
the targeted SSE pytest command and Ruff check shown above.
- Observed result: Targeted SSE tests passed with 12 tests, and
unknown/server_tool_use content blocks round-trip verbatim in
`content_block_start`; before this change the same input raised
`ValueError: Unsupported Anthropic content block type for SSE
conversion: 'server_tool_use'` after the full generation had already
been buffered, surfacing to the client as a 502 and a full multi-minute
retry.
- Not tested: End-to-end against a live upstream that emits server-side
tool blocks.

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

## Screenshots (if applicable)

N/A.

## Additional Notes

The prior `test_response_to_sse_rejects_unknown_content_block` is
replaced by `test_response_to_sse_emits_unknown_content_block_verbatim`;
current main's newer buffered CCR/thinking tests are preserved after the
merge from main.

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-09 21:45:40 -05:00
Rod Boev
d2a86b5909
fix(proxy): strip duplicated upstream server headers (#1828)
## Description

Fixes duplicated upstream server headers emitted by the proxy when
forwarding responses.

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

- Adjust proxy response forwarding so upstream server headers are not
duplicated.
- Preserve the intended response-header behavior while avoiding repeated
header values.
- Keep the change scoped to proxy/header handling.

## Testing

- [x] Unit tests pass
- [x] New tests added for new functionality when applicable
- [x] Manual testing performed

### Test Output

```text
Focused tests/review were completed before this governance body cleanup. The current branch is conflicted and still needs merge resolution before final merge readiness.
```

## Real Behavior Proof

- Environment: Headroom development/review context.
- Exact command / steps: Reviewed the proxy response-header behavior and
existing focused coverage for duplicate upstream server headers.
- Observed result: The PR implementation prevents duplicated upstream
server headers while preserving proxy forwarding behavior.
- Not tested: Current conflicted branch after merge resolution;
conflicts still need to be resolved before merge.

## Review Readiness

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

## Additional Notes

This body was normalized by a maintainer after approval so the
governance parser reflects the already-reviewed PR state. The PR remains
blocked by merge conflicts.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-09 21:43:17 -05:00
JerrettDavis
8527b910dc test(litellm): remove unused pytest import 2026-07-09 20:59:12 -05:00
ahhdammm
1deb947ac1
fix(proxy): hoist ccr_workspace_key default so /v1/messages survives CCR-inject off (#1096)
## Summary

`handle_anthropic_messages` only assigns `ccr_workspace_key` /
`ccr_workspace_label` **inside** the
`if (ccr_inject_tool or ccr_inject_system_instructions) and not
_bypass:` block (around `headroom/proxy/handlers/anthropic.py:1302`),
but references `ccr_workspace_key` **unconditionally** in the
proactive-expansion gate at
`headroom/proxy/handlers/anthropic.py:1394-1397`:

```python
if (
    self.ccr_context_tracker
    and self.config.ccr_proactive_expansion
    and ccr_workspace_key      # <-- unbound when the inject block was skipped
):
```

Running the proxy with `--no-ccr-inject-tool` and the default
`ccr_inject_system_instructions=False` (a real, supported configuration)
skips the assignment. With `ccr_context_tracking=True` and
`ccr_proactive_expansion=True` (both defaulting to `True`), the gate is
reached and raises `UnboundLocalError`, which FastAPI surfaces as HTTP
500 on **every** `/v1/messages` request. The Claude Code SDK retries ~10
times (`type=system/api_retry`) and then emits the upstream error as the
assistant reply (`API Error: 500 Internal Server Error`), which looked
exactly like an Anthropic outage from the agent side.

Fix: hoist `ccr_workspace_key, ccr_workspace_label = None, None` to
before the gated block. The downstream uses already treat a falsy key as
"workspace unresolved" — `track_compression` short-circuits to the
existing `elif self.ccr_context_tracker and not ccr_workspace_key:` log
line, and the proactive-expansion gate stays closed via short-circuit
`and`. Behavior with CCR inject enabled is byte-identical.

The bug appears to have been introduced by #500 (workspace scoping). I
traced it after my NanoClaw containers started returning `API Error: 500
Internal Server Error` for every scheduled run — `journalctl --user -u
headroom` showed the traceback.

## Reproduction

Failing test in `tests/test_anthropic_ccr_workspace_unbound.py` mirrors
the deployment config:

```python
config = ProxyConfig(
    ccr_inject_tool=False,                  # user passed --no-ccr-inject-tool
    ccr_inject_system_instructions=False,   # default
    ccr_context_tracking=True,              # default — installs the tracker
    ccr_proactive_expansion=True,           # default — reaches the gate
    ...
)
```

Before the fix:
```
headroom/proxy/handlers/anthropic.py:1397: in handle_anthropic_messages
    and ccr_workspace_key
        ^^^^^^^^^^^^^^^^^
E   UnboundLocalError: cannot access local variable 'ccr_workspace_key' where it is not associated with a value
FAILED tests/test_anthropic_ccr_workspace_unbound.py::test_proactive_expansion_does_not_raise_when_ccr_inject_disabled
```

After the fix:
```
tests/test_anthropic_ccr_workspace_unbound.py .                          [100%]
1 passed
```

## Real behavior proof

**Setup tested on:** Ubuntu 24.04 on WSL2 (NUC15CRH), Python 3.12.3,
`headroom-ai==0.25.0` venv at `/home/adam/headroom-env/`, service
started by user-level systemd unit:

```
headroom proxy --host 0.0.0.0 --port 8787 --mode token \
  --no-ccr-inject-tool --no-ccr-marker --no-telemetry --code-aware
```

Provider: Anthropic via direct `CLAUDE_CODE_OAUTH_TOKEN` injection from
the calling container (NanoClaw / Claude Agent SDK on
`claude-opus-4-8`).

**Before the patch** — every request through the proxy 500ed:
```
$ curl -sS -m 5 -o /tmp/r -w "HTTP %{http_code}\n" -X POST http://localhost:8787/v1/messages \
    -H "x-api-key: placeholder" -H "anthropic-version: 2023-06-01" \
    -H "content-type: application/json" \
    -d '{"model":"claude-opus-4-8","max_tokens":10,"messages":[{"role":"user","content":"hi"}]}'
HTTP 500
$ head -c 40 /tmp/r
Internal Server Error

$ journalctl --user -u headroom -n 50 --no-pager | grep -A1 ccr_workspace_key | head
    and ccr_workspace_key
        ^^^^^^^^^^^^^^^^^
UnboundLocalError: cannot access local variable 'ccr_workspace_key' where it is not associated with a value
```

NanoClaw container logs showed the SDK's 10 `system/api_retry` events
then surfacing `API Error: 500 Internal Server Error` as the assistant
result.

**After the patch** (applied in place to the installed file, service
restarted):
```
$ systemctl --user restart headroom
$ TOKEN=$(jq -r .claudeAiOauth.accessToken ~/.claude/.credentials.json)
$ curl -sS -m 30 -o /tmp/r -w "HTTP %{http_code}\n" -X POST http://localhost:8787/v1/messages \
    -H "Authorization: Bearer $TOKEN" -H "anthropic-version: 2023-06-01" \
    -H "content-type: application/json" \
    -d '{"model":"claude-opus-4-8","max_tokens":20,"messages":[{"role":"user","content":"reply with just the word pong"}]}'
HTTP 429
$ cat /tmp/r
{"type":"error","error":{"type":"rate_limit_error","message":"Error"},"request_id":"req_011Cc9PSZHi4QssEKLhZX5uq"}
```

The local 500 is gone — the proxy now forwards cleanly and surfaces
upstream's real response (here a 429 because the retry storm had been
hammering the account for hours; the shape of the response, and the
presence of an `anthropic-request_id`, confirms the proxy is no longer
crashing on its own code path).

Then `journalctl --user -u headroom --since "5 min ago" | grep -iE
'unbound|traceback'` returned no new occurrences after the restart at
12:30 PDT.

**What I did *not* test:**
- The `_bypass=True` path (same fix protects it, but I did not exercise
it end-to-end).
- The CCR-inject-on path — relied on the existing
`tests/test_proxy_anthropic_cache_stability.py` and
`tests/test_proxy_system_prompt_immutable.py` suites passing (they do;
ran `uv run pytest tests/test_proxy_anthropic_cache_stability.py
tests/test_anthropic_pre_upstream_backpressure.py
tests/test_anthropic_stage_timings.py
tests/test_provider_proxy_routes.py
tests/test_proxy_system_prompt_immutable.py` → 68 passed).

## Test plan
- [x] `uv run pytest tests/test_anthropic_ccr_workspace_unbound.py` —
fails on `main`, passes on this branch.
- [x] `uv run pytest tests/test_proxy_anthropic_cache_stability.py
tests/test_anthropic_pre_upstream_backpressure.py
tests/test_anthropic_stage_timings.py
tests/test_provider_proxy_routes.py
tests/test_proxy_system_prompt_immutable.py` — 68 passed.
- [x] `uv run ruff check` / `uv run ruff format --check` on modified
files — clean.
- [x] Live proxy verified against the configuration that reproduced the
bug.

Co-authored-by: Adam Barnum <adamleebarnum@gmail.com>
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-09 17:29:40 -05:00
Peter Lodri
9be727de68
fix(litellm): inherit CustomLogger so future hooks don't crash proxy (#1114) (#1391)
## Summary

Fixes #1114 — LiteLLM 1.89.x added `async_post_call_success_hook` and
started calling it after every successful completion. `HeadroomCallback`
was a plain `object` subclass with no such method, causing:

```
AttributeError: type object 'HeadroomCallback' has no attribute 'async_post_call_success_hook'
```

This crashed the LiteLLM proxy on every successful API call.

### Root cause

```python
class HeadroomCallback:   # plain object — no-op hooks not inherited
    ...
```

### Fix

Inherit from `litellm.integrations.custom_logger.CustomLogger` which
provides no-op defaults for every hook it defines. Future additions to
`CustomLogger` will be covered automatically.

```python
try:
    from litellm.integrations.custom_logger import CustomLogger as _CustomLogger
except ImportError:
    _CustomLogger = object  # litellm not installed — graceful fallback

class HeadroomCallback(_CustomLogger):
    ...
    def __init__(self, ...):
        super().__init__()
        ...
```

## Type of Change

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

## Changes Made

- `headroom/integrations/litellm_callback.py` — inherit `CustomLogger`;
add `super().__init__()`
- `tests/test_litellm_callback.py` — 7 tests: instantiation,
`async_post_call_success_hook` present + callable + no-op, all current
hooks present, pre-call hook still works

## Real behavior proof

**Setup:** Python 3.13, litellm 1.89.1, headroom-ai 0.27.0-dev  
**Steps after patch:**  
```bash
python3 -c "
from headroom.integrations.litellm_callback import HeadroomCallback
import asyncio
cb = HeadroomCallback()
# Simulate what litellm proxy calls on success
asyncio.run(cb.async_post_call_success_hook(data={}, user_api_key_dict={}, response=None))
print('OK — no AttributeError')
"
```
**After-fix evidence:** Runs without exception. Before fix:
`AttributeError: type object 'HeadroomCallback' has no attribute
'async_post_call_success_hook'`.

**What I did not test:** Live LiteLLM proxy with YAML config (no LiteLLM
proxy running in test env); tested via unit tests and direct Python
instantiation.

## Test Results

```
tests/test_litellm_callback.py  7/7 passed
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-09 17:09:33 -05:00
Rocker Zhang
7836aea2be
fix(proxy): preserve upstream 5xx status on retry exhaustion (#1570)
## What

When the upstream returns a retryable 5xx (529 Overloaded, 503),
`_retry_request` retried up to the cap and then raised, which the caller
collapsed into a generic 502. That hides the retryable signal: clients
see a 502 and give up instead of applying their own overload backoff.

On exhaustion, return the last upstream response (preserving its status
and body) when one is available. Connection and timeout errors still
raise — only an `HTTPStatusError` carrying a real upstream response is
surfaced.

## Why this scope

`_retry_request` is provider-agnostic, so this fix applies uniformly to
all providers (no per-handler change needed). It is purely a
returned-status correctness fix and does not touch request accounting —
a separate change handles counting an exhausted 5xx as a failed request
across all provider handlers.

## Verification

`tests/test_retry_preserve_upstream_status.py`: 529/503 status+body
preservation, 4xx no-retry, connect-error still raises, success
passthrough. Against unpatched main the 503-preservation test fails
(collapses to 502); with the fix all pass.


Addresses #1568.
2026-07-09 17:07:26 -05:00
Rocker Zhang
e365ad7152
fix(proxy): count exhausted upstream 5xx as failed across all providers (#1571)
## What

A companion to the retry-exhaustion change that returns the real
upstream 5xx (e.g. 529 Overloaded) instead of a synthetic 502. Once the
real 5xx is returned, it flows through the success funnel and is
recorded via `record_request`, feeding the savings/cost stats and
inflating the save-rate.

`RequestOutcome` now carries the upstream `status_code` (default 200).
In `emit_request_outcome`, a `status_code >= 500` records a failed
request and returns before the savings/cost/log success path. 4xx stay
on the normal funnel (client errors the proxy still served).

The real status is threaded onto the retry-fed `RequestOutcome` at every
provider site: Anthropic (message, batch, passthrough), OpenAI (chat,
responses, passthrough), Gemini (generateContent, all-non-text path,
countTokens). Sites that cannot carry a 5xx keep the default 200: local
cache hits, backend-routed paths that early-return on error, websocket
units (no HTTP status), and streaming generators that early-return on
`>= 400`.

## Scope notes

- **429**: an exhausted 429 (rate limit) currently stays on the success
funnel since it is < 500. Extending the failed-accounting to
exhausted-429 is a separate follow-up, kept out of this 5xx-scoped
change.
- **Streaming**: streaming responses return before the retry-exhaustion
logic, so they do not receive an exhausted 5xx through this path;
`from_stream` is unchanged.

## Verification

`tests/test_outcome_records_5xx_as_failed.py` exercises the `>= 500`
funnel guard; `tests/test_5xx_accounting_all_providers.py` pins the
per-provider contract for each wired site. Against unpatched main the
guard test fails (no `status_code` field); with the change all pass.


Addresses #1568. Builds on #1570 (preserve-5xx-status): the 503
accounting takes effect once that lands; the 429/529 accounting is
independent.
2026-07-09 17:07:08 -05:00
Zhenjia ZHOU
8879c50dbe
fix(adaptive-sizer): char bigrams for spaceless CJK items (#1748)
## Description

`compute_unique_bigram_curve` — the adaptive sizer's coverage-curve
builder, mirrored in Rust and Python — word-splits each item on
whitespace to form word bigrams. A spaceless CJK item has no whitespace,
so it collapsed into one `(whole_string, "")` pseudo-bigram: the
coverage curve then grew ~1 per item, the kneedle knee detector found no
knee, and CJK lists under-compressed.

Spaceless CJK items now use character bigrams, producing a real coverage
curve. Mirrored byte-exactly in Rust and Python (identical
reference-test curve values). Non-CJK items — anything
whitespace-bearing or spaceless-ASCII — are byte-identical to before, so
the `smart_crusher` parity fixtures are unchanged.

## Type of Change

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

## Changes Made

- `crates/headroom-core/src/transforms/adaptive_sizer.rs` +
`headroom/transforms/adaptive_sizer.py`: add
`is_cjk_char`/`_is_cjk_char` (identical code-point ranges) and a
spaceless-CJK character-bigram branch in `compute_unique_bigram_curve`.
- Rust unit tests + `tests/test_adaptive_sizer.py`: CJK curve,
single-char CJK, ASCII-unchanged, empty-item — the Rust and Python
reference values are identical.

## Testing

- [x] Unit tests pass (`cargo test` + `pytest`)
- [x] Linting passes (`cargo clippy` / `cargo fmt` / `ruff` / `mypy`)
- [x] New tests added for new functionality
- [x] Manual testing performed (see Real Behavior Proof)

### Test Output

```text
$ cargo test -p headroom-core --lib adaptive_sizer
test result: ok. 35 passed; 0 failed

$ .venv/bin/python -m pytest tests/test_adaptive_sizer.py
20 passed

$ .venv/bin/python -m pytest -k "smart_crusher and parity"
18 passed, 6 skipped   # non-CJK fixtures unchanged
```

## Real Behavior Proof

- Environment: macOS (Darwin), Rust via cargo, Python in a uv venv,
branch `feat/adaptive-sizer-cjk` off `main`.
- Exact command / steps: called `compute_unique_bigram_curve` on a CJK
list and on ASCII lists, in both implementations.
- Observed result: `compute_unique_bigram_curve(["数据库连接失败", "数据库连接成功"])`
returns `[6, 8]` in **both** Rust and Python (before: ~`[1, 2]` — one
pseudo-bigram per item, no coverage signal). ASCII curves are unchanged:
`["the cat", "the dog", "a fish"]` → `[1, 2, 3]`. The `smart_crusher`
parity suite (all-ASCII fixtures) stays green, confirming non-CJK output
is byte-identical.
- Byte-exact parity: the Rust reference test (`vec![6, 8]`) and the
Python test (`[6, 8]`) use the same inputs and the same expected values,
so the two implementations are pinned to agree.

## 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 sizing heuristic)
- [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 — N/A: internal sizing-heuristic
fix, no user-facing surface change

## Additional Notes

- This is a parity-locked function (Rust and Python must agree
byte-for-byte). The fix is CJK-gated, so non-CJK output is
byte-identical and the `smart_crusher` parity fixtures need no
re-recording.
2026-07-09 17:01:29 -05:00
Zhenjia ZHOU
985621d60e
fix(search-compressor): CJK-aware relevance + harden Rust/Python parity (#1749)
## Description

The search compressor's relevance scorer (`score_matches`, present in
both the Rust runtime path and the Python legacy mirror) split the query
on whitespace. A spaceless CJK query therefore matched a result line
only when the WHOLE query was a literal substring of that line — partial
overlaps never boosted relevant lines, so correct matches got dropped
when the result set was over budget.

This adds CJK character bigrams to the query match set, so a longer CJK
query boosts lines that share a substring. It also fixes two latent
Rust/Python parity divergences the ASCII-only fixtures had masked:

- **Length filter**: Rust counted word length in BYTES (`w.len()`),
Python in codepoints (`len(w)`), so a CJK word crossed the `> 2`
threshold differently. Rust now uses `chars().count()`.
- **Dedup**: Rust collected words into a `Vec` (no dedup), Python into a
`set`, so a repeated query word double-counted in Rust. Rust now uses a
`BTreeSet`.

Both scorers are byte-exact now; non-CJK output is unchanged (the 53
existing tests and the parity fixtures stay green).

## Type of Change

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

## Changes Made

- `crates/headroom-core/src/transforms/search_compressor.rs` +
`headroom/transforms/search_compressor.py`: add
`is_cjk_char`/`_is_cjk_char` and `cjk_bigrams`/`_cjk_bigrams` (identical
ranges + logic), union CJK bigrams into the query match set, and align
the Rust word set to Python (`chars().count()` length, `BTreeSet`
dedup).
- `tests/test_search_compressor_cjk.py` + a Rust unit test: CJK bigram
extraction (same input/expected in both languages) and a CJK query
boosting a partially-overlapping line.
- Corrected a stale `_score_matches` docstring that referenced a
non-existent parity assertion; it now states honestly how the two sides
are pinned (test-equal for word-overlap + CJK bigrams; a few error-boost
keywords still diverge, fixed only Rust-side).

## Testing

- [x] Unit tests pass (`cargo test` + `pytest`)
- [x] Linting passes (`cargo clippy` / `cargo fmt` / `ruff` / `mypy`)
- [x] New tests added for new functionality
- [x] Manual testing performed (see Real Behavior Proof)

### Test Output

```text
$ cargo test -p headroom-core --lib search_compressor
test result: ok. 16 passed; 0 failed

$ .venv/bin/python -m pytest tests/test_search_compressor_cjk.py \
    tests/test_transforms_search_compressor.py tests/test_search_compressor.py
55 passed   # 2 new CJK tests + 53 existing (no regression)
```

## Real Behavior Proof

- Environment: macOS (Darwin), Rust via cargo, Python in a uv venv
(`_core` rebuilt on this branch), branch `feat/search-compressor-cjk`
off `main`.
- Exact command / steps: scored a CJK content line against a longer CJK
query whose whole form is not a substring of the line.
- Observed result: for content `src/a.py:10:认证令牌已过期需要重新登录` and query
`认证令牌缓存淘汰策略` (the whole query is NOT a substring of the line, but its
bigrams are), the line now scores `> 0` (bigrams 认证 / 证令 / 令牌 match);
before, it scored `0`. An ASCII-only line still scores `0`. All 53
existing search-compressor tests are unchanged. `cjk_bigrams("认证令牌")`
returns `{认证, 证令, 令牌}` in **both** Rust and Python.

## 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 relevance scoring)
- [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 — N/A: internal relevance-scoring
fix, no user-facing surface change

## Additional Notes

- The two parity divergences (byte-vs-codepoint length, `Vec`-vs-`set`
dedup) were pre-existing and only reachable with non-ASCII or
repeated-word queries — the all-ASCII fixtures never exercised them.
This PR brings both sides back to byte-exact for the word-overlap +
CJK-bigram scoring. The remaining error-boost keyword divergence is
pre-existing (fixed only Rust-side in the 3e.1 port) and is now
documented in the code rather than glossed over.
2026-07-09 17:00:30 -05:00
Sakamoto, Kazunori
c85731dc23
fix(mcp): correct default Claude Code config path in ClaudeRegistrar (#1859)
## Description

<!-- Briefly explain the change and why it is needed. -->

`ClaudeRegistrar` originally assumed Claude Code's modern per-user MCP
config lives at `~/.claude/.claude.json`. On a real Claude Code 2.1.202
install with `CLAUDE_CONFIG_DIR` unset, the actual file is
`~/.claude.json`, directly under the home directory. Whenever `claude
mcp add` failed for any transient reason and the registrar fell back to
writing the config file directly, it wrote to a path Claude Code never
reads — registration reported success with no error, but the server
silently never became available, and once that wrong file existed,
`get_server()` kept reading it back as already-registered, so the
registrar never retried.

While fixing the path, several related correctness and test-isolation
issues in the same file were found and fixed:

- Three tests instantiated `ClaudeRegistrar(claude_cli=None)` without
`home_dir`, so the legacy config path resolved to the developer's real
`~/.claude/mcp.json` — one test was actually deleting a `headroom` entry
from it.
- `detect()` only checked the legacy `~/.claude` directory, so installs
where the `claude` CLI is absent from `PATH` and only the modern
`~/.claude.json` exists were treated as not detected, skipping
registration entirely.
- `unregister_server()` returned early on CLI success without cleaning
the legacy config file, so a stale legacy entry could survive a
successful `claude mcp remove` and `get_server()` would keep reporting
the server as registered.
- `_read_server_entry`, `_remove_from_file`, and `_register_via_file`
assumed `mcpServers` was always a dict once present; a hand-edited or
corrupted config with `mcpServers` set to `null`, a list, or a string
crashed with an unhandled `AttributeError`/`TypeError`.

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

- `_resolve_claude_config_dir` now defaults to `home` (the modern config
is `~/.claude.json`), keeping the `CLAUDE_CONFIG_DIR` and explicit
`config_dir` overrides intact; the legacy `.claude` directory is pinned
to `home / ".claude"` independently of where the modern config resolves.
- `detect()` also recognizes an install via
`self._modern_config.exists()`, not just the legacy directory.
- `unregister_server()` always cleans both the modern and legacy config
files, even after a successful CLI removal.
- `_read_server_entry`, `_remove_from_file`, and `_register_via_file`
now validate `mcpServers` is a dict before indexing into it, degrading
gracefully instead of crashing on malformed config.
- Module and constructor docstrings now state the current config-path
facts plainly (paths, and what `CLAUDE_CONFIG_DIR` relocates); the
constructor docstring also clarifies that `home_dir`/`config_dir`
isolate file-based reads/writes but not CLI subprocess calls.
- `tests/test_mcp_registry/test_claude_registrar.py`: corrected path
expectations, isolated the three previously-unisolated tests from the
real home directory, and added coverage for the modern-config-only
detect case, CLI-success-with-stale-legacy-entry, and non-dict
`mcpServers` values.
- Filed #1861 for a related, currently-unexercised gap: CLI subprocess
calls don't honor `home_dir`/`config_dir` overrides.

## 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_mcp_registry/test_claude_registrar.py
============================== 35 passed in 0.08s ==============================

$ ruff check headroom/mcp_registry/claude.py tests/test_mcp_registry/test_claude_registrar.py
All checks passed!

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

## Real Behavior Proof

- Environment: Claude Code 2.1.202 (macOS, Darwin 25.5.0),
`CLAUDE_CONFIG_DIR` unset.
- Exact command / steps: Injected a uniquely-named probe server into
**only** `~/.claude/.claude.json` (old assumed path) and ran `claude mcp
list`; then injected a different probe into **only** `~/.claude.json`
(corrected path) and ran `claude mcp list` again; restored both files
afterward.
1. `ZZZ_nested_probe` written to `~/.claude/.claude.json` only → `claude
mcp list`.
2. `ZZZ_flat_probe` written to `~/.claude.json` only → `claude mcp
list`.
- Observed result: The nested-path probe (`ZZZ_nested_probe`) was
**not** recognized by `claude mcp list` — Claude Code ignores
`~/.claude/.claude.json`. The flat-path probe (`ZZZ_flat_probe`) **was**
recognized and listed. This confirms `~/.claude.json` is the file Claude
Code actually reads. Both config files were restored to their original
state after the test.
- Not tested: older Claude Code versions (< 2.1.202); Windows/Linux path
resolution (logic is platform-agnostic via `pathlib`, but only macOS was
exercised); the CLI-subprocess env-isolation gap tracked in #1861.

## Review Readiness

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

## Checklist

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

## Screenshots (if applicable)

N/A — CLI/config-path change with no UI surface.

## Additional Notes

- The `CLAUDE_CONFIG_DIR=~/.claude` configuration still correctly
resolves the modern config to `~/.claude/.claude.json` via the unchanged
env override — only the default (env unset) changed.
- Any machine that hit the original bug may have a stale
`~/.claude/.claude.json` written by the old fallback; it is harmless and
can be deleted.
- #1861 tracks a related gap (CLI subprocess env isolation) that isn't
exercised by any current production call site.
2026-07-09 16:58:20 -05:00
Kenneth Wong
33c7f6cd3a
fix(bedrock): resolve global.* inference profiles + pin per-user app-profile ARNs (#1795)
## What & why

Bedrock model resolution failed on accounts whose inference profiles use
the newer `global.` cross-region prefix and undated version suffixes. On
such an account, `list_inference_profiles` returns current-gen models as
`global.anthropic.claude-opus-4-8`, `global.anthropic.claude-sonnet-5`,
`global.anthropic.claude-opus-4-6-v1`,
`global.anthropic.claude-fable-5`, etc.

`_normalize_bedrock_profile_id` only stripped `us.`/`eu.`/`apac.`/`au.`
and only matched a dated `-vN:M` suffix, so every `global.`-prefixed
profile was silently dropped from the discovered model map. Requests
then fell through to the fabricated fallback id and Bedrock rejected
them:

```
litellm.BadRequestError: BedrockException - {"message":"The provided model identifier is invalid."}
```

On the affected account the discovered-profile count went from 5 → 14
after the fix.

## Changes

1. **`_normalize_bedrock_profile_id`** — strip the `global.` prefix in
addition to the region prefixes, and match undated version suffixes
(`-v1`, or none at all) alongside the legacy dated `-vN:M`.

2. **`HEADROOM_BEDROCK_MODEL_MAP` operator override** (read from the
process environment). AWS discovery keys the model map by normalized
model name, so it cannot disambiguate application inference profiles
that share one underlying model — e.g. a team where
`claude-sonnet-5-alice` and `claude-sonnet-5-bob` both resolve to
`claude-sonnet-5`. When you need requests billed to a *specific*
application profile (per-user cost attribution), pin it explicitly:

   ```

HEADROOM_BEDROCK_MODEL_MAP="claude-sonnet-5=arn:aws:bedrock:REGION:ACCT:application-inference-profile/abc123,claude-opus-4-8=arn:...:application-inference-profile/def456"
   ```

The plain model name (kept plain so a client's tool-search deferral
stays on) resolves to the pinned ARN, routed via the converse endpoint.
The override wins over discovery; when unset, discovery-only behaviour
is unchanged.

## Tests

`tests/test_bedrock_region.py` (43 passing): `global.`-prefixed
normalization across dated / bare-`-v1` / no-suffix shapes; override-map
parsing (empty, single, multi, whitespace, malformed-skip); and
`map_model_id` override routing (pinned name → app-profile ARN via
converse, wins over discovery; unpinned name falls through).

No behavioural change for accounts already on system-defined
region-prefixed profiles.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-09 16:56:23 -05:00
Rod Boev
372d6c8cd4
fix(wrap): preserve custom Codex provider base_url during proxy injection (#1894)
## Description

Refs #1614 (Bug 2 only; Bug 1's config-mutation ordering is covered by a
separate PR).

`headroom wrap codex` unconditionally pointed the proxy's upstream
OpenAI route at `api.openai.com`, even when the user's Codex config
already declared a custom OpenAI-compatible provider such as
`freemodel.dev`, LiteLLM, or vLLM under `[model_providers.<name>]`. The
proxy then silently rerouted traffic to OpenAI, which rejected the
user's gateway API key, and Codex interpreted the resulting auth
failures as an invalid session.

## Type of Change

- [x] Bug fix

## Changes Made

- `_detect_custom_codex_upstream_base_url` and
`_codex_custom_provider_base_urls` in `headroom/cli/wrap.py` scan the
existing `config.toml` for a user-declared custom `[model_providers.*]`
table, excluding Codex built-ins and Headroom's own table, and return
its `base_url` when the selection is unambiguous: either the top-level
`model_provider` names it directly, or a prior wrap left the original
provider in the `# was: <original>` comment from
`_redirect_existing_top_level_keys`.
- The detector falls back to the sole custom provider when exactly one
candidate exists and no matching top-level selection is present, which
covers the issue repro where the custom table exists without a static
top-level provider pin.
- `_inject_codex_provider_config` now detects that custom upstream
before building the injected provider block. When found, it adds
`X-Headroom-Base-Url` to `env_http_headers`, mapped to
`HEADROOM_CODEX_UPSTREAM_BASE_URL`, matching Codex's env-var-based
header contract.
- `codex()` exports the detected value into
`HEADROOM_CODEX_UPSTREAM_BASE_URL` for the launched Codex process unless
the user already set it. The proxy's OpenAI HTTP handlers already honor
`X-Headroom-Base-Url`, so HTTP `/v1/chat/completions` and
`/v1/responses` requests forward to the preserved gateway instead of the
default OpenAI upstream.

This is scoped to the HTTP request path. Codex's WebSocket transport for
`/v1/responses` resolves its upstream from a separate header-independent
path and keeps the existing behavior.

## Testing

- [x] Focused Codex wrap tests passed locally before PR review: `pytest
tests/test_cli/test_wrap_codex.py -q`
- [x] Broader Codex CLI test selection passed locally before PR review:
`pytest tests/test_cli/ -k codex -q`
- [x] CI lint, format, and type checks passed on PR head `fb769349`.
- [x] CI build, test shards, native wrapper, Docker init, Docker wrap,
security, merge-conflict, and governance jobs passed on PR head
`fb769349`.
- [ ] Live `headroom wrap codex` against a real `freemodel.dev` account
was not run for this PR.

## Test Output

Previously reported local focused test output:

```text
pytest tests/test_cli/test_wrap_codex.py -q
83 passed

pytest tests/test_cli/ -k codex -q
113 passed, 426 deselected
```

Previously reported local static checks:

```text
ruff check
ruff format --check
mypy
```

CI evidence on PR head `fb769349`: GitHub Actions run `28987811482`
completed successfully for CI, including `lint` with `ruff check`, `ruff
format --check`, and `mypy`; `build`; `build-wheel`; four test shards;
`test-agno`; `test-extras`; `test-dashboard-ui`;
`windows-native-wrapper`; `macos-native-wrapper`; and
`docker-native-e2e`. PR Governance run `28987811437` completed
successfully.

## Real Behavior Proof

- Environment: Unit-level Codex config injection using pytest tmp home,
PR head `fb769349`, and GitHub Actions CI run `28987811482`.
- Exact command / steps: With a Codex config containing
`[model_providers.freemodel]`, `base_url = "https://api.freemodel.dev"`,
and `wire_api = "responses"`, call
`_inject_codex_provider_config(8787)`.
- Observed result: The injector returns `https://api.freemodel.dev`; the
injected `[model_providers.headroom]` table contains `env_http_headers =
{ "X-Headroom-Project" = "HEADROOM_PROJECT", "X-Headroom-Base-Url" =
"HEADROOM_CODEX_UPSTREAM_BASE_URL" }`; the user's
`[model_providers.freemodel]` table remains unchanged; and re-running
`_inject_codex_provider_config(9999)` preserves the same upstream while
updating the proxy port.
- Not tested: Live external traffic through a real `freemodel.dev` key,
WebSocket custom-upstream routing, and Bug 1's
dependency-check-before-config-mutation path.

## Review Readiness

- [x] Scope is limited to #1614 Bug 2, custom provider `base_url`
preservation for Codex wrap.
- [x] Bug 1 remains out of scope and is called out separately.
- [x] The changed code uses the existing proxy `X-Headroom-Base-Url`
contract instead of adding a new proxy route.
- [x] Ambiguous multiple custom providers keep prior fallback behavior
instead of guessing.
- [x] A collaborator reviewed and approved the current head after
running the focused Codex wrap tests locally.
- [x] This PR is ready for human review.

## Checklist

- [x] I have performed a self-review.
- [x] Focused tests added and passing.
- [x] Lint, format, and type-check clean on CI.
- [x] No unrelated files changed.
- [x] This PR is ready for human review.
2026-07-09 16:55:00 -05:00
Leoy
42bdf23d24
fix(install): write deployment manifest atomically and tolerate corrupt manifests (#1303)
## Description

Make deployment-manifest persistence in `headroom/install/state.py`
crash-safe by writing the manifest **atomically**. `save_manifest` used
a plain `path.write_text(...)` (truncate-then-write), so an interrupted
save (Ctrl-C, system restart, container OOM/SIGKILL) could leave a
truncated `manifest.json` on disk.

> **Note (rebased onto current `main`):** since this PR was opened,
#1491 hardened `load_manifest` to raise a typed `ManifestError` on a
corrupt manifest. I've rebased and **dropped my original `load_manifest
→ return None` change in favour of that deliberate typed-error design**,
so this PR now scopes down to the still-missing piece: the **atomic
write** (upstream `save_manifest` is still a plain `write_text`), plus a
regression test for the `ManifestError` path that `main` added without
test coverage.

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

- Add `_atomic_write_text(path, data)`: write to a same-directory temp
file → `flush()` + `os.fsync()` → `os.replace()` (atomic rename on POSIX
and Windows); the temp file is cleaned up if anything fails.
- `save_manifest` now persists via `_atomic_write_text` instead of
`path.write_text(...)`, so a crash between truncate and full write
leaves either the previous file or the complete new one — never a
truncated manifest.
- `load_manifest` is left exactly as `main` has it (raises
`ManifestError` on a corrupt payload) — no behavioural change from me
there.
- Tests: add `test_save_manifest_writes_atomically` (no leftover temp
file; manifest round-trips) and
`test_load_manifest_raises_manifest_error_on_corrupt_payload` (covers
the `ManifestError` path #1491 introduced but did not test).

## 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_install/test_state.py -q
.....                                                                    [100%]
5 passed in 0.11s

$ ruff check headroom/install/state.py tests/test_install/test_state.py
All checks passed!

$ ruff format --check headroom/install/state.py tests/test_install/test_state.py
2 files already formatted

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

## Real Behavior Proof

- **Environment:** macOS (Darwin), Python 3.13, rebased onto current
`main`.
- **Exact command / steps:** `save_manifest(manifest)` then inspect the
profile dir and reload.
- **Observed result:** after a save the profile directory contains only
`manifest.json` (no leftover `.manifest.json.*.tmp`), and
`load_manifest("default")` round-trips the persisted manifest. A
deliberately-corrupt `manifest.json` (`"{not json"`) makes
`load_manifest` raise `ManifestError` (typed), not a raw
`JSONDecodeError`.
- **Not tested:** the physical-crash-mid-write window is reasoned about
via `os.replace()` atomicity, not fault-injected.

## Review Readiness

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

## Checklist

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

## Additional Notes

Documentation / CHANGELOG boxes are unchecked as N/A — this is an
internal persistence-hardening fix with no user-facing surface. The diff
is now small (atomic write + two tests); the corrupt-manifest handling
itself lives in `main` via #1491.
2026-07-09 14:07:05 -05:00
Shengbo_Wang
1de35e775f
fix(code): parse-probe tree-sitter availability in code_handler (#1231) (#1300)
## Description

`_check_tree_sitter()` in
`headroom/compression/handlers/code_handler.py` only verified that
`tree_sitter_language_pack` could be imported. When tree-sitter core and
the language pack are built against different ABIs, the import succeeds
but `parser.language = get_language(...)` raises at request time,
silently falling back to the generic text compressor — with no warning,
while the banner still reports code-aware as enabled.

#1299 fixed the same class of bug in `transforms/code_compressor.py`.
This PR is the defensive follow-up tracked by #1231: it applies the same
parse probe to the compression **structure handler** so both code-aware
paths are consistent.

Closes #1231

## Type of Change

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

## Changes Made

- Replace the import-only probe in `code_handler._check_tree_sitter()`
with a real parse probe: construct a `Parser`, assign a Python language,
and parse `b"x = 1\n"` — if any step fails, mark unavailable
- Log a WARNING when import succeeds but parsing fails, so the downgrade
is visible
- Add `TestAvailabilityProbe` covering the simulated ABI mismatch (->
False) and healthy install (-> True) cases

> Note: an earlier revision of this PR also touched
`transforms/code_compressor.py`, but that fix landed independently via
#1299. After rebasing onto current `main`, this PR is scoped to the
remaining `code_handler.py` gap only.

## Testing

- [x] Unit tests pass (`pytest
tests/test_compression/test_code_handler.py` -> 22 passed, 8 skipped)
- [x] Linting / formatting pass (`ruff check .`, `ruff format --check
.`)
- [x] New tests added for new functionality

### Real Behavior Proof

- Setup: Windows 11 (GBK locale), Python 3.10, tree-sitter NOT installed
- Before fix: `_check_tree_sitter()` returns `True` on a
partial/ABI-mismatched install (import succeeds), then silently degrades
to the text compressor at request time with no warning
- After fix: the probe parses a trivial snippet; an ABI mismatch is
caught at probe time, `_check_tree_sitter()` returns `False`, and a
WARNING is logged.
`TestAvailabilityProbe::test_abi_mismatch_returns_false` reproduces this
with a fake Parser whose `language` setter raises.

Signed-off-by: RTCartist <wangshengb@buaa.edu.cn>
2026-07-09 14:06:29 -05:00
Sujit Patil
85804043ff
fix(proxy): record cache metrics for non-streaming backend paths (#1271)
## Description

Fixes missing cache metric propagation in backend-routed non-streaming
request paths.

The streaming implementations already populate cache usage metrics
(`cache_read`, `cache_write`, cache hit percentage) in `RequestOutcome`,
but the equivalent non-streaming paths were left incomplete after the P0
proxy pipeline audit:

- `anthropic.py` (Bedrock / Vertex non-streaming): extracted only
`output_tokens` from the backend usage block — `cache_read_input_tokens`
and `cache_creation_input_tokens` were never read. A comment in the code
explicitly acknowledged this: *"Cache metrics aren't extracted from the
backend response here yet — that's a follow-up."*
- `openai.py` (OpenAI backend non-streaming): extracted cache metrics
and fed them to `openai_prefix_tracker`, but never forwarded them into
`RequestOutcome`. The values were computed then silently dropped.

As a result, all non-streaming backend-routed requests reported:

```text
cache_read=0 cache_write=0 cache_hit_pct=0
```

even when upstream usage data contained valid cache counters.

## Type of Change

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

## Changes Made

- `headroom/proxy/handlers/anthropic.py`: Extract
`cache_read_input_tokens`, `cache_creation_input_tokens`, and TTL bucket
splits (`cache_write_5m_tokens`, `cache_write_1h_tokens`) from the
Bedrock non-streaming usage block. Compute `uncached_input_tokens`. Pass
all five fields to `RequestOutcome`.
- `headroom/proxy/handlers/openai.py`: Compute `uncached_input_tokens`
and forward the already-extracted `cache_read_tokens`,
`cache_write_tokens`, and `uncached_input_tokens` into `RequestOutcome`
in the backend non-streaming path.

## Testing

- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
# Existing regression suite that specifically targets this omission:
# tests/test_backend_nonstreaming_cache_metrics.py
#
# Module docstring from the file explicitly documents the bug class:
#
#   "The **non-streaming** backend paths were left behind — the same bug class
#    on the parallel code path: anthropic.py extracted only output_tokens;
#    openai.py extracted cache fields but never threaded them into RequestOutcome."
#
# Four tests cover both handlers and both the positive (cache data present)
# and zero (no cache data in upstream response) cases:
#
#   test_openai_backend_nonstreaming_emits_perf_with_cache_read_and_inferred_write
#   test_openai_backend_nonstreaming_perf_zeros_when_upstream_omits_cache_usage
#   test_anthropic_backend_nonstreaming_emits_perf_with_cache_read_and_write
#   test_anthropic_backend_nonstreaming_perf_zeros_when_upstream_omits_cache_usage
#
# Tests were written to fail on main before this fix (intentional regression tests).
# Local test execution is blocked by a missing MSVC toolchain (maturin/headroom._core
# Rust extension cannot compile on this machine without VS Build Tools).
```

## Real Behavior Proof

- **Environment:** Windows, Python 3.13, headroom main branch (commit
`b70fccbe`)
- **Exact steps:** Inspected the `RequestOutcome` construction in both
non-streaming backend branches. Confirmed that `cache_read_tokens` and
`cache_write_tokens` defaulted to `0` in both paths because the
constructor calls omitted them.
- **Observed result (pre-fix):** `PERF` log line emitted `cache_read=0
cache_write=0 cache_hit_pct=0` for every non-streaming Bedrock/backend
request, even when the upstream response body contained
`cache_read_input_tokens: 500, cache_creation_input_tokens: 200`.
- **Observed result (post-fix):** `RequestOutcome` now receives the
extracted values; the funnel passes them through to Prometheus, the cost
tracker, `RequestLog`, and the `PERF` line — matching the existing
streaming path behavior.
- **Not tested:** Live Bedrock / Vertex endpoint (no credentials on this
machine). The fix is a pure pass-through of values already present in
the parsed response body.

## Review Readiness

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

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works

## Additional Notes

The regression test file
`tests/test_backend_nonstreaming_cache_metrics.py` was intentionally
written to expose this exact omission (it was not added after the fix).
The streaming sibling fix was tracked as issue #327; this PR closes the
parallel non-streaming gap. The fix is a pure observability change — no
request or response payloads are modified.

---------

Co-authored-by: Sujit <sujit@example.com>
2026-07-09 14:06:05 -05:00
Hafiz Ismail
adf8fed9bd
fix(code): stop TS export duplication + comment displacement (#1906)
## Description

`CodeAwareCompressor` (AST-based code compression,
`headroom/transforms/code_compressor.py`) had two bugs in its
structure-reassembly path, found while investigating a reported Go
brace-duplication issue (the Go bug itself — `statement_list` row-range
swallowing a block's closing brace — was already fixed on `main` in
#1668; this PR fixes what was *actually* still broken):

1. **TS/JS `export` keyword duplication.** `export function foo() {}` /
`export class Foo {}` compressed to `export export function foo() {}` —
invalid syntax, silently discarded by `_verify_syntax`'s fallback (the
caller never sees an error, compression just quietly no-ops). Root
cause: `_compress_function_ast` / `_compress_class_ast` slice a node's
source by **line**, not by byte offset, deliberately — to preserve
leading indentation for definitions nested inside classes. But when a
node shares its *first* line with a preceding sibling (the `export`
keyword is a sibling of the function inside tree-sitter's
`export_statement` node, not part of the function node itself), that
line-based slice pulled the sibling's text in too. The
`export_statement` handler then re-prepended the same `export` text on
top, producing the duplicate.
2. **Doc-comment displacement (all languages).** A `/** ... */` or `//`
doc comment directly above a top-level function/class/type got detached
from its declaration during AST extraction and re-emitted in one cluster
at the very end of the compressed output, instead of staying attached to
what it documents. Root cause: doc comments are top-level *siblings* of
the declaration they document, not children of it — the extractor didn't
attach them to anything, so they fell through to a "leftover top-level
code" bucket that gets flushed as a single block after all functions.

Also tightens `test_actual_go_compression`, which — per its own comment
— was written to *tolerate* the Go bug (`compression_ratio may be 1.0 if
compression produces invalid syntax`) rather than catch it. Since the
underlying Go bug is already fixed on `main`, this now asserts real
compression (`compression_ratio < 1.0`), matching its JS/Python
siblings.

Closes #1905

## Type of Change

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

## Changes Made

Two commits: the fix itself, then the tests that prove it — bisectable
independently, both pass the full suite on their own.

**Commit 1 — `fix(code):`**
- `headroom/transforms/code_compressor.py`: add `_get_node_lines()` —
line-based node slicing that still preserves indentation, but trims a
preceding sibling's text from the first line when that prefix isn't pure
whitespace (i.e. an `export` keyword sharing the line), so callers that
re-add the sibling text themselves don't get a duplicate; used by
`_compress_function_ast` and `_compress_class_ast`.
- `headroom/transforms/code_compressor.py`: add
`_get_leading_comment_text()` — walks a node's `prev_sibling` chain to
collect contiguous doc-comment nodes immediately above it (no blank line
in between) and returns them for the caller to prepend, also marking
their byte ranges as captured so they aren't independently swept into
the leftover top-level-code bucket; wired into every capture branch in
`_extract_structure` (package, import, export statement, decorator,
function, class, type).
- `CHANGELOG.md`: added an entry under `### Fixed`.

**Commit 2 — `test(code):`**
- `tests/test_transforms/test_code_compressor.py`:
`test_actual_go_compression` now asserts `compression_ratio < 1.0`
instead of tolerating a 1.0 fallback.
- `tests/test_code_aware_brace_comment_regressions.py` (new): 4
regression tests — TS `export` not duplicated + valid syntax, TS doc
comments stay attached, Go doc comments stay attached, and a
real-TS-compression parity test matching the existing JS/Python/Go
"actual compression" tests.

## Testing

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

### Test Output

```text
$ ruff check headroom/transforms/code_compressor.py tests/test_transforms/test_code_compressor.py tests/test_code_aware_brace_comment_regressions.py
All checks passed!

$ ruff format --check headroom/transforms/code_compressor.py tests/test_transforms/test_code_compressor.py tests/test_code_aware_brace_comment_regressions.py
3 files already formatted

$ pytest tests/test_transforms/test_code_compressor.py tests/test_code_aware_regressions.py tests/test_code_aware_brace_comment_regressions.py -q
83 passed in 6.23s

$ pytest -q   # full suite
7912 passed, 5 failed, 442 skipped in 417.43s (0:06:57)
# The 5 failures are pre-existing and unrelated: confirmed to fail identically
# with this PR's changes stashed out (clean upstream/main checkout).
#   - test_wrap_marker_is_stale_when_pid_reused (PID-reuse detection, env-specific)
#   - test_read_cached_oauth_token_falls_back_to_gh_cli (leaks real local `gh` credentials)
#   - test_rtk_reader_returns_none_on_nonzero_exit / test_lean_ctx_reader_returns_none_on_failure_and_logs
#     (pass in isolation; fail only in full-suite order — pre-existing test-pollution, unrelated to code_compressor.py)
#   - test_parser_usable_in_thread_pool (test itself passes a str to parser.parse(),
#     which tree-sitter's binding has always required as bytes — a pre-existing test
#     bug unrelated to this change; separate fix in progress on another branch)

$ mypy headroom
Success: no issues found in 408 source files
```

## Real Behavior Proof

- Environment: macOS (Darwin 24.6.0), Python 3.14.5, headroom-ai dev
checkout built via `uv sync --extra dev` + `maturin develop -m
crates/headroom-py/Cargo.toml` (real `headroom._core` build, not
mocked), `tree-sitter==0.25.2` / `tree-sitter-language-pack` per the
pinned `[code]` extra.
- Exact command / steps: ran
`CodeAwareCompressor(CodeCompressorConfig(min_tokens_for_compression=10,
enable_ccr=False)).compress(open("sdk/typescript/src/client.ts").read(),
language="typescript")` identically against `git stash`-ed (pre-fix) and
current (post-fix) trees; full snippet and additional samples below.
- Observed result: `client.ts` (real 20KB SDK file in this repo) went
from `compression_ratio=1.0` with a silent fallback (`export export
class HeadroomClient` in the raw AST attempt, invalid syntax) to
`compression_ratio=0.942`, `syntax_valid=True` — real compression, no
duplication; full before/after table below.
- Not tested: real-world repos beyond this repo's own SDK sample and the
bundled benchmark fixture — broader corpus testing may follow as a
comment on this PR.

**Exact command, full snippet:**

```python
from headroom.transforms.code_compressor import CodeAwareCompressor, CodeCompressorConfig
compressor = CodeAwareCompressor(CodeCompressorConfig(min_tokens_for_compression=10, enable_ccr=False))
with open("sdk/typescript/src/client.ts") as f:
    code = f.read()
result = compressor.compress(code, language="typescript")
```

**Observed result, before vs. after, real code:**

| Sample | Before (main) | After (this fix) |
|---|---|---|
| `sdk/typescript/src/client.ts` (real 20KB SDK file, this repo) |
`compression_ratio=1.0`, silent fallback — `export export class
HeadroomClient` in the raw AST attempt, invalid syntax |
`compression_ratio=0.942`, `syntax_valid=True` — real compression, no
duplication |
| TS fixture exercising both bugs (exported fn/class + doc comments) |
`compression_ratio=1.0`, silent fallback | `compression_ratio=0.993`,
`syntax_valid=True` |
| `middleware/ratelimit.go` (bundled benchmark sample) |
`compression_ratio=0.862`, `syntax_valid=True` — unaffected (Go bug
already fixed on `main` by #1668) | `compression_ratio=0.862`,
`syntax_valid=True` — unchanged, confirms no regression |
| `generate_go_code(3)` (existing test fixture) |
`compression_ratio=0.498` | `compression_ratio=0.498` — unchanged,
confirms no regression |

On code shaped to actually exercise elision (function bodies long enough
to exceed `max_body_lines=5`), TypeScript compresses in line with other
languages once the correctness bug stops blocking it entirely:

| Language | Compression savings (synthetic fixture, ~10-line function
bodies) |
|---|---|
| Python | 64.4% |
| Go | 52.3% |
| TypeScript | 49.0% |
| JavaScript | 42.8% |

(`client.ts`'s real-world 5.8% savings is lower than the synthetic
TypeScript number above because most of its methods are ≤5 lines — under
the elision threshold regardless of language — not because of a
language-specific limitation.)

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

## Additional Notes

The Go brace-duplication bug that motivated this investigation was
already fixed on `main` (#1668, merged before this branch was based) —
confirmed via the minimal repro and `ratelimit.go`, both compress
cleanly with no duplicated braces. This PR fixes what was still actually
broken: the TS/JS `export`-duplication bug and the doc-comment
displacement bug (both present across languages), found empirically
while verifying the original bug report against the current `main`.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: JD Davis <mxjerrett@gmail.com>
2026-07-09 12:51:32 -05:00
Rod Boev
e3b45e402b
fix(learn): handle Windows UTF-8, drive-letter paths, and CLI shim fallback (#1895)
## Description

`headroom learn --verbosity` is broken on Windows in three related ways:

- Transcript/profile reads can use the platform default codec, so
non-ASCII content can raise `UnicodeDecodeError` and collapse learning
signals to empty output.
- `--project <path>` can miss real Claude project directories because
Windows profile junctions can raise `PermissionError` during directory
walks, and escaped Claude project folder names cannot always distinguish
`vibe-remote` from `vibe\remote`.
- `headroom learn --agent codex` can fail with `` `claude` not found in
PATH `` even when the npm-installed CLI exists, because Windows `.cmd`
shims require `PATHEXT` resolution.

Refs https://github.com/headroomlabs-ai/headroom/issues/1624 for the
Windows learn failures. The dashboard-hint UX and
third-party-provider-auth items in that issue are unrelated and out of
scope for this PR.

## 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/verbosity.py`: read and write verbosity
transcripts/profiles with `encoding="utf-8"` so non-ASCII content works
regardless of the Windows locale codec.
- `headroom/learn/plugins/claude.py`: skip inaccessible siblings one
entry at a time during greedy project path decoding, so one Windows
junction no longer hides valid project directories.
- `headroom/learn/plugins/claude.py`: prefer a valid `cwd` found in
Claude session JSONL when discovering project paths, which resolves
ambiguous escaped folder names such as `vibe-remote` versus
`vibe\remote`.
- `headroom/learn/analyzer.py`: resolve Windows CLI shim paths through
`shutil.which()` after `FileNotFoundError`, then retry once for
streaming and non-streaming CLI calls.
- `CHANGELOG.md`: document the Windows learn fixes under `Unreleased`.

## Testing

- [x] Unit tests pass (`uv run pytest
tests/test_verbosity_learn.py::TestWindowsEncoding
tests/test_learn/test_scanner.py::TestGreedyPathDecode::test_permission_denied_sibling_does_not_abort_the_walk
tests/test_learn/test_analyzer.py::TestWindowsCliShimFallback
tests/test_learn/test_scanner.py::TestDecodeProjectPath::test_discover_project_prefers_session_cwd_over_ambiguous_folder_name
-q`)
- [x] Linting passes (`uv run ruff check
headroom/learn/plugins/claude.py tests/test_learn/test_scanner.py`)
- [x] Formatting passes (`uv run ruff format --check
headroom/learn/plugins/claude.py tests/test_learn/test_scanner.py`)
- [ ] Type checking passes (`uv run mypy headroom`) not run; no new
public type surface
- [x] New tests added for the Windows `cwd` disambiguation regression
- [x] Manual testing performed

### Test Output

```text
uv run ruff format headroom/learn/plugins/claude.py
1 file reformatted

uv run ruff check headroom/learn/plugins/claude.py tests/test_learn/test_scanner.py
All checks passed!

uv run ruff format --check headroom/learn/plugins/claude.py tests/test_learn/test_scanner.py
2 files already formatted

uv run pytest tests/test_verbosity_learn.py::TestWindowsEncoding tests/test_learn/test_scanner.py::TestGreedyPathDecode::test_permission_denied_sibling_does_not_abort_the_walk tests/test_learn/test_analyzer.py::TestWindowsCliShimFallback tests/test_learn/test_scanner.py::TestDecodeProjectPath::test_discover_project_prefers_session_cwd_over_ambiguous_folder_name -q
9 passed in 0.25s
```

CI on current head `c6dbac40` is green. A prior `test (1)` run hit an
unrelated timing-sensitive scheduler assertion; GitHub did not permit
direct rerun without admin rights, so the empty commit `c6dbac40`
retriggered CI and the shard passed.

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, real filesystem for the
path-decoding reproduction.
- Exact command / steps: Ran `uv run pytest
tests/test_verbosity_learn.py::TestWindowsEncoding
tests/test_learn/test_scanner.py::TestGreedyPathDecode::test_permission_denied_sibling_does_not_abort_the_walk
tests/test_learn/test_analyzer.py::TestWindowsCliShimFallback
tests/test_learn/test_scanner.py::TestDecodeProjectPath::test_discover_project_prefers_session_cwd_over_ambiguous_folder_name
-q`, `uv run ruff check headroom/learn/plugins/claude.py
tests/test_learn/test_scanner.py`, and `uv run ruff format --check
headroom/learn/plugins/claude.py tests/test_learn/test_scanner.py`; the
path tests create real Windows-style project directories, inaccessible
siblings, ambiguous `vibe\remote` versus `vibe-remote` folders, and
Claude session JSONL with `cwd` pointing at the intended project.
- Observed result: The focused test command returned `9 passed in
0.25s`, Ruff check passed, and Ruff format check passed. The decoder
skips the inaccessible sibling and reaches `vibe-remote`; the
session-`cwd` test returns `vibe-remote` instead of trusting the
ambiguous escaped folder name; the UTF-8 tests round-trip non-ASCII
transcript/profile content under a non-UTF-8 Windows-style codec; the
CLI shim tests retry once through `shutil.which()` after
`FileNotFoundError`.
- Not tested: real npm-installed `claude`/`codex` CLI shims,
dashboard-hint UX, and third-party-provider auth.

## Review Readiness

- [x] I have performed a self-review
- [x] Retrospective review completed after opening; it found one missing
`cwd` disambiguation case, now fixed in this PR
- [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

- The post-open retrospective review concluded this needed targeted
rework rather than only a retrospective sign-off.
- The `cwd` recovery commit is `ee720bb1`; `0905be7b` contains the
required formatter cleanup; current head `c6dbac40` is an empty CI-rerun
commit after GitHub denied direct rerun without admin rights.

---------

Co-authored-by: JD Davis <mxjerrett@gmail.com>
2026-07-09 12:49:38 -05:00
yehsuf
f52ca19db1
fix(copilot-auth): stop discarding the caller's valid Copilot auth token (#1879)
## Description

`apply_copilot_api_auth()` treats any incoming
`gho_`/`ghs_`/`ghp_`/`github_pat_`-prefixed GitHub OAuth bearer token as
"not a suitable Copilot API token" (`_is_copilot_api_token()`) and
silently replaces it with a token independently fetched/exchanged by
Headroom itself, even when the caller already sent its own valid,
correctly-entitled Copilot credential.

This closes headroomlabs-ai/headroom#1813, which reports the exact same
defect from OpenCode's native GitHub Copilot integration: Headroom
replaces its native `gho_` bearer token, changing the effective
client/integrator lane Copilot's backend sees and breaking model
discovery/inference parity with native (non-proxied) behavior.

I hit the same root cause independently, through a different trigger
path, while building a new dedicated GitHub Copilot CLI redirect
integration (so Copilot CLI traffic gets the same full Headroom pipeline
treatment Claude Code already gets): a live Copilot CLI session's own
`gho_`-prefixed token worked end-to-end for model `claude-sonnet-5` when
sent directly to Copilot's real API, but the exact same request got `400
model_not_supported` once routed through Headroom, because Headroom
silently swapped in a different, independently-exchanged token with
different (lesser) model entitlements.

Closes #1813

## 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 `_is_forwardable_copilot_bearer_token()` in
`headroom/copilot_auth.py`: a new, narrowly-scoped helper used only by
`apply_copilot_api_auth()` (the chat-completion/inference auth path)
that accepts both short-lived Copilot API tokens (`tid_`) AND GitHub
OAuth tokens (`gho_`/`ghs_`/`ghp_`/`github_pat_`) as forwardable, since
live evidence (this PR + #1813) shows both are valid, correctly-entitled
Copilot bearer credentials when supplied directly by a Copilot-aware
client.
- **Deliberately did NOT touch `_is_copilot_api_token()`** — that helper
is used by a separate, unrelated code path
(`resolve_subscription_bearer_token_details()`, for Copilot
subscription/user-info API resolution) with different endpoint
requirements. Broadening it in place would have changed
subscription-resolution behavior as an unintended side effect; the new
helper keeps that concern isolated.
- `apply_copilot_api_auth()` now calls
`_is_forwardable_copilot_bearer_token()` instead of
`_is_copilot_api_token()` when deciding whether to forward the caller's
existing bearer token unchanged. Non-Copilot-shaped or
blank/whitespace-only tokens still fall through to Headroom's own
fetch/exchange, unchanged.
- Fixed a companion `/v1/responses` WebSocket ordering bug in
`headroom/proxy/handlers/openai.py` (filed as
headroomlabs-ai/headroom#1880): the `OPENAI_API_KEY` fallback used a
stale `_lower_headers` snapshot computed before
`apply_copilot_api_auth()` ran, and ran *before* it. Once the upstream
is known, Copilot auth is now resolved first, and the fallback checks
live header state instead — this matters more now that a
correctly-forwarded Copilot bearer token must not be shadowed by an
unrelated OpenAI-key fallback injected earlier in the flow.

## 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
$ pytest tests/test_copilot_auth.py tests/test_proxy_copilot_auth_hooks.py tests/test_provider_copilot_wrap.py -k "not falls_back_to_gh_cli" -q
============================= test session starts ==============================
platform darwin -- Python 3.12.12, pytest-9.0.3, pluggy-1.6.0
collected 95 items / 1 deselected / 94 selected

tests/test_copilot_auth.py ............................................. [ 47%]
............                                                             [ 60%]
tests/test_proxy_copilot_auth_hooks.py ...                               [ 63%]
tests/test_provider_copilot_wrap.py ..................................   [100%]

======================= 94 passed, 1 deselected in 0.52s =======================

# The 1 deselected test (test_read_cached_oauth_token_falls_back_to_gh_cli) is a
# pre-existing, environment-dependent failure unrelated to this change: it leaks a
# real gho_ token from this machine's own authenticated `gh` CLI keychain instead of
# using the mocked one. Confirmed identical on main before this change.

$ pytest tests/test_openai_codex_ws_lifecycle.py tests/test_openai_codex_routing.py tests/test_proxy_openai_responses_integration.py tests/test_codex_ws_compression_scheduler.py tests/test_openai_codex_ws_timings.py -q
================== 53 passed, 15 skipped, 1 warning in 16.19s ==================
# (15 skips are pre-existing, environment-gated, unrelated to this change)

$ ruff check headroom/copilot_auth.py headroom/proxy/handlers/openai.py tests/test_copilot_auth.py
All checks passed!
```

## Real Behavior Proof

- Environment: real GitHub Copilot CLI session (`gho_`-prefixed OAuth
token), routed through a dedicated Headroom redirect-proxy integration
(new, currently opt-in/staging-only work, not part of this PR) that
forwards Copilot CLI's `/v1/messages` and `/chat/completions` traffic
through Headroom.
- Exact command / steps: live Copilot CLI request for model
`claude-sonnet-5`, sent once directly to
`api.business.githubcopilot.com` and once routed through Headroom with
this fix applied.
- Observed result: both paths return `200 OK` with identical token/cost
usage. Before this fix, the proxied path returned `400
model_not_supported` for the exact same request, because Headroom
substituted a different, less-entitled token for the client's own valid
`gho_` token.
- Not tested: the companion WS-ordering fix (`/v1/responses` fallback
ordering) was validated via the existing WS lifecycle/routing/timing
unit test suites (see Test Output) rather than a live WebSocket session
— I don't have a live client that exercises that exact
`OPENAI_API_KEY`-fallback-into-Copilot-upstream path end-to-end.

## Review Readiness

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

## Checklist

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

## Screenshots (if applicable)

N/A

## Additional Notes

This PR was rebased onto current `main` after it turned out
`_is_copilot_api_token()` had already been introduced upstream (#557,
merged before this PR was opened) with the opposite assumption — that
`gho_`/`ghs_`/`ghp_`/`github_pat_` tokens always need exchanging and
must not be forwarded. That assumption is exactly what #1813 reports as
broken. Rather than reverting that classification wholesale (my original
approach in this branch), this version keeps it intact for its original
caller and adds a second, narrowly-scoped helper for the
inference/chat-completion auth path specifically — see "Changes Made"
above for the reasoning.

mypy is left unchecked above only because I didn't have it
configured/runnable in my local environment for this pass — happy to run
it if a maintainer flags a concern, or CI will catch it.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-09 12:46:35 -05:00
Dima Solodukha
d604e86904
fix(litellm): surface Bedrock cache token usage in non-streaming responses (#1848)
## Description

Non-streaming `complete_message()` builds the Anthropic-shape usage from
`prompt_tokens`/`completion_tokens` only. LiteLLM's `prompt_tokens`
includes cached tokens, so when Bedrock prompt caching is active a
non-streaming client sees `input_tokens` equal to the full prompt and no
cache fields. That looks identical to the cache being broken (#1345),
and the savings tracker never credits the hits. The streaming and OpenAI
paths already map these fields.

Related: #1390 — that PR makes the markers reach Bedrock; this one makes
the result visible in non-streaming responses.

Closes # (contributes to #1345 together with #1390; not closing it
alone)

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

- Extract `_anthropic_usage_from_litellm()` in
`headroom/backends/litellm.py`: maps `cache_read_input_tokens` /
`cache_creation_input_tokens` (with `prompt_tokens_details` fallback)
into the Anthropic-shape usage and reports `input_tokens` without the
cached portion, matching what Anthropic returns.
- Use it in `complete_message()` instead of the inline
`prompt_tokens`/`completion_tokens` dict.
- Add `tests/test_litellm_nonstream_cache_usage.py` (5 cases: plain
usage, cache read, cache write, `prompt_tokens_details` fallback,
negative clamp).

## 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
$ pytest tests/test_litellm_nonstream_cache_usage.py -q
5 passed, 1 warning in 2.13s

$ ruff check headroom/backends/litellm.py tests/test_litellm_nonstream_cache_usage.py
All checks passed!
```

mypy not run locally: my environment fails on unrelated numpy stubs
(`numpy/__init__.pyi: Type statement is only supported in Python
3.12+`); relying on CI for the mypy gate.

## Real Behavior Proof

- Environment: real AWS Bedrock, us-east-1,
`us.anthropic.claude-sonnet-4-5-20250929-v1:0`, headroom-ai 0.30.0 with
this patch, Python 3.13.
- Exact command / steps: `headroom proxy --backend bedrock
--bedrock-region us-east-1 --mode cache --port 8787`, then three
identical non-streaming `POST /v1/messages` with a 1,226-token system
block marked `cache_control: {"type": "ephemeral"}` (fresh salted
prefix), with the conversion fix from #1390 applied so markers reach
Bedrock.
- Observed result: before this patch usage reported `input_tokens=1213`
with no cache fields on every call; after — call 1: `input_tokens=11,
cache_creation_input_tokens=1226`; calls 2–3: `input_tokens=11,
cache_read_input_tokens=1226`. Matches a direct-to-Bedrock baseline
(boto3 `invoke_model` with the same payload).
- Not tested: streaming path (unchanged by this PR), non-Bedrock LiteLLM
providers (mapping is provider-agnostic: fields are absent → behavior
identical to before).

## Review Readiness

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

## Checklist

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

## Screenshots (if applicable)

N/A — token counts are in Real Behavior Proof above.

## Additional Notes

Documentation and CHANGELOG unchecked: single-function bugfix, no
user-facing docs describe the non-streaming usage fields; happy to add a
CHANGELOG entry if maintainers want one.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-09 12:44:36 -05:00