headroom/tests/test_proxy_handler_helpers.py

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

1166 lines
40 KiB
Python
Raw Permalink Normal View History

from __future__ import annotations
import asyncio
import base64
import builtins
import json
from types import SimpleNamespace
from unittest.mock import patch
import httpx
feat: add Vertex AI proxy routing (#793) ## Description Adds first-class GCP Vertex AI proxy routing for publisher REST endpoints so Vertex requests are forwarded to a configurable regional Vertex host instead of falling through to the generic OpenAI/Anthropic/Gemini passthrough selection. Fixes #792 ## Type of Change - [x] New feature (non-breaking change that adds functionality) - [x] Documentation update ## Changes Made - Added a `vertex` provider target with `VERTEX_TARGET_API_URL` and `--vertex-api-url` support. - Registered explicit Vertex publisher routes for Google `generateContent`, `streamGenerateContent`, `countTokens` and Anthropic publisher `rawPredict`, `streamRawPredict` passthrough. - Added startup banner/routing output for Vertex AI. - Added focused tests for provider target resolution, CLI/env config, banner output, and route delegation. - Added `wiki/vertex.md` with usage examples and Google Cloud source links. ## Sources - Vertex AI Gemini inference reference: https://docs.cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference - Google Cloud REST authentication: https://docs.cloud.google.com/docs/authentication/rest - Google Application Default Credentials: https://docs.cloud.google.com/docs/authentication/application-default-credentials ## Testing - [x] Linting passes (`python -m ruff check .`) - [x] New tests added for new functionality - [x] Focused unit tests pass - [ ] Full unit suite completed locally - [ ] Rust tests completed locally - [ ] Type checking passes locally ## Test Output ```text $ python -m ruff check . All checks passed! $ python -m pytest tests/test_provider_registry.py tests/test_provider_proxy_routes.py tests/test_cli_proxy_env.py tests/test_banner_upstream_targets.py -q 57 passed, 1 warning in 13.75s ``` Local limitations: - `python -m pytest tests scripts/tests -q` timed out after 1 hour on this Windows machine before completing. - `cargo test -p headroom-proxy --test integration_vertex_raw_predict` could not run because `cargo` is not installed on PATH in this environment. - The commit hook's `mypy` step fails locally on an existing Windows `fcntl` typing issue in `headroom/subscription/tracker.py`; `ruff`, `ruff-format`, and plugin-version hooks passed, and the commit was made with only `mypy` skipped. ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have made corresponding changes to the documentation - [x] I have added tests that prove the feature works - [ ] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable
2026-06-10 01:05:30 -05:00
from fastapi.responses import StreamingResponse
from headroom.proxy.handlers.anthropic import AnthropicHandlerMixin
feat: add Vertex AI proxy routing (#793) ## Description Adds first-class GCP Vertex AI proxy routing for publisher REST endpoints so Vertex requests are forwarded to a configurable regional Vertex host instead of falling through to the generic OpenAI/Anthropic/Gemini passthrough selection. Fixes #792 ## Type of Change - [x] New feature (non-breaking change that adds functionality) - [x] Documentation update ## Changes Made - Added a `vertex` provider target with `VERTEX_TARGET_API_URL` and `--vertex-api-url` support. - Registered explicit Vertex publisher routes for Google `generateContent`, `streamGenerateContent`, `countTokens` and Anthropic publisher `rawPredict`, `streamRawPredict` passthrough. - Added startup banner/routing output for Vertex AI. - Added focused tests for provider target resolution, CLI/env config, banner output, and route delegation. - Added `wiki/vertex.md` with usage examples and Google Cloud source links. ## Sources - Vertex AI Gemini inference reference: https://docs.cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference - Google Cloud REST authentication: https://docs.cloud.google.com/docs/authentication/rest - Google Application Default Credentials: https://docs.cloud.google.com/docs/authentication/application-default-credentials ## Testing - [x] Linting passes (`python -m ruff check .`) - [x] New tests added for new functionality - [x] Focused unit tests pass - [ ] Full unit suite completed locally - [ ] Rust tests completed locally - [ ] Type checking passes locally ## Test Output ```text $ python -m ruff check . All checks passed! $ python -m pytest tests/test_provider_registry.py tests/test_provider_proxy_routes.py tests/test_cli_proxy_env.py tests/test_banner_upstream_targets.py -q 57 passed, 1 warning in 13.75s ``` Local limitations: - `python -m pytest tests scripts/tests -q` timed out after 1 hour on this Windows machine before completing. - `cargo test -p headroom-proxy --test integration_vertex_raw_predict` could not run because `cargo` is not installed on PATH in this environment. - The commit hook's `mypy` step fails locally on an existing Windows `fcntl` typing issue in `headroom/subscription/tracker.py`; `ruff`, `ruff-format`, and plugin-version hooks passed, and the commit was made with only `mypy` skipped. ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have made corresponding changes to the documentation - [x] I have added tests that prove the feature works - [ ] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable
2026-06-10 01:05:30 -05:00
from headroom.proxy.handlers.openai import (
OpenAIHandlerMixin,
_decode_openai_bearer_payload,
_passthrough_usage_from_json,
fix(codex): poll /wham/usage for subscription limits (handshake no longer sends x-codex-* headers) (#924) ## Description Codex's subscription usage window (primary/secondary rate-limit gauges) stopped populating for ChatGPT-OAuth sessions. This PR restores it by polling Codex's dedicated usage endpoint instead of relying on response headers that are no longer sent. ### Why the previous approach no longer works The existing code populates `CodexRateLimitState` from `x-codex-*` rate-limit headers captured on the `/v1/responses` WebSocket handshake (`update_from_headers` at WS accept). That worked when OpenAI returned `x-codex-primary-used-percent`, `x-codex-primary-window-minutes`, etc. on the handshake response. OpenAI has since stopped sending those headers on the ChatGPT WebSocket handshake. I confirmed this by faithfully replaying a real Plus-account handshake (both `prewarm` and regular `request_kind`): no `x-codex-*` headers come back on either. This matches OpenAI's own move to a dedicated usage endpoint (`GET /backend-api/codex/usage` in CodexApi mode) and reports such as openai/codex#14728. So `update_from_headers` now runs on every accept but finds nothing to parse, and the window silently stays empty. The headers aren't coming back, so there is nothing to fix in the parsing path. The data now lives behind a request we have to make ourselves. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `subscription/codex_rate_limits.py`: - `parse_codex_usage_payload()` / `update_from_usage_payload()` — map the `GET /backend-api/wham/usage` JSON (`rate_limit.primary_window` / `secondary_window` with `used_percent`, `limit_window_seconds`, `reset_at`; `credits`; `rate_limit_reached_type`) into the existing `CodexRateLimitState`. `limit_window_seconds` is converted to window-minutes with the same round-up codex-rs uses (`(secs + 59) // 60`). - `maybe_schedule_usage_poll()` — fire-and-forget, throttled to one request per 60s, scoped to ChatGPT sessions (requires both a Bearer token and `ChatGPT-Account-Id`; API-key traffic is skipped). Uses an in-flight guard so concurrent accepts don't stack polls. Endpoint is overridable via `HEADROOM_CODEX_USAGE_URL`. - `proxy/handlers/openai.py`: - At the Codex WS accept site, after the now-usually-empty `update_from_headers` block, schedule the usage poll. Wrapped in `contextlib.suppress` and fully non-blocking so it can never delay or fail the WebSocket accept. The old header-capture path is intentionally left in place as a no-cost fallback in case OpenAI restores the headers. ## 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 (live `/wham/usage` replay against a Plus account returned HTTP 200 with the expected schema; payload fixture in tests mirrors that real shape) ## Test Output ``` $ uv run pytest tests/test_codex_rate_limits.py -q ........................................ [100%] 41 passed in 0.16s $ uv run ruff check headroom/subscription/codex_rate_limits.py headroom/proxy/handlers/openai.py tests/test_codex_rate_limits.py All checks passed! $ uv run mypy headroom/subscription/codex_rate_limits.py Success: no issues found in 1 source file ``` ## Additional Notes - New tests cover: full-payload mapping, window-minutes round-up, credits balance kept only when `has_credits`, promo object vs string, empty payload returns `None`, missing `used_percent` skipped, header-gating (requires Bearer + account-id), poll throttling, and no-event-loop safety. - Scoping to `ChatGPT-Account-Id` keeps the poll off API-key traffic, and the 60s throttle plus in-flight guard bound it to at most one lightweight GET per minute per running proxy. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 00:03:14 +02:00
_prefers_http1_passthrough,
feat: add Vertex AI proxy routing (#793) ## Description Adds first-class GCP Vertex AI proxy routing for publisher REST endpoints so Vertex requests are forwarded to a configurable regional Vertex host instead of falling through to the generic OpenAI/Anthropic/Gemini passthrough selection. Fixes #792 ## Type of Change - [x] New feature (non-breaking change that adds functionality) - [x] Documentation update ## Changes Made - Added a `vertex` provider target with `VERTEX_TARGET_API_URL` and `--vertex-api-url` support. - Registered explicit Vertex publisher routes for Google `generateContent`, `streamGenerateContent`, `countTokens` and Anthropic publisher `rawPredict`, `streamRawPredict` passthrough. - Added startup banner/routing output for Vertex AI. - Added focused tests for provider target resolution, CLI/env config, banner output, and route delegation. - Added `wiki/vertex.md` with usage examples and Google Cloud source links. ## Sources - Vertex AI Gemini inference reference: https://docs.cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference - Google Cloud REST authentication: https://docs.cloud.google.com/docs/authentication/rest - Google Application Default Credentials: https://docs.cloud.google.com/docs/authentication/application-default-credentials ## Testing - [x] Linting passes (`python -m ruff check .`) - [x] New tests added for new functionality - [x] Focused unit tests pass - [ ] Full unit suite completed locally - [ ] Rust tests completed locally - [ ] Type checking passes locally ## Test Output ```text $ python -m ruff check . All checks passed! $ python -m pytest tests/test_provider_registry.py tests/test_provider_proxy_routes.py tests/test_cli_proxy_env.py tests/test_banner_upstream_targets.py -q 57 passed, 1 warning in 13.75s ``` Local limitations: - `python -m pytest tests scripts/tests -q` timed out after 1 hour on this Windows machine before completing. - `cargo test -p headroom-proxy --test integration_vertex_raw_predict` could not run because `cargo` is not installed on PATH in this environment. - The commit hook's `mypy` step fails locally on an existing Windows `fcntl` typing issue in `headroom/subscription/tracker.py`; `ruff`, `ruff-format`, and plugin-version hooks passed, and the commit was made with only `mypy` skipped. ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have made corresponding changes to the documentation - [x] I have added tests that prove the feature works - [ ] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable
2026-06-10 01:05:30 -05:00
)
fix(proxy): relocate stray system-role messages to the top-level system param (#765) (#1357) ## Description On requests large enough to trigger compression, the proxy emitted an upstream Anthropic request whose `messages[0]` had `role: "system"`. Anthropic's Messages API rejects any `system` role inside `messages[]`: ``` 400 invalid_request_error: "messages.0: use the top-level 'system' parameter for the initial system prompt" ``` The original request correctly carries its system prompt in the top-level `system` parameter; a compression/transform/pipeline step relocates the harness system block into `messages[0]`, so the request fails outright (intermittent only because it requires a context large enough to compress). This adds a wire-contract guard in the Anthropic forwarder: as the **last** step before sending upstream (after every transform, memory injection, tool sort, and pipeline extension, covering both the Bedrock and direct paths), any stray `role="system"` message is relocated out of `messages[]` and merged back into the top-level `system` parameter. Content order is preserved (existing system first, relocated content after) and block-level `cache_control` survives. The guard is a no-op on the common path (no system-role entry → inputs pass through unchanged). Closes #765 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/proxy/helpers.py`: new pure helper `relocate_system_messages_to_top_level(messages, system) -> (clean_messages, new_system, changed)` plus `_system_message_to_blocks`. Handles `system` being `None`/`str`/`list`, never drops content, preserves order and content blocks. - `headroom/proxy/handlers/anthropic.py`: invoke the guard just before the byte-faithful forward block; on relocation, update `body["messages"]`/`body["system"]`, mark the body mutated (`system_role_relocated`) so the byte-faithful forwarder re-serializes, and log a warning. - `tests/test_proxy_handler_helpers.py`: 3 unit tests (relocate stray system into top-level, append-to-existing-system order, no-op without a system entry). - `CHANGELOG.md`: Bug Fixes entry. ## 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 --extra dev python -m pytest tests/test_proxy_handler_helpers.py -q 29 passed in 4.95s # Regression on the forward path (byte-faithful forwarding, system-prompt immutability, cache stability): $ uv run --extra dev python -m pytest tests/test_proxy_handler_helpers.py tests/test_proxy_byte_faithful_forwarding.py tests/test_proxy_system_prompt_immutable.py tests/test_proxy_anthropic_cache_stability.py -q 90 passed, 15 warnings in 29.72s $ uv run ruff check headroom/proxy/helpers.py headroom/proxy/handlers/anthropic.py tests/test_proxy_handler_helpers.py All checks passed! $ uv run ruff format --check ... # 3 files already formatted $ uv run mypy headroom/proxy/helpers.py headroom/proxy/handlers/anthropic.py Success: no issues found in 2 source files ``` ## Test verification (RED → GREEN) The new tests exercise the guard directly and import the new helper at module top, so reverting the production fix makes them fail at collection. **RED — production fix reverted (helper removed):** ```text ImportError while importing test module 'tests/test_proxy_handler_helpers.py'. E ImportError: cannot import name 'relocate_system_messages_to_top_level' from 'headroom.proxy.helpers' =========================== 1 error in 0.41s =============================== ``` **GREEN — production fix applied:** ```text tests/test_proxy_handler_helpers.py ... [100%] ======================= 3 passed, 26 deselected in 1.50s ======================= ``` ## Real Behavior Proof - Environment: Python 3.13, `uv run` in this repo, branch `fix/issue-765`. - Exact command / steps: ran the guard on a body in the exact #765 failure shape — `system: None` and a `role="system"` harness block at `messages[0]`: - Observed result: ```text BEFORE: messages[0].role = system (Anthropic 400 trigger) changed = True AFTER roles = ['user', 'assistant'] system param = [{"type": "text", "text": "You are Claude Code. <system-reminder>...</system-reminder>"}] OK: no role=system in messages[]; system content preserved in top-level param ``` The illegal `role="system"` entry is removed from `messages[]` and its content lands in the top-level `system` parameter — exactly the body Anthropic accepts. - Not tested: a full live 250k+-token Claude Code session against the real Anthropic API (needs a large live context + API key); the fix is validated at the request-shaping boundary the 400 is raised on. ## 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 or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Additional Notes The guard intentionally fires at the forwarder boundary rather than in any single transform: the issue's captures show the relocation can originate from the compression path, and pipeline extensions / hooks can also mutate `messages` late. Enforcing Anthropic's wire contract once, at the point the body is serialized upstream, fixes the 400 regardless of which step introduced the stray entry and matches the architecture invariant "never produce a `system`-role entry within `messages[]`". --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-08-13 18:52:09 +02:00
from headroom.proxy.helpers import (
_headroom_bypass_enabled,
relocate_system_messages_to_top_level,
)
from headroom.proxy.server import HeadroomProxy
def _jwt(payload: object) -> str:
header = {"alg": "none", "typ": "JWT"}
def encode(part: object) -> str:
raw = json.dumps(part, separators=(",", ":")).encode("utf-8")
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
return f"{encode(header)}.{encode(payload)}."
class _ImageCompressor:
def __init__(self, compressed_message):
self._compressed_message = compressed_message
def compress(self, messages, provider): # noqa: ANN001, ANN201
assert provider == "anthropic"
return [self._compressed_message]
class _FreshCompressor:
instances = 0
def __init__(self):
type(self).instances += 1
class _TimeoutHttpClient:
async def request(self, **kwargs): # noqa: ANN001, ANN201
raise httpx.ConnectTimeout("connect timed out")
fix(codex): poll /wham/usage for subscription limits (handshake no longer sends x-codex-* headers) (#924) ## Description Codex's subscription usage window (primary/secondary rate-limit gauges) stopped populating for ChatGPT-OAuth sessions. This PR restores it by polling Codex's dedicated usage endpoint instead of relying on response headers that are no longer sent. ### Why the previous approach no longer works The existing code populates `CodexRateLimitState` from `x-codex-*` rate-limit headers captured on the `/v1/responses` WebSocket handshake (`update_from_headers` at WS accept). That worked when OpenAI returned `x-codex-primary-used-percent`, `x-codex-primary-window-minutes`, etc. on the handshake response. OpenAI has since stopped sending those headers on the ChatGPT WebSocket handshake. I confirmed this by faithfully replaying a real Plus-account handshake (both `prewarm` and regular `request_kind`): no `x-codex-*` headers come back on either. This matches OpenAI's own move to a dedicated usage endpoint (`GET /backend-api/codex/usage` in CodexApi mode) and reports such as openai/codex#14728. So `update_from_headers` now runs on every accept but finds nothing to parse, and the window silently stays empty. The headers aren't coming back, so there is nothing to fix in the parsing path. The data now lives behind a request we have to make ourselves. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `subscription/codex_rate_limits.py`: - `parse_codex_usage_payload()` / `update_from_usage_payload()` — map the `GET /backend-api/wham/usage` JSON (`rate_limit.primary_window` / `secondary_window` with `used_percent`, `limit_window_seconds`, `reset_at`; `credits`; `rate_limit_reached_type`) into the existing `CodexRateLimitState`. `limit_window_seconds` is converted to window-minutes with the same round-up codex-rs uses (`(secs + 59) // 60`). - `maybe_schedule_usage_poll()` — fire-and-forget, throttled to one request per 60s, scoped to ChatGPT sessions (requires both a Bearer token and `ChatGPT-Account-Id`; API-key traffic is skipped). Uses an in-flight guard so concurrent accepts don't stack polls. Endpoint is overridable via `HEADROOM_CODEX_USAGE_URL`. - `proxy/handlers/openai.py`: - At the Codex WS accept site, after the now-usually-empty `update_from_headers` block, schedule the usage poll. Wrapped in `contextlib.suppress` and fully non-blocking so it can never delay or fail the WebSocket accept. The old header-capture path is intentionally left in place as a no-cost fallback in case OpenAI restores the headers. ## 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 (live `/wham/usage` replay against a Plus account returned HTTP 200 with the expected schema; payload fixture in tests mirrors that real shape) ## Test Output ``` $ uv run pytest tests/test_codex_rate_limits.py -q ........................................ [100%] 41 passed in 0.16s $ uv run ruff check headroom/subscription/codex_rate_limits.py headroom/proxy/handlers/openai.py tests/test_codex_rate_limits.py All checks passed! $ uv run mypy headroom/subscription/codex_rate_limits.py Success: no issues found in 1 source file ``` ## Additional Notes - New tests cover: full-payload mapping, window-minutes round-up, credits balance kept only when `has_credits`, promo object vs string, empty payload returns `None`, missing `used_percent` skipped, header-gating (requires Bearer + account-id), poll throttling, and no-event-loop safety. - Scoping to `ChatGPT-Account-Id` keeps the poll off API-key traffic, and the 60s throttle plus in-flight guard bound it to at most one lightweight GET per minute per running proxy. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 00:03:14 +02:00
class _RecordingHttpClient:
def __init__(self, label: str) -> None:
self.label = label
self.calls = 0
async def request(self, **kwargs): # noqa: ANN001, ANN201
self.calls += 1
request = httpx.Request(kwargs["method"], kwargs["url"])
return httpx.Response(
200,
request=request,
headers={"content-type": "application/json"},
json={"client": self.label},
)
class _ChatGPTAccountRequest:
method = "GET"
headers = {}
url = SimpleNamespace(path="/backend-api/me", query="")
async def body(self) -> bytes:
return b""
class _PassthroughRequest:
method = "GET"
headers = {}
fix(proxy): serve /favicon.ico locally instead of tunneling upstream (#1787) (#1847) ## Description The Headroom dashboard tunnels `GET /favicon.ico` requests to the wrapped upstream provider instead of serving its own. No route matched `/favicon.ico` in `headroom/proxy/server.py`, so the request fell through to the catch-all passthrough route (`headroom/providers/proxy_routes.py:994-1026`) registered by `register_provider_routes(app, proxy)`, and got forwarded to whichever LLM backend the proxy is wrapping — burning a real upstream request (and possibly failing auth) for a browser's automatic favicon fetch while viewing `/dashboard`. Closes #1787 ## 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 - `headroom/proxy/server.py`: added a `GET /favicon.ico` route returning `Response(status_code=204)`, registered next to the existing `/dashboard` route — i.e. before `register_provider_routes(app, proxy)` (line ~4184) registers the passthrough catch-all, so it takes priority. - `tests/test_proxy_handler_helpers.py`: `_PassthroughRequest.url.path` was hardcoded to `/favicon.ico` as a generic "goes to passthrough" example, which encoded the bug as expected behavior. Changed to `/some/other/path` so the passthrough-helper test no longer depends on favicon requests going upstream. - `tests/test_proxy_favicon_route.py` (new): regression test spinning up the real FastAPI app via `create_app`/`TestClient`, asserting `GET /favicon.ico` returns 204 and `proxy.handle_passthrough` is never called. - `CHANGELOG.md`: added an entry under `## Unreleased` / `### Fixed`. ## 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 $ python -m pytest tests/test_proxy_favicon_route.py tests/test_proxy_handler_helpers.py -q 28 passed $ python -m pytest tests/test_proxy_healthchecks.py tests/test_proxy_passthrough_integration.py tests/test_proxy_favicon_route.py tests/test_proxy_handler_helpers.py -q 41 passed, 19 skipped $ python -m ruff check headroom/proxy/server.py tests/test_proxy_favicon_route.py tests/test_proxy_handler_helpers.py All checks passed! $ python -m ruff format --check headroom/proxy/server.py tests/test_proxy_favicon_route.py tests/test_proxy_handler_helpers.py 3 files already formatted $ python -m mypy headroom/proxy/server.py (no errors) ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.13, local checkout, `python -m pytest`/`ruff`/`mypy` run directly (no `uv` available in this shell). - Exact command / steps: `python -m pytest tests/test_proxy_favicon_route.py -v` — this test builds the real proxy app with `create_app(ProxyConfig(...))`, wraps `client.app.state.proxy.handle_passthrough` with a mock, then issues `client.get("/favicon.ico")` via a real `TestClient` request through the full FastAPI routing stack (not a unit-level call of the handler function directly). - Observed result: response status is `204`, and `handle_passthrough` (the function that forwards to the upstream provider) is asserted `not_called()` — confirming the request is now intercepted before reaching the catch-all passthrough route, and does not tunnel to the wrapped provider. - Not tested: did not manually run `headroom wrap <provider>` end-to-end and open a real browser tab to `/dashboard` to visually confirm the favicon icon in the tab (the fix returns 204/no-icon rather than a real bundled `.ico` — browsers handle this fine, but the visual "no more broken/upstream favicon request" experience wasn't screenshotted). The FastAPI-level test above exercises the actual routing/dispatch path this bug lived in. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the style guidelines of this project - [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 (N/A — no user-facing docs describe dashboard route internals beyond CHANGELOG) - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated CHANGELOG.md where applicable ## Screenshots (if applicable) N/A — server-side route change, no UI change. ## Additional Notes Deliberately kept the fix minimal: no `StaticFiles` mount or general static-asset serving system was added, since a single favicon route doesn't warrant that abstraction. No real `.ico` binary asset was bundled either — a `204 No Content` response is sufficient for browsers and avoids maintaining a binary asset in the repo; this can be upgraded to serve a real branded icon later if desired. Co-authored-by: JD Davis <mxjerrett@gmail.com>
2026-07-08 06:36:10 +02:00
url = SimpleNamespace(path="/some/other/path", query="")
async def body(self) -> bytes:
return b""
feat: add Vertex AI proxy routing (#793) ## Description Adds first-class GCP Vertex AI proxy routing for publisher REST endpoints so Vertex requests are forwarded to a configurable regional Vertex host instead of falling through to the generic OpenAI/Anthropic/Gemini passthrough selection. Fixes #792 ## Type of Change - [x] New feature (non-breaking change that adds functionality) - [x] Documentation update ## Changes Made - Added a `vertex` provider target with `VERTEX_TARGET_API_URL` and `--vertex-api-url` support. - Registered explicit Vertex publisher routes for Google `generateContent`, `streamGenerateContent`, `countTokens` and Anthropic publisher `rawPredict`, `streamRawPredict` passthrough. - Added startup banner/routing output for Vertex AI. - Added focused tests for provider target resolution, CLI/env config, banner output, and route delegation. - Added `wiki/vertex.md` with usage examples and Google Cloud source links. ## Sources - Vertex AI Gemini inference reference: https://docs.cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference - Google Cloud REST authentication: https://docs.cloud.google.com/docs/authentication/rest - Google Application Default Credentials: https://docs.cloud.google.com/docs/authentication/application-default-credentials ## Testing - [x] Linting passes (`python -m ruff check .`) - [x] New tests added for new functionality - [x] Focused unit tests pass - [ ] Full unit suite completed locally - [ ] Rust tests completed locally - [ ] Type checking passes locally ## Test Output ```text $ python -m ruff check . All checks passed! $ python -m pytest tests/test_provider_registry.py tests/test_provider_proxy_routes.py tests/test_cli_proxy_env.py tests/test_banner_upstream_targets.py -q 57 passed, 1 warning in 13.75s ``` Local limitations: - `python -m pytest tests scripts/tests -q` timed out after 1 hour on this Windows machine before completing. - `cargo test -p headroom-proxy --test integration_vertex_raw_predict` could not run because `cargo` is not installed on PATH in this environment. - The commit hook's `mypy` step fails locally on an existing Windows `fcntl` typing issue in `headroom/subscription/tracker.py`; `ruff`, `ruff-format`, and plugin-version hooks passed, and the commit was made with only `mypy` skipped. ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have made corresponding changes to the documentation - [x] I have added tests that prove the feature works - [ ] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable
2026-06-10 01:05:30 -05:00
class _VertexPassthroughRequest:
method = "POST"
headers = {}
url = SimpleNamespace(
path="/v1/projects/p/locations/us-central1/publishers/google/models/gemini-2.0-flash:generateContent",
query="",
)
async def body(self) -> bytes:
return b'{"contents":[]}'
class _VertexStreamPassthroughRequest:
method = "POST"
headers = {}
url = SimpleNamespace(
path="/v1/projects/p/locations/us-central1/publishers/google/models/gemini-2.0-flash:streamGenerateContent",
query="alt=sse",
)
async def body(self) -> bytes:
return b'{"contents":[]}'
class _VertexGeminiImageRequest:
method = "POST"
headers = {}
query_params = {}
feat(proxy): let extensions report cost savings and their own latency (#3051) ## 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 <tejas@Tejass-MacBook-Pro.local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 10:25:47 -07:00
scope: dict = {"type": "http", "method": "POST"}
feat: add Vertex AI proxy routing (#793) ## Description Adds first-class GCP Vertex AI proxy routing for publisher REST endpoints so Vertex requests are forwarded to a configurable regional Vertex host instead of falling through to the generic OpenAI/Anthropic/Gemini passthrough selection. Fixes #792 ## Type of Change - [x] New feature (non-breaking change that adds functionality) - [x] Documentation update ## Changes Made - Added a `vertex` provider target with `VERTEX_TARGET_API_URL` and `--vertex-api-url` support. - Registered explicit Vertex publisher routes for Google `generateContent`, `streamGenerateContent`, `countTokens` and Anthropic publisher `rawPredict`, `streamRawPredict` passthrough. - Added startup banner/routing output for Vertex AI. - Added focused tests for provider target resolution, CLI/env config, banner output, and route delegation. - Added `wiki/vertex.md` with usage examples and Google Cloud source links. ## Sources - Vertex AI Gemini inference reference: https://docs.cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference - Google Cloud REST authentication: https://docs.cloud.google.com/docs/authentication/rest - Google Application Default Credentials: https://docs.cloud.google.com/docs/authentication/application-default-credentials ## Testing - [x] Linting passes (`python -m ruff check .`) - [x] New tests added for new functionality - [x] Focused unit tests pass - [ ] Full unit suite completed locally - [ ] Rust tests completed locally - [ ] Type checking passes locally ## Test Output ```text $ python -m ruff check . All checks passed! $ python -m pytest tests/test_provider_registry.py tests/test_provider_proxy_routes.py tests/test_cli_proxy_env.py tests/test_banner_upstream_targets.py -q 57 passed, 1 warning in 13.75s ``` Local limitations: - `python -m pytest tests scripts/tests -q` timed out after 1 hour on this Windows machine before completing. - `cargo test -p headroom-proxy --test integration_vertex_raw_predict` could not run because `cargo` is not installed on PATH in this environment. - The commit hook's `mypy` step fails locally on an existing Windows `fcntl` typing issue in `headroom/subscription/tracker.py`; `ruff`, `ruff-format`, and plugin-version hooks passed, and the commit was made with only `mypy` skipped. ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have made corresponding changes to the documentation - [x] I have added tests that prove the feature works - [ ] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable
2026-06-10 01:05:30 -05:00
url = SimpleNamespace(
path="/v1/projects/p/locations/us-central1/publishers/google/models/gemini-2.0-flash:generateContent",
query="",
)
async def body(self) -> bytes:
return json.dumps(
{
"contents": [
{
"role": "user",
"parts": [
{
"inlineData": {
"mimeType": "image/png",
"data": "aW1hZ2U=",
}
}
],
}
]
}
).encode("utf-8")
class _VertexUsageClient:
async def request(self, **kwargs): # noqa: ANN001, ANN201
request = httpx.Request(kwargs["method"], kwargs["url"], content=kwargs["content"])
return httpx.Response(
200,
request=request,
headers={"content-type": "application/json"},
json={
"candidates": [{"content": {"parts": [{"text": "ok"}]}}],
"usageMetadata": {
"promptTokenCount": 11,
"candidatesTokenCount": 7,
"cachedContentTokenCount": 3,
},
},
)
class _AsyncChunks(httpx.AsyncByteStream):
def __init__(self, chunks: list[bytes]) -> None:
self._chunks = chunks
async def __aiter__(self): # noqa: ANN204
for chunk in self._chunks:
yield chunk
class _VertexStreamClient:
def __init__(self) -> None:
self.sent_url = ""
def build_request(self, method, url, headers, content): # noqa: ANN001, ANN201
self.sent_url = str(url)
return httpx.Request(method, url, headers=headers, content=content)
async def send(self, request, stream=False): # noqa: ANN001, ANN201
assert stream is True
return httpx.Response(
200,
request=request,
headers={"content-type": "text/event-stream"},
stream=_AsyncChunks(
[
b'data: {"candidates":[{"content":{"parts":[{"text":"hello"}]}}]}\n\n',
b'data: {"usageMetadata":{"promptTokenCount":13,'
b'"candidatesTokenCount":5,"cachedContentTokenCount":2}}\n\n',
]
),
)
class _RetryThenSuccessClient:
def __init__(self) -> None:
self.attempts = 0
fix(proxy): add an Anthropic buffered read-timeout override (#1331) ## Description Buffered Anthropic `/v1/messages` requests still use Headroom's generic 300-second read timeout, which can produce proxy-generated `502 ReadTimeout` errors on long turns. This adds a dedicated buffered Anthropic timeout, keeps it applied across CCR and memory continuations plus batch paths, and makes the direct server entrypoint enforce the same positive-integer contract as the Click CLI. Closes #1261. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - Added `anthropic_buffered_request_timeout_seconds` for buffered Anthropic reads. - Routed `/v1/messages`, CCR continuation, memory continuation, batch create, batch passthrough, and batch results through that timeout. - Enforced the same positive-integer validation for `HEADROOM_ANTHROPIC_BUFFERED_REQUEST_TIMEOUT_SECONDS` and `--anthropic-buffered-request-timeout-seconds` in both startup paths. - Added focused regressions and updated `CHANGELOG.md`. ## Testing - [x] `uv run pytest tests/test_proxy/test_anthropic_buffered_timeout.py tests/test_cli_proxy_improvements.py::TestNewEnvVarWiring` - [x] `uv run ruff check .` - [x] `uv run ruff format . --check` ### Test Output ```text $ uv run pytest tests/test_proxy/test_anthropic_buffered_timeout.py tests/test_cli_proxy_improvements.py::TestNewEnvVarWiring 17 passed in 3.42s $ uv run ruff check . All checks passed! $ uv run ruff format . --check 966 files already formatted ``` ## Real Behavior Proof - Environment: local FastAPI `TestClient` with stubbed retry and HTTP client seams - Exact command / steps: run `uv run pytest tests/test_proxy/test_anthropic_buffered_timeout.py tests/test_cli_proxy_improvements.py::TestNewEnvVarWiring`; the tests build `ProxyConfig(request_timeout_seconds=7, connect_timeout_seconds=3, anthropic_buffered_request_timeout_seconds=19)`, drive `/v1/messages`, `/v1/messages/batches`, `/v1/messages/batches/{batch_id}/results`, a CCR continuation, and a memory continuation through `TestClient`, then verify `HEADROOM_ANTHROPIC_BUFFERED_REQUEST_TIMEOUT_SECONDS=0` falls back to `600`, `--anthropic-buffered-request-timeout-seconds 0` is rejected, and default proxy timeouts stay `read=300` and `write=300` - Observed result: buffered Anthropic paths use `httpx.Timeout(connect=3, read=19, write=7, pool=3)`, continuation requests stay on that same budget, invalid zero-valued startup config is rejected or ignored back to the default, and unrelated proxy timeout defaults stay unchanged - Not tested: live upstream Anthropic latency beyond the focused stubbed-timeout regression ## 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 added tests that prove the fix - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable
2026-06-23 23:46:31 -04:00
async def post(self, url, content, headers, timeout=None): # noqa: ANN001, ANN201
self.attempts += 1
if self.attempts == 1:
raise httpx.ConnectTimeout("connect timed out")
fix(proxy): add an Anthropic buffered read-timeout override (#1331) ## Description Buffered Anthropic `/v1/messages` requests still use Headroom's generic 300-second read timeout, which can produce proxy-generated `502 ReadTimeout` errors on long turns. This adds a dedicated buffered Anthropic timeout, keeps it applied across CCR and memory continuations plus batch paths, and makes the direct server entrypoint enforce the same positive-integer contract as the Click CLI. Closes #1261. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - Added `anthropic_buffered_request_timeout_seconds` for buffered Anthropic reads. - Routed `/v1/messages`, CCR continuation, memory continuation, batch create, batch passthrough, and batch results through that timeout. - Enforced the same positive-integer validation for `HEADROOM_ANTHROPIC_BUFFERED_REQUEST_TIMEOUT_SECONDS` and `--anthropic-buffered-request-timeout-seconds` in both startup paths. - Added focused regressions and updated `CHANGELOG.md`. ## Testing - [x] `uv run pytest tests/test_proxy/test_anthropic_buffered_timeout.py tests/test_cli_proxy_improvements.py::TestNewEnvVarWiring` - [x] `uv run ruff check .` - [x] `uv run ruff format . --check` ### Test Output ```text $ uv run pytest tests/test_proxy/test_anthropic_buffered_timeout.py tests/test_cli_proxy_improvements.py::TestNewEnvVarWiring 17 passed in 3.42s $ uv run ruff check . All checks passed! $ uv run ruff format . --check 966 files already formatted ``` ## Real Behavior Proof - Environment: local FastAPI `TestClient` with stubbed retry and HTTP client seams - Exact command / steps: run `uv run pytest tests/test_proxy/test_anthropic_buffered_timeout.py tests/test_cli_proxy_improvements.py::TestNewEnvVarWiring`; the tests build `ProxyConfig(request_timeout_seconds=7, connect_timeout_seconds=3, anthropic_buffered_request_timeout_seconds=19)`, drive `/v1/messages`, `/v1/messages/batches`, `/v1/messages/batches/{batch_id}/results`, a CCR continuation, and a memory continuation through `TestClient`, then verify `HEADROOM_ANTHROPIC_BUFFERED_REQUEST_TIMEOUT_SECONDS=0` falls back to `600`, `--anthropic-buffered-request-timeout-seconds 0` is rejected, and default proxy timeouts stay `read=300` and `write=300` - Observed result: buffered Anthropic paths use `httpx.Timeout(connect=3, read=19, write=7, pool=3)`, continuation requests stay on that same budget, invalid zero-valued startup config is rejected or ignored back to the default, and unrelated proxy timeout defaults stay unchanged - Not tested: live upstream Anthropic latency beyond the focused stubbed-timeout regression ## 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 added tests that prove the fix - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable
2026-06-23 23:46:31 -04:00
del timeout
request = httpx.Request("POST", url, headers=headers, content=content)
return httpx.Response(200, request=request, content=b"{}")
def test_decode_openai_bearer_payload_handles_missing_and_non_mapping_payloads() -> None:
assert _decode_openai_bearer_payload({}) is None
assert _decode_openai_bearer_payload({"authorization": "Basic abc"}) is None
assert (
_decode_openai_bearer_payload({"authorization": f"Bearer {_jwt(['not', 'a', 'dict'])}"})
is None
)
def test_openai_handler_prefix_helpers_cover_edge_cases() -> None:
assert OpenAIHandlerMixin._strict_previous_turn_frozen_count([], 2) == 2
assert (
OpenAIHandlerMixin._strict_previous_turn_frozen_count(
[{"role": "assistant"}, {"role": "user"}],
0,
)
== 1
)
fix(proxy): keep OpenAI tool observations mutable in cache mode (#1884) ## Description Diagnoses and fixes the low-savings OpenAI-compatible cache-mode path reported in #1696. OpenAI-compatible tool-calling clients can end a turn with `role: "tool"` (or legacy `role: "function"`) rather than `role: "user"`. The OpenAI chat handler's cache-mode freeze boundary treated those tails as non-mutable, and because `HeadroomProxy` resolves `_strict_previous_turn_frozen_count` from the Anthropic mixin first, the OpenAI-specific helper was not used in production. That froze the entire conversation before `ContentRouter` ran, leaving no live tool observation to compress and producing near-pass-through savings on long coding sessions. This PR keeps final OpenAI tool/function observations mutable in cache mode, explicitly calls the OpenAI helper to avoid the mixin-name collision, and clamps negative token-savings artifacts at the metrics/cost aggregation boundary so stats cannot under-report actual forwarded savings. Closes #1696 ## 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 - Treat final OpenAI `user`, `tool`, and `function` messages as the mutable cache-mode live zone. - Route OpenAI cache-boundary calls through `OpenAIHandlerMixin._strict_previous_turn_frozen_count` explicitly so the Anthropic mixin method cannot shadow it in `HeadroomProxy`'s MRO. - Preserve cache-mode live-tail boundaries even when compression-cache state would otherwise freeze the whole request. - Clamp negative `tokens_saved` artifacts in `CostTracker.record_tokens` and `PrometheusMetrics.record_request`. - Add regression coverage for OpenAI final `tool`/`function` tails, over-frozen tracker state, and non-negative savings aggregation. ## Testing - [ ] 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 $ maturin build --profile ci --out dist --interpreter python Built wheel for abi3 Python >= 3.10 to dist\headroom_ai-0.29.0-cp310-abi3-win_amd64.whl $ python -m pytest tests\test_proxy_handler_helpers.py tests\test_proxy_openai_cache_stability.py tests\test_observability_metrics.py tests\test_cost_tracker_counterfactual.py 49 passed in 10.27s $ python -m ruff check . All checks passed! $ python -m mypy headroom Success: no issues found in 407 source files $ python -m pytest 53 failed, 7703 passed, 488 skipped, 5893 warnings, 131 errors in 595.18s (0:09:55) ``` Full-suite note: the full local `pytest` run was attempted on Windows/Python 3.13 after building `headroom._core`. It did not complete green due to broad pre-existing/local-environment failures outside this change area, dominated by SQLite/memory persistence permission/path errors plus unrelated adapter/cache/tool tests. The focused regression suite for this PR passes, and repo-level lint/type gates pass. ## Real Behavior Proof - Environment: Windows, Python 3.13.13, Rust/Cargo available, local `headroom._core` wheel built with `maturin build --profile ci`. - Exact command / steps: ran the OpenAI cache-stability tests with final `role: "tool"` and `role: "function"` chat tails. - Observed result: `test_openai_cache_mode_keeps_final_tool_observation_mutable[tool]` and `[function]` pass, proving the pipeline receives `frozen_message_count == 2` for a 3-message request instead of freezing all 3 messages. - Not tested: live Lemonade/KiloCode upstream session; no local Lemonade Server was available. ## 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 - [ ] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A ## Additional Notes Docs and CHANGELOG are N/A for this narrow proxy bug fix. The broad local `pytest` checkbox is intentionally left unchecked because the full suite had unrelated local-environment failures; see the test output above. Focused regression tests, `ruff check .`, and `mypy headroom` are green.
2026-07-09 14:51:01 +00:00
assert (
OpenAIHandlerMixin._strict_previous_turn_frozen_count(
[{"role": "assistant"}, {"role": "tool", "content": "observation"}],
0,
)
== 1
)
assert (
OpenAIHandlerMixin._strict_previous_turn_frozen_count(
[{"role": "user"}, {"role": "assistant"}, {"role": "tool", "content": "obs"}],
3,
)
== 2
)
assert (
OpenAIHandlerMixin._strict_previous_turn_frozen_count(
[{"role": "assistant"}, {"role": "function", "content": "legacy observation"}],
0,
)
== 1
)
assert (
OpenAIHandlerMixin._strict_previous_turn_frozen_count(
[{"role": "user"}, {"role": "assistant"}],
0,
)
== 2
)
original = [{"role": "system", "content": "keep"}, {"role": "user", "content": "hello"}]
restored, changed = OpenAIHandlerMixin._restore_frozen_prefix(
original,
[],
frozen_message_count=1,
)
assert restored == [{"role": "system", "content": "keep"}]
assert changed == 1
restored, changed = OpenAIHandlerMixin._restore_frozen_prefix(
original,
[{"role": "system", "content": "changed"}, {"role": "user", "content": "hello"}],
frozen_message_count=1,
)
assert restored == original
assert changed == 1
fix(proxy): relocate stray system-role messages to the top-level system param (#765) (#1357) ## Description On requests large enough to trigger compression, the proxy emitted an upstream Anthropic request whose `messages[0]` had `role: "system"`. Anthropic's Messages API rejects any `system` role inside `messages[]`: ``` 400 invalid_request_error: "messages.0: use the top-level 'system' parameter for the initial system prompt" ``` The original request correctly carries its system prompt in the top-level `system` parameter; a compression/transform/pipeline step relocates the harness system block into `messages[0]`, so the request fails outright (intermittent only because it requires a context large enough to compress). This adds a wire-contract guard in the Anthropic forwarder: as the **last** step before sending upstream (after every transform, memory injection, tool sort, and pipeline extension, covering both the Bedrock and direct paths), any stray `role="system"` message is relocated out of `messages[]` and merged back into the top-level `system` parameter. Content order is preserved (existing system first, relocated content after) and block-level `cache_control` survives. The guard is a no-op on the common path (no system-role entry → inputs pass through unchanged). Closes #765 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/proxy/helpers.py`: new pure helper `relocate_system_messages_to_top_level(messages, system) -> (clean_messages, new_system, changed)` plus `_system_message_to_blocks`. Handles `system` being `None`/`str`/`list`, never drops content, preserves order and content blocks. - `headroom/proxy/handlers/anthropic.py`: invoke the guard just before the byte-faithful forward block; on relocation, update `body["messages"]`/`body["system"]`, mark the body mutated (`system_role_relocated`) so the byte-faithful forwarder re-serializes, and log a warning. - `tests/test_proxy_handler_helpers.py`: 3 unit tests (relocate stray system into top-level, append-to-existing-system order, no-op without a system entry). - `CHANGELOG.md`: Bug Fixes entry. ## 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 --extra dev python -m pytest tests/test_proxy_handler_helpers.py -q 29 passed in 4.95s # Regression on the forward path (byte-faithful forwarding, system-prompt immutability, cache stability): $ uv run --extra dev python -m pytest tests/test_proxy_handler_helpers.py tests/test_proxy_byte_faithful_forwarding.py tests/test_proxy_system_prompt_immutable.py tests/test_proxy_anthropic_cache_stability.py -q 90 passed, 15 warnings in 29.72s $ uv run ruff check headroom/proxy/helpers.py headroom/proxy/handlers/anthropic.py tests/test_proxy_handler_helpers.py All checks passed! $ uv run ruff format --check ... # 3 files already formatted $ uv run mypy headroom/proxy/helpers.py headroom/proxy/handlers/anthropic.py Success: no issues found in 2 source files ``` ## Test verification (RED → GREEN) The new tests exercise the guard directly and import the new helper at module top, so reverting the production fix makes them fail at collection. **RED — production fix reverted (helper removed):** ```text ImportError while importing test module 'tests/test_proxy_handler_helpers.py'. E ImportError: cannot import name 'relocate_system_messages_to_top_level' from 'headroom.proxy.helpers' =========================== 1 error in 0.41s =============================== ``` **GREEN — production fix applied:** ```text tests/test_proxy_handler_helpers.py ... [100%] ======================= 3 passed, 26 deselected in 1.50s ======================= ``` ## Real Behavior Proof - Environment: Python 3.13, `uv run` in this repo, branch `fix/issue-765`. - Exact command / steps: ran the guard on a body in the exact #765 failure shape — `system: None` and a `role="system"` harness block at `messages[0]`: - Observed result: ```text BEFORE: messages[0].role = system (Anthropic 400 trigger) changed = True AFTER roles = ['user', 'assistant'] system param = [{"type": "text", "text": "You are Claude Code. <system-reminder>...</system-reminder>"}] OK: no role=system in messages[]; system content preserved in top-level param ``` The illegal `role="system"` entry is removed from `messages[]` and its content lands in the top-level `system` parameter — exactly the body Anthropic accepts. - Not tested: a full live 250k+-token Claude Code session against the real Anthropic API (needs a large live context + API key); the fix is validated at the request-shaping boundary the 400 is raised on. ## 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 or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Additional Notes The guard intentionally fires at the forwarder boundary rather than in any single transform: the issue's captures show the relocation can originate from the compression path, and pipeline extensions / hooks can also mutate `messages` late. Enforcing Anthropic's wire contract once, at the point the body is serialized upstream, fixes the 400 regardless of which step introduced the stray entry and matches the architecture invariant "never produce a `system`-role entry within `messages[]`". --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-08-13 18:52:09 +02:00
def test_relocate_system_messages_moves_stray_system_into_top_level() -> None:
# Issue #765: compression relocated the harness system block into
# messages[0] as a role="system" entry, which Anthropic rejects with a 400.
# The forwarder guard must move it back to the top-level `system` parameter.
messages = [
{"role": "system", "content": "You are a harness."},
{"role": "user", "content": "hi"},
]
clean, system, changed = relocate_system_messages_to_top_level(messages, None)
assert changed is True
# No role="system" entry may survive in messages[] — that is the wire-contract violation.
assert all(m.get("role") != "system" for m in clean)
assert clean == [{"role": "user", "content": "hi"}]
# The relocated content lands in the top-level system parameter.
assert system == [{"type": "text", "text": "You are a harness."}]
def test_relocate_system_messages_appends_to_existing_system() -> None:
messages = [
{"role": "system", "content": [{"type": "text", "text": "B"}]},
{"role": "user", "content": "hi"},
]
clean, system, changed = relocate_system_messages_to_top_level(messages, "A")
assert changed is True
assert clean == [{"role": "user", "content": "hi"}]
# Existing system first, relocated content after — wire order preserved.
assert system == [{"type": "text", "text": "A"}, {"type": "text", "text": "B"}]
def test_relocate_system_messages_noop_without_system_entry() -> None:
messages = [{"role": "user", "content": "hi"}]
clean, system, changed = relocate_system_messages_to_top_level(messages, "A")
assert changed is False
assert clean is messages
assert system == "A"
def test_headroom_bypass_helper_is_transport_neutral() -> None:
assert _headroom_bypass_enabled({"x-headroom-bypass": "true"}) is True
assert _headroom_bypass_enabled({"x-headroom-bypass": " TRUE "}) is True
assert _headroom_bypass_enabled({"x-headroom-mode": "passthrough"}) is True
assert _headroom_bypass_enabled({"x-headroom-mode": " PASSTHROUGH "}) is True
assert _headroom_bypass_enabled({"x-headroom-bypass": "false"}) is False
assert _headroom_bypass_enabled({}) is False
assert _headroom_bypass_enabled(None) is False
assert OpenAIHandlerMixin._headroom_bypass_enabled({"x-headroom-bypass": "true"}) is True
fix(proxy): compress Hermes scoped coding-agent passthrough (#1815) ## Description Compress Hermes Studio scoped coding-agent passthrough requests in the generic OpenAI passthrough handler. Hermes can route scoped Claude Code and Codex traffic through Headroom while preserving its own proxy paths; this PR keeps Hermes responsible for scoped proxy authentication/provider adaptation while still applying Headroom compression to supported chat payloads before forwarding. The compression remains narrow-scoped: - Only chat messages with `user` or `assistant` roles are compressed. - Tool, function, reasoning, and system items are preserved byte-stable. - Non-dict items in the Responses `input` array are preserved and spliced back. ## 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) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Detect `/api/codex-proxy/.../v1/responses` paths and compress supported Responses `input` chat items before forwarding. - Detect `/api/claude-code-proxy/.../v1/messages` paths and compress supported Anthropic `messages` payloads before forwarding. - Preserve bypass, malformed payload, missing-model, tool/function, reasoning/system, and non-dict passthrough behavior. - Add regression coverage in `tests/test_hermes_passthrough_compression.py`. ## Testing - [x] Unit tests pass (`pytest`) - [ ] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_hermes_passthrough_compression.py -v test_codex_proxy_preserves_tool_and_function_items PASSED test_codex_proxy_preserves_nondict_items PASSED test_codex_proxy_bypass_header_skips_compression PASSED test_codex_proxy_malformed_input_preserved PASSED test_codex_proxy_compression_applies_to_chat_messages PASSED test_claude_proxy_preserves_tool_use_items PASSED test_claude_proxy_bypass_header_skips_compression PASSED test_claude_proxy_no_model_forwarded_unchanged PASSED test_claude_proxy_compression_applies_to_chat_messages PASSED test_non_hermes_routes_not_affected PASSED ``` ## Real Behavior Proof - Environment: Author-reported local test environment for `headroom/proxy/handlers/openai.py` and `tests/test_hermes_passthrough_compression.py`. - Exact command / steps: `python -m pytest tests/test_hermes_passthrough_compression.py -v`. - Observed result: The 10 Hermes passthrough regression tests passed, covering Codex and Claude scoped proxy routes plus preservation/bypass cases. - Not tested: End-to-end Hermes Studio traffic against a live upstream service is not covered by this PR body evidence. ## 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 - [ ] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A. ## Additional Notes Generated with Claude Code. The unchecked checklist items are not required for this narrow proxy-handler test change. --------- Co-authored-by: x1051445024 <你的GitHub注册邮箱> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-14 06:22:46 +08:00
def test_openai_passthrough_without_config_preserves_generic_request() -> None:
handler = object.__new__(OpenAIHandlerMixin)
handler.http_client = _RecordingHttpClient("h2")
request = _PassthroughRequest()
response = asyncio.run(handler.handle_passthrough(request, "https://api.openai.com"))
assert response.status_code == 200
assert json.loads(response.body)["client"] == "h2"
def test_openai_passthrough_connect_timeout_returns_502() -> None:
handler = object.__new__(OpenAIHandlerMixin)
handler.http_client = _TimeoutHttpClient()
async def run():
return await handler.handle_passthrough(
_PassthroughRequest(),
"https://api.openai.com",
)
response = asyncio.run(run())
assert response.status_code == 502
payload = json.loads(response.body)
assert payload["error"]["type"] == "connection_error"
assert "Failed to connect to upstream API" in payload["error"]["message"]
fix(codex): poll /wham/usage for subscription limits (handshake no longer sends x-codex-* headers) (#924) ## Description Codex's subscription usage window (primary/secondary rate-limit gauges) stopped populating for ChatGPT-OAuth sessions. This PR restores it by polling Codex's dedicated usage endpoint instead of relying on response headers that are no longer sent. ### Why the previous approach no longer works The existing code populates `CodexRateLimitState` from `x-codex-*` rate-limit headers captured on the `/v1/responses` WebSocket handshake (`update_from_headers` at WS accept). That worked when OpenAI returned `x-codex-primary-used-percent`, `x-codex-primary-window-minutes`, etc. on the handshake response. OpenAI has since stopped sending those headers on the ChatGPT WebSocket handshake. I confirmed this by faithfully replaying a real Plus-account handshake (both `prewarm` and regular `request_kind`): no `x-codex-*` headers come back on either. This matches OpenAI's own move to a dedicated usage endpoint (`GET /backend-api/codex/usage` in CodexApi mode) and reports such as openai/codex#14728. So `update_from_headers` now runs on every accept but finds nothing to parse, and the window silently stays empty. The headers aren't coming back, so there is nothing to fix in the parsing path. The data now lives behind a request we have to make ourselves. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `subscription/codex_rate_limits.py`: - `parse_codex_usage_payload()` / `update_from_usage_payload()` — map the `GET /backend-api/wham/usage` JSON (`rate_limit.primary_window` / `secondary_window` with `used_percent`, `limit_window_seconds`, `reset_at`; `credits`; `rate_limit_reached_type`) into the existing `CodexRateLimitState`. `limit_window_seconds` is converted to window-minutes with the same round-up codex-rs uses (`(secs + 59) // 60`). - `maybe_schedule_usage_poll()` — fire-and-forget, throttled to one request per 60s, scoped to ChatGPT sessions (requires both a Bearer token and `ChatGPT-Account-Id`; API-key traffic is skipped). Uses an in-flight guard so concurrent accepts don't stack polls. Endpoint is overridable via `HEADROOM_CODEX_USAGE_URL`. - `proxy/handlers/openai.py`: - At the Codex WS accept site, after the now-usually-empty `update_from_headers` block, schedule the usage poll. Wrapped in `contextlib.suppress` and fully non-blocking so it can never delay or fail the WebSocket accept. The old header-capture path is intentionally left in place as a no-cost fallback in case OpenAI restores the headers. ## 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 (live `/wham/usage` replay against a Plus account returned HTTP 200 with the expected schema; payload fixture in tests mirrors that real shape) ## Test Output ``` $ uv run pytest tests/test_codex_rate_limits.py -q ........................................ [100%] 41 passed in 0.16s $ uv run ruff check headroom/subscription/codex_rate_limits.py headroom/proxy/handlers/openai.py tests/test_codex_rate_limits.py All checks passed! $ uv run mypy headroom/subscription/codex_rate_limits.py Success: no issues found in 1 source file ``` ## Additional Notes - New tests cover: full-payload mapping, window-minutes round-up, credits balance kept only when `has_credits`, promo object vs string, empty payload returns `None`, missing `used_percent` skipped, header-gating (requires Bearer + account-id), poll throttling, and no-event-loop safety. - Scoping to `ChatGPT-Account-Id` keeps the poll off API-key traffic, and the 60s throttle plus in-flight guard bound it to at most one lightweight GET per minute per running proxy. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 00:03:14 +02:00
def test_prefers_http1_passthrough_matches_chatgpt_hosts_only() -> None:
assert _prefers_http1_passthrough("https://chatgpt.com") is True
assert _prefers_http1_passthrough("https://chatgpt.com/backend-api/me") is True
assert _prefers_http1_passthrough("https://api.chatgpt.com") is True
assert _prefers_http1_passthrough("https://CHATGPT.COM/backend-api/me") is True
assert _prefers_http1_passthrough("https://api.openai.com") is False
assert _prefers_http1_passthrough("https://notchatgpt.com") is False
assert _prefers_http1_passthrough("https://chatgpt.com.evil.com") is False
assert _prefers_http1_passthrough("") is False
def test_chatgpt_passthrough_uses_http1_client() -> None:
handler = object.__new__(OpenAIHandlerMixin)
handler.http_client = _RecordingHttpClient("h2")
handler.http_client_h1 = _RecordingHttpClient("h1")
response = asyncio.run(
handler.handle_passthrough(_ChatGPTAccountRequest(), "https://chatgpt.com")
)
assert response.status_code == 200
assert json.loads(response.body)["client"] == "h1"
assert handler.http_client.calls == 0
assert handler.http_client_h1.calls == 1
def test_non_chatgpt_passthrough_uses_default_client() -> None:
handler = object.__new__(OpenAIHandlerMixin)
handler.http_client = _RecordingHttpClient("h2")
handler.http_client_h1 = _RecordingHttpClient("h1")
response = asyncio.run(
handler.handle_passthrough(_PassthroughRequest(), "https://api.openai.com")
)
assert response.status_code == 200
assert json.loads(response.body)["client"] == "h2"
assert handler.http_client.calls == 1
assert handler.http_client_h1.calls == 0
def test_chatgpt_passthrough_falls_back_when_h1_client_missing() -> None:
handler = object.__new__(OpenAIHandlerMixin)
handler.http_client = _RecordingHttpClient("h2")
handler.http_client_h1 = None
response = asyncio.run(
handler.handle_passthrough(_ChatGPTAccountRequest(), "https://chatgpt.com")
)
assert response.status_code == 200
assert json.loads(response.body)["client"] == "h2"
assert handler.http_client.calls == 1
feat: add Vertex AI proxy routing (#793) ## Description Adds first-class GCP Vertex AI proxy routing for publisher REST endpoints so Vertex requests are forwarded to a configurable regional Vertex host instead of falling through to the generic OpenAI/Anthropic/Gemini passthrough selection. Fixes #792 ## Type of Change - [x] New feature (non-breaking change that adds functionality) - [x] Documentation update ## Changes Made - Added a `vertex` provider target with `VERTEX_TARGET_API_URL` and `--vertex-api-url` support. - Registered explicit Vertex publisher routes for Google `generateContent`, `streamGenerateContent`, `countTokens` and Anthropic publisher `rawPredict`, `streamRawPredict` passthrough. - Added startup banner/routing output for Vertex AI. - Added focused tests for provider target resolution, CLI/env config, banner output, and route delegation. - Added `wiki/vertex.md` with usage examples and Google Cloud source links. ## Sources - Vertex AI Gemini inference reference: https://docs.cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference - Google Cloud REST authentication: https://docs.cloud.google.com/docs/authentication/rest - Google Application Default Credentials: https://docs.cloud.google.com/docs/authentication/application-default-credentials ## Testing - [x] Linting passes (`python -m ruff check .`) - [x] New tests added for new functionality - [x] Focused unit tests pass - [ ] Full unit suite completed locally - [ ] Rust tests completed locally - [ ] Type checking passes locally ## Test Output ```text $ python -m ruff check . All checks passed! $ python -m pytest tests/test_provider_registry.py tests/test_provider_proxy_routes.py tests/test_cli_proxy_env.py tests/test_banner_upstream_targets.py -q 57 passed, 1 warning in 13.75s ``` Local limitations: - `python -m pytest tests scripts/tests -q` timed out after 1 hour on this Windows machine before completing. - `cargo test -p headroom-proxy --test integration_vertex_raw_predict` could not run because `cargo` is not installed on PATH in this environment. - The commit hook's `mypy` step fails locally on an existing Windows `fcntl` typing issue in `headroom/subscription/tracker.py`; `ruff`, `ruff-format`, and plugin-version hooks passed, and the commit was made with only `mypy` skipped. ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have made corresponding changes to the documentation - [x] I have added tests that prove the feature works - [ ] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable
2026-06-10 01:05:30 -05:00
def test_passthrough_usage_normalizes_vertex_usage_metadata() -> None:
usage = _passthrough_usage_from_json(
{
"usageMetadata": {
"promptTokenCount": 11,
"candidatesTokenCount": 7,
"cachedContentTokenCount": 3,
}
}
)
assert usage == {
"input_tokens": 11,
"output_tokens": 7,
"cache_read_input_tokens": 3,
}
fix(proxy/cost): count Gemini thinking tokens in output usage (#2639) ## Description The Gemini handlers take the response's output-token count straight from `candidatesTokenCount`: ```python output_tokens = _usage_int(usage.get("candidatesTokenCount")) ``` For Gemini 2.5 thinking models that undercounts. Gemini reports `candidatesTokenCount` **sometimes inclusive** of the reasoning tokens (`thoughtsTokenCount`) and **sometimes exclusive** of them. When it is exclusive, the thinking tokens are a separate bucket that is still billed at the output rate, so dropping them makes `output_tokens` (and therefore the output cost that flows through `record_tokens` -> `estimate_cost`) too low. The gap grows with reasoning effort. litellm handles exactly this: it adds `thoughtsTokenCount` to completion tokens unless `promptTokenCount + candidatesTokenCount == totalTokenCount` (its `is_candidate_token_count_inclusive` check). The Headroom handlers had no equivalent. ## Fix Add `gemini_output_tokens(usage_meta)` in `headroom/proxy/token_counting.py`: - No `thoughtsTokenCount` (the common non-2.5 case): return `candidatesTokenCount` unchanged. - `promptTokenCount + candidatesTokenCount == totalTokenCount`: candidates already include thoughts, return `candidatesTokenCount`. - Otherwise: return `candidatesTokenCount + thoughtsTokenCount`. This mirrors litellm's rule and is robust to missing or null fields. Wire it into the native Gemini handler (both the generate and count paths), the streaming usage extractors, and the OpenAI-compatible passthrough usage normalizer, so every Gemini usage path counts output the same way. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature - [ ] Breaking change - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring ## Changes Made - `headroom/proxy/token_counting.py`: add `gemini_output_tokens()`. - `headroom/proxy/handlers/gemini.py`: use it for `output_tokens` on both response paths. - `headroom/proxy/handlers/streaming.py`: use it in the two Gemini streaming usage extractors. - `headroom/proxy/handlers/openai.py`: use it in `_passthrough_usage_from_json` (Gemini-shaped usage). - `tests/test_proxy_handler_helpers.py`: unit test for `gemini_output_tokens` (inclusive / exclusive / no-thinking / empty) and a `_passthrough_usage_from_json` test that thinking tokens land in `output_tokens`. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check` / `ruff format --check`) - [x] Type checking passes (`mypy`) - [x] New tests added for the fix - [ ] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_proxy_handler_helpers.py -k "gemini_output_tokens or thinking or vertex_usage_metadata" -q 3 passed $ python -m pytest tests/test_proxy_gemini_native_integration.py tests/test_proxy/test_gemini_savings_profile.py tests/test_proxy_handler_helpers.py -q 38 passed, 18 skipped # with the wiring reverted, the passthrough test fails (output_tokens is 200, not 700): $ git stash push headroom/proxy/handlers/openai.py && \ python -m pytest tests/test_proxy_handler_helpers.py -k passthrough_usage_counts_gemini_thinking -q 1 failed $ uvx ruff@0.15.17 check headroom/proxy/token_counting.py headroom/proxy/handlers/gemini.py headroom/proxy/handlers/streaming.py headroom/proxy/handlers/openai.py tests/test_proxy_handler_helpers.py All checks passed! $ uvx mypy@1.20.2 --ignore-missing-imports headroom/proxy/token_counting.py Success: no issues found in 1 source file ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.12, project venv (`uv sync --extra proxy`), `uvx ruff@0.15.17` / `uvx mypy@1.20.2`, pytest in the venv. - Exact command / steps: called `gemini_output_tokens` on an exclusive usage (`prompt=1000, candidates=200, thoughts=500, total=1700`), an inclusive usage (`candidates=700, total=1700`), a no-thinking usage, and `{}`; drove `_passthrough_usage_from_json` with a thinking usage; then reverted the handler wiring and re-ran the passthrough test. - Observed result: exclusive returns 700 (200 visible plus 500 thinking), inclusive returns 700, no-thinking returns the candidates count, empty returns 0; `_passthrough_usage_from_json` reports `output_tokens=700`. With the wiring reverted it reports 200 (the undercount). Verified against litellm's documented rule. - Not tested: a live Gemini 2.5 request end to end (the accounting is verified at the usage-extraction boundary against litellm's reference logic). ## 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 - [ ] I have updated the CHANGELOG.md if applicable Co-authored-by: JD Davis <mxjerrett@gmail.com>
2026-07-29 21:44:03 +05:30
def test_gemini_output_tokens_includes_thinking_when_exclusive() -> None:
"""Gemini 2.5 thinking: when prompt + candidates != total, thoughtsTokenCount
is a separate output bucket and must be added, or output cost undercounts."""
from headroom.proxy.token_counting import gemini_output_tokens
exclusive = {
"promptTokenCount": 1000,
"candidatesTokenCount": 200,
"thoughtsTokenCount": 500,
"totalTokenCount": 1700,
}
assert gemini_output_tokens(exclusive) == 700 # 200 visible + 500 thinking
# Inclusive: candidatesTokenCount already covers thoughts (prompt+cand==total).
inclusive = {
"promptTokenCount": 1000,
"candidatesTokenCount": 700,
"thoughtsTokenCount": 500,
"totalTokenCount": 1700,
}
assert gemini_output_tokens(inclusive) == 700
# No thinking tokens: just the candidates count (common non-2.5 case).
assert gemini_output_tokens({"candidatesTokenCount": 42, "totalTokenCount": 100}) == 42
# Robust to empty / missing fields.
assert gemini_output_tokens({}) == 0
def test_passthrough_usage_counts_gemini_thinking_tokens() -> None:
"""_passthrough_usage_from_json must include thinking tokens in output_tokens."""
usage = _passthrough_usage_from_json(
{
"usageMetadata": {
"promptTokenCount": 1000,
"candidatesTokenCount": 200,
"thoughtsTokenCount": 500,
"totalTokenCount": 1700,
"cachedContentTokenCount": 100,
}
}
)
assert usage["output_tokens"] == 700
assert usage["input_tokens"] == 1000
feat: add Vertex AI proxy routing (#793) ## Description Adds first-class GCP Vertex AI proxy routing for publisher REST endpoints so Vertex requests are forwarded to a configurable regional Vertex host instead of falling through to the generic OpenAI/Anthropic/Gemini passthrough selection. Fixes #792 ## Type of Change - [x] New feature (non-breaking change that adds functionality) - [x] Documentation update ## Changes Made - Added a `vertex` provider target with `VERTEX_TARGET_API_URL` and `--vertex-api-url` support. - Registered explicit Vertex publisher routes for Google `generateContent`, `streamGenerateContent`, `countTokens` and Anthropic publisher `rawPredict`, `streamRawPredict` passthrough. - Added startup banner/routing output for Vertex AI. - Added focused tests for provider target resolution, CLI/env config, banner output, and route delegation. - Added `wiki/vertex.md` with usage examples and Google Cloud source links. ## Sources - Vertex AI Gemini inference reference: https://docs.cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference - Google Cloud REST authentication: https://docs.cloud.google.com/docs/authentication/rest - Google Application Default Credentials: https://docs.cloud.google.com/docs/authentication/application-default-credentials ## Testing - [x] Linting passes (`python -m ruff check .`) - [x] New tests added for new functionality - [x] Focused unit tests pass - [ ] Full unit suite completed locally - [ ] Rust tests completed locally - [ ] Type checking passes locally ## Test Output ```text $ python -m ruff check . All checks passed! $ python -m pytest tests/test_provider_registry.py tests/test_provider_proxy_routes.py tests/test_cli_proxy_env.py tests/test_banner_upstream_targets.py -q 57 passed, 1 warning in 13.75s ``` Local limitations: - `python -m pytest tests scripts/tests -q` timed out after 1 hour on this Windows machine before completing. - `cargo test -p headroom-proxy --test integration_vertex_raw_predict` could not run because `cargo` is not installed on PATH in this environment. - The commit hook's `mypy` step fails locally on an existing Windows `fcntl` typing issue in `headroom/subscription/tracker.py`; `ruff`, `ruff-format`, and plugin-version hooks passed, and the commit was made with only `mypy` skipped. ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have made corresponding changes to the documentation - [x] I have added tests that prove the feature works - [ ] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable
2026-06-10 01:05:30 -05:00
def test_vertex_passthrough_records_usage_metadata_for_dashboard() -> None:
handler = object.__new__(HeadroomProxy)
handler.http_client = _VertexUsageClient()
outcomes = []
async def next_request_id(): # noqa: ANN202
return "req_vertex"
async def record(outcome): # noqa: ANN001, ANN202
outcomes.append(outcome)
handler._next_request_id = next_request_id
handler._record_request_outcome = record
response = asyncio.run(
handler.handle_passthrough(
_VertexPassthroughRequest(),
"https://vertex.test",
"generateContent",
"vertex:google",
)
)
assert response.status_code == 200
assert len(outcomes) == 1
outcome = outcomes[0]
assert outcome.provider == "vertex:google"
assert outcome.model == "gemini-2.0-flash"
assert outcome.optimized_tokens == 11
assert outcome.output_tokens == 7
assert outcome.cache_read_tokens == 3
def test_vertex_stream_passthrough_preserves_chunks_and_records_usage() -> None:
handler = object.__new__(HeadroomProxy)
handler.http_client = _VertexStreamClient()
outcomes = []
async def next_request_id(): # noqa: ANN202
return "req_vertex_stream"
async def record(outcome): # noqa: ANN001, ANN202
outcomes.append(outcome)
handler._next_request_id = next_request_id
handler._record_request_outcome = record
response = asyncio.run(
handler.handle_passthrough(
_VertexStreamPassthroughRequest(),
"https://vertex.test",
"streamGenerateContent",
"vertex:google",
)
)
assert isinstance(response, StreamingResponse)
async def collect(): # noqa: ANN202
return [chunk async for chunk in response.body_iterator]
chunks = asyncio.run(collect())
assert len(chunks) == 2
assert chunks[0].startswith(b'data: {"candidates"')
assert b'"usageMetadata"' in chunks[1]
assert len(outcomes) == 1
outcome = outcomes[0]
assert outcome.provider == "vertex:google"
assert outcome.model == "gemini-2.0-flash"
assert outcome.optimized_tokens == 13
assert outcome.output_tokens == 5
assert outcome.cache_read_tokens == 2
def test_stream_finalizer_records_vertex_provider_for_dashboard() -> None:
handler = object.__new__(HeadroomProxy)
handler.config = SimpleNamespace(log_full_messages=False)
outcomes = []
async def record(outcome): # noqa: ANN001, ANN202
outcomes.append(outcome)
handler._record_request_outcome = record
asyncio.run(
handler._finalize_stream_response(
body={"contents": [{"role": "user", "parts": [{"text": "hello"}]}]},
provider="gemini",
outcome_provider="vertex:google",
model="gemini-2.0-flash",
request_id="req_vertex_stream_final",
original_tokens=20,
optimized_tokens=12,
tokens_saved=8,
transforms_applied=["test-transform"],
optimization_latency=3.0,
stream_state={
"input_tokens": 12,
"output_tokens": 5,
"cache_read_input_tokens": 2,
"cache_creation_input_tokens": 0,
"cache_creation_ephemeral_5m_input_tokens": 0,
"cache_creation_ephemeral_1h_input_tokens": 0,
"total_bytes": 100,
"sse_buffer": bytearray(),
"ttfb_ms": 4.0,
},
start_time=0.0,
tags={"route": "vertex"},
)
)
assert len(outcomes) == 1
outcome = outcomes[0]
assert outcome.provider == "vertex:google"
assert outcome.model == "gemini-2.0-flash"
assert outcome.optimized_tokens == 12
assert outcome.output_tokens == 5
assert outcome.tokens_saved == 8
assert outcome.cache_read_tokens == 2
def test_vertex_gemini_non_text_generate_records_dashboard_outcome() -> None:
handler = object.__new__(HeadroomProxy)
handler.memory_handler = None
handler.rate_limiter = None
outcomes = []
upstream_urls = []
async def next_request_id(): # noqa: ANN202
return "req_vertex_image"
async def record(outcome): # noqa: ANN001, ANN202
outcomes.append(outcome)
async def retry_request(method, url, headers, body): # noqa: ANN001, ANN202
upstream_urls.append(url)
request = httpx.Request(method, url, headers=headers)
return httpx.Response(
200,
request=request,
headers={"content-type": "application/json"},
json={
"usageMetadata": {
"promptTokenCount": 31,
"candidatesTokenCount": 4,
"cachedContentTokenCount": 6,
}
},
)
handler._next_request_id = next_request_id
handler._record_request_outcome = record
handler._retry_request = retry_request
response = asyncio.run(
handler.handle_gemini_generate_content(
_VertexGeminiImageRequest(),
"gemini-2.0-flash",
"https://vertex.test",
"vertex:google",
)
)
assert response.status_code == 200
assert upstream_urls == [
"https://vertex.test/v1/projects/p/locations/us-central1/publishers/google/models/gemini-2.0-flash:generateContent"
]
assert response.headers["x-headroom-tokens-before"] == "31"
assert response.headers["x-headroom-tokens-after"] == "31"
assert response.headers["x-headroom-tokens-saved"] == "0"
assert len(outcomes) == 1
outcome = outcomes[0]
assert outcome.provider == "vertex:google"
assert outcome.model == "gemini-2.0-flash"
assert outcome.original_tokens == 31
assert outcome.optimized_tokens == 31
assert outcome.output_tokens == 4
assert outcome.cache_read_tokens == 6
assert outcome.num_messages == 1
def test_retry_request_retries_connect_timeout() -> None:
proxy = object.__new__(HeadroomProxy)
proxy.http_client = _RetryThenSuccessClient()
proxy.config = SimpleNamespace(
retry_enabled=True,
retry_max_attempts=2,
retry_base_delay_ms=0,
retry_max_delay_ms=0,
)
response = asyncio.run(
proxy._retry_request(
"POST",
"https://api.openai.com/v1/responses",
{},
{"model": "gpt-5"},
)
)
assert response.status_code == 200
assert proxy.http_client.attempts == 2
fix(proxy): cancel retry backoff on shutdown (#1834) ## Description During proxy shutdown, an in-flight retrying request can currently stay asleep inside `_retry_request()` and keep the client socket hanging until the retry timer expires or an external supervisor kills the process. This wires retry backoff to a proxy-scoped shutdown event so shutdown interrupts those waits immediately and returns a clear `503` response instead of leaving the request stalled. Closes #1821. ## 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 proxy-scoped shutdown event in `headroom/proxy/server.py`. - Cleared that event at startup and set it at shutdown before teardown proceeds. - Replaced both retry-backoff sleeps with a helper that wakes on either timeout or shutdown. - Returned a shutdown `503` with `retry-after: 0` when shutdown interrupts retry backoff. - Stopped the shutdown interruption logs from falling back to the raw upstream URL when no safe path string is available. - Added focused regressions for retry-backoff interruption and shutdown event signaling. - Updated the existing Retry-After tests to observe the new shutdown-aware wait helper instead of the old raw sleep hook. ## Testing - [x] Unit tests pass (`uv run pytest tests/test_proxy_handler_helpers.py tests/test_proxy_pipeline_lifecycle.py -q`) - [x] Unit tests pass (`uv run pytest tests/test_proxy_retry_429.py -q`) - [x] Linting passes (`uv run ruff check headroom/proxy/server.py tests/test_proxy_handler_helpers.py tests/test_proxy_retry_429.py tests/test_proxy_pipeline_lifecycle.py`) - [ ] Type checking passes (`uv run mypy headroom`) - [x] New tests added for new functionality when applicable - [ ] Manual testing performed ### Test Output ```text uv run pytest tests/test_proxy_handler_helpers.py tests/test_proxy_pipeline_lifecycle.py -q 32 passed, 1 warning in 13.05s uv run pytest tests/test_proxy_retry_429.py -q 10 passed, 1 warning in 1.12s uv run ruff check headroom/proxy/server.py tests/test_proxy_handler_helpers.py tests/test_proxy_retry_429.py tests/test_proxy_pipeline_lifecycle.py All checks passed! ``` ## Real Behavior Proof - Environment: Windows, project `uv` environment, focused proxy retry and shutdown regressions. - Exact command / steps: copy the updated shutdown regression files into a detached `origin/main` worktree and run `tests/test_proxy_handler_helpers.py` plus `tests/test_proxy_pipeline_lifecycle.py`, then rerun those files on this branch and separately rerun `tests/test_proxy_retry_429.py` after updating the existing Retry-After tests to patch the shutdown-aware wait helper. - Observed result: base fails because retry backoff still returns the original `429` and `shutdown()` leaves the retry event unset; head passes the focused file, preserves the existing Retry-After assertions, and returns a shutdown `503` with `retry-after: 0` while signaling retry waiters during shutdown. - Not tested: live systemd-managed shutdown on Linux or a full VS Code / Claude Code session. ## 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 or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Additional Notes This is intentionally scoped to retry backoff during shutdown. It does not try to cancel unrelated in-flight request work or change the broader retry policy outside shutdown.
2026-07-06 09:24:47 -04:00
def test_retry_request_returns_503_when_shutdown_interrupts_retry_sleep() -> None:
class _Always429Client:
def __init__(self) -> None:
self.attempts = 0
async def post(self, url, **kwargs): # type: ignore[no-untyped-def]
self.attempts += 1
return httpx.Response(
429,
request=httpx.Request("POST", url),
json={"error": {"message": "slow down"}},
headers={"retry-after": "30"},
)
proxy = object.__new__(HeadroomProxy)
proxy.http_client = _Always429Client()
proxy.config = SimpleNamespace(
retry_enabled=True,
retry_max_attempts=3,
retry_base_delay_ms=30000,
retry_max_delay_ms=30000,
)
proxy._shutdown_event = asyncio.Event()
proxy._shutdown_event.set()
response = asyncio.run(
proxy._retry_request(
"POST",
"https://api.anthropic.test/v1/messages",
{},
{"model": "claude-3-5-sonnet"},
)
)
assert response.status_code == 503
assert response.json() == {
"error": {
"type": "shutdown",
"message": "Proxy is shutting down; retry backoff cancelled.",
}
}
assert response.headers["retry-after"] == "0"
assert proxy.http_client.attempts == 1
def test_anthropic_tool_sort_and_context_append_helpers() -> None:
tools = [
{"type": "function", "function": {"name": "beta"}},
{"name": "alpha"},
{"type": "tool"},
]
sorted_tools = AnthropicHandlerMixin._sort_tools_deterministically(tools)
assert [AnthropicHandlerMixin._tool_sort_key(tool)[0] for tool in sorted_tools] == [
"alpha",
"beta",
"tool",
]
assert AnthropicHandlerMixin._sort_tools_deterministically(None) is None
fix: preserve anthropic passthrough tool order (#1427) ## Description Preserves Anthropic `tools` order when Headroom is forwarding a passthrough/no-optimize request. This fixes a Claude Code style `tool_result` continuation failure against stricter Anthropic-compatible upstreams that treat the client's original tool ordering as part of the conversation state. Closes #1417 ## 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 - Preserve client-provided Anthropic `tools` order when `optimize=False` or the request is explicitly in Headroom passthrough/bypass mode. - Keep deterministic tool sorting for optimized requests where Headroom may rewrite the body for cache stability. - Avoid sorting batch-request tools before the no-optimize passthrough branch. - Add regression coverage for the Anthropic HTTP path to prove no-optimize forwarding keeps `Read`, then `Bash` tool order. - Update existing cache-stability and byte-faithful forwarding tests so no-optimize/passthrough expects preserved client order while optimized mode still proves deterministic sorting. ## Testing - [x] Focused unit tests pass (`pytest` on touched proxy test files) - [x] Linting passes (`ruff check` and `ruff format --check` on touched files) - [x] Type checking passes (`mypy headroom`) - [x] New regression tests added - [x] Manual testing performed ### Test Output ```text $ uv run --no-project --python /opt/homebrew/opt/python@3.13/bin/python3.13 --with pytest --with pytest-asyncio --with anyio --with 'httpx[http2]' --with fastapi --with pydantic --with tiktoken --with click --with rich --with opentelemetry-api --with opentelemetry-sdk --with zstandard --with openai --with mcp --with uvicorn pytest tests/test_proxy_handler_helpers.py tests/test_anthropic_stage_timings.py tests/test_proxy_anthropic_cache_stability.py tests/test_proxy_byte_faithful_forwarding.py -q ============================= test session starts ============================== platform darwin -- Python 3.13.11, pytest-9.1.1, pluggy-1.6.0 rootdir: /Users/vinaygupta/Desktop/git/headroom-fix-anthropic-tool-order configfile: pyproject.toml plugins: anyio-4.14.1, asyncio-1.4.0 asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function collected 87 items tests/test_proxy_handler_helpers.py .......................... [ 29%] tests/test_anthropic_stage_timings.py .... [ 34%] tests/test_proxy_anthropic_cache_stability.py ......................... [ 63%] tests/test_proxy_byte_faithful_forwarding.py ........................... [ 94%] ..... [100%] =============================== warnings summary =============================== .../fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. ======================== 87 passed, 1 warning in 5.13s ========================= $ uv run --no-project --python /opt/homebrew/opt/python@3.13/bin/python3.13 --with ruff==0.15.17 ruff format --check headroom/proxy/handlers/anthropic.py tests/test_proxy_handler_helpers.py tests/test_anthropic_stage_timings.py tests/test_proxy_anthropic_cache_stability.py tests/test_proxy_byte_faithful_forwarding.py 5 files already formatted $ uv run --no-project --python /opt/homebrew/opt/python@3.13/bin/python3.13 --with ruff==0.15.17 ruff check headroom/proxy/handlers/anthropic.py tests/test_proxy_handler_helpers.py tests/test_anthropic_stage_timings.py tests/test_proxy_anthropic_cache_stability.py tests/test_proxy_byte_faithful_forwarding.py All checks passed! ``` ## Real Behavior Proof - Environment: macOS, Python 3.13.11, local fake Anthropic-compatible upstream, local Headroom proxy launched with `--no-optimize --no-cache --no-rate-limit --stateless`. - Exact command / steps: ran a local reproduction harness that starts a fake `/v1/messages` upstream and Headroom proxy, then sends a Claude Code style two-turn flow: first assistant `Bash` `tool_use`, then user `tool_result`. - Observed result: after this patch, both direct and proxied flows returned `200` for `first_tool_use` and `second_tool_result`. The fake upstream log showed the proxied `tools` array remained `["Read", "Bash"]` on both turns. ```text DIRECT first_tool_use: 200 second_tool_result: 200 PROXIED first_tool_use: 200 second_tool_result: 200 UPSTREAM REQUEST LOG proxied first turn tools: ["Read", "Bash"] proxied tool_result turn tools: ["Read", "Bash"] ``` - Not tested: full `pytest`, full-repo `ruff check .`, `mypy headroom`, or a live third-party Anthropic-compatible provider. ## 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 or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A ## Additional Notes - This PR intentionally does not add documentation because it fixes passthrough behavior rather than introducing a new user-facing option. - The code-comment checklist item is left unchecked because the change is covered by a small helper docstring and regression tests; no extra inline comments seemed necessary. - `CHANGELOG.md` is left unchanged because this is a narrowly scoped bug fix. - Local pytest collection for these proxy tests required a local `headroom._core` extension symlink, which was removed before committing.
2026-06-30 08:38:51 -05:00
assert AnthropicHandlerMixin._tools_for_forwarding(tools, preserve_order=True) == tools
assert [
AnthropicHandlerMixin._tool_sort_key(tool)[0]
for tool in AnthropicHandlerMixin._tools_for_forwarding(tools, preserve_order=False) or []
] == [
"alpha",
"beta",
"tool",
]
assert (
AnthropicHandlerMixin._append_context_to_latest_non_frozen_user_turn(
[], "ctx", frozen_message_count=0
)
== []
)
assert AnthropicHandlerMixin._append_context_to_latest_non_frozen_user_turn(
[{"role": "user", "content": "hello"}],
"ctx",
frozen_message_count=0,
) == [{"role": "user", "content": "hello\n\nctx"}]
fix: A3 — byte-faithful Python forwarders; serialize canonical only when mutated Eliminates P0-2 universally. Every Python forwarder (server.py `_retry_request`, handlers/streaming.py `_stream_response`, handlers/openai.py `_ws_http_fallback`, handlers/batch.py `_batch_passthrough` + batch-create + Google batch passthrough, handlers/anthropic.py CCR continuation + batch endpoint) now switches from `httpx ... json=body` to `httpx ... content=raw_bytes`. The default httpx JSON encoder was re-serializing every request with `, `/`: ` separators and `\\uXXXX` ASCII escapes — collapsing Anthropic prompt-cache hit-rate. Forwarder strategy: - unmutated body → forward `await request.body()` verbatim; - mutated body → re-serialize once via the new `serialize_body_canonical(body) -> bytes` helper (compact separators, `ensure_ascii=False`, dict insertion order preserved). `HEADROOM_PROXY_PYTHON_FORWARDER_MODE` env var configures the mode: - `byte_faithful` (default) — the new behavior; - `legacy_json_kwarg` — explicit operator opt-in for emergency rollback. Documented in `docs/content/docs/configuration.mdx`. NOT a fallback — unknown values raise loudly per build constraint #4. `BodyMutationTracker` accompanies each request through the handler so transform sites mark the tracker (`memory_injection`, `image_compression`, `compression_*`, `batch_compression`, `ccr_continuation`, etc.). At forwarder dispatch we additionally compare the final body dict against the parsed original bytes as a structural safety net — any silent mutation we missed still triggers canonical re-serialization. A2 follow-up: `handlers/openai.py:534-540` (Chat Completions memory injection) was prepending a system message; replaced with `append_text_to_latest_user_chat_message`, the OpenAI Chat Completions analog of `_append_context_to_latest_non_frozen_user_turn`. The cache hot zone (system messages) is now sacrosanct on /v1/chat/completions too. Honors `HEADROOM_MEMORY_INJECTION_MODE=disabled`. Structured logging: every forwarder emits an `event=outbound_request` log line with `forwarder`, `path`, `body_bytes`, `body_mutated`, `mutation_reasons`, `source` (passthrough|canonical|legacy), `request_id`. Never logs Authorization or full body. `_read_request_json` factored to share `_read_request_body_bytes` with new `read_request_json_with_bytes` so the anthropic handler can capture both the parsed dict and the original (decompressed) bytes. Tests: - `tests/test_proxy_byte_faithful_forwarding.py` (28 tests): SHA-256 byte-equality on /v1/messages and streaming, unicode preservation, numeric precision, mutation-tracker invariants, canonical-serializer properties, legacy-mode rollback, OpenAI Chat memory routing. - Existing test mocks updated to accept the new `**kwargs` on `_retry_request` (no behavior change). - `tests/test_proxy_handlers_batch.py` updated to read the captured `content=` bytes (formerly `json=`). - One A2 test corrected (`test_anthropic_tool_sort_and_context_append_helpers`) to match the live-zone-tail semantics introduced by A2. Constraints satisfied: configurable env var; no new regex / hardcodes; no silent fallback (`legacy_json_kwarg` is operator opt-in); performant (`prepare_outbound_body_bytes` is O(1) for passthrough); elegant single-responsibility helpers; structured tracing logs.
2026-05-02 09:02:10 -07:00
# PR-A2 semantics: list-content user messages get the context appended
# to the first text block (live-zone-tail injection).
assert AnthropicHandlerMixin._append_context_to_latest_non_frozen_user_turn(
[{"role": "user", "content": [{"type": "text", "text": "hello"}]}],
"ctx",
frozen_message_count=0,
fix: A3 — byte-faithful Python forwarders; serialize canonical only when mutated Eliminates P0-2 universally. Every Python forwarder (server.py `_retry_request`, handlers/streaming.py `_stream_response`, handlers/openai.py `_ws_http_fallback`, handlers/batch.py `_batch_passthrough` + batch-create + Google batch passthrough, handlers/anthropic.py CCR continuation + batch endpoint) now switches from `httpx ... json=body` to `httpx ... content=raw_bytes`. The default httpx JSON encoder was re-serializing every request with `, `/`: ` separators and `\\uXXXX` ASCII escapes — collapsing Anthropic prompt-cache hit-rate. Forwarder strategy: - unmutated body → forward `await request.body()` verbatim; - mutated body → re-serialize once via the new `serialize_body_canonical(body) -> bytes` helper (compact separators, `ensure_ascii=False`, dict insertion order preserved). `HEADROOM_PROXY_PYTHON_FORWARDER_MODE` env var configures the mode: - `byte_faithful` (default) — the new behavior; - `legacy_json_kwarg` — explicit operator opt-in for emergency rollback. Documented in `docs/content/docs/configuration.mdx`. NOT a fallback — unknown values raise loudly per build constraint #4. `BodyMutationTracker` accompanies each request through the handler so transform sites mark the tracker (`memory_injection`, `image_compression`, `compression_*`, `batch_compression`, `ccr_continuation`, etc.). At forwarder dispatch we additionally compare the final body dict against the parsed original bytes as a structural safety net — any silent mutation we missed still triggers canonical re-serialization. A2 follow-up: `handlers/openai.py:534-540` (Chat Completions memory injection) was prepending a system message; replaced with `append_text_to_latest_user_chat_message`, the OpenAI Chat Completions analog of `_append_context_to_latest_non_frozen_user_turn`. The cache hot zone (system messages) is now sacrosanct on /v1/chat/completions too. Honors `HEADROOM_MEMORY_INJECTION_MODE=disabled`. Structured logging: every forwarder emits an `event=outbound_request` log line with `forwarder`, `path`, `body_bytes`, `body_mutated`, `mutation_reasons`, `source` (passthrough|canonical|legacy), `request_id`. Never logs Authorization or full body. `_read_request_json` factored to share `_read_request_body_bytes` with new `read_request_json_with_bytes` so the anthropic handler can capture both the parsed dict and the original (decompressed) bytes. Tests: - `tests/test_proxy_byte_faithful_forwarding.py` (28 tests): SHA-256 byte-equality on /v1/messages and streaming, unicode preservation, numeric precision, mutation-tracker invariants, canonical-serializer properties, legacy-mode rollback, OpenAI Chat memory routing. - Existing test mocks updated to accept the new `**kwargs` on `_retry_request` (no behavior change). - `tests/test_proxy_handlers_batch.py` updated to read the captured `content=` bytes (formerly `json=`). - One A2 test corrected (`test_anthropic_tool_sort_and_context_append_helpers`) to match the live-zone-tail semantics introduced by A2. Constraints satisfied: configurable env var; no new regex / hardcodes; no silent fallback (`legacy_json_kwarg` is operator opt-in); performant (`prepare_outbound_body_bytes` is O(1) for passthrough); elegant single-responsibility helpers; structured tracing logs.
2026-05-02 09:02:10 -07:00
) == [{"role": "user", "content": [{"type": "text", "text": "hello\n\nctx"}]}]
def test_anthropic_image_compression_helper_only_rewrites_latest_eligible_turn() -> None:
image_message = {
"role": "user",
"content": [{"type": "image", "source": {"type": "base64", "data": "abc"}}],
}
compressed = {
"role": "user",
"content": [{"type": "image", "source": {"type": "base64", "data": "xyz"}}],
}
assert (
AnthropicHandlerMixin._compress_latest_user_turn_images_cache_safe(
[],
frozen_message_count=0,
compressor=_ImageCompressor(compressed),
)
== []
)
assert AnthropicHandlerMixin._compress_latest_user_turn_images_cache_safe(
[image_message],
frozen_message_count=1,
compressor=_ImageCompressor(compressed),
) == [image_message]
assert AnthropicHandlerMixin._compress_latest_user_turn_images_cache_safe(
[{"role": "assistant", "content": image_message["content"]}],
frozen_message_count=0,
compressor=_ImageCompressor(compressed),
) == [{"role": "assistant", "content": image_message["content"]}]
assert AnthropicHandlerMixin._compress_latest_user_turn_images_cache_safe(
[{"role": "user", "content": "no-image"}],
frozen_message_count=0,
compressor=_ImageCompressor(compressed),
) == [{"role": "user", "content": "no-image"}]
assert AnthropicHandlerMixin._compress_latest_user_turn_images_cache_safe(
[image_message],
frozen_message_count=0,
compressor=_ImageCompressor(image_message),
) == [image_message]
assert AnthropicHandlerMixin._compress_latest_user_turn_images_cache_safe(
[image_message],
frozen_message_count=0,
compressor=_ImageCompressor(compressed),
) == [compressed]
fix(image): reuse image models instead of rebuilding them per request (#2513) (#2536) ## Description Fixes #2513. Image compression rebuilt its heavyweight models on every request: - `_compress_messages_worker` (`proxy/image_isolation.py`) created a new `ImageCompressor()` per call, and - `ImageCompressor.compress` (`image/compressor.py`) created a new `OnnxTechniqueRouter(use_siglip=...)` per image. Each `OnnxTechniqueRouter` loads native `ort.InferenceSession` models, and ONNX Runtime holds C++ memory that Python's GC does not eagerly reclaim. The image pool is a **persistent** single-worker `ProcessPoolExecutor`, so those sessions accumulated in the worker and RSS grew ~70 KB/request, reaching ~1.1 GB after ~15k image requests in a day (the log shows one `[RapidOCR] Using engine_name: onnxruntime` line per request, confirming reloads). ## Fix Load the models once and reuse them: - `ImageCompressor` caches the ONNX router on `self._onnx_router` (built lazily via `_get_onnx_router`) instead of building one per `compress()` call. - The isolation worker keeps a per-process `ImageCompressor` singleton (`_get_worker_compressor`) and reuses it across calls. - `_get_image_compressor()` (main process, used for the `has_images()` gate) returns a shared instance too. - Shared instances are marked `_is_singleton`, and `close()` is a no-op on them, so a caller's per-request `close()` no longer unloads the models the next request reuses. A non-singleton `close()` still releases the torch router and drops the cached ONNX router. RSS is now flat after the initial model load; behavior is otherwise unchanged. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/image/compressor.py`: add `_onnx_router` cache + `_get_onnx_router`, use it in `compress()`, add the `_is_singleton` flag, and make `close()` a no-op on a singleton (drop the cached ONNX router on a real close). - `headroom/proxy/image_isolation.py`: reuse a per-worker `ImageCompressor` singleton in `_compress_messages_worker` instead of building/closing one per call. - `headroom/proxy/helpers.py`: `_get_image_compressor()` returns a shared singleton instance. - `tests/test_image_compressor_singleton_reuse.py` (new): the ONNX router is built once and cached, singleton `close()` is a no-op while non-singleton `close()` releases, and both `_get_image_compressor` and the worker helper return a shared singleton. - `tests/test_proxy_handler_helpers.py`: updated the two existing `_get_image_compressor` tests that pinned the old fresh-per-call behavior to assert the singleton reuse instead (and reset the new module global so they stay isolated). ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_image_compressor_singleton_reuse.py -q 5 passed # with the fix reverted, all five fail (router rebuilt per call, close() # unloads the shared models, helpers return fresh instances) $ uvx ruff@0.15.17 check headroom/image/compressor.py headroom/proxy/image_isolation.py headroom/proxy/helpers.py tests/test_image_compressor_singleton_reuse.py All checks passed! $ uvx mypy@1.20.2 --ignore-missing-imports headroom/image/compressor.py headroom/proxy/image_isolation.py headroom/proxy/helpers.py Success: no issues found in 3 source files ``` The pre-existing async tests in `tests/test_image_compression_isolation.py` (4 `@pytest.mark.asyncio` cases) fail identically on clean `main` in this environment because pytest-asyncio is not configured here; they are unrelated to this change and pass in CI. ## Real Behavior Proof - Environment: Windows 11, Python 3.12, project venv (`uv sync --extra proxy`), `uvx ruff@0.15.17` / `uvx mypy@1.20.2`, pytest in the venv. - Exact command / steps: with `OnnxTechniqueRouter` construction mocked, called `ImageCompressor._get_onnx_router()` twice and asserted a single construction; exercised `close()` on singleton vs non-singleton instances; and called `_get_image_compressor()` / `_get_worker_compressor()` twice each. Then reverted the three source files and re-ran. - Observed result: with the fix the ONNX router is constructed once and reused, singleton `close()` leaves `_router`/`_onnx_router` intact (no `release_models`), non-singleton `close()` releases and nulls them, and both helper accessors return the same `_is_singleton` instance; with the fix reverted every one of these fails (fresh construction / unconditional release / new instances). Ran against the actual modules. - Not tested: a live multi-hour image workload measuring RSS (the leak is inferred from the removed per-request model construction; the ONNX/torch model load itself is mocked here). ## 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 - [ ] I have updated the CHANGELOG.md if applicable
2026-07-26 20:03:47 +05:30
def test_proxy_helper_reuses_a_singleton_image_compressor(monkeypatch) -> None:
# #2513: the compressor caches heavyweight models, so it must be a
# process-wide singleton rather than a fresh instance per request.
from headroom.proxy import helpers
monkeypatch.setattr(helpers, "_image_compressor_available", None)
fix(image): reuse image models instead of rebuilding them per request (#2513) (#2536) ## Description Fixes #2513. Image compression rebuilt its heavyweight models on every request: - `_compress_messages_worker` (`proxy/image_isolation.py`) created a new `ImageCompressor()` per call, and - `ImageCompressor.compress` (`image/compressor.py`) created a new `OnnxTechniqueRouter(use_siglip=...)` per image. Each `OnnxTechniqueRouter` loads native `ort.InferenceSession` models, and ONNX Runtime holds C++ memory that Python's GC does not eagerly reclaim. The image pool is a **persistent** single-worker `ProcessPoolExecutor`, so those sessions accumulated in the worker and RSS grew ~70 KB/request, reaching ~1.1 GB after ~15k image requests in a day (the log shows one `[RapidOCR] Using engine_name: onnxruntime` line per request, confirming reloads). ## Fix Load the models once and reuse them: - `ImageCompressor` caches the ONNX router on `self._onnx_router` (built lazily via `_get_onnx_router`) instead of building one per `compress()` call. - The isolation worker keeps a per-process `ImageCompressor` singleton (`_get_worker_compressor`) and reuses it across calls. - `_get_image_compressor()` (main process, used for the `has_images()` gate) returns a shared instance too. - Shared instances are marked `_is_singleton`, and `close()` is a no-op on them, so a caller's per-request `close()` no longer unloads the models the next request reuses. A non-singleton `close()` still releases the torch router and drops the cached ONNX router. RSS is now flat after the initial model load; behavior is otherwise unchanged. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/image/compressor.py`: add `_onnx_router` cache + `_get_onnx_router`, use it in `compress()`, add the `_is_singleton` flag, and make `close()` a no-op on a singleton (drop the cached ONNX router on a real close). - `headroom/proxy/image_isolation.py`: reuse a per-worker `ImageCompressor` singleton in `_compress_messages_worker` instead of building/closing one per call. - `headroom/proxy/helpers.py`: `_get_image_compressor()` returns a shared singleton instance. - `tests/test_image_compressor_singleton_reuse.py` (new): the ONNX router is built once and cached, singleton `close()` is a no-op while non-singleton `close()` releases, and both `_get_image_compressor` and the worker helper return a shared singleton. - `tests/test_proxy_handler_helpers.py`: updated the two existing `_get_image_compressor` tests that pinned the old fresh-per-call behavior to assert the singleton reuse instead (and reset the new module global so they stay isolated). ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_image_compressor_singleton_reuse.py -q 5 passed # with the fix reverted, all five fail (router rebuilt per call, close() # unloads the shared models, helpers return fresh instances) $ uvx ruff@0.15.17 check headroom/image/compressor.py headroom/proxy/image_isolation.py headroom/proxy/helpers.py tests/test_image_compressor_singleton_reuse.py All checks passed! $ uvx mypy@1.20.2 --ignore-missing-imports headroom/image/compressor.py headroom/proxy/image_isolation.py headroom/proxy/helpers.py Success: no issues found in 3 source files ``` The pre-existing async tests in `tests/test_image_compression_isolation.py` (4 `@pytest.mark.asyncio` cases) fail identically on clean `main` in this environment because pytest-asyncio is not configured here; they are unrelated to this change and pass in CI. ## Real Behavior Proof - Environment: Windows 11, Python 3.12, project venv (`uv sync --extra proxy`), `uvx ruff@0.15.17` / `uvx mypy@1.20.2`, pytest in the venv. - Exact command / steps: with `OnnxTechniqueRouter` construction mocked, called `ImageCompressor._get_onnx_router()` twice and asserted a single construction; exercised `close()` on singleton vs non-singleton instances; and called `_get_image_compressor()` / `_get_worker_compressor()` twice each. Then reverted the three source files and re-ran. - Observed result: with the fix the ONNX router is constructed once and reused, singleton `close()` leaves `_router`/`_onnx_router` intact (no `release_models`), non-singleton `close()` releases and nulls them, and both helper accessors return the same `_is_singleton` instance; with the fix reverted every one of these fails (fresh construction / unconditional release / new instances). Ran against the actual modules. - Not tested: a live multi-hour image workload measuring RSS (the leak is inferred from the removed per-request model construction; the ONNX/torch model load itself is mocked here). ## 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 - [ ] I have updated the CHANGELOG.md if applicable
2026-07-26 20:03:47 +05:30
monkeypatch.setattr(helpers, "_image_compressor_instance", None)
_FreshCompressor.instances = 0
with patch("headroom.image.ImageCompressor", _FreshCompressor):
first = helpers._get_image_compressor()
second = helpers._get_image_compressor()
assert isinstance(first, _FreshCompressor)
fix(image): reuse image models instead of rebuilding them per request (#2513) (#2536) ## Description Fixes #2513. Image compression rebuilt its heavyweight models on every request: - `_compress_messages_worker` (`proxy/image_isolation.py`) created a new `ImageCompressor()` per call, and - `ImageCompressor.compress` (`image/compressor.py`) created a new `OnnxTechniqueRouter(use_siglip=...)` per image. Each `OnnxTechniqueRouter` loads native `ort.InferenceSession` models, and ONNX Runtime holds C++ memory that Python's GC does not eagerly reclaim. The image pool is a **persistent** single-worker `ProcessPoolExecutor`, so those sessions accumulated in the worker and RSS grew ~70 KB/request, reaching ~1.1 GB after ~15k image requests in a day (the log shows one `[RapidOCR] Using engine_name: onnxruntime` line per request, confirming reloads). ## Fix Load the models once and reuse them: - `ImageCompressor` caches the ONNX router on `self._onnx_router` (built lazily via `_get_onnx_router`) instead of building one per `compress()` call. - The isolation worker keeps a per-process `ImageCompressor` singleton (`_get_worker_compressor`) and reuses it across calls. - `_get_image_compressor()` (main process, used for the `has_images()` gate) returns a shared instance too. - Shared instances are marked `_is_singleton`, and `close()` is a no-op on them, so a caller's per-request `close()` no longer unloads the models the next request reuses. A non-singleton `close()` still releases the torch router and drops the cached ONNX router. RSS is now flat after the initial model load; behavior is otherwise unchanged. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/image/compressor.py`: add `_onnx_router` cache + `_get_onnx_router`, use it in `compress()`, add the `_is_singleton` flag, and make `close()` a no-op on a singleton (drop the cached ONNX router on a real close). - `headroom/proxy/image_isolation.py`: reuse a per-worker `ImageCompressor` singleton in `_compress_messages_worker` instead of building/closing one per call. - `headroom/proxy/helpers.py`: `_get_image_compressor()` returns a shared singleton instance. - `tests/test_image_compressor_singleton_reuse.py` (new): the ONNX router is built once and cached, singleton `close()` is a no-op while non-singleton `close()` releases, and both `_get_image_compressor` and the worker helper return a shared singleton. - `tests/test_proxy_handler_helpers.py`: updated the two existing `_get_image_compressor` tests that pinned the old fresh-per-call behavior to assert the singleton reuse instead (and reset the new module global so they stay isolated). ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_image_compressor_singleton_reuse.py -q 5 passed # with the fix reverted, all five fail (router rebuilt per call, close() # unloads the shared models, helpers return fresh instances) $ uvx ruff@0.15.17 check headroom/image/compressor.py headroom/proxy/image_isolation.py headroom/proxy/helpers.py tests/test_image_compressor_singleton_reuse.py All checks passed! $ uvx mypy@1.20.2 --ignore-missing-imports headroom/image/compressor.py headroom/proxy/image_isolation.py headroom/proxy/helpers.py Success: no issues found in 3 source files ``` The pre-existing async tests in `tests/test_image_compression_isolation.py` (4 `@pytest.mark.asyncio` cases) fail identically on clean `main` in this environment because pytest-asyncio is not configured here; they are unrelated to this change and pass in CI. ## Real Behavior Proof - Environment: Windows 11, Python 3.12, project venv (`uv sync --extra proxy`), `uvx ruff@0.15.17` / `uvx mypy@1.20.2`, pytest in the venv. - Exact command / steps: with `OnnxTechniqueRouter` construction mocked, called `ImageCompressor._get_onnx_router()` twice and asserted a single construction; exercised `close()` on singleton vs non-singleton instances; and called `_get_image_compressor()` / `_get_worker_compressor()` twice each. Then reverted the three source files and re-ran. - Observed result: with the fix the ONNX router is constructed once and reused, singleton `close()` leaves `_router`/`_onnx_router` intact (no `release_models`), non-singleton `close()` releases and nulls them, and both helper accessors return the same `_is_singleton` instance; with the fix reverted every one of these fails (fresh construction / unconditional release / new instances). Ran against the actual modules. - Not tested: a live multi-hour image workload measuring RSS (the leak is inferred from the removed per-request model construction; the ONNX/torch model load itself is mocked here). ## 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 - [ ] I have updated the CHANGELOG.md if applicable
2026-07-26 20:03:47 +05:30
assert first is second
assert first._is_singleton is True
assert _FreshCompressor.instances == 1
def test_proxy_helper_caches_image_stack_import_failure(monkeypatch) -> None:
from headroom.proxy import helpers
real_import = builtins.__import__
calls = 0
def fake_import(name, *args, **kwargs): # noqa: ANN001, ANN202
nonlocal calls
if name == "headroom.image":
calls += 1
raise ImportError("image extras unavailable")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(helpers, "_image_compressor_available", None)
fix(image): reuse image models instead of rebuilding them per request (#2513) (#2536) ## Description Fixes #2513. Image compression rebuilt its heavyweight models on every request: - `_compress_messages_worker` (`proxy/image_isolation.py`) created a new `ImageCompressor()` per call, and - `ImageCompressor.compress` (`image/compressor.py`) created a new `OnnxTechniqueRouter(use_siglip=...)` per image. Each `OnnxTechniqueRouter` loads native `ort.InferenceSession` models, and ONNX Runtime holds C++ memory that Python's GC does not eagerly reclaim. The image pool is a **persistent** single-worker `ProcessPoolExecutor`, so those sessions accumulated in the worker and RSS grew ~70 KB/request, reaching ~1.1 GB after ~15k image requests in a day (the log shows one `[RapidOCR] Using engine_name: onnxruntime` line per request, confirming reloads). ## Fix Load the models once and reuse them: - `ImageCompressor` caches the ONNX router on `self._onnx_router` (built lazily via `_get_onnx_router`) instead of building one per `compress()` call. - The isolation worker keeps a per-process `ImageCompressor` singleton (`_get_worker_compressor`) and reuses it across calls. - `_get_image_compressor()` (main process, used for the `has_images()` gate) returns a shared instance too. - Shared instances are marked `_is_singleton`, and `close()` is a no-op on them, so a caller's per-request `close()` no longer unloads the models the next request reuses. A non-singleton `close()` still releases the torch router and drops the cached ONNX router. RSS is now flat after the initial model load; behavior is otherwise unchanged. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/image/compressor.py`: add `_onnx_router` cache + `_get_onnx_router`, use it in `compress()`, add the `_is_singleton` flag, and make `close()` a no-op on a singleton (drop the cached ONNX router on a real close). - `headroom/proxy/image_isolation.py`: reuse a per-worker `ImageCompressor` singleton in `_compress_messages_worker` instead of building/closing one per call. - `headroom/proxy/helpers.py`: `_get_image_compressor()` returns a shared singleton instance. - `tests/test_image_compressor_singleton_reuse.py` (new): the ONNX router is built once and cached, singleton `close()` is a no-op while non-singleton `close()` releases, and both `_get_image_compressor` and the worker helper return a shared singleton. - `tests/test_proxy_handler_helpers.py`: updated the two existing `_get_image_compressor` tests that pinned the old fresh-per-call behavior to assert the singleton reuse instead (and reset the new module global so they stay isolated). ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_image_compressor_singleton_reuse.py -q 5 passed # with the fix reverted, all five fail (router rebuilt per call, close() # unloads the shared models, helpers return fresh instances) $ uvx ruff@0.15.17 check headroom/image/compressor.py headroom/proxy/image_isolation.py headroom/proxy/helpers.py tests/test_image_compressor_singleton_reuse.py All checks passed! $ uvx mypy@1.20.2 --ignore-missing-imports headroom/image/compressor.py headroom/proxy/image_isolation.py headroom/proxy/helpers.py Success: no issues found in 3 source files ``` The pre-existing async tests in `tests/test_image_compression_isolation.py` (4 `@pytest.mark.asyncio` cases) fail identically on clean `main` in this environment because pytest-asyncio is not configured here; they are unrelated to this change and pass in CI. ## Real Behavior Proof - Environment: Windows 11, Python 3.12, project venv (`uv sync --extra proxy`), `uvx ruff@0.15.17` / `uvx mypy@1.20.2`, pytest in the venv. - Exact command / steps: with `OnnxTechniqueRouter` construction mocked, called `ImageCompressor._get_onnx_router()` twice and asserted a single construction; exercised `close()` on singleton vs non-singleton instances; and called `_get_image_compressor()` / `_get_worker_compressor()` twice each. Then reverted the three source files and re-ran. - Observed result: with the fix the ONNX router is constructed once and reused, singleton `close()` leaves `_router`/`_onnx_router` intact (no `release_models`), non-singleton `close()` releases and nulls them, and both helper accessors return the same `_is_singleton` instance; with the fix reverted every one of these fails (fresh construction / unconditional release / new instances). Ran against the actual modules. - Not tested: a live multi-hour image workload measuring RSS (the leak is inferred from the removed per-request model construction; the ONNX/torch model load itself is mocked here). ## 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 - [ ] I have updated the CHANGELOG.md if applicable
2026-07-26 20:03:47 +05:30
monkeypatch.setattr(helpers, "_image_compressor_instance", None)
monkeypatch.setattr(builtins, "__import__", fake_import)
assert helpers._get_image_compressor() is None
assert helpers._get_image_compressor() is None
assert calls == 1
assert helpers._image_compressor_available is False
def test_anthropic_cache_delta_helpers_cover_string_list_and_role_mismatch() -> None:
previous_original = [{"role": "user", "content": "hello"}]
previous_forwarded = [{"role": "user", "content": "HELLO"}]
assert AnthropicHandlerMixin._extract_cache_stable_delta(
[{"role": "user", "content": "hello"}, {"role": "assistant", "content": "next"}],
previous_original,
previous_forwarded,
) == (previous_forwarded, [{"role": "assistant", "content": "next"}])
assert (
AnthropicHandlerMixin._extract_cache_stable_delta(
[{"role": "assistant", "content": "hello"}],
previous_original,
previous_forwarded,
)
is None
)
string_suffix = AnthropicHandlerMixin._extract_cache_stable_last_message_suffix(
[{"role": "user", "content": "hello world"}],
previous_original,
previous_forwarded,
)
assert string_suffix == ([], previous_forwarded[0], [{"role": "user", "content": " world"}])
list_suffix = AnthropicHandlerMixin._extract_cache_stable_last_message_suffix(
[
{
"role": "user",
"content": [{"type": "text", "text": "a"}, {"type": "text", "text": "b"}],
}
],
[{"role": "user", "content": [{"type": "text", "text": "a"}]}],
[{"role": "user", "content": [{"type": "text", "text": "A"}]}],
)
assert list_suffix == (
[],
{"role": "user", "content": [{"type": "text", "text": "A"}]},
[{"role": "user", "content": [{"type": "text", "text": "b"}]}],
)
assert AnthropicHandlerMixin._merge_appended_message_delta(
{"role": "user", "content": "HELLO"},
{"role": "user", "content": " world"},
) == {"role": "user", "content": "HELLO world"}
assert AnthropicHandlerMixin._merge_appended_message_delta(
{"role": "user", "content": [{"type": "text", "text": "A"}]},
{"role": "user", "content": [{"type": "text", "text": "b"}]},
) == {"role": "user", "content": [{"type": "text", "text": "A"}, {"type": "text", "text": "b"}]}
assert (
AnthropicHandlerMixin._merge_appended_message_delta(
{"role": "user", "content": "A"},
{"role": "assistant", "content": "B"},
)
is None
)
def test_anthropic_assistant_message_helper_requires_assistant_role() -> None:
assert AnthropicHandlerMixin._assistant_message_from_response_json(None) is None
assert AnthropicHandlerMixin._assistant_message_from_response_json({"role": "user"}) is None
assert AnthropicHandlerMixin._assistant_message_from_response_json(
{"role": "assistant", "content": [{"type": "text", "text": "ok"}]}
) == {"role": "assistant", "content": [{"type": "text", "text": "ok"}]}
fix(ccr): scope proactive expansion by workspace (cross-project leak) Closes the cross-project context leak Jocelyn reported 2026-05-26: working on a Ruby/Rails project (daphni-rails), an unrelated Python file (an Ollama inference provider from project `tamag0`) was being injected into context as "Proactive Context Expansion - relevant to your query". Two completely different projects, two different languages, two different working directories — but the same proxy process was serving both, and the in-memory ContextTracker had no workspace identity to filter on. Root cause ---------- `self.ccr_context_tracker` is one instance per proxy process. Every session, every project, every user shared the same `_contexts` dict. `track_compression()` stored sample content with no provenance key; `analyze_query()` ran lexical keyword overlap across the full dict without filtering. Within the 5-minute age window, surface-level token matches ("provider", "session", "oauth", generic code/test structure) scored above the 0.3 relevance threshold, recommendations came back, and execute_expansions() injected the full original content into a foreign session. Refuted: this is NOT a race condition (joce's hypothesis). It reproduces single-threaded, one-request-at-a-time. Plain shared mutable state. Fix --- Add a required `workspace_key` to the tracker API and filter on it inside `analyze_query`: 1. `CompressedContext` gets a `workspace_key: str` field. 2. `track_compression(..., workspace_key=...)` is now keyword-only, no default — fail-loud on missing. 3. `analyze_query(..., workspace_key=...)` is also keyword-only; an empty workspace_key short-circuits to `[]` (fail-closed per `feedback_no_silent_fallbacks`). 4. The loop at `analyze_query` skips any entry whose workspace_key differs from the request's. In the Anthropic proxy handler: 5. New `_resolve_ccr_workspace(request, body)` static helper uses the memory subsystem's `ProjectResolver` so CCR and memory agree on project identity. Tier order: x-headroom-project-id → x-headroom-cwd → CLI override → cwd: line in system prompt. 6. Both track and analyze sites gate on `ccr_workspace_key` being non-empty — turning off proactive expansion entirely when project identity can't be resolved is the safest default (it's an optimization, not correctness). 7. `format_expansions_for_context(expansions, workspace_label=...)` was already wired (GH #462 Fix C); the call site now passes the label so the injected block declares its provenance, symmetric with the memory injection header. Affected population ------------------- - Default mode (no `--cache`): bug fixed. - Cache mode: was never affected — proactive expansion short- circuits in cache mode to preserve prefix stability. Tests ----- - 6 new workspace-scoping tests in `test_ccr_context_tracker.py`: same-workspace match still works, cross-workspace silently filtered, empty workspace_key fail-closes, two workspaces each see only their own, workspace_label propagates to formatter, LRU cross-workspace doesn't leak even with full tracker. - 6 new `_resolve_ccr_workspace` resolver tests in `test_proxy_handler_helpers.py`: explicit project-id wins, cwd header → key+label, two cwds get distinct keys, no-signal fail-closed, system-prompt cwd: fallback, malformed request fail-closed. - 32 existing tracker tests updated to pass `workspace_key="ws-test"`. - 55/55 tests pass; ci-precheck green. Defense-in-depth follow-up -------------------------- The compression_store itself (`headroom/cache/compression_store.py`) also lacks workspace scoping — a CCR `headroom_retrieve` call from Project B for a hash created by Project A would succeed. The practical attack surface is closed by this PR (hashes only reach Project B's model via proactive expansion, now gated), but defense-in-depth hardening of the store is worth a separate PR. Filed as task #44.
2026-05-26 13:23:51 -07:00
# ============================================================================
# CCR workspace resolution (cross-project leak fix, 2026-05-26).
#
# These tests pin the `_resolve_ccr_workspace` static helper that the
# anthropic handler uses to scope the proactive-expansion cache by
# project identity. The resolver shares its tier order with the memory
# subsystem's ProjectResolver: x-headroom-project-id → x-headroom-cwd →
# system-prompt `cwd:` line. Returns `("", None)` on no signal — the
# fail-closed signal that callers gate on.
# ============================================================================
def _fake_request(headers: dict[str, str]) -> SimpleNamespace:
"""Minimal Starlette/FastAPI-shaped request object for resolver tests."""
return SimpleNamespace(headers=headers)
def test_resolve_ccr_workspace_explicit_project_id_wins() -> None:
"""x-headroom-project-id is the highest-priority signal."""
request = _fake_request({"x-headroom-project-id": "my-cool-project"})
body = {}
key, label = AnthropicHandlerMixin._resolve_ccr_workspace(request, body)
fix(memory): make explicit-project and user store keys collision-resistant (#2231) ## Description Two of the memory storage router's key-derivation paths can pool distinct identities into one store. `ProjectResolver._identity_from_cwd` builds a collision-resistant key by appending a `sha256` digest to the sanitized basename: ```python safe_basename = cls._sanitize_basename(basename) or "project" digest = hashlib.sha256(normalised.encode("utf-8")).hexdigest()[:16] key = f"{safe_basename}-{digest}" ``` But the two non-cwd paths use the bare sanitized basename as the key: ```python # Tier 1 — explicit x-headroom-project-id safe = self._sanitize_basename(explicit) if safe: return safe, explicit # <-- no digest # USER mode user_safe = ProjectResolver._sanitize_basename(ctx.base_user_id) or "default" db_path = self._config.root_dir / "users" / user_safe / "memory.db" # <-- no digest ``` `_sanitize_basename` maps every disallowed character to a single dash, so distinct inputs collapse to the same basename: - `acme/api` and `acme api` (and `acme@api`) all → `acme-api` - user ids `alice/qa` and `alice qa` → `alice-qa` Both the project key (`root/projects/<key>/memory.db`) and the USER key (`root/users/<key>/memory.db`) are derived directly from that basename, so two distinct project ids — or, in USER mode, two distinct **users** — resolve to the same `memory.db` and share each other's memories. USER mode exists specifically to isolate users, so this is a cross-user data-isolation leak; the explicit-project-id path is the same leak across projects. Both are client-controlled (`x-headroom-project-id` / `x-headroom-user-id` headers), so the collision is easy to hit and could even be provoked deliberately. ## Fix Append the same digest of the raw id to both keys, exactly as `_identity_from_cwd` does, keeping the sanitized basename as a human-readable prefix: ```python digest = hashlib.sha256(explicit.encode("utf-8")).hexdigest()[:16] return f"{safe}-{digest}", explicit ``` ```python digest = hashlib.sha256(ctx.base_user_id.encode("utf-8")).hexdigest()[:16] user_key = f"{user_safe}-{digest}" db_path = self._config.root_dir / "users" / user_key / "memory.db" ``` Distinct ids now always land on distinct stores; the same id remains stable across calls. **Migration note:** this changes the on-disk key format for the explicit-project and USER stores (`<basename>` → `<basename>-<digest>`). Memories written under the old bare-basename paths are not migrated; the router will start a fresh store at the new path. GLOBAL and cwd-derived PROJECT stores (which already carried the digest) are unaffected. Flagging this explicitly so you can decide whether a migration shim is wanted before merge. Closes # ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/memory/storage_router.py`: append a `sha256` digest to the explicit-project-id key (Tier 1) and the USER-mode key, matching `_identity_from_cwd`. - `tests/test_memory_storage_router.py`: update the Tier-1 key assertion to the prefix+digest form; add collision regression tests for the explicit-project and USER paths. - `CHANGELOG.md`: Bug Fixes entry (including the migration note). ## Testing - [ ] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ uvx ruff@0.15.17 check headroom/memory/storage_router.py tests/test_memory_storage_router.py All checks passed! $ uvx mypy@1.20.2 --ignore-missing-imports headroom/memory/storage_router.py Success: no issues found in 1 source file ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17` / `uvx mypy@1.20.2`. A full `pytest` OOM-kills this box (ML stack import), so I reproduced the key derivation with a dependency-free script mirroring `_sanitize_basename` + the digest, and left the full pytest to CI. - Exact command / steps: derived keys for `alice/qa` and `alice qa` under the OLD bare-basename scheme and the NEW digest scheme. - Observed result: OLD → both `alice-qa` (identical → shared store); NEW → `alice-qa-7e02fc2dfbc447b4` vs `alice-qa-4c9241514a374ba3` (distinct), stable per input, with the `alice-qa-` prefix retained. - Not tested: a live proxy with two colliding tenants; full local `pytest` deferred to CI (OOM). ## 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 have updated the CHANGELOG.md if applicable ## Additional Notes The "unit tests pass locally" box is unchecked because the full suite imports the ML stack, which I can't run here. The changed/added tests use the existing `tests/test_memory_storage_router.py` harness so they run under the normal CI pytest job; behaviour is additionally verified by the standalone proof above. I updated `test_resolver_tier1_explicit_project_id_wins` to assert the new prefix+digest key. Happy to add a migration shim (read the old path if the new one is empty) if you'd prefer that over the fresh-store behavior. --------- Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-08-12 10:09:15 +05:30
assert key.startswith("my-cool-project-")
assert len(key.split("-")[-1]) == 16
fix(ccr): scope proactive expansion by workspace (cross-project leak) Closes the cross-project context leak Jocelyn reported 2026-05-26: working on a Ruby/Rails project (daphni-rails), an unrelated Python file (an Ollama inference provider from project `tamag0`) was being injected into context as "Proactive Context Expansion - relevant to your query". Two completely different projects, two different languages, two different working directories — but the same proxy process was serving both, and the in-memory ContextTracker had no workspace identity to filter on. Root cause ---------- `self.ccr_context_tracker` is one instance per proxy process. Every session, every project, every user shared the same `_contexts` dict. `track_compression()` stored sample content with no provenance key; `analyze_query()` ran lexical keyword overlap across the full dict without filtering. Within the 5-minute age window, surface-level token matches ("provider", "session", "oauth", generic code/test structure) scored above the 0.3 relevance threshold, recommendations came back, and execute_expansions() injected the full original content into a foreign session. Refuted: this is NOT a race condition (joce's hypothesis). It reproduces single-threaded, one-request-at-a-time. Plain shared mutable state. Fix --- Add a required `workspace_key` to the tracker API and filter on it inside `analyze_query`: 1. `CompressedContext` gets a `workspace_key: str` field. 2. `track_compression(..., workspace_key=...)` is now keyword-only, no default — fail-loud on missing. 3. `analyze_query(..., workspace_key=...)` is also keyword-only; an empty workspace_key short-circuits to `[]` (fail-closed per `feedback_no_silent_fallbacks`). 4. The loop at `analyze_query` skips any entry whose workspace_key differs from the request's. In the Anthropic proxy handler: 5. New `_resolve_ccr_workspace(request, body)` static helper uses the memory subsystem's `ProjectResolver` so CCR and memory agree on project identity. Tier order: x-headroom-project-id → x-headroom-cwd → CLI override → cwd: line in system prompt. 6. Both track and analyze sites gate on `ccr_workspace_key` being non-empty — turning off proactive expansion entirely when project identity can't be resolved is the safest default (it's an optimization, not correctness). 7. `format_expansions_for_context(expansions, workspace_label=...)` was already wired (GH #462 Fix C); the call site now passes the label so the injected block declares its provenance, symmetric with the memory injection header. Affected population ------------------- - Default mode (no `--cache`): bug fixed. - Cache mode: was never affected — proactive expansion short- circuits in cache mode to preserve prefix stability. Tests ----- - 6 new workspace-scoping tests in `test_ccr_context_tracker.py`: same-workspace match still works, cross-workspace silently filtered, empty workspace_key fail-closes, two workspaces each see only their own, workspace_label propagates to formatter, LRU cross-workspace doesn't leak even with full tracker. - 6 new `_resolve_ccr_workspace` resolver tests in `test_proxy_handler_helpers.py`: explicit project-id wins, cwd header → key+label, two cwds get distinct keys, no-signal fail-closed, system-prompt cwd: fallback, malformed request fail-closed. - 32 existing tracker tests updated to pass `workspace_key="ws-test"`. - 55/55 tests pass; ci-precheck green. Defense-in-depth follow-up -------------------------- The compression_store itself (`headroom/cache/compression_store.py`) also lacks workspace scoping — a CCR `headroom_retrieve` call from Project B for a hash created by Project A would succeed. The practical attack surface is closed by this PR (hashes only reach Project B's model via proactive expansion, now gated), but defense-in-depth hardening of the store is worth a separate PR. Filed as task #44.
2026-05-26 13:23:51 -07:00
assert label == "my-cool-project"
def test_resolve_ccr_workspace_cwd_header() -> None:
"""x-headroom-cwd produces a stable per-cwd key + basename label."""
request = _fake_request({"x-headroom-cwd": "/home/user/code/daphni-rails"})
body = {}
key, label = AnthropicHandlerMixin._resolve_ccr_workspace(request, body)
# Key format: "{basename}-{sha256[:16]}" — stable per absolute cwd.
assert key.startswith("daphni-rails-")
assert len(key) >= len("daphni-rails-") + 16
assert label == "daphni-rails"
def test_resolve_ccr_workspace_two_cwds_get_distinct_keys() -> None:
"""Two different cwds produce different workspace keys (cross-leak prevention)."""
key_a, _ = AnthropicHandlerMixin._resolve_ccr_workspace(
_fake_request({"x-headroom-cwd": "/home/user/code/daphni-rails"}), {}
)
key_b, _ = AnthropicHandlerMixin._resolve_ccr_workspace(
_fake_request({"x-headroom-cwd": "/home/user/code/tamag0"}), {}
)
assert key_a != key_b, "different cwds must yield different workspace keys"
def test_resolve_ccr_workspace_no_signal_returns_empty() -> None:
"""No project-id, no cwd header, no system prompt → fail-closed signal."""
request = _fake_request({})
body = {}
key, label = AnthropicHandlerMixin._resolve_ccr_workspace(request, body)
assert key == ""
assert label is None
def test_resolve_ccr_workspace_system_prompt_cwd_fallback() -> None:
"""System prompt with `cwd:` line is the lowest-tier fallback."""
request = _fake_request({})
body = {
"system": [{"type": "text", "text": "You are helpful.\ncwd: /home/u/code/my-project\nGo."}]
}
key, label = AnthropicHandlerMixin._resolve_ccr_workspace(request, body)
# The label is the basename of the cwd extracted from the prompt.
assert label == "my-project"
assert key.startswith("my-project-")
def test_resolve_ccr_workspace_malformed_request_returns_empty() -> None:
"""A request whose headers attribute can't be dict()-ed fails closed, not crashes."""
class _BrokenHeaders:
def __iter__(self):
raise RuntimeError("boom")
request = SimpleNamespace(headers=_BrokenHeaders())
body = {}
# The helper catches the exception, logs it, and returns the fail-
# closed sentinel ("", None). Critically, it does NOT raise — the
# proxy must continue serving the request even if CCR scoping fails.
key, label = AnthropicHandlerMixin._resolve_ccr_workspace(request, body)
assert key == ""
assert label is None
fix(proxy): freeze must forward cached (compressed) prefix byte-identical — stop token-mode cache busting (#1850) The freeze path (both providers) emits the agent's ORIGINAL bytes for a frozen message, but the provider cached whatever we FORWARDED last turn (the compressed form). Forwarding original then mismatches the cached prefix and busts it from that point — re-creating the whole suffix. Measured on a real SWE-bench run: 100% of attributed misses were prefix_change, ~56% of ALL cache-writes were bust-induced (2.8M tokens), driving cache_create +150% and cost +41% vs baseline. Cache mode already avoided this via _extract_cache_stable_delta (replay the previously-forwarded prefix, compress only the delta). Token mode called apply(frozen_count) directly, which forwards original for the frozen region. Fix: add a shared, provider-agnostic overlay_cached_prefix() that replays the previously-forwarded (cached, compressed) prefix byte-identical, append-only guarded and idempotent, and apply it in BOTH the Anthropic and OpenAI handlers right before forwarding. This makes freezing byte-identical in every mode, so the only remaining difference between "token" and "cache" mode is how large a mutable (still-compressible) tail each leaves — not whether the frozen prefix busts the cache. Tests: - test_cache_prefix_overlay.py: the helper (replay, append-only guard, idempotence). - test_cross_turn_cache_safety.py: the invariant that was missing — drive the REAL tracker + freeze + overlay over multiple append-only turns against a simulated provider prefix cache and assert the forwarded prefix stays byte-identical turn-over-turn. Load-bearing: it fails (detects the bust) without the overlay. ## Description <!-- Briefly explain the change and why it is needed. --> Closes # ## Type of Change - [ ] 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 - ## Testing <!-- Check what you actually ran, then paste the real command output below. --> - [ ] Unit tests pass (`pytest`) - [ ] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [ ] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text # Paste relevant command output or artifact links here ``` ## Real Behavior Proof - Environment: - Exact command / steps: - Observed result: - Not tested: ## Review Readiness - [ ] I have performed a self-review - [ ] This PR is ready for human review ## Checklist - [ ] My code follows the project's style guidelines - [ ] 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 - [ ] 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 - [ ] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) Add screenshots to help explain your changes. ## Additional Notes <!-- Mention any N/A checklist items, tradeoffs, follow-ups, or maintainer context. -->
2026-07-06 14:54:39 -07:00
class TestHasNewCcrMarkers:
"""#1850: replayed (overlay) markers must not count as new-this-turn.
``overlay_cached_prefix`` replays the previously-forwarded compressed prefix
byte-identical to keep the messages cache warm which reintroduces its old
``hash=`` markers. If those replayed markers counted as "new", the handler
would re-inject the retrieve tool every frozen turn and bust the *tools*
cache. ``has_new_ccr_markers`` filters them out.
"""
@staticmethod
def _hashes(*contents: str) -> list[str]:
from headroom.ccr.tool_injection import CCRToolInjector
inj = CCRToolInjector(
provider="anthropic", inject_tool=False, inject_system_instructions=False
)
inj.scan_for_markers([{"role": "user", "content": c} for c in contents])
return inj.detected_hashes
def test_replayed_markers_are_not_new(self):
from headroom.proxy.helpers import has_new_ccr_markers
marker = "[100 items compressed to 10. Retrieve more: hash=abc123def456abc123def456]"
current = self._hashes(marker)
assert current, "sanity: the marker must be detected"
# Every marker was already in what we forwarded last turn → nothing new.
assert (
has_new_ccr_markers(
current_detected_hashes=current,
previous_forwarded_messages=[{"role": "user", "content": marker}],
provider="anthropic",
)
is False
)
def test_genuinely_new_marker_is_detected(self):
from headroom.proxy.helpers import has_new_ccr_markers
old = "[100 items compressed to 10. Retrieve more: hash=abc123def456abc123def456]"
new = "[50 items compressed to 5. Retrieve more: hash=deadbeefdeadbeefdeadbeef]"
current = self._hashes(old, new)
# Only `old` was forwarded before; `new` is fresh → override must fire.
assert (
has_new_ccr_markers(
current_detected_hashes=current,
previous_forwarded_messages=[{"role": "user", "content": old}],
provider="anthropic",
)
is True
)
def test_no_previous_forward_means_all_new(self):
from headroom.proxy.helpers import has_new_ccr_markers
marker = "[100 items compressed to 10. Retrieve more: hash=abc123def456abc123def456]"
assert (
has_new_ccr_markers(
current_detected_hashes=self._hashes(marker),
previous_forwarded_messages=None,
provider="anthropic",
)
is True
)
def test_no_markers_means_nothing_new(self):
from headroom.proxy.helpers import has_new_ccr_markers
assert (
has_new_ccr_markers(
current_detected_hashes=[],
previous_forwarded_messages=None,
provider="anthropic",
)
is False
)
feat(cache): provider-agnostic cache-mode delta + cc-agnostic prefix comparison (#1868) ## Description <!-- Briefly explain the change and why it is needed. --> Closes # ## Type of Change - [ ] 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 - ## Testing <!-- Check what you actually ran, then paste the real command output below. --> - [ ] Unit tests pass (`pytest`) - [ ] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [ ] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text # Paste relevant command output or artifact links here ``` ## Real Behavior Proof - Environment: - Exact command / steps: - Observed result: - Not tested: ## Review Readiness - [ ] I have performed a self-review - [ ] This PR is ready for human review ## Checklist - [ ] My code follows the project's style guidelines - [ ] 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 - [ ] 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 - [ ] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) Add screenshots to help explain your changes. ## Additional Notes <!-- Mention any N/A checklist items, tradeoffs, follow-ups, or maintainer context. -->
2026-07-08 16:29:35 -04:00
def test_strict_frozen_count_tool_and_function_tail_are_mutable():
# OpenAI function-calling harnesses (Kimi / fireworks) end each turn with a
# role:"tool" (or legacy role:"function") observation — NOT role:"user".
# Gating the mutable tail on role=="user" froze the whole conversation on
# every such turn => zero compression. Tool/function observations must be
# treated as the mutable delta (freeze all-but-last), like a user obs.
from headroom.proxy.handlers.openai import OpenAIHandlerMixin as M
# role:tool tail -> only the last message is mutable (frozen = final_idx)
assert (
M._strict_previous_turn_frozen_count(
[{"role": "user"}, {"role": "assistant"}, {"role": "tool"}], 0
)
== 2
)
assert (
M._strict_previous_turn_frozen_count(
[{"role": "user"}, {"role": "assistant"}, {"role": "function"}], 0
)
== 2
)
# assistant/system tail is NOT an observation -> freeze everything
assert (
M._strict_previous_turn_frozen_count(
[{"role": "user"}, {"role": "tool"}, {"role": "assistant"}], 0
)
== 3
)
fix(proxy): handle ClientDisconnect in passthrough body reads (#2033) ## Description Catch `starlette.requests.ClientDisconnect` when reading request bodies in passthrough/forwarding handlers. Closes #2019 Without this, a client that disconnects mid-request causes an unhandled `ClientDisconnect` to propagate through the entire middleware stack, crashing the ASGI TaskGroup and contributing to proxy instability over long sessions (memory growth, freeze, unresponsive to SIGTERM). **Adversarial review uncovered 3 additional unprotected sites** in `proxy_routes.py` — same pattern (body read before try/except). Now fixed. ## Type of Change - [x] Bug fix (non-breaking change which fixes an issue) ## Changes Made **Proxy handlers** (6 sites, first commit): - openai `handle_passthrough`: wrap `await request.body()` in try/except ClientDisconnect (main crash site) - openai `_handle_streaming_passthrough`: same protection - anthropic batch passthrough: same protection - batch `_google_batch_passthrough`: same protection - batch `handle_google_batch_passthrough`: same protection - bedrock fallback-forward path: early-return on ClientDisconnect instead of attempting verbatim forward **Proxy routes** (3 sites, second commit — found by adversarial design scan): - `_handle_chatgpt_model_metadata` (proxy_routes.py:398) - `_handle_chatgpt_codex_images` (proxy_routes.py:438) - `openai_responses_sub` nested handler (proxy_routes.py:597) All nine sites return HTTP 204 on disconnect to allow the request to terminate cleanly. ## Testing - [x] **Existing tests**: 34/34 pass in `test_proxy_handler_helpers.py` - [x] **Unit tests**: 2 new tests — passthrough + streaming passthrough disconnect - [x] **Adversarial concurrency**: 50 threads × 10 iterations = 500 concurrent disconnect requests — zero crashes, all return 204 - [x] **Adversarial edge cases**: minimal request state, regression check (normal request path unaffected) - [x] **PBT (Hypothesis)**: 250 random method/path combinations, 3 properties verified: - All disconnect requests return 204 - ClientDisconnect never leaks out of handler - Response is always valid HTTP 2xx ```text # Unit tests tests/test_proxy_handler_helpers.py::test_handle_passthrough_client_disconnect PASSED tests/test_proxy_handler_helpers.py::test_handle_streaming_passthrough_client_disconnect PASSED # PBT (3 properties × 100-250 examples each) /tmp/pbt_client_disconnect.py::test_disconnect_always_returns_204 PASSED /tmp/pbt_client_disconnect.py::test_disconnect_does_not_crash_asgi PASSED /tmp/pbt_client_disconnect.py::test_response_is_valid_http PASSED # Adversarial /tmp/adversarial_client_disconnect.py → 500 concurrent requests: 0 errors, all 204 ``` - [x] `ruff check` and `ruff format --check` pass on all changed files ## Real Behavior Proof - Environment: Linux, Python 3.12, headroom main @ a617455 - Exact command / steps: - `uv run pytest tests/test_proxy_handler_helpers.py -v` — 34 passed - `uv run python /tmp/adversarial_client_disconnect.py` — 500 concurrent, 0 errors - `uv run python /tmp/pbt_client_disconnect.py` — 250 random inputs, 3/3 properties hold - `uv run ruff check . && uv run ruff format --check .` — All checks passed - Observed result: ClientDisconnect caught gracefully at all 9 sites, 204 returned, no ExceptionGroup crash, no data corruption - Not tested: Full E2E with real client disconnect (requires integration test infrastructure). Manual confirmation from issue reporter would validate the real-world fix. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review --------- Co-authored-by: lennney <lennney@users.noreply.github.com>
2026-07-11 23:22:05 +08:00
class _ClientDisconnectRequest:
"""Mock request whose body() raises ClientDisconnect to simulate mid-stream cancel."""
method = "POST"
headers = {"content-type": "application/json"}
url = SimpleNamespace(path="/v1/chat/completions", query="")
async def body(self) -> bytes:
from starlette.requests import ClientDisconnect
raise ClientDisconnect()
class _ClientDisconnectStreamRequest:
"""Mock request for streaming passthrough with ClientDisconnect."""
method = "POST"
headers = {"content-type": "application/json"}
url = SimpleNamespace(
path="/v1/projects/p/locations/us-central1/publishers/google/models/gemini-2.0-flash:streamGenerateContent",
query="alt=sse",
)
async def body(self) -> bytes:
from starlette.requests import ClientDisconnect
raise ClientDisconnect()
def test_handle_passthrough_client_disconnect():
"""ClientDisconnect during body read returns 204 instead of crashing TaskGroup."""
handler = object.__new__(OpenAIHandlerMixin)
response = asyncio.run(
handler.handle_passthrough(_ClientDisconnectRequest(), "https://api.openai.com")
)
assert response.status_code == 204
def test_handle_streaming_passthrough_client_disconnect():
"""ClientDisconnect during streaming body read returns 204."""
handler = object.__new__(OpenAIHandlerMixin)
response = asyncio.run(
handler.handle_passthrough(
_ClientDisconnectStreamRequest(),
"https://us-central1-aiplatform.googleapis.com",
endpoint_name="streamRawPredict",
provider="vertex:google",
)
)
assert response.status_code == 204