Commit 1b8c11e
fix(proxy/openai): apply output shaping on /v1/chat/completions (headroomlabs-ai#2328)
## Description
Fixes headroomlabs-ai#2302.
Output shaping (`HEADROOM_OUTPUT_SHAPER=1`) verbosity steering is wired
into the Anthropic `/v1/messages` handler and the OpenAI `/v1/responses`
handler, but never into `handle_openai_chat`. OpenAI-compatible clients
that route through `/v1/chat/completions` — GitHub Copilot CLI,
opencode, older SDKs — therefore got zero output savings, and `headroom
output-savings` reported:
```
No shaped requests recorded yet.
```
`handle_openai_chat` referenced verbosity only for cache-key
construction, never for actual shaping. The shared helpers
(`OutputShaperSettings`, `resolve_verbosity_level`, `assign_arm`,
`classify_turn`) existed but were not called from the chat path.
## Fix
Run the same shaping block the Anthropic handler already uses, at the
end of `handle_openai_chat` (after every other body mutation, before the
upstream forward, skipped under `x-headroom-bypass`):
- conversation-stable holdout via
`assign_arm(conversation_key_from_body(body), holdout)` —
`conversation_key_from_body` already reads `messages`, so it works
unchanged for a chat body;
- stratum labelling on the transforms channel so the outcome funnel
feeds the output-savings ledger from the chat path;
- for the treatment arm, verbosity steering via a new
`shape_openai_chat_request`.
The one genuinely new piece is a chat-specific steering injector.
Anthropic carries the system prompt in a top-level `system` field and
Responses in `instructions`; **chat/completions carries it as a `role:
"system"` message inside `messages`**, which neither existing injector
touches. `apply_openai_chat_verbosity_steering`:
- appends the byte-stable steering block to the tail of the last
`system`/`developer` message (idempotent via the
`<headroom_output_shaping>` sentinel, and it swaps cleanly when the
level changes);
- handles both string content and the content-part list form (`[{"type":
"text", ...}]`);
- inserts a `role: "system"` message at the front only when the request
has no system message.
Because a whole conversation is stably treatment or control and the
block text is fixed per level, a treatment conversation's steering is
byte-stable across turns, so the provider prefix cache is not thrashed.
Effort routing is intentionally not applied on this path —
`route_effort` writes Anthropic-shaped `output_config`/thinking config
with no portable chat/completions equivalent — so only the
token-reducing verbosity lever runs. Mutating `body` in place is enough
on this path; the outbound request serializes `body` fresh, so no
body-mutation tracker is needed.
## 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/output_steering.py`: add
`apply_openai_chat_verbosity_steering` (inject the steering block into
the chat `messages` system prompt).
- `headroom/proxy/output_shaper.py`: add `shape_openai_chat_request`
(verbosity-only chat shaper) and export both new names.
- `headroom/proxy/handlers/openai.py`: run the holdout/stratum + shaping
block at the end of `handle_openai_chat`, mirroring the Anthropic
handler and respecting bypass.
- `tests/test_output_steering.py`: cover the injector (append,
idempotency, level swap, insert-when-absent, list content, level-0
no-op).
- `tests/test_output_shaper.py`: cover `shape_openai_chat_request`
(disabled no-op, applies steering, level override, stable second pass).
- `CHANGELOG.md`: Bug Fixes entry.
## Testing
- [ ] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed
### Test Output
```text
$ uvx ruff@0.15.17 check headroom/proxy/output_steering.py headroom/proxy/output_shaper.py headroom/proxy/handlers/openai.py tests/test_output_steering.py tests/test_output_shaper.py
All checks passed!
$ uvx ruff@0.15.17 format --check <same files>
5 files already formatted
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/proxy/output_steering.py headroom/proxy/output_shaper.py
Success: no issues found in 2 source files
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17` / `uvx
mypy@1.20.2`. A full `pytest` OOMs this box (ML-stack import), so I
reproduced the injector with a dependency-free script and left the full
pytest to CI.
- Exact command / steps: replicated
`apply_openai_chat_verbosity_steering` (and the
`steering_text`/`replace_or_append_steering_block` primitives it uses)
and exercised: an existing string system message, an existing
content-part list, no system message, re-apply at the same level, and a
level swap.
- Observed result: the steering block is appended to the system message
while user turns and message order are untouched; re-applying at the
same level is a no-op; a level change replaces the block (exactly one
remains); a request with no system message gets one inserted at the
front; level 0 is a no-op. The added unit tests assert the same through
`shape_openai_chat_request`.
- Not tested: a live Copilot CLI `/v1/chat/completions` round trip; the
added tests drive the pure shaper/injector directly, matching the
existing `test_output_shaper.py` / `test_output_steering.py` patterns.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable
## Additional Notes
The "unit tests pass locally" box is unchecked because a local pytest
run imports the ML stack and OOMs this box; the added tests are pure (no
ML imports) and run under the normal CI pytest job, and the injector
behavior is corroborated by the standalone proof above. Effort routing
on chat/completions is deliberately out of scope here (no portable
equivalent to the Anthropic effort levers); this PR restores the
verbosity-steering savings the issue reports as missing, and effort
routing for chat can follow separately if wanted.
---------
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>1 parent a63d235 commit 1b8c11e
5 files changed
Lines changed: 253 additions & 0 deletions
File tree
- headroom/proxy
- handlers
- tests
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
3361 | 3361 | | |
3362 | 3362 | | |
3363 | 3363 | | |
| 3364 | + | |
| 3365 | + | |
| 3366 | + | |
| 3367 | + | |
| 3368 | + | |
| 3369 | + | |
| 3370 | + | |
| 3371 | + | |
| 3372 | + | |
| 3373 | + | |
| 3374 | + | |
| 3375 | + | |
| 3376 | + | |
| 3377 | + | |
| 3378 | + | |
| 3379 | + | |
| 3380 | + | |
| 3381 | + | |
| 3382 | + | |
| 3383 | + | |
| 3384 | + | |
| 3385 | + | |
| 3386 | + | |
| 3387 | + | |
| 3388 | + | |
| 3389 | + | |
| 3390 | + | |
| 3391 | + | |
| 3392 | + | |
| 3393 | + | |
| 3394 | + | |
| 3395 | + | |
| 3396 | + | |
| 3397 | + | |
| 3398 | + | |
| 3399 | + | |
| 3400 | + | |
| 3401 | + | |
| 3402 | + | |
| 3403 | + | |
| 3404 | + | |
| 3405 | + | |
| 3406 | + | |
| 3407 | + | |
| 3408 | + | |
| 3409 | + | |
| 3410 | + | |
| 3411 | + | |
| 3412 | + | |
| 3413 | + | |
| 3414 | + | |
| 3415 | + | |
| 3416 | + | |
| 3417 | + | |
| 3418 | + | |
| 3419 | + | |
| 3420 | + | |
3364 | 3421 | | |
3365 | 3422 | | |
3366 | 3423 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
58 | 58 | | |
59 | 59 | | |
60 | 60 | | |
| 61 | + | |
61 | 62 | | |
62 | 63 | | |
63 | 64 | | |
| |||
76 | 77 | | |
77 | 78 | | |
78 | 79 | | |
| 80 | + | |
79 | 81 | | |
80 | 82 | | |
81 | 83 | | |
| |||
84 | 86 | | |
85 | 87 | | |
86 | 88 | | |
| 89 | + | |
87 | 90 | | |
88 | 91 | | |
89 | 92 | | |
| |||
352 | 355 | | |
353 | 356 | | |
354 | 357 | | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
355 | 388 | | |
356 | 389 | | |
357 | 390 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
46 | 46 | | |
47 | 47 | | |
48 | 48 | | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
49 | 111 | | |
50 | 112 | | |
51 | 113 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
20 | 20 | | |
21 | 21 | | |
22 | 22 | | |
| 23 | + | |
23 | 24 | | |
24 | 25 | | |
25 | 26 | | |
| |||
402 | 403 | | |
403 | 404 | | |
404 | 405 | | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
| 435 | + | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
| 439 | + | |
| 440 | + | |
| 441 | + | |
| 442 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
42 | 42 | | |
43 | 43 | | |
44 | 44 | | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
0 commit comments