Skip to content

Commit 25ca580

Browse files
gglucassclaude
andauthored
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

File tree

headroom/proxy/handlers/openai.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -778,6 +778,73 @@ def _compact_openai_responses_tools(
778778
return compact_tools(payload)
779779

780780

781+
def _codex_additional_tools_lift_enabled() -> bool:
782+
from headroom.proxy import runtime_env
783+
784+
return (
785+
runtime_env.getenv("HEADROOM_CODEX_ADDITIONAL_TOOLS_LIFT", "1") or "1"
786+
).strip().lower() not in (
787+
"0",
788+
"false",
789+
"no",
790+
"off",
791+
)
792+
793+
794+
def _lift_codex_additional_tools(payload: dict[str, Any], *, request_id: str | None = None) -> int:
795+
"""Lift Codex ``additional_tools`` input items into top-level ``tools``.
796+
797+
Codex CLI 0.149.0 stopped sending a top-level ``tools`` array on
798+
``/v1/responses`` for models its capability cache flags (``gpt-5.6-sol``,
799+
its current default): tool definitions ride inside ``input`` as items of
800+
type ``additional_tools``. Every tools consumer downstream -- schema
801+
compaction, the output-shaper stratum, tools token accounting -- reads
802+
only ``payload["tools"]``, so those requests classified "notools" and
803+
recorded zero tool-schema savings while forwarding normally (#3185).
804+
805+
Mutates *payload* in place: concatenates the items' ``tools`` arrays into
806+
``payload["tools"]`` and drops the carrier items from ``input``. Returns
807+
the number of lifted tool definitions (0 = no-op). No-op when the payload
808+
already carries top-level tools, so classic-encoding clients are
809+
untouched and a future Codex reverting the change costs nothing. The
810+
classic top-level encoding is accepted upstream for these models --
811+
Codex <= 0.148 still sends it for ``gpt-5.6-sol`` -- verified against the
812+
live ChatGPT Codex backend with executed tool calls. Disable with
813+
``HEADROOM_CODEX_ADDITIONAL_TOOLS_LIFT=0``.
814+
"""
815+
if not isinstance(payload, dict) or payload.get("tools"):
816+
return 0
817+
items = payload.get("input")
818+
if not isinstance(items, list):
819+
return 0
820+
if not any(isinstance(item, dict) and item.get("type") == "additional_tools" for item in items):
821+
return 0
822+
if not _codex_additional_tools_lift_enabled():
823+
return 0
824+
lifted: list[Any] = []
825+
kept: list[Any] = []
826+
for item in items:
827+
if (
828+
isinstance(item, dict)
829+
and item.get("type") == "additional_tools"
830+
and isinstance(item.get("tools"), list)
831+
and item["tools"]
832+
):
833+
lifted.extend(item["tools"])
834+
else:
835+
kept.append(item)
836+
if not lifted:
837+
return 0
838+
payload["tools"] = lifted
839+
payload["input"] = kept
840+
logger.info(
841+
"[%s] Lifted %d Codex additional_tools definitions to top-level tools",
842+
request_id or "-",
843+
len(lifted),
844+
)
845+
return len(lifted)
846+
847+
781848
def _allow_responses_memory_tools(is_chatgpt_auth: bool) -> bool:
782849
# Preserve the ChatGPT Codex route's existing store policy and memory-tool
783850
# exclusion while API Responses memory continuations stay stateless.
@@ -2870,6 +2937,20 @@ async def _compress_openai_responses_payload_in_executor(
28702937
) -> tuple[dict[str, Any], bool, int, list[str], str | None, int, int, int, dict[str, float]]:
28712938
timing: dict[str, float] = {}
28722939

2940+
# Codex >= 0.149.0 nests tool definitions in `input` items of type
2941+
# additional_tools; normalize to the classic top-level array before
2942+
# shaping/compression so every downstream tools consumer engages.
2943+
# Runs once per pass, ahead of the executor closure, and never breaks
2944+
# forwarding.
2945+
try:
2946+
_lift_codex_additional_tools(payload, request_id=request_id)
2947+
except Exception: # pragma: no cover - defensive; never break forwarding
2948+
logger.warning(
2949+
"[%s] additional_tools lift failed; continuing unlifted",
2950+
request_id,
2951+
exc_info=True,
2952+
)
2953+
28732954
def _compress(): # noqa: ANN202
28742955
# Output shaping (opt-in via HEADROOM_OUTPUT_SHAPER) runs before
28752956
# compression so the turn classifier sees the client's input as
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
"""Codex >= 0.149.0 ``additional_tools`` normalization (#3185).
2+
3+
Codex CLI 0.149.0 sends tool definitions as ``input`` items of type
4+
``additional_tools`` instead of a top-level ``tools`` array for models its
5+
capability cache flags (``gpt-5.6-sol``). Without the lift, every tools
6+
consumer (schema compaction, output-shaper stratum, tools token accounting)
7+
sees a tool-less request and records zero tool-schema savings.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import copy
13+
from typing import Any
14+
15+
from headroom.proxy.handlers.openai import (
16+
_compact_openai_responses_tools,
17+
_lift_codex_additional_tools,
18+
)
19+
20+
21+
def _verbose_tool(name: str) -> dict[str, Any]:
22+
return {
23+
"type": "function",
24+
"name": name,
25+
"description": " ".join(["Runs a shell command in the workspace."] * 30),
26+
"parameters": {
27+
"$schema": "http://json-schema.org/draft-07/schema#",
28+
"type": "object",
29+
"title": name,
30+
"properties": {
31+
"command": {
32+
"type": "array",
33+
"title": "command",
34+
"items": {"type": "string"},
35+
}
36+
},
37+
"required": ["command"],
38+
},
39+
}
40+
41+
42+
def _codex_0149_payload() -> dict[str, Any]:
43+
return {
44+
"model": "gpt-5.6-sol",
45+
"include": ["reasoning.encrypted_content"],
46+
"reasoning": {"effort": "low", "context": "all_turns"},
47+
"tool_choice": "auto",
48+
"input": [
49+
{
50+
"type": "message",
51+
"role": "user",
52+
"content": [{"type": "input_text", "text": "do the thing"}],
53+
},
54+
{
55+
"type": "additional_tools",
56+
"tools": [_verbose_tool("shell"), _verbose_tool("update_plan")],
57+
},
58+
],
59+
}
60+
61+
62+
def test_lift_moves_additional_tools_to_top_level() -> None:
63+
payload = _codex_0149_payload()
64+
65+
lifted = _lift_codex_additional_tools(payload)
66+
67+
assert lifted == 2
68+
assert [t["name"] for t in payload["tools"]] == ["shell", "update_plan"]
69+
# The carrier item is dropped; every other input item survives in order.
70+
assert [item["type"] for item in payload["input"]] == ["message"]
71+
72+
73+
def test_lift_concatenates_multiple_carrier_items() -> None:
74+
payload = _codex_0149_payload()
75+
payload["input"].append({"type": "additional_tools", "tools": [_verbose_tool("view_image")]})
76+
77+
lifted = _lift_codex_additional_tools(payload)
78+
79+
assert lifted == 3
80+
assert [t["name"] for t in payload["tools"]] == ["shell", "update_plan", "view_image"]
81+
82+
83+
def test_lift_is_noop_when_top_level_tools_present() -> None:
84+
payload = _codex_0149_payload()
85+
payload["tools"] = [_verbose_tool("shell")]
86+
before = copy.deepcopy(payload)
87+
88+
assert _lift_codex_additional_tools(payload) == 0
89+
assert payload == before
90+
91+
92+
def test_lift_is_noop_without_carrier_items() -> None:
93+
payload = _codex_0149_payload()
94+
payload["input"] = [item for item in payload["input"] if item["type"] != "additional_tools"]
95+
before = copy.deepcopy(payload)
96+
97+
assert _lift_codex_additional_tools(payload) == 0
98+
assert payload == before
99+
100+
assert _lift_codex_additional_tools({"model": "gpt-5.6-sol", "input": "not-a-list"}) == 0
101+
assert _lift_codex_additional_tools("not-a-dict") == 0 # type: ignore[arg-type]
102+
103+
104+
def test_lift_disabled_by_kill_switch(monkeypatch) -> None:
105+
monkeypatch.setenv("HEADROOM_CODEX_ADDITIONAL_TOOLS_LIFT", "0")
106+
payload = _codex_0149_payload()
107+
before = copy.deepcopy(payload)
108+
109+
assert _lift_codex_additional_tools(payload) == 0
110+
assert payload == before
111+
112+
113+
def test_lift_logs_with_request_id(caplog) -> None:
114+
payload = _codex_0149_payload()
115+
116+
with caplog.at_level("INFO", logger="headroom.proxy"):
117+
assert _lift_codex_additional_tools(payload, request_id="req_test") == 2
118+
119+
assert any(
120+
"req_test" in message and "additional_tools" in message for message in caplog.messages
121+
)
122+
123+
124+
def test_lift_preserves_empty_carrier_items() -> None:
125+
payload = _codex_0149_payload()
126+
payload["input"].append({"type": "additional_tools", "tools": []})
127+
128+
lifted = _lift_codex_additional_tools(payload)
129+
130+
# The empty carrier holds no definitions to lift; it is preserved rather
131+
# than invented into an empty top-level array.
132+
assert lifted == 2
133+
assert [item["type"] for item in payload["input"]] == ["message", "additional_tools"]
134+
135+
136+
def test_lifted_tools_reach_schema_compaction() -> None:
137+
payload = _codex_0149_payload()
138+
139+
# Without the lift: compaction sees no tools and returns unmodified —
140+
# the exact production failure.
141+
_, modified, _, _ = _compact_openai_responses_tools(copy.deepcopy(payload))
142+
assert modified is False
143+
144+
_lift_codex_additional_tools(payload)
145+
compacted, modified, before_bytes, after_bytes = _compact_openai_responses_tools(payload)
146+
147+
assert modified is True
148+
assert after_bytes < before_bytes
149+
# Compaction preserves the invocation shape the model needs.
150+
assert [t["name"] for t in compacted["tools"]] == ["shell", "update_plan"]

0 commit comments

Comments
 (0)