Commit 25ca580
fix(proxy/responses): lift Codex >= 0.149.0 additional_tools into top-level tools (#3186)
## Description
Codex CLI 0.149.0 (npm `latest` since 2026-08-20 21:09 UTC) stopped
sending a top-level `tools` array on `/v1/responses` for models its
server-fetched capability cache flags (`gpt-5.6-sol`, its new default).
Tool definitions now ride inside `input` as items of a new type:
```json
{"type": "additional_tools", "tools": [ {...}, {...} ]}
```
Every tools consumer in the proxy - `tool_schema_compaction`, the
output-shaper stratum, the tools token accounting - reads only
`payload["tools"]`, so these requests classify `notools` and record
exactly zero tool-schema savings while forwarding and streaming
normally. Users on Codex <= 0.148 are unaffected; users silently lose
savings the moment their CLI updates. On our fleet the day after the
Codex release, 42 of 54 codex-primary users active in a 12h window had
savings frozen, and 0 of that day's codex new signups recorded any
savings.
This PR normalizes the new encoding to the classic one before
compression: `_lift_codex_additional_tools(payload)` concatenates the
carrier items' `tools` arrays into `payload["tools"]` and drops the
carriers from `input`, in place, once per compression pass - at the top
of `_compress_openai_responses_payload_in_executor`, the single funnel
every responses call site goes through (HTTP `/v1/responses`, WS first
and subsequent frames, passthrough). It no-ops when top-level `tools` is
already present, so classic-encoding clients pay nothing and a future
Codex reverting the change costs nothing. Normalizing (rather than
compacting inside the items and preserving the new wire shape) keeps
every downstream consumer working without touching their accounting; the
alternative shape is discussed in #3185.
Closes #3185
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- `headroom/proxy/handlers/openai.py`: new module function
`_lift_codex_additional_tools(payload, *, request_id=None)` plus
`_codex_additional_tools_lift_enabled()` (env gate via
`runtime_env.getenv`, hot-reloadable); called defensively at the top of
`_compress_openai_responses_payload_in_executor` so a lift failure can
never break forwarding.
- `tests/test_openai_responses_additional_tools.py`: 8 tests - lift
shape, multi-carrier concatenation, no-op on classic encoding, no-op
without carriers / non-dict / non-list input, kill switch, logging,
empty-carrier preservation, and lift-then-compaction integration
reproducing the exact production failure (compaction returns unmodified
without the lift).
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed
### Test Output
```text
$ uv run --frozen --extra dev pytest tests/test_openai_responses_additional_tools.py tests/test_openai_responses_context_compaction.py -q
==== 18 passed in 2.71s ====
$ uv run --frozen --extra dev pytest tests/test_proxy_openai.py -q # adjacent handler suite
==== 31 passed, 1 warning in 26.36s ====
$ uv run --frozen ruff check headroom/proxy/handlers/openai.py tests/test_openai_responses_additional_tools.py
All checks passed!
$ uv run --frozen ruff format --check headroom/proxy/handlers/openai.py tests/test_openai_responses_additional_tools.py
2 files already formatted
$ uv run --frozen mypy headroom/proxy/handlers/openai.py
Success: no issues found in 1 source file
```
## Real Behavior Proof
- Environment: macOS 15 (arm64), headroom-ai 0.35.0 wheel in a fresh
venv with empty state (`HOME` pointed at an empty dir), `headroom proxy
--port 6799 --no-http2 --log-messages --no-ccr`; Codex CLI 0.149.0
(standalone npm install) and 0.142.4, ChatGPT-plan OAuth, routed via a
`[model_providers]` block in `config.toml`.
- Exact command / steps: `CODEX_HOME=<test home> codex exec
--skip-git-repo-check "Run the shell command: echo headroom-test-123.
Then reply with exactly the output it printed."` against the proxy,
before and after injecting the lift (via a sitecustomize carrying the
same function); cross-checked Codex 0.142.4 default (gpt-5.5), 0.142.4
`-m gpt-5.6-sol`, and 0.149.0 `-m gpt-5.5`.
- Observed result: before - `/v1/responses compressed 59425->59425 bytes
(0 tokens saved,
transforms=['output_shaper:stratum:gpt|new_user_ask|m|notools',
'output_shaper:verbosity:L2'])` despite ~12k tokens of tool schemas in
the request (Codex's own `tool_token_count` log field). After -
`/v1/responses compressed 59437->58716 bytes (608 tokens saved,
transforms=['output_shaper:stratum:gpt|new_user_ask|m|tools',
'output_shaper:verbosity:L2',
'openai:responses:tool_schema_compaction'])`; the shell tool call
executed against the live ChatGPT Codex backend and returned its output,
the follow-up turn classified `mechanical_continuation|m|tools`, and the
prefix cache stayed hot (cache_hit_pct=100 on turn 2). The three
cross-check matrix cells all compress, confirming the backend accepts
the classic top-level encoding for these models and that the regression
is 0.149.0's default-model path specifically.
- Not tested: Codex over the WebSocket transport (the verified setups
pin `supports_websockets = false`; the lift sits in the shared executor
those frames also funnel through, and unit tests cover the per-frame
payload shapes); non-ChatGPT (API-key) Codex auth; models other than
gpt-5.5/gpt-5.6-sol.
## Runtime Rollout Safety
- Rollout-managed feature(s): none - not wired to the rollout system.
- Minimum rollout channel: n/a.
- Stable/default behavior changed: only for requests carrying
`additional_tools` input items with no top-level `tools` (the Codex >=
0.149.0 default-model encoding, which today gets zero compression); all
other traffic is byte-identical.
- Kill switch / disable path: `HEADROOM_CODEX_ADDITIONAL_TOOLS_LIFT=0`
(read through `runtime_env.getenv`, so hot-reload overrides apply
without a restart).
- Unsafe override required: no.
- Qualification impact: none known.
- Rollback path: set the kill switch, or revert this single commit - the
lift is self-contained (one function + one guarded call site).
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)
## Screenshots (if applicable)
n/a - proxy log lines quoted under Real Behavior Proof.
## Additional Notes
- Documentation checklist item is unchecked because no user-facing docs
describe the responses tools handling; happy to add a line wherever you
track client-compat notes if you have a preferred spot.
- If you would rather preserve the new wire shape upstream (compact
inside the carrier items instead of normalizing), I am happy to rework -
trade-offs are laid out in #3185.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>1 parent 5e0ce24 commit 25ca580
2 files changed
Lines changed: 231 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
778 | 778 | | |
779 | 779 | | |
780 | 780 | | |
| 781 | + | |
| 782 | + | |
| 783 | + | |
| 784 | + | |
| 785 | + | |
| 786 | + | |
| 787 | + | |
| 788 | + | |
| 789 | + | |
| 790 | + | |
| 791 | + | |
| 792 | + | |
| 793 | + | |
| 794 | + | |
| 795 | + | |
| 796 | + | |
| 797 | + | |
| 798 | + | |
| 799 | + | |
| 800 | + | |
| 801 | + | |
| 802 | + | |
| 803 | + | |
| 804 | + | |
| 805 | + | |
| 806 | + | |
| 807 | + | |
| 808 | + | |
| 809 | + | |
| 810 | + | |
| 811 | + | |
| 812 | + | |
| 813 | + | |
| 814 | + | |
| 815 | + | |
| 816 | + | |
| 817 | + | |
| 818 | + | |
| 819 | + | |
| 820 | + | |
| 821 | + | |
| 822 | + | |
| 823 | + | |
| 824 | + | |
| 825 | + | |
| 826 | + | |
| 827 | + | |
| 828 | + | |
| 829 | + | |
| 830 | + | |
| 831 | + | |
| 832 | + | |
| 833 | + | |
| 834 | + | |
| 835 | + | |
| 836 | + | |
| 837 | + | |
| 838 | + | |
| 839 | + | |
| 840 | + | |
| 841 | + | |
| 842 | + | |
| 843 | + | |
| 844 | + | |
| 845 | + | |
| 846 | + | |
| 847 | + | |
781 | 848 | | |
782 | 849 | | |
783 | 850 | | |
| |||
2870 | 2937 | | |
2871 | 2938 | | |
2872 | 2939 | | |
| 2940 | + | |
| 2941 | + | |
| 2942 | + | |
| 2943 | + | |
| 2944 | + | |
| 2945 | + | |
| 2946 | + | |
| 2947 | + | |
| 2948 | + | |
| 2949 | + | |
| 2950 | + | |
| 2951 | + | |
| 2952 | + | |
| 2953 | + | |
2873 | 2954 | | |
2874 | 2955 | | |
2875 | 2956 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 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 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
0 commit comments