diff --git a/CHANGELOG.md b/CHANGELOG.md index dbf607f2f..65df3b38a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **wrap/codex:** `headroom unwrap codex` now removes the Headroom rtk instruction block from the Codex global `AGENTS.md`. `wrap codex` injects it there, but unwrap only restored `config.toml` and MCP state, so a plain `codex` launch kept following the "prefix shell commands with `rtk`" guidance and failed once the managed rtk binary was off PATH. Unwrap now strips the marker-fenced block (preserving the rest of the file), mirroring `unwrap copilot` ([#1421](https://github.com/headroomlabs-ai/headroom/issues/1421)). * **proxy/auth:** classify real Anthropic OAuth tokens correctly. `classify_auth_mode` matched OAuth on the `sk-ant-oat-` prefix, but real access tokens are `sk-ant-oat01-...` (a version number, no dash after `oat`), so every real subscription/OAuth token fell through to the `sk-` branch and was tagged `PAYG` — enabling aggressive lossy compression, auto `cache_control`, and `prompt_cache_key` injection on subscription-bound requests the classifier is meant to route to the passthrough-prefer path. The prefix is now the dash-less `sk-ant-oat` (still matches the legacy dashed shape). The existing parity tests only passed because they used a synthetic `sk-ant-oat-01-` fixture; a regression test now covers the real `sk-ant-oat01-` format. * **install:** stop leaking a file descriptor on every `headroom install start`. `start_detached_agent()` opened the agent log file and handed it to `subprocess.Popen` but never closed the parent's copy, so each call leaked one fd (and pinned the log file open against rotation). The parent now closes its copy in a `try/finally` once the child has inherited it — the close also runs if `Popen` raises ([#1554](https://github.com/headroomlabs-ai/headroom/issues/1554)). +* **cli/proxy:** honor `HEADROOM_MIN_TOKENS=0` / `HEADROOM_MAX_ITEMS=0`. The Click `proxy` command built these with `_get_env_int_optional(name) or 500`/`or 50`, so an explicit `0` — a legitimate value (`min_tokens_to_crush=0` means "crush every item") — was treated as falsy and silently replaced with the default. The `headroom proxy` argparse path already preserved `0` via `_get_env_int`, so the two entry points disagreed. The Click path now uses the same None-checking helper. * **proxy:** include the system prompt, tools, and the response-shaping request fields in the SemanticCache key. `_compute_key` hashed only `{model, messages}`, so two non-streaming requests with identical messages but a different top-level `system` prompt, tool set, sampling config, or output-shaping field collided on one key and the second caller was served the first's cached response — generated under different request semantics, in the default config (`cache_enabled` defaults on). The key now folds the request fields that shape generation — `temperature`/`top_p`/`top_k`/`max_tokens`/`stop`, plus OpenAI `tool_choice`/`response_format`/`parallel_tool_calls`/`seed`/`presence_penalty`/`frequency_penalty`/`logit_bias`/`n`/`logprobs`/`top_logprobs`/`reasoning_effort`/`verbosity`/`modalities` and Anthropic `thinking`/`tool_choice`/`output_config` — canonicalizing `system`/`tools` so a moved `cache_control` breakpoint does not fragment it, and the handlers snapshot the fields once at the cache read and reuse them at write so a body mutated by the pipeline cannot diverge the key. Non-streaming path only. * **learn (verbosity):** `--verbosity --apply --all` now aggregates the savings baseline across every project instead of overwriting it per project (last-project-wins), which previously left the output shaper with a tiny, unrepresentative baseline. The applied verbosity level comes from the project with the most samples ([#1288](https://github.com/headroomlabs-ai/headroom/pull/1288)). * **proxy/anthropic:** restore token-mode compression on continued Claude Code turns with a frozen prefix and deferred CCR tool injection. Token mode now runs request-side compression even when the client did not pre-register `headroom_retrieve`, relying on the existing marker-triggered injection override to keep emitted CCR markers redeemable ([#1487](https://github.com/headroomlabs-ai/headroom/issues/1487)). diff --git a/headroom/cli/proxy.py b/headroom/cli/proxy.py index 64b07ef12..fbce35372 100644 --- a/headroom/cli/proxy.py +++ b/headroom/cli/proxy.py @@ -91,6 +91,18 @@ def _get_env_int_optional(name: str) -> int | None: raise click.ClickException(f"{name} must be an integer, got {val!r}") from None +def _get_env_int(name: str, default: int) -> int: + """Return the env var as an int, or ``default`` only when it is unset. + + Unlike ``_get_env_int_optional(name) or default``, an explicit ``0`` is + preserved — ``0`` is a legitimate value (e.g. ``HEADROOM_MIN_TOKENS=0`` + means "crush every item") and ``0 or default`` would silently discard it. + Mirrors ``headroom.proxy.server._get_env_int``. + """ + value = _get_env_int_optional(name) + return default if value is None else value + + def _get_env_float_optional(name: str) -> float | None: val = os.environ.get(name) if val is None or val == "": @@ -1082,8 +1094,8 @@ def proxy( rate_limit_requests_per_minute=rpm if rpm is not None else 60, rate_limit_tokens_per_minute=tpm if tpm is not None else 100_000, compress_user_messages=_get_env_bool("HEADROOM_COMPRESS_USER_MESSAGES", False), - 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, + min_tokens_to_crush=_get_env_int("HEADROOM_MIN_TOKENS", 500), + max_items_after_crush=_get_env_int("HEADROOM_MAX_ITEMS", 50), exclude_tools=_parse_exclude_tools(None) or None, protect_tool_results=frozenset(_parse_csv_tools(protect_tool_results)) if protect_tool_results diff --git a/tests/test_cli_proxy_env.py b/tests/test_cli_proxy_env.py index b0525ca51..ae1758b32 100644 --- a/tests/test_cli_proxy_env.py +++ b/tests/test_cli_proxy_env.py @@ -254,6 +254,26 @@ class TestCLIProxyEnvVars: assert result.exit_code == 0, result.output assert captured_config["config"].min_tokens_to_crush == 120 + def test_headroom_min_tokens_zero_is_preserved(self, runner): + """HEADROOM_MIN_TOKENS=0 is a legitimate value ("crush everything") and + must not be discarded by an `or 500` fallback (regression).""" + 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_MIN_TOKENS": "0", "HEADROOM_MAX_ITEMS": "0"}, + catch_exceptions=False, + ) + + assert result.exit_code == 0, result.output + assert captured_config["config"].min_tokens_to_crush == 0 + assert captured_config["config"].max_items_after_crush == 0 + def test_headroom_budget_from_env(self, runner): """HEADROOM_BUDGET env var should be passed to ProxyConfig.""" captured_config = {}