diff --git a/headroom/proxy/handlers/openai.py b/headroom/proxy/handlers/openai.py index a6823a075..37bd72fa3 100644 --- a/headroom/proxy/handlers/openai.py +++ b/headroom/proxy/handlers/openai.py @@ -778,6 +778,73 @@ def _compact_openai_responses_tools( return compact_tools(payload) +def _codex_additional_tools_lift_enabled() -> bool: + from headroom.proxy import runtime_env + + return ( + runtime_env.getenv("HEADROOM_CODEX_ADDITIONAL_TOOLS_LIFT", "1") or "1" + ).strip().lower() not in ( + "0", + "false", + "no", + "off", + ) + + +def _lift_codex_additional_tools(payload: dict[str, Any], *, request_id: str | None = None) -> int: + """Lift Codex ``additional_tools`` input items into top-level ``tools``. + + Codex CLI 0.149.0 stopped sending a top-level ``tools`` array on + ``/v1/responses`` for models its capability cache flags (``gpt-5.6-sol``, + its current default): tool definitions ride inside ``input`` as items of + type ``additional_tools``. Every tools consumer downstream -- schema + compaction, the output-shaper stratum, tools token accounting -- reads + only ``payload["tools"]``, so those requests classified "notools" and + recorded zero tool-schema savings while forwarding normally (#3185). + + Mutates *payload* in place: concatenates the items' ``tools`` arrays into + ``payload["tools"]`` and drops the carrier items from ``input``. Returns + the number of lifted tool definitions (0 = no-op). No-op when the payload + already carries top-level tools, so classic-encoding clients are + untouched and a future Codex reverting the change costs nothing. The + classic top-level encoding is accepted upstream for these models -- + Codex <= 0.148 still sends it for ``gpt-5.6-sol`` -- verified against the + live ChatGPT Codex backend with executed tool calls. Disable with + ``HEADROOM_CODEX_ADDITIONAL_TOOLS_LIFT=0``. + """ + if not isinstance(payload, dict) or payload.get("tools"): + return 0 + items = payload.get("input") + if not isinstance(items, list): + return 0 + if not any(isinstance(item, dict) and item.get("type") == "additional_tools" for item in items): + return 0 + if not _codex_additional_tools_lift_enabled(): + return 0 + lifted: list[Any] = [] + kept: list[Any] = [] + for item in items: + if ( + isinstance(item, dict) + and item.get("type") == "additional_tools" + and isinstance(item.get("tools"), list) + and item["tools"] + ): + lifted.extend(item["tools"]) + else: + kept.append(item) + if not lifted: + return 0 + payload["tools"] = lifted + payload["input"] = kept + logger.info( + "[%s] Lifted %d Codex additional_tools definitions to top-level tools", + request_id or "-", + len(lifted), + ) + return len(lifted) + + def _allow_responses_memory_tools(is_chatgpt_auth: bool) -> bool: # Preserve the ChatGPT Codex route's existing store policy and memory-tool # exclusion while API Responses memory continuations stay stateless. @@ -2870,6 +2937,20 @@ class OpenAIHandlerMixin: ) -> tuple[dict[str, Any], bool, int, list[str], str | None, int, int, int, dict[str, float]]: timing: dict[str, float] = {} + # Codex >= 0.149.0 nests tool definitions in `input` items of type + # additional_tools; normalize to the classic top-level array before + # shaping/compression so every downstream tools consumer engages. + # Runs once per pass, ahead of the executor closure, and never breaks + # forwarding. + try: + _lift_codex_additional_tools(payload, request_id=request_id) + except Exception: # pragma: no cover - defensive; never break forwarding + logger.warning( + "[%s] additional_tools lift failed; continuing unlifted", + request_id, + exc_info=True, + ) + def _compress(): # noqa: ANN202 # Output shaping (opt-in via HEADROOM_OUTPUT_SHAPER) runs before # compression so the turn classifier sees the client's input as diff --git a/tests/test_openai_responses_additional_tools.py b/tests/test_openai_responses_additional_tools.py new file mode 100644 index 000000000..3667c969d --- /dev/null +++ b/tests/test_openai_responses_additional_tools.py @@ -0,0 +1,150 @@ +"""Codex >= 0.149.0 ``additional_tools`` normalization (#3185). + +Codex CLI 0.149.0 sends tool definitions as ``input`` items of type +``additional_tools`` instead of a top-level ``tools`` array for models its +capability cache flags (``gpt-5.6-sol``). Without the lift, every tools +consumer (schema compaction, output-shaper stratum, tools token accounting) +sees a tool-less request and records zero tool-schema savings. +""" + +from __future__ import annotations + +import copy +from typing import Any + +from headroom.proxy.handlers.openai import ( + _compact_openai_responses_tools, + _lift_codex_additional_tools, +) + + +def _verbose_tool(name: str) -> dict[str, Any]: + return { + "type": "function", + "name": name, + "description": " ".join(["Runs a shell command in the workspace."] * 30), + "parameters": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": name, + "properties": { + "command": { + "type": "array", + "title": "command", + "items": {"type": "string"}, + } + }, + "required": ["command"], + }, + } + + +def _codex_0149_payload() -> dict[str, Any]: + return { + "model": "gpt-5.6-sol", + "include": ["reasoning.encrypted_content"], + "reasoning": {"effort": "low", "context": "all_turns"}, + "tool_choice": "auto", + "input": [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "do the thing"}], + }, + { + "type": "additional_tools", + "tools": [_verbose_tool("shell"), _verbose_tool("update_plan")], + }, + ], + } + + +def test_lift_moves_additional_tools_to_top_level() -> None: + payload = _codex_0149_payload() + + lifted = _lift_codex_additional_tools(payload) + + assert lifted == 2 + assert [t["name"] for t in payload["tools"]] == ["shell", "update_plan"] + # The carrier item is dropped; every other input item survives in order. + assert [item["type"] for item in payload["input"]] == ["message"] + + +def test_lift_concatenates_multiple_carrier_items() -> None: + payload = _codex_0149_payload() + payload["input"].append({"type": "additional_tools", "tools": [_verbose_tool("view_image")]}) + + lifted = _lift_codex_additional_tools(payload) + + assert lifted == 3 + assert [t["name"] for t in payload["tools"]] == ["shell", "update_plan", "view_image"] + + +def test_lift_is_noop_when_top_level_tools_present() -> None: + payload = _codex_0149_payload() + payload["tools"] = [_verbose_tool("shell")] + before = copy.deepcopy(payload) + + assert _lift_codex_additional_tools(payload) == 0 + assert payload == before + + +def test_lift_is_noop_without_carrier_items() -> None: + payload = _codex_0149_payload() + payload["input"] = [item for item in payload["input"] if item["type"] != "additional_tools"] + before = copy.deepcopy(payload) + + assert _lift_codex_additional_tools(payload) == 0 + assert payload == before + + assert _lift_codex_additional_tools({"model": "gpt-5.6-sol", "input": "not-a-list"}) == 0 + assert _lift_codex_additional_tools("not-a-dict") == 0 # type: ignore[arg-type] + + +def test_lift_disabled_by_kill_switch(monkeypatch) -> None: + monkeypatch.setenv("HEADROOM_CODEX_ADDITIONAL_TOOLS_LIFT", "0") + payload = _codex_0149_payload() + before = copy.deepcopy(payload) + + assert _lift_codex_additional_tools(payload) == 0 + assert payload == before + + +def test_lift_logs_with_request_id(caplog) -> None: + payload = _codex_0149_payload() + + with caplog.at_level("INFO", logger="headroom.proxy"): + assert _lift_codex_additional_tools(payload, request_id="req_test") == 2 + + assert any( + "req_test" in message and "additional_tools" in message for message in caplog.messages + ) + + +def test_lift_preserves_empty_carrier_items() -> None: + payload = _codex_0149_payload() + payload["input"].append({"type": "additional_tools", "tools": []}) + + lifted = _lift_codex_additional_tools(payload) + + # The empty carrier holds no definitions to lift; it is preserved rather + # than invented into an empty top-level array. + assert lifted == 2 + assert [item["type"] for item in payload["input"]] == ["message", "additional_tools"] + + +def test_lifted_tools_reach_schema_compaction() -> None: + payload = _codex_0149_payload() + + # Without the lift: compaction sees no tools and returns unmodified — + # the exact production failure. + _, modified, _, _ = _compact_openai_responses_tools(copy.deepcopy(payload)) + assert modified is False + + _lift_codex_additional_tools(payload) + compacted, modified, before_bytes, after_bytes = _compact_openai_responses_tools(payload) + + assert modified is True + assert after_bytes < before_bytes + # Compaction preserves the invocation shape the model needs. + assert [t["name"] for t in compacted["tools"]] == ["shell", "update_plan"]