## Summary
Adds a small provider-neutral savings attribution seam. Named sources
can attach realized or projected token/USD deltas to a request without
changing headline arithmetic or introducing private-package inventory
into OSS.
Also fixes the Anthropic buffered lifecycle so normal successful
responses run response hooks, applies stream-safety filtering, includes
tool savings in per-model perf totals, and surfaces the same breakdown
in request logs, `/stats`, `headroom perf`, Prometheus, OTEL, and the
dashboard.
## Why
Request-local savings were split between canonical token deltas,
process-global extension counters, and tool-only tags. This made correct
headline totals possible while losing attribution in perf, recent
requests, metrics, and the dashboard. Normal Anthropic responses also
skipped response hooks unless CCR ran.
## Validation
- 74 focused tests passed: turn hooks, OpenAI hook lifecycle, outcome
funnel, perf formats, and tool-search repair
- Ruff passes on all changed Python files
- Existing compression-observability suite: 11 passed; 2 tokenizer-cache
tests require network access to fetch the tiktoken vocabulary
## Compatibility
No named private packages or private inventory are encoded in OSS.
Existing hooks remain source-compatible because all new TurnContext
fields are optional.
---------
Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
## Description
Fixes the last harness gap in the turn-hook seam (the "B4" finding from
the savings audit). The OpenAI chat handler gated hooks on `not stream`,
so **streamed** `/v1/chat/completions` requests ran **no** turn hooks —
the lossless-guard plugin's on_request fold and tool-schema shrink were
skipped, unlike the Anthropic path (hooks run unconditionally). Affects
opencode / Cursor / older OpenAI SDKs / some Copilot flows; **not**
Claude Code (Anthropic path).
The gate existed for a real reason: hooks that **re-drive** the model in
`on_response` (defer a tool, reload it when asked) can't run mid-stream.
But an **on_request fold** mutates the outbound request before the send
— safe on a stream.
## Change
- Add an opt-in `stream_safe` hook attribute (fold-only hooks set it).
`run_request_hooks(ctx, stream_safe_only=…)` filters to stream-safe
hooks when set.
- OpenAI chat handler runs `on_request` on streaming with
`stream_safe_only=stream`; buffered runs all hooks; the `on_response`
re-drive (buffered response path) is untouched.
- **Default off = conservative:** a hook is buffered-only unless it
declares `stream_safe`, so **no behavior change** until a hook opts in.
## Type of Change
- [x] Bug fix / feature (opt-in, backward-compatible)
## Testing
```text
pytest tests/test_turn_hooks.py tests/test_openai_chat_turn_hooks.py -q → 25 passed
ruff + mypy → clean
```
New test pins the filter: streaming runs only stream-safe hooks'
on_request; buffered runs all.
## Notes
The companion plugin PR (headroom-lossless-guard) sets `stream_safe =
True` on its fold-only hook to actually claim the streaming savings.
Anthropic path already ran hooks on streaming, so it's unaffected.
## Checklist
- [x] Self-reviewed; tests pass; no CHANGELOG edit
## Description
Adds a small, neutral **extension point** to the proxy: a "turn hook"
that lets an opt-in extension observe and optionally re-drive a single
buffered model turn, without touching the core request/response flow for
anyone who has no extension installed.
A hook can:
- `on_request(ctx)` — inspect or rewrite the outbound tools/messages
before they go upstream (the extensible counterpart to the built-in
tool-search deferral that already lives at that point).
- `on_response(ctx, response, call_model)` — inspect the model's
response and, if it wants, call the model again (via `call_model`) and
return a **replacement** response — transparently to the client. This is
the capability that can't be done from ASGI middleware: it reuses the
proxy-internal re-call path (the same `api_call_fn` the CCR handler
already drives).
The module is **inert unless a hook is registered**: the runners return
their input unchanged and are gated on the registry, so with no
extension the proxy is byte-identical to today. A failing hook is logged
and skipped — it can never take the proxy down.
Closes #
## Type of Change
- [ ] Bug fix (non-breaking change that fixes an issue)
- [x] 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 `headroom/proxy/turn_hooks.py`: `TurnContext`, the `TurnHook`
protocol (`on_request` / `on_response`), a module registry
(`register_turn_hook` / `registered_turn_hooks` / `clear_turn_hooks`),
and the runners `run_request_hooks` / `run_response_hooks`. Inert when
empty; never raises.
- Wire it at four seams, each gated so an empty registry is a
byte-identical no-op:
- Anthropic — pre-send (right after the existing tool-search deferral) +
the CCR response seam.
- OpenAI — the Responses tool-shaping point (right after the existing
tool-search deferral, copy-on-write-safe) + the CCR response seam.
- Add `tests/test_turn_hooks.py`.
## 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 (no-op regression across CCR/handler
suites)
### Test Output
```text
$ ruff check headroom/proxy/turn_hooks.py tests/test_turn_hooks.py \
headroom/proxy/handlers/anthropic.py headroom/proxy/handlers/openai.py
All checks passed!
$ ruff format --check <same 4 files>
4 files already formatted
$ mypy headroom
Success: no issues found in 408 source files
$ pytest tests/test_turn_hooks.py -q
9 passed in 0.17s
$ pytest tests/test_turn_hooks.py tests/test_ccr_response_handler.py \
tests/test_ccr_tool_injection.py tests/test_proxy_ccr.py \
tests/test_openai_tool_search_deferral.py \
tests/test_openai_responses_compression_units.py \
tests/test_handler_outcome_tag_invariant.py -q
135 passed (+ 1 pre-existing cross-file flake in test_proxy_ccr::test_health_endpoint,
which passes in isolation and in its own file: `pytest tests/test_proxy_ccr.py` -> 19 passed)
```
## Real Behavior Proof
- **Environment:** local macOS, project `.venv` (Python 3.12.6); `ruff`
pinned to CI's `0.15.17` via `uvx ruff@0.15.17`; `mypy` from the venv.
- **Exact command / steps:** branched off `upstream/main`; added the
hook module + wired the four handler seams; ran the
ruff/format/mypy/pytest commands above.
- **Observed result:** The unit tests exercise the whole contract —
registry, `on_request` mutating `ctx.tools`, `on_response` returning a
replacement, the `await call_model(...)` re-drive loop,
replacement-chaining across hooks, and the never-raise guarantee. The
existing CCR + handler suites pass unchanged, which is the point: with
no hook registered the added code is a no-op (the runners short-circuit
on an empty registry).
- **Not tested:** the live interactive re-drive path with a *registered*
hook against a real upstream — no hook ships in this repo, so that path
is covered here only by the unit test's fake `call_model`. The
`on_request` seam fires on the Anthropic pre-send and OpenAI Responses
paths (where the existing tool-search deferral runs); other send paths
(e.g. chat-completions, streaming) are not wired in this PR.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable