mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
fix: wire HEADROOM_EXCLUDE_TOOLS / HEADROOM_TOOL_PROFILES into Click proxy entrypoint (#943)
## Description The Click-based `headroom proxy` entrypoint (`headroom/cli/proxy.py`) constructed `ProxyConfig` without calling `_parse_exclude_tools` or `_parse_tool_profiles`, so `HEADROOM_EXCLUDE_TOOLS` and `HEADROOM_TOOL_PROFILES` were silently ignored for any service launched via `headroom proxy`. The argparse path in `headroom/proxy/server.py` already handled these correctly. This PR imports both helpers into the Click entrypoint and wires their output into `ProxyConfig`. Closes #825 ## 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/cli/proxy.py`: import `_parse_exclude_tools` and `_parse_tool_profiles` alongside `ProxyConfig`/`run_server`; pass their output into the `ProxyConfig(...)` construction (`or None` guard collapses empty set/dict to `None` so unset vars leave `DEFAULT_EXCLUDE_TOOLS` unchanged) - `tests/test_cli_proxy_env.py`: new `TestCLIProxyExcludeToolsEnvVar` class with 5 regression tests ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ### Paste relevant command output or artifact links here ```text ============================= test session starts ============================== platform darwin -- Python 3.13.12, pytest-9.0.3 collected 43 items tests/test_cli_proxy_env.py::TestCLIProxyExcludeToolsEnvVar::test_exclude_tools_single_name_from_env PASSED tests/test_cli_proxy_env.py::TestCLIProxyExcludeToolsEnvVar::test_exclude_tools_multi_name_from_env PASSED tests/test_cli_proxy_env.py::TestCLIProxyExcludeToolsEnvVar::test_exclude_tools_unset_leaves_none PASSED tests/test_cli_proxy_env.py::TestCLIProxyExcludeToolsEnvVar::test_tool_profiles_from_env PASSED tests/test_cli_proxy_env.py::TestCLIProxyExcludeToolsEnvVar::test_tool_profiles_unset_leaves_none PASSED ============================== 43 passed in 8.95s ============================== ruff check headroom/cli/proxy.py tests/test_cli_proxy_env.py All checks passed! ``` ## Real Behavior Proof - Environment: Python 3.13.12, headroom-ai dev install - Exact command / steps: `HEADROOM_EXCLUDE_TOOLS=WebSearch headroom proxy` before fix silently built `ProxyConfig(exclude_tools=None)` despite the env var being set - Observed result: After fix, `ProxyConfig.exclude_tools` contains `{"WebSearch", "websearch"}` as verified by the new unit tests - Not tested: end-to-end proxy run with a live Anthropic 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 - [ ] 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 have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A ## Additional Notes The fix mirrors the exact pattern already used in the argparse path (`_main()` in `headroom/proxy/server.py` lines 3920-3922). The `or None` guard is intentional: `_parse_exclude_tools(None)` returns `set()` when the env var is unset, and `ProxyConfig.exclude_tools=None` means "use `DEFAULT_EXCLUDE_TOOLS` unchanged" — passing an empty set would instead replace the defaults with nothing. Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e0a9fdb62c
commit
9b7b436b04
2 changed files with 125 additions and 1 deletions
|
|
@ -672,7 +672,12 @@ def proxy(
|
||||||
"""
|
"""
|
||||||
# Import here to avoid slow startup
|
# Import here to avoid slow startup
|
||||||
try:
|
try:
|
||||||
from headroom.proxy.server import ProxyConfig, run_server
|
from headroom.proxy.server import (
|
||||||
|
ProxyConfig,
|
||||||
|
_parse_exclude_tools,
|
||||||
|
_parse_tool_profiles,
|
||||||
|
run_server,
|
||||||
|
)
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
click.secho(
|
click.secho(
|
||||||
"Error: Proxy dependencies not installed. Run: pip install headroom-ai[proxy]",
|
"Error: Proxy dependencies not installed. Run: pip install headroom-ai[proxy]",
|
||||||
|
|
@ -786,6 +791,8 @@ def proxy(
|
||||||
compress_user_messages=_get_env_bool("HEADROOM_COMPRESS_USER_MESSAGES", False),
|
compress_user_messages=_get_env_bool("HEADROOM_COMPRESS_USER_MESSAGES", False),
|
||||||
min_tokens_to_crush=_get_env_int_optional("HEADROOM_MIN_TOKENS") or 500,
|
min_tokens_to_crush=_get_env_int_optional("HEADROOM_MIN_TOKENS") or 500,
|
||||||
max_items_after_crush=_get_env_int_optional("HEADROOM_MAX_ITEMS") or 50,
|
max_items_after_crush=_get_env_int_optional("HEADROOM_MAX_ITEMS") or 50,
|
||||||
|
exclude_tools=_parse_exclude_tools(None) or None,
|
||||||
|
tool_profiles=_parse_tool_profiles([]) or None,
|
||||||
smart_crusher_with_compaction=_get_env_bool_optional("HEADROOM_SMART_CRUSHER_COMPACTION"),
|
smart_crusher_with_compaction=_get_env_bool_optional("HEADROOM_SMART_CRUSHER_COMPACTION"),
|
||||||
savings_profile=os.environ.get("HEADROOM_SAVINGS_PROFILE") or None,
|
savings_profile=os.environ.get("HEADROOM_SAVINGS_PROFILE") or None,
|
||||||
target_ratio=_get_env_float_optional("HEADROOM_TARGET_RATIO"),
|
target_ratio=_get_env_float_optional("HEADROOM_TARGET_RATIO"),
|
||||||
|
|
|
||||||
|
|
@ -785,3 +785,120 @@ class TestArgparseBackendValidation:
|
||||||
config = _proxy_config_from_env()
|
config = _proxy_config_from_env()
|
||||||
|
|
||||||
assert config.disable_kompress is True
|
assert config.disable_kompress is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestCLIProxyExcludeToolsEnvVar:
|
||||||
|
"""HEADROOM_EXCLUDE_TOOLS and HEADROOM_TOOL_PROFILES must reach ProxyConfig via the Click path.
|
||||||
|
|
||||||
|
Regression coverage for issue #825: the Click entrypoint (headroom/cli/proxy.py)
|
||||||
|
previously built ProxyConfig without calling _parse_exclude_tools or
|
||||||
|
_parse_tool_profiles, so those env vars were silently ignored for all
|
||||||
|
shared/deployed services that launch via `headroom proxy`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_exclude_tools_single_name_from_env(self, runner):
|
||||||
|
"""HEADROOM_EXCLUDE_TOOLS=WebSearch propagates to ProxyConfig.exclude_tools."""
|
||||||
|
captured_config = {}
|
||||||
|
|
||||||
|
def mock_run_server(config, **kwargs):
|
||||||
|
captured_config["config"] = config
|
||||||
|
|
||||||
|
with patch("headroom.proxy.server.run_server", mock_run_server):
|
||||||
|
result = runner.invoke(
|
||||||
|
main,
|
||||||
|
["proxy"],
|
||||||
|
env={"HEADROOM_EXCLUDE_TOOLS": "WebSearch"},
|
||||||
|
catch_exceptions=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
cfg = captured_config["config"]
|
||||||
|
assert cfg.exclude_tools is not None
|
||||||
|
assert "WebSearch" in cfg.exclude_tools
|
||||||
|
|
||||||
|
def test_exclude_tools_multi_name_from_env(self, runner):
|
||||||
|
"""HEADROOM_EXCLUDE_TOOLS=WebSearch,WebFetch yields both names (and lowercased) in result."""
|
||||||
|
captured_config = {}
|
||||||
|
|
||||||
|
def mock_run_server(config, **kwargs):
|
||||||
|
captured_config["config"] = config
|
||||||
|
|
||||||
|
with patch("headroom.proxy.server.run_server", mock_run_server):
|
||||||
|
result = runner.invoke(
|
||||||
|
main,
|
||||||
|
["proxy"],
|
||||||
|
env={"HEADROOM_EXCLUDE_TOOLS": "WebSearch,WebFetch"},
|
||||||
|
catch_exceptions=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
cfg = captured_config["config"]
|
||||||
|
assert cfg.exclude_tools is not None
|
||||||
|
assert "WebSearch" in cfg.exclude_tools
|
||||||
|
assert "WebFetch" in cfg.exclude_tools
|
||||||
|
assert "websearch" in cfg.exclude_tools
|
||||||
|
assert "webfetch" in cfg.exclude_tools
|
||||||
|
|
||||||
|
def test_exclude_tools_unset_leaves_none(self, runner):
|
||||||
|
"""Without HEADROOM_EXCLUDE_TOOLS, exclude_tools stays None (DEFAULT_EXCLUDE_TOOLS used)."""
|
||||||
|
captured_config = {}
|
||||||
|
|
||||||
|
def mock_run_server(config, **kwargs):
|
||||||
|
captured_config["config"] = config
|
||||||
|
|
||||||
|
env = {k: v for k, v in os.environ.items() if k != "HEADROOM_EXCLUDE_TOOLS"}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("headroom.proxy.server.run_server", mock_run_server),
|
||||||
|
patch.dict(os.environ, env, clear=True),
|
||||||
|
):
|
||||||
|
result = runner.invoke(
|
||||||
|
main,
|
||||||
|
["proxy"],
|
||||||
|
catch_exceptions=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert captured_config["config"].exclude_tools is None
|
||||||
|
|
||||||
|
def test_tool_profiles_from_env(self, runner):
|
||||||
|
"""HEADROOM_TOOL_PROFILES=Grep:conservative propagates to ProxyConfig.tool_profiles."""
|
||||||
|
captured_config = {}
|
||||||
|
|
||||||
|
def mock_run_server(config, **kwargs):
|
||||||
|
captured_config["config"] = config
|
||||||
|
|
||||||
|
with patch("headroom.proxy.server.run_server", mock_run_server):
|
||||||
|
result = runner.invoke(
|
||||||
|
main,
|
||||||
|
["proxy"],
|
||||||
|
env={"HEADROOM_TOOL_PROFILES": "Grep:conservative"},
|
||||||
|
catch_exceptions=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
cfg = captured_config["config"]
|
||||||
|
assert cfg.tool_profiles is not None
|
||||||
|
assert "Grep" in cfg.tool_profiles
|
||||||
|
|
||||||
|
def test_tool_profiles_unset_leaves_none(self, runner):
|
||||||
|
"""Without HEADROOM_TOOL_PROFILES, tool_profiles stays None."""
|
||||||
|
captured_config = {}
|
||||||
|
|
||||||
|
def mock_run_server(config, **kwargs):
|
||||||
|
captured_config["config"] = config
|
||||||
|
|
||||||
|
env = {k: v for k, v in os.environ.items() if k != "HEADROOM_TOOL_PROFILES"}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("headroom.proxy.server.run_server", mock_run_server),
|
||||||
|
patch.dict(os.environ, env, clear=True),
|
||||||
|
):
|
||||||
|
result = runner.invoke(
|
||||||
|
main,
|
||||||
|
["proxy"],
|
||||||
|
catch_exceptions=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert captured_config["config"].tool_profiles is None
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue