From ff17961cd76a7cea1cff0a9dcfb7338929f37c5a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:38:08 -0500 Subject: [PATCH 01/53] deps: bump ruff from 0.15.22 to 0.16.2 in the pip-minor-patch group across 1 directory (#2962) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the pip-minor-patch group with 1 update in the / directory: [ruff](https://github.com/astral-sh/ruff). Updates `ruff` from 0.15.22 to 0.16.2
Release notes

Sourced from ruff's releases.

0.16.2

Release Notes

Released on 2026-08-06.

Bug fixes

Server

Contributors

Install ruff 0.16.2

Install prebuilt binaries via shell script

curl --proto '=https' --tlsv1.2 -LsSf
https://releases.astral.sh/github/ruff/releases/download/0.16.2/ruff-installer.sh
| sh

Install prebuilt binaries via powershell script

powershell -ExecutionPolicy Bypass -c "irm
https://releases.astral.sh/github/ruff/releases/download/0.16.2/ruff-installer.ps1
| iex"

Download ruff 0.16.2

File Platform Checksum
ruff-aarch64-apple-darwin.tar.gz Apple Silicon macOS checksum
ruff-x86_64-apple-darwin.tar.gz Intel macOS checksum
ruff-aarch64-pc-windows-msvc.zip ARM64 Windows checksum
ruff-i686-pc-windows-msvc.zip x86 Windows checksum
ruff-x86_64-pc-windows-msvc.zip x64 Windows checksum
ruff-aarch64-unknown-linux-gnu.tar.gz ARM64 Linux checksum
ruff-i686-unknown-linux-gnu.tar.gz x86 Linux checksum
ruff-powerpc64-unknown-linux-gnu.tar.gz PPC64 Linux checksum
ruff-powerpc64le-unknown-linux-gnu.tar.gz PPC64LE Linux checksum
ruff-riscv64gc-unknown-linux-gnu.tar.gz RISCV Linux checksum
ruff-s390x-unknown-linux-gnu.tar.gz S390x Linux checksum

... (truncated)

Changelog

Sourced from ruff's changelog.

0.16.2

Released on 2026-08-06.

Bug fixes

Server

Contributors

0.16.1

Released on 2026-07-30.

Preview features

Bug fixes

Rule changes

Server

... (truncated)

Commits

--------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: JerrettDavis --- .pre-commit-config.yaml | 2 +- headroom/testing/README.md | 6 +- pyproject.toml | 2 +- uv.lock | 236 ++++++++++++++-------------- wiki/ARCHITECTURE.md | 67 ++++---- wiki/agno.md | 2 + wiki/api.md | 16 +- wiki/compression.md | 77 ++++----- wiki/configuration.md | 17 +- wiki/errors.md | 33 ++-- wiki/image-compression.md | 38 +++-- wiki/integration-guide.md | 25 +-- wiki/langchain.md | 137 +++++++++------- wiki/memory.md | 79 +++++----- wiki/metrics.md | 8 +- wiki/plans/dynamic-smart-crusher.md | 118 +++++--------- wiki/proxy.md | 17 +- wiki/quickstart.md | 24 +-- wiki/sdk.md | 30 ++-- wiki/shared-context.md | 40 ++--- wiki/strands.md | 2 + wiki/text-compression.md | 10 +- wiki/transforms.md | 56 +++---- wiki/troubleshooting.md | 3 + 24 files changed, 526 insertions(+), 519 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 08ee4e215..58d46b666 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,7 +27,7 @@ repos: # unconditionally, so installing hooks is not required for enforcement. args: [--assume-in-merge] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.22 + rev: v0.16.2 hooks: - id: ruff args: [--fix] diff --git a/headroom/testing/README.md b/headroom/testing/README.md index 7b9ee7f99..37c398669 100644 --- a/headroom/testing/README.md +++ b/headroom/testing/README.md @@ -33,7 +33,11 @@ assert report.passed suite = ( Headroom.Suite("phase-1") .Add(Headroom.WithOpenAI().named("openai-cache").WithCompression(mode="cache")) - .Add(Headroom.WithBedrock(region="us-east-1").named("bedrock-token").WithCompression(mode="token")) + .Add( + Headroom.WithBedrock(region="us-east-1") + .named("bedrock-token") + .WithCompression(mode="token") + ) ) suite.write_manifest_bundle("headroom-testing-bundle.json", provider="openai", port_start=19000) diff --git a/pyproject.toml b/pyproject.toml index e36dca4ae..c4450a1e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -277,7 +277,7 @@ dev = [ "pytest>=7.0.0", "pytest-cov>=4.0.0", "pytest-asyncio>=0.21.0", - "ruff==0.15.22", + "ruff==0.16.2", "mypy>=1.0.0", "pre-commit>=3.0.0", "openai>=1.0.0", diff --git a/uv.lock b/uv.lock index edd0b3646..45feb1dd2 100644 --- a/uv.lock +++ b/uv.lock @@ -280,12 +280,12 @@ name = "any-llm-sdk" version = "1.12.1" source = { registry = "https://pypi.org/simple/" } dependencies = [ - { name = "httpx" }, - { name = "openai" }, - { name = "openresponses-types" }, - { name = "pydantic" }, - { name = "rich" }, - { name = "typing-extensions" }, + { name = "httpx", marker = "python_full_version >= '3.11'" }, + { name = "openai", marker = "python_full_version >= '3.11'" }, + { name = "openresponses-types", marker = "python_full_version >= '3.11'" }, + { name = "pydantic", marker = "python_full_version >= '3.11'" }, + { name = "rich", marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/21/d5/21ef27d031b72f0054ba0015a66cc1f10cda9410e4a3acb6e795d848ad0d/any_llm_sdk-1.12.1.tar.gz", hash = "sha256:76e043fcaa56fccfb375a908511869dc7dbf393dd97a82d06bb764d8201724e8", size = 152078, upload-time = "2026-03-18T13:13:01.735Z" } wheels = [ @@ -811,7 +811,7 @@ name = "coloredlogs" version = "15.0.1" source = { registry = "https://pypi.org/simple/" } dependencies = [ - { name = "humanfriendly" }, + { name = "humanfriendly", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" } wheels = [ @@ -823,7 +823,7 @@ name = "colorlog" version = "6.10.1" source = { registry = "https://pypi.org/simple/" } dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "colorama", marker = "python_full_version >= '3.13' and sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a2/61/f083b5ac52e505dfc1c624eafbf8c7589a0d7f32daa398d2e7590efa5fda/colorlog-6.10.1.tar.gz", hash = "sha256:eb4ae5cb65fe7fec7773c2306061a8e63e02efc2c72eba9d27b0fa23c94f1321", size = 17162, upload-time = "2025-10-16T16:14:11.978Z" } wheels = [ @@ -1045,7 +1045,7 @@ name = "cuda-bindings" version = "13.3.1" source = { registry = "https://pypi.org/simple/" } dependencies = [ - { name = "cuda-pathfinder" }, + { name = "cuda-pathfinder", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/a9/21/8464d133752951c154feafb3b65c297e7d80f301183d220bec4c830f1441/cuda_bindings-13.3.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:120fcc53d57903df529c3486962c56528cba5b7d6c57c99537320ed9922c8b86", size = 6073403, upload-time = "2026-05-29T23:11:36.22Z" }, @@ -1080,43 +1080,43 @@ wheels = [ [package.optional-dependencies] cublas = [ - { name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cublas", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cuda-nvrtc", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cudart = [ - { name = "nvidia-cuda-runtime", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cuda-runtime", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cufft = [ - { name = "nvidia-cufft", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cufft", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cufile = [ - { name = "nvidia-cufile", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cufile", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cupti = [ - { name = "nvidia-cuda-cupti", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cuda-cupti", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] curand = [ - { name = "nvidia-curand", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-curand", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cusolver = [ - { name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-cusolver", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cublas", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cusolver", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cusparse", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cusparse = [ - { name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cusparse", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] nvjitlink = [ - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] nvrtc = [ - { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cuda-nvrtc", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] nvtx = [ - { name = "nvidia-nvtx", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvtx", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] [[package]] @@ -1220,7 +1220,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple/" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1637,7 +1637,7 @@ name = "gunicorn" version = "26.0.0" source = { registry = "https://pypi.org/simple/" } dependencies = [ - { name = "packaging" }, + { name = "packaging", marker = "python_full_version < '3.11' or sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286, upload-time = "2026-05-05T06:38:25.529Z" } wheels = [ @@ -1988,7 +1988,7 @@ requires-dist = [ { name = "rapidocr-onnxruntime", marker = "python_full_version < '3.13' and extra == 'image'", specifier = ">=1.4.0,<2" }, { name = "respx", marker = "extra == 'dev'", specifier = ">=0.20.0" }, { name = "rich", specifier = ">=13.0.0" }, - { name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.22" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "==0.16.2" }, { name = "scikit-learn", marker = "extra == 'evals'", specifier = ">=1.3.0" }, { name = "sentence-transformers", marker = "sys_platform == 'darwin' and extra == 'pytorch-mps'", specifier = ">=2.2.0" }, { name = "sentence-transformers", marker = "(platform_machine != 'x86_64' and extra == 'evals') or (sys_platform != 'darwin' and extra == 'evals')", specifier = ">=2.2.0,<6.0" }, @@ -2207,7 +2207,7 @@ name = "humanfriendly" version = "10.0" source = { registry = "https://pypi.org/simple/" } dependencies = [ - { name = "pyreadline3", marker = "sys_platform == 'win32'" }, + { name = "pyreadline3", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" } wheels = [ @@ -2690,18 +2690,18 @@ name = "litellm" version = "1.88.1" source = { registry = "https://pypi.org/simple/" } dependencies = [ - { name = "aiohttp" }, - { name = "click" }, - { name = "fastuuid" }, - { name = "httpx" }, - { name = "importlib-metadata" }, - { name = "jinja2" }, - { name = "jsonschema" }, - { name = "openai" }, - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "tiktoken" }, - { name = "tokenizers" }, + { name = "aiohttp", marker = "python_full_version < '3.14'" }, + { name = "click", marker = "python_full_version < '3.14'" }, + { name = "fastuuid", marker = "python_full_version < '3.14'" }, + { name = "httpx", marker = "python_full_version < '3.14'" }, + { name = "importlib-metadata", marker = "python_full_version < '3.14'" }, + { name = "jinja2", marker = "python_full_version < '3.14'" }, + { name = "jsonschema", marker = "python_full_version < '3.14'" }, + { name = "openai", marker = "python_full_version < '3.14'" }, + { name = "pydantic", marker = "python_full_version < '3.14'" }, + { name = "python-dotenv", marker = "python_full_version < '3.14'" }, + { name = "tiktoken", marker = "python_full_version < '3.14'" }, + { name = "tokenizers", marker = "python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/16/ea/f99ececb7f22703fe120f1d8be9ffb749ec9453fbbbbbebc0d6a6b4d7864/litellm-1.88.1.tar.gz", hash = "sha256:89c6b74cc7912d6365793006ff951c0450fe847625008dfe49de8a7dc4529aa5", size = 13885969, upload-time = "2026-06-09T01:06:25.192Z" } wheels = [ @@ -3583,7 +3583,7 @@ name = "nvidia-cublas" version = "13.1.1.3" source = { registry = "https://pypi.org/simple/" } dependencies = [ - { name = "nvidia-cuda-nvrtc" }, + { name = "nvidia-cuda-nvrtc", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, @@ -3622,7 +3622,7 @@ name = "nvidia-cudnn-cu13" version = "9.20.0.48" source = { registry = "https://pypi.org/simple/" } dependencies = [ - { name = "nvidia-cublas" }, + { name = "nvidia-cublas", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, @@ -3634,7 +3634,7 @@ name = "nvidia-cufft" version = "12.0.0.61" source = { registry = "https://pypi.org/simple/" } dependencies = [ - { name = "nvidia-nvjitlink" }, + { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, @@ -3664,9 +3664,9 @@ name = "nvidia-cusolver" version = "12.0.4.66" source = { registry = "https://pypi.org/simple/" } dependencies = [ - { name = "nvidia-cublas" }, - { name = "nvidia-cusparse" }, - { name = "nvidia-nvjitlink" }, + { name = "nvidia-cublas", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "nvidia-cusparse", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, @@ -3678,7 +3678,7 @@ name = "nvidia-cusparse" version = "12.6.3.3" source = { registry = "https://pypi.org/simple/" } dependencies = [ - { name = "nvidia-nvjitlink" }, + { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, @@ -3757,8 +3757,8 @@ name = "omegaconf" version = "2.3.0" source = { registry = "https://pypi.org/simple/" } dependencies = [ - { name = "antlr4-python3-runtime" }, - { name = "pyyaml" }, + { name = "antlr4-python3-runtime", marker = "python_full_version >= '3.13'" }, + { name = "pyyaml", marker = "python_full_version >= '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/09/48/6388f1bb9da707110532cb70ec4d2822858ddfb44f1cdf1233c20a80ea4b/omegaconf-2.3.0.tar.gz", hash = "sha256:d5d4b6d29955cc50ad50c46dc269bcd92c6e00f5f90d23ab5fee7bfca4ba4cc7", size = 3298120, upload-time = "2022-12-08T20:59:22.753Z" } wheels = [ @@ -3773,12 +3773,12 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "coloredlogs" }, - { name = "flatbuffers" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple/" } }, - { name = "packaging" }, - { name = "protobuf" }, - { name = "sympy" }, + { name = "coloredlogs", marker = "python_full_version < '3.11'" }, + { name = "flatbuffers", marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple/" }, marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "protobuf", marker = "python_full_version < '3.11'" }, + { name = "sympy", marker = "python_full_version < '3.11'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/35/d6/311b1afea060015b56c742f3531168c1644650767f27ef40062569960587/onnxruntime-1.23.2-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:a7730122afe186a784660f6ec5807138bf9d792fa1df76556b27307ea9ebcbe3", size = 17195934, upload-time = "2025-10-27T23:06:14.143Z" }, @@ -3824,10 +3824,10 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "flatbuffers" }, - { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple/" } }, - { name = "packaging" }, - { name = "protobuf" }, + { name = "flatbuffers", marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple/" }, marker = "python_full_version >= '3.11'" }, + { name = "packaging", marker = "python_full_version >= '3.11'" }, + { name = "protobuf", marker = "python_full_version >= '3.11'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/d4/81/29a9eb470994a75eb7b3ccf32be314d7c66675a00ac7b50294816cc2db27/onnxruntime-1.26.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ee1109ef4ef27cad90e823399e61e03b3c6c7bfe0fb820b4baf3678c15be8b3c", size = 18005108, upload-time = "2026-05-08T19:08:11.728Z" }, @@ -3911,7 +3911,7 @@ name = "openresponses-types" version = "2.3.0.post1" source = { registry = "https://pypi.org/simple/" } dependencies = [ - { name = "pydantic" }, + { name = "pydantic", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d9/26/b612c3215f5599714fa94d63eb5ee59b4eb66dbdeeaf86bb4d848359484d/openresponses_types-2.3.0.post1.tar.gz", hash = "sha256:11b8896d3621d2ac2439f6ff106f34ddcb1bbd517c317a6c852a9df2e98a0753", size = 19254, upload-time = "2026-01-22T20:02:03.933Z" } wheels = [ @@ -4154,10 +4154,10 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple/" } }, - { name = "python-dateutil" }, - { name = "pytz" }, - { name = "tzdata" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple/" }, marker = "python_full_version < '3.11'" }, + { name = "python-dateutil", marker = "python_full_version < '3.11'" }, + { name = "pytz", marker = "python_full_version < '3.11'" }, + { name = "tzdata", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } wheels = [ @@ -4229,9 +4229,9 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple/" } }, - { name = "python-dateutil" }, - { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, + { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple/" }, marker = "python_full_version >= '3.11'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, + { name = "tzdata", marker = "(python_full_version >= '3.11' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/de/da/b1dc0481ab8d55d0f46e343cfe67d4551a0e14fcee52bd38ca1bd73258d8/pandas-3.0.0.tar.gz", hash = "sha256:0facf7e87d38f721f0af46fe70d97373a37701b1c09f7ed7aeeb292ade5c050f", size = 4633005, upload-time = "2026-01-21T15:52:04.726Z" } wheels = [ @@ -5393,17 +5393,17 @@ name = "rapidocr" version = "3.8.1" source = { registry = "https://pypi.org/simple/" } dependencies = [ - { name = "colorlog" }, - { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple/" } }, - { name = "omegaconf" }, - { name = "opencv-python" }, - { name = "pillow" }, - { name = "pyclipper" }, - { name = "pyyaml" }, - { name = "requests" }, - { name = "shapely" }, - { name = "six" }, - { name = "tqdm" }, + { name = "colorlog", marker = "python_full_version >= '3.13'" }, + { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple/" }, marker = "python_full_version >= '3.13'" }, + { name = "omegaconf", marker = "python_full_version >= '3.13'" }, + { name = "opencv-python", marker = "python_full_version >= '3.13'" }, + { name = "pillow", marker = "python_full_version >= '3.13'" }, + { name = "pyclipper", marker = "python_full_version >= '3.13'" }, + { name = "pyyaml", marker = "python_full_version >= '3.13'" }, + { name = "requests", marker = "python_full_version >= '3.13'" }, + { name = "shapely", marker = "python_full_version >= '3.13'" }, + { name = "six", marker = "python_full_version >= '3.13'" }, + { name = "tqdm", marker = "python_full_version >= '3.13'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/ea/4a/fa521d947f0fc7bb304bf11bec4cb66266bd81494588b4cb48dc01001719/rapidocr-3.8.1-py3-none-any.whl", hash = "sha256:650044b1fbce9e6bae5cae462dcf8be754cde11e2f23fc51f65dcc08deae2c46", size = 15080319, upload-time = "2026-04-11T07:13:22.56Z" }, @@ -5414,17 +5414,17 @@ name = "rapidocr-onnxruntime" version = "1.4.4" source = { registry = "https://pypi.org/simple/" } dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple/" }, marker = "python_full_version < '3.11' or python_full_version >= '3.13'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple/" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple/" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, - { name = "onnxruntime", version = "1.23.2", source = { registry = "https://pypi.org/simple/" }, marker = "python_full_version < '3.11' or python_full_version >= '3.13'" }, + { name = "onnxruntime", version = "1.23.2", source = { registry = "https://pypi.org/simple/" }, marker = "python_full_version < '3.11'" }, { name = "onnxruntime", version = "1.26.0", source = { registry = "https://pypi.org/simple/" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, - { name = "opencv-python" }, - { name = "pillow" }, - { name = "pyclipper" }, - { name = "pyyaml" }, - { name = "shapely" }, - { name = "six" }, - { name = "tqdm" }, + { name = "opencv-python", marker = "python_full_version < '3.13'" }, + { name = "pillow", marker = "python_full_version < '3.13'" }, + { name = "pyclipper", marker = "python_full_version < '3.13'" }, + { name = "pyyaml", marker = "python_full_version < '3.13'" }, + { name = "shapely", marker = "python_full_version < '3.13'" }, + { name = "six", marker = "python_full_version < '3.13'" }, + { name = "tqdm", marker = "python_full_version < '3.13'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/ba/12/1e5497183bdbe782dbb91bad1d0d2297dba4d2831b2652657f7517bfc6df/rapidocr_onnxruntime-1.4.4-py3-none-any.whl", hash = "sha256:971d7d5f223a7a808662229df1ef69893809d8457d834e6373d3854bc1782cbf", size = 14915192, upload-time = "2025-01-17T01:48:25.104Z" }, @@ -5754,27 +5754,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.22" +version = "0.16.2" source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/3a/06/ae069393fc66e8ff33036d4b368003833bf6e88ccf182e17e7a2f1c754fd/ruff-0.15.22.tar.gz", hash = "sha256:3f15175b1fb580126f58285a5dae6b2ea89000136d980c64499211f116b54809", size = 4785063, upload-time = "2026-07-16T15:14:13.244Z" } +sdist = { url = "https://files.pythonhosted.org/packages/73/e1/4508a569211b35599016e84ba65c1a992b7a4004b4b6c4bea02a851cba1b/ruff-0.16.2.tar.gz", hash = "sha256:c3d7828d12e8927a6fc65fe38e2c2541b9e762d360a1786d752cb1b8883b3c9c", size = 4885811, upload-time = "2026-08-07T13:31:01.432Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/23/18/ee54b7ae1e121be7a28ea6da4b67564ebb0530e183a54415ab7e3bcd2c4e/ruff-0.15.22-py3-none-linux_armv6l.whl", hash = "sha256:44423e73493737f5e7c5b41d475483898ff37afcdae38bc3da5085e29af1c2d8", size = 10781258, upload-time = "2026-07-16T15:13:19.452Z" }, - { url = "https://files.pythonhosted.org/packages/2f/d2/2520cb14761ddbeaf57642a76942fc36adcbdbe53b4532241995f6fc485c/ruff-0.15.22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b82c6482946e9eda7ff2e091d25b8bad3f718684e1916d41bd56873cee05b697", size = 10999477, upload-time = "2026-07-16T15:13:23.318Z" }, - { url = "https://files.pythonhosted.org/packages/c9/10/74e53572aa758dfaa678c2a2646b5c5515d884b7ca56be4d2ce03ca4b560/ruff-0.15.22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:11c1c715af53a09f714e011106bffc419751ec8232fcb5da42173284ea3fec6f", size = 10466716, upload-time = "2026-07-16T15:13:26.162Z" }, - { url = "https://files.pythonhosted.org/packages/1e/cc/44eaaf0844e028182f2d0a8f2190d0f359159aed0a9e5ab861d892f1ae2a/ruff-0.15.22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:742a29cf29bddb7c8327895d6a10e0e6c5b38a96dd407af9b5d0857f809c0576", size = 10892644, upload-time = "2026-07-16T15:13:29.229Z" }, - { url = "https://files.pythonhosted.org/packages/9f/21/8edf559014d2b0f82beea19cfb713993ad802ccda16868769979c6090a84/ruff-0.15.22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72af58b951b0ae395935ae79763dc349bc0eb706319d28f7a33ad2cfb3cfc178", size = 10576719, upload-time = "2026-07-16T15:13:32.35Z" }, - { url = "https://files.pythonhosted.org/packages/bf/1e/3a13abd392a3b50b62e5938a831f9ab6e588358cacad5c18545b716d2182/ruff-0.15.22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d425005c1835eb24e2ee4161cb90e8db263415f4a71c8c72c33abaa6c0c224", size = 11376494, upload-time = "2026-07-16T15:13:35.958Z" }, - { url = "https://files.pythonhosted.org/packages/bf/3e/422d3d95bcf04dd78e1aeac22184d4f9a8fb2c01865d39d44618484a0317/ruff-0.15.22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b9b3f8779a4f08c969defc3c8c35abffaa757e601ed5ae66d6d1db6519969a", size = 12208370, upload-time = "2026-07-16T15:13:39.185Z" }, - { url = "https://files.pythonhosted.org/packages/1e/91/5d065a0e0a02bf4813f5119ad278462eed081d2b832eb7c021ade0ec9e65/ruff-0.15.22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e0dd1b2e4d3d585f897a0d137cbf4eaf6223bef4e8ce34d6bb12556c5f9249e", size = 11581098, upload-time = "2026-07-16T15:13:42.132Z" }, - { url = "https://files.pythonhosted.org/packages/f6/f9/a0d4871d12fae702eb1f41b686caf05f1f8b124dc6db6f784f53d74918fa/ruff-0.15.22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:365523eb91d9224e1bcb03b022fbf0facb8f9e23792a2c53d9d4b3924bdbdebb", size = 11399422, upload-time = "2026-07-16T15:13:45.2Z" }, - { url = "https://files.pythonhosted.org/packages/18/80/c843a5176cddbceb0b7e8dd41cf9993490796c1c469348d384f5a5c13c56/ruff-0.15.22-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fabfd168afdf29fee5be98b831efa9683c94d7c5a3b58b9ce5a2e38444589a74", size = 11381683, upload-time = "2026-07-16T15:13:48.46Z" }, - { url = "https://files.pythonhosted.org/packages/d4/00/8485de0ae92239438a36cfc51350db9b9e85c9ebdfaea91b18e422706662/ruff-0.15.22-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:225dbf095a87f1d9f90f5fd7924d2613ee452a75a4308c63a8f50f761787aa7c", size = 10850295, upload-time = "2026-07-16T15:13:51.655Z" }, - { url = "https://files.pythonhosted.org/packages/fa/91/24977ec2ec72eaf15e4394ace2959fdff2dd1e14f03e005e838023407169/ruff-0.15.22-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1877d63b9d24ed278744f1523fd11b85540566d54641f97c566d7d9dc5ca5296", size = 10579640, upload-time = "2026-07-16T15:13:54.79Z" }, - { url = "https://files.pythonhosted.org/packages/9c/47/9b51216951974df1f263ac19da550d34252e0ed7218c25f10c5ef9ed7517/ruff-0.15.22-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1606c510bd7215680d32efab38965f7cdec3ef69f5170a3f4791404ffdd5262", size = 11105077, upload-time = "2026-07-16T15:13:57.915Z" }, - { url = "https://files.pythonhosted.org/packages/c2/47/20e9d4a3b8016778acea5fc32bb50d35d207500a17ddb529ffa6996feef8/ruff-0.15.22-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:630479b18625f5ffc373f77603a22a9f8ac0acd7ff0501178b5db28ec71e9c64", size = 11490980, upload-time = "2026-07-16T15:14:01.032Z" }, - { url = "https://files.pythonhosted.org/packages/4d/76/3f72d8fc38c1cb77b38c56a70da9d0c17700cc1cc50f9649c9d3c8f5ba71/ruff-0.15.22-py3-none-win32.whl", hash = "sha256:e5ba0e4a13fd14abbed2a77b517a3911290c6c6c59ef67784328d1668fab76cf", size = 10789165, upload-time = "2026-07-16T15:14:04.16Z" }, - { url = "https://files.pythonhosted.org/packages/cb/46/4965251734c2b6fcdca1b1b187d20bcac3af0ee5b083b89c910bb961ce3a/ruff-0.15.22-py3-none-win_amd64.whl", hash = "sha256:9be63ba1eb936acd2d1342fb8337c356353706fce233b2a15a09a97037e6acde", size = 11938297, upload-time = "2026-07-16T15:14:07.316Z" }, - { url = "https://files.pythonhosted.org/packages/57/c9/e69b1ff4c8b69093ef08b8919ab767af0569666865b39c30a8795d88d3c6/ruff-0.15.22-py3-none-win_arm64.whl", hash = "sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661", size = 11298172, upload-time = "2026-07-16T15:14:10.51Z" }, + { url = "https://files.pythonhosted.org/packages/14/57/db19951540f98859c956b50bdb4d31089b4d91e9f15e2968e7d5193806d5/ruff-0.16.2-py3-none-linux_armv6l.whl", hash = "sha256:3c8de4cf2181f01d57946d87d777aa52916976fc09942aed89938fab5e013318", size = 10847925, upload-time = "2026-08-07T13:30:14.468Z" }, + { url = "https://files.pythonhosted.org/packages/13/5a/995fe85a8470d3e391ac0f7fa8054bb454eaf33ee138196d6172ed1079c0/ruff-0.16.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9a48cc05c6fbc811ca81b5d7ba95375affea6582d1b8024e455e41afbbf55344", size = 11072662, upload-time = "2026-08-07T13:30:18.143Z" }, + { url = "https://files.pythonhosted.org/packages/32/53/370d767c61c71a971a4ace36703a7ecd8c393956349a7325d7fab2b56827/ruff-0.16.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a2c0d14fcbb26c91f0f867a6dc9bd71bbc30b1b6151829c884f23faeab2e5700", size = 10566771, upload-time = "2026-08-07T13:30:20.899Z" }, + { url = "https://files.pythonhosted.org/packages/85/d6/9d96948caf5a632be62d62202d5ec914d6856f204fd79eb036e5915e79ea/ruff-0.16.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:335c621622c4650330be50842561c6586ac6971bb8ab5407fe34dcc9efb16bbe", size = 10975825, upload-time = "2026-08-07T13:30:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/92/ea87129b3414acb0b5770563779c51804d37ac67675c7ba35447ddb14773/ruff-0.16.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20e66910f2c37cc753f9ef6580c914a621b80c4fa3549d3e3521e29d0f5bfc3f", size = 10649437, upload-time = "2026-08-07T13:30:26.097Z" }, + { url = "https://files.pythonhosted.org/packages/ac/43/f8f291dcd4af5bb7872b74fdfa41a7cd7c856ca1d4069670971cf1b9f5cb/ruff-0.16.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7e36fbfba65510548156902bcf1350a979a958ce0347ce0f90d73894036b39f", size = 11446761, upload-time = "2026-08-07T13:30:28.752Z" }, + { url = "https://files.pythonhosted.org/packages/71/4a/ef991fb2fcf516ab71f0808adcdd8da5e18c8cde447f4ceaf5f47a5132a5/ruff-0.16.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f0eab35f80df8f134aae5d1630e751901321d317cc8e50dc39e36fa3ed34cd12", size = 12336364, upload-time = "2026-08-07T13:30:31.468Z" }, + { url = "https://files.pythonhosted.org/packages/f3/24/f615e74f307e6ca0e56a482872477b856c70d530aa356abfb6dfe5ca8a80/ruff-0.16.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ea8c0594feb894e89c8c61ab9c103d38b0ea72dfde6c594107147ca31b1140", size = 11630720, upload-time = "2026-08-07T13:30:34.426Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d3/8ef50149e8412a77f7ab409efdef0e2b23803707a3863da4fc64cb23d459/ruff-0.16.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab3d62dde0b19facdd632008cc4827fc28ada7736c6bd35ab6f1050f0bfed53f", size = 11466130, upload-time = "2026-08-07T13:30:36.958Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a7/a19334985c4dea8c381981fa252cd854c7ee52dc4b1686dc16f4a911c702/ruff-0.16.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e43e1f5b8388da9eca1b9e88328d47a5cec794633ccf6f7484ac2dd15eee92c0", size = 11523634, upload-time = "2026-08-07T13:30:39.822Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6c/96d192b0e742412ceda08c0a50f9669b253dde9fd6a60ea1a10c9fa79a63/ruff-0.16.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c24788a980581e1d7ea3a0cbe4344c4fbeb0a6a9b1f4713aa46bb104f8294690", size = 10949807, upload-time = "2026-08-07T13:30:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/fa/51/e26599ceca11e79ee255c7df515995561edf87e9ca1893284e44d98f5a86/ruff-0.16.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:81806b08329130005dd4a8a8394a0c9da8c6f4cafb16ba438d2a2ee6a18bedf1", size = 10646891, upload-time = "2026-08-07T13:30:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/68/01/800c4b1f97bc8d7c6029e06b1f20473a3cf1e13c4933d8f3342add83fc55/ruff-0.16.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4ce4e02bad779bef557f541a1b31f20d6abeae1cc05ed1b1ac019d4ffd1044c8", size = 11162063, upload-time = "2026-08-07T13:30:48.131Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d0/1477ea50fc5a0d4b0b71d1d63d50770bdd794d90b43e37a7618e63ec9894/ruff-0.16.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e0422abdf70070255fc4073ce9dfc814cc03db577013761ddd09bc1e4a9a4fbd", size = 11556038, upload-time = "2026-08-07T13:30:50.686Z" }, + { url = "https://files.pythonhosted.org/packages/b8/76/a7776f32048d991e16d4fa8ff91790b877342d3596cc3ed04acdbf1aaedc/ruff-0.16.2-py3-none-win32.whl", hash = "sha256:bf3a63d78fb39f4bf5ac8ae52051c5520505301abe19ba4e204c453b3f09bb0b", size = 10872850, upload-time = "2026-08-07T13:30:53.471Z" }, + { url = "https://files.pythonhosted.org/packages/00/0d/929c800d920e61397d82a01b60bffc68da3052c17d31de59efaad2e4ed75/ruff-0.16.2-py3-none-win_amd64.whl", hash = "sha256:bcabe2f6d0fc7819f1431793005af4e4de7371927d037345bf941252b195b9fa", size = 12023338, upload-time = "2026-08-07T13:30:56.193Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6c/93e26c22c5f78ff87363e07da49c84955affbeb1098bd1936bf3b3f293bf/ruff-0.16.2-py3-none-win_arm64.whl", hash = "sha256:d614e95cedf38a2053fd351c55b103ba30d017d61688fdbfd40ee0412852a99f", size = 11374065, upload-time = "2026-08-07T13:30:58.775Z" }, ] [[package]] @@ -5821,10 +5821,10 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "joblib" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple/" } }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple/" } }, - { name = "threadpoolctl" }, + { name = "joblib", marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple/" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple/" }, marker = "python_full_version < '3.11'" }, + { name = "threadpoolctl", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/98/c2/a7855e41c9d285dfe86dc50b250978105dce513d6e459ea66a6aeb0e1e0c/scikit_learn-1.7.2.tar.gz", hash = "sha256:20e9e49ecd130598f1ca38a1d85090e1a600147b9c02fa6f15d69cb53d968fda", size = 7193136, upload-time = "2025-09-09T08:21:29.075Z" } wheels = [ @@ -5879,10 +5879,10 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "joblib" }, - { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple/" } }, - { name = "scipy", version = "1.17.0", source = { registry = "https://pypi.org/simple/" } }, - { name = "threadpoolctl" }, + { name = "joblib", marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple/" }, marker = "python_full_version >= '3.11'" }, + { name = "scipy", version = "1.17.0", source = { registry = "https://pypi.org/simple/" }, marker = "python_full_version >= '3.11'" }, + { name = "threadpoolctl", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } wheels = [ @@ -5932,7 +5932,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple/" } }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple/" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } wheels = [ @@ -6002,7 +6002,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple/" } }, + { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple/" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/56/3e/9cca699f3486ce6bc12ff46dc2031f1ec8eb9ccc9a320fdaf925f1417426/scipy-1.17.0.tar.gz", hash = "sha256:2591060c8e648d8b96439e111ac41fd8342fdeff1876be2e19dea3fe8930454e", size = 30396830, upload-time = "2026-01-10T21:34:23.009Z" } wheels = [ diff --git a/wiki/ARCHITECTURE.md b/wiki/ARCHITECTURE.md index b5cd360cd..c8dccb921 100644 --- a/wiki/ARCHITECTURE.md +++ b/wiki/ARCHITECTURE.md @@ -226,8 +226,8 @@ analysis = { "data": [ {"ts": 45, "cpu": 92}, # Keep the spike! {"ts": 46, "cpu": 95}, - ... - ] + ..., + ], } ``` @@ -397,11 +397,9 @@ def analyze_field(key, items): "unique_ratio": len(set(values)) / len(values), # 0.0 = all same (constant) # 1.0 = all different (unique IDs) - "variance": statistics.variance(values), # For numbers # Low = stable # High = changing - "change_points": detect_spikes(values), # Indices where value jumps significantly } @@ -502,14 +500,14 @@ When SmartCrusher compresses, the original content is stored for on-demand retri ```python @dataclass class CompressionEntry: - hash: str # 16-char SHA256 for retrieval - original_content: str # Full JSON before compression - compressed_content: str # Compressed JSON + hash: str # 16-char SHA256 for retrieval + original_content: str # Full JSON before compression + compressed_content: str # Compressed JSON original_item_count: int compressed_item_count: int - tool_name: str | None # For feedback tracking + tool_name: str | None # For feedback tracking created_at: float - ttl: int = 300 # 5 minute default + ttl: int = 300 # 5 minute default ``` **Features:** @@ -625,12 +623,12 @@ The feedback system learns from retrieval patterns to improve future compression @dataclass class ToolPattern: tool_name: str - total_compressions: int # Times we compressed this tool - total_retrievals: int # Times LLM asked for more - full_retrievals: int # Retrieved everything (all retrievals — hash-only) - search_retrievals: int # Legacy; always 0 (retrieval is hash-only, no search) - common_queries: dict[str, int] # Legacy query-pattern frequency (no longer populated) - queried_fields: dict[str, int] # Legacy queried-field frequency (no longer populated) + total_compressions: int # Times we compressed this tool + total_retrievals: int # Times LLM asked for more + full_retrievals: int # Retrieved everything (all retrievals — hash-only) + search_retrievals: int # Legacy; always 0 (retrieval is hash-only, no search) + common_queries: dict[str, int] # Legacy query-pattern frequency (no longer populated) + queried_fields: dict[str, int] # Legacy queried-field frequency (no longer populated) ``` **Key Metrics:** @@ -644,12 +642,12 @@ class ToolPattern: ```python @dataclass class CompressionHints: - max_items: int = 15 # Target item count + max_items: int = 15 # Target item count suggested_items: int | None # Calculated optimal - skip_compression: bool # Don't compress at all - preserve_fields: list[str] # Always keep these fields - aggressiveness: float # 0.0 = aggressive, 1.0 = conservative - reason: str # Explanation + skip_compression: bool # Don't compress at all + preserve_fields: list[str] # Always keep these fields + aggressiveness: float # 0.0 = aggressive, 1.0 = conservative + reason: str # Explanation ``` **Feedback-Driven Adjustment:** @@ -733,24 +731,26 @@ if self.config.use_feedback_hints and tool_name: ```python @dataclass class CCRToolCall: - tool_call_id: str # For matching response - hash_key: str # CCR hash to retrieve + tool_call_id: str # For matching response + hash_key: str # CCR hash to retrieve + @dataclass class CCRToolResult: tool_call_id: str - content: str # Retrieved data as JSON + content: str # Retrieved data as JSON success: bool items_retrieved: int + class CCRResponseHandler: async def handle_response( self, - response: dict, # Initial LLM response - messages: list, # Conversation history - tools: list, # Tool definitions - api_call_fn: Callable, # Function to make API calls - provider: str, # "anthropic" or "openai" + response: dict, # Initial LLM response + messages: list, # Conversation history + tools: list, # Tool definitions + api_call_fn: Callable, # Function to make API calls + provider: str, # "anthropic" or "openai" ) -> dict: """Handle CCR tool calls until final response.""" ``` @@ -762,11 +762,14 @@ The handler also supports streaming responses via `StreamingCCRHandler`: ```python class StreamingCCRBuffer: """Buffers streaming chunks to detect CCR tool calls.""" + chunks: list[bytes] detected_ccr: bool + class StreamingCCRHandler: """Handles CCR in streaming responses.""" + async def process_stream(self, stream, messages, tools, api_call_fn): """Yields chunks, switching to buffered mode if CCR detected.""" ``` @@ -848,11 +851,11 @@ The tracker uses simple but effective heuristics: @dataclass class ContextTrackerConfig: enabled: bool = True - max_tracked_contexts: int = 100 # LRU eviction - relevance_threshold: float = 0.3 # Min score to recommend - max_context_age_seconds: float = 300 # 5 minutes + max_tracked_contexts: int = 100 # LRU eviction + relevance_threshold: float = 0.3 # Min score to recommend + max_context_age_seconds: float = 300 # 5 minutes proactive_expansion: bool = True - max_proactive_expansions: int = 2 # Per query + max_proactive_expansions: int = 2 # Per query ``` --- diff --git a/wiki/agno.md b/wiki/agno.md index 386d30bfe..028f36ef7 100644 --- a/wiki/agno.md +++ b/wiki/agno.md @@ -167,6 +167,7 @@ Full async support for high-throughput applications: import asyncio from headroom.integrations.agno import HeadroomAgnoModel + async def process_async(): model = HeadroomAgnoModel(OpenAIChat(id="gpt-4o")) @@ -179,6 +180,7 @@ async def process_async(): print(f"\nTokens saved: {model.total_tokens_saved}") + asyncio.run(process_async()) ``` diff --git a/wiki/api.md b/wiki/api.md index 66288cea7..28dd29ec1 100644 --- a/wiki/api.md +++ b/wiki/api.md @@ -101,9 +101,9 @@ config = CacheAlignerConfig( from headroom import RelevanceScorerConfig config = RelevanceScorerConfig( - scorer_type="bm25", # "bm25", "embedding", or "hybrid" - embedding_model=None, # Model name for embedding scorer - hybrid_alpha=0.5, # Weight for hybrid scoring + scorer_type="bm25", # "bm25", "embedding", or "hybrid" + embedding_model=None, # Model name for embedding scorer + hybrid_alpha=0.5, # Weight for hybrid scoring ) ``` @@ -286,10 +286,12 @@ result = aligner.align(messages) ```python from headroom import TransformPipeline -pipeline = TransformPipeline([ - SmartCrusher(), - CacheAligner(), -]) +pipeline = TransformPipeline( + [ + SmartCrusher(), + CacheAligner(), + ] +) result = pipeline.transform(messages) ``` diff --git a/wiki/compression.md b/wiki/compression.md index a76a173c3..5de8bd55e 100644 --- a/wiki/compression.md +++ b/wiki/compression.md @@ -79,21 +79,18 @@ from headroom.compression import UniversalCompressorConfig config = UniversalCompressorConfig( # Detection - use_magika=True, # Use ML-based detection (requires magika) - + use_magika=True, # Use ML-based detection (requires magika) # Compression # (Note: the legacy `use_llmlingua` flag was retired with the # LLMLingua-2 integration. The optional ML compressor is now Kompress, # installed via `headroom-ai[ml]` and configured separately.) compression_ratio_target=0.3, # Keep 30% of content (70% reduction) - min_content_length=100, # Skip content shorter than this - + min_content_length=100, # Skip content shorter than this # Structure preservation - use_entropy_preservation=True, # Preserve high-entropy tokens - entropy_threshold=0.85, # Entropy threshold for preservation - + use_entropy_preservation=True, # Preserve high-entropy tokens + entropy_threshold=0.85, # Entropy threshold for preservation # CCR - ccr_enabled=True, # Store originals for retrieval + ccr_enabled=True, # Store originals for retrieval ) ``` @@ -121,12 +118,12 @@ Preserves JSON structure while compressing values: from headroom.compression.handlers.json_handler import JSONStructureHandler handler = JSONStructureHandler( - preserve_short_values=True, # Keep values < 20 chars - short_value_threshold=20, # Threshold for "short" - preserve_high_entropy=True, # Keep UUIDs, hashes - entropy_threshold=0.85, # Entropy threshold - max_array_items_full=3, # Keep first N array items full - max_number_digits=10, # Preserve numbers up to N digits + preserve_short_values=True, # Keep values < 20 chars + short_value_threshold=20, # Threshold for "short" + preserve_high_entropy=True, # Keep UUIDs, hashes + entropy_threshold=0.85, # Entropy threshold + max_array_items_full=3, # Keep first N array items full + max_number_digits=10, # Preserve numbers up to N digits ) ``` @@ -141,18 +138,10 @@ handler = JSONStructureHandler( ```python # Before -{ - "id": "usr_abc123", - "name": "Alice Johnson", - "bio": "A long description that goes on and on..." -} +{"id": "usr_abc123", "name": "Alice Johnson", "bio": "A long description that goes on and on..."} # After (structure preserved, long values compressed) -{ - "id": "usr_abc123", - "name": "Alice Johnson", - "bio": "A long...[compressed]..." -} +{"id": "usr_abc123", "name": "Alice Johnson", "bio": "A long...[compressed]..."} ``` ### Code Handler @@ -163,9 +152,9 @@ Preserves code structure using AST parsing (tree-sitter) or regex fallback: from headroom.compression.handlers.code_handler import CodeStructureHandler handler = CodeStructureHandler( - preserve_comments=False, # Preserve comments as structural - use_tree_sitter=True, # Use tree-sitter for parsing - default_language="python", # Default when detection fails + preserve_comments=False, # Preserve comments as structural + use_tree_sitter=True, # Use tree-sitter for parsing + default_language="python", # Default when detection fails ) ``` @@ -226,24 +215,24 @@ from headroom.compression import compress result = compress(content) # Access result fields -print(result.compressed) # Compressed content -print(result.original) # Original content -print(result.compression_ratio) # e.g., 0.35 (35% of original size) -print(result.tokens_before) # Estimated tokens before -print(result.tokens_after) # Estimated tokens after -print(result.tokens_saved) # tokens_before - tokens_after -print(result.savings_percentage) # e.g., 65.0 (65% savings) +print(result.compressed) # Compressed content +print(result.original) # Original content +print(result.compression_ratio) # e.g., 0.35 (35% of original size) +print(result.tokens_before) # Estimated tokens before +print(result.tokens_after) # Estimated tokens after +print(result.tokens_saved) # tokens_before - tokens_after +print(result.savings_percentage) # e.g., 65.0 (65% savings) # Detection info -print(result.content_type) # ContentType.JSON, CODE, etc. -print(result.detection_confidence) # 0.0-1.0 +print(result.content_type) # ContentType.JSON, CODE, etc. +print(result.detection_confidence) # 0.0-1.0 # Structure info -print(result.handler_used) # "json", "code", etc. -print(result.preservation_ratio) # Fraction preserved as structure +print(result.handler_used) # "json", "code", etc. +print(result.preservation_ratio) # Fraction preserved as structure # CCR info -print(result.ccr_key) # Key for retrieval (if CCR enabled) +print(result.ccr_key) # Key for retrieval (if CCR enabled) ``` --- @@ -259,8 +248,8 @@ compressor = UniversalCompressor() contents = [ '{"users": [...]}', - 'def hello(): pass', - 'Plain text content', + "def hello(): pass", + "Plain text content", ] results = compressor.compress_batch(contents) @@ -393,11 +382,11 @@ json_content = """ result = compressor.compress(json_content) -print(f"Type: {result.content_type}") # ContentType.JSON -print(f"Handler: {result.handler_used}") # json +print(f"Type: {result.content_type}") # ContentType.JSON +print(f"Handler: {result.handler_used}") # json print(f"Saved: {result.savings_percentage:.0f}%") # ~60% print(f"Structure: {result.preservation_ratio:.0%} preserved") # ~40% -print(f"CCR Key: {result.ccr_key}") # For retrieval +print(f"CCR Key: {result.ccr_key}") # For retrieval ``` --- diff --git a/wiki/configuration.md b/wiki/configuration.md index 58b011e04..b74c2e89f 100644 --- a/wiki/configuration.md +++ b/wiki/configuration.md @@ -31,22 +31,17 @@ from openai import OpenAI client = HeadroomClient( original_client=OpenAI(), provider=OpenAIProvider(), - # Mode: "audit" (observe only) or "optimize" (apply transforms) default_mode="optimize", - # Enable provider-specific cache optimization enable_cache_optimizer=True, - # Enable query-level semantic caching enable_semantic_cache=False, - # Override default context limits per model model_context_limits={ "gpt-4o": 128000, "gpt-4o-mini": 128000, }, - # Database location (defaults to temp directory) # store_url="sqlite:////absolute/path/to/headroom.db", ) @@ -136,20 +131,14 @@ Override configuration for specific requests: response = client.chat.completions.create( model="gpt-4o", messages=[...], - # Override mode for this request headroom_mode="audit", - # Reserve more tokens for output headroom_output_buffer_tokens=8000, - # Keep last N turns (don't compress) headroom_keep_turns=5, - # Skip compression for specific tools - headroom_tool_profiles={ - "important_tool": {"skip_compression": True} - } + headroom_tool_profiles={"important_tool": {"skip_compression": True}}, ) ``` @@ -186,13 +175,10 @@ from headroom.transforms import SmartCrusherConfig config = SmartCrusherConfig( # Maximum items to keep after compression max_items_after_crush=15, - # Minimum tokens before applying compression min_tokens_to_crush=200, - # Relevance scoring tier: "bm25" (fast) or "embedding" (accurate) relevance_tier="bm25", - # Always keep items with these field values preserve_fields=["error", "warning", "failure"], ) @@ -208,7 +194,6 @@ from headroom.transforms import CacheAlignerConfig config = CacheAlignerConfig( # Enable/disable cache alignment enabled=True, - # Patterns to extract from system prompt dynamic_patterns=[ r"Today is \w+ \d+, \d{4}", diff --git a/wiki/errors.md b/wiki/errors.md index 345e593a5..c5642d362 100644 --- a/wiki/errors.md +++ b/wiki/errors.md @@ -6,12 +6,12 @@ Headroom provides explicit exceptions for debugging, with a safety guarantee tha ```python from headroom import ( - HeadroomError, # Base class - catch all Headroom errors - ConfigurationError, # Invalid configuration - ProviderError, # Provider issues (unknown model, etc.) - StorageError, # Database/storage failures - CompressionError, # Compression failures (rare) - ValidationError, # Setup validation failures + HeadroomError, # Base class - catch all Headroom errors + ConfigurationError, # Invalid configuration + ProviderError, # Provider issues (unknown model, etc.) + StorageError, # Database/storage failures + CompressionError, # Compression failures (rare) + ValidationError, # Setup validation failures ) ``` @@ -75,10 +75,7 @@ Raised for provider-specific issues. # - Token counting failure try: - response = client.chat.completions.create( - model="unknown-model-xyz", - messages=[...] - ) + response = client.chat.completions.create(model="unknown-model-xyz", messages=[...]) except ProviderError as e: print(f"Provider error: {e}") print(f"Provider: {e.details.get('provider')}") @@ -122,10 +119,7 @@ Raised when setup validation fails. ```python result = client.validate_setup() if not result["valid"]: - raise ValidationError( - "Setup validation failed", - details={"issues": result["issues"]} - ) + raise ValidationError("Setup validation failed", details={"issues": result["issues"]}) ``` ## Safety Guarantee @@ -136,16 +130,11 @@ This is a core design principle. Your LLM calls never fail due to Headroom: ```python # Even if SmartCrusher encounters unexpected data: -messages = [ - {"role": "tool", "content": "malformed json {{{"} -] +messages = [{"role": "tool", "content": "malformed json {{{"}] # This will NOT raise an exception # Instead, the malformed content passes through unchanged -response = client.chat.completions.create( - model="gpt-4o", - messages=messages -) +response = client.chat.completions.create(model="gpt-4o", messages=messages) ``` ## Logging Errors @@ -154,6 +143,7 @@ Enable logging to see error details: ```python import logging + logging.basicConfig(level=logging.WARNING) # Now you'll see warnings when compression is skipped: @@ -232,6 +222,7 @@ response = client.chat.completions.create(...) ```python import logging + logging.basicConfig(level=logging.DEBUG) # Shows detailed transform decisions diff --git a/wiki/image-compression.md b/wiki/image-compression.md index 4d50b8b30..0fa22ae9c 100644 --- a/wiki/image-compression.md +++ b/wiki/image-compression.md @@ -61,13 +61,15 @@ client = HeadroomClient(provider="openai") response = client.chat.completions.create( model="gpt-4o", - messages=[{ - "role": "user", - "content": [ - {"type": "text", "text": "What animal is this?"}, - {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,..."}} - ] - }] + messages=[ + { + "role": "user", + "content": [ + {"type": "text", "text": "What animal is this?"}, + {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,..."}}, + ], + } + ], ) # Image automatically compressed with detail="low" (87% savings) ``` @@ -106,8 +108,8 @@ from headroom.image import ImageCompressor compressor = ImageCompressor( model_id="chopratejas/technique-router", # HuggingFace model - use_siglip=True, # Enable image analysis - device="cuda", # Use GPU if available + use_siglip=True, # Enable image analysis + device="cuda", # Use GPU if available ) ``` @@ -229,9 +231,11 @@ The HuggingFace model downloads on first use: ```python # Force a specific cache directory import os + os.environ["HF_HOME"] = "/path/to/cache" from headroom.image import ImageCompressor + compressor = ImageCompressor() ``` @@ -290,10 +294,10 @@ class ImageCompressor: ```python @dataclass class CompressionResult: - technique: Technique # full_low, preserve, crop, transcode - original_tokens: int # Estimated tokens before - compressed_tokens: int # Estimated tokens after - confidence: float # Router confidence (0-1) + technique: Technique # full_low, preserve, crop, transcode + original_tokens: int # Estimated tokens before + compressed_tokens: int # Estimated tokens after + confidence: float # Router confidence (0-1) @property def savings_percent(self) -> float: @@ -304,10 +308,10 @@ class CompressionResult: ```python class Technique(Enum): - FULL_LOW = "full_low" # 87% savings - PRESERVE = "preserve" # 0% savings - CROP = "crop" # 50-90% savings - TRANSCODE = "transcode" # 99% savings + FULL_LOW = "full_low" # 87% savings + PRESERVE = "preserve" # 0% savings + CROP = "crop" # 50-90% savings + TRANSCODE = "transcode" # 99% savings ``` ## See Also diff --git a/wiki/integration-guide.md b/wiki/integration-guide.md index c1d0885bf..6d076ab06 100644 --- a/wiki/integration-guide.md +++ b/wiki/integration-guide.md @@ -92,22 +92,26 @@ import httpx from headroom import compress compressed = compress(messages, model="claude-sonnet-4-5-20250929") -httpx.post("https://api.anthropic.com/v1/messages", json={ - "model": "claude-sonnet-4-5-20250929", - "messages": compressed.messages, -}, headers={"X-Api-Key": api_key, "anthropic-version": "2023-06-01"}) +httpx.post( + "https://api.anthropic.com/v1/messages", + json={ + "model": "claude-sonnet-4-5-20250929", + "messages": compressed.messages, + }, + headers={"X-Api-Key": api_key, "anthropic-version": "2023-06-01"}, +) ``` ### What compress() returns ```python result = compress(messages, model="gpt-4o") -result.messages # list[dict] — compressed messages, same format as input -result.tokens_before # int — original token count -result.tokens_after # int — compressed token count -result.tokens_saved # int — tokens removed +result.messages # list[dict] — compressed messages, same format as input +result.tokens_before # int — original token count +result.tokens_after # int — compressed token count +result.tokens_saved # int — tokens removed result.compression_ratio # float — 0.0 (no savings) to 1.0 (100% removed) -result.transforms_applied # list[str] — what ran (e.g., ["router:smart_crusher:0.35"]) +result.transforms_applied # list[str] — what ran (e.g., ["router:smart_crusher:0.35"]) ``` --- @@ -169,6 +173,7 @@ app.add_middleware(CompressionMiddleware) # LiteLLM proxy from litellm.proxy.proxy_server import app + app.add_middleware(CompressionMiddleware) ``` @@ -309,6 +314,7 @@ Customize compression behavior without modifying Headroom's code: ```python from headroom import compress, CompressionHooks, CompressContext + class MyHooks(CompressionHooks): def pre_compress(self, messages, ctx): # Modify messages before compression (dedup, filter, inject) @@ -323,6 +329,7 @@ class MyHooks(CompressionHooks): # Observe results (logging, analytics, learning) print(f"Saved {event.tokens_saved} tokens") + result = compress(messages, model="gpt-4o", hooks=MyHooks()) ``` diff --git a/wiki/langchain.md b/wiki/langchain.md index 1d79f1da3..c89bf3fc8 100644 --- a/wiki/langchain.md +++ b/wiki/langchain.md @@ -91,11 +91,13 @@ Works seamlessly with LangChain tool calling: ```python from langchain_core.tools import tool + @tool def search(query: str) -> str: """Search the web.""" return {"results": [...]} # Large JSON response + llm_with_tools = llm.bind_tools([search]) response = llm_with_tools.invoke("Search for Python tutorials") # Tool outputs are automatically compressed in subsequent turns @@ -117,7 +119,7 @@ base_history = ChatMessageHistory() compressed_history = HeadroomChatMessageHistory( base_history, compress_threshold_tokens=4000, # Compress when over 4K tokens - keep_recent_turns=5, # Always keep last 5 turns + keep_recent_turns=5, # Always keep last 5 turns ) # Use with any memory class @@ -152,9 +154,9 @@ base_retriever = vectorstore.as_retriever(search_kwargs={"k": 50}) # Wrap with Headroom compression (keep best for precision) compressor = HeadroomDocumentCompressor( - max_documents=10, # Keep top 10 - min_relevance=0.3, # Minimum relevance score - prefer_diverse=True, # MMR-style diversity + max_documents=10, # Keep top 10 + min_relevance=0.3, # Minimum relevance score + prefer_diverse=True, # MMR-style diversity ) retriever = ContextualCompressionRetriever( @@ -179,18 +181,21 @@ from langchain.agents import create_openai_tools_agent, AgentExecutor from langchain_core.tools import tool from headroom.integrations import wrap_tools_with_headroom + @tool def search_database(query: str) -> str: """Search the database.""" # Returns 1000 results as JSON return json.dumps({"results": [...], "total": 1000}) + @tool def fetch_logs(service: str) -> str: """Fetch service logs.""" # Returns 500 log entries return json.dumps({"logs": [...]}) + # Wrap tools with compression tools = [search_database, fetch_logs] wrapped_tools = wrap_tools_with_headroom( @@ -296,26 +301,33 @@ from langchain_core.tools import tool from langgraph.prebuilt import create_react_agent from headroom.integrations import HeadroomChatModel, wrap_tools_with_headroom + # Define tools that return large outputs @tool def search_web(query: str) -> str: """Search the web for information.""" # Simulating large search results - return json.dumps({ - "results": [ - {"title": f"Result {i}", "snippet": "..." * 100, "url": f"https://..."} - for i in range(100) - ], - "total": 1000, - }) + return json.dumps( + { + "results": [ + {"title": f"Result {i}", "snippet": "..." * 100, "url": f"https://..."} + for i in range(100) + ], + "total": 1000, + } + ) + @tool def query_database(sql: str) -> str: """Execute SQL query.""" - return json.dumps({ - "rows": [{"id": i, "data": "..." * 50} for i in range(500)], - "total": 500, - }) + return json.dumps( + { + "rows": [{"id": i, "data": "..." * 50} for i in range(500)], + "total": 500, + } + ) + # Wrap model with Headroom llm = HeadroomChatModel(ChatOpenAI(model="gpt-4o")) @@ -327,9 +339,9 @@ tools = wrap_tools_with_headroom([search_web, query_database]) agent = create_react_agent(llm, tools) # Run - tool outputs are automatically compressed between iterations -result = agent.invoke({ - "messages": [("user", "Find all users who signed up last week and their activity")] -}) +result = agent.invoke( + {"messages": [("user", "Find all users who signed up last week and their activity")]} +) # Check savings print(f"Tokens saved: {llm.get_metrics()['tokens_saved']}") @@ -352,23 +364,29 @@ from langchain_core.messages import HumanMessage from langgraph.graph import StateGraph, MessagesState, START, END from headroom.integrations.langchain import create_compress_tool_messages_node + # Define your agent and tools nodes def agent_node(state: MessagesState): llm = ChatOpenAI(model="gpt-4o") response = llm.invoke(state["messages"]) return {"messages": [response]} + def tools_node(state: MessagesState): # Your tool execution logic here ... + # Build the graph with a compression step graph = StateGraph(MessagesState) graph.add_node("agent", agent_node) graph.add_node("tools", tools_node) -graph.add_node("compress", create_compress_tool_messages_node( - min_tokens_to_compress=100, # Only compress outputs > ~100 tokens -)) +graph.add_node( + "compress", + create_compress_tool_messages_node( + min_tokens_to_compress=100, # Only compress outputs > ~100 tokens + ), +) # Wire: tools -> compress -> agent (instead of tools -> agent directly) graph.add_edge(START, "agent") @@ -411,9 +429,9 @@ base_retriever = vectorstore.as_retriever(search_kwargs={"k": 50}) # Headroom compressor for precision compressor = HeadroomDocumentCompressor( - max_documents=5, # Keep only top 5 - min_relevance=0.4, # Must be 40%+ relevant - prefer_diverse=True, # Avoid redundant docs + max_documents=5, # Keep only top 5 + min_relevance=0.4, # Must be 40%+ relevant + prefer_diverse=True, # Avoid redundant docs ) # Combine into compression retriever @@ -461,7 +479,7 @@ base_history = ChatMessageHistory() compressed_history = HeadroomChatMessageHistory( base_history, compress_threshold_tokens=8000, # Compress when over 8K - keep_recent_turns=10, # Always keep last 10 turns + keep_recent_turns=10, # Always keep last 10 turns ) memory = ConversationBufferMemory( @@ -500,30 +518,45 @@ from headroom.integrations import ( reset_tool_metrics, ) + @tool def search_arxiv(query: str) -> str: """Search arXiv for papers.""" - return json.dumps({"papers": [{"title": f"Paper {i}", "abstract": "..." * 200} for i in range(50)]}) + return json.dumps( + {"papers": [{"title": f"Paper {i}", "abstract": "..." * 200} for i in range(50)]} + ) + @tool def search_github(query: str) -> str: """Search GitHub repositories.""" - return json.dumps({"repos": [{"name": f"repo-{i}", "description": "..." * 100, "stars": i * 100} for i in range(100)]}) + return json.dumps( + { + "repos": [ + {"name": f"repo-{i}", "description": "..." * 100, "stars": i * 100} + for i in range(100) + ] + } + ) + @tool def fetch_documentation(url: str) -> str: """Fetch documentation from URL.""" return "..." * 5000 # Large doc content + # Wrap everything llm = HeadroomChatModel(ChatOpenAI(model="gpt-4o")) tools = wrap_tools_with_headroom([search_arxiv, search_github, fetch_documentation]) -prompt = ChatPromptTemplate.from_messages([ - ("system", "You are a research assistant. Use tools to gather information."), - ("human", "{input}"), - ("placeholder", "{agent_scratchpad}"), -]) +prompt = ChatPromptTemplate.from_messages( + [ + ("system", "You are a research assistant. Use tools to gather information."), + ("human", "{input}"), + ("placeholder", "{agent_scratchpad}"), + ] +) agent = create_openai_tools_agent(llm, tools, prompt) executor = AgentExecutor(agent=agent, tools=tools, verbose=True) @@ -532,9 +565,11 @@ executor = AgentExecutor(agent=agent, tools=tools, verbose=True) reset_tool_metrics() # Run complex research task -result = executor.invoke({ - "input": "Research the latest advances in LLM context compression and find relevant GitHub projects" -}) +result = executor.invoke( + { + "input": "Research the latest advances in LLM context compression and find relevant GitHub projects" + } +) # Check per-tool metrics metrics = get_tool_metrics().get_summary() @@ -550,9 +585,9 @@ print(f"Per-tool breakdown: {metrics['by_tool']}") ```python HeadroomChatModel( - wrapped_model, # Any LangChain BaseChatModel + wrapped_model, # Any LangChain BaseChatModel headroom_config=HeadroomConfig(), # Headroom configuration - auto_detect_provider=True, # Auto-detect from wrapped model + auto_detect_provider=True, # Auto-detect from wrapped model ) ``` @@ -560,10 +595,10 @@ HeadroomChatModel( ```python HeadroomChatMessageHistory( - base_history, # Any BaseChatMessageHistory - compress_threshold_tokens=4000, # Token threshold for compression - keep_recent_turns=5, # Minimum turns to preserve - model="gpt-4o", # Model for token counting + base_history, # Any BaseChatMessageHistory + compress_threshold_tokens=4000, # Token threshold for compression + keep_recent_turns=5, # Minimum turns to preserve + model="gpt-4o", # Model for token counting ) ``` @@ -571,9 +606,9 @@ HeadroomChatMessageHistory( ```python HeadroomDocumentCompressor( - max_documents=10, # Maximum docs to return - min_relevance=0.0, # Minimum relevance score (0-1) - prefer_diverse=False, # Use MMR for diversity + max_documents=10, # Maximum docs to return + min_relevance=0.0, # Minimum relevance score (0-1) + prefer_diverse=False, # Use MMR for diversity ) ``` @@ -581,9 +616,9 @@ HeadroomDocumentCompressor( ```python wrap_tools_with_headroom( - tools, # List of LangChain tools - min_chars_to_compress=1000, # Minimum output size - smart_crusher_config=None, # SmartCrusher configuration + tools, # List of LangChain tools + min_chars_to_compress=1000, # Minimum output size + smart_crusher_config=None, # SmartCrusher configuration ) ``` @@ -595,27 +630,21 @@ wrap_tools_with_headroom( from headroom.integrations import ( # Chat Model HeadroomChatModel, - # Memory HeadroomChatMessageHistory, - # Retrievers HeadroomDocumentCompressor, - # Agents HeadroomToolWrapper, wrap_tools_with_headroom, get_tool_metrics, reset_tool_metrics, - # Streaming StreamingMetricsTracker, StreamingMetricsCallback, track_streaming_response, - # LangSmith HeadroomLangSmithCallbackHandler, - # Provider Detection detect_provider, get_headroom_provider, @@ -660,7 +689,7 @@ Check that your message count exceeds the threshold: history = HeadroomChatMessageHistory( base_history, compress_threshold_tokens=1000, # Lower threshold - keep_recent_turns=2, # Fewer preserved turns + keep_recent_turns=2, # Fewer preserved turns ) ``` diff --git a/wiki/memory.md b/wiki/memory.md index f699c4015..30d35e7f7 100644 --- a/wiki/memory.md +++ b/wiki/memory.md @@ -159,15 +159,13 @@ client = with_memory(OpenAI(), user_id="alice") # Use exactly like normal response = client.chat.completions.create( - model="gpt-4o", - messages=[{"role": "user", "content": "I prefer Python for backend work"}] + model="gpt-4o", messages=[{"role": "user", "content": "I prefer Python for backend work"}] ) # Memory extracted INLINE - zero extra latency # Later, in a new conversation... response = client.chat.completions.create( - model="gpt-4o", - messages=[{"role": "user", "content": "What language should I use?"}] + model="gpt-4o", messages=[{"role": "user", "content": "What language should I use?"}] ) # → Response uses the Python preference from memory ``` @@ -228,7 +226,7 @@ client1 = with_memory( ) response = client1.chat.completions.create( model="gpt-4o", - messages=[{"role": "user", "content": "I prefer Go for performance-critical code"}] + messages=[{"role": "user", "content": "I prefer Go for performance-critical code"}], ) # Memory stored at USER level (persists across sessions) @@ -239,8 +237,7 @@ client2 = with_memory( session_id="afternoon-session", # Different session ) response = client2.chat.completions.create( - model="gpt-4o", - messages=[{"role": "user", "content": "What language for my new microservice?"}] + model="gpt-4o", messages=[{"role": "user", "content": "What language for my new microservice?"}] ) # → Recalls Go preference from morning session! ``` @@ -270,17 +267,21 @@ new = await memory.supersede( ) # Query current state (excludes superseded) -current = await memory.query(MemoryFilter( - user_id="alice", - include_superseded=False, # Default -)) +current = await memory.query( + MemoryFilter( + user_id="alice", + include_superseded=False, # Default + ) +) # → Returns only "User now works at Anthropic" # Query full history (includes superseded) -history = await memory.query(MemoryFilter( - user_id="alice", - include_superseded=True, -)) +history = await memory.query( + MemoryFilter( + user_id="alice", + include_superseded=True, + ) +) # → Returns both memories with validity timestamps # Get the chain @@ -362,6 +363,7 @@ from headroom.memory import ( ) from headroom.memory.ports import MemoryFilter, VectorFilter + async def main(): # Create with custom configuration config = MemoryConfig( @@ -402,17 +404,20 @@ async def main(): ) # Query with filters - memories = await memory.query(MemoryFilter( - user_id="alice", - categories=[MemoryCategory.PREFERENCE, MemoryCategory.FACT], - min_importance=0.7, - limit=10, - )) + memories = await memory.query( + MemoryFilter( + user_id="alice", + categories=[MemoryCategory.PREFERENCE, MemoryCategory.FACT], + min_importance=0.7, + limit=10, + ) + ) # Convenience methods await memory.remember("Likes coffee", user_id="alice", importance=0.6) relevant = await memory.recall("beverage preferences", user_id="alice") + asyncio.run(main()) ``` @@ -482,13 +487,13 @@ Apple GPU instead of the default ONNX CPU embedder. Notes: ```python config = MemoryConfig( - db_path="memory.db", # SQLite database path - vector_dimension=384, # Must match embedder output - hnsw_ef_construction=200, # HNSW index quality (higher = better, slower) - hnsw_m=16, # HNSW connections per node - hnsw_ef_search=50, # HNSW search quality - cache_enabled=True, # Enable LRU cache - cache_max_size=1000, # Max cached memories + db_path="memory.db", # SQLite database path + vector_dimension=384, # Must match embedder output + hnsw_ef_construction=200, # HNSW index quality (higher = better, slower) + hnsw_m=16, # HNSW connections per node + hnsw_ef_search=50, # HNSW search quality + cache_enabled=True, # Enable LRU cache + cache_max_size=1000, # Max cached memories ) ``` @@ -499,7 +504,7 @@ client = with_memory( OpenAI(), user_id="alice", db_path="memory.db", - top_k=5, # Memories to inject per request + top_k=5, # Memories to inject per request session_id="optional-session", agent_id="optional-agent", embedder_backend=EmbedderBackend.LOCAL, @@ -659,6 +664,7 @@ client = with_memory( # Groq from groq import Groq + client = with_memory(Groq(), user_id="alice") # Any OpenAI-compatible client @@ -678,10 +684,12 @@ client = with_memory(OpenAI(), user_id="developer_jane") # Conversation 1: User shares context response = client.chat.completions.create( model="gpt-4o", - messages=[{ - "role": "user", - "content": "I'm a Python developer at a fintech startup. We use PostgreSQL and FastAPI." - }] + messages=[ + { + "role": "user", + "content": "I'm a Python developer at a fintech startup. We use PostgreSQL and FastAPI.", + } + ], ) # Memories extracted: # - [FACT] Python developer at fintech startup @@ -691,10 +699,7 @@ response = client.chat.completions.create( # Conversation 2 (new session): User asks question response = client.chat.completions.create( model="gpt-4o", - messages=[{ - "role": "user", - "content": "What database should I use for my new project?" - }] + messages=[{"role": "user", "content": "What database should I use for my new project?"}], ) # Response references PostgreSQL preference from memory: # → "Given your experience with PostgreSQL at your fintech company, diff --git a/wiki/metrics.md b/wiki/metrics.md index 01c09612e..12f8d0c5f 100644 --- a/wiki/metrics.md +++ b/wiki/metrics.md @@ -336,19 +336,19 @@ print(stats) "tokens_saved_total": 15000, "tokens_output_total": 8000, "cache_hits": 3, - "compression_ratio_avg": 0.70 + "compression_ratio_avg": 0.70, }, "config": { "mode": "optimize", "provider": "openai", "cache_optimizer_enabled": True, - "semantic_cache_enabled": False + "semantic_cache_enabled": False, }, "transforms": { "smart_crusher_enabled": True, "cache_aligner_enabled": True, - "rolling_window_enabled": True - } + "rolling_window_enabled": True, + }, } ``` diff --git a/wiki/plans/dynamic-smart-crusher.md b/wiki/plans/dynamic-smart-crusher.md index 88754bf79..648927eb2 100644 --- a/wiki/plans/dynamic-smart-crusher.md +++ b/wiki/plans/dynamic-smart-crusher.md @@ -72,17 +72,18 @@ Different data patterns need different position importance: ```python class AnchorStrategy(Enum): - FRONT_HEAVY = "front_heavy" # Search results: top items matter most - BACK_HEAVY = "back_heavy" # Logs: recent items matter most - BALANCED = "balanced" # Time series: both ends matter - MIDDLE_AWARE = "middle_aware" # Database: order might be arbitrary + FRONT_HEAVY = "front_heavy" # Search results: top items matter most + BACK_HEAVY = "back_heavy" # Logs: recent items matter most + BALANCED = "balanced" # Time series: both ends matter + MIDDLE_AWARE = "middle_aware" # Database: order might be arbitrary + def get_anchor_strategy(pattern: DataPattern) -> AnchorStrategy: return { DataPattern.SEARCH_RESULTS: AnchorStrategy.FRONT_HEAVY, # Top N by score - DataPattern.LOGS: AnchorStrategy.BACK_HEAVY, # Recency matters - DataPattern.TIME_SERIES: AnchorStrategy.BALANCED, # Both ends for trend - DataPattern.GENERIC: AnchorStrategy.MIDDLE_AWARE, # Don't assume order + DataPattern.LOGS: AnchorStrategy.BACK_HEAVY, # Recency matters + DataPattern.TIME_SERIES: AnchorStrategy.BALANCED, # Both ends for trend + DataPattern.GENERIC: AnchorStrategy.MIDDLE_AWARE, # Don't assume order }.get(pattern, AnchorStrategy.BALANCED) ``` @@ -150,7 +151,7 @@ def select_informative_anchors( items: list[dict], region: str, # "front", "back", "middle" slots: int, - all_items_hash: set[str] + all_items_hash: set[str], ) -> list[int]: """Select most informative items from a region.""" @@ -195,7 +196,7 @@ def calculate_information_score(item: dict, all_items: list[dict]) -> float: for field, value in item.items(): field_values = [i.get(field) for i in all_items if field in i] value_frequency = field_values.count(value) / len(field_values) - score += (1 - value_frequency) # Rare values score higher + score += 1 - value_frequency # Rare values score higher # 2. Structural uniqueness - different fields than typical typical_fields = get_typical_fields(all_items) @@ -221,6 +222,7 @@ Track which positions users actually retrieve and learn from it: @dataclass class PositionRetrievalPattern: """Learned position importance from retrieval data.""" + tool_name: str total_compressions: int position_retrievals: dict[str, int] # "front_10%", "middle", "back_10%" @@ -231,21 +233,13 @@ class PositionRetrievalPattern: if total == 0: return {"front": 0.5, "middle": 0.0, "back": 0.5} - return { - position: count / total - for position, count in self.position_retrievals.items() - } + return {position: count / total for position, count in self.position_retrievals.items()} class TOINPositionLearning: """Learn position importance from retrieval patterns.""" - def record_retrieval( - self, - tool_name: str, - original_size: int, - retrieved_indices: list[int] - ): + def record_retrieval(self, tool_name: str, original_size: int, retrieved_indices: list[int]): """Record which positions were retrieved.""" for idx in retrieved_indices: position = self._classify_position(idx, original_size) @@ -287,9 +281,7 @@ For large arrays, sample strategically from middle: ```python def stratified_middle_sample( - items: list[dict], - num_samples: int, - analysis: ArrayAnalysis + items: list[dict], num_samples: int, analysis: ArrayAnalysis ) -> list[int]: """Sample middle positions using stratified approach.""" @@ -310,9 +302,7 @@ def stratified_middle_sample( if analysis.numeric_fields: variance_scores = calculate_position_variance(items, analysis.numeric_fields) sorted_by_variance = sorted( - middle_items, - key=lambda i: variance_scores.get(i, 0), - reverse=True + middle_items, key=lambda i: variance_scores.get(i, 0), reverse=True ) return sorted(sorted_by_variance[:num_samples]) @@ -383,11 +373,7 @@ class TestAdversarialPositions: items[42]["name"] = "target_item" items[42]["description"] = "This is what user asked about" - result = smart_crusher.crush( - items, - max_items=10, - query="find target_item" - ) + result = smart_crusher.crush(items, max_items=10, query="find target_item") # Query-matched item MUST be preserved assert any("target_item" in item.get("name", "") for item in result) @@ -399,12 +385,15 @@ class TestAdversarialPositions: class TestSizeAdaptation: """Test that anchor allocation scales with array size.""" - @pytest.mark.parametrize("size,expected_min_anchors", [ - (20, 3), # Small array: at least 3 anchors - (100, 4), # Medium array: at least 4 anchors - (500, 5), # Large array: at least 5 anchors - (2000, 6), # Very large: at least 6 anchors - ]) + @pytest.mark.parametrize( + "size,expected_min_anchors", + [ + (20, 3), # Small array: at least 3 anchors + (100, 4), # Medium array: at least 4 anchors + (500, 5), # Large array: at least 5 anchors + (2000, 6), # Very large: at least 6 anchors + ], + ) def test_anchor_count_scales(self, size, expected_min_anchors): """Anchor count should increase with array size.""" items = [{"id": i, "value": i * 10} for i in range(size)] @@ -413,8 +402,7 @@ class TestSizeAdaptation: # Count items from first 10% and last 10% anchor_count = sum( - 1 for item in result - if item["id"] < size * 0.1 or item["id"] > size * 0.9 + 1 for item in result if item["id"] < size * 0.1 or item["id"] > size * 0.9 ) assert anchor_count >= expected_min_anchors @@ -454,10 +442,7 @@ class TestPatternAwareAnchoring: def test_search_results_front_heavy(self): """Search results should preserve more from front.""" - items = [ - {"title": f"Result {i}", "score": 1.0 - (i * 0.01)} - for i in range(100) - ] + items = [{"title": f"Result {i}", "score": 1.0 - (i * 0.01)} for i in range(100)] result = smart_crusher.crush(items, max_items=10) @@ -485,10 +470,7 @@ class TestPatternAwareAnchoring: def test_time_series_balanced(self): """Time series should have balanced front/back.""" - items = [ - {"timestamp": f"2024-01-01T{i:02d}:00:00", "value": 100 + i} - for i in range(24) - ] + items = [{"timestamp": f"2024-01-01T{i:02d}:00:00", "value": 100 + i} for i in range(24)] result = smart_crusher.crush(items, max_items=8) @@ -510,11 +492,7 @@ class TestQueryAwareAnchoring: """'Latest' in query should preserve more recent items.""" items = [{"id": i, "created": f"2024-01-{i:02d}"} for i in range(1, 31)] - result = smart_crusher.crush( - items, - max_items=8, - query="Show me the latest entries" - ) + result = smart_crusher.crush(items, max_items=8, query="Show me the latest entries") ids = [item["id"] for item in result] recent_count = sum(1 for id in ids if id > 20) @@ -525,11 +503,7 @@ class TestQueryAwareAnchoring: """'First' in query should preserve earlier items.""" items = [{"id": i, "created": f"2024-01-{i:02d}"} for i in range(1, 31)] - result = smart_crusher.crush( - items, - max_items=8, - query="Show me the first entries" - ) + result = smart_crusher.crush(items, max_items=8, query="Show me the first entries") ids = [item["id"] for item in result] early_count = sum(1 for id in ids if id < 10) @@ -540,11 +514,7 @@ class TestQueryAwareAnchoring: """Query for specific ID should find it regardless of position.""" items = [{"id": f"item_{i:04d}", "value": i} for i in range(1000)] - result = smart_crusher.crush( - items, - max_items=10, - query="Find item_0567" - ) + result = smart_crusher.crush(items, max_items=10, query="Find item_0567") assert any(item["id"] == "item_0567" for item in result) ``` @@ -574,8 +544,7 @@ class TestCoverageMetrics: def test_category_coverage(self): """Preserved items should represent all categories.""" items = [ - {"category": cat, "id": i} - for i, cat in enumerate(["A"] * 30 + ["B"] * 30 + ["C"] * 40) + {"category": cat, "id": i} for i, cat in enumerate(["A"] * 30 + ["B"] * 30 + ["C"] * 40) ] result = smart_crusher.crush(items, max_items=10) @@ -608,10 +577,7 @@ class TestRetrievalSimulation: def test_retrieval_hit_rate_random_queries(self): """Measure how often preserved items satisfy random queries.""" - items = [ - {"id": i, "name": f"Item {i}", "category": f"cat_{i % 5}"} - for i in range(100) - ] + items = [{"id": i, "name": f"Item {i}", "category": f"cat_{i % 5}"} for i in range(100)] compressed = smart_crusher.crush(items, max_items=15) compressed_ids = {item["id"] for item in compressed} @@ -636,9 +602,9 @@ class TestRetrievalSimulation: # Weight queries toward front (30%), back (30%), anomalies (40%) hits = 0 queries = ( - list(range(10)) * 3 + # Front queries - list(range(90, 100)) * 3 + # Back queries - [50] * 4 # Middle anomaly queries + list(range(10)) * 3 # Front queries + + list(range(90, 100)) * 3 # Back queries + + [50] * 4 # Middle anomaly queries ) for target_id in queries: @@ -718,12 +684,12 @@ class AnchorConfig: time_series_balance: float = 0.5 # Query keyword detection - recency_keywords: list[str] = field(default_factory=lambda: [ - "latest", "recent", "last", "newest", "current" - ]) - historical_keywords: list[str] = field(default_factory=lambda: [ - "first", "oldest", "earliest", "original", "initial" - ]) + recency_keywords: list[str] = field( + default_factory=lambda: ["latest", "recent", "last", "newest", "current"] + ) + historical_keywords: list[str] = field( + default_factory=lambda: ["first", "oldest", "earliest", "original", "initial"] + ) # Information density selection use_information_density: bool = True diff --git a/wiki/proxy.md b/wiki/proxy.md index 184640916..03c586b68 100644 --- a/wiki/proxy.md +++ b/wiki/proxy.md @@ -323,13 +323,18 @@ The provider caches the bytes you *forwarded*, which compression already changed ```python forwarded = [] + + def next_turn(new_messages): - r = requests.post(f"{proxy}/v1/compress", json={ - "messages": forwarded + new_messages, - "model": "claude-sonnet-4-6", - "config": {"frozen_message_count": len(forwarded)}, - }).json() - forwarded[:] = r["messages"] # next turn's frozen prefix + r = requests.post( + f"{proxy}/v1/compress", + json={ + "messages": forwarded + new_messages, + "model": "claude-sonnet-4-6", + "config": {"frozen_message_count": len(forwarded)}, + }, + ).json() + forwarded[:] = r["messages"] # next turn's frozen prefix return forwarded ``` diff --git a/wiki/quickstart.md b/wiki/quickstart.md index 417b779b0..405b32404 100644 --- a/wiki/quickstart.md +++ b/wiki/quickstart.md @@ -149,19 +149,21 @@ messages = [ { "role": "assistant", "content": None, - "tool_calls": [{ - "id": "call_1", - "type": "function", - "function": {"name": "search", "arguments": '{"q": "python"}'}, - }], + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "search", "arguments": '{"q": "python"}'}, + } + ], }, { "role": "tool", "tool_call_id": "call_1", # This is where Headroom shines - compressing large outputs - "content": json.dumps({ - "results": [{"title": f"Result {i}", "score": 100-i} for i in range(500)] - }), + "content": json.dumps( + {"results": [{"title": f"Result {i}", "score": 100 - i} for i in range(500)]} + ), }, {"role": "user", "content": "What are the top 3 results?"}, ] @@ -188,7 +190,9 @@ plan = client.chat.completions.simulate( print(f"Tokens before: {plan.tokens_before}") print(f"Tokens after: {plan.tokens_after}") -print(f"Would save: {plan.tokens_saved} tokens ({plan.tokens_saved/plan.tokens_before*100:.0f}%)") +print( + f"Would save: {plan.tokens_saved} tokens ({plan.tokens_saved / plan.tokens_before * 100:.0f}%)" +) print(f"Transforms: {plan.transforms}") print(f"Estimated savings: {plan.estimated_savings}") ``` @@ -227,6 +231,7 @@ print(response.content[0].text) ```python import logging + logging.basicConfig(level=logging.INFO) # Now you'll see: @@ -347,6 +352,7 @@ print(stats["config"]["mode"]) # Should be "optimize" # 2. Enable logging to see what's happening import logging + logging.basicConfig(level=logging.DEBUG) ``` diff --git a/wiki/sdk.md b/wiki/sdk.md index 81fe5af90..674d5c3e6 100644 --- a/wiki/sdk.md +++ b/wiki/sdk.md @@ -45,30 +45,26 @@ messages = [ { "role": "assistant", "content": None, - "tool_calls": [{ - "id": "call_123", - "type": "function", - "function": {"name": "search", "arguments": '{"q": "python"}'}, - }], + "tool_calls": [ + { + "id": "call_123", + "type": "function", + "function": {"name": "search", "arguments": '{"q": "python"}'}, + } + ], }, { "role": "tool", "tool_call_id": "call_123", - "content": json.dumps({ - "results": [ - {"title": f"Tutorial {i}", "score": 100-i} - for i in range(500) - ] - }), + "content": json.dumps( + {"results": [{"title": f"Tutorial {i}", "score": 100 - i} for i in range(500)]} + ), }, {"role": "user", "content": "What are the top 3?"}, ] # Headroom compresses 500 results to ~15, keeping highest-scoring items -response = client.chat.completions.create( - model="gpt-4o-mini", - messages=messages -) +response = client.chat.completions.create(model="gpt-4o-mini", messages=messages) # Check savings stats = client.get_stats() @@ -187,13 +183,10 @@ print(f"Transforms: {plan.transforms}") response = client.chat.completions.create( model="gpt-4o", messages=[...], - # Override mode for this request headroom_mode="audit", - # Reserve more tokens for output headroom_output_buffer_tokens=8000, - # Keep last N turns headroom_keep_turns=5, ) @@ -203,6 +196,7 @@ response = client.chat.completions.create( ```python import logging + logging.basicConfig(level=logging.INFO) # Now you'll see: diff --git a/wiki/shared-context.md b/wiki/shared-context.md index 72a9f133f..949180e9c 100644 --- a/wiki/shared-context.md +++ b/wiki/shared-context.md @@ -28,10 +28,10 @@ Store content under a key. Compresses automatically using Headroom's full pipeli ```python entry = ctx.put("findings", big_json_output, agent="researcher") -entry.original_tokens # 20,000 -entry.compressed_tokens # 4,000 -entry.savings_percent # 80.0 -entry.transforms # ["router:json:0.20"] +entry.original_tokens # 20,000 +entry.compressed_tokens # 4,000 +entry.savings_percent # 80.0 +entry.transforms # ["router:json:0.20"] ``` ### `get(key, *, full=False)` @@ -39,9 +39,9 @@ entry.transforms # ["router:json:0.20"] Retrieve content. Returns compressed version by default, original with `full=True`. ```python -compressed = ctx.get("findings") # 4K tokens +compressed = ctx.get("findings") # 4K tokens original = ctx.get("findings", full=True) # 20K tokens -missing = ctx.get("nonexistent") # None +missing = ctx.get("nonexistent") # None ``` ### `get_entry(key)` @@ -50,13 +50,13 @@ Get the full `ContextEntry` with metadata. ```python entry = ctx.get_entry("findings") -entry.key # "findings" -entry.agent # "researcher" -entry.original_tokens # 20000 +entry.key # "findings" +entry.agent # "researcher" +entry.original_tokens # 20000 entry.compressed_tokens # 4000 -entry.savings_percent # 80.0 -entry.timestamp # 1710000000.0 -entry.transforms # ["router:json:0.20"] +entry.savings_percent # 80.0 +entry.timestamp # 1710000000.0 +entry.transforms # ["router:json:0.20"] ``` ### `keys()` @@ -69,11 +69,11 @@ Aggregated stats across all entries. ```python stats = ctx.stats() -stats.entries # 3 -stats.total_original_tokens # 60000 +stats.entries # 3 +stats.total_original_tokens # 60000 stats.total_compressed_tokens # 12000 -stats.total_tokens_saved # 48000 -stats.savings_percent # 80.0 +stats.total_tokens_saved # 48000 +stats.savings_percent # 80.0 ``` ### `clear()` @@ -85,8 +85,8 @@ Remove all entries. ```python ctx = SharedContext( model="claude-sonnet-4-5-20250929", # For token counting - ttl=3600, # 1 hour (default) - max_entries=100, # Evicts oldest when full + ttl=3600, # 1 hour (default) + max_entries=100, # Evicts oldest when full ) ``` @@ -113,11 +113,13 @@ from headroom import SharedContext ctx = SharedContext() + def researcher_node(state): result = do_research() ctx.put("research", result) return {"research_summary": ctx.get("research")} + def coder_node(state): # Compressed summary in state, full details on demand full = ctx.get("research", full=True) @@ -131,6 +133,7 @@ from headroom import SharedContext ctx = SharedContext() + def compress_handoff(messages): for msg in messages: if len(msg.content) > 1000: @@ -138,6 +141,7 @@ def compress_handoff(messages): msg.content = ctx.get(msg.id) return messages + handoff(agent=coder, input_filter=compress_handoff) ``` diff --git a/wiki/strands.md b/wiki/strands.md index 585340c35..172e21b2b 100644 --- a/wiki/strands.md +++ b/wiki/strands.md @@ -114,11 +114,13 @@ HeadroomStrandsModel supports Strands' structured output feature: ```python from pydantic import BaseModel + class Analysis(BaseModel): severity: str root_cause: str recommendation: str + result = optimized.structured_output(Analysis, messages) ``` diff --git a/wiki/text-compression.md b/wiki/text-compression.md index 8fd4d743a..8656ea7e9 100644 --- a/wiki/text-compression.md +++ b/wiki/text-compression.md @@ -136,10 +136,14 @@ elif detection.content_type == ContentType.PLAIN_TEXT: ```python from headroom.transforms import ( - detect_content_type, ContentType, - SearchCompressor, LogCompressor, TextCompressor + detect_content_type, + ContentType, + SearchCompressor, + LogCompressor, + TextCompressor, ) + def compress_tool_output(content: str, context: str = "") -> str: """Application-level compression with explicit control.""" detection = detect_content_type(content) @@ -166,7 +170,7 @@ Each compressor accepts configuration options: from headroom.transforms import SearchCompressor, SearchCompressorConfig config = SearchCompressorConfig( - max_results=50, # Keep up to 50 matches + max_results=50, # Keep up to 50 matches preserve_file_diversity=True, # Ensure different files represented relevance_threshold=0.3, # Minimum relevance score to keep ) diff --git a/wiki/transforms.md b/wiki/transforms.md index 30ea74f7f..b599b5164 100644 --- a/wiki/transforms.md +++ b/wiki/transforms.md @@ -22,13 +22,13 @@ SmartCrusher analyzes JSON arrays and selectively keeps important items: from headroom import SmartCrusherConfig config = SmartCrusherConfig( - min_tokens_to_crush=200, # Only compress if > 200 tokens - max_items_after_crush=50, # Keep at most 50 items - keep_first=3, # Always keep first 3 items - keep_last=2, # Always keep last 2 items - relevance_threshold=0.3, # Keep items with relevance > 0.3 - anomaly_std_threshold=2.0, # Keep items > 2 std dev from mean - preserve_errors=True, # Always keep error items + min_tokens_to_crush=200, # Only compress if > 200 tokens + max_items_after_crush=50, # Keep at most 50 items + keep_first=3, # Always keep first 3 items + keep_last=2, # Always keep last 2 items + relevance_threshold=0.3, # Keep items with relevance > 0.3 + anomaly_std_threshold=2.0, # Keep items > 2 std dev from mean + preserve_errors=True, # Always keep error items ) ``` @@ -94,9 +94,9 @@ result = aligner.align(messages) from headroom import CacheAlignerConfig config = CacheAlignerConfig( - extract_dates=True, # Move dates to dynamic section - normalize_whitespace=True, # Consistent spacing - stable_prefix_min_tokens=100, # Min prefix size for alignment + extract_dates=True, # Move dates to dynamic section + normalize_whitespace=True, # Consistent spacing + stable_prefix_min_tokens=100, # Min prefix size for alignment ) ``` @@ -170,16 +170,16 @@ pip install "headroom-ai[code]" # Adds tree-sitter-language-pack from headroom.transforms import CodeAwareCompressor, CodeCompressorConfig, DocstringMode config = CodeCompressorConfig( - preserve_imports=True, # Always keep imports - preserve_signatures=True, # Always keep function signatures - preserve_type_annotations=True, # Keep type hints - preserve_error_handlers=True, # Keep try/except blocks - preserve_decorators=True, # Keep decorators + preserve_imports=True, # Always keep imports + preserve_signatures=True, # Always keep function signatures + preserve_type_annotations=True, # Keep type hints + preserve_error_handlers=True, # Keep try/except blocks + preserve_decorators=True, # Keep decorators docstring_mode=DocstringMode.FIRST_LINE, # FULL, FIRST_LINE, REMOVE - target_compression_rate=0.2, # Keep 20% of tokens - max_body_lines=5, # Lines to keep per function body - min_tokens_for_compression=100, # Skip small content - language_hint=None, # Auto-detect if None + target_compression_rate=0.2, # Keep 20% of tokens + max_body_lines=5, # Lines to keep per function body + min_tokens_for_compression=100, # Skip small content + language_hint=None, # Auto-detect if None ) compressor = CodeAwareCompressor(config) @@ -263,10 +263,10 @@ ContentRouter analyzes content and selects the best compression strategy: from headroom.transforms import ContentRouter, ContentRouterConfig, CompressionStrategy config = ContentRouterConfig( - min_section_tokens=100, # Minimum tokens to compress - enable_code_aware=True, # Use CodeAwareCompressor for code - enable_search_compression=True, # Use SearchCompressor for grep output - enable_log_compression=True, # Use LogCompressor for logs + min_section_tokens=100, # Minimum tokens to compress + enable_code_aware=True, # Use CodeAwareCompressor for code + enable_search_compression=True, # Use SearchCompressor for grep output + enable_log_compression=True, # Use LogCompressor for logs default_strategy=CompressionStrategy.TEXT, # Fallback strategy ) @@ -331,10 +331,12 @@ Combine transforms for optimal results. ```python from headroom import TransformPipeline, SmartCrusher, CacheAligner -pipeline = TransformPipeline([ - SmartCrusher(), # First: compress tool outputs - CacheAligner(), # Then: stabilize prefix -]) +pipeline = TransformPipeline( + [ + SmartCrusher(), # First: compress tool outputs + CacheAligner(), # Then: stabilize prefix + ] +) result = pipeline.transform(messages) print(f"Saved {result.tokens_saved} tokens") diff --git a/wiki/troubleshooting.md b/wiki/troubleshooting.md index 35907f057..ca505fcb0 100644 --- a/wiki/troubleshooting.md +++ b/wiki/troubleshooting.md @@ -224,6 +224,7 @@ client = HeadroomClient( # 2. For temp directory storage import tempfile import os + db_path = os.path.join(tempfile.gettempdir(), "headroom.db") client = HeadroomClient( original_client=OpenAI(), @@ -287,6 +288,7 @@ pip install --upgrade headroom-ai ```python # Check available imports import headroom + print(dir(headroom)) # Common imports: @@ -397,6 +399,7 @@ print(f"Waste signals: {plan.waste_signals}") # See the actual optimized messages import json + print(json.dumps(plan.messages_optimized, indent=2)) ``` From 5731be7e68f57292aed40d76e770657a88f78c13 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:39:31 -0500 Subject: [PATCH 02/53] deps: bump axum from 0.7.9 to 0.8.9 (#2966) Bumps [axum](https://github.com/tokio-rs/axum) from 0.7.9 to 0.8.9.
Release notes

Sourced from axum's releases.

axum-v0.8.9

  • added: WebSocketUpgrade::{requested_protocols, set_selected_protocol} for more flexible subprotocol selection (#3597)
  • changed: Update minimum rust version to 1.80 (#3620)
  • fixed: Set connect endpoint on correct field in MethodRouter (#3656)
  • fixed: Return specific error message when multipart body limit is exceeded (#3611)

#3597: tokio-rs/axum#3597 #3620: tokio-rs/axum#3620 #3656: tokio-rs/axum#3656 #3611: tokio-rs/axum#3611

axum v0.8.8

  • Clarify documentation for Router::route_layer (#3567)

#3567: tokio-rs/axum#3567

axum v0.8.7

  • Relax implicit Send / Sync bounds on RouterAsService, RouterIntoService (#3555)
  • Make it easier to visually scan for default features (#3550)
  • Fix some documentation typos

#3550: tokio-rs/axum#3550 #3555: tokio-rs/axum#3555

axum v0.8.5

  • fixed: Reject JSON request bodies with trailing characters after the JSON document (#3453)
  • added: Implement OptionalFromRequest for Multipart (#3220)
  • added: Getter methods Location::{status_code, location}
  • added: Support for writing arbitrary binary data into server-sent events (#3425)]
  • added: middleware::ResponseAxumBodyLayer for mapping response body to axum::body::Body (#3469)
  • added: impl FusedStream for WebSocket (#3443)
  • changed: The sse module and Sse type no longer depend on the tokio feature (#3154)
  • changed: If the location given to one of Redirects constructors is not a valid header value, instead of panicking on construction, the IntoResponse impl now returns an HTTP 500, just like Json does when serialization fails (#3377)
  • changed: Update minimum rust version to 1.78 (#3412)

#3154: tokio-rs/axum#3154 #3220: tokio-rs/axum#3220 #3377: tokio-rs/axum#3377 #3412: tokio-rs/axum#3412 #3425: tokio-rs/axum#3425 #3443: tokio-rs/axum#3443 #3453: tokio-rs/axum#3453 #3469: tokio-rs/axum#3469

axum v0.8.4

  • added: Router::reset_fallback (#3320)
  • added: WebSocketUpgrade::selected_protocol (#3248)
  • fixed: Panic location for overlapping method routes (#3319)
  • fixed: Don't leak a tokio task when using serve without graceful shutdown (#3129)

... (truncated)

Commits

--------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: JerrettDavis --- Cargo.lock | 125 ++++++------------ Cargo.toml | 2 +- crates/headroom-proxy/src/proxy.rs | 33 +++-- crates/headroom-proxy/src/websocket.rs | 8 +- .../tests/integration_bedrock_authmode.rs | 2 +- 5 files changed, 67 insertions(+), 103 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2da32bedf..7e112128f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -194,9 +194,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.92" +version = "0.1.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" dependencies = [ "proc-macro2", "quote", @@ -260,9 +260,9 @@ dependencies = [ [[package]] name = "aws-config" -version = "1.10.1" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b180a3c8b55960db3426d8964b8745e652466a1a49fe1a2eda828046d30b5e4" +checksum = "701418aa459dac33e50a0f8e818e5662a16bc018a6ac7423659b70f3799d67a8" dependencies = [ "aws-credential-types", "aws-runtime", @@ -325,9 +325,9 @@ dependencies = [ [[package]] name = "aws-runtime" -version = "1.9.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9007227e10b5fed2f3e0a2beff489211e2b5604c400b7a9d5d81ca9d64c24bb" +checksum = "a6b50a43f3ccdf331521c6d6c68b7cc9668b6e09d439ebda9569df5722324d76" dependencies = [ "aws-credential-types", "aws-sigv4", @@ -350,9 +350,9 @@ dependencies = [ [[package]] name = "aws-sdk-sso" -version = "1.105.0" +version = "1.104.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ffd0fbe7873cb548a7aa60f9573c268fff94155397fd4f14dc9f1ecaaab8516" +checksum = "b53416d16c278234845392e38d93bd4481d2f09daa0f005a2277f0aa91f59c22" dependencies = [ "arc-swap", "aws-credential-types", @@ -376,9 +376,9 @@ dependencies = [ [[package]] name = "aws-sdk-ssooidc" -version = "1.107.0" +version = "1.106.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175763eb222a46377df7aa257a3bca980ab3e96703fefc8f4d0b8da6ad2e254c" +checksum = "cc9b706c3305ed0285d5b1b696c747aa34950f830fb03e3e6c76890f99b9f188" dependencies = [ "arc-swap", "aws-credential-types", @@ -402,9 +402,9 @@ dependencies = [ [[package]] name = "aws-sdk-sts" -version = "1.110.0" +version = "1.109.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd8b14781dfbff48984017d57167b6ea0b6471c6920ec52b44a2677c7feb3c13" +checksum = "32d214cdfa5bbe17f117e76a7643fadf32a5234fb597322ef8b1fb4b2f17dbbd" dependencies = [ "arc-swap", "aws-credential-types", @@ -540,9 +540,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime" -version = "1.12.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07505b34e8f4b3591a4fa69e9792b52289b95488dbbc68c3c0075b7bedb245e1" +checksum = "bea94a9ff8464016338c851e24b472d7131c388c88898a502e781815b2ee6045" dependencies = [ "aws-smithy-async", "aws-smithy-http", @@ -656,15 +656,15 @@ dependencies = [ [[package]] name = "axum" -version = "0.7.9" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ - "async-trait", "axum-core", "axum-macros", "base64 0.22.1", "bytes", + "form_urlencoded", "futures-util", "http 1.5.0", "http-body 1.0.1", @@ -677,15 +677,14 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rustversion", - "serde", + "serde_core", "serde_json", "serde_path_to_error", "serde_urlencoded", "sha1 0.10.6", "sync_wrapper", "tokio", - "tokio-tungstenite 0.24.0", + "tokio-tungstenite 0.29.0", "tower", "tower-layer", "tower-service", @@ -694,19 +693,17 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.4.5" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ - "async-trait", "bytes", - "futures-util", + "futures-core", "http 1.5.0", "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", - "rustversion", "sync_wrapper", "tower-layer", "tower-service", @@ -715,9 +712,9 @@ dependencies = [ [[package]] name = "axum-macros" -version = "0.4.2" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" dependencies = [ "proc-macro2", "quote", @@ -894,9 +891,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.2" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" +checksum = "9066c49992464636f92905fa096ec58baaa4d57ec19a5c096c68d3e25ef3d136" dependencies = [ "find-msvc-tools", "jobserver", @@ -2564,9 +2561,9 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.38.2" +version = "0.38.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db" dependencies = [ "cc", "pkg-config", @@ -2669,9 +2666,9 @@ dependencies = [ [[package]] name = "matchit" -version = "0.7.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "matrixmultiply" @@ -3214,7 +3211,7 @@ dependencies = [ "bitflags", "num-traits", "rand 0.9.4", - "rand_chacha 0.9.0", + "rand_chacha", "rand_xorshift", "regex-syntax", "rusty-fork", @@ -3393,24 +3390,13 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - [[package]] name = "rand" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ - "rand_chacha 0.9.0", + "rand_chacha", "rand_core 0.9.5", ] @@ -3425,16 +3411,6 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - [[package]] name = "rand_chacha" version = "0.9.0" @@ -3445,15 +3421,6 @@ dependencies = [ "rand_core 0.9.5", ] -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - [[package]] name = "rand_core" version = "0.9.5" @@ -3506,7 +3473,7 @@ dependencies = [ "paste", "profiling", "rand 0.9.4", - "rand_chacha 0.9.0", + "rand_chacha", "simd_helpers", "thiserror 2.0.20", "v_frame", @@ -3710,9 +3677,9 @@ dependencies = [ [[package]] name = "rusqlite" -version = "0.40.2" +version = "0.40.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323" dependencies = [ "bitflags", "fallible-iterator", @@ -4433,14 +4400,14 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.24.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" dependencies = [ "futures-util", "log", "tokio", - "tungstenite 0.24.0", + "tungstenite 0.29.0", ] [[package]] @@ -4770,20 +4737,18 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tungstenite" -version = "0.24.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ - "byteorder", "bytes", "data-encoding", "http 1.5.0", "httparse", "log", - "rand 0.8.6", + "rand 0.9.4", "sha1 0.10.6", - "thiserror 1.0.69", - "utf-8", + "thiserror 2.0.20", ] [[package]] @@ -4922,12 +4887,6 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "utf8-zero" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index 07c308e1a..32fd4aed4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,7 +58,7 @@ tracing = { version = "0.1", features = ["log"] } anyhow = "1" clap = { version = "4", features = ["derive"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] } -axum = "0.7" +axum = "0.8" tower = "0.5" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } pyo3 = { version = "0.29", features = ["abi3-py310"] } diff --git a/crates/headroom-proxy/src/proxy.rs b/crates/headroom-proxy/src/proxy.rs index 1005fb03f..0389e14c2 100644 --- a/crates/headroom-proxy/src/proxy.rs +++ b/crates/headroom-proxy/src/proxy.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use std::time::Instant; use axum::body::{to_bytes, Body}; -use axum::extract::{ConnectInfo, DefaultBodyLimit, State, WebSocketUpgrade}; +use axum::extract::{ConnectInfo, DefaultBodyLimit, FromRequestParts, State, WebSocketUpgrade}; use axum::http::{HeaderMap, HeaderName, Request, Response, StatusCode, Uri}; use axum::response::IntoResponse; use axum::routing::{any, get, post}; @@ -189,14 +189,14 @@ pub fn build_app(state: AppState) -> Router { // publisher endpoints look like // `POST /v1beta1/projects/{p}/locations/{l}/publishers/anthropic/models/{m}:rawPredict` // (and `:streamRawPredict`). The trailing `:` is awkward - // in axum's `:param` syntax, so we capture the entire trailing - // segment as `:model_action` and split on the last `:` inside + // in axum's `{param}` syntax, so we capture the entire trailing + // segment as `{model_action}` and split on the last `:` inside // the dispatcher. Both verbs share the same axum route shape // — matchit can't distinguish two patterns that overlap on the // literal parameter. The verb dispatch lives in // [`crate::vertex::handle_vertex_predict_dispatch`]. .route( - "/v1beta1/projects/:project/locations/:location/publishers/anthropic/models/:model_action", + "/v1beta1/projects/{project}/locations/{location}/publishers/anthropic/models/{model_action}", post(crate::vertex::handle_vertex_predict_dispatch), ); @@ -219,11 +219,11 @@ pub fn build_app(state: AppState) -> Router { // Bedrock handlers identically. let bedrock_router: Router = Router::new() .route( - "/model/:model_id/invoke", + "/model/{model_id}/invoke", post(crate::bedrock::invoke::handle_invoke), ) .route( - "/model/:model_id/converse", + "/model/{model_id}/converse", post(crate::bedrock::invoke::handle_invoke), ) // PR-D2/PR-D5: streaming counterparts. Bedrock's protocol is @@ -235,11 +235,11 @@ pub fn build_app(state: AppState) -> Router { // processing pipeline, so both route to the same handler. // See `bedrock::invoke_streaming`. .route( - "/model/:model_id/invoke-with-response-stream", + "/model/{model_id}/invoke-with-response-stream", post(crate::bedrock::invoke_streaming::handle_invoke_streaming), ) .route( - "/model/:model_id/converse-stream", + "/model/{model_id}/converse-stream", post(crate::bedrock::invoke_streaming::handle_invoke_streaming), ) .route_layer(axum::middleware::from_fn( @@ -281,18 +281,18 @@ pub fn build_app(state: AppState) -> Router { post(crate::handlers::conversations::handle_conversations_create), ) .route( - "/v1/conversations/:conversation_id", + "/v1/conversations/{conversation_id}", get(crate::handlers::conversations::handle_conversations_get) .post(crate::handlers::conversations::handle_conversations_update) .delete(crate::handlers::conversations::handle_conversations_delete), ) .route( - "/v1/conversations/:conversation_id/items", + "/v1/conversations/{conversation_id}/items", post(crate::handlers::conversations::handle_conversations_items_create) .get(crate::handlers::conversations::handle_conversations_items_list), ) .route( - "/v1/conversations/:conversation_id/items/:item_id", + "/v1/conversations/{conversation_id}/items/{item_id}", get(crate::handlers::conversations::handle_conversations_item_get) .delete(crate::handlers::conversations::handle_conversations_item_delete), ); @@ -315,17 +315,22 @@ pub fn build_app(state: AppState) -> Router { async fn catch_all( State(state): State, ConnectInfo(client_addr): ConnectInfo, - ws: Option, req: Request, ) -> Response { - if is_websocket_upgrade(req.headers()) { - if let Some(ws) = ws { + let (mut parts, body) = req.into_parts(); + if is_websocket_upgrade(&parts.headers) { + // axum 0.8 requires optional extractors to opt in explicitly, and + // WebSocketUpgrade intentionally does not. Extract it only after the + // upgrade headers have identified this as a WebSocket request. + if let Ok(ws) = WebSocketUpgrade::from_request_parts(&mut parts, &state).await { + let req = Request::from_parts(parts, body); return ws_handler(ws, state, client_addr, req).await; } // Header says websocket but axum didn't extract it (likely missing // Sec-WebSocket-Key) — fall through to HTTP forwarding which will // surface the upstream error. } + let req = Request::from_parts(parts, body); forward_http(state, client_addr, req) .await .unwrap_or_else(|e| e.into_response()) diff --git a/crates/headroom-proxy/src/websocket.rs b/crates/headroom-proxy/src/websocket.rs index 509cd6bfd..3a1be2e4f 100644 --- a/crates/headroom-proxy/src/websocket.rs +++ b/crates/headroom-proxy/src/websocket.rs @@ -232,10 +232,10 @@ fn ax_to_tg(m: AxMsg) -> Option { fn tg_to_ax(m: TgMsg) -> Option { Some(match m { - TgMsg::Text(t) => AxMsg::Text(t.as_str().to_string()), - TgMsg::Binary(b) => AxMsg::Binary(b.to_vec()), - TgMsg::Ping(p) => AxMsg::Ping(p.to_vec()), - TgMsg::Pong(p) => AxMsg::Pong(p.to_vec()), + TgMsg::Text(t) => AxMsg::Text(t.as_str().to_string().into()), + TgMsg::Binary(b) => AxMsg::Binary(b.to_vec().into()), + TgMsg::Ping(p) => AxMsg::Ping(p.to_vec().into()), + TgMsg::Pong(p) => AxMsg::Pong(p.to_vec().into()), TgMsg::Close(Some(cf)) => AxMsg::Close(Some(CloseFrame { code: cf.code.into(), reason: cf.reason.to_string().into(), diff --git a/crates/headroom-proxy/tests/integration_bedrock_authmode.rs b/crates/headroom-proxy/tests/integration_bedrock_authmode.rs index 81fe30085..6fa056889 100644 --- a/crates/headroom-proxy/tests/integration_bedrock_authmode.rs +++ b/crates/headroom-proxy/tests/integration_bedrock_authmode.rs @@ -104,7 +104,7 @@ async fn bedrock_classified_as_oauth() { auth_mode.as_str().to_string() } let app = Router::new() - .route("/model/:model_id/invoke", post(probe)) + .route("/model/{model_id}/invoke", post(probe)) .route_layer(axum::middleware::from_fn(classify_and_attach_auth_mode)); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); From 322425c43bffde1ed0b64fecf3cf5951565dd82b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:40:45 -0500 Subject: [PATCH 03/53] deps: bump sha2 from 0.10.9 to 0.11.0 (#2288) Bumps [sha2](https://github.com/RustCrypto/hashes) from 0.10.9 to 0.11.0.
Commits

--------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: JerrettDavis --- Cargo.lock | 17 +++-------------- crates/headroom-core/Cargo.toml | 2 +- crates/headroom-core/src/rollout.rs | 7 ++++++- .../src/transforms/smart_crusher/hashing.rs | 5 ++++- crates/headroom-proxy/Cargo.toml | 4 ++-- 5 files changed, 16 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7e112128f..bfe188254 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -444,7 +444,7 @@ dependencies = [ "http 0.2.12", "http 1.5.0", "percent-encoding", - "sha2 0.11.0", + "sha2", "time", "tracing", ] @@ -1896,7 +1896,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", - "sha2 0.10.9", + "sha2", "tempfile", "thiserror 2.0.20", "tiktoken-rs", @@ -1959,7 +1959,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "sha2 0.10.9", + "sha2", "thiserror 2.0.20", "tokio", "tokio-stream", @@ -3962,17 +3962,6 @@ dependencies = [ "digest 0.11.3", ] -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - [[package]] name = "sha2" version = "0.11.0" diff --git a/crates/headroom-core/Cargo.toml b/crates/headroom-core/Cargo.toml index c1f0d5537..903f4511f 100644 --- a/crates/headroom-core/Cargo.toml +++ b/crates/headroom-core/Cargo.toml @@ -28,7 +28,7 @@ hf-hub = { version = "0.5", default-features = false, features = ["ureq", "rustl md-5 = "0.10" # `sha2` for `_hash_field_name` in smart_crusher (SHA256 truncated to 16 # hex chars). Python uses `hashlib.sha256` so we need byte-exact parity. -sha2 = "0.10" +sha2 = "0.11" # `dashmap` for the CCR storage backend. Concurrent HashMap with sharded # locking — distinct keys hashed to different shards never contend, so # multi-worker proxy load doesn't queue on a single Mutex. Lock-free diff --git a/crates/headroom-core/src/rollout.rs b/crates/headroom-core/src/rollout.rs index fc9d4fb92..1ff027413 100644 --- a/crates/headroom-core/src/rollout.rs +++ b/crates/headroom-core/src/rollout.rs @@ -323,7 +323,12 @@ pub fn feature_names() -> BTreeSet<&'static str> { fn digest_value(value: &Value) -> String { let canonical = serde_json::to_vec(value).expect("rollout provenance is serializable"); - format!("sha256:{:x}", Sha256::digest(canonical)) + let digest = Sha256::digest(canonical); + let mut hex = String::with_capacity(digest.len() * 2); + for byte in digest { + hex.push_str(&format!("{byte:02x}")); + } + format!("sha256:{hex}") } #[cfg(test)] diff --git a/crates/headroom-core/src/transforms/smart_crusher/hashing.rs b/crates/headroom-core/src/transforms/smart_crusher/hashing.rs index 2648639c5..437278bf1 100644 --- a/crates/headroom-core/src/transforms/smart_crusher/hashing.rs +++ b/crates/headroom-core/src/transforms/smart_crusher/hashing.rs @@ -28,7 +28,10 @@ pub fn hash_field_name(field_name: &str) -> String { let digest = hasher.finalize(); // Truncate to first 8 hex chars (4 bytes of digest). MUST match // Python's `[:8]` — see module-level note above. - let hex = format!("{:x}", digest); + let mut hex = String::with_capacity(digest.len() * 2); + for byte in digest { + hex.push_str(&format!("{byte:02x}")); + } hex[..8].to_string() } diff --git a/crates/headroom-proxy/Cargo.toml b/crates/headroom-proxy/Cargo.toml index b1f5088f1..a2bf52f31 100644 --- a/crates/headroom-proxy/Cargo.toml +++ b/crates/headroom-proxy/Cargo.toml @@ -74,7 +74,7 @@ prometheus = { version = "=0.14.0", default-features = false } # `aws-smithy-runtime-api`); promoted here to a direct, normal-build # dependency so the drift detector compiles outside `cfg(test)`. Also # used by PR-E4 for `prompt_cache_key` derivation. -sha2 = "0.10" +sha2 = "0.11" # PR-E6: bounded session-scoped cache of structural hashes. The # detector evicts the oldest session at 1000 entries — we never want # unbounded memory growth from a flood of unique session keys. `lru` @@ -110,7 +110,7 @@ tokio-stream = "0.1" # way to gate "the proxy did not perturb the request" because JSON # value-equality misses whitespace, key order, and Unicode escape # differences that all bust the prompt cache. -sha2 = "0.10" +sha2 = "0.11" # PR-C1: property tests for the byte-level SSE parser. The parser # must never panic on arbitrary input bytes (TCP can hand us anything, # including malformed UTF-8 split mid-codepoint or fuzz-generated From 2f4d001c9ffd7f856c8dab3e31a8240a1c676f04 Mon Sep 17 00:00:00 2001 From: Joseph Benno <91036825+Robert2547@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:10:44 -0400 Subject: [PATCH 04/53] fix(proxy): keep prefixed core tools resident (#3046) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Headroom's Tool Search deferral lowercased core tool names but did not account for client namespace prefixes. Oh My Pi sends built-ins such as `_read`, `_edit`, `_write`, and `_bash`, so those core tools were incorrectly marked `defer_loading=True`. This change centralizes resident-name normalization for both the Anthropic and OpenAI paths. It lowercases names and removes only leading underscores, preserving internal separators such as `mcp__server__read` so unrelated tools do not become resident. Closes #3031 ## 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 - Added a shared resident-tool name normalizer in `headroom/proxy/helpers.py`. - Applied the same normalization to Anthropic and OpenAI Tool Search deferral. - Added a regression test for Oh My Pi's exact 12-tool surface at the deferral threshold. - Added OpenAI coverage for prefixed resident tools and negative namespace cases. ## 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 --no-sync pytest --noconftest -q tests/test_openai_tool_search_deferral.py tests/test_issue_746_tool_search.py -k 'not normalize_tool_search_mode and not configure_' 72 passed, 23 deselected in 0.25s $ uv run --no-sync ruff check . All checks passed! $ uv run --no-sync ruff format --check . 1499 files already formatted $ UV_CACHE_DIR=/tmp/headroom-uv-cache uv run --no-sync mypy headroom Success: no issues found in 520 source files ``` ## Real Behavior Proof - Environment: Linux x86_64 sandbox; Python 3.12.13; uv 0.11.33; no provider credentials. - Exact command / steps: Exercised the exact 12-tool Oh My Pi fixture through the Anthropic deferral helper and prefixed resident plus negative names through the OpenAI helper. - Observed result: Anthropic kept `_edit`, `_task`, `_read`, `_bash`, `_glob`, `_grep`, `_write`, `computer`, and `web_search` resident while deferring `_hub`, `_todo`, and `_eval`. OpenAI kept prefixed core tools resident while `mcp__server__read` and `terminal_helper` remained deferred. - Not tested: Live Oh My Pi traffic against Anthropic, provider E2E tests, and the full native-backed pytest suite. ## Runtime Rollout Safety - Rollout-managed feature(s): Existing server-side Tool Search deferral for Anthropic and OpenAI. - Minimum rollout channel: N/A; targeted bug fix to existing behavior. - Stable/default behavior changed: Yes. Leading-underscore names that normalize to known resident names now remain resident. - Kill switch / disable path: Set `HEADROOM_TOOL_SEARCH=0`. - Unsafe override required: No. - Qualification impact: Prefixed core tools remain immediately available; non-core and MCP namespace behavior is unchanged. - Rollback path: Revert this commit or disable Tool Search with `HEADROOM_TOOL_SEARCH=0`. ## 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 - [ ] 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 ## Additional Notes --- headroom/proxy/helpers.py | 29 +++++++++++------- tests/test_issue_746_tool_search.py | 36 +++++++++++++++++++++++ tests/test_openai_tool_search_deferral.py | 14 +++++++++ 3 files changed, 68 insertions(+), 11 deletions(-) diff --git a/headroom/proxy/helpers.py b/headroom/proxy/helpers.py index d98b67be3..ecd57f242 100644 --- a/headroom/proxy/helpers.py +++ b/headroom/proxy/helpers.py @@ -2811,6 +2811,13 @@ _TOOL_SEARCH_DEFAULT_NAME = "tool_search_tool_regex" _TOOL_SEARCH_MIN_TOOLS = 12 +def _tool_search_resident_key(name: Any) -> str: + """Normalize a client tool name for resident-tool membership checks.""" + # Oh My Pi prefixes every built-in with ``_``. Strip only leading namespace + # markers so internal separators such as ``mcp__server__read`` stay intact. + return str(name or "").lower().lstrip("_") + + def anthropic_first_party_tool_search_supported(api_base_url: str | None) -> bool: """Return whether Anthropic server-side tool search is valid for this upstream.""" from headroom.providers.claude.runtime import is_custom_anthropic_base_url @@ -2872,17 +2879,17 @@ def inject_tool_search_deferral( last_resident_real: dict[str, Any] | None = None resident_has_cache_control = False - # Clients disagree on casing for the same tool: Claude Code sends ``Bash`` / - # ``ToolSearch`` where opencode sends ``bash``. Compare case-insensitively so - # the exemption applies to both — an exact match silently deferred *every* - # tool for PascalCase clients, including their own tool-search tool. - core_lower = {name.lower() for name in core_tools} + # Clients disagree on casing and leading namespace markers for the same tool: + # Claude Code sends ``Bash``, opencode sends ``bash``, and Oh My Pi sends + # ``_bash``. Normalize both the configured names and each candidate so the + # exemption applies consistently across clients. + core_keys = {_tool_search_resident_key(name) for name in core_tools} for tool in tools: if ( not isinstance(tool, dict) or tool.get("type") - or str(tool.get("name") or "").lower() in core_lower + or _tool_search_resident_key(tool.get("name")) in core_keys ): # Non-dict, server/typed tools (web_search, computer, …), and core # tools stay resident and unchanged. @@ -3259,10 +3266,10 @@ def inject_tool_search_deferral_openai( out: list[Any] = [{"type": _OPENAI_TOOL_SEARCH_TYPE}] deferred = 0 - # Case-insensitive for the same reason as the Anthropic path above: the - # resident-name sets are lowercase, clients are not required to be. - resident_lower = {name.lower() for name in core_tools} | { - name.lower() for name in _OPENAI_TOOL_SEARCH_RESIDENT_NAMES + # Normalize for the same reason as the Anthropic path above: clients may use + # different casing or a leading namespace marker for the same resident tool. + resident_keys = {_tool_search_resident_key(name) for name in core_tools} | { + _tool_search_resident_key(name) for name in _OPENAI_TOOL_SEARCH_RESIDENT_NAMES } for tool in tools: if not isinstance(tool, dict): @@ -3273,7 +3280,7 @@ def inject_tool_search_deferral_openai( # trained to search namespaces / MCP servers). Everything else — core # coding tools and other hosted tools — stays resident. deferrable = ( - ttype == "function" and str(tool.get("name") or "").lower() not in resident_lower + ttype == "function" and _tool_search_resident_key(tool.get("name")) not in resident_keys ) or ttype == "mcp" if deferrable and not tool.get("defer_loading"): new_tool = dict(tool) diff --git a/tests/test_issue_746_tool_search.py b/tests/test_issue_746_tool_search.py index 4b31cb7f8..b3321a010 100644 --- a/tests/test_issue_746_tool_search.py +++ b/tests/test_issue_746_tool_search.py @@ -350,6 +350,42 @@ def test_resident_real_tool_survives_pascal_case_surface() -> None: assert any(not t.get("type") and not t.get("defer_loading") for t in out) +def _omp_tools() -> list[dict]: + """Oh My Pi's 12-tool surface: underscore-prefixed built-ins plus typed tools.""" + named = [ + "_hub", + "_edit", + "_task", + "_todo", + "_eval", + "_read", + "_bash", + "_glob", + "_grep", + "_write", + ] + return [ + *[{"name": name, "description": name, "input_schema": {}} for name in named], + {"type": "computer_20250124", "name": "computer"}, + {"type": "web_search_20250305", "name": "web_search"}, + ] + + +def test_core_tools_match_leading_underscore_namespace() -> None: + tools = _omp_tools() + assert len(tools) == _TOOL_SEARCH_MIN_TOOLS + + out = inject_tool_search_deferral(tools) + + by_name = {tool.get("name"): tool for tool in out if isinstance(tool, dict)} + for name in ("_edit", "_task", "_read", "_bash", "_glob", "_grep", "_write"): + assert by_name[name].get("defer_loading") is None, name + for name in ("_hub", "_todo", "_eval"): + assert by_name[name].get("defer_loading") is True, name + for name in ("computer", "web_search"): + assert by_name[name].get("defer_loading") is None, name + + # --------------------------------------------------------------------------- # Tool-search history repair (#2805) # diff --git a/tests/test_openai_tool_search_deferral.py b/tests/test_openai_tool_search_deferral.py index 32486d06c..38241ded0 100644 --- a/tests/test_openai_tool_search_deferral.py +++ b/tests/test_openai_tool_search_deferral.py @@ -123,6 +123,20 @@ def test_terminal_helper_remains_deferrable(): assert helper.get("defer_loading") is True +def test_prefixed_core_and_terminal_names_stay_resident(): + resident = ["_bash", "_read", "_write", "_edit", "_glob", "_grep", "_terminal"] + noncore = ["_hub", "_todo", "_eval", "mcp__server__read", "terminal_helper"] + tools = [_fn(name) for name in resident + noncore] + + out = inject_tool_search_deferral_openai(tools, "gpt-5.6-terra") + + by_name = {tool["name"]: tool for tool in out if tool.get("type") == "function"} + for name in resident: + assert by_name[name].get("defer_loading") is None, name + for name in noncore: + assert by_name[name].get("defer_loading") is True, name + + def test_defers_mcp_server(): tools = [_fn(n) for n in _CORE] + [{"type": "mcp", "server_label": "sentry"}] tools += [_fn(f"x{i}") for i in range(8)] From f9807fd69e220f43068ec168515ae886dd36166f Mon Sep 17 00:00:00 2001 From: Tejas Chopra Date: Sun, 16 Aug 2026 10:25:47 -0700 Subject: [PATCH 05/53] feat(proxy): let extensions report cost savings and their own latency (#3051) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Two changes that let a proxy extension report **what it saved** and **what it cost**, so both show up under `/stats`, the dashboard, and Prometheus. `record_scope_savings` already existed and already accepted `usd` — the one channel in the proxy that can express savings *without* tokens. Two things stopped it working end to end. ### 1. Savings were silently dropped on Gemini traffic (bug) `bind_scope` shares one attribution ledger between ASGI middleware and the request handler. Anthropic and OpenAI call it; **Gemini never did**, so anything an extension recorded into the request scope was discarded for Gemini traffic only — silently, because an empty ledger and an unbound one are indistinguishable at the outcome funnel. Now bound at all four Gemini tag sites. ### 2. An extension's own latency was invisible (gap) `overhead_ms` is measured *inside* the handler, and an ASGI extension **wraps** that handler — so every millisecond it spends reaches the client while every timing surface stays flat. An extension that halves the bill and adds 200 ms per request is a trade the operator has to see both halves of, and only one half was reaching the dashboard. `record_scope_timing(scope, stage, ms)` is the symmetric counterpart to `record_scope_savings`, carried on the same bound ledger and merged into `RequestOutcome.pipeline_timing` at the outcome funnel — one place, so every provider picks it up at once. ## API surface ```python from headroom.proxy.savings_attribution import record_scope_savings, record_scope_timing record_scope_savings(scope, "my_extension", tokens=0, usd=0.004) # money without tokens record_scope_timing(scope, "my_extension", elapsed_ms) ``` Both take the ASGI `scope`, because middleware has no other way in. Documented in `extensions.py` — the module extension authors actually read, and the stability contract for this interface. - Savings → `/stats` `savings.by_source`, dashboard card, `headroom_savings_attributed_usd_total{source=...}` - Timing → `/stats` `pipeline_timing`, dashboard Performance panel, `headroom_transform_timing_ms_*` **Attribution only.** These rows explain the headline total; they are never added to it. ## Changes to existing behavior - `public_tags` now strips `_headroom_stage_timing` as well as `_headroom_savings_attribution`. Both ride on `tags` because that is the one dict reaching the outcome funnel from every handler, and a list and a dict must not land in a string-keyed label store. - `pipeline_timing` passed to `metrics.record_request` is merged rather than passed through **only when an extension contributed timings**; with no extension the handler's own dict is passed through unchanged (asserted by identity in the tests). - Stage names are extension-supplied, so they are capped at 16 and namespaced `ext:` — `deep_copy` reported by a plugin must never accumulate into the same series as `deep_copy` measured by the pipeline. A handler's own timing wins a collision (unreachable while the prefix stands; the safe way round if it ever goes). ## Failure modes Both calls are bounded (32 sources, 16 stages), never raise, and never change a response — telemetry from a plugin must not be able to break the request it is describing. Non-positive and non-numeric durations are ignored: a zero is a clock artifact, not an observation, and averaging it in would drag the mean down exactly where the stage is cheapest to skip. `timings_from_tags` tolerates junk on the tag. ## Test-double fix Three Gemini test fakes (`FakeRequest`, `_FakeRequest`, `_VertexGeminiImageRequest`) had no `.scope`, which every real Starlette `Request` has. They now do. This is a double that had drifted from the type it stands in for; the alternative was weakening the handler to tolerate a request shape that cannot occur in production. --- ## Real behavior proof **Setup:** macOS 15.4 (darwin 25.4.0), Python 3.12.13, this branch at `c814b950`, real `create_app` proxy with `respx`-mocked Anthropic upstream, a demo ASGI extension added via `app.add_middleware`. **The extension** — written as a third party would, reporting `tokens=0` because it re-routed `claude-opus-5` → `claude-haiku-4-5`: same tokens, cheaper model. That is precisely the case no existing Headroom savings channel can express, since all of them compute `saved = before - after`. ```python class DemoRouter: def __init__(self, app): self.app = app async def __call__(self, scope, receive, send): if scope.get("type") != "http": return await self.app(scope, receive, send) started = time.perf_counter() record_scope_savings(scope, "routemegood", tokens=0, usd=0.173) record_scope_timing(scope, "routemegood", (time.perf_counter() - started) * 1000) await self.app(scope, receive, send) ``` **Ran:** three POSTs to `/v1/messages`, then `GET /stats` and `GET /metrics`. **Observed:** ``` upstream call -> 200 upstream call -> 200 upstream call -> 200 === /stats savings.by_source (what the dashboard renders) === [ { "source": "routemegood", "realized": true, "events": 3, "tokens": 0, "usd": 0.519 } ] === /stats pipeline_timing (dashboard Performance panel) === { "ext:routemegood": { "average_ms": 0.01, "max_ms": 0.02, "count": 3 } } === /metrics === # HELP headroom_savings_attributed_tokens_total Tokens attributed to a savings source # TYPE headroom_savings_attributed_tokens_total counter headroom_savings_attributed_tokens_total{realized="true",source="routemegood"} 0 # HELP headroom_savings_attributed_usd_total Cost savings attributed to a source; may be negative # TYPE headroom_savings_attributed_usd_total gauge headroom_savings_attributed_usd_total{realized="true",source="routemegood"} 0.519 headroom_transform_timing_ms_sum{transform="ext:routemegood"} 0.03 ``` `$0.519 = 3 × $0.173` — three requests, correctly accumulated, with `tokens: 0` throughout. **Also have (not a substitute for the above):** 22 new unit tests in `tests/test_extension_attribution.py`, including four that drive the real `_record_request_outcome` funnel via the same descriptor-binding harness `test_request_outcome.py` uses. Full suite on this branch: **10,989 passed, 578 skipped**. Three failures — `test_graceful_shutdown.py::test_run_server_installs_cancelled_error_filter` (full-suite ordering; passes in isolation), `test_learn/test_integration.py::TestCodexIntegration::test_full_pipeline`, and `test_release_workflows.py::test_no_native_tls_in_wheel_build_tree` (needs `cargo`) — **reproduce identically on clean `main`** (`2f4d001c`, 10,967 passed, same 3 failed). Verified by stashing this branch and re-running the full suite on main in the same tree. **What I did not test:** a live provider (upstream is `respx`-mocked); the Gemini `bind_scope` fix against real Google traffic (covered by the existing 114 Gemini tests, which all pass); the dashboard rendered in a browser — I verified the JSON shape its templates bind to (`stats.savings?.by_source`, `stats.pipeline_timing`) rather than the pixels. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Tejas Chopra Co-authored-by: Claude Opus 5 --- headroom/proxy/extensions.py | 35 +++ headroom/proxy/handlers/gemini.py | 16 ++ headroom/proxy/outcome.py | 23 +- headroom/proxy/savings_attribution.py | 136 ++++++++- tests/test_extension_attribution.py | 398 ++++++++++++++++++++++++++ tests/test_gemini_nonjson_status.py | 1 + tests/test_proxy_handler_helpers.py | 1 + tests/test_proxy_handlers_batch.py | 3 + 8 files changed, 607 insertions(+), 6 deletions(-) create mode 100644 tests/test_extension_attribution.py diff --git a/headroom/proxy/extensions.py b/headroom/proxy/extensions.py index 9ead7a6cd..183028f71 100644 --- a/headroom/proxy/extensions.py +++ b/headroom/proxy/extensions.py @@ -18,6 +18,41 @@ Each ``install`` callable is invoked with the FastAPI ``app`` and the OSS makes no assumptions about what extensions do. The interface is deliberately minimal; extensions own the complexity behind it. +Reporting what an extension saved, and what it cost +--------------------------------------------------- + +An extension that changes the bill should say so, or the operator sees a +different total with nothing to attribute it to. Two calls, both taking the +ASGI ``scope`` so they work from middleware — which runs outside the request +handler and has no other way in:: + + from headroom.proxy.savings_attribution import ( + record_scope_savings, record_scope_timing, + ) + + record_scope_savings(scope, "my_extension", tokens=1200, usd=0.004) + record_scope_timing(scope, "my_extension", elapsed_ms) + +``record_scope_savings`` takes ``tokens``, ``usd``, or both, so an extension +that saves money WITHOUT saving tokens — routing a request to a cheaper model, +say — can report a real number instead of a token count nobody saved. Pass +``realized=False`` for a projection rather than a measured amount; the two are +kept apart everywhere they surface. Savings land on ``/stats`` under +``savings.by_source``, on the dashboard as their own card, and in Prometheus as +``headroom_savings_attributed_usd_total{source=...}``. **Attribution only** — +these rows explain the headline total, they are never added to it. + +``record_scope_timing`` is the other half of the trade: an extension's own +latency, which is otherwise invisible because ``overhead_ms`` is measured +inside the handler that the extension wraps. It lands in ``/stats`` under +``pipeline_timing``, in the dashboard's Performance panel, and in +``headroom_transform_timing_ms_*``, namespaced ``ext:`` so it can never +collide with a built-in transform. + +Both are bounded (32 sources, 16 stages), never raise, and never change a +response — telemetry from a plugin must not be able to break the request it is +describing. + **Extensions are opt-in.** Discovery enumerates every registered extension, but ``install_all`` only invokes those explicitly enabled by the operator. This protects users from silent behavior changes when a package they didn't diff --git a/headroom/proxy/handlers/gemini.py b/headroom/proxy/handlers/gemini.py index 3311fe69c..dfdc3e90f 100644 --- a/headroom/proxy/handlers/gemini.py +++ b/headroom/proxy/handlers/gemini.py @@ -316,6 +316,13 @@ class GeminiHandlerMixin: headers.pop("host", None) headers.pop("content-length", None) tags = extract_tags(headers) + # Anthropic and OpenAI bind here; Gemini did not, so anything an ASGI + # extension recorded into the request scope was dropped on the floor + # for Gemini traffic only — silently, because an empty ledger and an + # unbound one look identical at the outcome funnel. + from headroom.proxy.savings_attribution import bind_scope + + bind_scope(tags, request.scope) client = classify_client(headers) # PR-A5 (P5-49): strip internal x-headroom-* from upstream-bound # headers AFTER `_extract_tags` reads them. Memory user-id reads @@ -1027,6 +1034,9 @@ class GeminiHandlerMixin: headers.pop("content-length", None) headers.pop("accept-encoding", None) tags = extract_tags(headers) + from headroom.proxy.savings_attribution import bind_scope + + bind_scope(tags, request.scope) # Note: streaming handlers delegate to _stream_response, which # does its own classify_client. No need to compute here. is_antigravity = self._is_cloudcode_antigravity_request(body, headers) @@ -1180,6 +1190,9 @@ class GeminiHandlerMixin: headers.pop("host", None) headers.pop("content-length", None) tags = extract_tags(headers) + from headroom.proxy.savings_attribution import bind_scope + + bind_scope(tags, request.scope) # Streaming variant — delegates to _stream_response which # classifies the client itself from headers. # PR-A5 (P5-49): strip internal x-headroom-* before forwarding upstream. @@ -1328,6 +1341,9 @@ class GeminiHandlerMixin: # outcome. Extract here so apply_to_tags below has a dict to # mutate and the outcome at end-of-call inherits the tag. tags = extract_tags(request.headers) + from headroom.proxy.savings_attribution import bind_scope + + bind_scope(tags, request.scope) _decision = CompressionDecision.decide( headers=request.headers, config=self.config, diff --git a/headroom/proxy/outcome.py b/headroom/proxy/outcome.py index f98c88753..d96190b4d 100644 --- a/headroom/proxy/outcome.py +++ b/headroom/proxy/outcome.py @@ -399,7 +399,12 @@ async def emit_request_outcome(handler: Any, outcome: RequestOutcome) -> None: from headroom.proxy.cost import _summarize_transforms from headroom.proxy.models import RequestLog from headroom.proxy.project_context import get_current_project - from headroom.proxy.savings_attribution import encode, from_tags, public_tags + from headroom.proxy.savings_attribution import ( + encode, + from_tags, + public_tags, + timings_from_tags, + ) from headroom.telemetry.session import record_outcome # GitHub Copilot: requests routed to the Copilot API travel on the OpenAI or @@ -467,6 +472,20 @@ async def emit_request_outcome(handler: Any, outcome: RequestOutcome) -> None: tool_search_saved = tool_schema_saved_from_tags(outcome.tags or {}) savings_breakdown = from_tags(outcome.tags) + # Stage timings contributed from OUTSIDE the handler, folded in here rather + # than in each handler so every provider picks them up from one place. + # + # The handler's own timings win a name collision, which cannot happen while + # extension stages carry the ``ext:`` prefix but is the safe way round if + # that ever changes: a plugin must not be able to overwrite a measurement + # the pipeline made of itself. + extension_timing = timings_from_tags(outcome.tags) + pipeline_timing = ( + {**extension_timing, **(outcome.pipeline_timing or {})} + if extension_timing + else outcome.pipeline_timing + ) + # Billed input volume. Prefer the provider's own count where it reported one # — that is what the invoice charges for, and it is the number cache math is # already expressed in. Falls back to our local ``optimized_tokens`` when the @@ -488,7 +507,7 @@ async def emit_request_outcome(handler: Any, outcome: RequestOutcome) -> None: cached=outcome.cache_hit, overhead_ms=outcome.overhead_ms, ttfb_ms=outcome.ttfb_ms, - pipeline_timing=outcome.pipeline_timing, + pipeline_timing=pipeline_timing, waste_signals=outcome.waste_signals, cache_read_tokens=outcome.cache_read_tokens, cache_write_tokens=outcome.cache_write_tokens, diff --git a/headroom/proxy/savings_attribution.py b/headroom/proxy/savings_attribution.py index db7f517e9..2ea8c3fb4 100644 --- a/headroom/proxy/savings_attribution.py +++ b/headroom/proxy/savings_attribution.py @@ -4,6 +4,7 @@ from __future__ import annotations import base64 import json +import math import re from collections.abc import MutableMapping from typing import Any @@ -13,6 +14,43 @@ _NAME_RE = re.compile(r"[^a-z0-9_.-]+") MAX_SOURCES = 32 _SCOPE_KEY = "headroom_savings_attribution" +# Per-request stage timings contributed from outside the handler, merged into +# ``RequestOutcome.pipeline_timing`` at the outcome funnel. +# +# An ASGI middleware wraps the handler, so every millisecond it spends lands in +# the client's latency while ``overhead_ms`` -- measured inside the handler -- +# stays flat. An extension that halves the bill and adds 200ms per request is a +# trade the operator has to be able to see both halves of, and until now only +# one half reached the dashboard. +STAGE_TIMING_TAG = "_headroom_stage_timing" +_TIMING_SCOPE_KEY = "headroom_stage_timing" + +# Stage names are extension-supplied, so they are capped like every other +# client-influenced label in this proxy (see MAX_DISTINCT_MODELS). +MAX_STAGES = 16 + +# Namespace, so an extension can never shadow a built-in transform's timing -- +# ``deep_copy`` reported by a plugin and ``deep_copy`` reported by the pipeline +# must not accumulate into the same series. +STAGE_PREFIX = "ext:" + +# NON-FINITE VALUES POISON EVERY CONSUMER DOWNSTREAM, and they do it long after +# the call that introduced them. Starlette's JSONResponse encodes with +# ``allow_nan=False``, so a single ``inf`` reaching ``/stats`` raises +# ``ValueError: Out of range float values are not JSON compliant`` -- and the +# value sits in the process-wide metrics totals, so the endpoint stays broken +# until restart. Prometheus is no better: Python renders ``inf``, the exposition +# format wants ``+Inf``, and the scrape fails to parse. +# +# The request itself still returns 200 throughout, which is the worst shape a +# bug can have: the extension looks healthy while the operator's dashboard and +# scrape are dead. +# +# One hour bounds a single stage inside one request -- unreachable in practice, +# and it makes overflow-to-infinity on accumulation structurally impossible +# (16 stages x 1h is nowhere near the float ceiling). +MAX_STAGE_MS = 3_600_000.0 + def _source_name(value: object) -> str: name = _NAME_RE.sub("_", str(value or "other").strip().lower()).strip("_.-") @@ -29,7 +67,12 @@ def _ledger(tags: MutableMapping[str, Any]) -> list[dict[str, Any]]: def bind_scope(tags: MutableMapping[str, Any], scope: MutableMapping[str, Any]) -> None: - """Share one ledger between ASGI middleware and the request handler.""" + """Share the savings and timing ledgers between ASGI middleware and the handler. + + Both are bound together because an extension that reports one usually + reports the other, and a handler that binds only savings would drop the + timings silently -- which is the failure this call is here to prevent. + """ state = scope.setdefault("state", {}) ledger = state.get(_SCOPE_KEY) if not isinstance(ledger, list): @@ -37,6 +80,12 @@ def bind_scope(tags: MutableMapping[str, Any], scope: MutableMapping[str, Any]) state[_SCOPE_KEY] = ledger tags[SAVINGS_ATTRIBUTION_TAG] = ledger + timings = state.get(_TIMING_SCOPE_KEY) + if not isinstance(timings, dict): + timings = {} + state[_TIMING_SCOPE_KEY] = timings + tags[STAGE_TIMING_TAG] = timings + def record_scope_savings(scope: MutableMapping[str, Any], source: object, **values: Any) -> None: state = scope.setdefault("state", {}) @@ -47,6 +96,63 @@ def record_scope_savings(scope: MutableMapping[str, Any], source: object, **valu record_savings({SAVINGS_ATTRIBUTION_TAG: ledger}, source, **values) +def record_scope_timing(scope: MutableMapping[str, Any], stage: object, ms: float) -> None: + """Attribute milliseconds spent outside the handler to a named stage. + + Additive within one request, so a middleware that works in two passes + (before and after ``call_next``) reports each and gets their sum. Never + raises and never changes a response: a plugin's telemetry must not be able + to break the request it is describing. + """ + try: + elapsed = float(ms) + except (TypeError, ValueError): + return + # Non-positive is either a clock artifact or nothing happening; either way + # it is not a measurement, and averaging it in would drag the mean toward + # zero exactly where the stage is cheapest to ignore. Non-finite and + # absurdly large are not measurements either, and they break consumers + # rather than merely skewing them -- see MAX_STAGE_MS. + if not math.isfinite(elapsed) or not 0.0 < elapsed <= MAX_STAGE_MS: + return + + state = scope.setdefault("state", {}) + timings = state.get(_TIMING_SCOPE_KEY) + if not isinstance(timings, dict): + timings = {} + state[_TIMING_SCOPE_KEY] = timings + + name = STAGE_PREFIX + _source_name(stage) + if name not in timings and len(timings) >= MAX_STAGES: + return + timings[name] = round(float(timings.get(name, 0.0)) + elapsed, 4) + + +def timings_from_tags(tags: MutableMapping[str, Any] | None) -> dict[str, float]: + """Extension stage timings carried on the request's tags, if any.""" + raw = (tags or {}).get(STAGE_TIMING_TAG) + if not isinstance(raw, dict): + return {} + out: dict[str, float] = {} + for name, value in list(raw.items())[:MAX_STAGES]: + try: + elapsed = float(value) + except (TypeError, ValueError): + continue + # Re-checked rather than trusted: the ledger is a plain dict reachable + # through ``tags``, so a handler can be handed one this module never + # wrote. The guarantee has to hold at the read, not only at the write. + # + # Finiteness ONLY. ``MAX_STAGE_MS`` bounds a single sample at the write, + # where it prevents overflow; applying it here would test it against an + # ACCUMULATED total and silently discard a stage that legitimately ran + # for longer across many samples -- throwing away real data to guard + # against a value this path cannot produce. + if math.isfinite(elapsed) and elapsed > 0.0: + out[str(name)] = elapsed + return out + + def record_savings( tags: MutableMapping[str, Any], source: object, @@ -61,12 +167,25 @@ def record_savings( ledger = _ledger(tags) if len(ledger) >= MAX_SOURCES: return + # Same hazard as MAX_STAGE_MS, on the amounts rather than the durations: + # ``usd=inf`` reaches ``/stats`` and raises out of the JSON encoder, and + # ``int(inf)`` raises OverflowError right here, inside the handler, on a + # request that would otherwise have succeeded. Neither is a saving, so + # neither is recorded -- the alternative is a plugin's arithmetic bug + # taking down an endpoint it has nothing to do with. + try: + amount = float(usd or 0.0) + count = int(tokens or 0) + except (TypeError, ValueError, OverflowError): + return + if not math.isfinite(amount): + return item: dict[str, Any] = { "source": _source_name(source), "realized": bool(realized), "estimated": bool(estimated), - "tokens": max(0, int(tokens or 0)), - "usd": round(float(usd or 0.0), 12), + "tokens": max(0, count), + "usd": round(amount, 12), } if details: item["details"] = { @@ -84,8 +203,17 @@ def from_tags(tags: MutableMapping[str, Any] | None) -> list[dict[str, Any]]: return [dict(item) for item in raw[:MAX_SOURCES] if isinstance(item, dict)] +_INTERNAL_TAGS = frozenset({SAVINGS_ATTRIBUTION_TAG, STAGE_TIMING_TAG}) + + def public_tags(tags: MutableMapping[str, Any] | None) -> dict[str, Any]: - return {key: value for key, value in (tags or {}).items() if key != SAVINGS_ATTRIBUTION_TAG} + """Tags minus the internal ledgers, which are structures rather than labels. + + They are carried on ``tags`` because that is the one dict that reaches the + outcome funnel from every handler; letting them through to ``RequestLog`` + would put a list and a dict into a string-keyed label store. + """ + return {key: value for key, value in (tags or {}).items() if key not in _INTERNAL_TAGS} def encode(items: list[dict[str, Any]]) -> str: diff --git a/tests/test_extension_attribution.py b/tests/test_extension_attribution.py new file mode 100644 index 000000000..77deb8f61 --- /dev/null +++ b/tests/test_extension_attribution.py @@ -0,0 +1,398 @@ +"""Attribution and timing contributed by proxy extensions. + +An extension that changes the bill has to be able to say so, or the operator +sees a different total with nothing to explain it. The savings half of this +already existed but only reached two of the three handler families; the timing +half did not exist at all, so an extension's own latency was invisible — +``overhead_ms`` is measured inside the handler that the extension wraps. +""" + +from __future__ import annotations + +import math + +import pytest + +from headroom.proxy.savings_attribution import ( + MAX_STAGE_MS, + MAX_STAGES, + SAVINGS_ATTRIBUTION_TAG, + STAGE_PREFIX, + STAGE_TIMING_TAG, + bind_scope, + from_tags, + public_tags, + record_scope_savings, + record_scope_timing, + timings_from_tags, +) + + +def _scope() -> dict: + return {"type": "http", "method": "POST"} + + +# --- savings, from middleware ------------------------------------------------ + + +def test_middleware_savings_reach_the_handlers_tags() -> None: + """The contract: middleware records into the scope before the handler runs, + the handler binds, and the outcome funnel reads one ledger.""" + scope = _scope() + record_scope_savings(scope, "routemegood", usd=0.42) + + tags: dict = {} + bind_scope(tags, scope) + + (row,) = from_tags(tags) + assert row["source"] == "routemegood" + assert row["usd"] == 0.42 + + +def test_savings_can_be_money_without_being_tokens() -> None: + """The gap this closes. Every other savings channel computes + ``saved = before - after`` and three of them refuse a non-positive value, + so an extension that routes a request to a cheaper model — same tokens, + smaller bill — could only report by inventing a token count nobody saved.""" + scope = _scope() + record_scope_savings(scope, "model_router", tokens=0, usd=1.75) + + tags: dict = {} + bind_scope(tags, scope) + (row,) = from_tags(tags) + assert row["tokens"] == 0 + assert row["usd"] == 1.75 + + +def test_a_projection_is_not_a_measurement() -> None: + scope = _scope() + record_scope_savings(scope, "guess", usd=1.0, realized=False) + record_scope_savings(scope, "guess", usd=1.0, realized=True) + + tags: dict = {} + bind_scope(tags, scope) + assert sorted(row["realized"] for row in from_tags(tags)) == [False, True] + + +# --- timing ------------------------------------------------------------------ + + +def test_middleware_timing_reaches_the_handlers_tags() -> None: + scope = _scope() + record_scope_timing(scope, "routemegood", 12.5) + + tags: dict = {} + bind_scope(tags, scope) + assert timings_from_tags(tags) == {f"{STAGE_PREFIX}routemegood": 12.5} + + +def test_timing_is_additive_within_one_request() -> None: + """A middleware works in two passes — before ``call_next`` and after — and + should be able to report each without tracking the total itself.""" + scope = _scope() + record_scope_timing(scope, "ext", 4.0) + record_scope_timing(scope, "ext", 2.5) + + tags: dict = {} + bind_scope(tags, scope) + assert timings_from_tags(tags) == {f"{STAGE_PREFIX}ext": 6.5} + + +def test_extension_stages_are_namespaced() -> None: + """``deep_copy`` reported by a plugin and ``deep_copy`` measured by the + pipeline must not accumulate into the same series.""" + scope = _scope() + record_scope_timing(scope, "deep_copy", 1.0) + + tags: dict = {} + bind_scope(tags, scope) + assert list(timings_from_tags(tags)) == [f"{STAGE_PREFIX}deep_copy"] + + +@pytest.mark.parametrize( + "bad", + [ + 0, + -1.0, + None, + "slow", + float("nan"), + float("inf"), + float("-inf"), + 1e400, + MAX_STAGE_MS + 1, + ], +) +def test_a_non_measurement_is_not_recorded(bad) -> None: + """Zero and negative are clock artifacts, not observations; averaging them + in would drag the mean down exactly where the stage is cheapest to skip. + + Non-finite is worse than skew. Starlette encodes ``/stats`` with + ``allow_nan=False``, so one ``inf`` raises out of the JSON encoder — and it + lands in process-wide metrics totals, so the endpoint stays broken until + restart while the request that caused it returns 200. + """ + scope = _scope() + record_scope_timing(scope, "ext", bad) + + tags: dict = {} + bind_scope(tags, scope) + assert timings_from_tags(tags) == {} + + +def test_a_poisoned_ledger_is_rejected_on_read_too() -> None: + """The ledger is a plain dict reachable through ``tags``, so a handler can + be handed one this module never wrote. The guarantee holds at the read.""" + assert timings_from_tags({STAGE_TIMING_TAG: {"ext:a": float("inf"), "ext:b": 2.0}}) == { + "ext:b": 2.0 + } + + +def test_accumulation_cannot_overflow_to_infinity() -> None: + """Two finite values can sum to ``inf``. Bounding each SAMPLE makes that + unreachable rather than merely unlikely.""" + scope = _scope() + for _ in range(4): + record_scope_timing(scope, "ext", MAX_STAGE_MS) + + tags: dict = {} + bind_scope(tags, scope) + (total,) = timings_from_tags(tags).values() + assert math.isfinite(total) + + +def test_an_accumulated_total_may_exceed_the_per_sample_bound() -> None: + """The bound is on one sample, not on the sum. Testing it against the + accumulated total would silently discard a stage that legitimately ran + longer across many samples — throwing away real data to guard a value the + write path cannot produce.""" + scope = _scope() + for _ in range(3): + record_scope_timing(scope, "ext", MAX_STAGE_MS) + + tags: dict = {} + bind_scope(tags, scope) + assert timings_from_tags(tags) == {f"{STAGE_PREFIX}ext": MAX_STAGE_MS * 3} + + +@pytest.mark.parametrize("bad", [float("inf"), float("-inf"), float("nan")]) +def test_a_non_finite_amount_is_not_a_saving(bad) -> None: + """Pre-existing, and the same crash: ``usd=inf`` reaches ``/stats`` and + raises out of the JSON encoder.""" + scope = _scope() + record_scope_savings(scope, "buggy", usd=bad) + + tags: dict = {} + bind_scope(tags, scope) + assert from_tags(tags) == [] + + +@pytest.mark.parametrize("bad", [float("inf"), float("nan")]) +def test_a_non_finite_token_count_does_not_raise_inside_the_handler(bad) -> None: + """``int(inf)`` is an OverflowError, raised on a request that would + otherwise have succeeded. A plugin's arithmetic bug must not become the + proxy's 500.""" + scope = _scope() + record_scope_savings(scope, "buggy", tokens=bad) + + tags: dict = {} + bind_scope(tags, scope) + assert from_tags(tags) == [] + + +def test_a_real_saving_still_records_after_the_guards() -> None: + """The direction that must not be lost while hardening the other one.""" + scope = _scope() + record_scope_savings(scope, "routemegood", tokens=10, usd=0.5) + record_scope_timing(scope, "routemegood", 3.0) + + tags: dict = {} + bind_scope(tags, scope) + assert from_tags(tags)[0]["usd"] == 0.5 + assert timings_from_tags(tags) == {f"{STAGE_PREFIX}routemegood": 3.0} + + +def test_stage_cardinality_is_capped() -> None: + """Stage names are extension-supplied, so they are bounded like every other + client-influenced label in this proxy.""" + scope = _scope() + for i in range(MAX_STAGES * 4): + record_scope_timing(scope, f"stage-{i}", 1.0) + + tags: dict = {} + bind_scope(tags, scope) + assert len(timings_from_tags(tags)) == MAX_STAGES + + +def test_an_existing_stage_still_accumulates_at_the_cap() -> None: + """The cap bounds distinct names, not measurements. A stage already being + tracked must keep accumulating or its total silently stops growing.""" + scope = _scope() + for i in range(MAX_STAGES): + record_scope_timing(scope, f"stage-{i}", 1.0) + record_scope_timing(scope, "stage-0", 5.0) + + tags: dict = {} + bind_scope(tags, scope) + assert timings_from_tags(tags)[f"{STAGE_PREFIX}stage-0"] == 6.0 + + +def test_recording_before_any_bind_still_works() -> None: + """Ordering is not guaranteed: middleware runs first, and on a path where + the handler never binds, nothing should raise.""" + scope = _scope() + record_scope_timing(scope, "ext", 1.0) + record_scope_savings(scope, "ext", usd=1.0) + assert scope["state"] + + +def test_recording_after_bind_is_seen_by_the_already_bound_tags() -> None: + """A middleware measures its own post-response work AFTER the handler has + bound. Sharing one object rather than copying is what makes that land.""" + tags: dict = {} + scope = _scope() + bind_scope(tags, scope) + record_scope_timing(scope, "ext", 3.0) + record_scope_savings(scope, "ext", usd=0.5) + + assert timings_from_tags(tags) == {f"{STAGE_PREFIX}ext": 3.0} + assert from_tags(tags)[0]["usd"] == 0.5 + + +def test_bind_is_idempotent() -> None: + tags: dict = {} + scope = _scope() + bind_scope(tags, scope) + record_scope_timing(scope, "ext", 1.0) + bind_scope(tags, scope) + record_scope_timing(scope, "ext", 1.0) + assert timings_from_tags(tags) == {f"{STAGE_PREFIX}ext": 2.0} + + +def test_timings_from_tags_tolerates_junk() -> None: + for junk in ( + None, + {}, + {STAGE_TIMING_TAG: "nope"}, + {STAGE_TIMING_TAG: []}, + {STAGE_TIMING_TAG: {"a": "b"}}, + ): + assert timings_from_tags(junk) == {} + + +# --- the ledgers are structures, not labels --------------------------------- + + +def test_neither_ledger_leaks_into_request_log_tags() -> None: + """They ride on ``tags`` because that is the one dict reaching the outcome + funnel from every handler. A list and a dict must not land in a + string-keyed label store.""" + tags: dict = {"client": "claude-code"} + scope = _scope() + bind_scope(tags, scope) + record_scope_savings(scope, "ext", usd=1.0) + record_scope_timing(scope, "ext", 1.0) + + assert public_tags(tags) == {"client": "claude-code"} + assert SAVINGS_ATTRIBUTION_TAG not in public_tags(tags) + assert STAGE_TIMING_TAG not in public_tags(tags) + + +# --- through the outcome funnel --------------------------------------------- + +pytest.importorskip("fastapi") + + +class _Harness: + """Just enough of HeadroomProxy to drive the real funnel method. + + Mirrors ``tests/test_request_outcome.py::_FunnelHarness`` — the real + implementation is bound to the harness, so nothing under test is mocked. + """ + + def __init__(self) -> None: + from unittest.mock import AsyncMock, MagicMock + + from headroom.proxy.server import HeadroomProxy + + self.metrics = MagicMock() + self.metrics.record_request = AsyncMock() + self.cost_tracker = MagicMock() + self.logger = None + self._record_request_outcome = HeadroomProxy._record_request_outcome.__get__( + self, type(self) + ) + + +def _outcome(**overrides): + from headroom.proxy.outcome import RequestOutcome + + defaults = { + "request_id": "req-1", + "provider": "anthropic", + "model": "claude-sonnet-4", + "original_tokens": 1000, + "optimized_tokens": 1000, + "output_tokens": 50, + "tokens_saved": 0, + "attempted_input_tokens": 1000, + } + defaults.update(overrides) + return RequestOutcome(**defaults) + + +@pytest.mark.asyncio +async def test_extension_timing_reaches_pipeline_timing() -> None: + """The whole point of the timing half: ``pipeline_timing`` is what + ``/stats``, the dashboard's Performance panel and + ``headroom_transform_timing_ms_*`` are all built on.""" + scope = _scope() + record_scope_timing(scope, "routemegood", 8.0) + tags: dict = {} + bind_scope(tags, scope) + + h = _Harness() + await h._record_request_outcome(_outcome(tags=tags, pipeline_timing={"deep_copy": 1.0})) + + timing = h.metrics.record_request.await_args.kwargs["pipeline_timing"] + assert timing == {"deep_copy": 1.0, f"{STAGE_PREFIX}routemegood": 8.0} + + +@pytest.mark.asyncio +async def test_a_handler_timing_wins_a_name_collision() -> None: + """Namespacing makes this unreachable today; it is asserted so that if the + prefix ever goes, a plugin still cannot overwrite a measurement the + pipeline made of itself.""" + tags = {STAGE_TIMING_TAG: {"deep_copy": 99.0}} + + h = _Harness() + await h._record_request_outcome(_outcome(tags=tags, pipeline_timing={"deep_copy": 1.0})) + + timing = h.metrics.record_request.await_args.kwargs["pipeline_timing"] + assert timing["deep_copy"] == 1.0 + + +@pytest.mark.asyncio +async def test_no_extension_timing_leaves_pipeline_timing_untouched() -> None: + """Including identity: a request with no extension must pass the handler's + own dict through, not a rebuilt copy of it.""" + original = {"deep_copy": 1.0} + + h = _Harness() + await h._record_request_outcome(_outcome(pipeline_timing=original)) + + assert h.metrics.record_request.await_args.kwargs["pipeline_timing"] is original + + +@pytest.mark.asyncio +async def test_extension_savings_reach_the_metrics_call() -> None: + scope = _scope() + record_scope_savings(scope, "routemegood", usd=0.42, tokens=0) + tags: dict = {} + bind_scope(tags, scope) + + h = _Harness() + await h._record_request_outcome(_outcome(tags=tags)) + + attribution = h.metrics.record_request.await_args.kwargs["savings_attribution"] + assert [(row["source"], row["usd"]) for row in attribution] == [("routemegood", 0.42)] diff --git a/tests/test_gemini_nonjson_status.py b/tests/test_gemini_nonjson_status.py index 341fbaab2..2409b2390 100644 --- a/tests/test_gemini_nonjson_status.py +++ b/tests/test_gemini_nonjson_status.py @@ -15,6 +15,7 @@ class _FakeRequest: self.headers: dict[str, str] = {} self.query_params: dict[str, str] = {} self.url = SimpleNamespace(path="/v1beta/models/gemini-pro:generateContent", query="") + self.scope: dict = {"type": "http", "method": "POST"} class _NonJsonResponse: diff --git a/tests/test_proxy_handler_helpers.py b/tests/test_proxy_handler_helpers.py index a11e437b3..e0e2a90ea 100644 --- a/tests/test_proxy_handler_helpers.py +++ b/tests/test_proxy_handler_helpers.py @@ -117,6 +117,7 @@ class _VertexGeminiImageRequest: method = "POST" headers = {} query_params = {} + scope: dict = {"type": "http", "method": "POST"} url = SimpleNamespace( path="/v1/projects/p/locations/us-central1/publishers/google/models/gemini-2.0-flash:generateContent", query="", diff --git a/tests/test_proxy_handlers_batch.py b/tests/test_proxy_handlers_batch.py index 53e823fcf..504e0c6f1 100644 --- a/tests/test_proxy_handlers_batch.py +++ b/tests/test_proxy_handlers_batch.py @@ -156,6 +156,9 @@ class FakeRequest: self.method = method self.url = SimpleNamespace(path=path, query=query) self.query_params = {} + # Every real Starlette Request has one, and handlers now share a + # per-request attribution ledger through it (savings_attribution). + self.scope: dict = {"type": "http", "method": method} async def body(self) -> bytes: return self._body From 9d370592b022d01e6bc44a88649a611507794776 Mon Sep 17 00:00:00 2001 From: Parideboy Date: Mon, 17 Aug 2026 00:04:01 +0200 Subject: [PATCH 06/53] fix(proxy): stop cached responses replaying the producing turn's wire framing (#3024) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Closes #3019 A response-cache hit could hand the client an HTTP 200 that the client could not read, and nothing in the logs marked the turn as anything other than normal. Two separate problems combine to produce the reported failure. **The unreadable 200.** A cache entry stores the producing upstream's response headers verbatim. When the entry is replayed, the Anthropic handler removed only `content-encoding`, `content-length` and `content-type` before handing those headers to a brand-new `Response`. Anything else describing how that *other* connection framed its body rode along — most damagingly `transfer-encoding: chunked`. RFC 9112 §6.1 makes `Transfer-Encoding` override `Content-Length`, so the client is told to parse a plain JSON body as chunked frames, finds no valid chunk-size line, and reads an empty body out of a 200. Every other response-forwarding site in the Python proxy already strips that header; the two cache-hit sites were the only ones that did not. **How a CCR turn could put a foreign response in the cache.** On the Anthropic path, `cache.get` is gated on `not stream` but `cache.set` was not, and the cache key has no `stream` component. A CCR buffered-stream conversion takes a request the client sent with `stream: true`, forces `stream: false` upstream, and — unlike every other streaming turn, which returns via `_stream_response` and never touches the cache — falls through to the store site. The stored reply was shaped by that forced flip plus CCR tool injection, and the key cannot distinguish it from an ordinary non-streaming reply, so a later non-streaming caller could be served a response built for a request it never made. This is why the reporters saw the failures pair with CCR activity and stop under `--lossless` / `--no-ccr`. **Why it was invisible.** The cache-hit block emitted no log line at all, and the `PERF` line rendered no field for `RequestOutcome.from_response_cache`. A cache-served turn contacts no upstream, so it has no `outbound_request` line, no upstream stage timings, and all-zero token counters — byte-for-byte what a turn that died would look like. That is why `headroom doctor` reported zero failures while turns were dying. ### Scope note The header fix also lands on the OpenAI cache-hit site, which additionally never received the `content-type` fix from #2952. The `not stream` gate is added to the OpenAI store site too, where it is currently redundant — a streaming chat request returns via `_stream_response` long before that point — purely to state the invariant, since the Anthropic handler had exactly that shape until a buffered-CCR branch began falling through to it. Because the strip list now lives in one shared helper, the OpenAI handler's other five forwarding sites strip the three added headers as well. That is a widening, so it is worth being explicit about: each of those sites builds a fresh fixed-length `Response` (or, at `openai.py:6122`, synthesises SSE) from `response.content`, so replaying the upstream's framing there was the same latent bug, just without a cache to make it outlive the request that produced it. The precedent is already in the file — `openai.py:9865` passes `"transfer-encoding", "connection"` as extra names by hand, which is exactly the gap this PR closes centrally. That call site keeps its now-redundant arguments; removing them is a cleanup for another PR. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - Added `sanitize_forwarded_response_headers` to `headroom/proxy/helpers.py`, promoting the private helper that already lived in `headroom/proxy/handlers/openai.py` and extending it with the remaining wire-framing headers (`transfer-encoding`, `connection`, `keep-alive`). Matching is now case-insensitive; surviving headers keep their original casing. `openai.py`'s `_sanitize_forwarded_response_headers` is now a thin alias so its six call sites and the Anthropic handler strip an identical set. - `headroom/proxy/handlers/anthropic.py`: the response-cache hit now sanitises through that helper (passing `content-type` as an extra name, preserving #2952) instead of three hand-rolled `pop` calls. - `headroom/proxy/handlers/openai.py`: the response-cache hit sanitises the same way, gains the `content-type` handling it was missing, and sets `media_type="application/json"` explicitly. - `headroom/proxy/handlers/anthropic.py`: `cache.set` is now gated on `not stream`, mirroring the read gate. `stream` still holds the client's original flag at that point — the buffered-CCR conversion flips `body["stream"]`, never the local variable. - `headroom/proxy/handlers/openai.py`: the same `not stream` gate on its store site, as an invariant guard. - Both cache-hit sites now log `RESPONSE-CACHE-HIT: model=… bytes=… age_s=… hits=…`, following the existing `CACHE-MISS-ATTRIBUTION` line style. - `headroom/proxy/outcome.py`: the `PERF` line appends `cached=1` on a response-cache hit. It is appended only on a hit, so every other PERF line is byte-identical to before and existing parsers are unaffected. - `headroom/perf/analyzer.py`: `PerfRecord.from_response_cache` reads that field, so `headroom perf` can tell a cache-served turn from a dead one. It defaults to `False`, so older logs still parse. `PERF_RECORD_FIELDS` gains the name at the end of the list, which is what `headroom perf --format csv --raw` uses as its column set; appending keeps every existing column at its current position. `--format json --raw` gains the key too. - `tests/test_anthropic_pre_upstream_backpressure.py`: its cache-hit double was a partial hand-rolled stand-in for `CacheEntry` carrying only a body and headers, so it broke once the hit path started reading the entry's age and hit count. It now constructs a real `CacheEntry`, which is what the cache actually returns. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality ### Test Output ```text $ python -m pytest tests/test_proxy_response_cache_replay.py -q tests\test_proxy_response_cache_replay.py ......... [100%] ============================== 9 passed in 4.22s ============================== # Everything that mentions PERF, the sanitiser, cache.set, PerfRecord or # response_headers, plus the whole proxy suite. $ python -m pytest tests/test_proxy/ tests/test_proxy_compression_headers.py \ tests/test_agent_savings.py tests/test_anthropic_pre_upstream_backpressure.py \ tests/test_backend_nonstreaming_cache_metrics.py tests/test_backend_streaming_cache_metrics.py \ tests/test_ccr_buffered_stream_signed_thinking.py tests/test_cli_perf_format.py \ tests/test_codex_ws_compression_scheduler.py tests/test_handler_outcome_tag_invariant.py \ tests/test_openai_codex_ws_lifecycle.py tests/test_provider_codex_images.py \ tests/test_proxy_handlers_batch.py tests/test_proxy_passthrough_transient_retry.py \ tests/test_proxy_response_cache_replay.py tests/test_proxy_semantic_cache_key.py \ tests/test_proxy_streaming_request_logger.py tests/test_request_outcome.py \ tests/test_savings_tool_search_aggregation.py -q ================== 555 passed, 1 skipped in 88.60s (0:01:28) ================== # Full suite, 16 workers. See "Real Behavior Proof" below for how every # failure here was traced to a pre-existing failure or a parallelism flake. $ python -m pytest tests scripts/tests -n 16 -q -p no:randomly --timeout=300 83 failed, 10493 passed, 657 skipped, 80 errors in 437.00s (0:07:17) $ ruff check . All checks passed! $ ruff format --check 7 files already formatted $ python -m mypy headroom --ignore-missing-imports --python-version 3.13 Found 12 errors in 3 files (checked 520 source files) # All 12 are pre-existing MCP-SDK/tomllib drift in release_version.py, # ccr/mcp_server.py and memory/mcp_server.py; identical count before and # after this change, none in the files it touches. ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.13.11, pytest 9.1.1, ruff 0.16.2, branch based on `upstream/main` at `2d88e31a`. - Exact command / steps: Two experiments. (1) Revert-and-rerun: I reverted both fixes in place (dropped the three framing headers from `FRAMING_RESPONSE_HEADERS`, restored `cache.set` to `if self.cache and response.status_code == 200 and resp_json is not None:`), ran `python -m pytest tests/test_proxy_response_cache_replay.py -q`, then restored the fixes and re-ran. (2) Regression sweep: ran the full suite on this branch, then checked out `upstream/main` into a second worktree and re-ran, in that worktree, exactly the tests that failed here and not there. - Observed result: With the fixes reverted, 5 of 9 new tests fail and reproduce both halves of the bug. `test_buffered_ccr_turn_does_not_write_the_response_cache` fails with `AssertionError: Expected mock to not have been awaited. Awaited 1 times.` — a turn the client sent as `stream: true` really does reach `cache.set` through the buffered-CCR branch. `test_cache_hit_replays_a_body_the_client_can_actually_read` fails with `AssertionError: assert 'transfer-encoding' not in {'transfer-encoding': 'chunked', 'connection': 'keep-alive', 'request-id': ..., 'content-length': '228', ...}` — the replayed 200 carries the producing turn's chunked framing alongside a fresh `content-length`, which is the exact framing conflict a client cannot parse. With the fixes restored, all 9 pass, the replayed body arrives intact as `application/json`, and the run logs both `RESPONSE-CACHE-HIT` and a `PERF … cached=1` line. The full suite on this branch gives `83 failed, 10493 passed, 657 skipped, 80 errors`; 33 of those failures were not in my baseline list, so I ran those 33 in the `upstream/main` worktree and 20 failed there identically (Windows-specific: `sqlite:///C:\…` path handling, private-directory permissions, fsync, ONNX thread caps, serena config discovery). Re-running the remaining 13 serially on this branch gave `1 failed, 25 passed` — the other 12 were xdist parallelism flakes, including all four `tests/test_proxy/test_anthropic_ccr_deferred_injection.py` tests, which are the only ones in this change's blast radius and which pass serially. The one real serial failure, `tests/test_savings_ledger_offload.py::test_concurrent_requests_all_land_their_events` (`AssertionError: a concurrent append was lost / assert 23 == 24`), fails the same way on `upstream/main` run serially. The 80 errors are dashboard-template collection errors unrelated to the proxy. Net: no failure attributable to this change. - Not tested: I could not reproduce against live upstream traffic, so I have not confirmed which upstream in the reporters' setups emits `transfer-encoding: chunked`. Anthropic direct is HTTP/2, where the header is forbidden, but any HTTP/1.1 hop (corporate proxy, third-party gateway, local relay) reintroduces it. I have also not measured whether the `not stream` gate reduces the cache hit rate in practice; by construction it can only drop entries that were unsafe to serve. A reporter running unmodified 0.35.0 with `headroom proxy --no-cache` would confirm the cache path is the one involved, and that flag is a lighter workaround than `--lossless` or `--no-ccr` because it keeps CCR and compression enabled. ## Runtime Rollout Safety - Rollout-managed feature(s): none — this is a correctness fix on the always-on response-cache path (`cache_enabled` defaults to `True`). - Minimum rollout channel: stable. - Stable/default behavior changed: yes, in four ways. Replayed cached responses no longer carry the producing upstream's framing headers (or `server`, on the Anthropic side). Forwarded responses on the OpenAI handler's other five sanitiser call sites no longer carry `transfer-encoding`, `connection` or `keep-alive` either, since the strip list is now shared; all five build a fixed-length response from `response.content`, so none of them could legitimately replay that framing. A turn whose client asked for `stream: true` no longer writes the response cache on the Anthropic path. `PERF` lines gain a trailing `cached=1` on a response-cache hit only; all other PERF lines are unchanged. - Kill switch / disable path: `headroom proxy --no-cache` disables the response cache entirely and bypasses every path this PR touches. - Unsafe override required: no. - Qualification impact: low. No public API, config key, CLI flag or wire format changes. Two additive output changes: the `cached=1` PERF field, which `_parse_kv` already handles the same way it handles the existing trailing `client=` field, and a `from_response_cache` column appended to `headroom perf --format csv --raw` (plus the matching key in `--format json --raw`). Anything consuming that CSV positionally keeps working because the column is last; anything reading it by name is unaffected. - Rollback path: revert this commit. It is self-contained with no migration, no persisted state and no schema change; cache entries written before or after behave identically on read. ## 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) ## Additional Notes Documentation is marked N/A: no user-facing surface changes, and the new `cached=` PERF field is additive and self-describing. Relationship to nearby open PRs, since several touch adjacent code: - **#2953** (already merged, unreleased) added the `resp_json is not None` guard at the same Anthropic store site. That stops an SSE *body* being stored; it does not stop a JSON-bodied response storing chunked framing headers, and it does not add the `stream` gate. The two changes are complementary. - **#2959** and **#2968** both touch the buffered-CCR response path but address when and how the status is committed. Neither reaches the cache-hit replay. - **#3013** rewrites CCR into event-level stream splicing and keeps `buffered_stream_ccr` as a fallback, so the store site this PR gates remains reachable. If #3013 lands first I am happy to rebase. `mypy headroom --ignore-missing-imports` reports 12 pre-existing errors in `headroom/release_version.py`, `headroom/ccr/mcp_server.py` and `headroom/memory/mcp_server.py` from MCP SDK version drift in my local environment. None are in the files this PR touches, and the count is identical before and after the change. Co-authored-by: Claude Opus 5 Co-authored-by: JD Davis --- headroom/perf/analyzer.py | 9 + headroom/proxy/handlers/anthropic.py | 60 ++- headroom/proxy/handlers/openai.py | 50 ++- headroom/proxy/helpers.py | 34 ++ headroom/proxy/outcome.py | 10 + ...est_anthropic_pre_upstream_backpressure.py | 21 +- tests/test_proxy_response_cache_replay.py | 348 ++++++++++++++++++ 7 files changed, 507 insertions(+), 25 deletions(-) create mode 100644 tests/test_proxy_response_cache_replay.py diff --git a/headroom/perf/analyzer.py b/headroom/perf/analyzer.py index b296e59f6..e05997fac 100644 --- a/headroom/perf/analyzer.py +++ b/headroom/perf/analyzer.py @@ -167,6 +167,11 @@ class PerfRecord: ttfb_ms: float = 0.0 stages: dict[str, float] = field(default_factory=dict) savings_breakdown: list[dict[str, object]] = field(default_factory=list) + # True when the proxy answered from its own response cache and never + # contacted the upstream. Such a turn has all-zero token counters and no + # upstream stage timings, so without this flag it reads as a turn that + # did nothing (#3019). Absent from pre-#3019 logs, hence the default. + from_response_cache: bool = False @dataclass @@ -373,6 +378,7 @@ def parse_log_files(last_n_hours: float = 168.0) -> PerfReport: total_ms=float(kv.get("total_ms", 0)), tokens_out=int(kv.get("tok_out", 0)), ttfb_ms=float(kv.get("ttfb_ms", 0)), + from_response_cache=kv.get("cached", "0") == "1", stages=stages_by_rid.get(m.group("rid"), {}), ) ) @@ -765,6 +771,9 @@ PERF_RECORD_FIELDS = [ "ttfb_ms", "stages", "savings_breakdown", + # Appended last so every existing CSV column keeps its position; a reader + # that indexes by name is unaffected either way. + "from_response_cache", ] diff --git a/headroom/proxy/handlers/anthropic.py b/headroom/proxy/handlers/anthropic.py index 79045fa84..5abb1b074 100644 --- a/headroom/proxy/handlers/anthropic.py +++ b/headroom/proxy/handlers/anthropic.py @@ -36,7 +36,11 @@ from headroom.proxy.auth_mode import ( from headroom.proxy.compression_decision import CompressionDecision from headroom.proxy.forwarded_headers import resolve_client_ip from headroom.proxy.handlers._debug_dump import _debug_dump_mode, _redact_debug_value -from headroom.proxy.helpers import extract_tags, relocate_system_messages_to_top_level +from headroom.proxy.helpers import ( + extract_tags, + relocate_system_messages_to_top_level, + sanitize_forwarded_response_headers, +) from headroom.proxy.image_isolation import run_image_compression_isolated from headroom.proxy.memory_decision import MemoryDecision from headroom.proxy.memory_query import MemoryQuery @@ -1176,15 +1180,32 @@ class AnthropicHandlerMixin: ) ) - # Remove compression headers from cached response - response_headers = dict(cached.response_headers) - response_headers.pop("content-encoding", None) - response_headers.pop("content-length", None) - # Drop the stored content-type too. Starlette lets an - # explicit header win over ``media_type``, so keeping the - # producing request's type would let a cache entry hand this - # caller a wire format it never asked for (#2952). - response_headers.pop("content-type", None) + # Strip the stored response's wire-framing headers. The + # entry carries whatever the *producing* upstream sent, + # and replaying that framing over a different connection + # breaks the body: a stale ``transfer-encoding: chunked`` + # makes the client parse plain JSON as chunked frames and + # read nothing out of an HTTP 200 (#3019). ``content-type`` + # goes too, because Starlette lets an explicit header win + # over ``media_type`` and the producing request's type + # would hand this caller a wire format it never asked + # for (#2952). + response_headers = sanitize_forwarded_response_headers( + cached.response_headers, + "content-type", + ) + + # A cache hit answers the client without touching the + # upstream, so it emits no outbound_request line and no + # upstream stage timings. Without this log a served-from- + # cache turn is indistinguishable from a turn that died + # silently, which is exactly how #3019 stayed invisible. + logger.info( + f"[{request_id}] RESPONSE-CACHE-HIT: model={model} " + f"bytes={len(cached.response_body)} " + f"age_s={(datetime.now() - cached.created_at).total_seconds():.0f} " + f"hits={cached.hit_count}" + ) # Unit 4: release the pre-upstream semaphore on cache # hit — no upstream call will happen. @@ -3991,7 +4012,24 @@ class AnthropicHandlerMixin: # the key: the cache key has no ``stream`` component, so # a buffered request would be answered with a stream it # cannot read (#2952). - if self.cache and response.status_code == 200 and resp_json is not None: + # + # ``not stream`` mirrors the read gate at the cache + # lookup above. ``stream`` still holds the *client's* + # original flag here — the buffered-CCR conversion + # flips ``body["stream"]``, never this variable — so a + # turn the client asked to stream is the one case that + # can reach this store site with a buffered body. That + # body was shaped by a forced ``stream: false`` flip + # plus CCR tool injection, and the key cannot tell it + # apart from an ordinary non-streaming reply, so + # storing it lets a later caller be answered with a + # response built for a request it never made (#3019). + if ( + self.cache + and not stream + and response.status_code == 200 + and resp_json is not None + ): await self.cache.set( cache_lookup_messages, model, diff --git a/headroom/proxy/handlers/openai.py b/headroom/proxy/handlers/openai.py index c011b4984..4886593d4 100644 --- a/headroom/proxy/handlers/openai.py +++ b/headroom/proxy/handlers/openai.py @@ -28,6 +28,7 @@ from headroom.proxy.helpers import ( _headroom_bypass_enabled, extract_tags, jitter_delay_ms, + sanitize_forwarded_response_headers, ) from headroom.proxy.loopback_guard import is_loopback_host from headroom.proxy.stage_timer import StageTimer, emit_stage_timings_log @@ -327,10 +328,10 @@ def _sanitize_forwarded_response_headers( headers: httpx.Headers | dict[str, str], *extra_names: str, ) -> dict[str, str]: - cleaned = dict(headers) - for name in ("content-encoding", "content-length", "server", *extra_names): - cleaned.pop(name, None) - return cleaned + # Thin alias kept for the many call sites in this module; the policy + # (and the list of framing headers) lives in one place so the Anthropic + # handler strips exactly the same set — see #3019. + return sanitize_forwarded_response_headers(headers, *extra_names) def _resolve_openai_handler_path( @@ -3273,10 +3274,34 @@ class OpenAIHandlerMixin: ) ) - # Remove compression headers from cached response - response_headers = _sanitize_forwarded_response_headers(cached.response_headers) + # Strip the stored response's wire-framing headers, and its + # content-type: the entry carries whatever the *producing* + # upstream sent, and replaying that framing over a different + # connection breaks the body — a stale + # ``transfer-encoding: chunked`` makes the client parse plain + # JSON as chunked frames and read nothing out of an HTTP 200 + # (#3019, same reasoning as #2952 on the Anthropic twin). + response_headers = _sanitize_forwarded_response_headers( + cached.response_headers, + "content-type", + ) - return Response(content=cached.response_body, headers=response_headers) + # A cache hit answers without touching the upstream, so it + # emits no outbound_request line and no upstream stage + # timings. Log it, or a served-from-cache turn looks exactly + # like a turn that died silently (#3019). + logger.info( + f"[{request_id}] RESPONSE-CACHE-HIT: model={model} " + f"bytes={len(cached.response_body)} " + f"age_s={(datetime.now() - cached.created_at).total_seconds():.0f} " + f"hits={cached.hit_count}" + ) + + return Response( + content=cached.response_body, + headers=response_headers, + media_type="application/json", + ) # Token counting (offloaded off the event loop — GH #1701) tokenizer, original_tokens = await self._count_tokens_offloaded(model, messages) @@ -4810,7 +4835,16 @@ class OpenAIHandlerMixin: # Cache response under the SAME key it was looked up by: # cache_lookup_messages is the raw pre-mutation snapshot, not # the live (hooked) `messages` (#327). - if self.cache and response.status_code == 200: + # + # ``not stream`` mirrors the read gate at the cache lookup + # above. It is currently redundant here — a streaming chat + # request returns via ``_stream_response`` well before this + # point — but the Anthropic handler had the same shape until a + # buffered-CCR branch started falling through to its store + # site, which let a response built for a stream:true request + # answer a later non-streaming caller (#3019). Stating the + # invariant keeps that from being reintroduced silently. + if self.cache and not stream and response.status_code == 200: await self.cache.set( cache_lookup_messages, model, diff --git a/headroom/proxy/helpers.py b/headroom/proxy/helpers.py index ecd57f242..a20801c2e 100644 --- a/headroom/proxy/helpers.py +++ b/headroom/proxy/helpers.py @@ -317,6 +317,40 @@ def _headroom_bypass_enabled(headers: Any) -> bool: return bypass or passthrough +# Response headers that describe how the *upstream* framed its body on the +# wire, not what the payload means. Every one of them is invalid to replay: +# Starlette recomputes content-length, and uvicorn owns the connection +# framing. Forwarding a stale ``transfer-encoding: chunked`` onto a +# fixed-length body is the worst of them — RFC 9112 §6.1 makes +# Transfer-Encoding override Content-Length, so the client tries to parse a +# plain JSON body as chunked frames, finds no valid chunk-size line, and +# reads an empty body out of an HTTP 200 (#3019). +FRAMING_RESPONSE_HEADERS: tuple[str, ...] = ( + "content-encoding", + "content-length", + "transfer-encoding", + "connection", + "keep-alive", + "server", +) + + +def sanitize_forwarded_response_headers( + headers: Any, + *extra_names: str, +) -> dict[str, str]: + """Drop wire-framing headers before replaying an upstream response. + + Pass any additional header names to strip as ``extra_names`` (for + example ``"content-type"`` when the caller sets its own media type). + + Matching is case-insensitive, but the casing of the headers that + survive is left untouched. + """ + drop = {name.lower() for name in (*FRAMING_RESPONSE_HEADERS, *extra_names)} + return {key: value for key, value in dict(headers).items() if key.lower() not in drop} + + def log_outbound_request( *, forwarder: str, diff --git a/headroom/proxy/outcome.py b/headroom/proxy/outcome.py index d96190b4d..bc594f81e 100644 --- a/headroom/proxy/outcome.py +++ b/headroom/proxy/outcome.py @@ -586,6 +586,15 @@ async def emit_request_outcome(handler: Any, outcome: RequestOutcome) -> None: # line unchanged, and gives ``headroom perf --client X`` # parsers a clean key to filter on. client_part = f" client={outcome.client}" if outcome.client else "" + # ``cached=1`` marks a turn answered from Headroom's own response cache. + # Such a turn never contacts the upstream, so it has no outbound_request + # line, no upstream stage timings, and all-zero token counters — which + # made it indistinguishable in the logs from a turn that died silently + # (#3019). Appended only on a hit, so every other PERF line is unchanged + # and existing parsers keep working (``_parse_kv`` reads trailing + # key=value pairs after ``transforms=`` the same way it reads + # ``client=``). + cached_part = " cached=1" if outcome.from_response_cache else "" # Tool-schema DEFERRAL savings can't move tok_before/after (those count messages # only), so a tool-heavy turn shows tok_saved=0 while genuinely saving thousands of # tool-definition tokens. `tool_saved` carries that component and `total_saved` is @@ -611,4 +620,5 @@ async def emit_request_outcome(handler: Any, outcome: RequestOutcome) -> None: f"savings={encoded_savings} " f"transforms={_summarize_transforms(list(outcome.transforms_applied))}" f"{client_part}" + f"{cached_part}" ) diff --git a/tests/test_anthropic_pre_upstream_backpressure.py b/tests/test_anthropic_pre_upstream_backpressure.py index 777ee7c6a..be51e48a0 100644 --- a/tests/test_anthropic_pre_upstream_backpressure.py +++ b/tests/test_anthropic_pre_upstream_backpressure.py @@ -26,6 +26,7 @@ import json import logging import os import time +from datetime import datetime from types import SimpleNamespace from typing import Any from unittest.mock import MagicMock @@ -38,7 +39,7 @@ from fastapi.testclient import TestClient from headroom.cli.proxy import proxy as proxy_cli from headroom.proxy.handlers.anthropic import AnthropicHandlerMixin -from headroom.proxy.models import ProxyConfig +from headroom.proxy.models import CacheEntry, ProxyConfig from headroom.proxy.server import HeadroomProxy, create_app # --------------------------------------------------------------------------- # @@ -870,12 +871,20 @@ class _SecurityBlock: class _CacheHit: - class _Entry: - response_headers: dict = {} - response_body: bytes = b'{"id":"cached","type":"message","role":"assistant","content":[{"type":"text","text":"hit"}]}' - def __init__(self) -> None: - self._entry = self._Entry() + # A real ``CacheEntry`` rather than a hand-rolled stand-in: the + # cache-hit path reads more of the entry than just the body (it logs + # the entry's age and hit count), and a partial fake drifts out of + # sync with it silently. + self._entry = CacheEntry( + response_body=( + b'{"id":"cached","type":"message","role":"assistant",' + b'"content":[{"type":"text","text":"hit"}]}' + ), + response_headers={}, + created_at=datetime.now(), + ttl_seconds=3600, + ) async def get(self, _messages, _model, **_kwargs): return self._entry diff --git a/tests/test_proxy_response_cache_replay.py b/tests/test_proxy_response_cache_replay.py new file mode 100644 index 000000000..776d2569d --- /dev/null +++ b/tests/test_proxy_response_cache_replay.py @@ -0,0 +1,348 @@ +"""Regression tests for #3019 — a response-cache hit must not hand the client +an unusable HTTP 200. + +Three separate defects met to produce the reported failure: + +1. The cached entry stores the *producing* upstream's response headers + verbatim. Replaying ``transfer-encoding: chunked`` onto a fresh + fixed-length response makes the client parse plain JSON as chunked frames + (RFC 9112 §6.1: Transfer-Encoding overrides Content-Length), so it reads an + empty body out of a 200. +2. The Anthropic ``cache.set`` had no ``stream`` gate while ``cache.get`` did, + and the cache key has no ``stream`` component — so a buffered-CCR turn + (client asked for ``stream: true``, upstream forced to ``stream: false``) + could store a response that a later non-streaming caller was served. +3. Nothing logged the hit, and the PERF line rendered no ``cached=`` field, so + a served-from-cache turn was indistinguishable from a turn that died. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from datetime import datetime +from unittest.mock import AsyncMock, patch + +import pytest + +fastapi = pytest.importorskip("fastapi") +httpx = pytest.importorskip("httpx") + +from fastapi.testclient import TestClient # noqa: E402 + +from headroom.ccr.tool_injection import create_ccr_tool_definition # noqa: E402 +from headroom.proxy.helpers import sanitize_forwarded_response_headers # noqa: E402 +from headroom.proxy.models import CacheEntry # noqa: E402 +from headroom.proxy.outcome import RequestOutcome, emit_request_outcome # noqa: E402 +from headroom.proxy.server import ProxyConfig, create_app # noqa: E402 + + +class _CapturingHandler(logging.Handler): + def __init__(self) -> None: + super().__init__(level=logging.INFO) + self.records: list[logging.LogRecord] = [] + + def emit(self, record: logging.LogRecord) -> None: + self.records.append(record) + + def messages(self) -> list[str]: + return [record.getMessage() for record in self.records] + + +@pytest.fixture +def proxy_log_capture(): + """Capture ``headroom.proxy`` records. + + ``_setup_file_logging`` sets ``propagate = False`` on this logger, so + ``caplog`` (which hangs off the root) never sees them — the same reason + ``tests/test_anthropic_stage_timings.py`` attaches its own handler. + """ + target = logging.getLogger("headroom.proxy") + handler = _CapturingHandler() + previous_level = target.level + target.addHandler(handler) + target.setLevel(logging.INFO) + try: + yield handler + finally: + target.removeHandler(handler) + target.setLevel(previous_level) + + +# -------------------------------------------------------------------------- +# 1. The shared header sanitiser +# -------------------------------------------------------------------------- + + +class TestSanitizeForwardedResponseHeaders: + def test_drops_every_wire_framing_header(self): + cleaned = sanitize_forwarded_response_headers( + { + "content-encoding": "gzip", + "content-length": "412", + "transfer-encoding": "chunked", + "connection": "keep-alive", + "keep-alive": "timeout=5", + "server": "cloudflare", + "request-id": "req_abc", + "anthropic-ratelimit-requests-remaining": "42", + } + ) + assert cleaned == { + "request-id": "req_abc", + "anthropic-ratelimit-requests-remaining": "42", + } + + def test_matches_case_insensitively_but_preserves_surviving_casing(self): + cleaned = sanitize_forwarded_response_headers( + {"Transfer-Encoding": "chunked", "Request-Id": "req_abc"} + ) + assert cleaned == {"Request-Id": "req_abc"} + + def test_extra_names_are_dropped_too(self): + cleaned = sanitize_forwarded_response_headers( + {"content-type": "text/event-stream", "request-id": "req_abc"}, + "content-type", + ) + assert cleaned == {"request-id": "req_abc"} + + def test_accepts_httpx_headers(self): + cleaned = sanitize_forwarded_response_headers( + httpx.Headers({"transfer-encoding": "chunked", "request-id": "req_abc"}) + ) + assert "transfer-encoding" not in cleaned + assert cleaned["request-id"] == "req_abc" + + +# -------------------------------------------------------------------------- +# 2. Replaying a poisoned cache entry +# -------------------------------------------------------------------------- + + +def _cache_config() -> ProxyConfig: + return ProxyConfig( + optimize=False, + cache_enabled=True, + rate_limit_enabled=False, + cost_tracking_enabled=False, + log_requests=False, + image_optimize=False, + ) + + +_CACHED_BODY = json.dumps( + { + "id": "msg_cached", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-6", + "content": [{"type": "text", "text": "served from cache"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 10, "output_tokens": 5}, + } +).encode() + + +def _poisoned_entry() -> CacheEntry: + """A cache entry carrying the producing upstream's wire framing.""" + return CacheEntry( + response_body=_CACHED_BODY, + response_headers={ + "transfer-encoding": "chunked", + "content-length": "999999", + "content-encoding": "gzip", + "connection": "keep-alive", + "content-type": "text/event-stream", + "request-id": "req_from_the_producing_turn", + }, + created_at=datetime.now(), + ttl_seconds=3600, + ) + + +def test_cache_hit_replays_a_body_the_client_can_actually_read(proxy_log_capture): + """The replayed 200 must carry no stale framing and an intact JSON body.""" + with patch("headroom.proxy.server.AnyLLMBackend"): + app = create_app(_cache_config()) + with TestClient(app) as client: + proxy = client.app.state.proxy + proxy.cache.get = AsyncMock(return_value=_poisoned_entry()) + proxy._retry_request = AsyncMock( + side_effect=AssertionError("a cache hit must not contact the upstream") + ) + + resp = client.post( + "/v1/messages", + headers={"x-api-key": "test-key", "anthropic-version": "2023-06-01"}, + json={ + "model": "claude-sonnet-4-6", + "max_tokens": 64, + "messages": [{"role": "user", "content": "hello"}], + }, + ) + + assert resp.status_code == 200 + # The body survived intact — this is what an empty 200 looked like. + assert resp.json()["content"][0]["text"] == "served from cache" + + replayed = {key.lower(): value for key, value in resp.headers.items()} + # None of the producing turn's framing may ride along. + assert "transfer-encoding" not in replayed + assert "content-encoding" not in replayed + assert "connection" not in replayed + # content-type is the caller's, not the producing turn's (#2952). + assert replayed["content-type"] == "application/json" + # content-length describes THIS body, not the stored one. + assert replayed["content-length"] == str(len(_CACHED_BODY)) + # Non-framing upstream metadata still passes through. + assert replayed["request-id"] == "req_from_the_producing_turn" + + # The hit is no longer silent, and the PERF line marks it as cache-served. + messages = proxy_log_capture.messages() + assert any("RESPONSE-CACHE-HIT" in message for message in messages) + assert any(" PERF " in message and "cached=1" in message for message in messages) + + +# -------------------------------------------------------------------------- +# 3. A buffered-CCR turn must not populate the cache +# -------------------------------------------------------------------------- + + +def _ccr_cache_config() -> ProxyConfig: + return ProxyConfig( + optimize=False, + cache_enabled=True, + rate_limit_enabled=False, + cost_tracking_enabled=False, + log_requests=False, + ccr_inject_tool=True, + ccr_handle_responses=True, + ccr_context_tracking=False, + image_optimize=False, + ) + + +def test_buffered_ccr_turn_does_not_write_the_response_cache(): + """A client ``stream: true`` turn is converted to a buffered ``stream: + false`` upstream call. Its reply is shaped by that flip plus CCR tool + injection, and the cache key has no ``stream`` component — so storing it + would let a later non-streaming caller be served a response built for a + request it never made (#3019). + """ + upstream_response = { + "id": "msg_buffered", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-6", + "content": [{"type": "text", "text": "buffered reply"}], + "stop_reason": "end_turn", + "usage": { + "input_tokens": 10, + "output_tokens": 5, + "cache_read_input_tokens": 0, + "cache_creation_input_tokens": 0, + }, + } + + with patch("headroom.proxy.server.AnyLLMBackend"): + app = create_app(_ccr_cache_config()) + with TestClient(app) as client: + proxy = client.app.state.proxy + proxy._stream_response = AsyncMock( + side_effect=AssertionError("buffered CCR must not take the live stream path") + ) + proxy.cache.set = AsyncMock() + forwarded_bodies: list[dict] = [] + + async def _fake_retry(method, url, headers, body, stream=False, **kwargs): # noqa: ANN001 + forwarded_bodies.append(json.loads(json.dumps(body))) + return httpx.Response(200, json=upstream_response) + + proxy._retry_request = _fake_retry # type: ignore[assignment] + + resp = client.post( + "/v1/messages", + headers={ + "x-api-key": "test-key", + "anthropic-version": "2023-06-01", + "accept": "text/event-stream", + }, + json={ + "model": "claude-sonnet-4-6", + "max_tokens": 64, + "stream": True, + "tools": [create_ccr_tool_definition("anthropic")], + "messages": [{"role": "user", "content": "hello"}], + }, + ) + + assert resp.status_code == 200, resp.text + # The conversion really happened — otherwise this test proves nothing. + assert forwarded_bodies and forwarded_bodies[0]["stream"] is False + # ...and nothing was written to the response cache. + proxy.cache.set.assert_not_awaited() + + +# -------------------------------------------------------------------------- +# 4. The PERF line marks a cache-served turn +# -------------------------------------------------------------------------- + + +class _Metrics: + async def record_request(self, **kwargs): + return None + + async def record_failed(self, provider): + return None + + +class _Handler: + def __init__(self): + self.metrics = _Metrics() + self.cost_tracker = None + self.logger = None + + +def _perf_line(capture: _CapturingHandler) -> str: + for message in capture.messages(): + if " PERF " in message: + return message + raise AssertionError("no PERF log line captured") + + +def _outcome(*, from_response_cache: bool) -> RequestOutcome: + return RequestOutcome( + request_id="req-1", + provider="anthropic", + model="claude-sonnet-4-6", + original_tokens=0, + optimized_tokens=0, + output_tokens=0, + tokens_saved=0, + attempted_input_tokens=0, + from_response_cache=from_response_cache, + ) + + +def test_perf_line_marks_a_response_cache_hit(proxy_log_capture): + asyncio.run(emit_request_outcome(_Handler(), _outcome(from_response_cache=True))) + assert "cached=1" in _perf_line(proxy_log_capture) + + +def test_perf_line_is_unchanged_for_an_ordinary_turn(proxy_log_capture): + """Appended only on a hit, so existing PERF parsers see no new field.""" + asyncio.run(emit_request_outcome(_Handler(), _outcome(from_response_cache=False))) + assert "cached=" not in _perf_line(proxy_log_capture) + + +def test_perf_analyzer_reads_the_cached_field(): + from headroom.perf.analyzer import _parse_kv + + parsed = _parse_kv("model=claude-sonnet-4-6 transforms=none client=claude cached=1") + assert parsed["cached"] == "1" + # ``transforms=`` is parsed last and swallows the rest of the line, so the + # new trailing field has to survive that split the way ``client=`` does. + assert parsed["client"] == "claude" + assert parsed["transforms"] == "none" + assert parsed["model"] == "claude-sonnet-4-6" From a01897c791f4bb6471defafd560d29d491eb2df8 Mon Sep 17 00:00:00 2001 From: Abhay Singh Date: Mon, 17 Aug 2026 03:34:31 +0530 Subject: [PATCH 07/53] fix(proxy/gemini): guard CCR continuation usage against present-null counts (#3035) ## Description On the Gemini native `generateContent` path, a successful (200) response that triggers a CCR retrieval continuation is masked as a synthetic 502 when the continuation response carries a present-null usage count. `handle_gemini_generate_content` reads `usageMetadata` at three sites. The initial-response site and the non-CCR site both guard against Gemini returning a present-null count (a key present with a JSON `null`, which `.get(key, default)` returns as `None` rather than the default). The CCR-continuation site read the continuation's `usageMetadata` with a bare `.get(key, prior)`: ```python total_input_tokens = usage.get("promptTokenCount", total_input_tokens) output_tokens = usage.get("candidatesTokenCount", output_tokens) cache_read_tokens = usage.get("cachedContentTokenCount", cache_read_tokens) ``` When the continuation turn reports `"promptTokenCount": null`, `total_input_tokens` becomes `None`, and the following `uncached_input_tokens = max(0, total_input_tokens - cache_read_tokens)` and the `total_input_tokens > 0` baseline guard raise `TypeError`. The method's outer `except Exception` then returns a 502 JSONResponse and records a provider failure, so a genuinely successful upstream turn is reported to the client as a 502. ## Fix Read the continuation usage through the same `_usage_int` guard the two sibling sites use, keeping the pre-continuation count as the fallback (`_usage_int(value, default)` returns `default` when `value is None`). Behavior is otherwise unchanged: a present, valid count is still used, and an absent count still falls back to the pre-continuation value. Fixes #3034 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature - [ ] Breaking change - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/proxy/handlers/gemini.py` (`handle_gemini_generate_content`, CCR-continuation branch): read `promptTokenCount` / `candidatesTokenCount` / `cachedContentTokenCount` through `_usage_int(..., prior)` instead of a bare `.get(key, prior)`. - `tests/test_gemini_ccr_continuation_usage.py`: drive the handler through a CCR continuation whose `usageMetadata` counts are present-null; assert the client gets 200 (not 502), no provider failure is recorded, and the pre-continuation count survives as the fallback. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check`) - [x] Type checking passes (`mypy`) - [x] New tests added ### Test Output ```text tests/test_gemini_ccr_continuation_usage.py 1 passed tests/test_gemini_nonjson_status.py tests/test_gemini_compression_offload.py tests/test_proxy_gemini_native_integration.py (all pass; platform-skipped cases skipped) # uvx ruff@0.15.22 check -> All checks passed! # uvx mypy@1.20.2 headroom/proxy/handlers/gemini.py -> Success: no issues found in 1 source file ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.12.11, project venv, pytest 9.1.1, ruff 0.15.22 and mypy 1.20.2 via uvx. - Exact command / steps: ran `python -m pytest tests/test_gemini_ccr_continuation_usage.py -q` (pass-after); proved fail-before by `git stash`-ing only the `gemini.py` change and re-running (the test failed with `assert 502 == 200` and the captured log `TypeError: unsupported operand type(s) for -: 'NoneType' and 'NoneType'` at `gemini.py`), then restored the fix and re-ran green; ran the surrounding Gemini suite (`test_gemini_nonjson_status.py`, `test_gemini_compression_offload.py`, `test_proxy_gemini_native_integration.py`); then `uvx ruff@0.15.22 check` and `uvx mypy@1.20.2 headroom/proxy/handlers/gemini.py`. - Observed result: with the fix a CCR continuation carrying a present-null `promptTokenCount` returns 200 to the client and records the outcome with the pre-continuation count (100) instead of raising `TypeError` and returning a synthetic 502. - Not tested: a live Gemini session that both triggers a CCR retrieval continuation and receives a present-null continuation usage payload (needs a real safety-blocked continuation). The contract is verified at the handler with the same stub pattern the existing `test_gemini_nonjson_status.py` uses. ## Runtime Rollout Safety - Rollout-managed feature(s): none. This is the always-on Gemini native `generateContent` request path, not a rollout-channel-gated feature. - Minimum rollout channel: N/A (no rollout-managed behavior). - Stable/default behavior changed: yes, as a bug fix. A CCR continuation with a present-null usage count now returns the real 200 instead of a synthetic 502; all other cases (present valid count, absent count) are unchanged. - Kill switch / disable path: N/A. There is no behavioral toggle; the change only makes the existing continuation path null-safe. - Unsafe override required: no. - Qualification impact: brings the CCR-continuation usage extraction to parity with the two sibling sites that already guard present-null counts; no routing, compression, or pricing change. - Rollback path: revert this PR; the continuation site returns to the bare `.get(key, prior)` read. ## 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 - [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 ## Additional Notes The unguarded site was introduced in #2253 (native CCR retrieval); the present-null guard on the sibling sites landed separately and did not extend to it. The fix reuses the existing `_usage_int` helper so all three Gemini usage-extraction sites now handle present-null identically. --- headroom/proxy/handlers/gemini.py | 19 ++- tests/test_gemini_ccr_continuation_usage.py | 130 ++++++++++++++++++++ 2 files changed, 146 insertions(+), 3 deletions(-) create mode 100644 tests/test_gemini_ccr_continuation_usage.py diff --git a/headroom/proxy/handlers/gemini.py b/headroom/proxy/handlers/gemini.py index dfdc3e90f..35e249b39 100644 --- a/headroom/proxy/handlers/gemini.py +++ b/headroom/proxy/handlers/gemini.py @@ -876,9 +876,22 @@ class GeminiHandlerMixin: resp_json = final_resp_json response_content = json.dumps(resp_json).encode() usage = resp_json.get("usageMetadata", {}) - total_input_tokens = usage.get("promptTokenCount", total_input_tokens) - output_tokens = usage.get("candidatesTokenCount", output_tokens) - cache_read_tokens = usage.get("cachedContentTokenCount", cache_read_tokens) + # A CCR continuation response can carry a present-null count + # (e.g. a safety-blocked continuation turn), where + # ``.get(key, prior)`` returns None rather than the prior + # value, and the ``max(0, prompt - cache_read)`` / + # ``total_input_tokens > 0`` arithmetic below would then raise + # TypeError and the outer handler would mask a successful 200 + # as a synthetic 502. Guard with ``_usage_int`` (keeping the + # pre-continuation count as the fallback), mirroring the two + # sibling extraction sites above. + total_input_tokens = _usage_int( + usage.get("promptTokenCount"), total_input_tokens + ) + output_tokens = _usage_int(usage.get("candidatesTokenCount"), output_tokens) + cache_read_tokens = _usage_int( + usage.get("cachedContentTokenCount"), cache_read_tokens + ) uncached_input_tokens = max(0, total_input_tokens - cache_read_tokens) diff --git a/tests/test_gemini_ccr_continuation_usage.py b/tests/test_gemini_ccr_continuation_usage.py new file mode 100644 index 000000000..8cf0649a8 --- /dev/null +++ b/tests/test_gemini_ccr_continuation_usage.py @@ -0,0 +1,130 @@ +"""A Gemini CCR continuation with a present-null usage count must not 502. + +The initial-response and non-CCR extraction sites guard against Gemini +returning a *present-null* ``promptTokenCount`` (a key that is present with a +JSON ``null`` value, which ``.get(key, default)`` returns as ``None`` rather +than the default). The CCR-continuation site re-read the continuation's +``usageMetadata`` with a bare ``.get(key, prior)`` and skipped that guard, so a +present-null count on the continuation turned the ``max(0, prompt - cache_read)`` +arithmetic into ``None`` math, raised ``TypeError``, and the outer handler +masked a successful 200 as a synthetic 502. +""" + +from __future__ import annotations + +import json +from types import SimpleNamespace + +import pytest + +from headroom.proxy.handlers.gemini import GeminiHandlerMixin + + +class _FakeRequest: + def __init__(self) -> None: + self.headers: dict[str, str] = {} + self.query_params: dict[str, str] = {} + self.url = SimpleNamespace(path="/v1beta/models/gemini-pro:generateContent", query="") + + +class _CcrToolCallResponse: + """Initial 200 carrying a CCR tool call and a valid promptTokenCount.""" + + status_code = 200 + content = json.dumps( + { + "candidates": [ + {"content": {"parts": [{"functionCall": {"name": "headroom_retrieve"}}]}} + ], + "usageMetadata": {"promptTokenCount": 100, "candidatesTokenCount": 5}, + } + ).encode() + headers = {"content-type": "application/json"} + + def json(self) -> object: + return json.loads(self.content) + + +class _CcrConfig: + enabled = True + + +class _CcrHandler: + """Stub CCR handler whose continuation reports a present-null usage count.""" + + config = _CcrConfig() + + def has_ccr_tool_calls(self, resp_json, provider) -> bool: # noqa: ANN001 + return True + + async def handle_response(self, resp_json, contents, native_fns, api_call_fn, provider): # noqa: ANN001, ANN201 + return { + "candidates": [{"content": {"parts": [{"text": "resolved"}]}}], + # The continuation turn omits real counts as JSON null. + "usageMetadata": { + "promptTokenCount": None, + "candidatesTokenCount": None, + "cachedContentTokenCount": None, + }, + } + + def residual_ccr_status(self, final_resp_json, provider): # noqa: ANN001, ANN201 + return None # not RESIDUAL_CCR_ERROR + + +class _FakeMetrics: + def __init__(self) -> None: + self.failed: list[str] = [] + + async def record_failed(self, *, provider: str, model: str = "") -> None: + self.failed.append(f"{provider}:{model}") + + +class _Handler(GeminiHandlerMixin): + GEMINI_API_URL = "https://gemini.example" + + def __init__(self) -> None: + self.memory_handler = None + self.rate_limiter = None + self.usage_reporter = None + self.config = SimpleNamespace( + optimize=False, + anthropic_pre_upstream_memory_context_timeout_seconds=0.1, + ) + self.metrics = _FakeMetrics() + self.ccr_response_handler = _CcrHandler() + self.outcomes: list = [] + + async def _next_request_id(self) -> str: + return "req-ccr-1" + + async def _retry_request(self, method, url, headers, body): # noqa: ANN001, ANN201 + return _CcrToolCallResponse() + + async def _record_request_outcome(self, outcome) -> None: # noqa: ANN001 + self.outcomes.append(outcome) + + async def _count_tokens_offloaded(self, model, messages): # noqa: ANN001, ANN201 + return SimpleNamespace(), 100 + + +@pytest.mark.asyncio +async def test_ccr_continuation_present_null_usage_does_not_502( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def payload(request): # noqa: ANN001, ANN201 + return {"contents": [{"role": "user", "parts": [{"text": "hello"}]}]} + + monkeypatch.setattr("headroom.proxy.helpers._read_request_json", payload) + + handler = _Handler() + response = await handler.handle_gemini_generate_content(_FakeRequest(), "gemini-pro") + + # Before the fix this raised TypeError on the None arithmetic and the outer + # handler returned a synthetic 502 with a recorded failure. + assert response.status_code == 200 + assert handler.metrics.failed == [] + assert handler.outcomes[0].status_code == 200 + # The pre-continuation count (100) survives as the fallback. + assert handler.outcomes[0].optimized_tokens == 100 + assert handler.outcomes[0].cache_read_tokens == 0 From a06a51eca63f88271dfa77f2ee6bf3c8da6b24e4 Mon Sep 17 00:00:00 2001 From: Gil Korzen <80160051+GilKorzen@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:04:35 +0300 Subject: [PATCH 08/53] fix(proxy): preserve Codex WebSocket model attribution (#3029) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Codex can switch models during a multi-turn Responses WebSocket conversation. Headroom was not consistently attributing each completed turn to the model that handled it, which made per-model usage and savings reporting inaccurate. Closes #3027 ## 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 - Attribute each completed WebSocket response to its reported model. - Keep session-end metrics consistent with the response that completed. - Add a regression test covering two different models on one WebSocket session. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added new functionality - [x] Manual testing performed ### Test Output ```text uv run pytest -q tests/test_openai_codex_ws_lifecycle.py -k session_metrics_track_model_per_response_create 1 passed, 51 deselected in 2.09s Full Codex WebSocket lifecycle module: 52 passed Adjacent Codex WebSocket suites: 77 passed, 1 skipped uv run ruff check . All checks passed uv run ruff format --check . 1411 files already formatted uv run mypy headroom Success: no issues found in 520 source files ``` ## Real Behavior Proof - Environment: Windows, Python 3.13.3, OpenAI Codex Responses WebSocket. - Exact command / steps: From the repository root, run `uv sync --extra dev --extra proxy`, then run `uv run headroom wrap codex`; in one live Codex conversation complete one turn with model A, switch to model B, complete a second turn, and inspect the proxy dashboard or `http://localhost:8787/stats` recent requests. - Observed result: Both completed turns appeared under the models that handled them, in order. - Not tested: Production deployment and non-Codex transports. ## Runtime Rollout Safety - Rollout-managed feature(s): None. - Minimum rollout channel: Stable/default. - Stable/default behavior changed: Corrects telemetry attribution only; no public API or routing changes. - Kill switch / disable path: Revert the change or use the previous release. - Unsafe override required: No. - Qualification impact: None. - Rollback path: Revert commit `d5d8d7ca`. ## 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 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) ### Pre change In a single session, started with `5.6-sol` and then switched to `5.6-luna`. The dashboard did not reflect the model change. image ### After change Repeated the same steps: started with `5.6-sol` and switched to `5.6-luna`. The dashboard now correctly reflects the model change. image ## Additional Notes --- headroom/proxy/handlers/openai.py | 17 ++++---- tests/test_openai_codex_ws_lifecycle.py | 53 +++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 7 deletions(-) diff --git a/headroom/proxy/handlers/openai.py b/headroom/proxy/handlers/openai.py index 4886593d4..1ced3014b 100644 --- a/headroom/proxy/handlers/openai.py +++ b/headroom/proxy/handlers/openai.py @@ -7194,7 +7194,6 @@ class OpenAIHandlerMixin: if isinstance(first_response_body, dict) else None ) - # Hot-fix follow-up to PR #406 — inline Rust compression on the # WS first frame before forwarding upstream. PR #406 enabled # the same call for HTTP /v1/responses; PR-C5's "WS-side @@ -8034,6 +8033,7 @@ class OpenAIHandlerMixin: response_output_items.clear() response_started_ms: float | None = None + completed_response_model = "unknown" async def _record_ws_response_metrics() -> None: """Record one completed Responses turn on long-lived WS sessions.""" @@ -8090,7 +8090,7 @@ class OpenAIHandlerMixin: ): return - model_for_metrics = str(body.get("model") or "unknown") + model_for_metrics = completed_response_model latency_ms = ( (time.perf_counter() * 1000.0 - response_started_ms) if response_started_ms is not None @@ -8243,6 +8243,13 @@ class OpenAIHandlerMixin: upstream_frame_index, ws_last_upstream_frame_type, ) + response = event.get("response") + completed_response_model = ( + str(response.get("model") or "unknown") + if isinstance(response, dict) + else "unknown" + ) + if event_type == "response.created": response_started_ms = time.perf_counter() * 1000.0 ( @@ -8615,11 +8622,7 @@ class OpenAIHandlerMixin: ) if not isinstance(ws_inner_for_telemetry, dict): ws_inner_for_telemetry = {} - model_name = ( - ws_inner_for_telemetry.get("model") - or (body.get("model") if isinstance(body, dict) else None) - or "unknown" - ) + model_name = str(current_response_template.get("model") or "unknown") _final_auth_mode = classify_auth_mode(ws_headers) residual_input_tokens = max(0, ws_input_tokens_total - ws_recorded_input_tokens_total) residual_output_tokens = max( diff --git a/tests/test_openai_codex_ws_lifecycle.py b/tests/test_openai_codex_ws_lifecycle.py index 259c58838..e24e79200 100644 --- a/tests/test_openai_codex_ws_lifecycle.py +++ b/tests/test_openai_codex_ws_lifecycle.py @@ -2243,3 +2243,56 @@ async def test_ws_memory_continuation_continues_pre_stream_and_passes_late_call( assert second_response[6]["item"] == function_call_two assert second_response[7]["response"]["id"] == "r-2" assert executed == [("memory_search", {}, "user-1", "openai")] + + +@pytest.mark.asyncio +async def test_ws_session_metrics_track_model_per_response_create(): + """A model switch on one WS session must affect the next request outcome.""" + upstream_events = [ + json.dumps({"type": "response.created", "response": {"id": "r_1"}}), + json.dumps( + { + "type": "response.completed", + "response": { + "id": "r_1", + "model": "model-a", + "usage": {"input_tokens": 10, "output_tokens": 1}, + }, + } + ), + json.dumps({"type": "response.created", "response": {"id": "r_2"}}), + json.dumps( + { + "type": "response.completed", + "response": { + "id": "r_2", + "model": "model-b", + "usage": {"input_tokens": 10, "output_tokens": 1}, + }, + } + ), + ] + first_frame = json.dumps( + { + "type": "response.create", + "response": {"model": "model-a", "input": "first turn"}, + } + ) + second_frame = json.dumps( + { + "type": "response.create", + "response": {"model": "model-b", "input": "second turn"}, + } + ) + upstream = _FakeUpstream(upstream_events) + fake_ws_mod = _make_fake_websockets_module(upstream) + client_ws = _FakeWebSocket(frames=[first_frame, second_frame]) + handler = _DummyOpenAIHandler() + + with patch.dict(sys.modules, {"websockets": fake_ws_mod}): + await handler.handle_openai_responses_ws(client_ws) + + assert [request["model"] for request in handler.metrics.recorded_requests] == [ + "model-a", + "model-b", + ] From 536c949a692f4855719d71d612abc4968040286b Mon Sep 17 00:00:00 2001 From: Abhay Singh Date: Mon, 17 Aug 2026 03:34:39 +0530 Subject: [PATCH 09/53] fix(proxy/openai): propagate provider usage on the Responses WS->HTTP fallback (#2988) ## Description When Codex uses the OpenAI Responses WebSocket endpoint through Headroom and the upstream WebSocket is rejected, Headroom falls back to HTTPS POST/SSE. On that fallback the dashboard reported zero or tiny input tokens for a large request, and invalid savings: ```json { "input_tokens_original": 3, "input_tokens_optimized": 0, "output_tokens": 246, "tokens_saved": 31052, "savings_percent": 33233.33 } ``` ## Root cause `_ws_http_fallback` (openai.py) relays the SSE `data:` events to the client but never parses the terminal `response.completed` event for usage. The non-fallback WS path accumulates `_extract_responses_usage(event)` into the session totals on every `response.completed` frame (openai.py ~8182); the fallback path did not. So `ws_input_tokens_total` stayed at the small local count, and the session-end RequestLog computed `optimized_tokens = residual_input_tokens = 0`, leaving `tokens_saved > input_tokens_original` and `savings_percent` far above 100%. ## Fix `_ws_http_fallback` now parses each relayed `response.completed` line with the existing `_extract_responses_usage` and returns the accumulated `(input, output, cache_read, cache_write, uncached)` provider usage. The caller folds it into the WS session totals, so the session-end outcome uses the authoritative provider wire-token count -- bringing the fallback to parity with the non-fallback WS path. SSE relay behaviour is otherwise unchanged. Fixes #2957 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature - [ ] Breaking change - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/proxy/handlers/openai.py` (`_ws_http_fallback`): accumulate usage from `response.completed` SSE lines (both the main relay loop and the buffer flush) and return the `(input, output, cache_read, cache_write, uncached)` tuple from every exit path; the WS handler caller adds it to `ws_input_tokens_total` / `ws_output_tokens_total` / cache / uncached totals before the session-end RequestLog. - `tests/test_ws_http_fallback.py`: the fallback returns the provider usage from a `response.completed` event (input/output/cache_read/uncached), and returns all-zeros when no completed event arrives. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check`) - [x] Type checking passes (`mypy`) - [x] New tests added ### Test Output ```text tests/test_ws_http_fallback.py 13 passed (11 existing + 2 new) # uvx ruff@0.15.22 check -> All checks passed! # uvx mypy@1.20.2 headroom/proxy/handlers/openai.py -> Success: no issues found in 1 source file ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.12.11, project venv, pytest 9.1.1, ruff 0.15.22 and mypy 1.20.2 via uvx. - Exact command / steps: drove `_ws_http_fallback` with the existing WS/stream mocks, feeding an SSE `response.completed` carrying `usage.input_tokens=31055`, `output_tokens=246`, `input_tokens_details.cached_tokens=20000`. The method now returns `(31055, 246, 20000, ..., 11055)`; a stream with no completed event returns all zeros. The existing 11 relay/routing/retry tests are unchanged (they ignore the new return value). - Observed result: the fallback surfaces the provider's real input usage, so the WS session-end outcome records the actual input tokens instead of 0, and savings percentages stay within a meaningful range. - Not tested: a live Codex WS session that triggers the upstream-WS rejection and HTTP fallback end to end (needs a real upstream refusing the WS). The usage-propagation contract is verified at the fallback boundary with the same mocks the existing fallback tests use. ## Runtime Rollout Safety - Rollout-managed feature(s): none. The OpenAI Responses WS-to-HTTP fallback is always-on transport behavior, not rollout-channel-gated. - Minimum rollout channel: N/A (no rollout-managed behavior). - Stable/default behavior changed: yes, as a bug fix. On the WS-to-HTTP fallback the session-end outcome now records the provider's real input/output/cache usage from `response.completed` instead of leaving `ws_input_tokens_total` at 0 (which produced >100% savings). SSE relay to the client is unchanged. - Kill switch / disable path: N/A. This corrects accounting only; there is no behavioral toggle and no user-facing surface beyond the recorded outcome numbers. - Unsafe override required: no. - Qualification impact: fallback-path token accounting now matches the non-fallback WS path and the HTTP Responses path (all three use `_extract_responses_usage`); savings percentages return to a valid range. - Rollback path: revert this PR; the fallback returns to reporting zero input usage on this path. ## 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 - [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 ## Additional Notes The fix reuses the already-present `_extract_responses_usage` (same parser the non-fallback WS path and HTTP Responses path use), so cache-read/write and uncached accounting stay consistent across all three transports. Co-authored-by: JD Davis --- headroom/proxy/handlers/openai.py | 44 +++++++++++++++++++++++++---- tests/test_ws_http_fallback.py | 47 +++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/headroom/proxy/handlers/openai.py b/headroom/proxy/handlers/openai.py index 1ced3014b..a73ac0ff7 100644 --- a/headroom/proxy/handlers/openai.py +++ b/headroom/proxy/handlers/openai.py @@ -8602,9 +8602,23 @@ class OpenAIHandlerMixin: f"[{request_id}] WS upstream failed ({_ws_detail}), " f"falling back to HTTP POST streaming" ) - await self._ws_http_fallback( + ( + fb_input_tokens, + fb_output_tokens, + fb_cache_read_tokens, + fb_cache_write_tokens, + fb_uncached_tokens, + ) = await self._ws_http_fallback( websocket, body, first_msg_raw, upstream_headers, request_id ) + # Fold the fallback's provider usage into the session totals so + # the WS session-end outcome records the authoritative wire-token + # count instead of 0 (#2957). + ws_input_tokens_total += fb_input_tokens + ws_output_tokens_total += fb_output_tokens + ws_cache_read_tokens_total += fb_cache_read_tokens + ws_cache_write_tokens_total += fb_cache_write_tokens + ws_uncached_input_tokens_total += fb_uncached_tokens # ── WS session-end metric + RequestLog ────────────────── # @@ -8850,14 +8864,31 @@ class OpenAIHandlerMixin: first_msg_raw: str, upstream_headers: dict[str, str], request_id: str, - ) -> None: + ) -> tuple[int, int, int, int, int]: """Fall back to HTTP POST streaming when upstream WS fails. Converts the WS ``response.create`` message to an HTTP POST to ``/v1/responses?stream=true``, reads SSE events, and relays each ``data:`` line as a WS text message to the client. This makes Codex work immediately instead of exhausting its WS retry budget. + + Returns ``(input, output, cache_read, cache_write, uncached)`` provider + usage parsed from the ``response.completed`` SSE event. The caller folds + it into the session totals so the WS session-end outcome uses the + authoritative wire-token count; otherwise a fallback recorded + ``input_tokens=0`` and savings percentages blew past 100 (#2957). """ + fallback_usage = [0, 0, 0, 0, 0] + + def _accumulate_usage(data_str: str) -> None: + try: + event = json.loads(data_str) + except (json.JSONDecodeError, TypeError): + return + if isinstance(event, dict) and event.get("type") == "response.completed": + for i, value in enumerate(_extract_responses_usage(event)): + fallback_usage[i] += value + # Route to correct endpoint based on auth mode is_chatgpt_fallback = has_chatgpt_account_header(upstream_headers) if is_chatgpt_fallback: @@ -8963,7 +8994,7 @@ class OpenAIHandlerMixin: }, } await websocket.send_text(json.dumps(error_event)) - return + return tuple(fallback_usage) # type: ignore[return-value] # Refresh Codex /stats from the fallback response # headers. We can't forward them onto the client 101 @@ -8989,10 +9020,11 @@ class OpenAIHandlerMixin: data = line[6:] if data == "[DONE]": continue + _accumulate_usage(data) try: await websocket.send_text(data) except Exception: - return + return tuple(fallback_usage) # type: ignore[return-value] elif line.startswith("event: "): # SSE event type — skip, the data line contains the type continue @@ -9001,9 +9033,10 @@ class OpenAIHandlerMixin: for line in buffer.strip().splitlines(): line = line.strip() if line.startswith("data: ") and line[6:] != "[DONE]": + _accumulate_usage(line[6:]) with contextlib.suppress(Exception): await websocket.send_text(line[6:]) - return + return tuple(fallback_usage) # type: ignore[return-value] except (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout) as http_err: if http_attempt >= retry_attempts - 1: raise @@ -9034,6 +9067,7 @@ class OpenAIHandlerMixin: finally: with contextlib.suppress(Exception): await websocket.close() + return tuple(fallback_usage) # type: ignore[return-value] def _derived_compress_pipeline(self, key: str, **overrides: Any) -> Any: """Cached ``/v1/compress`` pipeline derived from the live OpenAI router. diff --git a/tests/test_ws_http_fallback.py b/tests/test_ws_http_fallback.py index 459246fb3..d0205c877 100644 --- a/tests/test_ws_http_fallback.py +++ b/tests/test_ws_http_fallback.py @@ -310,6 +310,53 @@ class TestWsHttpFallback: assert "api.openai.com" in captured_url["url"] + def test_fallback_returns_provider_usage_from_completed_event(self): + """The fallback must surface the provider's input usage (#2957). + + Otherwise the WS session-end outcome records input_tokens=0 for a large + request and savings percentages blow past 100. + """ + handler = _make_handler() + ws = FakeWebSocket() + completed = { + "type": "response.completed", + "response": { + "usage": { + "input_tokens": 31055, + "output_tokens": 246, + "input_tokens_details": {"cached_tokens": 20000}, + } + }, + } + sse_lines = [ + 'data: {"type":"response.created","response":{"id":"r1"}}\n\n', + f"data: {json.dumps(completed)}\n\n", + "data: [DONE]\n\n", + ] + handler.http_client = FakeHttpClient(FakeStreamResponse(200, sse_lines)) + + body = {"model": "gpt-5.4", "input": "big context"} + usage = asyncio.run(handler._ws_http_fallback(ws, body, json.dumps(body), {}, "req_usage")) + + input_tokens, output_tokens, cache_read, _cache_write, uncached = usage + assert input_tokens == 31055 + assert output_tokens == 246 + assert cache_read == 20000 + assert uncached == 31055 - 20000 + + def test_fallback_returns_zero_usage_without_completed_event(self): + handler = _make_handler() + ws = FakeWebSocket() + handler.http_client = FakeHttpClient( + FakeStreamResponse(200, ['data: {"type":"response.created"}\n\n', "data: [DONE]\n\n"]) + ) + usage = asyncio.run( + handler._ws_http_fallback( + ws, {"model": "gpt-5.4", "input": "hi"}, json.dumps({"input": "hi"}), {}, "req_none" + ) + ) + assert usage == (0, 0, 0, 0, 0) + def test_fallback_refreshes_codex_rate_limit_state(self, monkeypatch): """A successful fallback refreshes Codex /stats from response headers. From be5b26d807be81d83594c9144a8520f6f0f1b273 Mon Sep 17 00:00:00 2001 From: Abhay Singh Date: Mon, 17 Aug 2026 03:34:44 +0530 Subject: [PATCH 10/53] fix(doctor): surface that Claude Desktop agent sessions bypass the proxy (#2987) ## Description `headroom doctor` reports the `claude` check as a pass whenever `~/.claude/settings.json` carries an `ANTHROPIC_BASE_URL` pointing at the proxy. That is correct for the terminal Claude Code CLI. But Claude Desktop (`com.anthropic.claudefordesktop`) unconditionally overwrites that variable when spawning agent sessions (#869), so on a Desktop-primary machine `doctor` asserts routing that is in fact discarded, and nothing in the output hints that Desktop sessions are unrouted (#2925). ## Fix Add a per-surface `claude desktop` check that warns about the bypass when Claude Desktop's config directory is detected, pointing at #869. Following the issue's suggestion, it models per-surface reporting like the existing `wrap_marker` / `shell env` rows: it is a separate row emitted only when Desktop is present, so it never contradicts a genuinely routed CLI, and the existing `claude` check is left unchanged. Detection uses Claude Desktop's per-user config directory (distinct from the CLI's `~/.claude`): - macOS: `~/Library/Application Support/Claude` - Windows: `%APPDATA%\Claude` - Linux: `$XDG_CONFIG_HOME/Claude` (or `~/.config/Claude`) Fixes #2925 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature - [ ] Breaking change - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/cli/doctor.py`: add `claude_desktop_config_dir()` (cross-platform) and `check_claude_desktop()` (WARN when the dir exists, `None` otherwise); append it to the `doctor()` check list when present. - `tests/test_cli_doctor.py`: `TestClaudeDesktop` -- no row when absent; WARN naming the bypass and #869 when present; the `doctor --json` entrypoint appends the row only when Desktop is detected. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check`) - [x] Type checking passes (`mypy`) - [x] New tests added ### Test Output ```text tests/test_cli_doctor.py 78 passed # uvx ruff@0.15.22 check -> All checks passed! # uvx mypy@1.20.2 headroom/cli/doctor.py -> Success: no issues found in 1 source file ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.12.11, project venv, pytest 9.1.1, ruff 0.15.22 and mypy 1.20.2 via uvx. - Exact command / steps: `uvx ruff@0.15.22 check headroom/cli/doctor.py tests/test_cli_doctor.py`; `uvx mypy@1.20.2 headroom/cli/doctor.py`; `python -m pytest tests/test_cli_doctor.py -q`; then drove the check directly and through the `doctor --json` entrypoint with `claude_desktop_config_dir` pointed at a tmp dir (created the dir, ran `doctor --json`, then removed it and reran). - Observed result: with the dir present, a `claude desktop` row appears with status `warn` and a `#869` hint; with the dir absent, no such row is emitted and the rest of the report is unchanged. A Desktop-primary machine now gets an explicit warning that Desktop agent sessions bypass the proxy, instead of a bare `claude: pass` that reads as though all Claude routing is live. - Not tested: a live Claude Desktop install (detection is directory-existence, exercised against a tmp dir). ## Runtime Rollout Safety - Rollout-managed feature(s): none. This adds a read-only diagnostic row to `headroom doctor`; it is not behind any rollout channel or feature flag. - Minimum rollout channel: N/A (no rollout-managed behavior). - Stable/default behavior changed: no. The existing `claude` check and all other rows are unchanged; the new `claude desktop` row is additive and only appears when Claude Desktop's config directory is detected. - Kill switch / disable path: N/A. The row self-suppresses (returns `None`) on any machine without the Desktop config directory. - Unsafe override required: no. - Qualification impact: none. No proxy request path, routing, or token accounting is touched; the change is confined to the doctor diagnostic surface. - Rollback path: revert this PR; the doctor output returns to its prior set of rows with no state or migration to undo. ## 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 - [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 ## Additional Notes Scope: this warns whenever Claude Desktop is present, which is accurate (Desktop agent sessions always bypass per #869) and matches the precedent for doctor-accuracy fixes (#2618/#2614 Codex, #2566 ollama). The issue's stronger refinement -- suppress the warning when a `client=claude-code` request has recently reached the proxy -- would need per-client traffic observation the doctor does not have today; I left that as a follow-up rather than build new traffic-tracking infra into this fix. Happy to add it if you'd prefer the conditional form. Rebased onto current `main` to resolve an overlap with the newly merged `check_claude_auth_conflict` in `doctor.py`; both checks now coexist. Co-authored-by: JD Davis --- headroom/cli/doctor.py | 49 ++++++++++++++++++++++++++++++++++++++++ tests/test_cli_doctor.py | 38 +++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/headroom/cli/doctor.py b/headroom/cli/doctor.py index a45a0cbf1..cd5d4839a 100644 --- a/headroom/cli/doctor.py +++ b/headroom/cli/doctor.py @@ -14,6 +14,7 @@ from __future__ import annotations import json import os import re +import sys from collections.abc import Callable, Mapping from dataclasses import asdict, dataclass from datetime import datetime @@ -224,6 +225,51 @@ def check_claude_auth_conflict( ) +def claude_desktop_config_dir() -> Path: + """Return Claude Desktop's per-user config directory for this platform. + + Claude Desktop (``com.anthropic.claudefordesktop``) stores its config here, + distinct from Claude Code CLI's ``~/.claude``. Directory existence is used as + a proxy for "Desktop is installed / has been run" (#2925). + """ + home = Path.home() + if sys.platform == "darwin": + return home / "Library" / "Application Support" / "Claude" + if os.name == "nt": + appdata = os.environ.get("APPDATA") + base = Path(appdata) if appdata else home / "AppData" / "Roaming" + return base / "Claude" + xdg = os.environ.get("XDG_CONFIG_HOME") + base = Path(xdg) if xdg else home / ".config" + return base / "Claude" + + +def check_claude_desktop(config_dir: Path) -> CheckResult | None: + """Surface that Claude Desktop agent sessions bypass the proxy (#2925 / #869). + + Claude Desktop unconditionally overwrites ``ANTHROPIC_BASE_URL`` when it + spawns agent sessions, so a correctly-wrapped ``~/.claude/settings.json`` + (which the ``claude`` check verifies for the terminal CLI) does not route + Desktop traffic. Without this, ``doctor`` passes on the settings value alone + and never hints that Desktop sessions are unrouted. + + Reported as its own per-surface row -- like ``wrap_marker`` and ``shell env`` + -- and only when Desktop is detected, so it never contradicts a genuinely + routed CLI. Returns ``None`` when Desktop is absent (no row). + """ + if not config_dir.exists(): + return None + return CheckResult( + name="claude desktop", + status=WARN, + summary="agent sessions bypass the proxy (Desktop overwrites ANTHROPIC_BASE_URL)", + hint=( + "Desktop routing is not supported yet (see #869); use the terminal " + "Claude Code CLI for proxy-routed sessions." + ), + ) + + def check_claude_remote_control_gate( settings_path: Path, environ: Mapping[str, str], @@ -630,6 +676,9 @@ def doctor(port: int, emit_json: bool) -> None: ) if remote_control_gate_check is not None: checks.append(remote_control_gate_check) + desktop_check = check_claude_desktop(claude_desktop_config_dir()) + if desktop_check is not None: + checks.append(desktop_check) deployments = check_deployments(list_manifests()) if deployments is not None: checks.append(deployments) diff --git a/tests/test_cli_doctor.py b/tests/test_cli_doctor.py index 6fe42581d..8695af976 100644 --- a/tests/test_cli_doctor.py +++ b/tests/test_cli_doctor.py @@ -15,6 +15,7 @@ from headroom.cli.doctor import ( SKIP, WARN, check_budget, + check_claude_desktop, check_claude_remote_control_gate, check_claude_routing, check_codex_routing, @@ -152,6 +153,43 @@ class TestClaudeRouting: assert "gateway.corp.example" in result.summary +class TestClaudeDesktop: + def test_no_desktop_dir_produces_no_row(self, tmp_path): + # #2925: absent Desktop -> no row, so it never contradicts a routed CLI. + assert check_claude_desktop(tmp_path / "Claude") is None + + def test_desktop_present_warns_about_bypass(self, tmp_path): + desktop = tmp_path / "Claude" + desktop.mkdir() + result = check_claude_desktop(desktop) + assert result is not None + assert result.name == "claude desktop" + assert result.status == WARN + assert "bypass" in result.summary + assert "#869" in (result.hint or "") + + def test_doctor_appends_desktop_row_when_present(self, tmp_path, monkeypatch): + # Integration: the entrypoint surfaces the Desktop row when detected. + desktop = tmp_path / "Claude" + desktop.mkdir() + monkeypatch.setattr(doctor_mod, "claude_desktop_config_dir", lambda: desktop) + monkeypatch.setattr(doctor_mod, "probe_json", lambda *a, **k: None) + monkeypatch.setattr(doctor_mod, "list_manifests", lambda: []) + result = CliRunner().invoke(main, ["doctor", "--json"]) + payload = json.loads(result.output) + rows = {c["name"]: c for c in payload["checks"]} + assert "claude desktop" in rows + assert rows["claude desktop"]["status"] == WARN + + def test_doctor_omits_desktop_row_when_absent(self, tmp_path, monkeypatch): + monkeypatch.setattr(doctor_mod, "claude_desktop_config_dir", lambda: tmp_path / "Claude") + monkeypatch.setattr(doctor_mod, "probe_json", lambda *a, **k: None) + monkeypatch.setattr(doctor_mod, "list_manifests", lambda: []) + result = CliRunner().invoke(main, ["doctor", "--json"]) + payload = json.loads(result.output) + assert "claude desktop" not in {c["name"] for c in payload["checks"]} + + class TestClaudeRemoteControlGate: def test_settings_custom_base_warns(self, tmp_path): path = tmp_path / "settings.json" From 6d87825f62e47bc65eeae05fbb8a131d545fe5a2 Mon Sep 17 00:00:00 2001 From: Abhay Singh Date: Mon, 17 Aug 2026 03:34:54 +0530 Subject: [PATCH 11/53] fix(proxy): tune macOS libmalloc and trim allocator pages so long-lived RSS stays bounded (#2879) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes #2820. Prevents long-lived macOS proxies from retaining every largest transient request-body allocation in libmalloc. The reporter’s production A/B isolated the allocator behavior and verified the two pre-main libmalloc knobs; this PR applies them through a one-time Darwin-only re-exec and adds periodic per-worker pressure relief. - `MallocAggressiveMadvise=1` returns freed pages eagerly. - `MallocLargeCache=0` disables the large-allocation death-row cache. - Operator-set allocator variables are preserved; `HEADROOM_MALLOC_TUNING=0` is the kill switch. - Periodic trim defaults on only for macOS, runs off the event loop, performs no forced Python GC, validates its interval, and is retained/cancelled through the app lifecycle. - Non-Darwin behavior remains unchanged unless explicitly enabled. - Semantically rebased onto current `main`, retaining startup dependency validation, MCP SDK v1 compatibility, and all newer proxy behavior. ## Verification - 147 proxy CLI/config/malloc/MCP-contract tests pass; 1 platform skip. - Ruff check and formatting clean; `git diff --check` clean. - The reporter’s macOS A/B reduced dirty empty malloc regions to zero and lowered steady/startup RSS; the control flow and shutdown lifecycle are covered locally. ## Safety The re-exec is Darwin-only, PID-preserving, loop-guarded, and opt-out. The trim task is per worker because allocator state is per process, and shutdown cancels it explicitly. --- headroom/cli/proxy.py | 34 ++++ headroom/proxy/malloc_trim.py | 135 ++++++++++++++++ headroom/proxy/models.py | 12 ++ headroom/proxy/server.py | 22 +++ tests/test_malloc_tuning.py | 288 ++++++++++++++++++++++++++++++++++ 5 files changed, 491 insertions(+) create mode 100644 headroom/proxy/malloc_trim.py create mode 100644 tests/test_malloc_tuning.py diff --git a/headroom/cli/proxy.py b/headroom/cli/proxy.py index 73e2aee8a..9e9309dd8 100644 --- a/headroom/cli/proxy.py +++ b/headroom/cli/proxy.py @@ -113,6 +113,35 @@ def _get_env_bool_optional(name: str) -> bool | None: return _get_env_bool(name, False) +# libmalloc reads these before main() runs, so they cannot be set from inside +# the current process — the proxy re-execs itself once to apply them. Without +# them, freed pages from large concurrent request bodies stay resident +# (``vmmap`` shows whole "MALLOC_LARGE (empty)" regions) and long-lived proxy +# RSS only ratchets upward (#2820). Vars the operator already set are left +# untouched; HEADROOM_MALLOC_TUNING=0 disables the re-exec entirely. +_MALLOC_TUNING = { + "MallocAggressiveMadvise": "1", # madvise freed pages back to the OS eagerly + "MallocLargeCache": "0", # no death-row cache for freed large allocations +} + + +def _reexec_with_malloc_tuning() -> None: + if sys.platform != "darwin": + return + if not _get_env_bool("HEADROOM_MALLOC_TUNING", True): + return + if os.environ.get("_HEADROOM_MALLOC_TUNED") == "1": + return + missing = {k: v for k, v in _MALLOC_TUNING.items() if k not in os.environ} + # Set the loop guard before the re-exec so the replacement process (which + # inherits this environment) skips this path instead of re-execing forever. + os.environ["_HEADROOM_MALLOC_TUNED"] = "1" + if not missing: + return + os.environ.update(missing) + os.execv(sys.executable, [sys.executable, "-m", "headroom.cli", *sys.argv[1:]]) + + def _get_env_int_optional(name: str) -> int | None: val = os.environ.get(name) if val is None or val == "": @@ -1065,6 +1094,7 @@ def proxy( Usage with OpenAI-compatible clients: OPENAI_BASE_URL=http://localhost:8787/v1 your-app """ + _reexec_with_malloc_tuning() ensure_proxy_dependencies() # Import here to avoid slow startup @@ -1261,6 +1291,10 @@ 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), + periodic_malloc_trim_enabled=_get_env_bool( + "HEADROOM_MALLOC_TRIM", sys.platform == "darwin" + ), + malloc_trim_interval_seconds=_get_env_int("HEADROOM_MALLOC_TRIM_INTERVAL_SECONDS", 60), 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, diff --git a/headroom/proxy/malloc_trim.py b/headroom/proxy/malloc_trim.py new file mode 100644 index 000000000..d7cd06eff --- /dev/null +++ b/headroom/proxy/malloc_trim.py @@ -0,0 +1,135 @@ +"""Return freed-but-retained allocator pages to the OS on long-lived proxies. + +Large concurrent Anthropic bodies (0.5-1 MB of JSON parsed, deep-copied and +re-serialized per in-flight request) drive libmalloc and pymalloc to a +high-water mark that is never returned to the OS: after a burst the malloc +zones keep entire regions resident but empty (``vmmap`` lists them as +``MALLOC_LARGE (empty)`` / ``MALLOC_SMALL (empty)``), so process RSS only +ratchets upward. Over a multi-day proxy lifetime under Claude Code traffic +this reaches double-digit GB and starves the host. + +Neither runtime returns these pages on its own. macOS exposes +``malloc_zone_pressure_relief(NULL, 0)`` to purge every zone's free pages; +glibc has ``malloc_trim(0)``. ``trim()`` calls that entry point directly: it is +a C call that releases the GIL and reclaims whatever is already on the +allocator's free lists. It deliberately does not run a Python ``gc.collect()`` +-- a full cyclic collection holds the GIL, and this periodic task runs off the +event-loop thread precisely so it cannot stall request handling; freeing cyclic +garbage is left to CPython's own automatic collection. +""" + +from __future__ import annotations + +import asyncio +import ctypes +import logging +import sys +import time + +logger = logging.getLogger(__name__) + +# Interval bounds for the periodic trim task. A non-positive interval would make +# ``asyncio.sleep`` return immediately and spin a continuous collect/trim loop, +# so anything below the minimum falls back to the default. +_DEFAULT_TRIM_INTERVAL_SECONDS = 60 +_MIN_TRIM_INTERVAL_SECONDS = 1 + +# Lazily resolved (platform_tag, foreign_function | None). ``None`` function +# means the platform has no supported trim call and trim() is a no-op. +_relief: tuple[str, object | None] | None = None + + +def _resolve() -> tuple[str, object | None]: + global _relief + if _relief is not None: + return _relief + try: + libc = ctypes.CDLL(None) + if sys.platform == "darwin": + fn = libc.malloc_zone_pressure_relief + fn.argtypes = [ctypes.c_void_p, ctypes.c_size_t] + fn.restype = ctypes.c_size_t + _relief = ("darwin", fn) + else: + fn = libc.malloc_trim + fn.argtypes = [ctypes.c_size_t] + fn.restype = ctypes.c_int + _relief = ("glibc", fn) + except (OSError, AttributeError): + _relief = ("unsupported", None) + return _relief + + +def trim() -> int: + """Return allocator free pages to the OS. + + Calls the platform's allocator pressure-relief entry point + (``malloc_zone_pressure_relief`` on macOS, ``malloc_trim`` on glibc). This + is a C call that releases the GIL for its duration and reclaims pages + already on the allocator's free lists. It deliberately does *not* run a + Python ``gc.collect()`` (a full cyclic collection holds the GIL); cyclic + garbage is left to CPython's automatic collection, so this off-thread + periodic task never holds the GIL for a full-heap traversal. + + Returns the number of bytes freed on macOS (glibc's ``malloc_trim`` + reports only success, so 0 is returned there and on unsupported + platforms). + """ + kind, fn = _resolve() + if fn is None: + return 0 + if kind == "darwin": + return int(fn(None, 0)) # type: ignore[operator] + fn(0) # type: ignore[operator] + return 0 + + +async def trim_periodically(interval_seconds: int = 60) -> None: + """Background task that periodically returns allocator free pages to the OS. + + Runs in every worker process (allocator state is per-process). The trim is + the platform's allocator pressure-relief C call + (``malloc_zone_pressure_relief``/``malloc_trim``), dispatched via + ``asyncio.to_thread`` so it runs off the event-loop thread. Because it is a + C call that releases the GIL and runs no Python ``gc.collect()``, it holds + the GIL only as briefly as the to_thread hand-off, so a slow purge on a + large heap does not stall request handling. The task exits immediately on + platforms with no supported trim call, so it is a true no-op there. + + Args: + interval_seconds: How often to trim (default: 60 seconds). A value below + ``_MIN_TRIM_INTERVAL_SECONDS`` (which would busy-loop) falls back to + the default. + """ + _, fn = _resolve() + if fn is None: + # No supported allocator-trim call on this platform (Windows, musl, ...); + # do not spin a wakeup task that can only ever no-op. + logger.debug("MallocTrim: no supported trim on %s; task disabled", sys.platform) + return + + if interval_seconds < _MIN_TRIM_INTERVAL_SECONDS: + logger.warning( + "MallocTrim: interval %ss is below the %ds minimum; using default %ds", + interval_seconds, + _MIN_TRIM_INTERVAL_SECONDS, + _DEFAULT_TRIM_INTERVAL_SECONDS, + ) + interval_seconds = _DEFAULT_TRIM_INTERVAL_SECONDS + + while True: + await asyncio.sleep(interval_seconds) + try: + start = time.perf_counter() + # Off the event-loop thread: the C-level purge can pause for a while + # on a large heap, and that pause must not stall proxy traffic. + freed = await asyncio.to_thread(trim) + elapsed_ms = (time.perf_counter() - start) * 1000 + log = logger.info if freed >= (16 << 20) else logger.debug + log( + "MallocTrim: returned %.1f MB to OS in %.0f ms", + freed / 1048576, + elapsed_ms, + ) + except Exception as e: + logger.debug("MallocTrim failed: %s", e) diff --git a/headroom/proxy/models.py b/headroom/proxy/models.py index 92c6ef366..f3331f46e 100644 --- a/headroom/proxy/models.py +++ b/headroom/proxy/models.py @@ -7,6 +7,7 @@ Extracted from server.py to keep the codebase maintainable. from __future__ import annotations import logging +import sys from dataclasses import InitVar, dataclass, field from datetime import datetime from typing import Any, Literal @@ -439,6 +440,17 @@ class ProxyConfig: # Env: HEADROOM_PERIODIC_TOIN_STATS=0. periodic_toin_stats_enabled: bool = True + # Periodic allocator trim. Long-lived proxies processing large concurrent + # request bodies ratchet RSS through freed-but-retained allocator pages; + # this returns them to the OS (malloc_zone_pressure_relief on macOS, + # malloc_trim on glibc). Default-on only on macOS, where the retained-page + # ratchet is the documented failure (#2820); an opt-in elsewhere via + # HEADROOM_MALLOC_TRIM=1 so glibc deployments do not silently take on a + # once-a-minute allocator purge they did not ask for. Envs: + # HEADROOM_MALLOC_TRIM=0/1, HEADROOM_MALLOC_TRIM_INTERVAL_SECONDS. + periodic_malloc_trim_enabled: bool = field(default_factory=lambda: sys.platform == "darwin") + malloc_trim_interval_seconds: int = 60 + # Stateless mode — disable all filesystem writes for read-only / container deployments stateless: bool = False diff --git a/headroom/proxy/server.py b/headroom/proxy/server.py index 5a15f5f4a..c3a137b5d 100644 --- a/headroom/proxy/server.py +++ b/headroom/proxy/server.py @@ -144,6 +144,7 @@ from headroom.proxy.helpers import ( ) from headroom.proxy.loop_callback_failure_policy import is_known_websocket_callback_failure from headroom.proxy.loopback_guard import is_loopback_host +from headroom.proxy.malloc_trim import trim_periodically from headroom.proxy.memory_handler import MemoryConfig, MemoryHandler # Data models (extracted to headroom/proxy/models.py for maintainability) @@ -2601,6 +2602,7 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI: app.state.ready = False app.state.startup_error = None app.state.periodic_toin_stats_task = None + app.state.periodic_malloc_trim_task = None try: try: @@ -2611,6 +2613,12 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI: app.state.periodic_toin_stats_task = asyncio.create_task( _log_toin_stats_periodically() ) + # Per-worker on purpose: allocator state is per-process, so + # every worker must trim its own zones (no beacon-owner gate). + if config.periodic_malloc_trim_enabled: + app.state.periodic_malloc_trim_task = asyncio.create_task( + trim_periodically(config.malloc_trim_interval_seconds) + ) if proxy.usage_reporter: await proxy.usage_reporter.start(proxy) if proxy.traffic_learner: @@ -2670,6 +2678,16 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI: ) app.state.periodic_toin_stats_task = None + periodic_malloc_trim_task = app.state.periodic_malloc_trim_task + if periodic_malloc_trim_task is not None: + periodic_malloc_trim_task.cancel() + await _timed( + asyncio.gather(periodic_malloc_trim_task, return_exceptions=True), + label="periodic_malloc_trim.stop", + timeout=3.0, + ) + app.state.periodic_malloc_trim_task = None + if _cc_reconciler is not None: await _timed(_cc_reconciler.stop(), label="cc_reconciler.stop", timeout=3.0) if _beacon_is_owner[0]: @@ -5160,6 +5178,10 @@ def _proxy_config_from_env() -> ProxyConfig: http2=_get_env_bool("HEADROOM_HTTP2", True), http_proxy=os.environ.get("HEADROOM_HTTP_PROXY") or None, periodic_toin_stats_enabled=_get_env_bool("HEADROOM_PERIODIC_TOIN_STATS", True), + periodic_malloc_trim_enabled=_get_env_bool( + "HEADROOM_MALLOC_TRIM", sys.platform == "darwin" + ), + malloc_trim_interval_seconds=_get_env_int("HEADROOM_MALLOC_TRIM_INTERVAL_SECONDS", 60), proxy_token=os.environ.get("HEADROOM_PROXY_TOKEN") or None, offline=_get_env_bool("HEADROOM_OFFLINE", False), # Default mode is CACHE (Headroom's coding posture): delta-only compression diff --git a/tests/test_malloc_tuning.py b/tests/test_malloc_tuning.py new file mode 100644 index 000000000..6b490242b --- /dev/null +++ b/tests/test_malloc_tuning.py @@ -0,0 +1,288 @@ +"""macOS libmalloc tuning: pre-main re-exec gating + periodic allocator trim (#2820).""" + +from __future__ import annotations + +import asyncio + +import pytest + +import headroom.cli.proxy as proxy_cli +from headroom.proxy import malloc_trim + + +class _ExecCalled(Exception): + """Sentinel so a fake execv can stop execution the way real execv would.""" + + +def _fake_execv(recorder: dict): + def _execv(path, argv): # noqa: ANN001 + recorder["path"] = path + recorder["argv"] = list(argv) + raise _ExecCalled + + return _execv + + +@pytest.fixture(autouse=True) +def _clean_malloc_env(monkeypatch): + for var in ( + "HEADROOM_MALLOC_TUNING", + "_HEADROOM_MALLOC_TUNED", + "MallocAggressiveMadvise", + "MallocLargeCache", + ): + monkeypatch.delenv(var, raising=False) + + +# --------------------------------------------------------------------------- # +# _reexec_with_malloc_tuning +# --------------------------------------------------------------------------- # +def test_reexec_noop_off_darwin(monkeypatch): + monkeypatch.setattr(proxy_cli.sys, "platform", "linux") + rec: dict = {} + monkeypatch.setattr(proxy_cli.os, "execv", _fake_execv(rec)) + proxy_cli._reexec_with_malloc_tuning() # must not raise / exec + assert rec == {} + + +def test_reexec_respects_opt_out(monkeypatch): + monkeypatch.setattr(proxy_cli.sys, "platform", "darwin") + monkeypatch.setenv("HEADROOM_MALLOC_TUNING", "0") + rec: dict = {} + monkeypatch.setattr(proxy_cli.os, "execv", _fake_execv(rec)) + proxy_cli._reexec_with_malloc_tuning() + assert rec == {} + + +def test_reexec_guard_prevents_loop(monkeypatch): + monkeypatch.setattr(proxy_cli.sys, "platform", "darwin") + monkeypatch.setenv("_HEADROOM_MALLOC_TUNED", "1") + rec: dict = {} + monkeypatch.setattr(proxy_cli.os, "execv", _fake_execv(rec)) + proxy_cli._reexec_with_malloc_tuning() + assert rec == {} + + +def test_reexec_skips_when_operator_already_set_vars(monkeypatch): + monkeypatch.setattr(proxy_cli.sys, "platform", "darwin") + monkeypatch.setenv("MallocAggressiveMadvise", "1") + monkeypatch.setenv("MallocLargeCache", "0") + rec: dict = {} + monkeypatch.setattr(proxy_cli.os, "execv", _fake_execv(rec)) + proxy_cli._reexec_with_malloc_tuning() + # No re-exec (vars present), but the guard is still stamped. + assert rec == {} + assert proxy_cli.os.environ.get("_HEADROOM_MALLOC_TUNED") == "1" + + +def test_reexec_sets_vars_and_execs_once(monkeypatch): + monkeypatch.setattr(proxy_cli.sys, "platform", "darwin") + monkeypatch.setattr(proxy_cli.sys, "executable", "/usr/bin/python3") + monkeypatch.setattr(proxy_cli.sys, "argv", ["headroom", "proxy", "--port", "8787"]) + rec: dict = {} + monkeypatch.setattr(proxy_cli.os, "execv", _fake_execv(rec)) + + with pytest.raises(_ExecCalled): + proxy_cli._reexec_with_malloc_tuning() + + # The tuning knobs and the loop guard are exported to the replacement process. + assert proxy_cli.os.environ["MallocAggressiveMadvise"] == "1" + assert proxy_cli.os.environ["MallocLargeCache"] == "0" + assert proxy_cli.os.environ["_HEADROOM_MALLOC_TUNED"] == "1" + # Re-exec normalizes to `python -m headroom.cli `, preserving the PID. + assert rec["path"] == "/usr/bin/python3" + assert rec["argv"] == ["/usr/bin/python3", "-m", "headroom.cli", "proxy", "--port", "8787"] + + +# --------------------------------------------------------------------------- # +# malloc_trim.trim / trim_periodically +# --------------------------------------------------------------------------- # +def test_trim_calls_platform_fn(monkeypatch): + def fake_fn(ptr, size): # noqa: ANN001 (mac signature) + return 4096 + + monkeypatch.setattr(malloc_trim, "_resolve", lambda: ("darwin", fake_fn)) + assert malloc_trim.trim() == 4096 + + +def test_trim_never_runs_python_gc(monkeypatch): + # The periodic trim must NOT trigger a full cyclic collection: gc.collect() + # holds the GIL for a whole-heap traversal, which would stall the event loop + # even though the C purge itself is dispatched off-thread. Only the + # GIL-releasing allocator C call may run. + import gc + + ran: list[str] = [] + monkeypatch.setattr(gc, "collect", lambda *a, **k: ran.append("gc") or 0) + monkeypatch.setattr(malloc_trim, "_resolve", lambda: ("glibc", lambda _size: 0)) + malloc_trim.trim() + assert ran == [] + + +def test_trim_is_noop_on_unsupported_platform(monkeypatch): + monkeypatch.setattr(malloc_trim, "_resolve", lambda: ("unsupported", None)) + assert malloc_trim.trim() == 0 + + +def test_trim_periodically_trims_each_interval(monkeypatch): + monkeypatch.setattr(malloc_trim, "_resolve", lambda: ("glibc", object())) + trims: list[int] = [] + monkeypatch.setattr(malloc_trim, "trim", lambda: trims.append(1) or 0) + + async def fake_sleep(_seconds): + if len(trims) >= 2: # let two ticks run, then break the loop + raise asyncio.CancelledError + + monkeypatch.setattr(malloc_trim.asyncio, "sleep", fake_sleep) + + with pytest.raises(asyncio.CancelledError): + asyncio.run(malloc_trim.trim_periodically(interval_seconds=1)) + assert len(trims) == 2 + + +def test_trim_periodically_is_disabled_on_unsupported_platform(monkeypatch): + # No supported trim call: the task must return at once, never scheduling a + # wakeup (so it is a true no-op on Windows/musl, not a 60s spinner). + monkeypatch.setattr(malloc_trim, "_resolve", lambda: ("unsupported", None)) + trims: list[int] = [] + monkeypatch.setattr(malloc_trim, "trim", lambda: trims.append(1) or 0) + + async def _no_sleep(_seconds): + raise AssertionError("unsupported platform must not schedule a trim wakeup") + + monkeypatch.setattr(malloc_trim.asyncio, "sleep", _no_sleep) + asyncio.run(malloc_trim.trim_periodically(interval_seconds=60)) # returns, no raise + assert trims == [] + + +@pytest.mark.parametrize("bad_interval", [0, -5]) +def test_trim_periodically_rejects_non_positive_interval(monkeypatch, bad_interval): + # A non-positive interval would make asyncio.sleep return immediately and + # spin a continuous collect/trim loop; it must fall back to the default. + monkeypatch.setattr(malloc_trim, "_resolve", lambda: ("glibc", object())) + monkeypatch.setattr(malloc_trim, "trim", lambda: 0) + slept: list[float] = [] + + async def capture_sleep(seconds): + slept.append(seconds) + raise asyncio.CancelledError # stop after the first sleep + + monkeypatch.setattr(malloc_trim.asyncio, "sleep", capture_sleep) + with pytest.raises(asyncio.CancelledError): + asyncio.run(malloc_trim.trim_periodically(interval_seconds=bad_interval)) + assert slept == [malloc_trim._DEFAULT_TRIM_INTERVAL_SECONDS] + + +def test_trim_runs_off_the_event_loop_thread(monkeypatch): + # The blocking trim must run in a worker thread (via asyncio.to_thread), not + # on the event loop, so a slow trim cannot stall other async work. + import threading + + monkeypatch.setattr(malloc_trim, "_resolve", lambda: ("glibc", object())) + seen: dict[str, int] = {} + + def record(): + seen["thread"] = threading.get_ident() + return 0 + + monkeypatch.setattr(malloc_trim, "trim", record) + calls = {"n": 0} + + async def sleeper(_seconds): + calls["n"] += 1 + if calls["n"] >= 2: # first sleep returns; after the trim, stop + raise asyncio.CancelledError + + monkeypatch.setattr(malloc_trim.asyncio, "sleep", sleeper) + + async def _run() -> int: + loop_thread = threading.get_ident() + with pytest.raises(asyncio.CancelledError): + await malloc_trim.trim_periodically(interval_seconds=60) + return loop_thread + + loop_thread = asyncio.run(_run()) + assert "thread" in seen # trim actually ran + assert seen["thread"] != loop_thread # ran off the event-loop thread + + +@pytest.mark.asyncio +async def test_slow_trim_does_not_stop_unrelated_async_work(monkeypatch): + # The periodic trim is dispatched off the event-loop thread via + # asyncio.to_thread and runs no Python gc.collect(), so even a slow purge + # must not freeze the loop. It is modeled here with a worker-thread park + # which, like the real GIL-releasing allocator C call, does not hold the + # GIL while it waits: unrelated coroutines keep making progress meanwhile. + import threading + + monkeypatch.setattr(malloc_trim, "_resolve", lambda: ("glibc", object())) + started = threading.Event() + release = threading.Event() + + def slow_trim() -> int: + started.set() + release.wait(5.0) # hold the worker thread until the test lets go + return 0 + + monkeypatch.setattr(malloc_trim, "trim", slow_trim) + + # Fire the trim's interval immediately (the interval is >= 1s) while leaving + # the counter's sub-second sleeps to behave normally. + real_sleep = asyncio.sleep + + async def smart_sleep(seconds): + if seconds >= 1: + return + await real_sleep(seconds) + + monkeypatch.setattr(malloc_trim.asyncio, "sleep", smart_sleep) + + ticks = 0 + + async def counter() -> None: + nonlocal ticks + while True: + await real_sleep(0.005) + ticks += 1 + + counter_task = asyncio.create_task(counter()) + trim_task = asyncio.create_task(malloc_trim.trim_periodically(interval_seconds=60)) + try: + # Wait for the trim to actually start blocking a worker thread. + for _ in range(400): + if started.is_set(): + break + await real_sleep(0.005) + assert started.is_set(), "trim never started" + + # The trim is now parked off-loop. The event loop must keep ticking. + ticks_before = ticks + await real_sleep(0.2) + ticks_during_trim = ticks - ticks_before + finally: + release.set() + counter_task.cancel() + trim_task.cancel() + + # On-loop blocking would freeze the counter (~0 ticks); off-thread it keeps + # ticking (~40 in 0.2s). Generous floor for scheduler jitter. + assert ticks_during_trim >= 10 + + +# --------------------------------------------------------------------------- # +# ProxyConfig wiring +# --------------------------------------------------------------------------- # +def test_proxy_config_malloc_trim_default_is_darwin_scoped(monkeypatch): + # Default-on only on macOS (the platform with the documented RSS ratchet); + # elsewhere it is opt-in, so glibc deployments do not silently take on a + # once-a-minute allocator purge. + from headroom.proxy import models + + monkeypatch.setattr(models.sys, "platform", "darwin") + assert models.ProxyConfig().periodic_malloc_trim_enabled is True + + monkeypatch.setattr(models.sys, "platform", "linux") + assert models.ProxyConfig().periodic_malloc_trim_enabled is False + + # The interval knob is platform-independent. + assert models.ProxyConfig().malloc_trim_interval_seconds == 60 From c8310819a4221b0d120436786fc499a24c8e55f1 Mon Sep 17 00:00:00 2001 From: Nestor G Pestelos Jr Date: Mon, 17 Aug 2026 06:04:59 +0800 Subject: [PATCH 12/53] fix(wrap): set xAI upstream for grok-build proxy (#2772) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description `headroom wrap grok-build` injected the client hop into `~/.grok/config.toml` but started the local proxy **without** setting the OpenAI-compatible upstream to xAI. The proxy defaulted to `api.openai.com`, so Grok session auth returned **401** on every chat completion even though compression still ran. `wrap grok` already passes `openai_api_url` → xAI. This PR aligns `wrap grok-build` and the Grok-only persistent `install` path on the shared `DEFAULT_API_URL` (`https://api.x.ai`). Closes # (none — discovered in live Grok Build pilot) ## Type of Change - [x] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which 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 - Pass `openai_api_url=_GROK_DEFAULT_API_URL` into `_run_proxy_only_watcher` from `wrap grok-build` - Use shared `DEFAULT_API_URL` from `wrap grok` (no hard-coded string drift) - Print proxy upstream in Grok Build setup lines - Persistent install: when targets are Grok-only, set `OPENAI_TARGET_API_URL` + `--openai-api-url` (skip when Codex/Copilot/Aider/OpenCode share the proxy; explicit env still wins) - Regression tests for wrap kwargs, setup lines, and install planner ## Testing - [x] Unit tests pass (`pytest` targeted suite) - [ ] Linting passes (`ruff check .`) — not run in this environment (no native editable build) - [ ] Type checking passes (`mypy headroom`) — not run - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ PYTHONPATH=$PWD python -m pytest \ tests/test_cli/test_wrap_bridge.py::test_wrap_grok_build_passes_xai_openai_api_url \ tests/test_cli/test_wrap_bridge.py::test_wrap_grok_build_uses_actual_proxy_port \ tests/test_install/test_planner.py::test_build_manifest_grok_build_only_sets_xai_upstream \ tests/test_install/test_planner.py::test_build_manifest_grok_with_codex_does_not_force_xai \ tests/test_install/test_planner.py::test_build_manifest_extra_env_wins_over_grok_xai_default \ tests/test_provider_grok_build.py::test_grok_build_setup_lines_include_proxy_url -q ...... 6 passed in 0.33s ``` ## Real Behavior Proof - Environment: macOS (darwin), Headroom 0.33.0 via `uv tool install "headroom-ai[proxy,mcp,code]==0.33.0"`, Grok Build CLI, models `grok-build` and `grok-4.5`, proxy on `127.0.0.1:8787`, upstream must be xAI - Exact command / steps: (1) Before: stock `headroom wrap grok-build` then `grok -m grok-build` one-shot prompt. (2) After: same wrap path with this branch (`openai_api_url=DEFAULT_API_URL` into `_run_proxy_only_watcher`) then `grok -m grok-build -p '…HEADROOM_XAI_OK…'`. Also exercised `grok-4.5` via `[model."grok-4.5"] base_url` → same proxy. - Observed result: Before — proxy log outbound `api.openai.com` → HTTP 401; client failed while local compression still ran. After — setup line prints Proxy upstream `https://api.x.ai`; proxy log `POST https://api.x.ai/v1/chat/completions` (and `/v1/responses` for grok-4.5) → status=200; dashboard shows 0 failed requests and accumulating token savings on live traffic. - Not tested: full `uv run` editable/maturin native build on this host; multi-tool install matrix beyond planner unit tests; Windows; ruff/mypy full tree ## 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 commented my code, particularly in hard-to-understand areas - [ ] I made corresponding changes to the documentation (CLI help text / setup lines only) - [x] My changes generate no new warnings - [x] I 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 by release-please from Conventional Commit PR title (a CI guard enforces this) ## Additional Notes - Intentional non-goal: changing default model, savings %, or Grok Build context-tool defaults - Mixed-target install (e.g. `grok_build` + `codex`) does **not** force xAI — operator must set upstream explicitly if they share one proxy - Related live routing: manual `[model."grok-4.5"] base_url` through the same proxy works once upstream is xAI (`/v1/responses`) --------- Co-authored-by: Grok 4.5 Co-authored-by: Nestor G Pestelos Jr Co-authored-by: JD Davis --- headroom/cli/wrap.py | 18 ++++++++---- headroom/install/planner.py | 17 ++++++++++++ headroom/providers/grok_build/runtime.py | 3 ++ tests/test_cli/test_wrap_bridge.py | 34 +++++++++++++++++++++++ tests/test_install/test_planner.py | 35 ++++++++++++++++++++++++ tests/test_provider_grok_build.py | 8 ++++++ 6 files changed, 110 insertions(+), 5 deletions(-) diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index 09c6642bb..585e633cc 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -136,7 +136,12 @@ from headroom.providers.copilot import ( validate_configuration as _validate_copilot_configuration, ) from headroom.providers.cursor import render_setup_lines as _render_cursor_setup_lines -from headroom.providers.grok import build_launch_env as _build_grok_launch_env +from headroom.providers.grok import ( + DEFAULT_API_URL as _GROK_DEFAULT_API_URL, +) +from headroom.providers.grok import ( + build_launch_env as _build_grok_launch_env, +) from headroom.providers.grok_build import render_setup_lines as _render_grok_build_setup_lines from headroom.providers.grok_build.config import ( inject_grok_provider_config, @@ -6417,7 +6422,7 @@ def grok( backend=backend, anyllm_provider=anyllm_provider, region=region, - openai_api_url="https://api.x.ai", + openai_api_url=_GROK_DEFAULT_API_URL, ) @@ -6507,9 +6512,9 @@ def grok_build( \b Grok Build reads model endpoints from ``~/.grok/config.toml``. This - command starts the proxy, optionally sets up the selected CLI context - tool, injects a Headroom-managed ``[model.grok-build]`` override, and - prints next steps. + command starts the proxy (upstream ``https://api.x.ai``, same as + ``wrap grok``), injects a Headroom-managed ``[model.grok-build]`` + override, and prints next steps. \b Example: @@ -6536,6 +6541,8 @@ def grok_build( for line in _render_grok_build_setup_lines(actual_port, project=project): click.echo(line) + # Client hop is local proxy via config.toml; upstream must be xAI (not + # the OpenAI default). Omitting this caused 401s with Grok auth headers. _run_proxy_only_watcher( agent_label="grok-build", port=port, @@ -6544,6 +6551,7 @@ def grok_build( memory=memory, agent_type="grok_build", print_setup_lines=_print_grok_build_setup, + openai_api_url=_GROK_DEFAULT_API_URL, ) diff --git a/headroom/install/planner.py b/headroom/install/planner.py index 290cff063..518688abf 100644 --- a/headroom/install/planner.py +++ b/headroom/install/planner.py @@ -9,6 +9,7 @@ from collections.abc import Iterable import click from headroom import paths as _paths +from headroom.providers.grok.runtime import DEFAULT_API_URL as _GROK_DEFAULT_API_URL from headroom.providers.install_registry import build_install_target_envs from headroom.rollout import RolloutChannel @@ -177,6 +178,19 @@ def build_manifest( base_env["HEADROOM_TELEMETRY"] = "on" if telemetry_enabled else "off" if memory_enabled: base_env["HEADROOM_MEMORY_ENABLED"] = "1" + # Grok / Grok Build need proxy upstream = xAI. Only auto-set when no other + # OpenAI-compatible tools share this proxy (those may need api.openai.com / + # Copilot). Explicit OPENAI_TARGET_API_URL in extra_env still wins below. + _openai_native = { + ToolTarget.CODEX.value, + ToolTarget.COPILOT.value, + ToolTarget.AIDER.value, + ToolTarget.OPENCODE.value, + } + _grok_targets = {ToolTarget.GROK.value, ToolTarget.GROK_BUILD.value} + target_set = set(resolved_targets) + if target_set & _grok_targets and not (target_set & _openai_native): + base_env.setdefault("OPENAI_TARGET_API_URL", _GROK_DEFAULT_API_URL) # Applied last so explicit --env overrides win over the auto-derived # defaults above (e.g. a custom HEADROOM_WORKSPACE_DIR). if extra_env: @@ -241,6 +255,9 @@ def build_manifest( proxy_args.extend(["--protect-tool-results", protect_tool_results]) if bedrock_profile: proxy_args.extend(["--bedrock-profile", bedrock_profile]) + openai_target = base_env.get("OPENAI_TARGET_API_URL") + if openai_target: + proxy_args.extend(["--openai-api-url", openai_target]) container_name = f"headroom-{normalized_profile}" return DeploymentManifest( diff --git a/headroom/providers/grok_build/runtime.py b/headroom/providers/grok_build/runtime.py index f8bc7f123..26ca11e4e 100644 --- a/headroom/providers/grok_build/runtime.py +++ b/headroom/providers/grok_build/runtime.py @@ -4,6 +4,7 @@ from __future__ import annotations from dataclasses import dataclass +from headroom.providers.grok.runtime import DEFAULT_API_URL from headroom.proxy.project_context import with_project_prefix @@ -41,6 +42,8 @@ def render_setup_lines(port: int, project: str | None = None) -> list[str]: " [model.grok-build]", f' base_url = "{target.base_url}"', "", + f" Proxy upstream (OpenAI-compatible): {DEFAULT_API_URL}", + "", " Start Grok Build in this project directory:", " grok", "", diff --git a/tests/test_cli/test_wrap_bridge.py b/tests/test_cli/test_wrap_bridge.py index c6c98172d..9f79dd8a6 100644 --- a/tests/test_cli/test_wrap_bridge.py +++ b/tests/test_cli/test_wrap_bridge.py @@ -74,6 +74,40 @@ def test_wrap_grok_build_uses_actual_proxy_port(monkeypatch, tmp_path: Path) -> assert "http://127.0.0.1:8787/" not in result.output +def test_wrap_grok_build_passes_xai_openai_api_url(monkeypatch, tmp_path: Path) -> None: + """Grok Build must set proxy upstream to xAI (same as wrap grok). + + Without openai_api_url, the proxy defaults to api.openai.com and Grok + session auth returns 401 on every chat completion. + """ + from headroom.providers.grok import DEFAULT_API_URL + + _set_test_home(monkeypatch, tmp_path) + runner = CliRunner() + captured: dict = {} + + def fake_watcher(**kwargs) -> None: + captured.update(kwargs) + kwargs["print_setup_lines"](kwargs["port"]) + + monkeypatch.setattr("headroom.cli.wrap._run_proxy_only_watcher", fake_watcher) + + result = runner.invoke(main, ["wrap", "grok-build", "--port", "8787"]) + + assert result.exit_code == 0, result.output + assert captured.get("openai_api_url") == DEFAULT_API_URL + # Equality on the constant (not substring containment) keeps CodeQL + # incomplete-url-substring-sanitization quiet while pinning the host. + assert DEFAULT_API_URL == "https://api.x.ai" + expected_upstream = f" Proxy upstream (OpenAI-compatible): {DEFAULT_API_URL}" + upstream_lines = [ + line + for line in result.output.splitlines() + if line.startswith(" Proxy upstream (OpenAI-compatible): ") + ] + assert upstream_lines == [expected_upstream] + + def test_wrap_rejects_retired_context_tool_flag(monkeypatch, tmp_path: Path) -> None: """A surviving --context-tool must fail loudly, not be silently ignored. diff --git a/tests/test_install/test_planner.py b/tests/test_install/test_planner.py index b4389e611..30ab8a070 100644 --- a/tests/test_install/test_planner.py +++ b/tests/test_install/test_planner.py @@ -330,3 +330,38 @@ def test_build_manifest_extra_env_overrides_derived_defaults() -> None: # telemetry_enabled=False in _base_manifest_kwargs would normally set "off"; # an explicit --env must win. assert manifest.base_env["HEADROOM_TELEMETRY"] == "on" + + +def test_build_manifest_grok_build_only_sets_xai_upstream() -> None: + """Persistent install for Grok Build alone must route proxy upstream to xAI.""" + from headroom.providers.grok import DEFAULT_API_URL + + manifest = build_manifest(**_base_manifest_kwargs(targets=["grok_build"], backend="openai")) + + assert manifest.base_env.get("OPENAI_TARGET_API_URL") == DEFAULT_API_URL + idx = manifest.proxy_args.index("--openai-api-url") + assert manifest.proxy_args[idx + 1] == DEFAULT_API_URL + + +def test_build_manifest_grok_with_codex_does_not_force_xai() -> None: + """Do not override OpenAI upstream when OpenAI-native tools share the proxy.""" + manifest = build_manifest( + **_base_manifest_kwargs(targets=["grok_build", "codex"], backend="openai") + ) + + assert "OPENAI_TARGET_API_URL" not in manifest.base_env + assert "--openai-api-url" not in manifest.proxy_args + + +def test_build_manifest_extra_env_wins_over_grok_xai_default() -> None: + manifest = build_manifest( + **_base_manifest_kwargs( + targets=["grok_build"], + backend="openai", + extra_env={"OPENAI_TARGET_API_URL": "https://gateway.example/v1"}, + ) + ) + + assert manifest.base_env["OPENAI_TARGET_API_URL"] == "https://gateway.example/v1" + idx = manifest.proxy_args.index("--openai-api-url") + assert manifest.proxy_args[idx + 1] == "https://gateway.example/v1" diff --git a/tests/test_provider_grok_build.py b/tests/test_provider_grok_build.py index 43ae61e7e..e0918f792 100644 --- a/tests/test_provider_grok_build.py +++ b/tests/test_provider_grok_build.py @@ -5,6 +5,7 @@ from pathlib import Path import pytest +from headroom.providers.grok import DEFAULT_API_URL from headroom.providers.grok_build import build_proxy_targets, render_setup_lines from headroom.providers.grok_build.config import ( inject_grok_provider_config, @@ -46,6 +47,13 @@ def test_grok_build_setup_lines_include_proxy_url() -> None: assert "http://127.0.0.1:8787/v1" in joined assert "[model.grok-build]" in joined + # Exact labeled line equality (not bare host substring containment) so + # CodeQL does not flag incomplete URL substring sanitization. + expected_upstream = f" Proxy upstream (OpenAI-compatible): {DEFAULT_API_URL}" + upstream_lines = [ + line for line in lines if line.startswith(" Proxy upstream (OpenAI-compatible): ") + ] + assert upstream_lines == [expected_upstream] def test_grok_build_build_install_env_returns_proxy_url() -> None: From ddd9f76729d5662201b84bd0a51281cd3ac64ad3 Mon Sep 17 00:00:00 2001 From: Abhay Singh Date: Mon, 17 Aug 2026 03:35:04 +0530 Subject: [PATCH 13/53] fix(install): stop the PowerShell installer leaking temp dirs into the real user PATH (#2985) ## Description `scripts/install.ps1` persists the install directory to the user's PATH through `Ensure-PathEntry`, which calls `[Environment]::SetEnvironmentVariable('Path', ..., 'User')`. That value lives in the `HKCU\Environment` registry key, so it is **not** scoped by a `HOME` / `USERPROFILE` override. `tests/test_install/test_native_installers.py::test_powershell_native_installer_supports_persistent_docker_lifecycle` runs that real installer against a `tmp_path` fake home. Every run therefore prepended the test's throwaway shim directory to the developer's actual, persistent user PATH -- and it stayed there after the test finished. The entries accumulate one per run, ahead of the real install dir; and since the installer also drops `headroom.ps1`/`headroom.cmd` into that dir, `headroom` in a fresh shell could then resolve to a leftover wrapper from a deleted temp directory (#2970). ## Fix Make the persistence scope configurable via `HEADROOM_INSTALL_PATH_SCOPE`, defaulting to `'User'` so production behavior is unchanged: ```powershell $scope = if ($env:HEADROOM_INSTALL_PATH_SCOPE) { $env:HEADROOM_INSTALL_PATH_SCOPE } else { 'User' } $currentPath = [Environment]::GetEnvironmentVariable('Path', $scope) ... [Environment]::SetEnvironmentVariable('Path', ($newPath -join ';'), $scope) ``` The installer tests (`_build_env`) set `HEADROOM_INSTALL_PATH_SCOPE=Process`, so the PATH update stays in the spawned PowerShell process (discarded when it exits) instead of writing to the registry. Fixes #2970 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature - [ ] Breaking change - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `scripts/install.ps1` (`Ensure-PathEntry`): read/write the PATH via `$env:HEADROOM_INSTALL_PATH_SCOPE` (default `'User'`). - `tests/test_install/test_native_installers.py`: `_build_env` sets `HEADROOM_INSTALL_PATH_SCOPE=Process` for every installer invocation; add a Windows-only `test_powershell_installer_does_not_leak_into_user_path` asserting the real User PATH entry count is unchanged across an installer run. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check`) - [x] New test added ### Test Output ```text tests/test_install/test_native_installers.py -k does_not_leak_into_user_path 1 passed # uvx ruff@0.15.22 check tests/test_install/test_native_installers.py -> All checks passed! ``` ## Real Behavior Proof - Environment: Windows 11, Windows PowerShell 5.1, Python 3.12.11, project venv, pytest 9.1.1, ruff 0.15.22 via uvx. - Exact command / steps: recorded the real user PATH entry count (`([Environment]::GetEnvironmentVariable('Path','User') -split ';').Count` = 27), ran the PowerShell installer test with the fix, then re-read the count: still 27 -- no leak. The new `test_powershell_installer_does_not_leak_into_user_path` formalizes this (before == after). - Observed result: running the installer test suite no longer mutates the developer's persistent user PATH; production installs still persist to `'User'` as before. - Not tested: the sibling `test_powershell_native_installer_supports_persistent_docker_lifecycle` fails on my Windows host on an unrelated `trusted_cidrs` dashboard-gateway assertion (it fails identically on `main` without this change, and the whole PowerShell suite is skipped on the Linux CI runners). This PR does not touch that path. ## Runtime Rollout Safety - Rollout-managed feature(s): none. This is the native PowerShell installer script, not a rollout-channel-gated runtime feature. - Minimum rollout channel: N/A (no rollout-managed behavior). - Stable/default behavior changed: no. Production installs still persist PATH to the `User` scope exactly as before; the new `HEADROOM_INSTALL_PATH_SCOPE` override defaults to `User` and is used only by the test suite to avoid mutating the developer's persistent PATH. - Kill switch / disable path: leave `HEADROOM_INSTALL_PATH_SCOPE` unset (the default) for the normal `User` behavior. - Unsafe override required: no. - Qualification impact: none. Installer-only; no proxy runtime path is touched. - Rollback path: revert this PR; the installer returns to writing the `User` PATH unconditionally. ## 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 - [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 ## Additional Notes The scope override defaults to `'User'`, so nothing changes for real installs. It doubles as an escape hatch for any environment (CI images, ephemeral containers) that must not touch the persistent user PATH. --------- Co-authored-by: JD Davis --- scripts/install.ps1 | 28 ++++- tests/test_install/test_native_installers.py | 124 +++++++++++++++++++ 2 files changed, 150 insertions(+), 2 deletions(-) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index b7444fb2a..9926690de 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -22,14 +22,38 @@ function Require-Command { function Ensure-PathEntry { param([string]$PathEntry) - $currentPath = [Environment]::GetEnvironmentVariable('Path', 'User') + # Persist to the User PATH by default. The 'User' scope lives in + # HKCU\Environment and is NOT redirected by a HOME/USERPROFILE override, so a + # caller that must not mutate the real persistent PATH (the installer test + # suite, which runs this against a throwaway fake home) sets + # HEADROOM_INSTALL_PATH_SCOPE=Process to keep the update ephemeral instead of + # leaking the temp shim dir into the developer's actual user PATH (#2970). + # + # Only those two persistence modes are supported. The value is handed to + # .NET's EnvironmentVariableTarget, whose 'Machine' member would rewrite the + # SYSTEM-wide PATH if this variable were inherited by an elevated installer, + # and a typo would otherwise fail late with an opaque enum-conversion error. + # Normalize case-insensitively and allow-list 'User'/'Process', failing early + # and clearly for 'Machine' or anything else. + $scope = 'User' + if ($env:HEADROOM_INSTALL_PATH_SCOPE) { + switch ($env:HEADROOM_INSTALL_PATH_SCOPE.Trim().ToLowerInvariant()) { + 'user' { $scope = 'User' } + 'process' { $scope = 'Process' } + default { + throw "HEADROOM_INSTALL_PATH_SCOPE must be 'User' or 'Process' (got '$($env:HEADROOM_INSTALL_PATH_SCOPE)'); 'Machine' and other targets are not supported." + } + } + } + + $currentPath = [Environment]::GetEnvironmentVariable('Path', $scope) $parts = @() if ($currentPath) { $parts = $currentPath -split ';' | Where-Object { $_ } } if ($parts -notcontains $PathEntry) { $newPath = @($PathEntry) + $parts - [Environment]::SetEnvironmentVariable('Path', ($newPath -join ';'), 'User') + [Environment]::SetEnvironmentVariable('Path', ($newPath -join ';'), $scope) } } diff --git a/tests/test_install/test_native_installers.py b/tests/test_install/test_native_installers.py index 94fee0ba9..37bcaac3e 100644 --- a/tests/test_install/test_native_installers.py +++ b/tests/test_install/test_native_installers.py @@ -224,6 +224,11 @@ def _build_env(home: Path, tmp_path: Path) -> dict[str, str]: env["PATH"] = str(shim_dir) + os.pathsep + env.get("PATH", "") env["FAKE_DOCKER_STATE"] = str(tmp_path / "fake-docker-state.json") env["FAKE_DOCKER_LOG"] = str(tmp_path / "fake-docker.log") + # #2970: the PowerShell installer's Ensure-PathEntry persists to the 'User' + # PATH scope (HKCU\Environment), which a HOME/USERPROFILE override does not + # redirect. Keep the PATH update ephemeral (Process scope) so running these + # tests never leaks the throwaway shim dir into the developer's real PATH. + env["HEADROOM_INSTALL_PATH_SCOPE"] = "Process" return env @@ -555,6 +560,125 @@ def _powershell_executable() -> str | None: return shutil.which("pwsh") or shutil.which("powershell") or shutil.which("powershell.exe") +@pytest.mark.skipif( + os.name != "nt" or _powershell_executable() is None, + reason="Windows PowerShell coverage runs on Windows hosts only", +) +def test_powershell_installer_does_not_leak_into_user_path(tmp_path: Path) -> None: + """The installer must not mutate the real HKCU User PATH (#2970). + + ``Ensure-PathEntry`` persists to the 'User' scope, which a HOME/USERPROFILE + override does not redirect, so running the installer against a throwaway home + used to leak the temp shim dir into the developer's real PATH. ``_build_env`` + now sets ``HEADROOM_INSTALL_PATH_SCOPE=Process`` to keep the update + ephemeral; the real User PATH must be unchanged across the run. + """ + powershell = _powershell_executable() + assert powershell is not None + + count_cmd = [ + powershell, + "-NoProfile", + "-Command", + "([Environment]::GetEnvironmentVariable('Path','User') -split ';').Count", + ] + before = _run(count_cmd, env=os.environ.copy()).stdout.strip() + + home = tmp_path / "home" + (home / ".local").mkdir(parents=True) + env = _build_env(home, tmp_path) + env["HEADROOM_DOCKER_IMAGE"] = "headroom:test-image" + _run( + [ + powershell, + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + str(REPO_ROOT / "scripts" / "install.ps1"), + ], + env=env, + cwd=REPO_ROOT, + ) + + after = _run(count_cmd, env=os.environ.copy()).stdout.strip() + assert after == before, f"installer leaked into the real User PATH: {before} -> {after}" + + +# AST-extract Ensure-PathEntry from install.ps1 and invoke it in isolation under +# a given HEADROOM_INSTALL_PATH_SCOPE, so the scope allow-list is exercised +# without running the whole installer. Parsing via the PowerShell AST (not a +# regex) keeps this pinned to the real function body. Only 'Process' (ephemeral) +# and the throwing paths are driven — never 'User', which would mutate the real +# HKCU PATH. +_ENSURE_PATH_SCOPE_HARNESS = r""" +param([string]$InstallScript, [string]$ScopeValue) +$ErrorActionPreference = 'Stop' +$ast = [System.Management.Automation.Language.Parser]::ParseFile( + $InstallScript, [ref]$null, [ref]$null) +$fn = $ast.FindAll({ + param($n) + $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $n.Name -eq 'Ensure-PathEntry' +}, $true) | Select-Object -First 1 +if (-not $fn) { Write-Output 'NOFUNC'; exit 3 } +Invoke-Expression $fn.Extent.Text +$env:HEADROOM_INSTALL_PATH_SCOPE = $ScopeValue +try { + Ensure-PathEntry -PathEntry 'C:\headroom-scope-test-marker' + Write-Output 'OK' +} catch { + Write-Output ('ERR:' + $_.Exception.Message) +} +""" + + +def _invoke_scope_harness(scope_value: str, tmp_path: Path) -> str: + powershell = _powershell_executable() + assert powershell is not None + harness = tmp_path / "scope_harness.ps1" + harness.write_text(_ENSURE_PATH_SCOPE_HARNESS, encoding="utf-8") + result = _run( + [ + powershell, + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + str(harness), + "-InstallScript", + str(REPO_ROOT / "scripts" / "install.ps1"), + "-ScopeValue", + scope_value, + ], + env=os.environ.copy(), + check=False, + ) + return result.stdout.strip() + + +@pytest.mark.skipif( + os.name != "nt" or _powershell_executable() is None, + reason="Windows PowerShell coverage runs on Windows hosts only", +) +def test_path_scope_accepts_process_case_insensitively(tmp_path: Path) -> None: + """'Process' (any case) is a supported ephemeral target: Ensure-PathEntry runs.""" + assert _invoke_scope_harness("process", tmp_path).endswith("OK") + assert _invoke_scope_harness("Process", tmp_path).endswith("OK") + + +@pytest.mark.skipif( + os.name != "nt" or _powershell_executable() is None, + reason="Windows PowerShell coverage runs on Windows hosts only", +) +def test_path_scope_rejects_machine_and_invalid_values(tmp_path: Path) -> None: + """'Machine' (system-wide) and typos must fail early, before any PATH write.""" + for bad in ("Machine", "machine", "system", "bogus"): + out = _invoke_scope_harness(bad, tmp_path) + assert out.startswith("ERR:"), f"scope {bad!r} was not rejected: {out!r}" + assert "User" in out and "Process" in out, out + + @pytest.mark.skipif( os.name != "nt" or _powershell_executable() is None, reason="Windows PowerShell coverage runs on Windows hosts only", From 2a8472525d3a027c95dc38a10c4b6707b482cabc Mon Sep 17 00:00:00 2001 From: Abhay Singh Date: Mon, 17 Aug 2026 03:39:46 +0530 Subject: [PATCH 14/53] feat(wrap/claude): make the --1m fallback model configurable via HEADROOM_1M_MODEL (#2983) ## Description The model `headroom wrap claude --1m` falls back to (when no model is otherwise selected) was a hardcoded constant `claude-opus-4-8`, with no env var or config key to override it. So it goes stale with every new Opus release, and the only workaround is pinning `ANTHROPIC_MODEL` globally -- which also changes every non-`--1m` session and overrides Claude Code's own `/model` picker. The knob the user actually wants ("what should `--1m` default to") did not exist (#2937). ## Fix Add a `HEADROOM_1M_MODEL` env override that `_resolve_1m_model` consults for its fallback default, and bump the built-in default to `claude-opus-5` (Opus 5 has shipped): ```python _1M_MODEL_ENV = "HEADROOM_1M_MODEL" _DEFAULT_1M_MODEL = "claude-opus-5" def _resolve_1m_model(current: str | None) -> str: fallback = (os.environ.get(_1M_MODEL_ENV) or "").strip() or _DEFAULT_1M_MODEL base = (current or "").strip() or fallback return base if base.endswith(_CONTEXT_1M_SUFFIX) else f"{base}{_CONTEXT_1M_SUFFIX}" ``` Precedence is unchanged: an explicit `ANTHROPIC_MODEL` (or a pass-through `--model`, via the existing `_apply_1m_to_claude_args`) still wins. `HEADROOM_1M_MODEL` only supplies the fallback when nothing else is selected. The `[1m]` suffixing and idempotency are unchanged. Fixes #2937 ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [x] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [x] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `docs/content/docs/configuration.mdx`: document `HEADROOM_1M_MODEL` (new "Claude 1M context window" subsection covering `--1m` resolution order and `[1m]` acceptance) and register it in the Environment Variables catalog with its current default. - `tests/test_cli/test_wrap_helpers.py`: assert the knob stays documented and the documented default tracks `_DEFAULT_1M_MODEL`, so it cannot silently drift. - `headroom/cli/wrap.py`: add `HEADROOM_1M_MODEL` env override in `_resolve_1m_model`; bump `_DEFAULT_1M_MODEL` to `claude-opus-5`. - `tests/test_cli/test_wrap_helpers.py`: env override wins the fallback; an explicit current model still wins over the env; blank env falls back to the built-in; env value is idempotent for an already-`[1m]` value. Updated the existing "falls back to default" test to assert against the constant (robust to future bumps) and to clear the env var. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check`) - [x] Type checking passes (`mypy`) - [x] New tests added ### Test Output ```text tests/test_cli/test_wrap_helpers.py -k "resolve_1m or apply_1m" 11 passed tests/test_cli/test_wrap_claude_vertex_proxy_env.py -k 1m 4 passed # uvx ruff@0.15.22 check -> All checks passed! # uvx mypy@1.20.2 headroom/cli/wrap.py -> Success: no issues found in 1 source file ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.12.11, project venv, pytest 9.1.1, ruff 0.15.22 and mypy 1.20.2 via uvx. - Exact command / steps: exercised `_resolve_1m_model` directly with the env var set/unset. With `HEADROOM_1M_MODEL=claude-opus-9` and no `ANTHROPIC_MODEL`, `--1m` resolves to `claude-opus-9[1m]`; with the env var unset it resolves to `claude-opus-5[1m]`; a set `ANTHROPIC_MODEL` (e.g. `claude-sonnet-5`) still wins as `claude-sonnet-5[1m]`. - Observed result: operators can point `--1m` at the current Opus without a code change and without pinning `ANTHROPIC_MODEL` globally, and a fresh install no longer silently opts `--1m` into the previous generation. - Not tested: a live Claude Code 1M session (no entitled account here). The resolution is verified at the helper the launch path uses. ## Runtime Rollout Safety - Rollout-managed feature(s): none. `wrap claude --1m` model resolution is a launch-time CLI helper, not a rollout-channel-gated runtime feature. - Minimum rollout channel: N/A (no rollout-managed behavior). - Stable/default behavior changed: yes, narrowly. The built-in `--1m` fallback default moves from `claude-opus-4-8` to `claude-opus-5` only when neither `HEADROOM_1M_MODEL` nor `ANTHROPIC_MODEL` is set; any explicit selection is unaffected. - Kill switch / disable path: set `HEADROOM_1M_MODEL` (or `ANTHROPIC_MODEL`) to pin any model; both override the default. - Unsafe override required: no. - Qualification impact: none. No proxy request path, routing, or token accounting is touched. - Rollback path: revert this PR, or set `HEADROOM_1M_MODEL=claude-opus-4-8` to restore the prior default without a code change. ## 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 - [x] 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 - [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 ## Additional Notes The default bump (`claude-opus-4-8` -> `claude-opus-5`) is the second half of the issue's request. If you would rather keep the constant and ship only the env override, I can drop that one line; the override alone already lets operators avoid the stale default. --------- Co-authored-by: JD Davis --- docs/content/docs/configuration.mdx | 19 ++++++++++ headroom/cli/wrap.py | 19 ++++++---- tests/test_cli/test_wrap_helpers.py | 55 ++++++++++++++++++++++++++--- 3 files changed, 82 insertions(+), 11 deletions(-) diff --git a/docs/content/docs/configuration.mdx b/docs/content/docs/configuration.mdx index c8a4fca82..51101e2f1 100644 --- a/docs/content/docs/configuration.mdx +++ b/docs/content/docs/configuration.mdx @@ -210,6 +210,24 @@ response = client.chat.completions.create( The `RollingWindowConfig`, `IntelligentContextConfig`, and `ScoringWeights` classes are no longer part of Headroom. Context management now happens automatically inside the pipeline (live-zone-only compression). +### Claude 1M context window (`headroom wrap claude --1m`) + +`headroom wrap claude --1m` opts a Claude Code session into Anthropic's 1M-token context window by selecting a `[1m]`-suffixed model id, which makes Claude Code send the `context-1m` beta header. The model that `--1m` targets is resolved in this order: + +1. an explicit `--model` / `ANTHROPIC_MODEL` value (used as-is, with a `[1m]` suffix appended when missing), +2. otherwise `HEADROOM_1M_MODEL`, when set, +3. otherwise the built-in default (currently `claude-opus-5`). + +Set `HEADROOM_1M_MODEL` to point `--1m` at a specific model without pinning `ANTHROPIC_MODEL` globally, so the default can follow a new Opus generation without a code change: + +```bash +# Route --1m at a specific model for this shell / session +export HEADROOM_1M_MODEL=claude-opus-5 +headroom wrap claude --1m +``` + +`HEADROOM_1M_MODEL` is a fallback only: an explicit `--model` or `ANTHROPIC_MODEL` always wins. The value may be given with or without the `[1m]` suffix; both `claude-opus-5` and `claude-opus-5[1m]` are accepted, and the suffix is added when absent. + ## Pipeline Extensions Use a `headroom.pipeline_extension` entry point when you need to normalize or annotate requests before they leave Headroom. The `PRE_SEND` stage is the right place for provider-specific request cleanup, such as turning `content: null` into `content: ""` for upstreams that reject OpenAI-spec tool-call messages. @@ -317,6 +335,7 @@ headroom proxy --learn --min-evidence 3 | `HEADROOM_DEDUPE` | Whole-conversation verbatim cross-turn dedup in the router (cache-safe, information-preserving via retrieval markers). Superseded-read drop + lossless folds run without it; this adds verbatim dedup. | `off` | | `HEADROOM_CACHE_TTL_LEARN` | Append per-turn cache-outcome observations (provider, model, idle, hit/miss) to `cache_ttl_observations.jsonl` for the offline `headroom-cache-ttl` learner. Observation-only (no request-behavior change); respects `HEADROOM_STATELESS`; the log is size-bounded. | `off` | | `HEADROOM_KOMPRESS_ENDPOINT` / `HEADROOM_KOMPRESS_ENDPOINT_TOKEN` | Offload ML compression (Kompress) to a remote endpoint instead of the local ONNX model — used by reasoning compaction and the router when set. | -- | +| `HEADROOM_1M_MODEL` | Fallback model that `headroom wrap claude --1m` targets when neither `--model` nor `ANTHROPIC_MODEL` is set. Accepts the id with or without the `[1m]` suffix (added when absent); an explicit `--model` / `ANTHROPIC_MODEL` always wins. See [Claude 1M context window](#claude-1m-context-window-headroom-wrap-claude---1m). | `claude-opus-5` | For provider-only proxying, prefer `HEADROOM_HTTP_PROXY` over process-wide variables such as `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, or `NO_PROXY`. HTTPX reads those global variables, but Headroom also passes them through to tool executions. diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index 585e633cc..2d1f7d52e 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -321,9 +321,13 @@ _AGENT_SAVINGS_WRAP_AGENTS = {"claude", "codex", "cursor", "grok", "grok_build"} # so `--1m` forces the suffix via ANTHROPIC_MODEL on the launched process. _ANTHROPIC_MODEL_ENV = "ANTHROPIC_MODEL" _CONTEXT_1M_SUFFIX = "[1m]" -# Only used when no model is otherwise selected (no ANTHROPIC_MODEL set). The -# current default Opus; the suffix logic preserves any model the user did set. -_DEFAULT_1M_MODEL = "claude-opus-4-8" +_1M_MODEL_ENV = "HEADROOM_1M_MODEL" +# Fallback model for `--1m` when nothing else selects one (no ANTHROPIC_MODEL, +# no explicit --model). Overridable via HEADROOM_1M_MODEL so it can track new +# Opus releases without a code change and without pinning ANTHROPIC_MODEL +# globally (which would also change non-`--1m` sessions and override Claude +# Code's /model picker). #2937. +_DEFAULT_1M_MODEL = "claude-opus-5" _OPENCLAUDE_INSTRUCTIONS_FILE = "CONVENTIONS.md" @@ -331,11 +335,12 @@ def _resolve_1m_model(current: str | None) -> str: """Return the model id that makes Claude Code request the 1M window (#1158). Preserves a model the user already selected via ``ANTHROPIC_MODEL`` (only - appending the ``[1m]`` suffix when missing); falls back to the default Opus - when none is set. Idempotent — a value already ending in ``[1m]`` is - returned unchanged. + appending the ``[1m]`` suffix when missing). When none is set it falls back + to ``HEADROOM_1M_MODEL`` if defined, else the built-in default Opus (#2937). + Idempotent — a value already ending in ``[1m]`` is returned unchanged. """ - base = (current or "").strip() or _DEFAULT_1M_MODEL + fallback = (os.environ.get(_1M_MODEL_ENV) or "").strip() or _DEFAULT_1M_MODEL + base = (current or "").strip() or fallback return base if base.endswith(_CONTEXT_1M_SUFFIX) else f"{base}{_CONTEXT_1M_SUFFIX}" diff --git a/tests/test_cli/test_wrap_helpers.py b/tests/test_cli/test_wrap_helpers.py index 2fad26a26..f53df49ba 100644 --- a/tests/test_cli/test_wrap_helpers.py +++ b/tests/test_cli/test_wrap_helpers.py @@ -752,10 +752,57 @@ def test_resolve_1m_model_is_idempotent() -> None: assert wrap_mod._resolve_1m_model("claude-opus-4-8[1m]") == "claude-opus-4-8[1m]" -def test_resolve_1m_model_falls_back_to_default_when_unset() -> None: - """With no model selected, fall back to the default Opus carrying [1m].""" - assert wrap_mod._resolve_1m_model(None) == "claude-opus-4-8[1m]" - assert wrap_mod._resolve_1m_model(" ") == "claude-opus-4-8[1m]" +def test_resolve_1m_model_falls_back_to_default_when_unset( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """With no model selected, fall back to the built-in default carrying [1m].""" + monkeypatch.delenv("HEADROOM_1M_MODEL", raising=False) + expected = f"{wrap_mod._DEFAULT_1M_MODEL}[1m]" + assert wrap_mod._resolve_1m_model(None) == expected + assert wrap_mod._resolve_1m_model(" ") == expected + + +def test_resolve_1m_model_env_overrides_builtin_default(monkeypatch: pytest.MonkeyPatch) -> None: + """HEADROOM_1M_MODEL overrides the built-in fallback so --1m can track new + Opus releases without a code change or pinning ANTHROPIC_MODEL (#2937).""" + monkeypatch.setenv("HEADROOM_1M_MODEL", "claude-opus-9") + assert wrap_mod._resolve_1m_model(None) == "claude-opus-9[1m]" + + +def test_resolve_1m_model_current_wins_over_env(monkeypatch: pytest.MonkeyPatch) -> None: + """An explicit ANTHROPIC_MODEL still wins; HEADROOM_1M_MODEL is only the + fallback default when nothing else is selected.""" + monkeypatch.setenv("HEADROOM_1M_MODEL", "claude-opus-9") + assert wrap_mod._resolve_1m_model("claude-sonnet-5") == "claude-sonnet-5[1m]" + + +def test_resolve_1m_model_env_idempotent_on_suffixed_value( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A HEADROOM_1M_MODEL that already carries [1m] is not double-suffixed.""" + monkeypatch.setenv("HEADROOM_1M_MODEL", "claude-opus-9[1m]") + assert wrap_mod._resolve_1m_model(None) == "claude-opus-9[1m]" + + +def test_resolve_1m_model_blank_env_falls_back_to_builtin(monkeypatch: pytest.MonkeyPatch) -> None: + """A blank/whitespace HEADROOM_1M_MODEL falls back to the built-in default.""" + monkeypatch.setenv("HEADROOM_1M_MODEL", " ") + assert wrap_mod._resolve_1m_model(None) == f"{wrap_mod._DEFAULT_1M_MODEL}[1m]" + + +def test_headroom_1m_model_is_documented_and_default_matches_code() -> None: + """The HEADROOM_1M_MODEL knob must stay documented, and the documented + default must track the code, so the supported configuration surface cannot + silently drift or disappear (#2937). + """ + docs = Path(__file__).resolve().parents[2] / "docs" / "content" / "docs" / "configuration.mdx" + text = docs.read_text(encoding="utf-8") + assert wrap_mod._1M_MODEL_ENV in text, f"{wrap_mod._1M_MODEL_ENV} is not documented" + # The env-var catalog row must advertise the current built-in default. + assert f"`{wrap_mod._DEFAULT_1M_MODEL}`" in text, ( + "documented HEADROOM_1M_MODEL default is out of sync with " + f"_DEFAULT_1M_MODEL={wrap_mod._DEFAULT_1M_MODEL!r}" + ) class TestFindAvailablePort: From ef7e07e0f5d6510ab96b5abb1698b1b681b5f9bf Mon Sep 17 00:00:00 2001 From: AxelRay Date: Mon, 17 Aug 2026 05:09:50 +0700 Subject: [PATCH 15/53] fix(policy): price net-cost mutations with the 1h cache-write tier (#2780) ## Description This fixes the net-cost mutation gate for requests using Anthropic's 1-hour prompt-cache TTL. The gate previously hardcoded the 5-minute cache-write multiplier of 1.25x. A 1-hour cache write costs 2.0x, so the old calculation understated the true write penalty and could incorrectly recommend mutation for 1-hour clients. Closes #2773 ## 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 - Added TTL-aware cache-write multiplier selection for 5-minute and 1-hour tiers. - Threaded the resolved TTL through the content router and compression policy helpers. - Preserved the existing 5-minute behavior as the default. - Added Python and Rust regression coverage for the 1-hour tier. - Retuned the netcost gate fixtures so the 1-hour write tier flips the decision in the full ContentRouter path. - Did not edit CHANGELOG.md. ## 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 ```text pytest tests/test_compression_policy.py -q 20 passed cargo test -p headroom-core --lib compression_policy -- --nocapture 14 passed pytest tests/test_netcost_gate.py -q 27 passed Ruff checks and formatting passed. git diff --check passed. ``` ## Real Behavior Proof - Environment: Linux x86_64 contributor checkout with Python and Rust test environments. - Exact command / steps: - Ran the Python compression policy test suite. - Ran the Rust compression policy unit tests. - Ran the netcost gate suite, including the 1-hour env and request-marker cases. - Exercised the new 1-hour TTL golden case alongside the existing 5-minute cases. - Observed result: The 1-hour case uses the 2.0x write multiplier and skips the same candidate that still mutates under 5-minute pricing. Existing 5-minute behavior remains covered and passing. - Not tested: A live Anthropic request through the proxy and production traffic. ## 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 - [ ] 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) Not applicable for this backend policy fix. ## Additional Notes Ready for review. CI is green on the current tip. --- .../headroom-core/src/compression_policy.rs | 97 ++++++++++++++++++- headroom/proxy/handlers/anthropic.py | 33 ++++--- headroom/transforms/compression_policy.py | 55 ++++++++--- headroom/transforms/content_router.py | 32 +++++- tests/test_compression_policy.py | 21 ++++ tests/test_netcost_gate.py | 54 +++++++++++ 6 files changed, 262 insertions(+), 30 deletions(-) diff --git a/crates/headroom-core/src/compression_policy.rs b/crates/headroom-core/src/compression_policy.rs index 4d9d786c6..145a9dd57 100644 --- a/crates/headroom-core/src/compression_policy.rs +++ b/crates/headroom-core/src/compression_policy.rs @@ -135,11 +135,24 @@ pub(crate) const MAX_LOSSY_RATIO_SUBSCRIPTION: f32 = 0.25; /// net-cost mutation formula (#856). pub const CACHE_WRITE_MULTIPLIER: f32 = 1.25; +/// Anthropic prompt-cache write multiplier for the 1-hour TTL tier. +pub const CACHE_WRITE_MULTIPLIER_1H: f32 = 2.0; + /// Anthropic prompt-cache read multiplier: a `cache_read` token costs /// 0.1× a plain input token. Input to the net-cost mutation formula /// (#856). pub const CACHE_READ_MULTIPLIER: f32 = 0.1; +/// Return the cache-write multiplier for a prompt-cache TTL tier. +/// +/// Invalid, missing, and non-positive values retain the 5-minute default. +pub fn cache_write_multiplier_for_ttl(ttl_seconds: Option) -> f32 { + match ttl_seconds { + Some(ttl) if ttl.is_finite() && ttl >= 3_600.0 => CACHE_WRITE_MULTIPLIER_1H, + _ => CACHE_WRITE_MULTIPLIER, + } +} + /// Per-auth-mode policy that downstream compression stages consult. /// /// `Copy` because the struct is small POD (two `bool`s + a `u32` + an @@ -269,7 +282,26 @@ impl CompressionPolicy { expected_reads: f32, p_alive: f32, ) -> f32 { - let w = CACHE_WRITE_MULTIPLIER; + self.net_mutation_gain_with_write_multiplier( + delta_t, + suffix_tokens, + expected_reads, + p_alive, + None, + ) + } + + /// Variant of [`Self::net_mutation_gain`] with an explicit cache-write + /// multiplier. `None` uses the 5-minute default. + pub fn net_mutation_gain_with_write_multiplier( + &self, + delta_t: u32, + suffix_tokens: u32, + expected_reads: f32, + p_alive: f32, + write_multiplier: Option, + ) -> f32 { + let w = write_multiplier.unwrap_or(CACHE_WRITE_MULTIPLIER); let r = CACHE_READ_MULTIPLIER; // f32::max ignores NaN (returns the other operand), so NaN reads // land on 0.0; clamp would propagate NaN, so guard alive explicitly. @@ -299,7 +331,32 @@ impl CompressionPolicy { expected_reads: f32, p_alive: f32, ) -> bool { - self.net_mutation_gain(delta_t, suffix_tokens, expected_reads, p_alive) > 0.0 + self.should_mutate_deep_with_write_multiplier( + delta_t, + suffix_tokens, + expected_reads, + p_alive, + None, + ) + } + + /// Variant of [`Self::should_mutate_deep`] with an explicit cache-write + /// multiplier. `None` uses the 5-minute default. + pub fn should_mutate_deep_with_write_multiplier( + &self, + delta_t: u32, + suffix_tokens: u32, + expected_reads: f32, + p_alive: f32, + write_multiplier: Option, + ) -> bool { + self.net_mutation_gain_with_write_multiplier( + delta_t, + suffix_tokens, + expected_reads, + p_alive, + write_multiplier, + ) > 0.0 } /// Remaining-read count at which a warm-cache (P_alive = 1) @@ -315,10 +372,21 @@ impl CompressionPolicy { /// session lasts N more turns"). Returns 0 when `delta_t` is 0 /// (no savings — callers gate on `delta_t > 0`). pub fn break_even_reads(&self, delta_t: u32, suffix_tokens: u32) -> f32 { + self.break_even_reads_with_write_multiplier(delta_t, suffix_tokens, None) + } + + /// Variant of [`Self::break_even_reads`] with an explicit cache-write + /// multiplier. `None` uses the 5-minute default. + pub fn break_even_reads_with_write_multiplier( + &self, + delta_t: u32, + suffix_tokens: u32, + write_multiplier: Option, + ) -> f32 { if delta_t == 0 { return 0.0; } - let w = CACHE_WRITE_MULTIPLIER; + let w = write_multiplier.unwrap_or(CACHE_WRITE_MULTIPLIER); let r = CACHE_READ_MULTIPLIER; ((w - r) / r) * ((suffix_tokens as f32) / (delta_t as f32)) } @@ -447,6 +515,29 @@ mod tests { assert!(p.should_mutate_deep(50_000, 10_000, 3.0, 1.0)); } + #[test] + fn net_gain_big_shave_shallow_suffix_is_loss_at_1h_tier() { + let p = CompressionPolicy::for_mode(AuthMode::Payg); + let default_gain = p.net_mutation_gain(50_000, 10_000, 3.0, 1.0); + assert!(default_gain > 0.0, "default gain = {default_gain}"); + + let gain = p.net_mutation_gain_with_write_multiplier( + 50_000, + 10_000, + 3.0, + 1.0, + Some(CACHE_WRITE_MULTIPLIER_1H), + ); + assert!((gain - (-4_000.0)).abs() < 1.0, "gain = {gain}"); + assert!(!p.should_mutate_deep_with_write_multiplier( + 50_000, + 10_000, + 3.0, + 1.0, + Some(CACHE_WRITE_MULTIPLIER_1H), + )); + } + #[test] fn net_gain_no_suffix_edit_profitable_with_reads_remaining() { // S = 0: nothing cached after the edit is invalidated. Warm-case diff --git a/headroom/proxy/handlers/anthropic.py b/headroom/proxy/handlers/anthropic.py index 5abb1b074..c41f861fd 100644 --- a/headroom/proxy/handlers/anthropic.py +++ b/headroom/proxy/handlers/anthropic.py @@ -1364,24 +1364,20 @@ class AnthropicHandlerMixin: # lossless whole-prefix recompaction instead of the byte-identical splice # (the splice preserves a dead cache) and skips the overlay replay. Both are # deterministic → the recompacted prefix re-caches byte-stable on warm turns. + from headroom.transforms.cold_prefix import ( + anthropic_cache_ttl_seconds, + is_cold_prefix, + ) + + # Resolve the authoritative request-level prompt-cache tier once. + # The same value drives cold-prefix handling and net-cost pricing. + _cc_ttl = anthropic_cache_ttl_seconds(model, original_client_messages, system_prompt) _cold_recompact_active = False if os.environ.get("HEADROOM_COLD_RECOMPACT", "").strip().lower() in ( "1", "true", "yes", ): - from headroom.transforms.cold_prefix import ( - anthropic_cache_ttl_seconds, - is_cold_prefix, - ) - - # Read CC's ACTUAL prompt-cache TTL (request cache_control.ttl + the - # DISABLE_/ENABLE_/FORCE_PROMPT_CACHING_* env controls) instead of the - # static 300s guess — a wrong TTL is exactly what busts a warm cache. - # None ⇒ caching is OFF (no cache to bust) ⇒ recompact every turn. - _cc_ttl = anthropic_cache_ttl_seconds( - model, original_client_messages, system_prompt - ) _cold_recompact_active = _cc_ttl is None or is_cold_prefix( prefix_tracker, ttl_seconds=_cc_ttl ) @@ -1580,6 +1576,7 @@ class AnthropicHandlerMixin: biases=biases, request_id=request_id, compression_policy=compression_policy, + cache_ttl_seconds=_cc_ttl, **proxy_pipeline_kwargs(self.config), ), lambda bg_result: comp_cache.update_from_result( @@ -1624,6 +1621,7 @@ class AnthropicHandlerMixin: biases=biases, request_id=request_id, compression_policy=compression_policy, + cache_ttl_seconds=_cc_ttl, skip_kompress=True, **proxy_pipeline_kwargs(self.config), ), @@ -1674,6 +1672,7 @@ class AnthropicHandlerMixin: biases=biases, request_id=request_id, compression_policy=compression_policy, + cache_ttl_seconds=_cc_ttl, **proxy_pipeline_kwargs(self.config), ), timeout=COMPRESSION_TIMEOUT_SECONDS, @@ -1715,6 +1714,7 @@ class AnthropicHandlerMixin: biases=biases, request_id=request_id, compression_policy=compression_policy, + cache_ttl_seconds=_cc_ttl, **proxy_pipeline_kwargs(self.config), ), timeout=COMPRESSION_TIMEOUT_SECONDS, @@ -1847,6 +1847,7 @@ class AnthropicHandlerMixin: biases=biases, request_id=request_id, compression_policy=compression_policy, + cache_ttl_seconds=_cc_ttl, **proxy_pipeline_kwargs(self.config), ), timeout=COMPRESSION_TIMEOUT_SECONDS, @@ -4544,6 +4545,8 @@ class AnthropicHandlerMixin: compressed_requests = [] pipeline_timing: dict[str, float] = {} + from headroom.transforms.cold_prefix import anthropic_cache_ttl_seconds + # Apply compression to each request in the batch for batch_req in requests_list: custom_id = batch_req.get("custom_id", "") @@ -4553,6 +4556,9 @@ class AnthropicHandlerMixin: messages = params.get("messages", []) original_messages = copy.deepcopy(messages) model = params.get("model", "unknown") + cache_ttl_seconds = anthropic_cache_ttl_seconds( + model, original_messages, params.get("system") + ) if not messages or not self.config.optimize: # No messages or optimization disabled - pass through unchanged @@ -4589,7 +4595,7 @@ class AnthropicHandlerMixin: # blocks every other request for the duration; a timeout # here is caught below and passes the item through. result = await self._run_compression_in_executor( - lambda messages=messages, model=model, context_limit=context_limit, frozen_message_count=frozen_message_count: ( + lambda messages=messages, model=model, context_limit=context_limit, frozen_message_count=frozen_message_count, cache_ttl_seconds=cache_ttl_seconds: ( self.anthropic_pipeline.apply( messages=messages, model=model, @@ -4597,6 +4603,7 @@ class AnthropicHandlerMixin: context=extract_user_query(messages), frozen_message_count=frozen_message_count, request_id=request_id, + cache_ttl_seconds=cache_ttl_seconds, **proxy_pipeline_kwargs(self.config), ) ), diff --git a/headroom/transforms/compression_policy.py b/headroom/transforms/compression_policy.py index faf54fa60..241db44d8 100644 --- a/headroom/transforms/compression_policy.py +++ b/headroom/transforms/compression_policy.py @@ -58,21 +58,35 @@ _MAX_LOSSY_RATIO_SUBSCRIPTION: float = 0.25 #: Anthropic prompt-cache write multiplier: a ``cache_creation`` token #: costs 1.25x a plain input token (5-minute TTL tier). Input to the #: net-cost mutation formula (#856). Mirrors the Rust ``pub const``. -#: ponytail: hardcoded to the 5m tier. A client on Anthropic's 1h cache -#: (ENABLE_PROMPT_CACHING_1H / cache_control.ttl="1h", which Headroom -#: preserves) writes at 2.0x, so its mutations are gated with a ~40% -#: under-stated write penalty. Harmless while the net-cost gate stays -#: default-off (HEADROOM_NET_COST_POLICY); thread the TTL from -#: cold_prefix.anthropic_cache_ttl_seconds through ContentRouter -> -#: net_mutation_gain if that gate is ever turned on. CACHE_WRITE_MULTIPLIER: float = 1.25 +#: Anthropic prompt-cache write multiplier for the 1-hour TTL tier. +CACHE_WRITE_MULTIPLIER_1H: float = 2.0 + #: Anthropic prompt-cache read multiplier: a ``cache_read`` token costs #: 0.1x a plain input token. Input to the net-cost mutation formula #: (#856). Mirrors the Rust ``pub const``. CACHE_READ_MULTIPLIER: float = 0.1 +def cache_write_multiplier_for_ttl(ttl_seconds: float | int | None) -> float: + """Return the cache-write multiplier for a prompt-cache TTL tier. + + The net-cost gate prefers an authoritative request-level TTL and falls + back to its environment setting when no request TTL is available. + Invalid and non-positive values retain the 5-minute default. + """ + if ttl_seconds is None: + return CACHE_WRITE_MULTIPLIER + try: + ttl = float(ttl_seconds) + except (TypeError, ValueError): + return CACHE_WRITE_MULTIPLIER + if not math.isfinite(ttl) or ttl <= 0.0: + return CACHE_WRITE_MULTIPLIER + return CACHE_WRITE_MULTIPLIER_1H if ttl >= 3600.0 else CACHE_WRITE_MULTIPLIER + + @dataclass(frozen=True, slots=True) class CompressionPolicy: """Per-auth-mode policy that downstream compression stages consult. @@ -136,6 +150,8 @@ class CompressionPolicy: suffix_tokens: int, expected_reads: float, p_alive: float, + *, + write_multiplier: float | None = None, ) -> float: """Net gain (in plain-input-token cost units) of a mutation that removes ``delta_t`` tokens from a message whose cached suffix is @@ -156,7 +172,7 @@ class CompressionPolicy: ``>= 0`` (NaN → 0), ``p_alive`` to ``[0, 1]`` (NaN → 1, the conservative full-penalty assumption — same as Rust). """ - w = CACHE_WRITE_MULTIPLIER + w = CACHE_WRITE_MULTIPLIER if write_multiplier is None else write_multiplier r = CACHE_READ_MULTIPLIER dt = max(0, delta_t) suffix = max(0, suffix_tokens) @@ -172,12 +188,29 @@ class CompressionPolicy: suffix_tokens: int, expected_reads: float, p_alive: float, + *, + write_multiplier: float | None = None, ) -> bool: """Decision form of :meth:`net_mutation_gain`: mutate iff the gain is strictly positive.""" - return self.net_mutation_gain(delta_t, suffix_tokens, expected_reads, p_alive) > 0.0 + return ( + self.net_mutation_gain( + delta_t, + suffix_tokens, + expected_reads, + p_alive, + write_multiplier=write_multiplier, + ) + > 0.0 + ) - def break_even_reads(self, delta_t: int, suffix_tokens: int) -> float: + def break_even_reads( + self, + delta_t: int, + suffix_tokens: int, + *, + write_multiplier: float | None = None, + ) -> float: """Remaining-read count at which a warm-cache (``p_alive=1``) mutation breaks even:: @@ -192,7 +225,7 @@ class CompressionPolicy: """ if delta_t <= 0: return 0.0 - w = CACHE_WRITE_MULTIPLIER + w = CACHE_WRITE_MULTIPLIER if write_multiplier is None else write_multiplier r = CACHE_READ_MULTIPLIER return ((w - r) / r) * (float(max(0, suffix_tokens)) / float(delta_t)) diff --git a/headroom/transforms/content_router.py b/headroom/transforms/content_router.py index 0ba64cb17..ed842ff36 100644 --- a/headroom/transforms/content_router.py +++ b/headroom/transforms/content_router.py @@ -65,6 +65,7 @@ from ..tokenizers.base import count_content_blocks from ..tokenizers.estimator import EstimatingTokenCounter from . import mixed_content as _mixed_content from .base import Transform +from .compression_policy import cache_write_multiplier_for_ttl from .compressor_registry import ( CompressInput, CompressorDescriptor, @@ -4529,6 +4530,7 @@ class ContentRouter(Transform): transforms_applied: list[str], batch_state: dict[str, int | None] | None = None, p_alive_override: float | None = None, + write_multiplier: float | None = None, ) -> bool: """Break-even gate for one candidate mutation (#856 P2, flag-gated). @@ -4613,7 +4615,15 @@ class ContentRouter(Transform): p_alive = _p_alive except ValueError: logger.warning("HEADROOM_NET_COST_P_ALIVE malformed; using 1.0") - gain = float(policy.net_mutation_gain(delta_t, suffix, reads, p_alive)) + gain = float( + policy.net_mutation_gain( + delta_t, + suffix, + reads, + p_alive, + write_multiplier=write_multiplier, + ) + ) allowed = gain > 0.0 logger.info( "NetCostPolicy slot=%d delta_t=%d suffix=%d reads=%.1f p_alive=%.2f " @@ -4976,7 +4986,22 @@ class ContentRouter(Transform): # env-constant behaviour. Derived once here (not per slot) — idle is a # per-request property, like frozen_message_count. netcost_p_alive_override: float | None = None + netcost_write_multiplier: float | None = None if netcost_enabled: + # Prefer the authoritative per-request prompt-cache TTL when the + # caller has one; retain the env setting for other providers and + # legacy callers. + request_ttl = kwargs.get("cache_ttl_seconds") + if request_ttl is None: + netcost_ttl = _net_cost_cache_ttl_seconds() + else: + try: + netcost_ttl = float(request_ttl) + except (TypeError, ValueError): + netcost_ttl = _net_cost_cache_ttl_seconds() + if not math.isfinite(netcost_ttl) or netcost_ttl <= 0.0: + netcost_ttl = _net_cost_cache_ttl_seconds() + netcost_write_multiplier = cache_write_multiplier_for_ttl(netcost_ttl) netcost_suffix_tokens = [0] * (num_messages + 1) for j in range(num_messages - 1, -1, -1): netcost_suffix_tokens[j] = netcost_suffix_tokens[j + 1] + _netcost_message_tokens( @@ -4989,8 +5014,7 @@ class ContentRouter(Transform): except (TypeError, ValueError): idle_f = None if idle_f is not None and math.isfinite(idle_f) and idle_f >= 0.0: - ttl = _net_cost_cache_ttl_seconds() - netcost_p_alive_override = max(0.0, 1.0 - idle_f / ttl) + netcost_p_alive_override = max(0.0, 1.0 - idle_f / netcost_ttl) # Tasks: list of (slot_index, content, context, bias, content_key) _PendingTask = tuple[int, str, str, float, int, bool] @@ -5294,6 +5318,7 @@ class ContentRouter(Transform): transforms_applied=transforms_applied, batch_state=netcost_batch_state, p_alive_override=netcost_p_alive_override, + write_multiplier=netcost_write_multiplier, ): # Net-cost gate: mutation would cost more in cache # invalidation than it saves — leave untouched. @@ -5471,6 +5496,7 @@ class ContentRouter(Transform): transforms_applied=transforms_applied, batch_state=netcost_batch_state, p_alive_override=netcost_p_alive_override, + write_multiplier=netcost_write_multiplier, ): result_slots[slot_idx] = message continue diff --git a/tests/test_compression_policy.py b/tests/test_compression_policy.py index cac6f02f9..e7ff143a5 100644 --- a/tests/test_compression_policy.py +++ b/tests/test_compression_policy.py @@ -19,6 +19,7 @@ import pytest from headroom.proxy.auth_mode import AuthMode from headroom.transforms.compression_policy import ( CompressionPolicy, + cache_write_multiplier_for_ttl, policy_default_payg, policy_for_mode, ) @@ -183,6 +184,26 @@ class TestNetCostFormula: assert abs(gain - 3_500.0) < 1.0 assert p.should_mutate_deep(50_000, 10_000, 3.0, 1.0) + one_hour_gain = p.net_mutation_gain( + 50_000, + 10_000, + 3.0, + 1.0, + write_multiplier=2.0, + ) + assert abs(one_hour_gain - (-4_000.0)) < 1.0 + assert not p.should_mutate_deep( + 50_000, + 10_000, + 3.0, + 1.0, + write_multiplier=2.0, + ) + + def test_cache_write_multiplier_follows_ttl_tier(self): + assert cache_write_multiplier_for_ttl(300) == 1.25 + assert cache_write_multiplier_for_ttl(3600) == 2.0 + def test_no_suffix_edit_profitable_with_reads_remaining(self): # S = 0: warm-case saving is the avoided rereads, dT*r*R — # positive whenever at least one read remains. At R=0 with a diff --git a/tests/test_netcost_gate.py b/tests/test_netcost_gate.py index 8a070eafe..3a6352164 100644 --- a/tests/test_netcost_gate.py +++ b/tests/test_netcost_gate.py @@ -73,6 +73,60 @@ class TestNetCostGate: assert _tool_slot_compressed(result, messages) assert not any(t.startswith("netcost:skip:") for t in result.transforms_applied) + def test_one_hour_ttl_prices_write_tier(self, router, tokenizer, monkeypatch): + # 5-minute pricing still admits this shave, while the larger 1h + # cache-write multiplier must turn the same candidate into a skip. + monkeypatch.setenv("HEADROOM_NET_COST_POLICY", "1") + messages = _messages(_tool_json(300), suffix_filler_words=1000) + + monkeypatch.delenv("HEADROOM_NET_COST_CACHE_TTL_SECONDS", raising=False) + five_minute = router.apply([dict(m) for m in messages], tokenizer) + assert _tool_slot_compressed(five_minute, messages) + + monkeypatch.setenv("HEADROOM_NET_COST_CACHE_TTL_SECONDS", "3600") + one_hour = router.apply([dict(m) for m in messages], tokenizer) + assert not _tool_slot_compressed(one_hour, messages) + assert any(t.startswith("netcost:skip:") for t in one_hour.transforms_applied) + + def test_request_one_hour_marker_overrides_env_fallback(self, router, tokenizer, monkeypatch): + monkeypatch.setenv("HEADROOM_NET_COST_POLICY", "1") + monkeypatch.delenv("HEADROOM_NET_COST_CACHE_TTL_SECONDS", raising=False) + for name in ( + "DISABLE_PROMPT_CACHING", + "DISABLE_PROMPT_CACHING_SONNET", + "ENABLE_PROMPT_CACHING_1H", + "FORCE_PROMPT_CACHING_5M", + ): + monkeypatch.delenv(name, raising=False) + + messages = _messages(_tool_json(300), suffix_filler_words=1000) + messages[0] = { + "role": "user", + "content": [ + { + "type": "text", + "text": "fetch the records", + "cache_control": {"type": "ephemeral", "ttl": "1h"}, + } + ], + } + + from headroom.transforms.cold_prefix import anthropic_cache_ttl_seconds + + request_ttl = anthropic_cache_ttl_seconds("claude-sonnet-4-6", messages) + assert request_ttl == 3600 + + five_minute = router.apply([dict(m) for m in messages], tokenizer) + assert _tool_slot_compressed(five_minute, messages) + + one_hour = router.apply( + [dict(m) for m in messages], + tokenizer, + cache_ttl_seconds=request_ttl, + ) + assert not _tool_slot_compressed(one_hour, messages) + assert any(t.startswith("netcost:skip:") for t in one_hour.transforms_applied) + def test_flag_on_gates_cached_results_too(self, router, tokenizer, monkeypatch): # First apply warms the result cache with the flag off; second apply # with the flag on must still gate the cache-hit path. From 96c25f518154536cf15f4e0b2d3fed80de6e67f6 Mon Sep 17 00:00:00 2001 From: Tejas Chopra Date: Sun, 16 Aug 2026 17:56:10 -0700 Subject: [PATCH 16/53] fix(cli): stop the macOS malloc re-exec replacing an embedder's process (#3064) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description **`main` cannot currently run its own test suite on macOS.** `pytest tests/` dies at roughly 2% with exit code 2 — no traceback, no summary, no failing test named. The pytest process is simply gone. Two independent defects, both landed today, both invisible to CI. ### 1. The macOS malloc re-exec replaces the calling process `headroom proxy` re-execs itself once on Darwin to apply two libmalloc knobs that libmalloc only reads before `main()` (#2820, PR #2879): ```python os.execv(sys.executable, [sys.executable, "-m", "headroom.cli", *sys.argv[1:]]) ``` That reconstruction is only faithful when the process really *is* the Headroom CLI. Ten-plus test files invoke the `proxy` command in-process through Click's `CliRunner`. There, `os.execv` replaces **pytest** with a Headroom process holding pytest's argv. Run with `-s`, the mechanism is visible: ``` tests/test_agent_savings.py Usage: python -m headroom.cli [OPTIONS] COMMAND [ARGS]... Error: No such command 'tests/test_agent_savings.py::test_proxy_cli_reads_agent_90_profile_env'. ``` Everything after the first such test — roughly 98% of the suite — never runs. The same hazard applies to any application embedding the CLI. **The documented kill switch does not help.** `tests/conftest.py:41` scrubs every `HEADROOM_*` variable for hermeticity, so `HEADROOM_MALLOC_TUNING` is deleted before the guard reads it. Only the private `_HEADROOM_MALLOC_TUNED` survives, because it starts with an underscore. **CI could not have caught this.** The tuning is Darwin-only, and while the repo *does* have macOS jobs (`macos-native-wrapper`, `wrap-native (macos-latest)`), neither runs the Python test suite — the `test` shards are `ubuntu-latest` only. So `sys.platform != "darwin"` returns first everywhere pytest actually runs. #2879 merged with 37 green checks. ### 2. A semantic merge conflict between two green PRs #3051 added `bind_scope(tags, request.scope)` at `gemini.py:325` and updated the three Gemini fakes it knew about. #3035 branched earlier and added a fourth `_FakeRequest` without `.scope`. Each was green against its own base; together they fail: ``` AttributeError: '_FakeRequest' object has no attribute 'scope' ``` Git merged both cleanly. Only running the suite on merged `main` surfaces it. ## 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 ## Changes Made - Added `_process_is_headroom_cli_entrypoint()`: the re-exec now verifies its own precondition — `argv[0]` must be the `headroom` console script or `headroom/cli/__main__.py`. - The embedded path returns **before** stamping `_HEADROOM_MALLOC_TUNED`, so a genuine CLI child inheriting the environment can still apply the tuning. - Gave the Gemini `_FakeRequest` the `.scope` every real Starlette `Request` carries. - `test_reexec_skips_when_operator_already_set_vars` now sets a realistic `argv[0]`, matching its sibling exec test. - New `tests/test_cli_proxy_malloc_reexec_guard.py` asserting the guard's logic on **every** platform, since no CI runner is macOS. ## Testing - [x] Unit tests pass - [x] Linting passes (ruff check + format) - [ ] Type checking passes (`uv run mypy headroom`) — not run - [x] New tests added for new functionality ### Test Output Before, on `main`: ```text $ .venv/bin/python -m pytest tests/ -q collected 11622 items / 8 skipped ... tests/test_agent_savings.py ............................ $ echo $? 2 ``` No summary line — the run does not end, it is replaced. After, on this branch: ```text $ .venv/bin/python -m pytest tests/ -q 3 failed, 11055 passed, 581 skipped, 6034 warnings in 303.69s (0:05:03) ``` All three remaining failures reproduce at `f9807fd6`, before today's merges, and are unrelated: | test | cause | |---|---| | `test_graceful_shutdown::test_run_server_installs_cancelled_error_filter` | full-suite ordering; passes in isolation (11 passed) | | `test_learn/test_integration::TestCodexIntegration::test_full_pipeline` | pre-existing | | `test_release_workflows::test_no_native_tls_in_wheel_build_tree` | requires `cargo`, absent on this host | ## Real Behavior Proof - Environment: macOS 15 (darwin 25.4.0), Python 3.12.13, arm64, real checkout of `main` at `ef7e07e0`. - Exact command / steps: bisected the crash to a single test, then to a single commit — `be5b26d8` (parent) exits 0, `6d87825f` (#2879) exits 2. Confirmed causation by temporarily replacing the `os.execv` line with `return`, which makes the test pass. Recovered the mechanism by running the crashing test with `-s`, which prints the Headroom CLI rejecting pytest's own argv. - Observed result: on `main` the suite cannot reach a summary; on this branch it completes with 11,055 passing. The two-file reproduction (`test_agent_savings.py` + `test_anthropic_beta_session_sticky.py`) goes from exit 2 to 62 passed. - Not tested: a real `headroom proxy` launch on macOS confirming libmalloc still receives the knobs after re-exec. The guard is covered by unit tests asserting `execv` is still called with `["-m", "headroom.cli", "proxy", "--port", "8787"]` for a console-script `argv[0]`, but I have not watched `vmmap` on a live proxy. **A macOS maintainer should confirm #2820's RSS fix still works end to end before this ships.** ## Runtime Rollout Safety - Rollout-managed feature(s): none. - Minimum rollout channel: N/A. - Stable/default behavior changed: no for a real CLI launch; the re-exec no longer fires when the CLI is invoked in-process, which was never intended to work. - Kill switch / disable path: `HEADROOM_MALLOC_TUNING=0` still disables the tuning outright. - Unsafe override required: none. - Qualification impact: none. - Rollback path: revert this commit — but that restores a `main` whose test suite cannot run on macOS. ## 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 - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective - [x] New and existing unit tests pass locally with my changes ## Additional Notes **This is my fault and worth recording.** I merged both #2879 and #3035 earlier today on the rule "approved + green CI". Both were genuinely approved and genuinely green. Neither was rebased onto current `main` first, and CI has no macOS runner, so green meant less than it appeared to. Two process gaps this exposes, neither of which this PR fixes: 1. **The Python test suite never runs on macOS.** The repo has macOS jobs (`macos-native-wrapper`, `wrap-native (macos-latest)`), but the `test` shards are `ubuntu-latest` only, so Darwin-only code paths — the allocator tuning is one, `wrap` has others — are unreachable by pytest in CI. Even a reduced macOS shard would have caught this. 2. **Nothing requires a PR to be current with `main` before merging.** Both defects here are cross-PR interactions that no per-PR check can see. Enabling "require branches to be up to date before merging" on `main` would have forced a rebase and surfaced the Gemini fake. I would suggest an issue for each rather than folding them in here. Co-authored-by: Tejas Chopra --- headroom/cli/proxy.py | 21 ++++ tests/test_cli_proxy_malloc_reexec_guard.py | 103 ++++++++++++++++++++ tests/test_gemini_ccr_continuation_usage.py | 4 + tests/test_malloc_tuning.py | 4 + 4 files changed, 132 insertions(+) create mode 100644 tests/test_cli_proxy_malloc_reexec_guard.py diff --git a/headroom/cli/proxy.py b/headroom/cli/proxy.py index 9e9309dd8..bbdba0fed 100644 --- a/headroom/cli/proxy.py +++ b/headroom/cli/proxy.py @@ -5,6 +5,7 @@ import os import sys import warnings from importlib import import_module +from pathlib import Path from typing import Any, Literal, cast import click @@ -125,6 +126,24 @@ _MALLOC_TUNING = { } +def _process_is_headroom_cli_entrypoint() -> bool: + """Is this process the Headroom CLI itself, rather than an embedder? + + ``_reexec_with_malloc_tuning`` rebuilds the command line as + ``python -m headroom.cli ``. That is only a faithful + reconstruction when the process really was started as the Headroom CLI. If + something else invoked the ``proxy`` command in-process — pytest's + ``CliRunner``, an embedding application, ``runpy`` — then ``argv[1:]`` + belongs to *that* program, and ``os.execv`` would replace it with a Headroom + process parsing arguments that were never meant for us. + """ + argv0 = Path(sys.argv[0] or "") + if argv0.name in {"headroom", "headroom.exe"}: + return True + # `python -m headroom.cli` sets argv[0] to .../headroom/cli/__main__.py. + return argv0.parts[-3:] == ("headroom", "cli", "__main__.py") + + def _reexec_with_malloc_tuning() -> None: if sys.platform != "darwin": return @@ -132,6 +151,8 @@ def _reexec_with_malloc_tuning() -> None: return if os.environ.get("_HEADROOM_MALLOC_TUNED") == "1": return + if not _process_is_headroom_cli_entrypoint(): + return missing = {k: v for k, v in _MALLOC_TUNING.items() if k not in os.environ} # Set the loop guard before the re-exec so the replacement process (which # inherits this environment) skips this path instead of re-execing forever. diff --git a/tests/test_cli_proxy_malloc_reexec_guard.py b/tests/test_cli_proxy_malloc_reexec_guard.py new file mode 100644 index 000000000..086888cee --- /dev/null +++ b/tests/test_cli_proxy_malloc_reexec_guard.py @@ -0,0 +1,103 @@ +"""The macOS malloc re-exec must never replace an embedder's process. + +``headroom proxy`` re-execs itself once on Darwin to apply two libmalloc knobs +that libmalloc only reads before ``main()`` (#2820). The re-exec rebuilds the +command as ``python -m headroom.cli ``, which is only a faithful +reconstruction when this process really is the Headroom CLI. + +When the ``proxy`` command is invoked *in-process* — pytest's ``CliRunner``, an +embedding application — ``os.execv`` replaces that process instead. The whole +pytest run is destroyed mid-suite with no traceback, and the replacement +Headroom process is handed pytest's own argv. + +CI cannot catch this: the tuning is Darwin-only and no CI runner is macOS, so +these tests assert the guard's *logic* on every platform rather than relying on +the re-exec being reachable. +""" + +from __future__ import annotations + +import sys + +import pytest + +from headroom.cli import proxy as proxy_cli + + +@pytest.mark.parametrize( + "argv0", + [ + "/usr/local/bin/headroom", + "/opt/homebrew/bin/headroom", + ], +) +def test_console_script_is_recognised_as_the_entrypoint( + argv0: str, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(sys, "argv", [argv0, "proxy"]) + assert proxy_cli._process_is_headroom_cli_entrypoint() is True + + +def test_module_invocation_is_recognised_as_the_entrypoint( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + sys, "argv", ["/venv/lib/python3.12/site-packages/headroom/cli/__main__.py", "proxy"] + ) + assert proxy_cli._process_is_headroom_cli_entrypoint() is True + + +@pytest.mark.parametrize( + "argv0", + [ + "/venv/bin/pytest", + # `python -m pytest` — same basename as a module run, different package. + "/venv/lib/python3.12/site-packages/pytest/__main__.py", + "/usr/bin/uvicorn", + "", + ], +) +def test_embedders_are_not_mistaken_for_the_entrypoint( + argv0: str, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(sys, "argv", [argv0, "proxy"]) + assert proxy_cli._process_is_headroom_cli_entrypoint() is False + + +def test_reexec_does_not_exec_when_embedded(monkeypatch: pytest.MonkeyPatch) -> None: + """The end-to-end guard: no execv when another program owns the process.""" + monkeypatch.setattr(sys, "platform", "darwin") + monkeypatch.setattr(sys, "argv", ["/venv/bin/pytest", "tests/"]) + monkeypatch.delenv("_HEADROOM_MALLOC_TUNED", raising=False) + for key in proxy_cli._MALLOC_TUNING: + monkeypatch.delenv(key, raising=False) + + calls: list[object] = [] + monkeypatch.setattr(proxy_cli.os, "execv", lambda *a, **k: calls.append(a)) + + proxy_cli._reexec_with_malloc_tuning() + + assert calls == [] + # The loop guard must not be set either: this process never applied the + # tuning, so a genuine CLI child inheriting the env must still be free to. + assert "_HEADROOM_MALLOC_TUNED" not in proxy_cli.os.environ + + +def test_reexec_still_execs_for_a_real_cli_launch(monkeypatch: pytest.MonkeyPatch) -> None: + """The fix must not disable the feature it is guarding.""" + monkeypatch.setattr(sys, "platform", "darwin") + monkeypatch.setattr(sys, "argv", ["/usr/local/bin/headroom", "proxy", "--port", "8787"]) + monkeypatch.delenv("_HEADROOM_MALLOC_TUNED", raising=False) + for key in proxy_cli._MALLOC_TUNING: + monkeypatch.delenv(key, raising=False) + + calls: list[tuple] = [] + monkeypatch.setattr(proxy_cli.os, "execv", lambda *a, **k: calls.append(a)) + + proxy_cli._reexec_with_malloc_tuning() + + assert len(calls) == 1 + _executable, argv = calls[0] + assert argv[1:] == ["-m", "headroom.cli", "proxy", "--port", "8787"] + for key, value in proxy_cli._MALLOC_TUNING.items(): + assert proxy_cli.os.environ[key] == value diff --git a/tests/test_gemini_ccr_continuation_usage.py b/tests/test_gemini_ccr_continuation_usage.py index 8cf0649a8..8028bd1df 100644 --- a/tests/test_gemini_ccr_continuation_usage.py +++ b/tests/test_gemini_ccr_continuation_usage.py @@ -25,6 +25,10 @@ class _FakeRequest: self.headers: dict[str, str] = {} self.query_params: dict[str, str] = {} self.url = SimpleNamespace(path="/v1beta/models/gemini-pro:generateContent", query="") + # Every real Starlette Request carries a scope, and the Gemini handler + # binds the savings-attribution ledger to it (#3051). Without this the + # double is a shape that cannot occur in production. + self.scope: dict = {"type": "http", "method": "POST"} class _CcrToolCallResponse: diff --git a/tests/test_malloc_tuning.py b/tests/test_malloc_tuning.py index 6b490242b..2c30b2731 100644 --- a/tests/test_malloc_tuning.py +++ b/tests/test_malloc_tuning.py @@ -65,6 +65,10 @@ def test_reexec_guard_prevents_loop(monkeypatch): def test_reexec_skips_when_operator_already_set_vars(monkeypatch): monkeypatch.setattr(proxy_cli.sys, "platform", "darwin") + # A real CLI launch, like the sibling exec test below: the tuning path is + # only reachable when this process is the Headroom CLI entrypoint, and + # under pytest argv[0] is pytest's own. + monkeypatch.setattr(proxy_cli.sys, "argv", ["headroom", "proxy"]) monkeypatch.setenv("MallocAggressiveMadvise", "1") monkeypatch.setenv("MallocLargeCache", "0") rec: dict = {} From a6ab359a5d8d67a85f734131b55dbcef768a821a Mon Sep 17 00:00:00 2001 From: Tejas Chopra Date: Sun, 16 Aug 2026 18:23:27 -0700 Subject: [PATCH 17/53] fix(proxy): guard feedback endpoints and add CSRF checks to loopback writes (#3060) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description `#2927` brought eight telemetry/TOIN routes under `require_loopback`. Two structurally identical siblings 60 lines above them were missed: ``` GET /v1/feedback GET /v1/feedback/{tool_name} ``` Neither is an aggregate-counter endpoint. Their `common_queries` / `queried_fields` keys are built verbatim from agent search text — `event.query.lower()` at `headroom/cache/compression_feedback.py:311` — and up to 100 queries are retained per tool, keyed by real tool name. Under the shipped Docker default (`--host 0.0.0.0`) a LAN peer gets a 404 from `/v1/toin/patterns` and the query corpus from `/v1/feedback`. Separately, five mutating loopback-only routes had no CSRF guard. `require_loopback` cannot stop that attack: a remote page POSTing to a known `127.0.0.1` URL with `Content-Type: text/plain` is a CORS *simple* request, so there is no preflight, and the browser still sends the real loopback `Host` header — both of the guard's gates pass. Only `Origin` betrays the caller, and only `require_same_origin` inspects it. That guard already existed at `headroom/proxy/loopback_guard.py:219` and was applied solely to `/settings`. Closes #2927 (completes it — the original eight routes were already done). ## 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 ## Changes Made - Added `Depends(_require_loopback)` to `/v1/feedback` and `/v1/feedback/{tool_name}`. - Stripped `common_queries` / `queried_fields` from both response bodies even on the guarded path, matching the whitelist discipline #2930 applied at `server.py:4909-4916`. - Added `_feedback_stats_without_query_text()` so the scrub happens at the HTTP boundary; `get_stats()` is unchanged and in-process compression decisions are untouched. - Added `Depends(_require_same_origin)` to `POST /stats/reset`, `/cache/clear`, `/v1/retrieve`, `/v1/telemetry/import`, `/admin/runtime-env`. ## Testing - [x] Unit tests pass - [x] Linting passes (ruff check + format) - [ ] Type checking passes (`uv run mypy headroom`) — not run - [x] New tests added for new functionality ### Test Output ```text $ .venv/bin/python -m pytest tests/test_proxy_loopback_gating.py -q 99 passed, 1 warning in 4.18s $ .venv/bin/python -m pytest tests/test_proxy_settings_endpoints.py tests/test_telemetry.py \ tests/test_proxy_cache_telemetry.py tests/test_proxy_telemetry_env.py tests/test_telemetry_context.py -q 101 passed, 1 warning in 3.67s $ .venv/bin/python -m pytest tests/test_critical_fixes.py tests/test_compression_store.py \ tests/test_toin_full_integration.py tests/test_ccr_feedback.py tests/test_critical_gaps.py \ tests/test_proxy_ccr.py tests/test_proxy_dashboard_stats_cache.py -q 168 passed, 4 skipped, 3 warnings in 13.18s $ .venv/bin/python -m ruff check headroom/proxy/server.py tests/test_proxy_loopback_gating.py All checks passed! ``` Against the parent commit (`git stash` of `server.py` only), all 14 new tests fail: ```text FAILED test_non_loopback_caller_gets_404[get-/v1/feedback] FAILED test_non_loopback_caller_gets_404[get-/v1/feedback/example] FAILED test_cross_origin_post_rejected[/stats/reset] FAILED test_cross_origin_post_rejected[/cache/clear] FAILED test_cross_origin_post_rejected[/v1/retrieve] FAILED test_cross_origin_post_rejected[/v1/telemetry/import] FAILED test_cross_origin_post_rejected[/admin/runtime-env] FAILED test_sandboxed_null_origin_post_rejected[...] (5 cases) FAILED test_feedback_stats_exclude_agent_query_text FAILED test_feedback_tool_detail_excludes_agent_query_text 14 failed, 85 passed ``` ## Real Behavior Proof - Environment: macOS 15 (darwin 25.4.0), Python 3.12.13, this branch, FastAPI `TestClient` against the real `create_app` proxy. - Exact command / steps: drive `/v1/feedback` with a feedback singleton whose `common_queries` contains `"find the customer api key rotation runbook"`, once from a non-loopback peer and once from a loopback peer; POST each of the five mutating routes with `Origin: https://attacker.example` and `Content-Type: text/plain`. - Observed result: non-loopback callers now receive 404 where they previously received 200 with the query corpus; on the loopback path the response no longer contains `common_queries`, `queried_fields`, or the substring `customer api key rotation`, while `retrieval_rate` still resolves to `0.25`. All five cross-origin POSTs return 403; the same requests with no `Origin`, or with `Origin: http://127.0.0.1`, are unaffected. - Not tested: a real browser issuing the cross-origin POST (the CORS simple-request shape is reproduced at the header level, not in a browser), and a live non-loopback deployment. ## Runtime Rollout Safety - Rollout-managed feature(s): none. - Minimum rollout channel: N/A. - Stable/default behavior changed: yes — `/v1/feedback*` now 404 for non-loopback callers and no longer return query text; five POST routes reject cross-origin browser callers. - Kill switch / disable path: none; these are security guards and are deliberately not configurable. - Unsafe override required: none. - Qualification impact: none. - Rollback path: revert this commit. ## 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 - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective - [x] New and existing unit tests pass locally with my changes ## Additional Notes `/stats` also calls `feedback.get_stats()` (`server.py:3896`) but only reads aggregate counters at `:4303-4311` and never emits query text — verified, and the reason the scrub is applied at the HTTP boundary rather than inside `get_stats()`. The five POST routes are strictly loopback-gated, so the trusted-dashboard wrapper `/settings` uses is unnecessary here; for a loopback caller that wrapper falls through to the same raw guard. No dashboard asset calls them, and the TypeScript SDK (`sdk/typescript/src/client.ts:322,443`) sends no `Origin` header, which the guard passes through unchanged. Co-authored-by: Tejas Chopra --- headroom/proxy/server.py | 73 ++++++++++++--- tests/test_proxy_loopback_gating.py | 135 ++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+), 12 deletions(-) diff --git a/headroom/proxy/server.py b/headroom/proxy/server.py index c3a137b5d..990504597 100644 --- a/headroom/proxy/server.py +++ b/headroom/proxy/server.py @@ -2438,6 +2438,35 @@ def _normalized_http_origin(value: str) -> tuple[str, str, int] | None: return scheme, parsed.hostname.lower(), port +#: Feedback-pattern keys built verbatim from agent query text. They are useful +#: in-process for compression decisions but must never reach an HTTP response — +#: same privacy contract the TOIN endpoints were brought under in #2926/#2927. +_FEEDBACK_QUERY_TEXT_KEYS = ("common_queries", "queried_fields") + + +def _feedback_stats_without_query_text(stats: dict[str, Any]) -> dict[str, Any]: + """Return ``stats`` with per-tool query text stripped from ``tool_patterns``. + + Copies only the levels it edits; the aggregate counters are shared with the + caller's dict, which is fine because they are scalars. + """ + + patterns = stats.get("tool_patterns") + if not isinstance(patterns, dict): + return stats + + scrubbed: dict[str, Any] = {} + for name, pattern in patterns.items(): + if isinstance(pattern, dict): + scrubbed[name] = { + key: value for key, value in pattern.items() if key not in _FEEDBACK_QUERY_TEXT_KEYS + } + else: + scrubbed[name] = pattern + + return {**stats, "tool_patterns": scrubbed} + + _is_known_websocket_callback_failure = is_known_websocket_callback_failure @@ -3506,7 +3535,10 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI: payload["runtime"] = _runtime_payload() return JSONResponse(status_code=200, content=payload) - @app.post("/admin/runtime-env", dependencies=[Depends(_require_loopback)]) + @app.post( + "/admin/runtime-env", + dependencies=[Depends(_require_loopback), Depends(_require_same_origin)], + ) async def admin_runtime_env(request: Request): """Hot-reload live env knobs (the output-shaper family, the ast-grep read threshold) without restarting the proxy. @@ -4447,7 +4479,10 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI: payload["persistence"] = {**persistence, "error": None} return payload - @app.post("/stats/reset", dependencies=[Depends(_require_loopback)]) + @app.post( + "/stats/reset", + dependencies=[Depends(_require_loopback), Depends(_require_same_origin)], + ) async def stats_reset(): """Reset in-memory proxy stats for local test/debug isolation.""" await proxy.metrics.reset_runtime() @@ -4584,7 +4619,10 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI: report = tracker.get_report() return report.to_dict() - @app.post("/cache/clear", dependencies=[Depends(_require_loopback)]) + @app.post( + "/cache/clear", + dependencies=[Depends(_require_loopback), Depends(_require_same_origin)], + ) async def clear_cache(): """Clear the response cache. @@ -4600,7 +4638,10 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI: return {"status": "cache disabled"} # CCR (Compress-Cache-Retrieve) endpoints - @app.post("/v1/retrieve", dependencies=[Depends(_require_loopback)]) + @app.post( + "/v1/retrieve", + dependencies=[Depends(_require_loopback), Depends(_require_same_origin)], + ) async def ccr_retrieve(request: Request): """Retrieve original content from CCR compression cache. @@ -4669,21 +4710,25 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI: ], } - @app.get("/v1/feedback") + @app.get("/v1/feedback", dependencies=[Depends(_require_loopback)]) async def ccr_feedback(): """Get CCR feedback loop statistics and learned patterns. This endpoint exposes the feedback loop's learned patterns for monitoring and debugging. It shows: - Per-tool retrieval rates (high = compress less aggressively) - - Common search queries per tool - - Queried fields (suggest what to preserve) + - Aggregate compression/retrieval counters per tool Use this to understand how well compression is working and whether the feedback loop is adjusting appropriately. + + Loopback-guarded and query-text free for the same reason as the + telemetry and TOIN endpoints (#2926/#2927): ``common_queries`` and + ``queried_fields`` are built verbatim from agent search queries, so + they stay out of the response even on the guarded path. """ feedback = get_compression_feedback() - stats = feedback.get_stats() + stats = _feedback_stats_without_query_text(feedback.get_stats()) return { "feedback": stats, "hints_example": { @@ -4702,12 +4747,15 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI: }, } - @app.get("/v1/feedback/{tool_name}") + @app.get("/v1/feedback/{tool_name}", dependencies=[Depends(_require_loopback)]) async def ccr_feedback_for_tool(tool_name: str): """Get compression hints for a specific tool. Returns feedback-based hints that would be used for compressing this tool's output. + + Loopback-guarded, and the pattern block excludes ``common_queries`` + and ``queried_fields`` — both are raw agent query text (#2926/#2927). """ feedback = get_compression_feedback() hints = feedback.get_compression_hints(tool_name) @@ -4730,8 +4778,6 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI: "retrieval_rate": patterns.retrieval_rate if patterns else 0.0, "full_retrieval_rate": patterns.full_retrieval_rate if patterns else 0.0, "search_rate": patterns.search_rate if patterns else 0.0, - "common_queries": list(patterns.common_queries.keys())[:10] if patterns else [], - "queried_fields": list(patterns.queried_fields.keys())[:10] if patterns else [], } if patterns else None, @@ -4777,7 +4823,10 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI: telemetry = get_telemetry_collector() return telemetry.export_stats() - @app.post("/v1/telemetry/import", dependencies=[Depends(_require_loopback)]) + @app.post( + "/v1/telemetry/import", + dependencies=[Depends(_require_loopback), Depends(_require_same_origin)], + ) async def telemetry_import(request: Request): """Import telemetry data from another source. diff --git a/tests/test_proxy_loopback_gating.py b/tests/test_proxy_loopback_gating.py index 5e9a1c9cb..49381d1ef 100644 --- a/tests/test_proxy_loopback_gating.py +++ b/tests/test_proxy_loopback_gating.py @@ -15,6 +15,7 @@ from fastapi import FastAPI from fastapi.testclient import TestClient from headroom.cache.backends import InMemoryBackend +from headroom.cache.compression_feedback import CompressionHints from headroom.cache.compression_store import get_compression_store, reset_compression_store from headroom.proxy.loopback_guard import is_ip_literal_host_header from headroom.proxy.server import ProxyConfig, create_app @@ -30,6 +31,11 @@ GATED = [ ("get", "/v1/toin/stats"), ("get", "/v1/toin/patterns"), ("get", "/v1/toin/pattern/example"), + # #2927 guarded the eight telemetry/TOIN routes the issue enumerated but + # left these two siblings open, and their payload carries the same raw + # agent query text (``common_queries``, built from ``event.query``). + ("get", "/v1/feedback"), + ("get", "/v1/feedback/example"), ] @@ -119,6 +125,135 @@ def test_toin_pattern_detail_whitelists_learned_payload(monkeypatch: pytest.Monk } +# Mutating routes reachable from loopback. `require_loopback` cannot stop a +# remote page from POSTing to a known 127.0.0.1 URL: a "simple" cross-origin +# request (Content-Type: text/plain carrying JSON) skips preflight, and the +# browser still sends the real loopback Host header. Only `Origin` betrays the +# attacker, and only `require_same_origin` inspects it. +CSRF_GUARDED = [ + "/stats/reset", + "/cache/clear", + "/v1/retrieve", + "/v1/telemetry/import", + "/admin/runtime-env", +] + + +@pytest.mark.parametrize("path", CSRF_GUARDED) +def test_cross_origin_post_rejected(path: str) -> None: + resp = _loopback_client().post( + path, + headers={"Origin": "https://attacker.example", "Content-Type": "text/plain"}, + content="{}", + ) + assert resp.status_code == 403, resp.text + + +@pytest.mark.parametrize("path", CSRF_GUARDED) +def test_sandboxed_null_origin_post_rejected(path: str) -> None: + # A sandboxed iframe or file:// page sends the opaque literal "null". + resp = _loopback_client().post( + path, + headers={"Origin": "null", "Content-Type": "text/plain"}, + content="{}", + ) + assert resp.status_code == 403, resp.text + + +@pytest.mark.parametrize("path", CSRF_GUARDED) +def test_loopback_origin_post_allowed(path: str) -> None: + # The local dashboard is same-origin on loopback and must keep working. + resp = _loopback_client().post( + path, + headers={"Origin": "http://127.0.0.1"}, + json={}, + ) + assert resp.status_code != 403, resp.text + + +@pytest.mark.parametrize("path", CSRF_GUARDED) +def test_originless_post_allowed(path: str) -> None: + # CLI tools and the TypeScript SDK send no Origin header at all; the guard + # must pass them through or it breaks every non-browser client. + resp = _loopback_client().post(path, json={}) + assert resp.status_code != 403, resp.text + + +def _feedback_with_query_text(): + """A feedback singleton whose patterns carry raw agent query text.""" + + class FakePattern: + total_compressions = 8 + total_retrievals = 2 + retrieval_rate = 0.25 + full_retrieval_rate = 0.1 + search_rate = 0.5 + common_queries = {"find the customer api key rotation runbook": 3} + queried_fields = {"internal_field_name": 2} + + class FakeFeedback: + def get_stats(self): + return { + "total_compressions": 8, + "total_retrievals": 2, + "global_retrieval_rate": 0.25, + "tools_tracked": 1, + "tool_patterns": { + "Grep": { + "compressions": 8, + "retrievals": 2, + "retrieval_rate": 0.25, + "full_rate": 0.1, + "search_rate": 0.5, + "common_queries": ["find the customer api key rotation runbook"], + "queried_fields": ["internal_field_name"], + } + }, + } + + def get_compression_hints(self, tool_name): + # The real implementation is annotated ``-> CompressionHints`` and + # always returns one, so the double must too. + return CompressionHints() + + def get_all_patterns(self): + return {"Grep": FakePattern()} + + return FakeFeedback() + + +def test_feedback_stats_exclude_agent_query_text(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "headroom.proxy.server.get_compression_feedback", + _feedback_with_query_text, + ) + response = _loopback_client().get("/v1/feedback") + + assert response.status_code == 200 + pattern = response.json()["feedback"]["tool_patterns"]["Grep"] + assert "common_queries" not in pattern + assert "queried_fields" not in pattern + # The aggregate counters the endpoint exists to expose still survive. + assert pattern["retrieval_rate"] == 0.25 + assert "customer api key rotation" not in response.text + + +def test_feedback_tool_detail_excludes_agent_query_text(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "headroom.proxy.server.get_compression_feedback", + _feedback_with_query_text, + ) + response = _loopback_client().get("/v1/feedback/Grep") + + assert response.status_code == 200 + pattern = response.json()["pattern"] + assert "common_queries" not in pattern + assert "queried_fields" not in pattern + assert pattern["retrieval_rate"] == 0.25 + assert "customer api key rotation" not in response.text + assert "internal_field_name" not in response.text + + # CCR data endpoints — cached session content, gated to 404 off-loopback (#1227). def test_stats_lifetime_route_uses_dashboard_metadata_access_policy( monkeypatch: pytest.MonkeyPatch, From 481e0b83d5393419b27b17d95767104c7c1bda26 Mon Sep 17 00:00:00 2001 From: Tejas Chopra Date: Sun, 16 Aug 2026 19:05:29 -0700 Subject: [PATCH 18/53] fix(docker): publish compose ports on loopback only (#3061) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description `docker compose up -d` published every service on `0.0.0.0`, and none of the three authenticates an inbound caller by default: | port | service | default auth | |---|---|---| | 8787 | proxy | `/v1/*` data plane open unless `HEADROOM_PROXY_TOKEN` is set | | 6333/6334 | Qdrant | **none at all** — holds embeddings derived from prompts | | 7474/7687 | Neo4j | `NEO4J_AUTH` falls back to `neo4j/devpassword`, published in this file | So the shipped default handed any peer on the surrounding network a relay through the proxy plus direct read/write on the vector and graph stores built from the operator's own prompt content. The proxy already warns about exactly this shape at `headroom/proxy/server.py:3289` — the compose file just never took its own advice. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [x] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update ## Changes Made - Pinned all five published ports to `127.0.0.1`. - Documented in the file header how to expose the proxy deliberately, pairing the port override with `HEADROOM_PROXY_TOKEN` rather than leaving that implicit. - Added a commented `HEADROOM_PROXY_TOKEN` entry to the proxy service environment. - Added a regression test asserting every published port names a loopback host IP. ## Testing - [x] Unit tests pass - [x] Linting passes (ruff check + format) - [ ] Type checking passes — N/A (YAML + test only) - [x] New tests added for new functionality ### Test Output ```text $ .venv/bin/python -m pytest tests/test_docker_compose_persistence.py -q 3 passed in 0.14s $ docker compose -f docker-compose.yml config # validates headroom-proxy host_ip=127.0.0.1 published=8787 -> 8787 neo4j host_ip=127.0.0.1 published=7474 -> 7474 neo4j host_ip=127.0.0.1 published=7687 -> 7687 qdrant host_ip=127.0.0.1 published=6333 -> 6333 qdrant host_ip=127.0.0.1 published=6334 -> 6334 ``` Against the parent commit: ```text FAILED test_top_level_compose_publishes_only_to_loopback E AssertionError: headroom-proxy: port '8787:8787' publishes on all interfaces ``` ## Real Behavior Proof - Environment: macOS 15 (darwin 25.4.0), Docker Compose v2 available locally. - Exact command / steps: `docker compose -f docker-compose.yml config --format json` before and after, comparing the resolved `host_ip` on every published port. - Observed result: before, no port carried a `host_ip` (Docker binds `0.0.0.0`); after, all five resolve to `host_ip=127.0.0.1`. The compose file still validates. - Not tested: bringing the stack up and probing the ports from a second machine on the LAN — the assertion is made against Docker's own resolved configuration rather than a live two-host network. ## Runtime Rollout Safety - Rollout-managed feature(s): none. - Minimum rollout channel: N/A. - Stable/default behavior changed: yes — the compose stack is no longer reachable from other machines by default. - Kill switch / disable path: override `ports:` in a `docker-compose.override.yml`; the header documents this and pairs it with `HEADROOM_PROXY_TOKEN`. - Unsafe override required: none. - Qualification impact: none. - Rollback path: revert this commit. ## 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 - [x] I have made corresponding changes to the documentation (the compose header) - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective - [x] New and existing unit tests pass locally with my changes ## Additional Notes **This is a deliberate breaking change for one workflow**: anyone reaching the compose proxy from another machine will need to override `ports:`. That is exactly the configuration that was unsafe, so it should break loudly rather than silently. `http://localhost:8787` from the host is unchanged, the container still listens on `0.0.0.0` internally, and service-to-service traffic on the compose network is unaffected. Scope note: I fixed all three services rather than only the proxy. Closing 8787 while leaving an unauthenticated Qdrant and a default-password Neo4j published on `0.0.0.0` would not have improved the security posture. Co-authored-by: Tejas Chopra --- docker-compose.yml | 35 ++++++++++++++++++++---- tests/test_docker_compose_persistence.py | 31 +++++++++++++++++++++ 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 204aecb81..75c836b17 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,10 +13,23 @@ # on its own (`docker run -p 8787:8787 ghcr.io/headroomlabs-ai/headroom`); the two # database services below are only required for the memory/relevance features. # -# Ports exposed on the host: +# Ports published on the host — all bound to 127.0.0.1 (this machine only): # 8787 proxy (OpenAI-compatible endpoint) # 6333 Qdrant REST 6334 Qdrant gRPC # 7474 Neo4j Browser 7687 Neo4j Bolt +# +# None of these three services authenticates inbound callers by default: the +# proxy's /v1/* data plane is open unless HEADROOM_PROXY_TOKEN is set, Qdrant +# has no API key, and Neo4j falls back to a published dev password. Publishing +# them on 0.0.0.0 therefore hands any peer on your network a relay through the +# proxy plus direct read/write on the embeddings and graph derived from your +# prompts. They are bound to loopback so that `docker compose up -d` is safe on +# a shared or untrusted network. +# +# To reach the proxy from another machine, publish it deliberately AND require +# a token — never one without the other: +# HEADROOM_PROXY_TOKEN=$(openssl rand -hex 32) # put this in .env +# ports: ["8787:8787"] # override in a compose override file # ============================================================================= services: @@ -38,8 +51,14 @@ services: # if you want to use a custom OpenAI-compatible API endpoint, # uncomment and set the following line with the desired URL # - OPENAI_TARGET_API_URL=https://api.x.ai + # Required before publishing this port beyond loopback: without it the + # /v1/* data plane accepts unauthenticated callers. + # - HEADROOM_PROXY_TOKEN=${HEADROOM_PROXY_TOKEN} ports: - - "8787:8787" + # Loopback-only. The container still listens on 0.0.0.0 (above) so the + # other compose services can reach it by name; this line controls only + # which host interfaces the port is published on. + - "127.0.0.1:8787:8787" volumes: - headroom_workspace:/home/nonroot/.headroom # Readiness probe: the orchestrator polls /readyz so dependents and @@ -62,8 +81,10 @@ services: qdrant: image: qdrant/qdrant:v1.17.1 ports: - - "6333:6333" # REST API - - "6334:6334" # gRPC + # Loopback-only: Qdrant runs unauthenticated here and holds embeddings + # derived from your prompts. + - "127.0.0.1:6333:6333" # REST API + - "127.0.0.1:6334:6334" # gRPC # Named volume keeps the vector index across container restarts/recreates. volumes: - qdrant_data:/qdrant/storage @@ -75,8 +96,10 @@ services: neo4j: image: neo4j:5.26 ports: - - "7474:7474" # HTTP (Browser) - - "7687:7687" # Bolt + # Loopback-only: NEO4J_AUTH below defaults to a password published in + # this file, so an exposed Bolt port is an open database. + - "127.0.0.1:7474:7474" # HTTP (Browser) + - "127.0.0.1:7687:7687" # Bolt # Named volume persists the graph data across container restarts/recreates. volumes: - neo4j_data:/data diff --git a/tests/test_docker_compose_persistence.py b/tests/test_docker_compose_persistence.py index 16f8bcd76..1841fe5d2 100644 --- a/tests/test_docker_compose_persistence.py +++ b/tests/test_docker_compose_persistence.py @@ -4,9 +4,40 @@ from __future__ import annotations from pathlib import Path +import yaml + ROOT = Path(__file__).resolve().parents[1] +def test_top_level_compose_publishes_only_to_loopback() -> None: + """Every published port must name an explicit loopback host IP. + + None of the three services authenticates by default: the proxy's /v1/* + data plane is open without HEADROOM_PROXY_TOKEN, Qdrant has no API key, + and NEO4J_AUTH falls back to a password published in the compose file. A + bare "8787:8787" binds 0.0.0.0 on the host, so `docker compose up -d` on a + shared network would expose all three. + """ + compose = yaml.safe_load((ROOT / "docker-compose.yml").read_text(encoding="utf-8")) + + published = [ + (service, port) + for service, spec in compose["services"].items() + for port in spec.get("ports", []) + ] + assert published, "expected the compose file to publish at least one port" + + for service, port in published: + # Short syntax is "HOST_IP:HOST_PORT:CONTAINER_PORT"; anything with + # fewer than three segments is published on every interface. + assert isinstance(port, str), f"{service}: expected short-syntax port, got {port!r}" + segments = port.split(":") + assert len(segments) == 3, f"{service}: port {port!r} publishes on all interfaces" + assert segments[0] in {"127.0.0.1", "::1"}, ( + f"{service}: port {port!r} publishes on {segments[0]}, not loopback" + ) + + def test_top_level_compose_pins_headroom_state_to_named_volume() -> None: compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8") From ac8646aa3c6323c3c0b7051e09831f779859af6f Mon Sep 17 00:00:00 2001 From: Tejas Chopra Date: Sun, 16 Aug 2026 19:05:32 -0700 Subject: [PATCH 19/53] fix(ci): scope the release credential and stop persisting it to disk (#3062) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description `RELEASE_PLEASE_TOKEN` is currently a maintainer's personal PAT. It bypasses branch and tag protection on `main` (`release-please.yml` says so in its own comment), and forging a tag with it fires `release.yml` and `docker.yml` on `release: published`, which publish to PyPI, npm and GHCR. If it is a classic token with `repo` scope it is also valid against every other repository that account can reach. `release-metadata-sync.yml` made that credential readable on the runner. `actions/checkout` defaults to `persist-credentials: true`, writing the token into `.git/config`, and the very next step runs `scripts/version-sync.py` **from the checked-out branch**. The trigger is a push to the glob `release-please--branches--**`, which is not a protected namespace, so a principal with push access could land a modified `version-sync.py` and read it. Closes #2955. ## 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 ## Changes Made - Both release workflows now prefer a GitHub App installation token — scoped to this repository, expiring in an hour — over the PAT, via `actions/create-github-app-token@v3`. - The minting step is gated on `vars.RELEASE_APP_ID` and marked `continue-on-error`, so an unconfigured app falls through to the existing `PAT -> GITHUB_TOKEN` chain and nothing breaks today. - `release-metadata-sync.yml`'s checkout no longer persists credentials, and no longer receives a token at all. - The final push supplies the credential through the step's own `env` and an explicit remote URL, so it is never on disk while branch-supplied code runs. ## Testing - [x] Unit tests pass - [x] Linting passes (ruff check + format on the test file) - [ ] Type checking passes — N/A (YAML + test only) - [x] New tests added for new functionality ### Test Output ```text $ .venv/bin/python -m pytest tests/test_release_workflows.py -q 1 failed, 44 passed, 1 skipped in 0.23s ``` The single failure is `test_no_native_tls_in_wheel_build_tree`, which shells out to `cargo`. It reproduces identically on unmodified `main` on this machine (no Rust toolchain installed) and is unrelated to this change. New tests only: ```text $ .venv/bin/python -m pytest tests/test_release_workflows.py -q -k "persist_credentials or scoped_app_token" 3 passed, 46 deselected in 0.18s ``` Against the parent commit: ```text FAILED test_metadata_sync_does_not_persist_credentials_for_branch_supplied_code FAILED test_release_workflows_prefer_scoped_app_token[release-please.yml-release-please] FAILED test_release_workflows_prefer_scoped_app_token[release-metadata-sync.yml-sync] 3 failed, 46 deselected ``` ## Real Behavior Proof - Environment: macOS 15 (darwin 25.4.0), Python 3.12.13; workflows parsed with PyYAML, not executed on a runner. - Exact command / steps: parse both workflow files and assert (a) every `actions/checkout` step sets `persist-credentials: false` and receives no `token`, (b) exactly one gated `create-github-app-token` step exists per workflow, and (c) every credential consumer places `steps.app-token.outputs.token` ahead of `secrets.RELEASE_PLEASE_TOKEN` in its fallback chain. - Observed result: all three assertions pass on this branch and fail on the parent commit. Both files remain valid YAML. - **Not tested — important:** none of this has executed on a GitHub runner. I have not minted a real installation token, not confirmed the app-token step's `continue-on-error` fallback behaves as expected when `vars.RELEASE_APP_ID` is unset, and not performed a real push with the explicit-remote-URL form. The first live release run is the real test. ## Runtime Rollout Safety - Rollout-managed feature(s): none. - Minimum rollout channel: N/A. - Stable/default behavior changed: no, unless `vars.RELEASE_APP_ID` is set — without it both workflows resolve to exactly today's credential chain. - Kill switch / disable path: unset `vars.RELEASE_APP_ID` to fall back to the PAT. - Unsafe override required: none. - Qualification impact: none. - Rollback path: revert this commit. ## 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 - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective - [x] New and existing unit tests pass locally with my changes ## Additional Notes **This narrows blast radius; it does not make the trigger safe on its own.** For an `on: push` workflow GitHub reads the workflow file from the pushed ref, so a principal with push access can still edit this file on their branch. The durable fix is the scoped app token *plus revoking the personal PAT* — the revocation is a console action and is deliberately not in this commit. **Two repo settings are required to actually complete #2955**, and neither can land in git: ``` vars.RELEASE_APP_ID (repository variable) secrets.RELEASE_APP_PRIVATE_KEY (repository secret) ``` Until those exist this PR is a no-op on behavior and a defense-in-depth improvement on the `persist-credentials` path only. Co-authored-by: Tejas Chopra --- .github/workflows/release-metadata-sync.yml | 38 +++++++++--- .github/workflows/release-please.yml | 33 ++++++++--- tests/test_release_workflows.py | 66 +++++++++++++++++++++ 3 files changed, 121 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release-metadata-sync.yml b/.github/workflows/release-metadata-sync.yml index ef775cf3b..2526b9770 100644 --- a/.github/workflows/release-metadata-sync.yml +++ b/.github/workflows/release-metadata-sync.yml @@ -50,15 +50,26 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: + # Prefer a short-lived, repo-scoped GitHub App installation token over a + # personal PAT. Gated on the repo variable so an unconfigured app simply + # falls through to the existing chain instead of breaking the release. + - name: Mint installation token + id: app-token + if: ${{ vars.RELEASE_APP_ID != '' }} + continue-on-error: true + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ vars.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + - uses: actions/checkout@v7 with: ref: ${{ github.ref_name }} - # PAT (not GITHUB_TOKEN) for the same reason release-please.yml uses one: - # a push made with GITHUB_TOKEN does not trigger workflows, so the release - # PR's checks would never re-run against the synced commit and would stay - # red. Falls back to GITHUB_TOKEN, where the sync still lands and a manual - # re-run of the PR's checks picks it up. - token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }} + # Do NOT persist the credential into .git/config. The next step runs + # scripts/version-sync.py *from the checked-out branch*, and this job + # triggers on a push to the unprotected glob release-please--branches--**. + # A persisted token would be readable by that script. + persist-credentials: false - uses: actions/setup-python@v6 with: @@ -72,6 +83,14 @@ jobs: run: python scripts/verify-versions.py - name: Commit and push if anything changed + env: + # An app installation token if one was minted, else the existing + # chain. A PAT (not GITHUB_TOKEN) is still preferred here for the same + # reason release-please.yml wants one: a push made with GITHUB_TOKEN + # does not trigger workflows, so the release PR's checks would never + # re-run against the synced commit and would stay red. Supplied only + # to this step, after the branch-supplied script has already run. + SYNC_TOKEN: ${{ steps.app-token.outputs.token || secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }} run: | if git diff --quiet; then echo "Already in sync — nothing to commit." @@ -81,7 +100,12 @@ jobs: git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add -A git commit -m "chore: sync generated version metadata" + # Push via an explicit remote URL because the checkout no longer + # persists credentials. Passed on stdin-free env expansion so the + # token is not written to the command line or into .git/config. # This push re-triggers this workflow. version-sync.py is idempotent, so # the next run finds no diff and exits above without pushing — the loop # terminates after one no-op run. - git push origin HEAD:"${GITHUB_REF_NAME}" + git push \ + "https://x-access-token:${SYNC_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \ + HEAD:"${GITHUB_REF_NAME}" diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index ce458bfd8..c40893be2 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -42,16 +42,31 @@ jobs: release-please: runs-on: ubuntu-latest steps: + # Prefer a short-lived, repo-scoped GitHub App installation token. A + # personal PAT carries the maintainer's whole account — with a classic + # `repo` scope that reaches every other repository they can access — and + # this credential can tag past branch protection and reaches PyPI, npm and + # GHCR through the `release: published` publishes. An installation token is + # scoped to this repository and expires in an hour. Gated on the repo + # variable so an unconfigured app falls through instead of blocking a + # release. See #2955. + - name: Mint installation token + id: app-token + if: ${{ vars.RELEASE_APP_ID != '' }} + continue-on-error: true + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ vars.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + - uses: googleapis/release-please-action@v5 with: - # PAT (not GITHUB_TOKEN): a release/tag created by GITHUB_TOKEN does - # NOT emit events that trigger other workflows, so release.yml - # (PyPI/npm) and docker.yml — which fire on `release: published` — - # never ran, and releases had to be cut by hand. A PAT is treated as a - # real user, so the release it creates DOES trigger those publishes; it - # also lets the bot tag past branch/tag protection. Falls back to - # GITHUB_TOKEN when the secret is unset (the release PR still opens; it - # just won't trigger the downstream publishes). - token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }} + # Neither an app token nor a PAT is GITHUB_TOKEN, and that matters: a + # release/tag created by GITHUB_TOKEN does NOT emit events that trigger + # other workflows, so release.yml (PyPI/npm) and docker.yml — which fire + # on `release: published` — never ran, and releases had to be cut by + # hand. Falls back to GITHUB_TOKEN when nothing else is set (the release + # PR still opens; it just won't trigger the downstream publishes). + token: ${{ steps.app-token.outputs.token || secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }} config-file: .release-please-config.json manifest-file: .release-please-manifest.json diff --git a/tests/test_release_workflows.py b/tests/test_release_workflows.py index 207827802..d43e1ce6d 100644 --- a/tests/test_release_workflows.py +++ b/tests/test_release_workflows.py @@ -1467,3 +1467,69 @@ def test_version_sync_covers_every_file_the_verifier_gates() -> None: "server.json", ]: assert fragment in sync, f"version-sync.py no longer propagates a version to {fragment}" + + +def test_metadata_sync_does_not_persist_credentials_for_branch_supplied_code() -> None: + """The release credential must not be readable by the synced branch's code. + + ``release-metadata-sync`` triggers on a push to the ``release-please--branches--**`` + glob, which is not a protected namespace, and then runs + ``scripts/version-sync.py`` *from the checked-out branch*. With + ``actions/checkout``'s default ``persist-credentials: true`` the token is + written to ``.git/config`` before that script runs, so anyone able to push a + matching branch could read it. The credential reaches PyPI, npm and GHCR via + the ``release: published`` publishes, so this is not a theoretical leak. + """ + workflow = yaml.safe_load( + (ROOT / ".github/workflows/release-metadata-sync.yml").read_text(encoding="utf-8") + ) + steps = workflow["jobs"]["sync"]["steps"] + + checkouts = [s for s in steps if str(s.get("uses", "")).startswith("actions/checkout")] + assert checkouts, "expected a checkout step" + for step in checkouts: + assert step.get("with", {}).get("persist-credentials") is False, step + # A token passed to checkout is exactly what persist-credentials would + # write to disk; the push step supplies it via env instead. + assert "token" not in step.get("with", {}), step + + +@pytest.mark.parametrize( + "workflow_path,job", + [ + (".github/workflows/release-please.yml", "release-please"), + (".github/workflows/release-metadata-sync.yml", "sync"), + ], +) +def test_release_workflows_prefer_scoped_app_token(workflow_path: str, job: str) -> None: + """A repo-scoped, short-lived app token must be preferred over the PAT. + + The PAT carries a maintainer's entire account and bypasses branch and tag + protection (#2955). The app-token step is gated on ``vars.RELEASE_APP_ID`` + and marked ``continue-on-error`` so an unconfigured app falls back to the + existing chain rather than blocking a release. + """ + workflow = yaml.safe_load((ROOT / workflow_path).read_text(encoding="utf-8")) + steps = workflow["jobs"][job]["steps"] + + minters = [ + s for s in steps if str(s.get("uses", "")).startswith("actions/create-github-app-token") + ] + assert len(minters) == 1, steps + minter = minters[0] + assert minter["id"] == "app-token" + assert minter["continue-on-error"] is True + assert "vars.RELEASE_APP_ID" in str(minter["if"]) + + # Whatever consumes the credential must try the app token first. + consumers = [ + value + for step in steps + for value in list(step.get("with", {}).values()) + list(step.get("env", {}).values()) + if "RELEASE_PLEASE_TOKEN" in str(value) + ] + assert consumers, "expected a step consuming the release credential" + for value in consumers: + assert str(value).index("steps.app-token.outputs.token") < str(value).index( + "secrets.RELEASE_PLEASE_TOKEN" + ), value From cbb950a441b0f4adff52965be80d3ed32664280d Mon Sep 17 00:00:00 2001 From: Tejas Chopra Date: Sun, 16 Aug 2026 19:05:36 -0700 Subject: [PATCH 20/53] ci(governance): require a Conventional Commit PR title (#3063) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description The repo squash-merges, so the PR title — not the commits inside the PR — becomes the commit subject on `main`. Nothing validated it. `commitlint` (`ci.yml:429`) lints a PR's *commits* and therefore cannot catch this by construction: a PR with clean conventional commits and a prose title passes CI and then lands a prose subject on `main`. That is how `31452426` landed: ``` Unify savings attribution across stats, perf, metrics, and dashboard (#2976) ``` release-please cannot parse it — `unexpected token ' ' at 1:6`, because `Unify` is five characters and position six is a space where the parser needs `(`, `!` or `:`. The change is silently dropped from the changelog. ## 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 ## Changes Made - Added `COMMIT_TYPES` and `TITLE_RE` to `scripts/pr-governance.py`, matching `.commitlintrc.json`'s `type-enum`. - Added a title check to `validate_pull_request`, reported through the existing governance comment. - Added a test asserting `COMMIT_TYPES` equals `.commitlintrc.json`'s `type-enum`, so the two gates cannot drift apart. - Gave the `_event` test helper the `title` field a real `pull_request` payload always carries. ## Testing - [x] Unit tests pass - [x] Linting passes (ruff check + format) - [ ] Type checking passes — N/A (script + test only) - [x] New tests added for new functionality ### Test Output ```text $ .venv/bin/python -m pytest scripts/tests/test_pr_governance.py -q 12 passed in 0.02s ``` Against the parent commit: ```text FAILED test_validate_pull_request_rejects_non_conventional_title FAILED test_validate_pull_request_rejects_empty_and_typeless_titles FAILED test_commit_types_match_commitlint_config 3 failed, 9 passed ``` ## Real Behavior Proof - Environment: macOS 15 (darwin 25.4.0), Python 3.12.13, `scripts/pr-governance.py` loaded directly. - Exact command / steps: ran `TITLE_RE` against the titles of **all 117 pull requests opened in this repository between 2026-08-10 and 2026-08-16**, pulled with `gh pr list --json title`. - Observed result: exactly one title is flagged — `#2976`, `Unify savings attribution across stats, perf, metrics, and dashboard`, the one that jammed the release. Zero false positives across the other 116, including every Dependabot `deps: bump ...` title, `chore: release main`, and scoped forms like `fix(proxy/anthropic): ...`. - Not tested: the check running inside a live `pull_request_target` event on a GitHub runner. ## Runtime Rollout Safety - Rollout-managed feature(s): none. - Minimum rollout channel: N/A. - Stable/default behavior changed: yes — a PR with a non-conventional title now gets the `status: needs author action` label and a governance comment. - Kill switch / disable path: revert; the check is not independently configurable. - Unsafe override required: none. - Qualification impact: none. - Rollback path: revert this commit. ## 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 - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective - [x] New and existing unit tests pass locally with my changes ## Additional Notes The check lives in `pr-governance.py` rather than `ci.yml` for two reasons: 1. `ci.yml`'s `pull_request` trigger has no `edited` type, so a corrected title would never be re-checked. 2. Its `paths-ignore` skips docs-only PRs, which still squash-merge a subject onto `main`. `pr-health.yml` already triggers on `edited` and reports through the same governance comment the author is reading anyway. Bot PRs keep their existing exemption — Dependabot and release-please titles are already conventional, and the early return for `is_bot_pr` is untouched. Co-authored-by: Tejas Chopra --- scripts/pr-governance.py | 35 +++++++++++++ scripts/tests/test_pr_governance.py | 80 ++++++++++++++++++++++++++++- 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/scripts/pr-governance.py b/scripts/pr-governance.py index 8c5165e39..313be118f 100644 --- a/scripts/pr-governance.py +++ b/scripts/pr-governance.py @@ -41,6 +41,28 @@ ROLLOUT_FIELDS = ( "Rollback path", ) +# Conventional-commit types accepted by .commitlintrc.json. Keep the two in +# sync: commitlint gates the *commits* on a PR, but the repo squash-merges, so +# it is the PR *title* that becomes the subject line on main. +COMMIT_TYPES = ( + "build", + "chore", + "ci", + "deps", + "docs", + "feat", + "fix", + "parity", + "perf", + "refactor", + "revert", + "style", + "test", +) + +# type(optional-scope)!: subject +TITLE_RE = re.compile(rf"^(?:{'|'.join(COMMIT_TYPES)})(?:\([^)]+\))?!?: .+") + SECTION_RE = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE) CHECKBOX_RE = re.compile(r"^- \[(?P[ xX])\] (?P