mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
fix(proxy/openai): run tool-description compaction on chat-completions (#2741)
## Description
`HEADROOM_TOOL_DESC_MAX_CHARS` was wired into the Anthropic handler and
the Responses (Codex) handler, but never into **chat-completions** — so
the env var was a silent no-op for every chat client: opencode, Cline,
Aider, Roo, anything routed through LiteLLM.
Tool descriptions live on the `tools` array, which the message pipeline
never inspects, so no other pass was covering them.
Closes #
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
## Changes Made
- Run the L2 tool-description pass on the chat-completions path,
mirroring the block the Anthropic and Responses handlers already had.
- `compact_tool_descriptions` already walks both wire shapes — nested
`{"function": {"description": ...}}` for chat, flat for Responses — so
this is wiring, not a new codec.
- Chains after the existing schema compaction, seeding the token
"before" count only when that pass didn't, so the two compose instead of
double-counting.
- Labelled `openai:chat:tool_desc_compaction`, distinct from the
Anthropic and Responses labels so `headroom perf --by-transform` can
attribute it.
- Still opt-in and off by default: an unset env var leaves the tools
array — and therefore its cache prefix — byte-identical.
### Scope note: two adjacent "gaps" that turned out not to be
While surveying handler parity I flagged three missing chat-completions
transforms. Only one was real; recording the other two so nobody
re-opens them:
- **`tool_search_deferral` — correctly absent.** `{"type":
"tool_search"}` and `defer_loading` are Responses-API constructs, and
`_model_supports_openai_tool_search` gates them to `gpt-5.4+`. Injecting
that shape into a chat-completions request would be invalid, not an
improvement.
- **`system_prompt_compaction` — not applicable.** Anthropic needs a
dedicated pass because `system` is an out-of-band top-level field the
message pipeline never sees. On chat-completions the system prompt *is*
`messages[0]`, so it already reaches ContentRouter and is governed by
the existing `compress_system_messages` / `skip_system` gate. Wiring a
second path there would change system-prefix cache behavior for no new
coverage.
## 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
$ .venv/bin/ruff check headroom/ tests/test_openai_chat_tool_desc_compaction.py --exclude headroom/dashboard/templates
All checks passed!
$ .venv/bin/mypy headroom/
Success: no issues found in 508 source files
$ python -m pytest tests/test_openai_chat_tool_desc_compaction.py tests/test_tool_schema_compaction.py \
tests/test_proxy_openai_cache_stability.py tests/test_openai_responses_context_compaction.py -q
49 passed in 16.59s
```
## Real Behavior Proof
- **Environment:** macOS 26.4 arm64, Python 3.12.6, repo `.venv`.
- **Exact command / steps:** ran `compact_tool_descriptions` at
`HEADROOM_TOOL_DESC_MAX_CHARS=30` against both wire shapes with the same
tool (a `read` tool with an 86-char description and a described `path`
param).
- **Observed result:**
```text
chat-completions (nested) modified=True bytes 272->215
responses (flat) modified=True bytes 259->202
```
Chat previously reported `modified=False` from the handler because the
pass was never invoked at all.
- **Not tested:** no live chat-completions request against a real
provider — the handler block is a thin adapter over
`compact_tool_descriptions`, and the regression was a missing *call*,
which the wiring test catches at source level. A full end-to-end drive
would need an upstream endpoint.
## 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`
🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
parent
224578e80b
commit
f9db5b5060
2 changed files with 166 additions and 0 deletions
|
|
@ -3475,6 +3475,46 @@ class OpenAIHandlerMixin:
|
|||
except Exception as e:
|
||||
logger.debug(f"[{request_id}] tool schema compaction failed: {e}")
|
||||
|
||||
# Layer 2: tool description truncation (opt-in via
|
||||
# HEADROOM_TOOL_DESC_MAX_CHARS). The Anthropic and Responses handlers
|
||||
# both ran this pass; chat-completions never did, so the env var was a
|
||||
# silent no-op for every chat client (opencode, Cline, Aider, Roo,
|
||||
# LiteLLM-routed). Tool descriptions live on the tools array, which the
|
||||
# message pipeline never sees, so nothing else was covering them.
|
||||
# `compact_tool_descriptions` walks both the nested chat shape
|
||||
# ({"function": {"description": ...}}) and the flat Responses shape.
|
||||
try:
|
||||
from headroom.proxy.tool_schema_compaction import (
|
||||
compact_tool_descriptions,
|
||||
tool_desc_max_chars,
|
||||
)
|
||||
|
||||
_desc_max = tool_desc_max_chars()
|
||||
if _desc_max > 0:
|
||||
_desc_payload, _desc_modified, _desc_before, _desc_after = (
|
||||
compact_tool_descriptions({"tools": tools}, _desc_max)
|
||||
)
|
||||
if _desc_modified and _desc_payload.get("tools") is not None:
|
||||
# Seed "before" only if schema compaction above didn't; the
|
||||
# two passes chain, so "after" must track the latest tools.
|
||||
if not tool_tokens_before_compaction:
|
||||
tool_tokens_before_compaction = tokenizer.count_text(
|
||||
_json_debug_dumps(tools)
|
||||
)
|
||||
tools = _desc_payload["tools"]
|
||||
transforms_applied.append("openai:chat:tool_desc_compaction")
|
||||
logger.debug(
|
||||
"[%s] tool description compaction: %d -> %d bytes "
|
||||
"(%.0f%% saved, max_chars=%d)",
|
||||
request_id,
|
||||
_desc_before,
|
||||
_desc_after,
|
||||
(1 - _desc_after / max(_desc_before, 1)) * 100,
|
||||
_desc_max,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"[{request_id}] tool desc compaction failed: {e}")
|
||||
|
||||
body["messages"] = optimized_messages
|
||||
if tools or _original_tools is not None:
|
||||
body["tools"] = tools
|
||||
|
|
|
|||
126
tests/test_openai_chat_tool_desc_compaction.py
Normal file
126
tests/test_openai_chat_tool_desc_compaction.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
"""Tool-description compaction must run on chat-completions, not just Anthropic/Responses.
|
||||
|
||||
``HEADROOM_TOOL_DESC_MAX_CHARS`` was wired into the Anthropic handler and the
|
||||
Responses (Codex) handler but never into chat-completions, so the env var was a
|
||||
silent no-op for every chat client — opencode, Cline, Aider, Roo, anything routed
|
||||
through LiteLLM. Tool descriptions live on the ``tools`` array, which the message
|
||||
pipeline never inspects, so no other pass was covering them.
|
||||
|
||||
These tests pin the shape handling and the opt-in gate rather than driving the
|
||||
whole handler: the handler block is a thin adapter over
|
||||
``compact_tool_descriptions``, and the thing that actually broke was that nobody
|
||||
called it with the chat-shaped payload.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
import headroom.proxy.tool_schema_compaction as tsc
|
||||
from headroom.proxy.tool_schema_compaction import compact_tool_descriptions, tool_desc_max_chars
|
||||
|
||||
_LONG_DESC = "Reads a file from disk and returns the full text content as a string, with numbers."
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_desc_cache():
|
||||
"""The max-chars lookup is process-cached; clear it around each test."""
|
||||
tsc._TOOL_DESC_MAX_CHARS = None
|
||||
yield
|
||||
tsc._TOOL_DESC_MAX_CHARS = None
|
||||
|
||||
|
||||
def _chat_tools() -> list[dict]:
|
||||
"""chat-completions shape: name/description nested under "function"."""
|
||||
return [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read",
|
||||
"description": _LONG_DESC,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"path": {"type": "string", "description": "File path to read"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def _responses_tools() -> list[dict]:
|
||||
"""Responses shape: name/description flat on the tool."""
|
||||
return [
|
||||
{
|
||||
"type": "function",
|
||||
"name": "read",
|
||||
"description": _LONG_DESC,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"path": {"type": "string", "description": "File path to read"}},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_compacts_the_nested_chat_completions_tool_shape(monkeypatch):
|
||||
"""The shape the chat handler passes — the one that was never being compacted."""
|
||||
monkeypatch.setenv("HEADROOM_TOOL_DESC_MAX_CHARS", "30")
|
||||
|
||||
payload, modified, before, after = compact_tool_descriptions(
|
||||
{"tools": _chat_tools()}, tool_desc_max_chars()
|
||||
)
|
||||
|
||||
assert modified is True
|
||||
assert after < before
|
||||
desc = payload["tools"][0]["function"]["description"]
|
||||
assert len(desc) <= len(_LONG_DESC)
|
||||
assert desc != _LONG_DESC
|
||||
|
||||
|
||||
def test_both_wire_shapes_are_handled(monkeypatch):
|
||||
"""One helper serves both handlers, so chat needed wiring — not a new codec."""
|
||||
monkeypatch.setenv("HEADROOM_TOOL_DESC_MAX_CHARS", "30")
|
||||
max_chars = tool_desc_max_chars()
|
||||
|
||||
_, chat_modified, chat_before, chat_after = compact_tool_descriptions(
|
||||
{"tools": _chat_tools()}, max_chars
|
||||
)
|
||||
_, resp_modified, resp_before, resp_after = compact_tool_descriptions(
|
||||
{"tools": _responses_tools()}, max_chars
|
||||
)
|
||||
|
||||
assert chat_modified is resp_modified is True
|
||||
assert chat_after < chat_before
|
||||
assert resp_after < resp_before
|
||||
|
||||
|
||||
def test_disabled_by_default_leaves_tools_untouched():
|
||||
"""Opt-in only: an unset env var must not perturb the tools prefix or its cache."""
|
||||
tools = _chat_tools()
|
||||
assert tool_desc_max_chars() == 0
|
||||
|
||||
payload, modified, before, after = compact_tool_descriptions(
|
||||
{"tools": tools}, tool_desc_max_chars()
|
||||
)
|
||||
|
||||
assert modified is False
|
||||
assert payload["tools"] == tools
|
||||
assert (before, after) == (0, 0)
|
||||
|
||||
|
||||
def test_chat_handler_calls_the_desc_pass(monkeypatch):
|
||||
"""Guard the wiring itself: the handler source must invoke the L2 pass.
|
||||
|
||||
ponytail: source-level check, not a live handler drive — spinning the full
|
||||
chat-completions path needs an upstream, and the regression here was a missing
|
||||
CALL, which is exactly what this catches.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
from headroom.proxy.handlers import openai as openai_handler
|
||||
|
||||
source = inspect.getsource(openai_handler)
|
||||
assert "openai:chat:tool_desc_compaction" in source
|
||||
# The Anthropic and Responses handlers already had their own labels; make sure
|
||||
# the chat one is distinct so `headroom perf --by-transform` can attribute it.
|
||||
assert "openai:responses:tool_desc_compaction" in source
|
||||
Loading…
Add table
Add a link
Reference in a new issue