Merge remote-tracking branch 'upstream/main' into pr2117

This commit is contained in:
Tejas Chopra 2026-07-13 15:01:33 -07:00
commit c3b2522d0b
89 changed files with 10168 additions and 328 deletions

4
.gitattributes vendored
View file

@ -1,2 +1,6 @@
*.py text eol=lf
*.sh text eol=lf
# CHANGELOG appends conflict on nearly every concurrent PR; union-merge keeps
# all entries instead of forcing a manual resolution, killing the merge cascade.
CHANGELOG.md merge=union

View file

@ -22,6 +22,14 @@ on:
release:
types: [published]
# A merge spree pushes many commits to main; without this, each commit starts
# a full multi-arch image build and they pile up against the 20-job concurrency
# cap. Supersede all but the latest build for a given ref. cancel-in-progress is
# scoped to main only so a release tag's publish (its own ref) is never killed.
concurrency:
group: docker-${{ github.ref }}
cancel-in-progress: ${{ github.ref == 'refs/heads/main' }}
env:
REGISTRY: ghcr.io

View file

@ -12,6 +12,10 @@ on:
pull_request:
branches: [main]
concurrency:
group: merge-conflicts-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read

View file

@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
### Fixed
- **memory:** annotate `_EMBEDDER_CACHE` as `dict[tuple[str, str, str], Embedder]` to match the 3-element key (backend, model, ollama_base_url). The stale 2-tuple annotation made `mypy headroom` fail on `main`, which broke the `lint` CI job on every open PR.
- **install:** include `orjson` in the `[proxy]` extra so `uv tool install "headroom-ai[all]"` satisfies LiteLLM OpenRouter/provider backends that import it at runtime ([#2056](https://github.com/headroomlabs-ai/headroom/issues/2056)).
- The dashboard's per-request metadata (the `recent_requests` / `request_logs`
tail and the `config` block with upstream URLs) is gated to loopback callers
via `_request_is_loopback`. When Headroom runs in a bridge-network container
@ -101,13 +103,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Bug Fixes
* **proxy:** strip output-only content blocks from request messages before forwarding. Anthropic's server-side refusal-fallback feature (`server-side-fallback-2026-06-01`) emits a `{"type":"fallback","from":{...},"to":{...}}` block inside the assistant response to signal that a refused request was re-served by the fallback model. That block is valid on the *response* path but rejected on the *request* path, so when a client replays the assistant turn the next request 400s (`invalid_request_error: messages.N.content.0: Input tag 'fallback' ...`) and the conversation gets permanently stuck through the proxy. `read_request_json_with_bytes` (Anthropic/OpenAI/Bedrock) and `_read_request_json` (Gemini) now drop such blocks — re-encoding the raw bytes so byte-faithful passthrough cannot leak the pre-strip body, backfilling a benign text block if a turn is emptied, and leaving requests without such blocks byte-identical (no cache churn).
* **wrap/doctor:** make the Claude Remote Control gate warning accurate and stop it firing for users who never had the feature ([#1779](https://github.com/headroomlabs-ai/headroom/issues/1779)). Claude Code 2.1.196 added a client-side check that **deterministically** disables first-party Remote Control (`/remote-control` / `/rc`) whenever `ANTHROPIC_BASE_URL` points at a non-`api.anthropic.com` host — which Headroom always does. The old notice hedged ("may hide the Remote Control menu"); it now states the disable as fact, names the `/rc` command, and detects the installed Claude Code version so the wording is exact (`2.1.196` when known, `2.1.196+` when not). The gate is upstream and RC's control-plane talks to `claude.ai` (not the API host), so Headroom cannot restore it — the warning tells you to run Claude without Headroom for RC sessions. The warning is suppressed for auth modes that never had Remote Control (API-key/PAYG via `ANTHROPIC_API_KEY`/`ANTHROPIC_AUTH_TOKEN`, and Bedrock/Vertex/Foundry cloud IAM) and on Claude Code builds older than 2.1.196 where RC is unaffected by a custom base URL. Both the `headroom wrap claude` launch banner and `headroom doctor` co-report the sibling base-URL gates Headroom *does* restore — on-demand tool loading (#746, automatic) and the 1M context window (#1158, via `--1m`) — and the wrap-side co-report is session-accurate: it says "already restored via --1m" when the flag is in effect and reports tool deferral OFF (not falsely "kept on") when the user chose `--tool-search false`/`ENABLE_TOOL_SEARCH=false`; the `ENABLE_TOOL_SEARCH=...` banner line got the same accuracy fix. `is_custom_anthropic_base_url` now recognizes scheme-less values (`myproxy.local:8080`, `127.0.0.1:8787`) as custom hosts and degrades gracefully on malformed URLs instead of crashing `doctor`. `doctor` resolves the Claude Code version lazily, so runs with no custom base URL never pay the `claude --version` subprocess. No request bytes are touched (cache-safe); this is UX/notice-only.
* **install:** default the docker image to `ghcr.io/headroomlabs-ai/headroom:latest` instead of the dead `ghcr.io/chopratejas/headroom:latest`. After the repo moved to the `headroomlabs-ai` org, GHCR did not redirect the old package, so `headroom install` / `headroom init` and the install scripts pulled a frozen `0.27.0` image while current releases publish to the new path ([#1867](https://github.com/headroomlabs-ai/headroom/issues/1867)).
* **transforms/content-router:** stop a profile-derived `read_protection_window` kwarg from weakening an explicit `--protect-tool-results` guarantee. `ContentRouter.apply()` computes `read_protection_window` from `protect_recent_reads_fraction`, where `0.0` (the sentinel `--protect-tool-results` sets) means "protect all excluded-tool output regardless of conversation depth" per #1374's documented contract — but the method then unconditionally overwrote that window with a `read_protection_window` kwarg whenever one was present. `proxy_pipeline_kwargs()` supplies that kwarg on every request from the active `AgentSavingsProfile.protect_recent` (the default `coding` profile sets `protect_recent=2`), so in practice only the last 2 messages ever kept read-protection and older excluded-tool output silently fell through to lossy compression. The runtime kwarg may now only narrow the window when `protect_recent_reads_fraction > 0`; it can no longer shrink the "protect everything" guarantee set by `--protect-tool-results`.
* **memory:** include the Ollama server URL in the embedder cache key so a second backend can't get an embedder bound to the wrong server. `_create_embedder` cached by `(backend, model)` only, but the Ollama embedder is constructed with `base_url=config.ollama_base_url`. Two configs in the same process that shared a backend and model but pointed at different Ollama servers (e.g. a per-project storage router) collided on one cache slot, so the second silently reused the first's embedder and embedded against the wrong host. The cache key now also includes `ollama_base_url`.
* **tokenizers:** use `o200k_base` for the gpt-4.1 / gpt-4.5 / o4 families in `get_encoding_for_model`. `gpt-4.1*` and `gpt-4.5*` matched the broad `gpt-4` prefix and were encoded with `cl100k_base`, and `o4*` matched no prefix and fell through to the `cl100k_base` default — all three use `o200k_base`, so their token counts were computed with the wrong vocabulary. Added explicit `gpt-4.1`/`gpt-4.5` prefixes ahead of `gpt-4` and an `o4` prefix; `gpt-4` and `gpt-3.5` snapshots still resolve to `cl100k_base`.
* **cache/ccr:** stop counting a successful eviction as a retrieval in the compression feedback learner, which inverted the learning signal. When an entry is evicted without ever being retrieved, `CompressionStore` emits a synthetic `retrieval_type="eviction_success"` event to mark that the compression was sufficient (the LLM never needed the original). `CompressionFeedback.record_retrieval` had no branch for it, so — because the type is not `"full"` — it was counted as a *search retrieval*, inflating the tool's `retrieval_rate`/`search_rate`. `get_compression_hints` reads a high retrieval rate as "compressing too aggressively" and backs off, so a compression that actually worked pushed the learner toward *less* compression (a standalone repro scores one successful eviction as a 100% retrieval rate). The event is now recognized and left out of the retrieval counters; the compression is still counted by `record_compression` at store time, so a never-retrieved entry correctly yields a low retrieval rate. Genuine retrievals are unaffected.
* **proxy/anthropic:** don't launder a non-2xx upstream into HTTP 200 when enterprise security scans the response. On the non-streaming `/v1/messages` path the response-scan branch rebuilt the reply as `httpx.Response(status_code=200)` and returned it without checking the upstream status, so a rate-limit (429), overloaded (529), or other 4xx error whose JSON body was scanned reached the client as an HTTP 200 — the client's retry/backoff never fired and an error looked like success. The branch is now gated on a 200 upstream, matching the sibling CCR/cache/buffered-stream blocks in the same handler; non-2xx responses fall through and keep their real status.
* **learn:** classify timeout and connection tool failures correctly instead of as generic runtime errors. In `classify_error` the generic `RUNTIME_ERROR` pattern (`Traceback|Exception:|Error:`) was checked before the dedicated `TIMEOUT` and `CONNECTION_ERROR` patterns. Because every Python exception repr is `XxxError: ...`, a `TimeoutError: ...` or `ConnectionError: ...` matched the catch-all first and was miscategorized as `RUNTIME_ERROR`, leaving those two categories unreachable for the common colon-repr form (they only fired for tokenless phrasings like `deadline exceeded`). The `TIMEOUT` and `CONNECTION_ERROR` patterns are now checked before the generic catch-all; tokenless generic errors still classify as `RUNTIME_ERROR`.
* **tokenizers:** resolve HuggingFace tokenizer names by the most-specific prefix. `get_tokenizer_name` scanned `MODEL_TO_TOKENIZER` in dict-insertion order and returned the first key the model merely starts with, so a short family key shadowed a more-specific one — `qwen2-7b-instruct` matched `qwen` before `qwen2`/`qwen2-7b` and resolved to the Qwen1 tokenizer (a different vocabulary, hence wrong token counts); `qwen2.5-*` and `deepseek-v2.x` were mis-resolved the same way. It now picks the longest matching prefix, mirroring the order-dependent-prefix guard the sibling tiktoken `get_encoding_for_model` already documents.
* **pricing:** map retired `claude-3-sonnet-20240229` to a Sonnet-tier price instead of Haiku. When LiteLLM's cost DB lacks the retired model, resolution falls through to `MODEL_ALIASES`, which pointed Claude 3 Sonnet (a $3/$15-per-1M model) at `claude-3-haiku-20240307` ($0.25/$1.25) — a different tier that underpriced every cost/savings figure for that model ~12x on both input and output. It now aliases to `claude-sonnet-4-20250514`, the same-price target the other retired-Sonnet aliases already use.
* **cache/semantic:** don't evict an unrelated entry when re-storing a key that is already cached. `SemanticCache.put` ran its at-capacity eviction loop before computing the entry's key, so overwriting a key that was already present (a duplicate or retried store) still evicted the LRU-oldest distinct entry even though an in-place update grows nothing. That silently dropped a live entry and turned a later lookup for it into a false cache miss. The key is now computed first and the eviction loop only runs when the key is genuinely new (mirroring `CompressionCache.store_compressed`, which deletes-then-inserts).
* **tokenizers:** stop `TiktokenCounter.count_messages` from exploding on non-text content blocks. Its multi-part branch handled only `text` and OpenAI `image_url`; every other shape (Anthropic `image`/`tool_result`/`tool_use`, Strands blocks) fell through to `count_text(str(part))`, which json-stringified the base64 payload and tokenized it as text — a 1MB image counted as ~330K phantom tokens (~218x overcount in a standalone repro), corrupting every downstream budgeting/compression decision for multimodal OpenAI-model requests. Unknown block shapes now delegate to the base `_count_content_parts`, which prices images/documents by a bounded estimate (the overcount that helper already exists to prevent).
* **install:** don't let a host env export override the manifest in persistent-docker deployments. `build_runtime_command` emitted the manifest's pinned `--env NAME=VALUE` pairs and then, for every host var matching a passthrough prefix, a bare `--env NAME`. Docker resolves duplicate `--env` last-wins, so a stale host export (e.g. `HEADROOM_BACKEND=anyllm`) that shared a passthrough prefix with a pinned manifest value (`HEADROOM_BACKEND=anthropic`) was appended after it and silently won, diverging the container from its deployment config. The bare passthrough is now skipped for any name the manifest already pins.
* **memory:** honor explicit `store=false` on OpenAI `/v1/responses` requests by skipping Headroom memory-tool injection that depends on stored-response continuations. Memory context injection stays available, and requests no longer get rewritten to `store=true` behind the client's back ([#1944](https://github.com/headroomlabs-ai/headroom/issues/1944)).
* **proxy/batch:** stop corrupting Google `batchGenerateContent` requests whose contents interleave text turns with text-less entries (functionCall/functionResponse/images). The batch handler restored preserved (non-text) entries with the raw-index loop that #836 replaced everywhere else — indexing the shorter `optimized_contents` (text-less entries produce no message) by the original `contents[]` index, which overwrites the wrong entry and drops any preserved entry whose original index is past the optimized length. A request like `[user text, model functionCall, user functionResponse, model text]` was forwarded to Google as two entries: the model's answer overwritten by the functionCall and the functionResponse dropped. The batch handler now uses the shared `_rebuild_gemini_contents` interleaving helper, so all entries survive in order.
* **proxy/gemini:** preserve Gemini code-execution parts (`executableCode` / `codeExecutionResult`) across the compression round-trip. `_has_non_text_parts` only recognized `inlineData`/`fileData`/`functionCall`/`functionResponse`, so a content entry carrying code-execution parts was not marked as preserved. A mixed `text`+`executableCode` entry lost its code payload (only the text survived), and a text-less code-execution entry was treated as a phantom that dropped the entire turn and shifted a neighboring message into the wrong role slot. Both keys are now recognized so those entries are preserved verbatim.
* **cache/ccr:** don't evict a live entry when a duplicate hash is re-stored at capacity. `CompressionStore.store` ran `_evict_if_needed()` before checking whether the key already existed, so re-storing an already-present hash while the store was full evicted the oldest *distinct* entry to "make room" and then merely overwrote the existing key in place — no room was ever needed. The store dropped below `max_entries` and a live, never-retrieved entry was destroyed, so its `<<ccr:...>>` marker (still in the conversation) resolved to a 404. The CCR mirror bridge re-stores the same `explicit_hash` every turn a marker is re-encountered, so this fired routinely. Eviction now runs only for a genuinely new key.
* **tokenizers:** recurse into a native `tool_result` whose content is a list of blocks instead of JSON-serializing it. `_count_content_parts` counted a `tool_result` with list content via `_count_serialized` (json.dumps + sample), so a base64 image nested in a tool result (computer-use / MCP screenshot tools) was priced as text — a ~50-200x overcount (a ~200KB screenshot read as ~70K tokens instead of ~1600). It now recurses into the nested blocks, matching the sibling Strands `toolResult` branch, so the image is priced structurally. The overcount made a single screenshot appear to blow past the model's context window and triggered unnecessary/over-aggressive compression.
* **tokenizers:** price dense scripts (CJK/Kana/Hangul) in the fixed-ratio estimator path. `EstimatingTokenCounter.count_text` applied the CJK correction only on the auto-detect path; its fixed-ratio early return divided by the Latin ratio with no adjustment. The registry builds every provider-calibrated counter with a fixed ratio (Anthropic 3.5, Google 4.0, Cohere 4.0, Moonshot 3.1), and the Anthropic/Gemini proxy handlers count via `get_tokenizer(model).count_messages`, so a CJK-heavy context read as ~40-55% of its true token size — it could fall under the size/backpressure gates and skip compression, and every `x-headroom-tokens-before` metric for CJK traffic was materially wrong. The fixed-ratio path now applies the same dense-script split.
* **ccr:** don't crash `parse_tool_call` on a CCR tool call whose arguments aren't an object. For the OpenAI/`openai_responses` shape the arguments are `json.loads`-decoded and only `JSONDecodeError` was caught, so a model that emitted `arguments='[]'`/`'"abc"'`/`'123'` (decoding to a list/str/number) — or a non-dict Anthropic `input` — reached `input_data.get("hash")` and raised `AttributeError`; a null `arguments` raised an uncaught `TypeError` from `json.loads(None)`. Both are now handled: the decode also catches `TypeError`, and a non-dict `input_data` returns `None` (not a valid CCR call) instead of crashing CCR response processing.
* **proxy/memory:** capture the user's prompt from Anthropic text blocks when building the memory-retrieval query. `extract_memory_query_sources` only recorded `latest_user` when a user message's `content` was a plain string; for the standard Anthropic `/v1/messages` shape (`content=[{"type":"text","text":...}]`, used by Claude Code) it routed into the tool-result extractor and never read the `text` blocks — so the actual question was discarded. On a first turn the embedding query was then empty and memory injection was silently skipped entirely; with history it keyed on stale assistant/tool context instead of the user's ask. The user turn's `text` blocks are now captured.
* **memory/sqlite:** stop `SQLiteMemoryStore.query` from emitting `OFFSET` without a `LIMIT`. The query builder appended `LIMIT` only when `filter.limit is not None` and `OFFSET` independently when `filter.offset > 0`, but SQLite accepts `OFFSET` only as part of a `LIMIT` clause — so a `MemoryFilter(offset=N)` with no limit produced `... OFFSET ?` and crashed with `sqlite3.OperationalError: near "OFFSET": syntax error`. An offset-without-limit now emits SQLite's unbounded `LIMIT -1` so pagination works.
* **mcp/codex:** don't corrupt an unparseable or non-table `config.toml` on register. `CodexRegistrar.register_server` only guarded against clobbering a user-managed entry when `get_server` returned one, but `get_server` returns `None` both for an unparseable TOML file and for an `mcp_servers`/`mcp_servers.<name>` that is present but not a table. In those cases `register_server` fell through to `_write_block`, which blindly appended a `[mcp_servers.<name>]` table — appending into an unparseable file, or creating a duplicate `[mcp_servers.headroom]` key alongside a non-table entry (e.g. `headroom = "..."`), which `tomllib`/codex then reject, destroying a previously-valid config. It now refuses (`FAILED`) and leaves the file untouched, mirroring the claude (#1660) and opencode (#1661) guards.
* **proxy/vertex:** route Vertex `publisher=google` (Gemini) requests to the region matching the request path. `vertex_generate_content`, `vertex_stream_generate_content`, and `vertex_count_tokens` discarded the path's `location` and forwarded to the single fixed host from `_api_target(proxy, "vertex")` (default `us-central1`), instead of the region-aware `_vertex_target_for_location` the sibling Anthropic `rawPredict` route already uses. So a request to `.../locations/europe-west1/publishers/google/...` was sent to a `us-central1` host, which Vertex rejects on the region/host mismatch. The three google routes now derive the host from the request's `location` (operator-pinned upstreams are still honored).
* **proxy/anthropic:** give each Anthropic conversation its own session id. `SessionTrackerStore.compute_session_id` derived its fallback id from `model` + system text harvested only from `role:"system"` entries inside `messages` — but Anthropic carries the system prompt as a top-level `body["system"]` field, so genuine Anthropic requests (which never carry `x-headroom-session-id`) collapsed to `md5(model:[])` and every conversation on the same model shared one `PrefixCacheTracker`. That let session-sticky state cross-contaminate: conversation A's sticky `headroom_retrieve`/memory tools and `anthropic-beta` headers were injected into conversation B, and frozen-prefix/compression-cache state mixed across conversations. The Anthropic handler now folds the top-level `system` into the session-id inputs (prepending a synthetic `role:"system"` message used only to derive the id), giving distinct conversations distinct ids.
* **cache/semantic:** key entries by the full-context hash, not the trailing query text. `SemanticCache.put` stored each response under `sha256(query)[:16]` where `query` is only the last user message, and the exact-match branch of `get` returned the slot without checking the stored entry's `messages_hash`. Two requests that share a trailing message ("continue", "yes", "run the tests") but differ in earlier context therefore collided on one slot — the second overwrote the first, and the first's hash then resolved to the second's cached response (wrong data served). Entries are now keyed by `messages_hash` when present, and `get` verifies `entry.messages_hash` before returning.
* **wrap/opencode:** `headroom unwrap opencode` now removes the Headroom rtk instruction block from the project and global `AGENTS.md`. `wrap opencode` injects the marker-fenced "prefix shell commands with `rtk`" guidance into both `./AGENTS.md` and `<opencode-home>/AGENTS.md`, but unwrap only restored the config and MCP state — so a plain `opencode` launch kept following the rtk guidance and failed once the managed rtk binary was off PATH. Unwrap now strips the marker-fenced block from both files, mirroring `unwrap codex` (#1421) and `unwrap copilot`.
* **proxy/savings:** stop billing the $3/M fallback rate for genuinely free models. `_estimate_compression_savings_usd` and `_estimate_input_cost_usd` read `input_cost_per_token` from litellm and used `if not input_cost_per_token: raise`, which treats a legitimate `0.0` (a free / local / vendored-at-0 model that litellm does carry) as "price unavailable" and falls back to `DEFAULT_FALLBACK_INPUT_COST_PER_TOKEN` — fabricating dollar savings/cost for a model that costs nothing. Both now use an explicit `is None` check so a present `0.0` flows through as `$0` while a missing key still falls back.
* **proxy/cost:** value prefix-cache savings with the most-used model's price, not the first-recorded one. `build_prefix_cache_stats` scanned `cost_tracker._tokens_sent_by_model` and broke on the *first* provider-matching model with a price — despite the "most-used model" comment — so a Claude Code session (Sonnet for the main loop, Haiku for titles/subagents) priced all of a provider's cache-read savings at whichever model happened to be recorded first. If Haiku ($0.80/M) came before Sonnet ($3/M), the dashboard understated cache savings ~3.75x (and vice-versa). It now picks the provider-matching, priced model with the highest token volume.
* **proxy/openai:** stop overriding an explicit client `stream_options.include_usage` on the streaming chat path. To count tokens from the trailing usage chunk, the handler set `include_usage: True` unconditionally — including flipping an explicit client `false` to `true`. The upstream then appended a usage-only chunk (`choices: []`) the client never requested, and the common `chunk.choices[0].delta` loop raised `IndexError`. The option is now only filled in when the client left the choice open (no `stream_options`, or a dict without `include_usage`); an explicit `true`/`false` is respected.
* **proxy/openai:** stop PRE_SEND from reintroducing `tools: []` after the direct #728 fix. The OpenAI request handler now mirrors the existing `tools or _original_tools is not None` body-write guard during PRE_SEND write-back, so providers that reject empty tool arrays no longer see a tools field when the client omitted it, while explicit client `tools: []` remains preserved ([#1983](https://github.com/headroomlabs-ai/headroom/issues/1983)).
* **proxy/openai:** keep the exact Responses function name `terminal` resident during OpenAI tool-search deferral so cache-mode optimization stops forwarding `terminal.terminal` and triggering the reserved-namespace 400 on Codex Responses ([#1946](https://github.com/headroomlabs-ai/headroom/issues/1946)).
* **proxy/transforms:** startup warmup no longer calls Kompress native preload before the proxy binds its port. Enabled Kompress is deferred to first use, unavailable Kompress stays reported unavailable, and cached-model startup avoids the native ONNX load path that crashed older glibc hosts ([#1908](https://github.com/headroomlabs-ai/headroom/issues/1908)).
* **subscription/copilot:** show a fully-consumed Copilot quota as 100% used instead of unknown. `parse_copilot_quota` read `remaining = raw.get("remaining") or raw.get("quota_remaining")`, so a category reporting `remaining: 0` (quota fully spent) had that legitimate `0` treated as falsy and — with no `quota_remaining` alias in the real payload — collapsed to `None`. `CopilotQuotaCategory.used`/`used_percent` then returned `None`, so the dashboard rendered the exhausted category as `used: -` / 0% (green gauge) rather than `300/300` / 100%. Now uses an explicit `is None` check.
* **proxy/gemini:** thread the savings-profile kwargs into the native Gemini/Vertex compression paths. `handle_gemini_generate_content`, `handle_google_cloudcode_stream`, and `handle_gemini_count_tokens` called `openai_pipeline.apply()` without `proxy_pipeline_kwargs(self.config)`, so `HEADROOM_SAVINGS_PROFILE` and the ProxyConfig knobs (`target_ratio`/`min_tokens_to_compress`/`protect_recent`/`max_items_after_crush`/...) were silently dropped on the Gemini path — those requests compressed with router defaults instead of the configured profile, diverging from the Claude/Codex/Cursor paths. This is the same fix #1534 made for the OpenAI chat path; it now covers Gemini too.
* **wrap:** `headroom wrap claude` no longer installs RTK or lean-ctx by default. Claude context-tool setup is now explicit via `--context-tool`, `--no-context-tool` remains accepted, and other wrap commands keep their current defaults ([#1915](https://github.com/headroomlabs-ai/headroom/issues/1915)).

View file

@ -1,6 +1,6 @@
services:
cli:
image: ${HEADROOM_IMAGE:-ghcr.io/chopratejas/headroom:latest}
image: ${HEADROOM_IMAGE:-ghcr.io/headroomlabs-ai/headroom:latest}
entrypoint: ["headroom"]
working_dir: /workspace
stdin_open: true
@ -24,7 +24,7 @@ services:
command: ["--help"]
proxy:
image: ${HEADROOM_IMAGE:-ghcr.io/chopratejas/headroom:latest}
image: ${HEADROOM_IMAGE:-ghcr.io/headroomlabs-ai/headroom:latest}
entrypoint: ["headroom", "proxy"]
working_dir: /workspace
restart: unless-stopped

View file

@ -107,7 +107,10 @@ def get_version() -> str:
if root is not None:
source_version = _source_tree_version(root)
if source_version:
return source_version
# A source checkout sits ahead of the last release tag, so this is
# the next version we'd cut, not a shipped one. Tag it -dev so a
# dev build is never mistaken for the published release.
return f"{source_version}-dev"
build_version = _packaged_build_version()
if build_version:

View file

@ -273,6 +273,19 @@ class CompressionFeedback:
if not tool_name:
return
# An entry evicted without ever being retrieved is a compression
# SUCCESS, not a retrieval: the LLM never needed the original data (see
# CompressionStore._record_eviction_success). It arrives here as
# retrieval_type="eviction_success"; because that is not "full" it used
# to fall into the search_retrievals branch below and inflate
# retrieval_rate/search_rate, which drove get_compression_hints toward
# LESS aggressive compression -- the inverse of the intended signal. The
# compression itself was already counted by record_compression at store
# time, so a never-retrieved entry already yields a low retrieval rate;
# this event must not be counted as a retrieval.
if event.retrieval_type == "eviction_success":
return
with self._lock:
self._total_retrievals += 1

View file

@ -347,16 +347,22 @@ class CompressionStore:
self.process_pending_feedback()
with self._lock:
self._evict_if_needed()
# CRITICAL FIX: Hash collision detection
# If hash already exists with DIFFERENT content, log a warning.
# This indicates either a hash collision or duplicate store calls.
# Decide whether this is a NEW key before evicting. Evicting to make
# room only applies to a genuinely new entry; a re-store of an
# existing key overwrites in place (no room needed). Evicting first
# for a duplicate would needlessly destroy a live, unrelated entry
# and drop the store below capacity, making that entry's <<ccr:...>>
# marker (still sitting in the conversation) unredeemable — a 404.
# The CCR mirror bridge re-stores the same explicit_hash on every
# turn a marker is re-encountered, so duplicate stores are common.
existing = self._backend.get(hash_key)
if existing is not None:
if existing is None:
self._evict_if_needed()
else:
# Hash already present. Different content means a true (extremely
# rare with SHA256[:24]) collision; same content is a duplicate
# re-store. Either way we overwrite in place.
if existing.original_content != original:
# True hash collision - different content, same hash
# This is extremely rare with SHA256[:24] but should be logged
logger.warning(
"Hash collision detected: hash=%s tool=%s (existing_len=%d, new_len=%d)",
hash_key,
@ -365,12 +371,11 @@ class CompressionStore:
len(original),
)
else:
# Same content being stored again - this is fine, just update
logger.debug(
"Duplicate store for hash=%s, updating entry",
hash_key,
)
# Mark old heap entry as stale since we're replacing
# Mark old heap entry as stale since we're replacing it.
self._stale_heap_entries += 1
self._backend.set(hash_key, entry)

View file

@ -300,8 +300,15 @@ class RegexDetector:
DynamicCategory.REQUEST_ID,
"api_key",
),
# Common prefixed IDs (req_, sess_, txn_, etc.)
(r"\b[a-z]{2,6}_[a-zA-Z0-9]{8,}", DynamicCategory.REQUEST_ID, "prefixed_id"),
# Common prefixed IDs (req_, sess_, txn_, etc.). The suffix must
# contain at least one digit (lookahead) so genuine generated ids
# like "req_a1b2c3d4" match while plain snake_case compound words
# like "in_progress" or "is_valid" (all letters, no digit) do not.
(
r"\b[a-z]{2,6}_(?=[a-zA-Z0-9]*\d)[a-zA-Z0-9]{8,}",
DynamicCategory.REQUEST_ID,
"prefixed_id",
),
# Hex strings of common ID lengths (32 = MD5, 40 = SHA1, 64 = SHA256)
(r"\b[a-fA-F0-9]{32}\b", DynamicCategory.IDENTIFIER, "hex_32"),
(r"\b[a-fA-F0-9]{40}\b", DynamicCategory.IDENTIFIER, "hex_40"),
@ -325,10 +332,20 @@ class RegexDetector:
]
# Build structural pattern from dynamic labels
# Pattern: "label" followed by separator then value
# Pattern: "label" followed by an explicit key/value separator then value.
#
# Two constraints keep this from firing on ordinary prose and code:
# * A word boundary (\b) and a trailing negative-lookahead on word
# characters anchor the label as a whole word. Without them a label
# like "token" or "last" matched as a substring inside unrelated
# identifiers such as "getAuthToken" or "blast".
# * The separator must be an explicit ":" or "=" (optionally spaced).
# A bare-whitespace separator turned any English sentence beginning
# with a label word ("current work is ...", "name of the file ...")
# into a bogus label/value pair that swallowed the rest of the clause.
labels_pattern = "|".join(re.escape(label) for label in config.dynamic_labels)
self._structural_pattern = re.compile(
rf"(?P<label>(?:{labels_pattern}))(?P<sep>\s*[:=]\s*|\s+)(?P<value>[^\n,;]+)",
rf"\b(?P<label>(?:{labels_pattern}))(?!\w)(?P<sep>\s*[:=]\s*)(?P<value>[^\n,;]+)",
re.IGNORECASE,
)
@ -459,12 +476,18 @@ class RegexDetector:
if len(text) < self.config.min_entropy_length:
continue
# Skip if all letters or all numbers (not random-looking)
if text.isalpha() or text.isdigit():
# Require genuinely id-shaped structure rather than a random-looking
# spelling. Generated identifiers (session ids, request ids, hashes,
# tokens) essentially always mix in at least one digit, whereas
# ordinary words and compound identifiers are letters (plus "-"/"_"
# separators) only. Skipping the letters-only case avoids flagging
# prose words as well as snake_case / kebab-case vocabulary like
# "in_progress", "system-reminder" or "total_tokens" that recurs
# identically every turn and must stay in the cacheable prefix.
if text.isdigit():
continue
# Skip common words that might look like IDs
if text.lower() in {"username", "password", "localhost", "undefined"}:
letters_only = text.replace("-", "").replace("_", "")
if not letters_only or letters_only.isalpha():
continue
# Calculate entropy

View file

@ -192,15 +192,6 @@ class SemanticCache:
"""
self._cleanup_expired()
# Evict if at capacity
while len(self._cache) >= self.config.max_entries:
self._evict_oldest()
# Generate embedding if available
embedding: list[float] = []
if self._embedding_fn:
embedding = self._embedding_fn(query)
# Create cache key. Prefer the full-context hash: two requests that share
# a trailing user message ("continue", "yes", "run the tests") but differ
# in earlier context must NOT collide on one query-derived slot and
@ -208,6 +199,20 @@ class SemanticCache:
# messages_hash is supplied (e.g. embedding-only usage).
key = messages_hash or self._generate_key(query)
# Evict if adding a NEW key would exceed capacity. Overwriting a key that
# is already present is an in-place update that does not grow the map, so
# it must NOT evict — the old code ran the eviction loop before computing
# the key, so re-storing an existing entry at capacity dropped an
# unrelated live entry and turned a later lookup for it into a false miss.
# (Mirrors CompressionCache.store_compressed, which deletes-then-inserts.)
while key not in self._cache and len(self._cache) >= self.config.max_entries:
self._evict_oldest()
# Generate embedding if available
embedding: list[float] = []
if self._embedding_fn:
embedding = self._embedding_fn(query)
now = time.time()
entry = CacheEntry(
embedding=embedding,

View file

@ -130,6 +130,7 @@ def _format_session_summary(
"too_small": "Too small (< 500 tokens)",
"passthrough": "Passthrough (token counting)",
"no_compressible_content": "No compressible content (user/assistant only)",
"unknown_token_accounting": "Unknown token accounting",
}
for key, count in uncomp.items():
label = reason_labels.get(key, key)

View file

@ -14,7 +14,7 @@ from __future__ import annotations
import json
import os
import re
from collections.abc import Mapping
from collections.abc import Callable, Mapping
from dataclasses import asdict, dataclass
from datetime import datetime
from pathlib import Path
@ -29,7 +29,11 @@ from headroom.install.state import list_manifests
from headroom.paths import savings_path
from headroom.providers.claude import (
REMOTE_CONTROL_BASE_URL_ENV,
REMOTE_CONTROL_SIBLING_GATE_NOTE,
detect_claude_code_version,
is_custom_anthropic_base_url,
remote_control_applies_to_auth,
remote_control_gate_active,
remote_control_gate_message,
)
@ -168,37 +172,71 @@ def check_claude_routing(settings_path: Path, port: int) -> CheckResult:
def check_claude_remote_control_gate(
settings_path: Path, environ: Mapping[str, str]
settings_path: Path,
environ: Mapping[str, str],
*,
version: tuple[int, int, int] | None = None,
version_resolver: Callable[[], tuple[int, int, int] | None] | None = None,
) -> CheckResult | None:
"""Warn once when Claude custom-base routing hides Remote Control."""
"""Warn once when Claude custom-base routing hides Remote Control (issue #1779).
Fires only for a session that could ever have had Remote Control a
subscription auth mode (not API-key/cloud IAM) on a Claude Code build at/after
the gate version, or an unknown version. Auth signals are read from the shell
``environ`` overlaid on the settings-file ``env`` block, so an API key
configured in either place suppresses the warning.
``version`` is the detected Claude Code version (``None`` = unknown); tests
pass it directly so the check stays pure. ``version_resolver`` lets the
``doctor`` entrypoint defer the ``claude --version`` subprocess until the
cheap gates (custom base URL + subscription auth) have passed most doctor
runs never pay it. An explicit ``version`` wins over the resolver; the
resolver is called at most once.
"""
name = "claude remote control"
settings_env: dict[str, object] = {}
settings_base_url = ""
if settings_path.exists():
try:
payload = json.loads(settings_path.read_text(encoding="utf-8"))
env_block = payload.get("env")
if isinstance(env_block, dict):
settings_env = env_block
settings_base_url = str(env_block.get("ANTHROPIC_BASE_URL", "") or "")
except (OSError, ValueError):
settings_env = {}
settings_base_url = ""
if is_custom_anthropic_base_url(settings_base_url):
remote_message = remote_control_gate_message(f"{REMOTE_CONTROL_BASE_URL_ENV} from settings")
return CheckResult(
name=name,
status=WARN,
summary=remote_message,
hint=remote_message,
)
# Shell env wins over settings env, matching Claude Code's own precedence.
effective_env: dict[str, object] = {**settings_env, **dict(environ)}
env_base_url = environ.get("ANTHROPIC_BASE_URL", "")
if is_custom_anthropic_base_url(env_base_url):
remote_message = remote_control_gate_message(f"{REMOTE_CONTROL_BASE_URL_ENV} in shell")
return CheckResult(
name=name,
status=WARN,
summary=remote_message,
hint=remote_message,
)
resolved_version = version
version_resolved = version is not None or version_resolver is None
for base_url, source in (
(settings_base_url, "from settings"),
(env_base_url, "in shell"),
):
# Cheap gates first so the version subprocess only runs when a warning
# is actually plausible for this environment.
if not is_custom_anthropic_base_url(base_url):
continue
if not remote_control_applies_to_auth(effective_env):
return None
if not version_resolved and version_resolver is not None:
resolved_version = version_resolver()
version_resolved = True
if remote_control_gate_active(base_url, effective_env, resolved_version):
remote_message = remote_control_gate_message(
f"{REMOTE_CONTROL_BASE_URL_ENV} {source}", version=resolved_version
)
return CheckResult(
name=name,
status=WARN,
summary=remote_message,
hint=REMOTE_CONTROL_SIBLING_GATE_NOTE,
)
return None
@ -473,7 +511,12 @@ def doctor(port: int, emit_json: bool) -> None:
check_savings(stats, savings_path()),
check_budget(stats),
]
remote_control_gate_check = check_claude_remote_control_gate(claude_settings_path(), os.environ)
# Lazy resolver: `claude --version` is a Node CLI subprocess (seconds of
# cold start, 10s worst-case timeout) — only pay for it when the RC gate
# is actually plausible (custom base URL + subscription auth).
remote_control_gate_check = check_claude_remote_control_gate(
claude_settings_path(), os.environ, version_resolver=detect_claude_code_version
)
if remote_control_gate_check is not None:
checks.append(remote_control_gate_check)
deployments = check_deployments(list_manifests())

View file

@ -541,7 +541,7 @@ def _ensure_runtime_manifest(
proxy_mode="token",
memory_enabled=memory,
telemetry_enabled=True,
image="ghcr.io/chopratejas/headroom:latest",
image="ghcr.io/headroomlabs-ai/headroom:latest",
)
manifest.supervisor_kind = SupervisorKind.NONE.value
manifest.artifacts = []
@ -577,7 +577,7 @@ def _env_manifest(values: dict[str, str]) -> Any:
proxy_mode="token",
memory_enabled=False,
telemetry_enabled=True,
image="ghcr.io/chopratejas/headroom:latest",
image="ghcr.io/headroomlabs-ai/headroom:latest",
)

View file

@ -213,7 +213,7 @@ def _reject_task_lifecycle(manifest: DeploymentManifest, action: str) -> None:
)
@click.option(
"--image",
default="ghcr.io/chopratejas/headroom:latest",
default="ghcr.io/headroomlabs-ai/headroom:latest",
show_default=True,
help="Docker image to use when runtime=docker or preset=persistent-docker.",
)

View file

@ -285,6 +285,10 @@ def dashboard(port: int, no_open: bool) -> None:
help=(
"Comma-separated tool names whose results are never lossy-compressed, "
"merged with the built-in defaults (e.g. Bash,WebFetch). "
"In token mode, this also resets protect_recent_reads_fraction "
"from 0.3 (only recent ~30% of results protected) to 0.0 (all "
"results protected indefinitely), which prevents older Read/Glob/"
"Grep/Write/Edit tool results from being silently compressed. "
"Env: HEADROOM_PROTECT_TOOL_RESULTS."
),
)
@ -373,6 +377,26 @@ def dashboard(port: int, no_open: bool) -> None:
"Env: HEADROOM_RETRY_MAX_ATTEMPTS."
),
)
@click.option(
"--retry-base-delay-ms",
type=click.IntRange(min=0),
default=None,
envvar="HEADROOM_RETRY_BASE_DELAY_MS",
help=(
"Initial upstream retry delay in milliseconds (minimum: 0, default: 1000). "
"Env: HEADROOM_RETRY_BASE_DELAY_MS."
),
)
@click.option(
"--retry-max-delay-ms",
type=click.IntRange(min=0),
default=None,
envvar="HEADROOM_RETRY_MAX_DELAY_MS",
help=(
"Maximum upstream retry delay in milliseconds (minimum: 0, default: 30000). "
"Env: HEADROOM_RETRY_MAX_DELAY_MS."
),
)
@click.option(
"--request-timeout-seconds",
type=int,
@ -876,6 +900,8 @@ def proxy(
no_subscription_tracking: bool,
subscription_poll_interval: int | None,
retry_max_attempts: int | None,
retry_base_delay_ms: int | None,
retry_max_delay_ms: int | None,
request_timeout_seconds: int | None,
connect_timeout_seconds: int | None,
anthropic_buffered_request_timeout_seconds: int | None,
@ -1129,6 +1155,8 @@ def proxy(
subscription_poll_interval if subscription_poll_interval is not None else 300
),
retry_max_attempts=retry_max_attempts if retry_max_attempts is not None else 3,
retry_base_delay_ms=retry_base_delay_ms if retry_base_delay_ms is not None else 1000,
retry_max_delay_ms=retry_max_delay_ms if retry_max_delay_ms is not None else 30000,
request_timeout_seconds=request_timeout_seconds
if request_timeout_seconds is not None and request_timeout_seconds > 0
else 300,

View file

@ -61,8 +61,11 @@ from headroom.providers.claude import (
REMOTE_CONTROL_BASE_URL_ENV,
TOOL_SEARCH_DEFAULT,
TOOL_SEARCH_ENV,
is_custom_anthropic_base_url,
detect_claude_code_version,
remote_control_applies_to_auth,
remote_control_gate_active,
remote_control_gate_message,
remote_control_sibling_gate_note,
)
from headroom.providers.claude import (
proxy_base_url as _claude_proxy_base_url,
@ -105,6 +108,9 @@ from headroom.providers.copilot import (
)
from headroom.providers.cursor import render_setup_lines as _render_cursor_setup_lines
from headroom.providers.mistral_vibe import build_launch_env as _build_mistral_vibe_launch_env
from headroom.providers.openclaw import (
OPENCLAW_NPM_PACKAGE,
)
from headroom.providers.openclaw import (
build_plugin_entry as _build_openclaw_plugin_entry_impl,
)
@ -241,6 +247,32 @@ def _configure_tool_search_env(env: dict[str, str], flag_value: str | None) -> s
return _TOOL_SEARCH_DEFAULT
# ENABLE_TOOL_SEARCH modes that turn deferral OFF. Everything else Claude Code
# accepts (true/1/yes/on/auto/auto:N) keeps on-demand tool loading active.
_TOOL_SEARCH_FALSY = {"false", "0", "no", "off"}
def _resolved_tool_search_mode(flag_value: str | None) -> str:
"""Predict the ``ENABLE_TOOL_SEARCH`` value the launched process will get.
Runs :func:`_configure_tool_search_env` against a throwaway copy of the
relevant environment, so messages printed *before* the real injection (the
Remote Control sibling note, issue #1779) apply the exact same precedence
(flag > existing non-blank env > default) and can never drift from it.
"""
probe: dict[str, str] = {}
existing = os.environ.get(_TOOL_SEARCH_ENV)
if existing is not None:
probe[_TOOL_SEARCH_ENV] = existing
written = _configure_tool_search_env(probe, flag_value)
return written if written is not None else probe.get(_TOOL_SEARCH_ENV, "")
def _tool_search_mode_is_active(value: str) -> bool:
"""Whether an ``ENABLE_TOOL_SEARCH`` mode keeps tool deferral on (#746)."""
return value.strip().lower() not in _TOOL_SEARCH_FALSY
def _live_wrap_module() -> Any:
"""Return the current live wrap module instance."""
return cast(Any, sys.modules[__name__])
@ -1505,7 +1537,14 @@ def _codex_session_home_overlay() -> Any:
with tempfile.TemporaryDirectory(prefix="headroom-codex-home-") as tmp_dir:
session_home = Path(tmp_dir)
if source_home.exists():
shutil.copytree(source_home, session_home, dirs_exist_ok=True)
shutil.copytree(
source_home,
session_home,
dirs_exist_ok=True,
ignore=lambda directory, names: [
name for name in names if (Path(directory) / name).is_socket()
],
)
os.environ["CODEX_HOME"] = str(session_home)
try:
@ -2683,7 +2722,9 @@ def _proxy_needs_version_restart(payload: dict[str, Any] | None) -> bool:
"""Return True when a running Headroom proxy uses a different package version."""
running_version = _proxy_version(payload)
running_release = _normalize_release_version(running_version)
current_release = _normalize_release_version(_HEADROOM_VERSION)
# -dev is a display marker for source builds; compare the base release so a
# dev CLI still restarts a stale proxy on a real version difference.
current_release = _normalize_release_version(_HEADROOM_VERSION.removesuffix("-dev"))
return (
running_release is not None
and current_release is not None
@ -4061,11 +4102,37 @@ def claude(
)
else:
click.echo(f" ANTHROPIC_BASE_URL={proxy_url}")
if is_custom_anthropic_base_url(proxy_url):
# Issue #1779: Claude Code 2.1.196+ deterministically disables
# first-party Remote Control (/rc) behind a custom ANTHROPIC_BASE_URL.
# Warn accurately — but only for subscription sessions that ever had
# RC (skip API-key/cloud auth) and only when the installed version is
# at/after the gate (or unknown). The gate is upstream; Headroom
# cannot restore RC, so this is a launch-time notice, not a fix.
# Detecting the version shells out to `claude --version`, so skip that
# subprocess for auth modes we would never warn about anyway.
_cc_version = (
detect_claude_code_version(claude_bin)
if remote_control_applies_to_auth(os.environ)
else None
)
if remote_control_gate_active(proxy_url, os.environ, _cc_version):
click.echo(
" "
+ remote_control_gate_message(
f"the wrapped Claude session's {REMOTE_CONTROL_BASE_URL_ENV}"
f"the wrapped Claude session's {REMOTE_CONTROL_BASE_URL_ENV}",
version=_cc_version,
)
)
# Session-accurate sibling co-report: reflect what THIS launch
# actually does with #746/#1158 (never claim deferral is on for
# a --tool-search false session, never advise --1m twice).
click.echo(
" "
+ remote_control_sibling_gate_note(
tool_search_active=_tool_search_mode_is_active(
_resolved_tool_search_mode(tool_search)
),
context_1m_enabled=context_1m,
)
)
if claude_args:
@ -4121,9 +4188,16 @@ def claude(
# proxy so tool schemas are not eagerly materialized into local context.
_tool_search_value = _configure_tool_search_env(env, tool_search)
if _tool_search_value is not None:
# Describe what the written value actually does: --tool-search
# false/0/no/off turns deferral OFF, and the banner must say so
# rather than repeat "kept on" (issue #1779 accuracy rule).
_tool_search_state = (
"on-demand tool loading kept on"
if _tool_search_mode_is_active(_tool_search_value)
else "on-demand tool loading DISABLED per your setting"
)
click.echo(
f" {_TOOL_SEARCH_ENV}={_tool_search_value} "
"(on-demand tool loading kept on; issue #746)"
f" {_TOOL_SEARCH_ENV}={_tool_search_value} ({_tool_search_state}; issue #746)"
)
elif verbose:
click.echo(
@ -5663,7 +5737,7 @@ def openhands(
)
@click.option(
"--plugin-spec",
default="headroom-ai/openclaw",
default=OPENCLAW_NPM_PACKAGE,
show_default=True,
help="NPM plugin spec for OpenClaw install (used when --plugin-path is omitted)",
)
@ -5802,9 +5876,6 @@ def openclaw(
enabled=True,
)
click.echo(" Writing plugin configuration...")
_write_openclaw_plugin_entry(openclaw_bin, entry)
install_cmd = [
openclaw_bin,
"plugins",
@ -5855,6 +5926,12 @@ def openclaw(
elif verbose and install_result.stdout.strip():
click.echo(install_result.stdout.strip())
# Write the managed plugin entry only after a successful (or recoverable)
# install, so a hard install failure leaves no stale
# plugins.entries.headroom config behind.
click.echo(" Writing plugin configuration...")
_write_openclaw_plugin_entry(openclaw_bin, entry)
_set_openclaw_context_engine_slot(openclaw_bin, "headroom")
_run_checked(
[openclaw_bin, "config", "validate"],
@ -5903,6 +5980,11 @@ def openclaw(
is_flag=True,
help="Skip CLI context-tool setup",
)
@click.option(
"--no-project-rtk",
is_flag=True,
help="Skip rtk instruction injection into the project AGENTS.md",
)
@click.option("--no-mcp", is_flag=True, help="Skip headroom MCP server registration")
@click.option("--no-serena", is_flag=True, help="Skip Serena MCP server registration")
@click.option(
@ -5924,6 +6006,7 @@ def openclaw(
def opencode(
port: int,
no_rtk: bool,
no_project_rtk: bool,
no_mcp: bool,
no_serena: bool,
code_graph: bool,
@ -5949,6 +6032,7 @@ def opencode(
headroom wrap opencode # Start proxy + context tool + opencode
headroom wrap opencode -- "fix the bug" # Pass prompt to opencode
headroom wrap opencode --no-context-tool # Skip CLI context-tool setup
headroom wrap opencode --no-project-rtk # Keep project AGENTS.md unchanged
headroom wrap opencode --no-mcp # Skip MCP retrieve tool registration
headroom wrap opencode --no-serena # Skip Serena MCP registration
headroom wrap opencode --port 9999 # Custom proxy port
@ -5968,9 +6052,9 @@ def opencode(
click.echo(" Setting up rtk for OpenCode...")
rtk_path = _ensure_rtk_binary(verbose=verbose)
if rtk_path:
# Inject into project AGENTS.md
project_agents = Path.cwd() / "AGENTS.md"
_inject_rtk_instructions(project_agents, verbose=verbose)
if not no_project_rtk:
project_agents = Path.cwd() / "AGENTS.md"
_inject_rtk_instructions(project_agents, verbose=verbose)
# Inject into global OpenCode AGENTS.md
global_agents = _opencode_home_dir() / "AGENTS.md"
_inject_rtk_instructions(global_agents, verbose=verbose)
@ -6171,6 +6255,17 @@ def unwrap_opencode(port: int, no_stop_proxy: bool) -> None:
elif serena_status == "failed":
click.echo(" Serena MCP server matched Headroom ledger but could not be removed.")
# `wrap opencode` injects the marker-fenced rtk guidance into both the project
# `AGENTS.md` and the global `_opencode_home_dir() / "AGENTS.md"`; that block is
# durable state the config restore above does not touch. Without removing it, a
# plain `opencode` launch keeps following Headroom's "prefix shell commands with
# rtk" instruction and fails when the managed rtk binary is off PATH. Mirror what
# unwrap_codex / unwrap_copilot already do. Best-effort and unconditional, like
# the MCP cleanup above.
for _agents_md in (Path.cwd() / "AGENTS.md", _opencode_home_dir() / "AGENTS.md"):
if _remove_rtk_instructions(_agents_md):
click.echo(f" Removed Headroom rtk instructions from {_agents_md}.")
click.echo()
click.echo("✓ OpenCode is no longer routed through the Headroom proxy.")
if not no_stop_proxy and status != "noop":

6379
headroom/cli/wrap.py.orig Normal file

File diff suppressed because it is too large Load diff

View file

@ -1307,12 +1307,12 @@
<div class="px-4 py-3 min-w-0">
<span class="px-2 py-0.5 bg-border rounded text-xs truncate" x-text="truncateModel(req.model)"></span>
</div>
<div class="px-4 py-3 text-right font-mono tabular-nums" x-text="formatNumber(req.input_tokens_optimized)"></div>
<div class="px-4 py-3 text-right font-mono tabular-nums" x-text="formatNumber(req.output_tokens || 0)"></div>
<div class="px-4 py-3 text-right font-mono tabular-nums" x-text="formatOptionalNumber(req.input_tokens_optimized)"></div>
<div class="px-4 py-3 text-right font-mono tabular-nums" x-text="formatOptionalNumber(req.output_tokens)"></div>
<div class="px-4 py-3 text-right">
<span class="text-accent font-mono tabular-nums" x-text="req.savings_percent.toFixed(0) + '%'"></span>
<span class="text-accent font-mono tabular-nums" x-text="formatOptionalPercent(req.savings_percent)"></span>
</div>
<div class="px-4 py-3 text-right font-mono tabular-nums text-gray-400" x-text="(req.total_latency_ms || 0).toFixed(0) + 'ms'"></div>
<div class="px-4 py-3 text-right font-mono tabular-nums text-gray-400" x-text="formatOptionalMs(req.total_latency_ms)"></div>
</div>
</div>
<!-- Expanded detail row -->
@ -1321,19 +1321,19 @@
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 text-xs">
<div>
<div class="text-gray-500 uppercase tracking-wide mb-1">Original Tokens</div>
<div class="font-mono" x-text="formatNumber(req.input_tokens_original)"></div>
<div class="font-mono" x-text="formatOptionalNumber(req.input_tokens_original)"></div>
</div>
<div>
<div class="text-gray-500 uppercase tracking-wide mb-1">Compressed Tokens</div>
<div class="font-mono" x-text="formatNumber(req.input_tokens_optimized)"></div>
<div class="font-mono" x-text="formatOptionalNumber(req.input_tokens_optimized)"></div>
</div>
<div>
<div class="text-gray-500 uppercase tracking-wide mb-1">Tokens Removed</div>
<div class="font-mono text-accent" x-text="formatNumber(req.tokens_saved)"></div>
<div class="font-mono text-accent" x-text="formatOptionalNumber(req.tokens_saved)"></div>
</div>
<div>
<div class="text-gray-500 uppercase tracking-wide mb-1">Optimization Time</div>
<div class="font-mono" x-text="(req.optimization_latency_ms || 0).toFixed(0) + 'ms'"></div>
<div class="font-mono" x-text="formatOptionalMs(req.optimization_latency_ms)"></div>
</div>
</div>
<!-- Transforms Applied -->
@ -2054,12 +2054,28 @@
// --- Formatting ---
hasNumber(n) {
return typeof n === 'number' && Number.isFinite(n);
},
formatNumber(n) {
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
return n.toString();
},
formatOptionalNumber(n) {
return this.hasNumber(n) ? this.formatNumber(n) : 'unknown';
},
formatOptionalPercent(n) {
return this.hasNumber(n) ? n.toFixed(0) + '%' : 'unknown';
},
formatOptionalMs(n) {
return this.hasNumber(n) ? n.toFixed(0) + 'ms' : 'unknown';
},
formatCurrency(n) {
if (n < 0) return '-' + this.formatCurrency(-n);
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';

View file

@ -104,7 +104,7 @@ class DeploymentManifest:
memory_enabled: bool = False
memory_db_path: str = ""
telemetry_enabled: bool = True
image: str = "ghcr.io/chopratejas/headroom:latest"
image: str = "ghcr.io/headroomlabs-ai/headroom:latest"
service_name: str = "headroom"
container_name: str = "headroom-persistent"
health_url: str = "http://127.0.0.1:8787/readyz"

View file

@ -140,7 +140,13 @@ def build_runtime_command(manifest: DeploymentManifest) -> list[str]:
for name, value in runtime_env.items():
command.extend(["--env", f"{name}={value}"])
for name in sorted(os.environ):
if name.startswith(PASSTHROUGH_ENV_PREFIXES):
# Skip any name the manifest already pinned above: Docker resolves
# duplicate `--env` last-wins, so a bare `--env HEADROOM_BACKEND`
# passthrough (which reads the host process env at
# `start_persistent_docker` time) would silently override the manifest's
# `--env HEADROOM_BACKEND=<value>`, diverging the container from its
# deployment config.
if name.startswith(PASSTHROUGH_ENV_PREFIXES) and name not in runtime_env:
command.extend(["--env", name])
# The image ENTRYPOINT already runs `headroom proxy` (see Dockerfile), so
# the args appended after the image name are only the proxy flags — never

View file

@ -51,8 +51,18 @@ _ERROR_PATTERNS: list[tuple[re.Pattern[str], ErrorCategory]] = [
),
(re.compile(r"EISDIR|Is a directory", re.I), ErrorCategory.IS_DIRECTORY),
(re.compile(r"SyntaxError|IndentationError", re.I), ErrorCategory.SYNTAX_ERROR),
(re.compile(r"Traceback \(most recent|Exception:|Error:", re.I), ErrorCategory.RUNTIME_ERROR),
# Specific runtime failures must be checked BEFORE the generic RUNTIME_ERROR
# catch-all below. Every Python exception repr is "XxxError: ..." (or
# "Exception: ..."), so the generic `Error:`/`Exception:` pattern would
# otherwise match first and a "TimeoutError: ..." / "ConnectionError: ..."
# would be miscategorized as RUNTIME_ERROR, making the dedicated TIMEOUT and
# CONNECTION_ERROR categories unreachable for the common colon-repr form.
(re.compile(r"timed? ?out|TimeoutError|deadline exceeded", re.I), ErrorCategory.TIMEOUT),
(
re.compile(r"ConnectionError|ConnectionRefused|ECONNREFUSED|network", re.I),
ErrorCategory.CONNECTION_ERROR,
),
(re.compile(r"Traceback \(most recent|Exception:|Error:", re.I), ErrorCategory.RUNTIME_ERROR),
(re.compile(r"No (?:matches|files|results) found|0 matches", re.I), ErrorCategory.NO_MATCHES),
(
re.compile(r"user.*reject|user.*denied|declined|didn't want to proceed", re.I),
@ -60,10 +70,6 @@ _ERROR_PATTERNS: list[tuple[re.Pattern[str], ErrorCategory]] = [
),
(re.compile(r"[Ss]ibling tool call errored", re.I), ErrorCategory.SIBLING_ERROR),
(re.compile(r"exit code|non-zero|exited with", re.I), ErrorCategory.EXIT_CODE),
(
re.compile(r"ConnectionError|ConnectionRefused|ECONNREFUSED|network", re.I),
ErrorCategory.CONNECTION_ERROR,
),
(
re.compile(r"BUILD FAILED|compilation error|compile error", re.I),
ErrorCategory.BUILD_FAILURE,

View file

@ -110,6 +110,22 @@ class CodexRegistrar(MCPRegistrar):
# Drop any prior Headroom block before re-writing.
self.unregister_server(spec.name)
# `existing is None` here can also mean the file is present but
# unparseable, or defines mcp_servers[.<name>] as a non-table.
# _write_block appends a `[mcp_servers.<name>]` table, so appending into
# an unparseable file corrupts it further, and appending alongside a
# non-table entry creates a duplicate `[mcp_servers.<name>]` key that
# tomllib/codex then reject — destroying a previously-valid user config.
# Refuse rather than clobber, mirroring the claude (#1660) / opencode
# (#1661) guards.
if existing is None:
reason = self._unmergeable_reason(spec.name)
if reason is not None:
return RegisterResult(
RegisterStatus.FAILED,
f"{reason}; refusing to overwrite. Fix or remove the file, then re-run.",
)
return self._write_block(spec)
def unregister_server(self, server_name: str) -> bool:
@ -155,6 +171,36 @@ class CodexRegistrar(MCPRegistrar):
return {}
return data if isinstance(data, dict) else {}
def _unmergeable_reason(self, name: str) -> str | None:
"""Return why the existing config cannot be safely merged, or ``None``.
``_write_block`` appends a ``[mcp_servers.<name>]`` table. That is only
safe when the file is absent/empty or parses as a TOML table whose
``mcp_servers`` (and ``mcp_servers.<name>``) are tables. A present-but-
unparseable file, or a non-table ``mcp_servers`` / ``mcp_servers.<name>``,
would be corrupted (unparseable) or made to hold a duplicate key
(non-table entry) by a blind append.
"""
if not self._config_file.exists():
return None
raw = self._read_text()
if not raw.strip():
return None
try:
data = tomllib.loads(fsutil.read_text(self._config_file))
except (tomllib.TOMLDecodeError, OSError) as exc:
return f"{self._config_file} is not valid TOML ({exc})"
if not isinstance(data, dict):
return f"{self._config_file} top-level TOML is not a table"
servers = data.get("mcp_servers")
if servers is not None and not isinstance(servers, dict):
return f"{self._config_file} has a non-table mcp_servers"
if isinstance(servers, dict):
entry = servers.get(name)
if entry is not None and not isinstance(entry, dict):
return f"{self._config_file} has a non-table mcp_servers.{name}"
return None
def _read_text(self) -> str:
return fsutil.read_text(self._config_file, default="")

View file

@ -586,6 +586,11 @@ class SQLiteMemoryStore:
params.append(filter.limit)
if filter.offset > 0:
# SQLite only accepts OFFSET as part of a LIMIT clause; an OFFSET
# without a LIMIT is a syntax error. When the caller paginates with
# an offset but no limit, use SQLite's unbounded ``LIMIT -1``.
if filter.limit is None:
query += " LIMIT -1"
query += " OFFSET ?"
params.append(filter.offset)

View file

@ -34,7 +34,7 @@ _MEMORY_TEXT_GROUP = "headroom.memory_text"
# safely serve every per-project ``LocalBackend`` created by the
# BackendRouter. Without this cache, opening N project DBs would load
# the sentence-transformers / ONNX model N times.
_EMBEDDER_CACHE: dict[tuple[str, str], Embedder] = {}
_EMBEDDER_CACHE: dict[tuple[str, str, str], Embedder] = {}
_EMBEDDER_CACHE_LOCK = threading.Lock()
@ -173,6 +173,14 @@ def _create_embedder(config: MemoryConfig) -> Embedder:
if hasattr(config.embedder_backend, "value")
else str(config.embedder_backend),
config.embedder_model or "",
# The Ollama backend is built with ``base_url=config.ollama_base_url``,
# so two configs that share a backend and model but point at different
# Ollama servers must NOT share a cached embedder — otherwise the second
# caller silently gets an embedder bound to the first server. (The
# ``openai_api_key`` omission is handled by the up-front validation
# above; ``ollama_base_url`` has no such guard and would just resolve to
# the wrong host.)
config.ollama_base_url or "",
)
with _EMBEDDER_CACHE_LOCK:

View file

@ -25,8 +25,11 @@ MODEL_ALIASES: dict[str, str] = {
# Claude 3.5 Sonnet retired Feb 2026, pricing same as claude-sonnet-4-20250514
"claude-3-5-sonnet-20241022": "claude-sonnet-4-20250514",
"claude-3-5-sonnet-20240620": "claude-sonnet-4-20250514",
# Claude 3 Sonnet retired
"claude-3-sonnet-20240229": "claude-3-haiku-20240307",
# Claude 3 Sonnet retired. It was a Sonnet-tier model ($3/$15 per 1M
# in/out) — same price as claude-sonnet-4-20250514 — so alias it there.
# The old target, claude-3-haiku-20240307 ($0.25/$1.25), is a different
# (Haiku) tier and underpriced every cost/savings figure ~12x.
"claude-3-sonnet-20240229": "claude-sonnet-4-20250514",
}

View file

@ -3,19 +3,35 @@
from .runtime import (
DEFAULT_API_URL,
REMOTE_CONTROL_BASE_URL_ENV,
REMOTE_CONTROL_GATED_MIN_VERSION,
REMOTE_CONTROL_NON_SUBSCRIPTION_ENV,
REMOTE_CONTROL_SIBLING_GATE_NOTE,
TOOL_SEARCH_DEFAULT,
TOOL_SEARCH_ENV,
detect_claude_code_version,
is_custom_anthropic_base_url,
parse_claude_code_version,
proxy_base_url,
remote_control_applies_to_auth,
remote_control_gate_active,
remote_control_gate_message,
remote_control_sibling_gate_note,
)
__all__ = [
"DEFAULT_API_URL",
"REMOTE_CONTROL_BASE_URL_ENV",
"REMOTE_CONTROL_GATED_MIN_VERSION",
"REMOTE_CONTROL_NON_SUBSCRIPTION_ENV",
"REMOTE_CONTROL_SIBLING_GATE_NOTE",
"TOOL_SEARCH_DEFAULT",
"TOOL_SEARCH_ENV",
"detect_claude_code_version",
"is_custom_anthropic_base_url",
"remote_control_gate_message",
"parse_claude_code_version",
"proxy_base_url",
"remote_control_applies_to_auth",
"remote_control_gate_active",
"remote_control_gate_message",
"remote_control_sibling_gate_note",
]

View file

@ -2,6 +2,8 @@
from __future__ import annotations
import re
from collections.abc import Mapping
from urllib.parse import urlparse
DEFAULT_API_URL = "https://api.anthropic.com"
@ -15,28 +17,241 @@ TOOL_SEARCH_ENV = "ENABLE_TOOL_SEARCH"
TOOL_SEARCH_DEFAULT = "true"
REMOTE_CONTROL_BASE_URL_ENV = "ANTHROPIC_BASE_URL"
REMOTE_CONTROL_FEATURE = "Remote Control"
REMOTE_CONTROL_DISABLED_MESSAGE = (
f"{REMOTE_CONTROL_FEATURE}: "
"Claude Code may hide the Remote Control menu while "
f"{REMOTE_CONTROL_BASE_URL_ENV} points at a custom endpoint "
"({source}); "
"launch Claude without Headroom for sessions that need this feature."
# GH #1779: Claude Code v2.1.196 added a client-side eligibility check that
# DISABLES first-party Remote Control (`/remote-control` / `/rc`, which mirrors a
# local CLI session to claude.ai/code and the mobile apps) whenever
# ANTHROPIC_BASE_URL points at a non-`api.anthropic.com` host. Headroom routes
# through http://127.0.0.1:<port>, so on this version and newer the disable is
# DETERMINISTIC (not "may") — the `/rc` command simply vanishes. The gate is
# upstream in the Claude Code binary and RC's control-plane talks to claude.ai,
# not the API host, so Headroom cannot force it back on; the honest fix is an
# accurate warning at launch/doctor time. This is the same base-URL gating
# family as #746 (on-demand tool loading) and #1158 (1M context window), both of
# which Headroom *can* restore (see the sibling-gate note below).
REMOTE_CONTROL_GATED_MIN_VERSION = (2, 1, 196)
# Auth-mode signals that mean Remote Control was NEVER available for this
# session, so its gate warning must not fire (issue #1779). RC mirrors a local
# CLI session to a claude.ai account — a Claude Pro/Max *subscription* feature.
# API-key (PAYG) callers and cloud IAM/ADC callers (Bedrock / Vertex / Foundry)
# have no claude.ai session to mirror and never saw the `/rc` command. Presence
# of any of these (non-empty) in the effective environment means "not a
# subscription session — stay silent."
REMOTE_CONTROL_NON_SUBSCRIPTION_ENV = (
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"CLAUDE_CODE_USE_BEDROCK",
"CLAUDE_CODE_USE_VERTEX",
"CLAUDE_CODE_USE_FOUNDRY",
)
# Co-reported alongside the RC gate so the user sees the whole base-URL gating
# family in one place (issue #1779). Unlike RC, Headroom *does* restore these two
# siblings — #746 by default, #1158 on request — which is the point of showing
# them together: RC is the one member of the family Headroom cannot fix.
# This constant describes DEFAULT behaviour and is the right form for `doctor`,
# which cannot see the wrap launch flags. `wrap` knows its flags and must use
# :func:`remote_control_sibling_gate_note` instead, so the note never claims
# tool deferral is on for a session where the user turned it off, nor tells a
# user to pass `--1m` they already passed.
REMOTE_CONTROL_SIBLING_GATE_NOTE = (
"Same base-URL gate also affects on-demand tool loading "
"(#746 — `headroom wrap claude` keeps it on by default) and the 1M context "
"window (#1158 — opt in with `headroom wrap claude --1m`)."
)
def remote_control_gate_message(source: str) -> str:
"""Return the shared Remote Control compatibility message for Claude warning paths."""
def remote_control_sibling_gate_note(*, tool_search_active: bool, context_1m_enabled: bool) -> str:
"""Session-accurate sibling-gate co-report for the wrap launch banner.
Unlike the flag-blind :data:`REMOTE_CONTROL_SIBLING_GATE_NOTE`, this
reflects what THIS session actually does (issue #1779 accuracy rule: never
show the user a claim the session contradicts):
* ``tool_search_active`` whether the resolved ``ENABLE_TOOL_SEARCH`` mode
keeps deferral on (#746). ``False`` when the user chose a falsy mode.
* ``context_1m_enabled`` whether ``--1m`` was passed (#1158); if so, don't
advise adding a flag that is already in effect.
"""
tool_part = (
"#746 — Headroom keeps it on for this session"
if tool_search_active
else "#746 — OFF for this session per your --tool-search/ENABLE_TOOL_SEARCH setting"
)
context_part = (
"#1158 — already restored via --1m"
if context_1m_enabled
else "#1158 — restore with `headroom wrap claude --1m`"
)
return (
"Same base-URL gate also affects on-demand tool loading "
f"({tool_part}) and the 1M context window ({context_part})."
)
_CLAUDE_VERSION_RE = re.compile(r"(\d+)\.(\d+)\.(\d+)")
def _version_str(version: tuple[int, int, int]) -> str:
return ".".join(str(part) for part in version)
def remote_control_gate_message(source: str, *, version: tuple[int, int, int] | None = None) -> str:
"""Return the Remote Control gate message for Claude warning paths.
Accuracy matters here (issue #1779): on Claude Code
:data:`REMOTE_CONTROL_GATED_MIN_VERSION` and newer the disable is
deterministic, so the wording states it as fact never "may".
* ``version`` known and gated name the exact version and state the
deterministic disable.
* ``version`` unknown (``None``) state the version threshold and let the
user self-identify, without falsely asserting their build.
Callers gate on :func:`remote_control_gate_active` first, so a version known
to be *older* than the threshold never reaches this function.
"""
source_clean = source.strip() or "this endpoint"
return REMOTE_CONTROL_DISABLED_MESSAGE.format(source=source_clean)
min_ver = _version_str(REMOTE_CONTROL_GATED_MIN_VERSION)
if version is not None and version >= REMOTE_CONTROL_GATED_MIN_VERSION:
lead = (
f"Claude Code {_version_str(version)} disables the "
"/remote-control (/rc) command while "
f"{REMOTE_CONTROL_BASE_URL_ENV} points at a custom endpoint "
f"({source_clean})."
)
else:
lead = (
f"Claude Code {min_ver}+ disables the /remote-control (/rc) command "
f"while {REMOTE_CONTROL_BASE_URL_ENV} points at a custom endpoint "
f"({source_clean}); if your Claude Code is {min_ver} or newer, /rc "
"is unavailable in this session."
)
return (
f"{REMOTE_CONTROL_FEATURE}: {lead} "
"Headroom cannot override this client-side gate — run Claude without "
"Headroom for sessions that need Remote Control."
)
def is_custom_anthropic_base_url(value: str | None) -> bool:
"""Return whether ANTHROPIC_BASE_URL is custom from Claude's Remote Control gate view."""
"""Return whether ANTHROPIC_BASE_URL is custom from Claude's Remote Control gate view.
Host-equality only (issue #1779): the scheme, port, path, and trailing
slash are ignored, and matching is exact a lookalike such as
``api.anthropic.com.evil.com`` is custom. Scheme-less values
(``myproxy.local:8080``, ``127.0.0.1:8787``) are re-parsed as a network
location; ``urlparse`` alone reads them as a path (or treats the host as a
URL *scheme*), yielding no hostname which silently classified every
scheme-less custom host as "not custom" and suppressed the warning.
"""
raw = (value or "").strip()
if not raw:
return False
host = (urlparse(raw).hostname or "").strip().lower()
return host not in {"", "api.anthropic.com"}
return _gate_view_host(raw) not in {"", "api.anthropic.com"}
def _gate_view_host(raw: str) -> str:
"""Best-effort host extraction for the Remote Control gate view.
``urlparse`` raises ``ValueError`` on bracket-malformed input (e.g. the
typo'd IPv6 literal ``http://[::1:8787``). These values are user-editable
(shell env / settings.json), and the doctor path must degrade to "no host"
rather than crash (issue #1779). Host-less results classify as not-custom;
the routing check separately flags unusable URLs, so nothing is hidden.
"""
try:
host = (urlparse(raw).hostname or "").strip().lower()
except ValueError:
return ""
if not host and "://" not in raw:
try:
host = (urlparse(f"//{raw}").hostname or "").strip().lower()
except ValueError:
return ""
return host
def remote_control_applies_to_auth(environ: Mapping[str, object]) -> bool:
"""Return whether this auth mode is one that ever had Remote Control.
``False`` for API-key (PAYG) and cloud IAM/ADC sessions they never saw the
``/rc`` command, so the gate warning must stay silent for them (issue
#1779). See :data:`REMOTE_CONTROL_NON_SUBSCRIPTION_ENV`.
"""
return not any(
str(environ.get(key) or "").strip() for key in REMOTE_CONTROL_NON_SUBSCRIPTION_ENV
)
def parse_claude_code_version(text: str | None) -> tuple[int, int, int] | None:
"""Parse a ``MAJOR.MINOR.PATCH`` version out of ``claude --version`` output.
``claude --version`` prints e.g. ``2.1.196 (Claude Code)``. Returns the first
dotted triple found, or ``None`` when nothing parses (unknown version).
"""
match = _CLAUDE_VERSION_RE.search(text or "")
if match is None:
return None
return (int(match.group(1)), int(match.group(2)), int(match.group(3)))
def detect_claude_code_version(claude_bin: str | None = None) -> tuple[int, int, int] | None:
"""Best-effort detection of the installed Claude Code version.
Runs ``claude --version`` and parses it. Returns ``None`` on any failure
(binary missing, non-zero exit, timeout, unparseable or absent output) so
callers fall back to the version-unknown wording rather than crash. Never
raises. Uses the shared ``headroom._subprocess`` wrapper, which forces
``encoding="utf-8"`` under ``text=True`` (the repo's Windows-cp1252 guard).
"""
import shutil
import subprocess
from headroom._subprocess import run
binary = claude_bin or shutil.which("claude")
if not binary:
return None
try:
proc = run([binary, "--version"], capture_output=True, text=True, timeout=10)
except (OSError, subprocess.SubprocessError):
return None
# getattr, not attribute access: a stubbed CompletedProcess (e.g. a test's
# SimpleNamespace) may lack stdout/stderr — degrade to "unknown", never raise.
stdout = getattr(proc, "stdout", "") or ""
stderr = getattr(proc, "stderr", "") or ""
return parse_claude_code_version(f"{stdout} {stderr}")
def remote_control_gate_active(
base_url: str | None,
environ: Mapping[str, object],
version: tuple[int, int, int] | None,
) -> bool:
"""Whether to surface the Remote Control gate warning for this session.
``True`` only when ALL hold (issue #1779):
* ``base_url`` is a custom (non-``api.anthropic.com``) endpoint the gate's
trigger,
* the auth mode is one that ever had Remote Control (not API-key / cloud
IAM) so PAYG users never see a warning for a feature they never had,
* the Claude Code version is at or above
:data:`REMOTE_CONTROL_GATED_MIN_VERSION`, **or** unknown (``None``).
Returns ``False`` when the version is known to be *older* than the gate on
those builds Remote Control is unaffected by a custom base URL, so warning
would be a false alarm.
"""
if not is_custom_anthropic_base_url(base_url):
return False
if not remote_control_applies_to_auth(environ):
return False
if version is not None and version < REMOTE_CONTROL_GATED_MIN_VERSION:
return False
return True
def proxy_base_url(port: int) -> str:

View file

@ -1,6 +1,7 @@
"""OpenClaw-specific provider helpers."""
from .wrap import (
OPENCLAW_NPM_PACKAGE,
build_plugin_entry,
build_unwrap_entry,
decode_entry_json,
@ -8,6 +9,7 @@ from .wrap import (
)
__all__ = [
"OPENCLAW_NPM_PACKAGE",
"build_plugin_entry",
"build_unwrap_entry",
"decode_entry_json",

View file

@ -5,6 +5,11 @@ from __future__ import annotations
import json
from typing import Any
# Published npm package name for the OpenClaw plugin. This MUST match the
# "name" field in plugins/openclaw/package.json and the release workflow's
# NPM_OPENCLAW_PACKAGE (.github/workflows/release.yml). Keep the three in sync.
OPENCLAW_NPM_PACKAGE = "headroom-openclaw"
DEFAULT_GATEWAY_PROVIDER_IDS = ["openai-codex"]

View file

@ -10,6 +10,7 @@ from __future__ import annotations
import importlib.util
import logging
import math
from collections import deque
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any
@ -139,10 +140,17 @@ def build_prefix_cache_stats(
read_mult: float = econ["read_multiplier"] # type: ignore[assignment]
write_mult: float = econ["write_multiplier"] # type: ignore[assignment]
# Get the base input price per token for the most-used model on this provider
# Get the base input price per token for the most-used model on this
# provider. Pick the provider-matching, priced model with the highest
# token volume — not the first one recorded. A Claude Code session sends
# both Sonnet (main loop) and Haiku (titles/subagents); breaking on the
# first-inserted model would price all cache savings at whichever happened
# to be seen first (e.g. Haiku's $0.80/M vs Sonnet's $3/M), skewing the
# dashboard's savings figure ~3.75x.
input_price_per_token = None
if cost_tracker:
for model_name in cost_tracker._tokens_sent_by_model:
best_tokens = -1
for model_name, tokens_sent in cost_tracker._tokens_sent_by_model.items():
# Match model to provider
_openai_prefixes = ("gpt", "o1", "o3", "o4")
is_match = (
@ -151,11 +159,11 @@ def build_prefix_cache_stats(
or (provider == "gemini" and "gemini" in model_name)
or (provider == "bedrock" and "claude" in model_name)
)
if is_match:
if is_match and tokens_sent > best_tokens:
price_per_1m = cost_tracker._get_list_price(model_name)
if price_per_1m:
input_price_per_token = price_per_1m / 1_000_000
break
best_tokens = tokens_sent
# Calculate savings:
# Cache reads save (1.0 - read_mult) per token vs uncached input price.
@ -465,23 +473,50 @@ def build_session_summary(
"too_small": 0,
"passthrough": 0,
"no_compressible_content": 0,
"unknown_token_accounting": 0,
}
def _entry_has_number(entry: Any, attr: str) -> bool:
value = getattr(entry, attr, None)
return (
isinstance(value, (int, float))
and not isinstance(value, bool)
and math.isfinite(float(value))
)
def _entry_number(entry: Any, attr: str) -> int | float:
value = getattr(entry, attr, 0)
return value if _entry_has_number(entry, attr) else 0
if proxy.logger:
for entry in proxy.logger._logs:
if entry.model and "count_tokens" in entry.model:
uncompressed_reasons["passthrough"] += 1
continue
if entry.tokens_saved > 0:
tokens_saved = _entry_number(entry, "tokens_saved")
input_tokens_original = _entry_number(entry, "input_tokens_original")
input_tokens_optimized = _entry_number(entry, "input_tokens_optimized")
has_complete_token_accounting = all(
_entry_has_number(entry, attr)
for attr in (
"input_tokens_original",
"input_tokens_optimized",
"tokens_saved",
"savings_percent",
)
)
if tokens_saved > 0 and has_complete_token_accounting:
compressed_requests.append(
{
"savings_pct": round(entry.savings_percent, 1),
"tokens_saved": entry.tokens_saved,
"original": entry.input_tokens_original,
"optimized": entry.input_tokens_optimized,
"savings_pct": round(_entry_number(entry, "savings_percent"), 1),
"tokens_saved": tokens_saved,
"original": input_tokens_original,
"optimized": input_tokens_optimized,
}
)
elif entry.input_tokens_original > 0:
elif not has_complete_token_accounting:
uncompressed_reasons["unknown_token_accounting"] += 1
elif input_tokens_original > 0:
# Categorize why it wasn't compressed
transforms = entry.transforms_applied or []
if not transforms:
@ -489,7 +524,7 @@ def build_session_summary(
uncompressed_reasons["prefix_frozen"] += 1
elif all("excluded" in t or "protected" in t for t in transforms):
uncompressed_reasons["no_compressible_content"] += 1
elif entry.input_tokens_original < 500:
elif input_tokens_original < 500:
uncompressed_reasons["too_small"] += 1
else:
uncompressed_reasons["prefix_frozen"] += 1

View file

@ -3190,8 +3190,19 @@ class AnthropicHandlerMixin:
if _compression_failed:
response_headers["x-headroom-compression-failed"] = "true"
# Enterprise Security: scan response + de-anonymize
if self.security and _security_ctx and resp_json:
# Enterprise Security: scan response + de-anonymize.
# Gate on a 200 upstream like the sibling CCR/cache/buffered
# blocks below: without this, a non-2xx upstream (rate limit
# 429, overloaded 529, 5xx) whose JSON body is scanned was
# rebuilt as httpx.Response(status_code=200) and returned as
# HTTP 200, so the client's retry/backoff never triggered and
# an error looked like success.
if (
self.security
and _security_ctx
and resp_json
and response.status_code == 200
):
try:
resp_json = self.security.scan_response(resp_json, _security_ctx)
response = httpx.Response(

View file

@ -225,10 +225,17 @@ class BatchHandlerMixin:
optimized_messages
)
# Restore preserved content entries that had non-text parts
for orig_idx, original_content in preserved_contents.items():
if orig_idx < len(optimized_contents):
optimized_contents[orig_idx] = original_content
# Restore preserved (non-text) entries at their ORIGINAL positions.
# preserved_indices are indices into the original contents[], but
# optimized_contents lives in a shorter index space (text-less
# entries produced no message), so indexing it by orig_idx
# overwrites the wrong entry and drops any preserved entry whose
# original index is >= len(optimized_contents). Use the shared
# interleaving helper the non-batch Gemini handlers already use
# (#836).
optimized_contents = self._rebuild_gemini_contents(
contents, preserved_indices, preserved_contents, optimized_contents
)
# Create compressed batch request
compressed_req_content = {**req_content, "contents": optimized_contents}

View file

@ -58,6 +58,8 @@ class GeminiHandlerMixin:
- fileData: File references (URI + MIME type)
- functionCall: Function calls from model
- functionResponse: Responses to function calls
- executableCode / codeExecutionResult: Gemini code-execution parts,
echoed back in contents[] on later turns
Args:
content: A single Gemini content entry with 'parts' list.
@ -69,7 +71,14 @@ class GeminiHandlerMixin:
for part in parts:
if any(
key in part
for key in ("inlineData", "fileData", "functionCall", "functionResponse")
for key in (
"inlineData",
"fileData",
"functionCall",
"functionResponse",
"executableCode",
"codeExecutionResult",
)
):
return True
return False

View file

@ -603,27 +603,17 @@ def _compact_openai_responses_tools(
return updated, True, before, after
def _ensure_responses_store_for_memory_tools(
payload: dict[str, Any],
*,
memory_tools_injected: bool,
) -> bool:
"""Keep Responses API memory-tool continuations addressable.
def _responses_request_allows_memory_tool_continuation(payload: dict[str, Any]) -> bool:
"""Return whether Responses memory tools may rely on stored continuations.
Memory tools are transparent to clients: Headroom executes the emitted
function_call, then sends function_call_output in a continuation request
using previous_response_id. OpenAI only allows that continuation when the
previous response was stored. Clients such as pi/Codex can set store=false
to avoid retaining ordinary responses, but that makes memory-tool
continuations fail with previous_response_not_found.
Return True when this function changes the payload.
Headroom memory tools use ``previous_response_id`` continuations after a
tool call. Those continuations require the originating response to be
stored. When a client explicitly sends ``store=false``, preserve that
contract and skip the Responses memory-tool injection path instead of
mutating the request.
"""
if memory_tools_injected and payload.get("store") is False:
payload["store"] = True
return True
return False
return payload.get("store") is not False
def _responses_input_item_text_bytes(item: Any) -> int:
@ -633,6 +623,15 @@ def _responses_input_item_text_bytes(item: Any) -> int:
output = item.get("output")
if isinstance(output, str):
return len(output.encode("utf-8", errors="replace"))
if isinstance(output, list):
total = 0
for part in output:
if isinstance(part, str):
total += len(part.encode("utf-8", errors="replace"))
elif isinstance(part, dict) and isinstance(part.get("text"), str):
total += len(part["text"].encode("utf-8", errors="replace"))
if total > 0:
return total
content = item.get("content")
if isinstance(content, str):
@ -1296,9 +1295,9 @@ class OpenAIHandlerMixin:
# remain as defense-in-depth.
type_tag = item.get("type")
if type_tag in self.OPENAI_RESPONSES_OUTPUT_TYPES:
output = item.get("output")
if isinstance(output, str):
return output, ("output", None)
output_text = _responses_part_text(item.get("output"))
if output_text:
return output_text, ("output", None)
return None
def _set_slot_text(
@ -1396,12 +1395,8 @@ class OpenAIHandlerMixin:
# Protected from lossy compression — but grep/log/json output
# can still be losslessly compacted. Reuse the router helper
# so the Responses path matches the chat/Anthropic behavior.
excl_out = item.get("output")
fold = (
router._lossless_compact_excluded(excl_out)
if isinstance(excl_out, str)
else None
)
excl_out = _responses_part_text(item.get("output"))
fold = router._lossless_compact_excluded(excl_out) if excl_out else None
if fold is not None:
lossless_excluded.append((idx, ("output", None), fold[0], excl_out))
if debug_enabled:
@ -1511,6 +1506,24 @@ class OpenAIHandlerMixin:
unit_build_started = time.perf_counter()
unit_debug: list[dict[str, Any]] = []
# Aggregate-then-floor: the Responses payload splits each tool output
# into its own unit, so a per-item size floor would reject every unit
# in a session made of many small tool outputs (e.g. Codex), yielding
# 0% savings even when the combined compressible text is large. The
# Anthropic path compresses the whole message list as one batch and is
# not subject to a per-item floor. Match that: evaluate the floor once
# against the *aggregate* compressible bytes of the extracted group. If
# the group as a whole clears the threshold, disable the per-unit floor
# so small units still reach the router; if the whole group is below
# the threshold, keep the floor so trivially small payloads are skipped.
aggregate_compressible_bytes = sum(
len(text.encode("utf-8", errors="replace")) for _, _, text in candidates
)
effective_unit_min_bytes = (
0
if aggregate_compressible_bytes >= self.OPENAI_RESPONSES_ROUTER_MIN_BYTES
else self.OPENAI_RESPONSES_ROUTER_MIN_BYTES
)
for item_idx, slot_ref, original_text in candidates:
item = items[item_idx] if item_idx < len(items) else {}
item_type = item.get("type", "unknown") if isinstance(item, dict) else "unknown"
@ -1523,7 +1536,7 @@ class OpenAIHandlerMixin:
item_type=str(item_type),
cache_zone="live",
mutable=True,
min_bytes=self.OPENAI_RESPONSES_ROUTER_MIN_BYTES,
min_bytes=effective_unit_min_bytes,
)
routed_units.append(RoutedCompressionUnit(unit=unit, slot=(item_idx, slot_ref)))
if debug_enabled:
@ -4061,28 +4074,27 @@ class OpenAIHandlerMixin:
else:
memory_tool_defs_responses.append(t)
resp_tools = body.get("tools") or []
resp_tools, mem_tools_injected = _apply_sticky_mem_tools_resp(
provider="openai",
session_id=_responses_session_id,
request_id=request_id,
existing_tools=resp_tools,
memory_tools_to_inject=memory_tool_defs_responses,
inject_this_turn=bool(self.memory_handler.config.inject_tools),
)
if mem_tools_injected:
body["tools"] = resp_tools
body_mutation_tracker.mark_mutated("responses_memory_tools")
logger.info(f"[{request_id}] Memory: Injected memory tools (openai/responses)")
if _ensure_responses_store_for_memory_tools(
body,
memory_tools_injected=True,
):
body_mutation_tracker.mark_mutated("responses_memory_store")
if _responses_request_allows_memory_tool_continuation(body):
resp_tools = body.get("tools") or []
resp_tools, mem_tools_injected = _apply_sticky_mem_tools_resp(
provider="openai",
session_id=_responses_session_id,
request_id=request_id,
existing_tools=resp_tools,
memory_tools_to_inject=memory_tool_defs_responses,
inject_this_turn=bool(self.memory_handler.config.inject_tools),
)
if mem_tools_injected:
body["tools"] = resp_tools
body_mutation_tracker.mark_mutated("responses_memory_tools")
logger.info(
f"[{request_id}] Memory: forced store=true for Responses memory tool continuation"
f"[{request_id}] Memory: Injected memory tools (openai/responses)"
)
elif self.memory_handler.config.inject_tools:
logger.info(
"[%s] Memory: skipped Responses memory tools because client set store=false",
request_id,
)
except Exception as e:
logger.warning(f"[{request_id}] Memory injection failed (responses): {e}")
elif self.memory_handler and memory_user_id and _bypass:
@ -5337,39 +5349,37 @@ class OpenAIHandlerMixin:
),
)
# --- Memory: inject context, tools, and instructions ---
# Gated on MemoryDecision — uniform bypass-respect across
# all five sites. WS sets memory_user_id only on the inject
# path (matches pre-PR behaviour); MemoryDecision is the
# canonical gate.
memory_user_id: str | None = None
memory_request_ctx = None
if self.memory_handler and body:
_ws_memory_user_id_candidate = ws_headers.get(
"x-headroom-user-id",
os.environ.get("USER", os.environ.get("USERNAME", "default")),
)
else:
_ws_memory_user_id_candidate = None
from headroom.proxy.helpers import get_memory_injection_mode
from headroom.proxy.memory_decision import MemoryDecision
from headroom.proxy.memory_query import MemoryQuery
ws_memory_decision = MemoryDecision.decide(
headers=ws_headers,
memory_handler=self.memory_handler if body else None,
memory_user_id=_ws_memory_user_id_candidate,
mode_name=get_memory_injection_mode(),
)
# ws_tags was extracted at handler entry (L3028); applying
# the memory skip reason here so per-turn RequestOutcomes
# carry it for dashboard slicing.
ws_memory_decision.apply_to_tags(ws_tags)
if ws_memory_decision.inject:
memory_user_id = _ws_memory_user_id_candidate
async def _prepare_memory_frame(frame_body: dict[str, Any], frame_raw: str) -> str:
nonlocal memory_user_id, memory_request_ctx
memory_user_id_candidate = (
ws_headers.get(
"x-headroom-user-id",
os.environ.get("USER", os.environ.get("USERNAME", "default")),
)
if self.memory_handler
else None
)
memory_decision = MemoryDecision.decide(
headers=ws_headers,
memory_handler=self.memory_handler,
memory_user_id=memory_user_id_candidate,
mode_name=get_memory_injection_mode(),
)
memory_decision.apply_to_tags(ws_tags)
if not memory_decision.inject:
return frame_raw
memory_user_id = memory_user_id_candidate
try:
# Unwrap response.create envelope to access the response body
ws_response_body = body.get("response", body)
ws_response_body = frame_body.get("response", frame_body)
# Per-project memory routing (GH #462). For WS,
# ``ws_response_body`` carries ``instructions`` —
@ -5531,19 +5541,20 @@ class OpenAIHandlerMixin:
)
# Write back into envelope if it was wrapped
if "response" in body and isinstance(body["response"], dict):
body["response"] = ws_response_body
if "response" in frame_body and isinstance(frame_body["response"], dict):
frame_body["response"] = ws_response_body
else:
body = ws_response_body
frame_body = ws_response_body
first_msg_raw = json.dumps(body)
return json.dumps(frame_body)
except Exception as e:
logger.warning(f"[{request_id}] WS Memory injection failed: {e}")
elif self.memory_handler and body and _ws_bypass:
logger.info(
"[%s] WS memory passthrough reason=bypass_header",
request_id,
)
return frame_raw
if isinstance(body, dict) and (
body.get("type") == "response.create" or ("type" not in body and "input" in body)
):
first_msg_raw = await _prepare_memory_frame(body, first_msg_raw)
# Hot-fix follow-up to PR #406 — inline Rust compression on the
# WS first frame before forwarding upstream. PR #406 enabled
@ -6134,6 +6145,7 @@ class OpenAIHandlerMixin:
and _inbound_frame_body.get("type") == "response.create"
):
ws_response_create_frames += 1
msg = await _prepare_memory_frame(_inbound_frame_body, msg)
(
msg,
_frame_modified,

View file

@ -2379,6 +2379,61 @@ async def _read_request_body_bytes(request: Request) -> bytes:
return cast(bytes, raw)
# ---------------------------------------------------------------------------
# Output-only content blocks
# ---------------------------------------------------------------------------
# The Anthropic *response* schema can emit signaling blocks that the *request*
# schema (messages[].content[]) does not accept. The primary case is the
# server-side refusal fallback notification introduced with the
# ``server-side-fallback-2026-06-01`` beta::
#
# {"type": "fallback",
# "from": {"model": "claude-fable-5"},
# "to": {"model": "claude-opus-4-8"}}
#
# The API returns it inside an assistant turn to signal that a refused request
# was transparently re-served by the fallback model. When a client replays that
# assistant turn on the next call, the request validator rejects it::
#
# 400 invalid_request_error: messages.N.content.0: Input tag 'fallback'
# found using 'type' does not match any of the expected tags
#
# These blocks are output-only and carry no state the model needs on input, so
# they are safe to drop before forwarding.
OUTPUT_ONLY_REQUEST_BLOCK_TYPES: frozenset[str] = frozenset({"fallback"})
def strip_output_only_request_blocks(messages: Any) -> bool:
"""Remove output-only content blocks from request ``messages`` in place.
Returns ``True`` if any block was removed. If stripping empties a message's
``content`` list it is backfilled with a single benign text block, because
the API also rejects an empty ``content`` array. Idempotent.
"""
if not isinstance(messages, list):
return False
changed = False
for msg in messages:
if not isinstance(msg, dict):
continue
content = msg.get("content")
if not isinstance(content, list):
continue
kept = [
block
for block in content
if not (
isinstance(block, dict) and block.get("type") in OUTPUT_ONLY_REQUEST_BLOCK_TYPES
)
]
if len(kept) != len(content):
changed = True
if not kept:
kept = [{"type": "text", "text": "(model fallback)"}]
msg["content"] = kept
return changed
async def _read_request_json(request: Request) -> dict[str, Any]:
"""Read and parse JSON from a request, handling compressed bodies.
@ -2401,6 +2456,17 @@ async def _read_request_json(request: Request) -> dict[str, Any]:
result = json.loads(text)
if not isinstance(result, dict):
raise ValueError("Request body must be a JSON object, not " + type(result).__name__)
# Drop output-only blocks the request schema rejects (see
# ``strip_output_only_request_blocks``). Callers of this bytes-less reader
# (e.g. the Gemini path) re-serialize ``result`` themselves.
if strip_output_only_request_blocks(result.get("messages")):
logger.warning(
"removed output-only content block(s) (%s) from request messages "
"before forwarding (not valid on the request path)",
",".join(sorted(OUTPUT_ONLY_REQUEST_BLOCK_TYPES)),
)
return result
@ -2422,6 +2488,21 @@ async def read_request_json_with_bytes(request: Request) -> tuple[dict[str, Any]
result = json.loads(text)
if not isinstance(result, dict):
raise ValueError("Request body must be a JSON object, not " + type(result).__name__)
# Drop output-only blocks (see ``strip_output_only_request_blocks``) before
# any downstream deepcopy / compression / 400-retry path. This is the shared
# reader for the Anthropic, OpenAI, and Bedrock handlers, so one guard here
# covers every client that routes through the proxy. When a block is removed
# we re-encode ``raw`` so a byte-faithful passthrough forwarder cannot leak
# the pre-strip body; unchanged requests keep their exact original bytes.
if strip_output_only_request_blocks(result.get("messages")):
raw = json.dumps(result, ensure_ascii=False).encode("utf-8")
logger.warning(
"removed output-only content block(s) (%s) from request messages "
"before forwarding (not valid on the request path)",
",".join(sorted(OUTPUT_ONLY_REQUEST_BLOCK_TYPES)),
)
return result, raw

View file

@ -59,6 +59,18 @@ def extract_memory_query_sources(
tool_outputs=tool_outputs,
lookback_tools=lookback_tools,
)
if not latest_user:
# Anthropic user turns carry the actual prompt as text blocks
# ({"type":"text","text":...}), not a plain string. Capture it
# so the memory retrieval query keys on the user's question and
# not just any tool_result blocks in the same turn.
user_text = "\n".join(
b.get("text", "")
for b in content
if isinstance(b, dict) and b.get("type") == "text"
).strip()
if user_text:
latest_user = user_text
elif isinstance(content, str) and not latest_user:
latest_user = content

View file

@ -209,7 +209,11 @@ def _estimate_compression_savings_usd(model: str, tokens_saved: int) -> float:
resolved = _resolve_litellm_model(model)
info = litellm.model_cost.get(resolved, {})
input_cost_per_token = info.get("input_cost_per_token")
if not input_cost_per_token:
# Distinguish "price unknown" (missing key → fall back) from a model that
# is legitimately free (input_cost_per_token == 0.0). `if not ...` treated
# a real 0.0 as unavailable and billed the $3/M fallback — phantom savings
# for a model that costs nothing.
if input_cost_per_token is None:
raise RuntimeError("input cost unavailable")
return float(tokens_saved) * float(input_cost_per_token)
except Exception:
@ -290,7 +294,9 @@ def _estimate_input_cost_usd(
resolved = _resolve_litellm_model(model)
info = litellm.model_cost.get(resolved, {})
input_cost_per_token = info.get("input_cost_per_token")
if not input_cost_per_token:
# A missing key means the model is unknown → fall back to a blended rate.
# A present 0.0 means the model is free and must cost $0, not the fallback.
if input_cost_per_token is None:
raise RuntimeError("input cost unavailable")
if use_breakdown:
@ -372,6 +378,16 @@ def _empty_display_session() -> dict[str, Any]:
}
def _empty_by_model_entry() -> dict[str, Any]:
return {
"requests": 0,
"tokens_saved": 0,
"compression_savings_usd": 0.0,
"total_input_tokens": 0,
"total_input_cost_usd": 0.0,
}
def _empty_project_entry() -> dict[str, Any]:
return {
"requests": 0,
@ -416,6 +432,28 @@ def _normalize_projects(raw: Any) -> dict[str, dict[str, Any]]:
return projects
def _normalize_by_model(raw: Any) -> dict[str, dict[str, Any]]:
if not isinstance(raw, dict):
return {}
result: dict[str, dict[str, Any]] = {}
for model_name, entry in raw.items():
model = _normalize_model(model_name)
if not isinstance(entry, dict):
continue
normalized = _empty_by_model_entry()
normalized["requests"] = _coerce_int(entry.get("requests"))
normalized["tokens_saved"] = _coerce_int(entry.get("tokens_saved"))
normalized["compression_savings_usd"] = round(
_coerce_float(entry.get("compression_savings_usd")), 6
)
normalized["total_input_tokens"] = _coerce_int(entry.get("total_input_tokens"))
normalized["total_input_cost_usd"] = round(
_coerce_float(entry.get("total_input_cost_usd")), 6
)
result[model] = normalized
return result
def _normalize_display_session(entry: Any) -> dict[str, Any]:
if not isinstance(entry, dict):
return _empty_display_session()
@ -553,6 +591,12 @@ class SavingsTracker:
6,
)
self._record_by_model_locked(
model,
tokens_saved_delta=delta_tokens,
savings_usd_delta=delta_usd,
)
self._state["history"].append(
{
"timestamp": _to_utc_iso(timestamp_dt),
@ -687,6 +731,15 @@ class SavingsTracker:
if session.get("started_at") is None:
session["started_at"] = session["last_activity_at"]
self._record_by_model_locked(
model,
requests_delta=1,
tokens_saved_delta=delta_tokens_saved,
savings_usd_delta=delta_savings_usd,
input_tokens_delta=delta_input_tokens,
input_cost_usd_delta=delta_input_cost_usd,
)
self._record_project_locked(
project,
timestamp_dt=timestamp_dt,
@ -756,6 +809,34 @@ class SavingsTracker:
)
del projects[evict]
def _record_by_model_locked(
self,
model: str,
*,
requests_delta: int = 0,
tokens_saved_delta: int = 0,
savings_usd_delta: float = 0.0,
input_tokens_delta: int = 0,
input_cost_usd_delta: float = 0.0,
) -> None:
"""Accumulate per-model savings. Caller must hold ``self._lock``.
Lazy-inits ``by_model`` so existing state files without the key work
without migration.
"""
by_model: dict[str, dict[str, Any]] = self._state.setdefault("by_model", {})
key = _normalize_model(model)
entry = by_model.setdefault(key, _empty_by_model_entry())
entry["requests"] += max(requests_delta, 0)
entry["tokens_saved"] += max(tokens_saved_delta, 0)
entry["compression_savings_usd"] = round(
entry["compression_savings_usd"] + max(savings_usd_delta, 0.0), 6
)
entry["total_input_tokens"] += max(input_tokens_delta, 0)
entry["total_input_cost_usd"] = round(
entry["total_input_cost_usd"] + max(input_cost_usd_delta, 0.0), 6
)
def _projects_snapshot_locked(self) -> dict[str, dict[str, Any]]:
"""Per-project stats with a derived ``savings_percent``, sorted by savings."""
projects = self._state.get("projects", {})
@ -775,6 +856,25 @@ class SavingsTracker:
result[name] = view
return result
def _by_model_snapshot_locked(self) -> dict[str, dict[str, Any]]:
"""Per-model stats ranked by savings."""
by_model = self._state.get("by_model", {})
ranked = sorted(
by_model.items(),
key=lambda item: item[1]["tokens_saved"],
reverse=True,
)
result: dict[str, dict[str, Any]] = {}
for model, entry in ranked:
view = dict(entry)
total_before = entry["tokens_saved"] + entry["total_input_tokens"]
view["savings_percent"] = round(
(entry["tokens_saved"] / total_before * 100) if total_before > 0 else 0.0,
2,
)
result[model] = view
return result
def stats_preview(self, recent_points: int = 20) -> dict[str, Any]:
"""Return a compact preview for `/stats`."""
snapshot = self.snapshot()
@ -789,6 +889,7 @@ class SavingsTracker:
"retention": snapshot["retention"],
"projects": snapshot["projects"],
"projects_limit": DEFAULT_MAX_PROJECTS,
"by_model": snapshot["by_model"],
}
def history_response(self, history_mode: str = "compact") -> dict[str, Any]:
@ -818,6 +919,7 @@ class SavingsTracker:
},
"retention": snapshot["retention"],
"projects": snapshot["projects"],
"by_model": snapshot["by_model"],
"history_summary": {
"mode": history_mode,
"stored_points": len(raw_history),
@ -882,6 +984,7 @@ class SavingsTracker:
"max_response_history_points": self._max_response_history_points,
},
"projects": self._projects_snapshot_locked(),
"by_model": self._by_model_snapshot_locked(),
}
def _default_state(self) -> dict[str, Any]:
@ -899,6 +1002,7 @@ class SavingsTracker:
"display_session": _empty_display_session(),
"history": [],
"projects": {},
"by_model": {},
}
def _load_state(self) -> dict[str, Any]:
@ -978,6 +1082,7 @@ class SavingsTracker:
"display_session": _normalize_display_session(raw.get("display_session")),
"history": normalized_history,
"projects": _normalize_projects(raw.get("projects")),
"by_model": _normalize_by_model(raw.get("by_model")),
}
if normalized_history:
@ -1093,6 +1198,7 @@ class SavingsTracker:
"display_session": self._state["display_session"],
"history": self._state["history"],
"projects": self._state.get("projects", {}),
"by_model": self._state.get("by_model", {}),
}
json_data = json.dumps(payload, indent=2)

View file

@ -30,6 +30,7 @@ import contextlib
import hmac
import json
import logging
import math
import os
import sys
import threading
@ -761,6 +762,11 @@ class HeadroomProxy(
if is_token_mode(config.mode):
router_config.protect_recent_reads_fraction = 0.3
router_config.search_group_by_file = True
# Note: protect_tool_results runs AFTER token mode (ordering matters).
# It resets protect_recent_reads_fraction from 0.3→0.0, restoring
# full protection for ALL excluded-tool results regardless of age.
# This means naming any tool with --protect-tool-results also
# protects Read/Glob/Grep/Write/Edit results indefinitely.
if config.protect_tool_results:
router_config.protect_recent_reads_fraction = 0.0
# `--compress-user-messages` flips the router's default skip rule.
@ -2959,29 +2965,63 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI:
RECENT_REQUEST_LOG_WINDOW = 100
def _is_recent_request_number(value: Any) -> bool:
return (
isinstance(value, (int, float))
and not isinstance(value, bool)
and math.isfinite(float(value))
)
def _recent_request_optional_number(log: dict[str, Any], key: str) -> int | float | None:
value = log.get(key)
return value if _is_recent_request_number(value) else None
def _recent_request_token_accounting_status(log: dict[str, Any]) -> str:
token_fields = (
"input_tokens_original",
"input_tokens_optimized",
"tokens_saved",
"savings_percent",
)
present = [_is_recent_request_number(log.get(field)) for field in token_fields]
if all(present):
return "complete"
if any(present):
return "partial"
return "missing"
def _build_recent_request_payload(limit: int = RECENT_REQUEST_LOG_WINDOW) -> dict[str, Any]:
recent_request_logs = proxy.logger.get_recent(limit) if proxy.logger else []
dashboard_recent_requests = [
{
"request_id": log.get("request_id"),
"timestamp": log.get("timestamp"),
"provider": log.get("provider"),
"model": log.get("model"),
"input_tokens_original": log.get("input_tokens_original"),
"input_tokens_optimized": log.get("input_tokens_optimized"),
"output_tokens": log.get("output_tokens"),
"tokens_saved": log.get("tokens_saved"),
"savings_percent": log.get("savings_percent"),
"optimization_latency_ms": log.get("optimization_latency_ms"),
"total_latency_ms": log.get("total_latency_ms"),
"transforms_applied": log.get("transforms_applied", []),
"waste_signals": log.get("waste_signals"),
"tool_schema_saved_tokens": _tool_schema_saved_from_tags(log.get("tags")),
}
for log in recent_request_logs
if log.get("input_tokens_original") is not None
and log.get("input_tokens_optimized") is not None
][-10:]
dashboard_recent_requests = []
for log in recent_request_logs:
token_accounting_status = _recent_request_token_accounting_status(log)
dashboard_recent_requests.append(
{
"request_id": log.get("request_id"),
"timestamp": log.get("timestamp"),
"provider": log.get("provider"),
"model": log.get("model"),
"input_tokens_original": _recent_request_optional_number(
log, "input_tokens_original"
),
"input_tokens_optimized": _recent_request_optional_number(
log, "input_tokens_optimized"
),
"output_tokens": _recent_request_optional_number(log, "output_tokens"),
"tokens_saved": _recent_request_optional_number(log, "tokens_saved"),
"savings_percent": _recent_request_optional_number(log, "savings_percent"),
"optimization_latency_ms": _recent_request_optional_number(
log, "optimization_latency_ms"
),
"total_latency_ms": _recent_request_optional_number(log, "total_latency_ms"),
"has_exact_tokens": token_accounting_status == "complete",
"token_accounting_status": token_accounting_status,
"transforms_applied": log.get("transforms_applied", []),
"waste_signals": log.get("waste_signals"),
"tool_schema_saved_tokens": _tool_schema_saved_from_tags(log.get("tags")),
}
)
dashboard_recent_requests = dashboard_recent_requests[-10:]
return {
"request_logs": recent_request_logs[-10:],
"recent_requests": dashboard_recent_requests,

View file

@ -159,6 +159,13 @@ class BaseTokenizer(ABC):
content = part.get("content", "")
if isinstance(content, str):
total += self.count_text(content)
elif isinstance(content, list):
# Recurse into nested blocks (matching the Strands
# toolResult branch below). A tool that returns an image
# nests a base64 block here; serializing it would price
# the ~KB/MB base64 string as text — a 50-200x overcount
# (a screenshot reads as tens of thousands of tokens).
total += self._count_content_parts(content)
else:
total += self._count_serialized(content)
elif part_type == "tool_use":

View file

@ -100,9 +100,19 @@ class EstimatingTokenCounter(BaseTokenizer):
if not text:
return 0
# Use fixed ratio if provided
# Use fixed ratio if provided. Dense scripts (CJK/Kana/Hangul) still
# tokenize at ~1 token per character, so pricing them at the (Latin)
# fixed ratio under-counts by 2-4x — the same correction the auto path
# below applies. The registry builds every provider-calibrated counter
# (Anthropic 3.5, Google 4.0, Cohere 4.0, Moonshot 3.1) with a fixed
# ratio, so this is the live proxy count path for those providers.
if self._fixed_ratio is not None:
return max(1, int(len(text) / self._fixed_ratio + 0.5))
cjk_chars = self._count_cjk_chars(text)
other_chars = len(text) - cjk_chars
return max(
1,
int(other_chars / self._fixed_ratio + cjk_chars / self.CHARS_PER_TOKEN_CJK + 0.5),
)
# Auto-detect content type and adjust ratio
ratio = self._detect_ratio(text)

View file

@ -204,10 +204,15 @@ def get_tokenizer_name(model: str) -> str:
if model_lower in MODEL_TO_TOKENIZER:
return MODEL_TO_TOKENIZER[model_lower]
# Try prefix matching
for key, value in MODEL_TO_TOKENIZER.items():
# Try prefix matching, longest (most specific) key first. Scanning in
# dict-insertion order is wrong: a short family key like "qwen" precedes
# "qwen2"/"qwen2.5", so "qwen2-7b-instruct" would match "qwen" first and
# resolve to the Qwen1 tokenizer (a different vocabulary -> wrong counts).
# The sibling tiktoken resolver (get_encoding_for_model) documents and
# guards this exact order-dependent pitfall.
for key in sorted(MODEL_TO_TOKENIZER, key=len, reverse=True):
if model_lower.startswith(key):
return value
return MODEL_TO_TOKENIZER[key]
# Assume model name is the tokenizer name
return model

View file

@ -176,11 +176,18 @@ def get_encoding_for_model(model: str) -> str:
# o200k_base instead of cl100k_base for unknown gpt-4 snapshots.
for prefix, encoding in (
("gpt-4o", "o200k_base"),
# gpt-4.1 / gpt-4.5 use o200k_base and MUST precede the "gpt-4" prefix,
# which they would otherwise match and be mis-encoded as cl100k_base.
("gpt-4.1", "o200k_base"),
("gpt-4.5", "o200k_base"),
("gpt-4-turbo", "cl100k_base"),
("gpt-4", "cl100k_base"),
("gpt-3.5", "cl100k_base"),
("o1", "o200k_base"),
("o3", "o200k_base"),
# o4 reasoning models use o200k_base; without this they fell through to
# the cl100k_base default.
("o4", "o200k_base"),
):
if model.startswith(prefix):
return encoding
@ -282,7 +289,15 @@ class TiktokenCounter(BaseTokenizer):
else:
total += 170 # Base for high detail
else:
total += self.count_text(str(part))
# Any other block shape (Anthropic
# image/tool_result/tool_use, Strands blocks)
# is priced by the base handler, which uses a
# bounded per-image/document estimate. Stringifying
# it here would json-serialize a base64 blob and
# count it as text — a 1MB image becomes ~330K
# phantom tokens (the exact overcount base.py
# _count_content_parts exists to prevent).
total += self._count_content_parts([part])
elif isinstance(part, str):
total += self.count_text(part)
elif key == "role":

View file

@ -93,7 +93,7 @@ def _tool_call_args_text(raw: Any) -> str:
if isinstance(raw, str):
text = raw
elif isinstance(raw, dict):
text = " ".join(str(v) for v in raw.values() if isinstance(v, (str, int, float, bool)))
text = " ".join(str(v) for v in raw.values() if isinstance(v, str | int | float | bool))
else:
return ""
return " ".join(text.split())[:300]
@ -2704,35 +2704,17 @@ class ContentRouter(Transform):
# 1. ML text compressor: Kompress.
#
# Eager preload is cache-only (allow_download=False): on a cold cache we
# must NOT trigger a network download here, because this runs on the
# blocking startup/lifespan path before the proxy binds its port. A slow
# download stalls the bind, and a hard crash in the native download/ML
# stack (uncatchable SIGABRT) kills the interpreter before it ever
# listens — the proxy then "never opens its port" and the supervisor
# gives up. When the model isn't cached we defer to first use instead.
# Native model initialization stays out of the blocking startup/lifespan
# path. The existing lazy request path loads Kompress on first use.
if self.config.enable_kompress:
from .kompress_compressor import KompressModelNotCached
compressor = self._get_kompress()
if compressor:
if not hasattr(compressor, "preload"):
status["kompress"] = "enabled"
status["kompress_backend"] = "unknown"
else:
try:
backend = compressor.preload(allow_download=False)
except KompressModelNotCached:
logger.warning(
"Kompress model not cached; compression disabled "
"until model is downloaded. Ensure HuggingFace is "
"accessible or pre-download with headroom-ai[ml]."
)
status["kompress"] = "deferred"
else:
logger.info("Kompress model pre-loaded at startup backend=%s", backend)
status["kompress"] = "enabled"
status["kompress_backend"] = str(backend)
logger.info("Kompress model preload deferred until first request")
status["kompress"] = "deferred"
else:
status["kompress"] = "unavailable"
@ -3315,7 +3297,16 @@ class ContentRouter(Transform):
else:
read_protection_window = num_messages # 0.0 = protect all (old behavior)
runtime_read_protection_window = kwargs.get("read_protection_window")
if runtime_read_protection_window is not None:
if (
runtime_read_protection_window is not None
and self.config.protect_recent_reads_fraction > 0
):
# A profile-derived window may only narrow protection when the
# deployment hasn't explicitly opted into "protect everything"
# (protect_recent_reads_fraction == 0.0, set by --protect-tool-results).
# See #1374's documented contract: protected tool output must never
# lossy-compress "regardless of conversation depth" -- a per-request
# savings-profile kwarg must not silently weaken that.
read_protection_window = max(0, int(runtime_read_protection_window))
# Adaptive compression ratio: scale with context pressure

View file

@ -66,6 +66,9 @@ dependencies = [
proxy = [
"fastapi>=0.100.0",
"uvicorn>=0.23.0,<1.0",
# LiteLLM provider backends (e.g. openrouter) expect orjson at runtime but
# litellm only declares it under its own [proxy] extra (GH #2056).
"orjson>=3.9.14; platform_python_implementation != 'PyPy'",
"httpx[http2]>=0.24.0",
"openai>=2.14.0", # OpenAI API format support
"mcp>=1.0.0", # MCP server (headroom_compress, retrieve, stats)

View file

@ -1,6 +1,6 @@
$ErrorActionPreference = 'Stop'
$ImageDefault = 'ghcr.io/chopratejas/headroom:latest'
$ImageDefault = 'ghcr.io/headroomlabs-ai/headroom:latest'
$InstallImage = if ($env:HEADROOM_DOCKER_IMAGE) { $env:HEADROOM_DOCKER_IMAGE } else { $ImageDefault }
$InstallDir = Join-Path $HOME '.local\bin'
if (-not (Test-Path (Join-Path $HOME '.local'))) {
@ -616,7 +616,7 @@ function Show-InstallApplyHelp {
' --mode TEXT Proxy optimization mode. [default: token]',
' --memory Enable persistent memory in the runtime.',
' --no-telemetry Disable anonymous telemetry in the runtime.',
' --image TEXT Docker image to use. [default: HEADROOM_DOCKER_IMAGE or ghcr.io/chopratejas/headroom:latest]',
' --image TEXT Docker image to use. [default: HEADROOM_DOCKER_IMAGE or ghcr.io/headroomlabs-ai/headroom:latest]',
' -?, --help Show this message and exit.'
)
Write-Host ($lines -join [Environment]::NewLine)

View file

@ -2,7 +2,7 @@
set -euo pipefail
IMAGE_DEFAULT="ghcr.io/chopratejas/headroom:latest"
IMAGE_DEFAULT="ghcr.io/headroomlabs-ai/headroom:latest"
INSTALL_IMAGE="${HEADROOM_DOCKER_IMAGE:-${IMAGE_DEFAULT}}"
INSTALL_DIR="${HOME}/.local/bin"
if [[ ! -d "${HOME}/.local" ]]; then
@ -602,7 +602,7 @@ Options:
--mode TEXT Proxy optimization mode. [default: token]
--memory Enable persistent memory in the runtime.
--no-telemetry Disable anonymous telemetry in the runtime.
--image TEXT Docker image to use. [default: HEADROOM_DOCKER_IMAGE or ghcr.io/chopratejas/headroom:latest]
--image TEXT Docker image to use. [default: HEADROOM_DOCKER_IMAGE or ghcr.io/headroomlabs-ai/headroom:latest]
-?, --help Show this message and exit.
EOF
}

View file

@ -72,6 +72,15 @@ export interface RequestMetrics {
model: string;
stream: boolean;
mode: string;
inputTokensOriginal?: number | null;
inputTokensOptimized?: number | null;
outputTokens?: number | null;
tokensSaved?: number | null;
savingsPercent?: number | null;
optimizationLatencyMs?: number | null;
totalLatencyMs?: number | null;
hasExactTokens?: boolean;
tokenAccountingStatus?: "complete" | "partial" | "missing";
tokensInputBefore: number;
tokensInputAfter: number;
tokensOutput?: number | null;

View file

@ -0,0 +1 @@
{"author":"superdiaodiao","body":"## Description\n\nFor a long-lived Codex WebSocket session, Headroom performs memory context lookup and tool injection only for the first client frame. Later `response.create` frames are compressed and output-shaped, but they do not run the memory decision/query/injection path again.\n\nThis means a user prompt sent on a later turn cannot retrieve newly relevant memories automatically, even though memory is enabled and the same WebSocket remains open.\n\n## To Reproduce\n\n1. Start Headroom with OpenAI/Codex memory enabled, including context injection.\n2. Connect Codex through `/v1/responses` WebSocket.\n3. Send a first `response.create` frame that establishes the session.\n4. Save a durable memory, or choose an existing memory relevant only to a later prompt.\n5. Send a second `response.create` frame on the same WebSocket whose user input should match that memory.\n6. Observe that the second frame is forwarded without a new memory lookup or context injection.\n\nA static source check shows the same lifecycle gap: the memory block in `handle_openai_responses_websocket` runs before the relay loop, while `_client_to_upstream` applies `_maybe_compress_response_create_frame` and output shaping to subsequent frames but does not rerun memory lookup/injection.\n\n## Expected Behavior\n\nEvery new `response.create` turn should independently:\n\n- resolve project/user memory scope;\n- build a query from that turn's current user input;\n- search and inject relevant memory;\n- preserve session-sticky memory tool definitions without duplicating them.\n\n## Actual Behavior\n\nOnly the first frame gets the memory pipeline. Later turns on the same WebSocket miss automatic retrieval.\n\n## Code Sample\n\nConceptual frame sequence:\n\n```json\n{\"type\":\"response.create\",\"response\":{\"input\":\"initial turn\"}}\n{\"type\":\"response.create\",\"response\":{\"input\":\"later turn requiring stored preference\"}}\n```\n\nThe second frame reaches the compression/shaping path but not the memory lookup path.\n\n## Error Output\n\nNo exception is emitted. This is a silent behavior gap; logs show no memory lookup/injection event for later `response.create` frames.\n\n## Environment\n\n- **Headroom version**: 0.31.0 and current main at `868b88bc6400c98f11134dbbe3cb03d1ecff7e1d`\n- **Python version**: 3.13\n- **OS**: macOS\n- **LLM Provider**: OpenAI Responses / Codex subscription WebSocket\n\n## Additional Context\n\nThis appears independent of project DB routing fixes such as GH #462/#1147. The correct DB may be selected, but the lookup is never invoked for the later turn.\n\nA regression test could open one WS connection, send two `response.create` frames with distinct inputs, and assert that memory query construction/injection runs once per frame while tool definitions remain deduplicated.\n","labels":["bug"],"number":2059,"state":"OPEN","title":"[BUG] Codex WebSocket memory lookup only runs on the first response.create frame","updatedAt":"2026-07-12T13:32:05Z","url":"https://github.com/headroomlabs-ai/headroom/issues/2059"}

View file

@ -26,6 +26,7 @@ import logging
import os
import time
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock
import anyio
@ -74,8 +75,8 @@ class _DummyMetrics:
class _ResponseStub:
def __init__(self) -> None:
self.status_code = 200
def __init__(self, status_code: int = 200) -> None:
self.status_code = status_code
self.headers = {"content-type": "application/json"}
self._text = json.dumps(
{
@ -115,6 +116,8 @@ class _DummyAnthropicHandler(AnthropicHandlerMixin):
anthropic_pre_upstream_sem: asyncio.Semaphore | None = None,
upstream_delay_s: float = 0.0,
raise_during_critical: bool = False,
security: Any = None,
upstream_status: int = 200,
) -> None:
self.rate_limiter = None
self.metrics = _DummyMetrics()
@ -140,7 +143,8 @@ class _DummyAnthropicHandler(AnthropicHandlerMixin):
self.cost_tracker = None
self.memory_handler = None
self.cache = None
self.security = None
self.security = security
self._upstream_status = upstream_status
self.ccr_context_tracker = None
self.ccr_injector = None
self.ccr_response_handler = None
@ -258,7 +262,7 @@ class _DummyAnthropicHandler(AnthropicHandlerMixin):
if self._upstream_delay_s > 0:
await asyncio.sleep(self._upstream_delay_s)
self.upstream_exit_times.append(time.perf_counter())
return _ResponseStub()
return _ResponseStub(status_code=self._upstream_status)
def _get_compression_cache(self, session_id):
return SimpleNamespace(
@ -931,3 +935,73 @@ def test_early_exit_paths_release_semaphore_under_contention(scenario):
with _tokenizer_patch():
anyio.run(_run)
# --------------------------------------------------------------------------- #
# Enterprise security response scan must not launder a non-2xx upstream #
# --------------------------------------------------------------------------- #
class _PassthroughSecurity:
"""Minimal enterprise-security stub: scan_request returns a truthy context
(so the response-scan branch is armed) and scan_response leaves the body
unchanged."""
def scan_request(self, messages, ctx):
return messages, {"anonymization": {}}
def scan_response(self, resp_json, ctx):
return resp_json
@pytest.mark.parametrize("upstream_status", [429, 529, 400])
def test_security_scan_preserves_non_200_upstream_status(upstream_status):
"""A non-2xx upstream must reach the client with its real status even when
enterprise security is scanning responses.
The response-scan branch rebuilt the reply as httpx.Response(status_code=200)
and returned it without checking the upstream status, so a rate-limit (429),
overloaded (529), or 4xx error was laundered into an HTTP 200 and the
client's retry/backoff never fired. The branch is now gated on a 200 upstream
like the sibling CCR/cache blocks.
"""
sem = asyncio.Semaphore(2)
handler = _DummyAnthropicHandler(
anthropic_pre_upstream_sem=sem,
security=_PassthroughSecurity(),
upstream_status=upstream_status,
)
request = _build_request(
{
"model": "claude-3-5-sonnet-latest",
"messages": [{"role": "user", "content": "hello"}],
},
{"authorization": "Bearer sk-ant-api-test"},
)
with _tokenizer_patch():
response = anyio.run(handler.handle_anthropic_messages, request)
assert response.status_code == upstream_status
def test_security_scan_still_returns_200_for_ok_upstream():
"""Guard the positive case: a 200 upstream is still scanned and returned 200."""
sem = asyncio.Semaphore(2)
handler = _DummyAnthropicHandler(
anthropic_pre_upstream_sem=sem,
security=_PassthroughSecurity(),
upstream_status=200,
)
request = _build_request(
{
"model": "claude-3-5-sonnet-latest",
"messages": [{"role": "user", "content": "hello"}],
},
{"authorization": "Bearer sk-ant-api-test"},
)
with _tokenizer_patch():
response = anyio.run(handler.handle_anthropic_messages, request)
assert response.status_code == 200

View file

@ -85,10 +85,16 @@ class TestRegexDetector:
assert spans[0].category == DynamicCategory.VERSION
def test_date_prefix_pattern(self, detector):
"""Test full date prefix phrase detection."""
spans = detector.detect("Today is Monday, January 15, 2024. You are an assistant.")
"""Test labeled date phrase detection.
Structural detection requires an explicit ``:``/``=`` separator (a
bare-whitespace separator used to swallow ordinary prose such as
"Today is Monday..." see issue #2110). With the label properly
delimited, the locale-formatted date value is still extracted.
"""
spans = detector.detect("Today: Monday, January 15, 2024. You are an assistant.")
assert len(spans) >= 1
# Should detect the full phrase
# Should detect the labeled value
date_spans = [s for s in spans if s.category == DynamicCategory.DATE]
assert len(date_spans) >= 1
@ -287,6 +293,76 @@ class TestEntropyDetection:
assert "password" not in flagged_words
class TestIssue2110FalsePositives:
"""Regression tests for issue #2110.
The detector misclassified ordinary English words and code identifiers
(e.g. ``in_progress``, ``is_valid``, ``getAuthToken``) as dynamic content,
extracting them from the system prompt and re-appending them as a growing
``[Dynamic Context]`` tail that corrupted the cached prefix. Genuinely
dynamic *shapes* (UUIDs, timestamps, hashes, prefixed ids with a digit)
must still be detected.
"""
@pytest.fixture
def detector(self):
return DynamicContentDetector(DetectorConfig(tiers=["regex"]))
# --- must NOT be flagged (the reported false positives) ------------------
@pytest.mark.parametrize(
"text",
[
"in_progress", # snake_case status word (prefixed_id false positive)
"is_valid", # snake_case identifier (entropy false positive)
"in_pr", # ordinary short token
"total_tokens", # snake_case compound word
"system-reminder", # kebab-case tag name
"getAuthToken (function - src/services/firebase.ts:92)", # code identifier + path
"DebugModal (function - src/components/layout/DebugModal.tsx:11)",
"The current work is being done", # prose starting with a label word
"last updated the file yesterday", # prose starting with a label word
"the user should review this", # prose containing a label word
"the name of the file is unknown", # prose containing a label word
],
)
def test_ordinary_words_and_identifiers_not_extracted(self, detector, text):
result = detector.detect(text)
assert result.spans == [], f"unexpected dynamic spans for {text!r}: {result.spans}"
# Nothing extracted -> the static content is preserved verbatim and the
# dynamic tail stays empty (so it can't grow over a session).
assert result.dynamic_content == ""
# --- MUST still be flagged (genuinely dynamic shapes) --------------------
def test_uuid_still_detected(self, detector):
text = "550e8400-e29b-41d4-a716-446655440000"
spans = detector.detect(text).spans
assert any(s.category == DynamicCategory.UUID and s.text == text for s in spans)
def test_timestamp_still_detected(self, detector):
spans = detector.detect("event at 2026-07-12T10:30:00Z happened").spans
assert any(s.text == "2026-07-12T10:30:00Z" for s in spans)
def test_long_hex_hash_still_detected(self, detector):
sha1 = "da39a3ee5e6b4b0d3255bfef95601890afd80709"
spans = detector.detect(sha1).spans
assert any(s.category == DynamicCategory.IDENTIFIER and s.text == sha1 for s in spans)
def test_prefixed_id_with_digit_still_detected(self, detector):
spans = detector.detect("req_a1b2c3d4").spans
assert any(s.category == DynamicCategory.REQUEST_ID for s in spans)
def test_labeled_dynamic_value_still_detected(self, detector):
# Explicit "label: value" — the label stays static, the value is dynamic.
spans = detector.detect("session_id: 8f3e2a1c9d").spans
assert any(s.text == "8f3e2a1c9d" for s in spans)
def test_high_entropy_id_with_digits_still_detected(self, detector):
spans = detector.detect("a1b2c3d4e5f6g7h8").spans
assert any(s.category == DynamicCategory.IDENTIFIER for s in spans)
class TestEdgeCases:
"""Test edge cases and tricky inputs."""

View file

@ -96,6 +96,31 @@ class TestSemanticCache:
assert cache.get("query3", messages_hash="h3") is not None
assert cache.get("query4", messages_hash="h4") is not None
def test_update_at_capacity_does_not_evict_unrelated_entry(self):
"""Re-storing an existing key at capacity must not drop another entry.
The eviction loop used to run before the cache key was computed, so
overwriting a key that was already present (a retried/duplicate store)
still evicted the LRU-oldest distinct entry even though the update grows
nothing. That silently dropped a live entry and turned a later lookup for
it into a false miss.
"""
config = SemanticCacheConfig(max_entries=2)
cache = SemanticCache(config)
cache.put("query1", "response1", messages_hash="h1")
cache.put("query2", "response2", messages_hash="h2")
# Re-store the already-present h2 (e.g. a duplicate/retried request).
cache.put("query2", "response2b", messages_hash="h2")
# h1 must still be there — updating h2 must not evict it.
got1 = cache.get("query1", messages_hash="h1")
assert got1 is not None and got1.response == "response1"
# h2 reflects the update.
got2 = cache.get("query2", messages_hash="h2")
assert got2 is not None and got2.response == "response2b"
def test_ttl_expiration(self):
"""Test TTL expiration."""
config = SemanticCacheConfig(ttl_seconds=1)

View file

@ -86,6 +86,50 @@ class TestCompressionFeedback:
assert pattern.retrieval_rate == 0.5
assert pattern.full_retrieval_rate == 1.0 # All were full retrievals
def test_eviction_success_is_not_counted_as_retrieval(self):
"""An eviction-without-retrieval is a compression success, not a retrieval.
The event arrives with retrieval_type="eviction_success". Because that
isn't "full" it used to fall into the search_retrievals branch and
inflate retrieval_rate/search_rate, driving get_compression_hints toward
less aggressive compression the inverse of the intended signal. It must
leave the retrieval counters untouched.
"""
feedback = CompressionFeedback()
feedback.record_compression("test_tool", 100, 10)
event = RetrievalEvent(
hash="abc123",
query=None,
items_retrieved=0,
total_items=100,
tool_name="test_tool",
timestamp=time.time(),
retrieval_type="eviction_success",
)
feedback.record_retrieval(event, strategy="smart")
pattern = feedback.get_all_patterns()["test_tool"]
assert pattern.total_retrievals == 0
assert pattern.search_retrievals == 0
assert pattern.retrieval_rate == 0.0 # a successful compression, not a retrieval
# A genuine retrieval afterward is still counted.
feedback.record_retrieval(
RetrievalEvent(
hash="def456",
query="find errors",
items_retrieved=50,
total_items=100,
tool_name="test_tool",
timestamp=time.time(),
retrieval_type="search",
)
)
pattern = feedback.get_all_patterns()["test_tool"]
assert pattern.total_retrievals == 1
assert pattern.search_retrievals == 1
def test_hints_default_with_no_data(self):
"""Default hints returned when no data exists."""
feedback = CompressionFeedback()

View file

@ -41,6 +41,16 @@ def _clear_claude_mode_env(monkeypatch: pytest.MonkeyPatch) -> None:
"CLAUDE_CODE_USE_VERTEX",
"CLAUDE_CODE_USE_FOUNDRY",
"VERTEX_TARGET_API_URL",
# Issue #1779: these put Claude Code on a non-subscription auth path, so
# the Remote Control gate warning must not fire. Clear them so the
# plain-mode RC-warning assertion is deterministic regardless of the
# ambient environment the test runs in.
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"CLAUDE_CODE_USE_BEDROCK",
# The RC sibling note reflects the resolved ENABLE_TOOL_SEARCH mode;
# clear any ambient value so the default-session assertions hold.
"ENABLE_TOOL_SEARCH",
):
monkeypatch.delenv(key, raising=False)
@ -50,6 +60,7 @@ def _invoke_wrap_claude(
monkeypatch: pytest.MonkeyPatch,
*,
env: dict[str, str],
extra_args: tuple[str, ...] = (),
) -> tuple[dict[str, Any], str]:
captured: dict[str, Any] = {}
@ -80,6 +91,10 @@ def _invoke_wrap_claude(
return _Completed()
monkeypatch.setattr(wrap_mod, "_ensure_proxy", fake_ensure_proxy)
# Issue #1779: pin the detected Claude Code version to the gated release so
# the plain-mode RC warning is deterministic without shelling out to a real
# `claude --version` (which would otherwise hit the child-launch fake_run).
monkeypatch.setattr(wrap_mod, "detect_claude_code_version", lambda *_a, **_k: (2, 1, 196))
monkeypatch.setattr(wrap_mod.subprocess, "run", fake_run)
result = runner.invoke(
@ -91,6 +106,7 @@ def _invoke_wrap_claude(
"--no-mcp",
"--no-tokensave",
"--no-serena",
*extra_args,
],
env=env,
)
@ -107,6 +123,53 @@ def test_wrap_claude_plain_mode_warns_about_remote_control_gate(
assert captured["child_cmd"] == ["/usr/bin/claude"]
assert "Remote Control" in output
assert "wrapped Claude session's ANTHROPIC_BASE_URL" in output
# Issue #1779: the warning is accurate (deterministic, names /rc) and
# co-reports the sibling base-URL gates (#746 / #1158).
assert "2.1.196" in output
assert "/rc" in output
assert "may hide" not in output
assert "#746" in output and "#1158" in output
def test_wrap_claude_plain_mode_api_key_auth_skips_remote_control_warning(
runner: CliRunner, monkeypatch: pytest.MonkeyPatch
) -> None:
# Issue #1779: an API-key (PAYG) session never had Remote Control, so the
# gate warning must not fire even in plain proxy mode.
_captured, output = _invoke_wrap_claude(
runner, monkeypatch, env={"ANTHROPIC_API_KEY": "sk-ant-api-xxx"}
)
assert "Remote Control" not in output
def test_wrap_claude_sibling_note_accurate_under_1m_and_tool_search_optouts(
runner: CliRunner, monkeypatch: pytest.MonkeyPatch
) -> None:
# Issue #1779 accuracy under opt-ins: with --1m the note must not advise
# adding --1m again, and with --tool-search false it must not claim
# deferral is kept on — nor may the #746 banner line say "kept on".
_captured, output = _invoke_wrap_claude(
runner,
monkeypatch,
env={},
extra_args=("--1m", "--tool-search", "false"),
)
assert "already restored via --1m" in output
assert "restore with `headroom wrap claude --1m`" not in output
assert "OFF for this session" in output
assert "DISABLED per your setting" in output
assert "kept on" not in output
def test_wrap_claude_tool_search_banner_line_still_accurate_when_active(
runner: CliRunner, monkeypatch: pytest.MonkeyPatch
) -> None:
# Default session: deferral is on, and both the #746 banner line and the
# RC sibling note say so.
_captured, output = _invoke_wrap_claude(runner, monkeypatch, env={})
assert "on-demand tool loading kept on" in output
assert "keeps it on for this session" in output
assert "DISABLED per your setting" not in output
def test_wrap_claude_vertex_passes_custom_base_url_to_proxy_before_child_redirect(
@ -114,7 +177,7 @@ def test_wrap_claude_vertex_passes_custom_base_url_to_proxy_before_child_redirec
) -> None:
custom_vertex_url = "https://vertex-gateway.internal/custom/v1"
captured, _output = _invoke_wrap_claude(
captured, output = _invoke_wrap_claude(
runner,
monkeypatch,
env={
@ -123,6 +186,10 @@ def test_wrap_claude_vertex_passes_custom_base_url_to_proxy_before_child_redirec
},
)
# Issue #1779: Vertex sessions authenticate with cloud IAM and never had
# Remote Control — the RC gate warning must not fire in this mode.
assert "Remote Control" not in output
ensure_kwargs = captured["ensure_kwargs"]
child_env = captured["child_env"]
write_kwargs = captured["write_base_url_kwargs"]
@ -189,7 +256,7 @@ def test_wrap_claude_foundry_proxy_env_behavior_is_unchanged(
) -> None:
foundry_url = "https://my-resource.services.ai.azure.com/anthropic"
captured, _output = _invoke_wrap_claude(
captured, output = _invoke_wrap_claude(
runner,
monkeypatch,
env={
@ -198,6 +265,10 @@ def test_wrap_claude_foundry_proxy_env_behavior_is_unchanged(
},
)
# Issue #1779: Foundry sessions authenticate with Azure credentials and
# never had Remote Control — the RC gate warning must not fire.
assert "Remote Control" not in output
ensure_kwargs = captured["ensure_kwargs"]
child_env = captured["child_env"]
assert ensure_kwargs["anthropic_api_url"] == foundry_url

View file

@ -10,7 +10,9 @@ way a user would from the shell.
from __future__ import annotations
import shutil
import socket
import sqlite3
import sys
from pathlib import Path
from unittest.mock import patch
@ -1081,6 +1083,38 @@ def test_codex_session_home_overlay_seeds_active_home_and_cleans_up(
assert auth_file.read_text(encoding="utf-8") == original_auth
@pytest.mark.skipif(
sys.platform == "win32" or not hasattr(socket, "AF_UNIX"),
reason="requires POSIX Unix domain sockets",
)
def test_codex_session_home_overlay_skips_unix_sockets(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_set_test_home(monkeypatch, tmp_path)
codex_home = tmp_path / "custom-codex-home"
socket_dir = codex_home / "vendor_imports" / "skills" / ".git"
socket_dir.mkdir(parents=True)
monkeypatch.setenv("CODEX_HOME", str(codex_home))
monkeypatch.chdir(codex_home)
head_file = socket_dir / "HEAD"
head_file.write_text("ref: refs/heads/main\n", encoding="utf-8")
socket_file = socket_dir / "fsmonitor--daemon.ipc"
relative_socket_file = socket_file.relative_to(codex_home)
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as fsmonitor_socket:
fsmonitor_socket.bind(str(relative_socket_file))
with wrap_mod._codex_session_home_overlay() as session_home:
session_socket_dir = session_home / socket_dir.relative_to(codex_home)
assert (session_socket_dir / "HEAD").read_text(encoding="utf-8") == (
"ref: refs/heads/main\n"
)
assert not (session_socket_dir / socket_file.name).exists()
assert socket_file.is_socket()
def test_wrap_codex_launch_uses_session_scoped_codex_home(
runner: CliRunner, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:

View file

@ -61,7 +61,7 @@ def test_wrap_openclaw_default_installs_from_npm_and_restarts(runner: CliRunner)
"plugins",
"install",
"--dangerously-force-unsafe-install",
"headroom-ai/openclaw",
"headroom-openclaw",
] in cmds
assert ["openclaw", "config", "validate"] in cmds
assert ["openclaw", "gateway", "restart"] in cmds
@ -77,7 +77,9 @@ def test_wrap_openclaw_default_installs_from_npm_and_restarts(runner: CliRunner)
for i, cmd in enumerate(cmds)
if cmd[:4] == ["openclaw", "plugins", "install", "--dangerously-force-unsafe-install"]
)
assert config_set_index < install_index
# Config must be written only after a successful install so a failed
# install leaves no stale plugins.entries.headroom entry (issue #1969).
assert install_index < config_set_index
# Verify plugin install in npm mode does not set cwd
install_call = next(
@ -490,6 +492,44 @@ def test_wrap_openclaw_fails_for_npm_mode_hook_pack_bug_without_local_fallback(
assert "openclaw plugins install failed" in result.output
def test_wrap_openclaw_default_plugin_spec_matches_published_package() -> None:
"""The --plugin-spec default must be the real published npm package name."""
from headroom.providers.openclaw import OPENCLAW_NPM_PACKAGE
assert OPENCLAW_NPM_PACKAGE == "headroom-openclaw"
command = wrap_cli.wrap.commands["openclaw"]
plugin_spec_option = next(p for p in command.params if p.name == "plugin_spec")
assert plugin_spec_option.default == "headroom-openclaw"
def test_wrap_openclaw_failed_install_writes_no_config_entry(runner: CliRunner) -> None:
"""A hard `plugins install` failure must not leave a stale config entry."""
calls: list[dict] = []
def which(name: str) -> str | None:
return {"openclaw": "openclaw", "npm": "npm"}.get(name)
def run(cmd, **kwargs): # noqa: ANN001
calls.append({"cmd": list(cmd), **kwargs})
if cmd[:3] == ["openclaw", "plugins", "install"]:
return MagicMock(returncode=1, stdout="", stderr="npm 404 not found")
return MagicMock(returncode=0, stdout="", stderr="")
with patch("headroom.cli.wrap.shutil.which", side_effect=which):
with patch("headroom.cli.wrap.subprocess.run", side_effect=run):
result = runner.invoke(main, ["wrap", "openclaw"])
assert result.exit_code != 0
assert "openclaw plugins install failed" in result.output
cmds = [c["cmd"] for c in calls]
# No plugin config entry should be written when the install hard-fails.
assert not any(
cmd[:4] == ["openclaw", "config", "set", "plugins.entries.headroom"] for cmd in cmds
)
def test_wrap_openclaw_copy_mode_uses_path_install(runner: CliRunner, plugin_dir: Path) -> None:
calls: list[dict] = []

View file

@ -241,6 +241,75 @@ def test_wrap_opencode_injects_rtk_into_agents_md(
assert wrap_mod._RTK_MARKER in project_agents.read_text(encoding="utf-8")
def test_unwrap_opencode_removes_rtk_from_agents_md(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""unwrap opencode removes the rtk block that wrap opencode injected into both
the project and global AGENTS.md mirroring unwrap_codex / unwrap_copilot."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
_set_test_home(monkeypatch, tmp_path)
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
global_agents = tmp_path / ".config" / "opencode" / "AGENTS.md"
project_agents = tmp_path / "AGENTS.md"
assert wrap_mod._RTK_MARKER in global_agents.read_text(encoding="utf-8")
assert wrap_mod._RTK_MARKER in project_agents.read_text(encoding="utf-8")
with patch.object(wrap_mod, "_stop_local_proxy_for_unwrap", return_value="stopped"):
result = runner.invoke(main, ["unwrap", "opencode"])
assert result.exit_code == 0, result.output
# Both rtk blocks are gone after unwrap (previously left behind). A file that
# held only the rtk block is removed entirely by _remove_rtk_instructions, so
# treat a missing file as "block gone".
def _rtk_absent(path: Path) -> bool:
return not path.exists() or wrap_mod._RTK_MARKER not in path.read_text(encoding="utf-8")
assert _rtk_absent(global_agents)
assert _rtk_absent(project_agents)
def test_wrap_opencode_no_project_rtk_only_skips_project_agents_md(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
_set_test_home(monkeypatch, tmp_path)
project_agents = tmp_path / "AGENTS.md"
project_agents.write_text("# Team instructions\n", encoding="utf-8")
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
result = runner.invoke(
main,
[
"wrap",
"opencode",
"--no-project-rtk",
"--no-proxy",
"--port",
"9000",
"--no-mcp",
],
)
assert result.exit_code == 0, result.output
assert project_agents.read_text(encoding="utf-8") == "# Team instructions\n"
global_agents = tmp_path / ".config" / "opencode" / "AGENTS.md"
assert wrap_mod._RTK_MARKER in global_agents.read_text(encoding="utf-8")
def test_wrap_opencode_idempotent_no_duplicate_block(
runner: CliRunner,
tmp_path: Path,

View file

@ -163,6 +163,32 @@ def test_ensure_proxy_restarts_idle_stale_persistent_deployment(monkeypatch) ->
assert calls == ["restart:default:8787"]
def test_ensure_proxy_restarts_stale_proxy_from_dev_build(monkeypatch) -> None:
"""A source (-dev) CLI still restarts a stale proxy: the -dev marker is
display-only and must not disable a real version-mismatch restart."""
calls: list[str] = []
health = {
"version": "0.0.1",
"runtime": {"websocket_sessions": {"active_sessions": 0, "active_relay_tasks": 0}},
"config": {"pid": 12345},
}
monkeypatch.setattr(wrap_cli, "_HEADROOM_VERSION", "0.32.0-dev")
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest())
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: True)
monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: health)
monkeypatch.setattr(
wrap_cli,
"_restart_persistent_proxy",
lambda manifest, port: calls.append(f"restart:{manifest.profile}:{port}") or True,
)
proc, actual_port = wrap_cli._ensure_proxy(8787, False)
assert proc is None
assert actual_port == 8787
assert calls == ["restart:default:8787"]
def test_ensure_proxy_leaves_active_stale_persistent_deployment_running(monkeypatch) -> None:
health = {
"version": "0.0.1",

View file

@ -172,6 +172,138 @@ class TestClaudeRemoteControlGate:
)
assert check_claude_remote_control_gate(path, {}) is None
def test_api_key_auth_suppresses_warning(self, tmp_path):
# Issue #1779: a PAYG / API-key session never had Remote Control, so the
# gate warning must not fire even behind a custom base URL.
path = tmp_path / "settings.json"
path.write_text(
json.dumps({"env": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787"}}),
encoding="utf-8",
)
assert check_claude_remote_control_gate(path, {"ANTHROPIC_API_KEY": "sk-ant-api-x"}) is None
def test_settings_api_key_suppresses_warning(self, tmp_path):
# An API key configured in settings.json (not just the shell) also means
# a non-subscription session — stay silent.
path = tmp_path / "settings.json"
path.write_text(
json.dumps(
{
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787",
"ANTHROPIC_API_KEY": "sk-ant-api-x",
}
}
),
encoding="utf-8",
)
assert check_claude_remote_control_gate(path, {}) is None
def test_version_resolver_not_called_without_custom_base(self, tmp_path):
# The `claude --version` subprocess is expensive (Node CLI cold start);
# the check must not invoke the resolver when no custom base URL exists.
path = tmp_path / "settings.json"
path.write_text(
json.dumps({"env": {"ANTHROPIC_BASE_URL": "https://api.anthropic.com"}}),
encoding="utf-8",
)
def boom() -> tuple[int, int, int]:
raise AssertionError("resolver must not run when no custom base URL")
assert check_claude_remote_control_gate(path, {}, version_resolver=boom) is None
def test_version_resolver_not_called_for_api_key_auth(self, tmp_path):
# PAYG sessions are suppressed before version matters — no subprocess.
path = tmp_path / "settings.json"
path.write_text(
json.dumps({"env": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787"}}),
encoding="utf-8",
)
def boom() -> tuple[int, int, int]:
raise AssertionError("resolver must not run for API-key auth")
assert (
check_claude_remote_control_gate(
path, {"ANTHROPIC_API_KEY": "sk-ant-api-x"}, version_resolver=boom
)
is None
)
def test_version_resolver_called_once_and_honored(self, tmp_path):
path = tmp_path / "settings.json"
path.write_text(
json.dumps({"env": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787"}}),
encoding="utf-8",
)
calls: list[int] = []
def resolver() -> tuple[int, int, int]:
calls.append(1)
return (2, 1, 196)
# Shell env ALSO custom so both loop sources are live — still one call.
result = check_claude_remote_control_gate(
path,
{"ANTHROPIC_BASE_URL": "http://127.0.0.1:9999"},
version_resolver=resolver,
)
assert result is not None
assert "2.1.196" in result.summary
assert calls == [1]
def test_version_resolver_pre_gate_version_suppresses(self, tmp_path):
path = tmp_path / "settings.json"
path.write_text(
json.dumps({"env": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787"}}),
encoding="utf-8",
)
assert (
check_claude_remote_control_gate(path, {}, version_resolver=lambda: (2, 1, 195)) is None
)
def test_malformed_settings_base_url_does_not_crash(self, tmp_path):
# Issue #1779: settings.json is user-edited; a typo'd IPv6 literal made
# urlparse raise ValueError("Invalid IPv6 URL") and crashed doctor.
# Malformed values degrade to "no host" and the check stays silent —
# check_claude_routing separately flags unusable URLs.
path = tmp_path / "settings.json"
path.write_text(
json.dumps({"env": {"ANTHROPIC_BASE_URL": "http://[::1:8787"}}),
encoding="utf-8",
)
assert check_claude_remote_control_gate(path, {}) is None
def test_malformed_shell_base_url_does_not_crash(self, tmp_path):
path = tmp_path / "settings.json"
path.write_text("{}", encoding="utf-8")
assert check_claude_remote_control_gate(path, {"ANTHROPIC_BASE_URL": "http://["}) is None
def test_pre_gate_version_suppresses_warning(self, tmp_path):
# Older Claude Code does not gate RC on the base URL — no false alarm.
path = tmp_path / "settings.json"
path.write_text(
json.dumps({"env": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787"}}),
encoding="utf-8",
)
assert check_claude_remote_control_gate(path, {}, version=(2, 1, 195)) is None
def test_gated_version_warns_with_exact_version(self, tmp_path):
path = tmp_path / "settings.json"
path.write_text(
json.dumps({"env": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787"}}),
encoding="utf-8",
)
result = check_claude_remote_control_gate(path, {}, version=(2, 1, 196))
assert result is not None
assert result.status == WARN
assert "2.1.196" in result.summary
assert "disables" in result.summary
# Sibling gates are co-reported in the hint (#746 / #1158).
assert "#746" in (result.hint or "")
assert "#1158" in (result.hint or "")
def test_settings_check_still_routes(self, tmp_path):
path = tmp_path / "settings.json"
path.write_text(

View file

@ -193,6 +193,23 @@ class TestRetryMaxAttemptsValidation:
assert result.exit_code != 0
class TestRetryDelayValidation:
def test_retry_delays_are_forwarded(self, runner: CliRunner, mock_run_server: dict) -> None:
result = runner.invoke(
main,
["proxy", "--retry-base-delay-ms", "250", "--retry-max-delay-ms", "5000"],
catch_exceptions=False,
)
assert result.exit_code == 0, result.output
assert mock_run_server["config"].retry_base_delay_ms == 250
assert mock_run_server["config"].retry_max_delay_ms == 5000
@pytest.mark.parametrize("option", ["--retry-base-delay-ms", "--retry-max-delay-ms"])
def test_negative_delay_is_rejected(self, runner: CliRunner, option: str) -> None:
result = runner.invoke(main, ["proxy", option, "-1"])
assert result.exit_code != 0
class TestConnectTimeoutSecondsValidation:
"""--connect-timeout-seconds should accept 1-300, reject outside that range."""
@ -350,6 +367,20 @@ class TestNewEnvVarWiring:
assert result.exit_code == 0, result.output
assert mock_run_server["config"].retry_max_attempts == 5
def test_headroom_retry_delays_from_env(self, runner: CliRunner, mock_run_server: dict) -> None:
result = runner.invoke(
main,
["proxy"],
env={
"HEADROOM_RETRY_BASE_DELAY_MS": "125",
"HEADROOM_RETRY_MAX_DELAY_MS": "8000",
},
catch_exceptions=False,
)
assert result.exit_code == 0, result.output
assert mock_run_server["config"].retry_base_delay_ms == 125
assert mock_run_server["config"].retry_max_delay_ms == 8000
def test_headroom_connect_timeout_from_env(
self, runner: CliRunner, mock_run_server: dict
) -> None:

View file

@ -0,0 +1,411 @@
from __future__ import annotations
import asyncio
import json
import re
import sys
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from tests.test_openai_codex_ws_lifecycle import (
_DummyOpenAIHandler,
_FakeUpstream,
_FakeWebSocket,
_make_fake_websockets_module,
)
class _MemoryHandler:
def __init__(self) -> None:
self.config = SimpleNamespace(
inject_context=True,
inject_tools=True,
project_root_override="",
)
self.queries: list[str] = []
async def search_and_format_context(self, _user_id, messages, **_kwargs):
current_turn = messages[-1]["content"] if messages else ""
self.queries.append(current_turn)
return f"current memory: {current_turn}"
def compute_memory_tool_definitions(self, _provider):
return [
{
"type": "function",
"function": {
"name": "memory_search",
"description": "search",
"parameters": {"type": "object"},
},
},
{
"type": "function",
"function": {
"name": "memory_save",
"description": "save",
"parameters": {"type": "object"},
},
},
]
def _expected_memory_response_tools() -> list[dict[str, object]]:
expected: list[dict[str, object]] = []
for tool in _MemoryHandler().compute_memory_tool_definitions("openai"):
function = tool["function"]
expected.append(
{
"type": "function",
"name": function["name"],
"description": function["description"],
"parameters": function["parameters"],
}
)
return expected
def _turn(text: str) -> str:
return json.dumps({"type": "response.create", "response": {"input": text}})
def _direct_turn(text: str) -> str:
return json.dumps({"input": text})
def _issue_2059_artifact_path() -> Path:
return Path(__file__).resolve().parent / "fixtures" / "issues" / "headroom_issue_2059.json"
def _issue_2059_turns() -> tuple[str, str]:
issue_path = _issue_2059_artifact_path()
issue = json.loads(issue_path.read_text(encoding="utf-8"))
match = re.search(r"```json\s*(.*?)```", issue["body"], re.DOTALL)
assert match is not None, "issue 2059 artifact must contain a JSON code sample"
frames = [line.strip() for line in match.group(1).splitlines() if line.strip()]
assert len(frames) == 2, "issue 2059 artifact must contain exactly two frames"
return frames[0], frames[1]
def _issue_2059_inputs() -> tuple[str, str]:
first, later = _issue_2059_turns()
return (
json.loads(first)["response"]["input"],
json.loads(later)["response"]["input"],
)
def _list_turn(text: str, *, instructions: str) -> str:
return json.dumps(
{
"type": "response.create",
"response": {
"instructions": instructions,
"input": [
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": text}],
}
],
},
}
)
class _FlakyMemoryHandler(_MemoryHandler):
def __init__(self, *, fail_on: set[str]) -> None:
super().__init__()
self.fail_on = set(fail_on)
async def search_and_format_context(self, _user_id, messages, **_kwargs):
current_turn = messages[-1]["content"] if messages else ""
self.queries.append(current_turn)
if current_turn in self.fail_on:
raise RuntimeError(f"memory failed for {current_turn}")
return f"current memory: {current_turn}"
class _ToolFailingMemoryHandler(_MemoryHandler):
def __init__(self) -> None:
super().__init__()
self._fail_next_tools = True
def compute_memory_tool_definitions(self, _provider):
if self._fail_next_tools:
self._fail_next_tools = False
raise RuntimeError("memory tool preparation failed")
return super().compute_memory_tool_definitions(_provider)
@pytest.mark.asyncio
async def test_memory_lookup_runs_for_each_issue_artifact_frame_and_preserves_non_create_frames():
upstream = _FakeUpstream(
[
json.dumps({"type": "response.created", "response": {"id": "r_1"}}),
json.dumps({"type": "response.completed", "response": {"id": "r_1"}}),
]
)
first_turn, later_turn = _issue_2059_turns()
first_input, later_input = _issue_2059_inputs()
client_frames = [
first_turn,
json.dumps({"type": "response.cancel"}),
later_turn,
]
client_ws = _FakeWebSocket(frames=client_frames)
handler = _DummyOpenAIHandler()
memory = _MemoryHandler()
handler.memory_handler = memory
with patch.dict(sys.modules, {"websockets": _make_fake_websockets_module(upstream)}):
await handler.handle_openai_responses_ws(client_ws)
assert memory.queries == [first_input, later_input]
assert upstream.sent[1] == client_frames[1]
forwarded_turns = [
json.loads(frame) for frame in upstream.sent if "response" in json.loads(frame)
]
assert f"current memory: {first_input}" in forwarded_turns[0]["response"]["input"]
assert f"current memory: {later_input}" in forwarded_turns[1]["response"]["input"]
expected_tools = _expected_memory_response_tools()
for frame in forwarded_turns:
assert frame["response"]["tools"] == expected_tools
assert forwarded_turns[0]["response"]["tools"] == forwarded_turns[1]["response"]["tools"]
@pytest.mark.asyncio
async def test_memory_lookup_skips_input_bearing_non_create_first_frame():
upstream = _FakeUpstream(
[
json.dumps({"type": "response.created", "response": {"id": "r_1"}}),
json.dumps({"type": "response.completed", "response": {"id": "r_1"}}),
]
)
_first_input, later_input = _issue_2059_inputs()
cancel_frame = json.dumps(
{
"type": "response.cancel",
"response_id": "r_1",
"input": "must not query",
}
)
later_turn = _issue_2059_turns()[1]
client_ws = _FakeWebSocket(frames=[cancel_frame, later_turn])
handler = _DummyOpenAIHandler()
memory = _MemoryHandler()
handler.memory_handler = memory
with patch.dict(sys.modules, {"websockets": _make_fake_websockets_module(upstream)}):
await handler.handle_openai_responses_ws(client_ws)
assert memory.queries == [later_input]
assert upstream.sent[0] == cancel_frame
forwarded_later = json.loads(upstream.sent[1])
assert f"current memory: {later_input}" in forwarded_later["response"]["input"]
@pytest.mark.asyncio
async def test_memory_lookup_skips_bypassed_frames():
upstream = _FakeUpstream(
[
json.dumps({"type": "response.created", "response": {"id": "r_1"}}),
json.dumps({"type": "response.completed", "response": {"id": "r_1"}}),
]
)
first, later = _issue_2059_turns()
client_ws = _FakeWebSocket(
frames=[first, later],
headers={"authorization": "Bearer test", "x-headroom-bypass": "true"},
)
handler = _DummyOpenAIHandler()
memory = _MemoryHandler()
handler.memory_handler = memory
with patch.dict(sys.modules, {"websockets": _make_fake_websockets_module(upstream)}):
await handler.handle_openai_responses_ws(client_ws)
assert memory.queries == []
assert upstream.sent == [first, later]
@pytest.mark.asyncio
async def test_memory_lookup_keeps_legacy_direct_first_frame():
upstream = _FakeUpstream(
[
json.dumps({"type": "response.created", "response": {"id": "r_1"}}),
json.dumps({"type": "response.completed", "response": {"id": "r_1"}}),
]
)
first_input, later_input = _issue_2059_inputs()
first = _direct_turn(first_input)
later = _issue_2059_turns()[1]
client_ws = _FakeWebSocket(frames=[first, later])
handler = _DummyOpenAIHandler()
memory = _MemoryHandler()
handler.memory_handler = memory
with patch.dict(sys.modules, {"websockets": _make_fake_websockets_module(upstream)}):
await handler.handle_openai_responses_ws(client_ws)
assert memory.queries == [first_input, later_input]
forwarded_first = json.loads(upstream.sent[0])
forwarded_later = json.loads(upstream.sent[1])
assert f"current memory: {first_input}" in forwarded_first["input"]
assert f"current memory: {later_input}" in forwarded_later["response"]["input"]
@pytest.mark.asyncio
async def test_memory_lookup_skips_disabled_memory(monkeypatch):
monkeypatch.setenv("HEADROOM_MEMORY_INJECTION_MODE", "disabled")
upstream = _FakeUpstream(
[
json.dumps({"type": "response.created", "response": {"id": "r_1"}}),
json.dumps({"type": "response.completed", "response": {"id": "r_1"}}),
]
)
first, later = _issue_2059_turns()
client_ws = _FakeWebSocket(frames=[first, later])
handler = _DummyOpenAIHandler()
memory = _MemoryHandler()
handler.memory_handler = memory
with patch.dict(sys.modules, {"websockets": _make_fake_websockets_module(upstream)}):
await handler.handle_openai_responses_ws(client_ws)
assert memory.queries == []
assert upstream.sent == [first, later]
@pytest.mark.asyncio
async def test_memory_lookup_fails_open_and_recovers_on_later_frame():
first, later = _issue_2059_turns()
first_input, later_input = _issue_2059_inputs()
upstream = _FakeUpstream([], hold_after_events=True)
client_ws = _FakeWebSocket(frames=[first, later], hold_after_initial=True)
handler = _DummyOpenAIHandler()
memory = _FlakyMemoryHandler(fail_on={first_input})
handler.memory_handler = memory
async def _trigger() -> None:
await asyncio.sleep(0.05)
client_ws.trigger_disconnect()
with patch.dict(sys.modules, {"websockets": _make_fake_websockets_module(upstream)}):
trigger_task = asyncio.create_task(_trigger())
try:
await handler.handle_openai_responses_ws(client_ws)
finally:
trigger_task.cancel()
try:
await trigger_task
except asyncio.CancelledError:
pass
assert memory.queries == [first_input, later_input]
assert upstream.sent[0] == first
assert f"current memory: {later_input}" in json.loads(upstream.sent[1])["response"]["input"]
@pytest.mark.asyncio
async def test_memory_lookup_fails_open_when_tool_preparation_raises():
first, later = _issue_2059_turns()
first_input, later_input = _issue_2059_inputs()
upstream = _FakeUpstream([], hold_after_events=True)
client_ws = _FakeWebSocket(frames=[first, later], hold_after_initial=True)
handler = _DummyOpenAIHandler()
memory = _ToolFailingMemoryHandler()
handler.memory_handler = memory
async def _trigger() -> None:
await asyncio.sleep(0.05)
client_ws.trigger_disconnect()
with patch.dict(sys.modules, {"websockets": _make_fake_websockets_module(upstream)}):
trigger_task = asyncio.create_task(_trigger())
try:
await handler.handle_openai_responses_ws(client_ws)
finally:
trigger_task.cancel()
try:
await trigger_task
except asyncio.CancelledError:
pass
assert memory.queries == [first_input, later_input]
assert upstream.sent[0] == first
assert f"current memory: {later_input}" in json.loads(upstream.sent[1])["response"]["input"]
@pytest.mark.asyncio
async def test_memory_lookup_preserves_list_shaped_later_frame_input():
first, _later = _issue_2059_turns()
list_frame = _list_turn(
"later turn with list payload",
instructions="list payload instructions",
)
expected_input = json.loads(list_frame)["response"]["input"]
upstream = _FakeUpstream([], hold_after_events=True)
client_ws = _FakeWebSocket(frames=[first, list_frame], hold_after_initial=True)
handler = _DummyOpenAIHandler()
memory = _MemoryHandler()
handler.memory_handler = memory
async def _trigger() -> None:
await asyncio.sleep(0.05)
client_ws.trigger_disconnect()
with patch.dict(sys.modules, {"websockets": _make_fake_websockets_module(upstream)}):
trigger_task = asyncio.create_task(_trigger())
try:
await handler.handle_openai_responses_ws(client_ws)
finally:
trigger_task.cancel()
try:
await trigger_task
except asyncio.CancelledError:
pass
forwarded_later = json.loads(upstream.sent[1])
assert forwarded_later["response"]["input"] == expected_input
assert memory.queries[-1] == "list payload instructions"
@pytest.mark.asyncio
async def test_later_frame_compression_receives_memory_prepared_input():
first, later = _issue_2059_turns()
_first_input, later_input = _issue_2059_inputs()
upstream = _FakeUpstream([], hold_after_events=True)
client_ws = _FakeWebSocket(frames=[first, later], hold_after_initial=True)
handler = _DummyOpenAIHandler()
handler.config.optimize = True
memory = _MemoryHandler()
handler.memory_handler = memory
seen_inputs: list[object] = []
def _capture_compress(payload, *, model, request_id, timing=None):
seen_inputs.append(payload["input"])
return payload, False, 0, [], "test_noop", 10, 10, 0
async def _trigger() -> None:
await asyncio.sleep(0.05)
client_ws.trigger_disconnect()
handler._compress_openai_responses_payload = _capture_compress # type: ignore[method-assign]
with patch.dict(sys.modules, {"websockets": _make_fake_websockets_module(upstream)}):
trigger_task = asyncio.create_task(_trigger())
try:
await handler.handle_openai_responses_ws(client_ws)
finally:
trigger_task.cancel()
try:
await trigger_task
except asyncio.CancelledError:
pass
assert len(seen_inputs) == 2
assert f"current memory: {later_input}" in str(seen_inputs[1])

View file

@ -706,6 +706,32 @@ class TestCompressionStoreEviction:
assert store_with_small_capacity.exists(hashes[2])
assert store_with_small_capacity.exists(new_hash)
def test_duplicate_store_at_capacity_does_not_evict(
self, store_with_small_capacity: CompressionStore
):
"""Re-storing an already-present hash at capacity overwrites in place and
must NOT evict an unrelated live entry (which would drop below capacity
and make that entry's marker unredeemable). The CCR mirror bridge
re-stores the same hash on later turns, so this is a common path."""
hashes = []
for i in range(3):
hashes.append(
store_with_small_capacity.store(
original=f"content_{i}", compressed=f"compressed_{i}"
)
)
time.sleep(0.01)
assert store_with_small_capacity.get_stats()["entry_count"] == 3
# Re-store the SAME content for the oldest entry (a duplicate -> same hash).
dup = store_with_small_capacity.store(original="content_0", compressed="compressed_0")
assert dup == hashes[0]
# No eviction happened: all three entries survive and count stays at 3.
for h in hashes:
assert store_with_small_capacity.exists(h)
assert store_with_small_capacity.get_stats()["entry_count"] == 3
def test_eviction_cleans_expired_first(self):
"""Eviction cleans expired entries before evicting valid ones."""
store = CompressionStore(max_entries=3, default_ttl=1)

View file

@ -141,6 +141,79 @@ def test_bash_tool_result_passthrough_when_protected() -> None:
assert "router:excluded:tool" in result.transforms_applied
# ---------------------------------------------------------------------------
# Test 4: protect_tool_results sentinel survives a profile-derived
# read_protection_window kwarg, even when the protected output is old
# ---------------------------------------------------------------------------
def test_protect_tool_results_survives_runtime_read_protection_window_kwarg() -> None:
"""A profile-derived `read_protection_window` kwarg (e.g. from
AgentSavingsProfile.protect_recent=2, threaded in via
proxy_pipeline_kwargs()) must not shrink protection below what
protect_recent_reads_fraction == 0.0 (the --protect-tool-results
sentinel) already guarantees for the whole conversation.
Regression test for the precedence bug: content_router.py used to apply
the runtime kwarg unconditionally, so a Bash tool_result more than
`read_protection_window` messages old fell through to lossy compression
even though --protect-tool-results promised it would never compress
"regardless of conversation depth" (see PR #1374)."""
pytest.importorskip("tiktoken") # needed for OpenAI tokenizer
from headroom.providers import OpenAIProvider
from headroom.tokenizer import Tokenizer
provider = OpenAIProvider()
token_counter = provider.get_token_counter("gpt-4o")
tokenizer = Tokenizer(token_counter, "gpt-4o")
proxy = _build(protect_tool_results=frozenset({"Bash", "bash"}), mode="token")
router = _router(proxy)
bash_output = "\n".join(
f"line {i}: some output from a bash command that is long enough to compress"
for i in range(80)
)
messages: list[dict[str, object]] = [
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_bash_1",
"type": "function",
"function": {"name": "Bash", "arguments": "{}"},
}
],
},
{
"role": "tool",
"tool_call_id": "call_bash_1",
"content": bash_output,
},
]
# Pad with enough intervening turns that the Bash tool_result above
# falls outside a read_protection_window=2 (it's ~9-10 messages from
# the end once padding is added).
for i in range(8):
messages.append({"role": "user", "content": f"follow-up turn {i}"})
messages.append({"role": "assistant", "content": f"reply {i}"})
# Simulate the profile-derived kwarg the proxy threads into every
# request via proxy_pipeline_kwargs() (AgentSavingsProfile("coding")
# sets protect_recent=2).
result = router.apply(messages, tokenizer, read_protection_window=2)
tool_msg = next(m for m in result.messages if m.get("tool_call_id") == "call_bash_1")
assert tool_msg["content"] == bash_output, (
"Bash tool_result must stay verbatim: protect_recent_reads_fraction == 0.0 "
"(set by --protect-tool-results) must not be weakened by a profile-derived "
"read_protection_window kwarg"
)
assert "router:excluded:tool" in result.transforms_applied
# ---------------------------------------------------------------------------
# Baseline: Bash NOT in DEFAULT_EXCLUDE_TOOLS (unchanged by this PR)
# ---------------------------------------------------------------------------

View file

@ -173,7 +173,16 @@ class TestHasNonTextParts:
@pytest.mark.parametrize(
"non_text_key",
["inlineData", "fileData", "functionCall", "functionResponse"],
[
"inlineData",
"fileData",
"functionCall",
"functionResponse",
# Gemini code-execution parts, echoed back in contents[] on later
# turns; previously not detected, so they were dropped on round-trip.
"executableCode",
"codeExecutionResult",
],
)
def test_each_non_text_key_detected(self, proxy, non_text_key):
"""Each non-text part type is correctly detected."""
@ -657,6 +666,30 @@ class TestRebuildGeminiContents:
assert result[0]["parts"][0]["text"] == "Hello, world!"
assert result[1]["parts"][0]["text"] == "Hello! How can I help you today?"
def test_code_execution_entry_survives(self, proxy):
"""A text-less code-execution entry (executableCode + codeExecutionResult)
between two text turns must survive the round-trip at its position, and
not shift a neighboring turn. Before the fix it was not detected as
non-text, so it was dropped and the following user turn was misplaced."""
code_entry = {
"role": "model",
"parts": [
{"executableCode": {"language": "PYTHON", "code": "x = 1"}},
{"codeExecutionResult": {"outcome": "OUTCOME_OK", "output": "1"}},
],
}
contents = [
{"role": "user", "parts": [{"text": "Question 1"}]},
code_entry,
{"role": "user", "parts": [{"text": "Question 2"}]},
]
result = self._round_trip(proxy, contents)
assert len(result) == 3
assert result[1] == code_entry # preserved verbatim, in place
assert result[2]["parts"][0]["text"] == "Question 2"
def test_function_call_sequence_preserved(self, proxy):
"""functionCall and functionResponse entries must survive and appear at correct positions."""
contents = [

View file

@ -16,7 +16,11 @@ from typing import Any
import pytest
from headroom.tokenizers import huggingface as hf_mod
from headroom.tokenizers.huggingface import HuggingFaceTokenizer, _load_tokenizer
from headroom.tokenizers.huggingface import (
HuggingFaceTokenizer,
_load_tokenizer,
get_tokenizer_name,
)
@pytest.fixture(autouse=True)
@ -112,3 +116,20 @@ def test_count_messages_fails_open_to_estimation(monkeypatch: pytest.MonkeyPatch
def test_invalid_timeout_env_falls_back_to_default(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("HEADROOM_HF_TOKENIZER_LOAD_TIMEOUT_SECS", "not-a-number")
assert hf_mod._load_timeout_secs() == hf_mod._LOAD_TIMEOUT_DEFAULT
def test_get_tokenizer_name_prefers_most_specific_prefix() -> None:
"""A more-specific family key must win over a shorter one.
Prefix matching used to scan MODEL_TO_TOKENIZER in dict-insertion order, so
the short "qwen" key preceded "qwen2"/"qwen2.5" and shadowed them
"qwen2-7b-instruct" resolved to the Qwen1 tokenizer (a different vocabulary,
hence wrong counts). The resolver now picks the longest matching prefix.
"""
# Versioned models not present as literal keys must hit the right family.
assert get_tokenizer_name("qwen2-7b-instruct") == "Qwen/Qwen2-7B"
assert get_tokenizer_name("qwen2.5-turbo") == "Qwen/Qwen2.5-7B"
assert get_tokenizer_name("deepseek-v2.5") == "deepseek-ai/DeepSeek-V2"
# Direct hits and the shorter family fallback still resolve as before.
assert get_tokenizer_name("qwen-14b") == "Qwen/Qwen-14B"
assert get_tokenizer_name("deepseek-chat") == "deepseek-ai/deepseek-llm-7b-base"

View file

@ -34,7 +34,7 @@ def test_build_manifest_for_persistent_docker_sets_expected_defaults() -> None:
proxy_mode="token",
memory_enabled=True,
telemetry_enabled=False,
image="ghcr.io/chopratejas/headroom:latest",
image="ghcr.io/headroomlabs-ai/headroom:latest",
)
assert manifest.supervisor_kind == "none"
@ -63,7 +63,7 @@ def test_build_manifest_uses_provider_slice_env_builders_for_all_supported_targe
proxy_mode="token",
memory_enabled=False,
telemetry_enabled=True,
image="ghcr.io/chopratejas/headroom:latest",
image="ghcr.io/headroomlabs-ai/headroom:latest",
)
# telemetry_enabled=True must write the explicit opt-in value + flag.
@ -122,7 +122,7 @@ def test_build_manifest_omits_no_http2_by_default() -> None:
proxy_mode="token",
memory_enabled=False,
telemetry_enabled=True,
image="ghcr.io/chopratejas/headroom:latest",
image="ghcr.io/headroomlabs-ai/headroom:latest",
)
assert "--no-http2" not in manifest.proxy_args
@ -143,7 +143,7 @@ def test_build_manifest_persists_no_http2_override() -> None:
proxy_mode="token",
memory_enabled=False,
telemetry_enabled=True,
image="ghcr.io/chopratejas/headroom:latest",
image="ghcr.io/headroomlabs-ai/headroom:latest",
no_http2=True,
)

View file

@ -44,7 +44,7 @@ def test_build_runtime_command_for_docker_includes_deployment_env(
port=8787,
host="127.0.0.1",
backend="anthropic",
image="ghcr.io/chopratejas/headroom:latest",
image="ghcr.io/headroomlabs-ai/headroom:latest",
base_env={"HEADROOM_PORT": "8787"},
proxy_args=["--host", "127.0.0.1", "--port", "8787"],
)
@ -56,13 +56,54 @@ def test_build_runtime_command_for_docker_includes_deployment_env(
assert "HEADROOM_DEPLOYMENT_PROFILE=default" in joined
assert "HEADROOM_DEPLOYMENT_PRESET=persistent-docker" in joined
assert "127.0.0.1:8787:8787" in joined
assert "ghcr.io/chopratejas/headroom:latest" in command
assert "ghcr.io/headroomlabs-ai/headroom:latest" in command
# Canonical Headroom filesystem contract (issue #175) forwarded into
# the container.
assert "HEADROOM_WORKSPACE_DIR=/tmp/headroom-home/.headroom" in command
assert "HEADROOM_CONFIG_DIR=/tmp/headroom-home/.headroom/config" in command
def test_build_runtime_command_docker_manifest_env_beats_host_passthrough(
monkeypatch, tmp_path: Path
) -> None:
"""A manifest value must win over a conflicting host export.
The manifest pins ``HEADROOM_BACKEND=anthropic``; the host process has a
stale ``HEADROOM_BACKEND=anyllm`` exported. Both start with the
``HEADROOM_`` passthrough prefix, so without the dedupe the command emits
``--env HEADROOM_BACKEND=anthropic`` and then a bare ``--env
HEADROOM_BACKEND``. Docker resolves duplicate ``--env`` last-wins, so the
bare passthrough reads the host value and silently overrides the manifest,
diverging the container from its deployment config.
"""
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HEADROOM_BACKEND", "anyllm")
manifest = DeploymentManifest(
profile="default",
preset="persistent-docker",
runtime_kind="docker",
supervisor_kind="none",
scope="user",
provider_mode="manual",
targets=["claude"],
port=8787,
host="127.0.0.1",
backend="anthropic",
image="ghcr.io/chopratejas/headroom:latest",
base_env={"HEADROOM_PORT": "8787", "HEADROOM_BACKEND": "anthropic"},
proxy_args=["--host", "127.0.0.1", "--port", "8787"],
)
command = build_runtime_command(manifest)
# The manifest value is emitted...
assert "HEADROOM_BACKEND=anthropic" in command
# ...and no bare `--env HEADROOM_BACKEND` follows it to pull in the host
# value. `in` on the list matches the exact token, so the pinned
# `HEADROOM_BACKEND=anthropic` element does not count here.
assert "HEADROOM_BACKEND" not in command
def test_build_runtime_command_for_docker_matches_wrapper_parity(
monkeypatch, tmp_path: Path
) -> None:
@ -80,7 +121,7 @@ def test_build_runtime_command_for_docker_matches_wrapper_parity(
port=8787,
host="127.0.0.1",
backend="anthropic",
image="ghcr.io/chopratejas/headroom:latest",
image="ghcr.io/headroomlabs-ai/headroom:latest",
base_env={"HEADROOM_PORT": "8787"},
proxy_args=["--host", "127.0.0.1", "--port", "8787"],
)
@ -116,7 +157,7 @@ def test_build_runtime_command_for_docker_does_not_duplicate_entrypoint(
port=8787,
host="127.0.0.1",
backend="anthropic",
image="ghcr.io/chopratejas/headroom:latest",
image="ghcr.io/headroomlabs-ai/headroom:latest",
base_env={"HEADROOM_PORT": "8787"},
proxy_args=["--host", "127.0.0.1", "--port", "8787", "--backend", "anthropic"],
)
@ -225,7 +266,7 @@ def test_build_runtime_command_python_and_docker_user(monkeypatch, tmp_path: Pat
port=8787,
host="127.0.0.1",
backend="anthropic",
image="ghcr.io/chopratejas/headroom:latest",
image="ghcr.io/headroomlabs-ai/headroom:latest",
base_env={"HEADROOM_PORT": "8787"},
proxy_args=["--host", "127.0.0.1", "--port", "8787"],
)

View file

@ -22,4 +22,7 @@ def test_remote_control_gate_message_mentions_warning_and_source() -> None:
message = remote_control_gate_message(source=REMOTE_CONTROL_BASE_URL_ENV)
assert "Remote Control" in message
assert REMOTE_CONTROL_BASE_URL_ENV in message
assert "launch Claude without Headroom for sessions that need this feature" in message
# Issue #1779: the wording must be accurate — name the /rc command and tell
# the user how to regain it, without the old hedged "may hide the menu".
assert "/rc" in message
assert "run Claude without Headroom for sessions that need Remote Control" in message

View file

@ -0,0 +1,248 @@
"""Issue #1779: Remote Control is *silently* disabled behind the proxy.
Claude Code v2.1.196 added a client-side eligibility check that deterministically
disables first-party Remote Control (`/remote-control` / `/rc`) whenever
`ANTHROPIC_BASE_URL` points at a non-`api.anthropic.com` host which Headroom
always does. The gate is upstream, so Headroom's fix is an *accurate* warning
that:
* states the disable as a fact on v2.1.196+ (never the old hedged "may"),
* fires only for subscription sessions that ever had RC (never API-key / cloud),
* fires only when the installed version is at/after the gate, or unknown,
* co-reports the sibling base-URL gates #746 and #1158.
"""
from __future__ import annotations
import pytest
from headroom.providers.claude.runtime import (
REMOTE_CONTROL_GATED_MIN_VERSION,
REMOTE_CONTROL_SIBLING_GATE_NOTE,
detect_claude_code_version,
is_custom_anthropic_base_url,
parse_claude_code_version,
remote_control_applies_to_auth,
remote_control_gate_active,
remote_control_gate_message,
remote_control_sibling_gate_note,
)
_CUSTOM = "http://127.0.0.1:8787"
_NATIVE = "https://api.anthropic.com"
_GATED = REMOTE_CONTROL_GATED_MIN_VERSION # (2, 1, 196)
_OLD = (2, 1, 195)
# ---------------------------------------------------------------------------
# Message accuracy — deterministic wording, not "may"
# ---------------------------------------------------------------------------
def test_message_is_accurate_not_hedged() -> None:
msg = remote_control_gate_message("ANTHROPIC_BASE_URL in shell", version=_GATED)
# Deterministic: names the exact version and says it "disables" /rc.
assert "2.1.196" in msg
assert "disables" in msg
assert "/remote-control (/rc)" in msg
# The old hedged phrasing is gone.
assert "may hide" not in msg
assert "run Claude without Headroom for sessions that need Remote Control" in msg
def test_message_unknown_version_states_threshold() -> None:
msg = remote_control_gate_message("ANTHROPIC_BASE_URL in shell", version=None)
# Without a detected version we state the threshold and let the user
# self-identify — no false claim about their specific build.
assert "2.1.196+" in msg
assert "/rc" in msg
assert "may hide" not in msg
# ---------------------------------------------------------------------------
# Auth gating — never warn a PAYG / cloud user (RC was never theirs)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"env_key",
[
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"CLAUDE_CODE_USE_BEDROCK",
"CLAUDE_CODE_USE_VERTEX",
"CLAUDE_CODE_USE_FOUNDRY",
],
)
def test_non_subscription_auth_never_applies(env_key: str) -> None:
assert remote_control_applies_to_auth({env_key: "something"}) is False
# And therefore the whole gate is inactive even on a gated version / custom URL.
assert remote_control_gate_active(_CUSTOM, {env_key: "something"}, _GATED) is False
def test_subscription_auth_applies() -> None:
assert remote_control_applies_to_auth({}) is True
assert remote_control_applies_to_auth({"PATH": "/usr/bin"}) is True
def test_blank_api_key_is_not_treated_as_payg() -> None:
# An empty / whitespace value is "unset" — a subscription session.
assert remote_control_applies_to_auth({"ANTHROPIC_API_KEY": " "}) is True
assert remote_control_gate_active(_CUSTOM, {"ANTHROPIC_API_KEY": ""}, _GATED) is True
# ---------------------------------------------------------------------------
# Version gating — no false alarm on pre-2.1.196 builds
# ---------------------------------------------------------------------------
def test_gate_active_on_gated_version() -> None:
assert remote_control_gate_active(_CUSTOM, {}, _GATED) is True
assert remote_control_gate_active(_CUSTOM, {}, (2, 2, 0)) is True
def test_gate_inactive_on_pre_gate_version() -> None:
# Older Claude Code does not gate RC on the base URL — warning would be false.
assert remote_control_gate_active(_CUSTOM, {}, _OLD) is False
assert remote_control_gate_active(_CUSTOM, {}, (1, 0, 0)) is False
def test_gate_active_when_version_unknown() -> None:
# Unknown version → warn conservatively (the message self-qualifies).
assert remote_control_gate_active(_CUSTOM, {}, None) is True
def test_gate_inactive_on_native_base_url() -> None:
assert remote_control_gate_active(_NATIVE, {}, _GATED) is False
assert remote_control_gate_active(None, {}, _GATED) is False
# ---------------------------------------------------------------------------
# Version parsing
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"text,expected",
[
("2.1.196 (Claude Code)", (2, 1, 196)),
("claude-code/2.1.200", (2, 1, 200)),
("v2.0.0", (2, 0, 0)),
(" 2.1.196\n", (2, 1, 196)),
("no version here", None),
("", None),
(None, None),
],
)
def test_parse_claude_code_version(text, expected) -> None:
assert parse_claude_code_version(text) == expected
def test_detect_claude_code_version_missing_binary_is_none() -> None:
# A binary that does not exist must never raise — best-effort → None.
assert detect_claude_code_version("definitely-not-a-real-binary-xyz") is None
def test_detect_claude_code_version_tolerates_proc_without_stdout(monkeypatch) -> None:
# Regression (CI test failure on PR #1779): a stubbed subprocess result — a
# SimpleNamespace with only returncode, no stdout/stderr — must not raise
# AttributeError. detect is best-effort → returns None (version unknown).
from types import SimpleNamespace
import headroom._subprocess as _sub
monkeypatch.setattr(_sub, "run", lambda *a, **k: SimpleNamespace(returncode=0))
assert detect_claude_code_version("claude") is None
def test_detect_claude_code_version_parses_wrapper_output(monkeypatch) -> None:
from types import SimpleNamespace
import headroom._subprocess as _sub
monkeypatch.setattr(
_sub,
"run",
lambda *a, **k: SimpleNamespace(returncode=0, stdout="2.1.196 (Claude Code)\n", stderr=""),
)
assert detect_claude_code_version("claude") == (2, 1, 196)
# ---------------------------------------------------------------------------
# Sibling co-report (#746 / #1158)
# ---------------------------------------------------------------------------
def test_sibling_gate_note_co_reports_746_and_1158() -> None:
assert "#746" in REMOTE_CONTROL_SIBLING_GATE_NOTE
assert "#1158" in REMOTE_CONTROL_SIBLING_GATE_NOTE
assert "--1m" in REMOTE_CONTROL_SIBLING_GATE_NOTE
def test_sibling_note_defaults_claim_active_and_advise_1m() -> None:
note = remote_control_sibling_gate_note(tool_search_active=True, context_1m_enabled=False)
assert "#746" in note and "#1158" in note
assert "keeps it on for this session" in note
assert "restore with `headroom wrap claude --1m`" in note
def test_sibling_note_does_not_claim_disabled_tool_search_is_on() -> None:
# Accuracy under opt-outs: --tool-search false means deferral is OFF — the
# note must say so, not repeat the default "keeps it on" claim.
note = remote_control_sibling_gate_note(tool_search_active=False, context_1m_enabled=False)
assert "OFF for this session" in note
assert "keeps it on" not in note
def test_sibling_note_does_not_advise_1m_already_passed() -> None:
# Accuracy under opt-ins: with --1m in effect, don't advise adding it.
note = remote_control_sibling_gate_note(tool_search_active=True, context_1m_enabled=True)
assert "already restored via --1m" in note
assert "restore with `headroom wrap claude --1m`" not in note
# ---------------------------------------------------------------------------
# is_custom_anthropic_base_url — string/host edges (Stage-4 matrix)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"value,expected",
[
# Native host in every spelling: scheme, http-vs-https, trailing slash,
# port, case, and scheme-less — all NOT custom (host-equality only).
("https://api.anthropic.com", False),
("http://api.anthropic.com", False),
("https://api.anthropic.com/", False),
("https://api.anthropic.com:8443", False),
("https://API.ANTHROPIC.COM", False),
("API.ANTHROPIC.COM", False),
("api.anthropic.com:443", False),
# Lookalike suffix must NOT pass — exact host match, no endswith.
("https://api.anthropic.com.evil.com", True),
# Custom hosts, with and without scheme (scheme-less used to be a
# silent false-negative: urlparse read the host as a path/scheme).
("http://127.0.0.1:8787", True),
("127.0.0.1:8787", True),
("myproxy.local:8080", True),
("evil.com", True),
("https://gateway.internal.example", True),
# Valid IPv6 loopback literal — a real custom host.
("http://[::1]:8787", True),
# Unset / blank — not custom (nothing overrides the default endpoint).
("", False),
(" ", False),
(None, False),
# Malformed values must degrade to "no host -> not custom", never
# raise: urlparse throws ValueError("Invalid IPv6 URL") on stray
# brackets, and these strings are user-editable (settings.json /
# shell). The routing check flags unusable URLs separately.
("http://[", False),
("[", False),
("http://[::1:8787", False),
("http://:8080", False),
("http://", False),
],
)
def test_is_custom_anthropic_base_url_host_edges(value, expected) -> None:
assert is_custom_anthropic_base_url(value) is expected

View file

@ -1,11 +1,7 @@
"""Startup eager-preload must be cache-only so a cold cache cannot block or
crash the proxy before it binds its port.
"""Startup eager-preload must defer Kompress native loading before binding.
Regression for the production crash where ``eager_load_compressors`` ran a
network ``hf_hub_download`` of the Kompress ONNX model on the blocking
startup/lifespan path. On a cold cache that download could hang (300s bind
timeout) or hit a native ``SIGABRT`` in the download/ML stack, killing the
interpreter before it ever listened on its port.
Regression for the production crash where ``eager_load_compressors`` entered
the cached Kompress native stack on the blocking startup/lifespan path.
"""
from __future__ import annotations
@ -110,6 +106,12 @@ class _StubCompressor:
raise KompressModelNotCached("org/model")
class _FatalPreloadCompressor(_StubCompressor):
def preload(self, *, allow_download: bool = True) -> str:
self.preload_calls.append(allow_download)
raise SystemExit("native Kompress preload")
def _router_kompress_only() -> ContentRouter:
return ContentRouter(
ContentRouterConfig(
@ -120,24 +122,81 @@ def _router_kompress_only() -> ContentRouter:
)
def test_eager_load_defers_when_model_not_cached(monkeypatch):
@pytest.mark.parametrize("cache_state", ["cached", "uncached"])
def test_eager_load_defers_kompress_regardless_of_cache_state(monkeypatch, cache_state):
router = _router_kompress_only()
stub = _StubCompressor(cached=False)
stub = _StubCompressor(cached=cache_state == "cached")
monkeypatch.setattr(router, "_get_kompress", lambda: stub)
status = router.eager_load_compressors()
assert status["kompress"] == "deferred"
assert stub.preload_calls == [False] # cache-only preload at startup
assert stub.preload_calls == []
def test_eager_load_enabled_when_model_cached(monkeypatch):
router = _router_kompress_only()
def test_eager_load_keeps_disabled_kompress_disabled(monkeypatch):
router = ContentRouter(
ContentRouterConfig(
enable_kompress=False,
enable_code_aware=False,
enable_smart_crusher=False,
)
)
stub = _StubCompressor(cached=True)
monkeypatch.setattr(router, "_get_kompress", lambda: stub)
status = router.eager_load_compressors()
assert status["kompress"] == "enabled"
assert status["kompress_backend"] == "onnx"
assert stub.preload_calls == [False]
assert "kompress" not in status
assert stub.preload_calls == []
def test_eager_load_reports_unavailable_kompress(monkeypatch):
router = _router_kompress_only()
monkeypatch.setattr(router, "_get_kompress", lambda: None)
status = router.eager_load_compressors()
assert status["kompress"] == "unavailable"
def test_non_kompress_warmups_continue_when_kompress_is_deferred(monkeypatch):
router = _router_kompress_only()
stub = _StubCompressor(cached=True)
monkeypatch.setattr(router, "_get_kompress", lambda: stub)
monkeypatch.setattr("headroom.compression.detector._magika_available", lambda: True)
monkeypatch.setattr("headroom.compression.detector._get_magika", lambda: object())
status = router.eager_load_compressors()
assert status["kompress"] == "deferred"
assert status["magika"] == "enabled"
assert stub.preload_calls == []
@pytest.mark.asyncio
async def test_proxy_startup_does_not_enter_cached_kompress_native_loader(monkeypatch):
pytest.importorskip("httpx")
from headroom.proxy.server import HeadroomProxy, ProxyConfig
proxy = HeadroomProxy(
ProxyConfig(
optimize=True,
cache_enabled=False,
rate_limit_enabled=False,
cost_tracking_enabled=False,
code_aware_enabled=False,
)
)
router = _router_kompress_only()
stub = _FatalPreloadCompressor(cached=True)
monkeypatch.setattr(router, "_get_kompress", lambda: stub)
proxy.anthropic_pipeline.transforms = [router]
proxy.openai_pipeline.transforms = [router]
await proxy.startup()
try:
assert stub.preload_calls == []
assert proxy.warmup.kompress.info["source_status"] == "deferred"
finally:
await proxy.shutdown()

View file

@ -0,0 +1,33 @@
"""Error classification ordering: specific categories must not be shadowed by
the generic RUNTIME_ERROR catch-all."""
from __future__ import annotations
from headroom.learn._shared import classify_error
from headroom.learn.models import ErrorCategory
def test_timeout_repr_is_not_shadowed_by_runtime_error() -> None:
# "TimeoutError: ..." contains "Error:", which the generic RUNTIME_ERROR
# pattern also matches; TIMEOUT must still win.
assert classify_error("TimeoutError: timed out after 30s") == ErrorCategory.TIMEOUT
assert classify_error("operation timed out") == ErrorCategory.TIMEOUT
def test_connection_repr_is_not_shadowed_by_runtime_error() -> None:
assert (
classify_error("ConnectionError: [Errno 111] Connection refused")
== ErrorCategory.CONNECTION_ERROR
)
assert classify_error("ECONNREFUSED") == ErrorCategory.CONNECTION_ERROR
def test_generic_error_still_classifies_as_runtime() -> None:
# A plain exception repr with no more-specific token stays RUNTIME_ERROR
# (matches the opencode scanner's expectation).
assert classify_error("Error: command failed with exit code 1") == ErrorCategory.RUNTIME_ERROR
assert classify_error("Traceback (most recent call last):") == ErrorCategory.RUNTIME_ERROR
def test_non_error_text_is_unknown() -> None:
assert classify_error("all good, tests passed") == ErrorCategory.UNKNOWN

View file

@ -144,6 +144,50 @@ def test_get_server_robust_to_unparseable_toml(tmp_path: Path) -> None:
assert _make_registrar(tmp_path).get_server("headroom") is None
def test_register_refuses_unparseable_config(tmp_path: Path) -> None:
"""An unparseable config.toml must not be appended to (that would corrupt it
further); refuse and leave it byte-for-byte untouched."""
cfg = _config_path(tmp_path)
cfg.parent.mkdir()
original = "this = is = not = valid\n"
cfg.write_text(original)
result = _make_registrar(tmp_path).register_server(_spec())
assert result.status == RegisterStatus.FAILED
assert "not valid TOML" in result.detail
assert cfg.read_text() == original
def test_register_refuses_non_table_mcp_servers_entry(tmp_path: Path) -> None:
"""A valid config whose mcp_servers.headroom is a non-table must not get a
duplicate `[mcp_servers.headroom]` table appended (which tomllib rejects)."""
cfg = _config_path(tmp_path)
cfg.parent.mkdir()
original = '[mcp_servers]\nheadroom = "not-a-table"\n'
cfg.write_text(original)
result = _make_registrar(tmp_path).register_server(_spec())
assert result.status == RegisterStatus.FAILED
assert "non-table" in result.detail
# Untouched — still the original single (string) definition.
assert cfg.read_text() == original
def test_register_refuses_non_table_mcp_servers(tmp_path: Path) -> None:
"""A non-table top-level mcp_servers is also refused, not clobbered."""
cfg = _config_path(tmp_path)
cfg.parent.mkdir()
original = 'mcp_servers = "oops"\n'
cfg.write_text(original)
result = _make_registrar(tmp_path).register_server(_spec())
assert result.status == RegisterStatus.FAILED
assert cfg.read_text() == original
# ----------------------------------------------------------------------
# register_server() — happy paths
# ----------------------------------------------------------------------

View file

@ -0,0 +1,52 @@
"""The embedder cache must not serve an embedder bound to the wrong server.
Kept out of ``test_factory.py`` (which skips wholesale without hnswlib) because
these cases only construct the lightweight Ollama embedder and need no vector
index.
"""
from __future__ import annotations
from headroom.memory.config import EmbedderBackend, MemoryConfig
from headroom.memory.factory import _create_embedder, _reset_embedder_cache_for_tests
def test_ollama_embedder_cache_keys_on_base_url():
"""Two configs that share backend + model but differ in ollama_base_url must
not share a cached embedder the second would otherwise get an embedder
bound to the first server."""
_reset_embedder_cache_for_tests()
try:
cfg1 = MemoryConfig(
embedder_backend=EmbedderBackend.OLLAMA,
embedder_model="nomic-embed-text",
ollama_base_url="http://gpu1:11434",
)
cfg2 = MemoryConfig(
embedder_backend=EmbedderBackend.OLLAMA,
embedder_model="nomic-embed-text",
ollama_base_url="http://gpu2:11434",
)
e1 = _create_embedder(cfg1)
e2 = _create_embedder(cfg2)
assert e1 is not e2
assert e1._base_url == "http://gpu1:11434"
assert e2._base_url == "http://gpu2:11434"
finally:
_reset_embedder_cache_for_tests()
def test_ollama_embedder_cache_reuses_same_base_url():
"""Same backend + model + base_url still hits the cache (one model load)."""
_reset_embedder_cache_for_tests()
try:
cfg = MemoryConfig(
embedder_backend=EmbedderBackend.OLLAMA,
embedder_model="nomic-embed-text",
ollama_base_url="http://gpu1:11434",
)
assert _create_embedder(cfg) is _create_embedder(cfg)
finally:
_reset_embedder_cache_for_tests()

View file

@ -240,6 +240,20 @@ class TestSQLiteMemoryStore:
results = await store.query(MemoryFilter(user_id="bob"))
assert len(results) == 3
@pytest.mark.asyncio
async def test_query_offset_without_limit(self, store):
"""A MemoryFilter with an offset but no limit must not emit `OFFSET`
without a `LIMIT` (a SQLite syntax error) it should skip `offset` rows
and return the rest."""
await store.save_batch([Memory(content=f"Alice {i}", user_id="alice") for i in range(5)])
# Before the fix this raised sqlite3.OperationalError: near "OFFSET".
results = await store.query(MemoryFilter(user_id="alice", offset=2))
assert len(results) == 3
# offset past the end returns nothing (still no crash).
assert await store.query(MemoryFilter(user_id="alice", offset=10)) == []
@pytest.mark.asyncio
async def test_query_by_importance_range(self, store):
"""Test querying memories by importance range."""

View file

@ -54,3 +54,35 @@ def test_extract_sources_handles_anthropic_tool_result_without_user_text() -> No
assert user_text == "real user"
assert tool_outputs == ("nested",)
assert assistant_turns == ()
def test_extract_sources_captures_anthropic_user_text_blocks() -> None:
"""Anthropic user turns carry the prompt as text blocks (the standard Claude
Code shape). The user's question must be captured — not dropped — so memory
retrieval keys on it."""
messages = [
{"role": "user", "content": [{"type": "text", "text": "help me refactor auth"}]},
]
user_text, _tool_outputs, _assistant_turns = extract_memory_query_sources(messages)
assert user_text == "help me refactor auth"
def test_extract_sources_captures_user_text_alongside_tool_result() -> None:
"""A user turn mixing a tool_result and a text block yields both: the text as
the user query and the tool output as context."""
messages = [
{
"role": "user",
"content": [
{"type": "tool_result", "content": "exit 0"},
{"type": "text", "text": "did the tests pass?"},
],
},
]
user_text, tool_outputs, _assistant_turns = extract_memory_query_sources(messages)
assert user_text == "did the tests pass?"
assert tool_outputs == ("exit 0",)

View file

@ -176,6 +176,46 @@ def test_openai_responses_adapter_compresses_custom_tool_call_output():
assert strategy_chain == []
def test_openai_responses_adapter_compresses_output_content_parts():
router = ContentRouter()
def compress(self, content: str, **_kwargs):
return RouterCompressionResult(
compressed="content part output summary",
original=content,
strategy_used=CompressionStrategy.KOMPRESS,
)
router.compress = MethodType(compress, router)
handler = _handler_with_router(router)
long_text = " ".join(f"part{i}" for i in range(180))
payload = {
"model": "gpt-5",
"input": [
{
"type": "function_call_output",
"call_id": "c1",
"output": [{"type": "output_text", "text": long_text}],
}
],
}
new_payload, modified, saved, transforms, units_by_category, strategy_chain, _attempted = (
handler._compress_openai_responses_live_text_units_with_router(
payload,
model="gpt-5",
request_id="req_content_parts",
)
)
assert modified is True
assert saved > 0
assert new_payload["input"][0]["output"] == "content part output summary"
assert "router:openai:responses:function_call_output:kompress" in transforms
assert units_by_category == {"applied": 1}
assert strategy_chain == []
def test_openai_responses_adapter_reuses_exact_tool_output_cache():
router = ContentRouter()
calls = {"count": 0}
@ -490,6 +530,45 @@ def test_openai_responses_adapter_losslessly_folds_excluded_grep_output():
assert search_unheading(folded) == grep_out # byte-exact recovery
def test_openai_responses_adapter_losslessly_folds_excluded_output_content_parts():
from headroom.transforms.lossless_compaction import search_unheading
router = ContentRouter()
router.config.exclude_tools = {"grep"}
handler = _handler_with_router(router)
grep_out = "".join(
f"src/part_{f}.py:{ln}:matching content in a content part\n"
for f in range(8)
for ln in range(6)
)
payload = {
"model": "gpt-5",
"input": [
{"type": "function_call", "call_id": "call_1", "name": "grep", "arguments": "{}"},
{
"type": "function_call_output",
"call_id": "call_1",
"output": [{"type": "output_text", "text": grep_out}],
},
],
}
new_payload, modified, saved, transforms, _units, _chain, _attempted = (
handler._compress_openai_responses_live_text_units_with_router(
payload,
model="gpt-5",
request_id="req_content_part_fold",
)
)
assert modified is True
assert saved >= 0
assert "router:excluded:lossless" in transforms
folded = new_payload["input"][1]["output"]
assert len(folded) < len(grep_out)
assert search_unheading(folded) == grep_out
def test_openai_responses_adapter_excludes_tool_case_insensitively_with_debug(monkeypatch):
"""Excluded match is case-insensitive, and the debug path stays exercised.
@ -670,3 +749,104 @@ def test_openai_responses_payload_routes_through_content_router_without_rust(
assert reason is None
assert new_payload["input"][0]["output"] == "compressed fallback"
assert any(t.startswith("router:openai:responses:") for t in transforms)
def test_openai_responses_adapter_aggregates_small_tool_outputs_before_floor():
"""Regression for #2050: many individually-small tool outputs whose combined
size clears the floor must still reach the router.
The Responses path extracts each ``function_call_output`` as its own unit.
A per-item size floor would reject every unit in a session made of many
small tool outputs (the Codex shape), yielding 0% savings even though the
aggregate compressible text is large. The floor must be evaluated against
the aggregate of the extracted group, matching the batch (Anthropic) path.
"""
router = ContentRouter()
def compress(self, content: str, **_kwargs):
return RouterCompressionResult(
compressed="tiny summary",
original=content,
strategy_used=CompressionStrategy.KOMPRESS,
)
router.compress = MethodType(compress, router)
handler = _handler_with_router(router)
# Each output is individually below OPENAI_RESPONSES_ROUTER_MIN_BYTES (512),
# but the four combined exceed it — exactly the case that used to floor to
# zero savings. Guard the premise so the test stays honest if the byte
# shapes drift.
floor = OpenAIHandlerMixin.OPENAI_RESPONSES_ROUTER_MIN_BYTES
outputs = [" ".join(f"tok{i}_{j}" for j in range(30)) for i in range(4)]
assert all(len(o.encode("utf-8")) < floor for o in outputs)
assert sum(len(o.encode("utf-8")) for o in outputs) >= floor
payload = {
"model": "gpt-5",
"input": [
{
"type": "function_call_output",
"call_id": f"c{i}",
"output": output,
}
for i, output in enumerate(outputs)
],
}
new_payload, modified, saved, transforms, units_by_category, _strategy_chain, _attempted = (
handler._compress_openai_responses_live_text_units_with_router(
payload,
model="gpt-5",
request_id="req_aggregate_floor",
)
)
assert modified is True
assert saved > 0
# No unit should be size-floored; every extracted unit is compressed.
assert "size_floor" not in units_by_category
assert units_by_category == {"applied": len(outputs)}
assert all(item["output"] == "tiny summary" for item in new_payload["input"])
def test_openai_responses_adapter_floors_when_aggregate_below_threshold():
"""Complement to #2050: when the *whole* group is below the floor the units
are still skipped, so trivially small payloads don't churn the router.
"""
router = ContentRouter()
def compress(self, content: str, **_kwargs): # pragma: no cover - must not run
raise AssertionError("aggregate below floor should skip compression")
router.compress = MethodType(compress, router)
handler = _handler_with_router(router)
floor = OpenAIHandlerMixin.OPENAI_RESPONSES_ROUTER_MIN_BYTES
outputs = ["ok", "done"]
assert sum(len(o.encode("utf-8")) for o in outputs) < floor
payload = {
"model": "gpt-5",
"input": [
{
"type": "function_call_output",
"call_id": f"c{i}",
"output": output,
}
for i, output in enumerate(outputs)
],
}
new_payload, modified, saved, _transforms, units_by_category, _strategy_chain, _attempted = (
handler._compress_openai_responses_live_text_units_with_router(
payload,
model="gpt-5",
request_id="req_aggregate_below",
)
)
assert modified is False
assert saved == 0
assert units_by_category == {"size_floor": len(outputs)}
assert new_payload == payload

View file

@ -6,8 +6,8 @@ from typing import Any
from headroom.proxy.handlers.openai import (
OpenAIHandlerMixin,
_compact_openai_responses_tools,
_ensure_responses_store_for_memory_tools,
_openai_responses_context_budget,
_responses_request_allows_memory_tool_continuation,
)
from headroom.transforms.content_router import (
CompressionStrategy,
@ -438,48 +438,25 @@ def test_content_router_retries_kompress_when_structured_strategy_noops(monkeypa
assert strategy_chain == ["smart_crusher", "kompress"]
def test_responses_memory_tools_store_false_regression() -> None:
"""Regression: store=false makes previous_response_id continuations fail."""
def test_responses_memory_tools_skip_explicit_store_false() -> None:
"""Regression: explicit store=false must block Responses memory-tool injection."""
payload = {"model": "gpt-5.5", "input": "remember this", "store": False}
changed = _ensure_responses_store_for_memory_tools(
payload,
memory_tools_injected=True,
)
assert changed is True
assert payload["store"] is True
assert _responses_request_allows_memory_tool_continuation(payload) is False
assert payload["store"] is False
def test_responses_memory_tools_do_not_change_unrelated_requests() -> None:
def test_responses_memory_tools_allow_default_and_stored_requests() -> None:
no_memory_payload = {"model": "gpt-5.5", "input": "plain", "store": False}
already_stored_payload = {"model": "gpt-5.5", "input": "plain", "store": True}
default_store_payload = {"model": "gpt-5.5", "input": "plain"}
assert (
_ensure_responses_store_for_memory_tools(
no_memory_payload,
memory_tools_injected=False,
)
is False
)
assert _responses_request_allows_memory_tool_continuation(no_memory_payload) is False
assert no_memory_payload["store"] is False
assert (
_ensure_responses_store_for_memory_tools(
already_stored_payload,
memory_tools_injected=True,
)
is False
)
assert _responses_request_allows_memory_tool_continuation(already_stored_payload) is True
assert already_stored_payload["store"] is True
assert (
_ensure_responses_store_for_memory_tools(
default_store_payload,
memory_tools_injected=True,
)
is False
)
assert _responses_request_allows_memory_tool_continuation(default_store_payload) is True
assert "store" not in default_store_payload

View file

@ -22,6 +22,8 @@ SYS_PLATFORM_MARKER = "sys_platform"
PLATFORM_MACHINE_MARKER = "platform_machine"
TORCH_PACKAGE_NAME = "torch"
TORCH_TRANSITIVE_PACKAGE_NAMES = frozenset({"sentence-transformers"})
ORJSON_PACKAGE_NAME = "orjson"
PROXY_EXTRA = "proxy"
UV_LOCK_FILE = "uv.lock"
@ -103,3 +105,25 @@ def test_all_extra_does_not_require_torch_on_macos_x86_64() -> None:
assert locked_torch_transitive_dependency_names
assert TORCH_PACKAGE_NAME not in selected_all_dependency_names
assert selected_all_dependency_names.isdisjoint(locked_torch_transitive_dependency_names)
def test_proxy_extra_includes_orjson_for_litellm_backends() -> None:
"""`headroom-ai[all]` must ship orjson for LiteLLM provider backends (GH #2056)."""
pyproject = tomllib.loads((ROOT / PYPROJECT_FILE).read_text(encoding="utf-8"))
optional_deps = pyproject["project"]["optional-dependencies"]
environment = default_environment()
selected_proxy_dependency_names = _selected_dependency_names_for_extra(
optional_deps,
PROXY_EXTRA,
environment,
)
selected_all_dependency_names = _selected_dependency_names_for_extra(
optional_deps,
ALL_EXTRA,
environment,
)
assert ORJSON_PACKAGE_NAME in selected_proxy_dependency_names
assert ORJSON_PACKAGE_NAME in selected_all_dependency_names

View file

@ -0,0 +1,102 @@
"""Output-only content blocks must be stripped from request messages.
Anthropic's server-side refusal-fallback feature emits an output-only
``{"type": "fallback", ...}`` block inside an assistant response. It is valid on
the response path but rejected on the request path, so replaying that assistant
turn 400s the whole request. The shared body readers must drop it before
forwarding. See ``strip_output_only_request_blocks`` in ``headroom.proxy.helpers``.
"""
import asyncio
import json
from headroom.proxy.helpers import (
read_request_json_with_bytes,
strip_output_only_request_blocks,
)
_FALLBACK = {
"type": "fallback",
"from": {"model": "claude-fable-5"},
"to": {"model": "claude-opus-4-8"},
}
class _FakeHeaders:
def __init__(self, d=None):
self._d = {k.lower(): v for k, v in (d or {}).items()}
def get(self, k, default=None):
return self._d.get(k.lower(), default)
class _FakeRequest:
def __init__(self, raw, headers=None):
self._raw = raw
self.headers = _FakeHeaders(headers)
async def body(self):
return self._raw
def _has_fallback(messages):
for msg in messages:
content = msg.get("content")
if isinstance(content, list):
for block in content:
if isinstance(block, dict) and block.get("type") == "fallback":
return True
return False
def test_strip_removes_fallback_and_backfills_emptied_turn():
messages = [
{"role": "user", "content": "hi"},
# assistant turn that is ONLY a fallback signal (the crash case)
{"role": "assistant", "content": [dict(_FALLBACK)]},
# fallback prefix + real content
{"role": "assistant", "content": [dict(_FALLBACK), {"type": "text", "text": "A."}]},
]
assert strip_output_only_request_blocks(messages) is True
assert not _has_fallback(messages)
# emptied turn is backfilled with a single benign text block
assert messages[1]["content"] == [{"type": "text", "text": "(model fallback)"}]
# mixed turn keeps only the real content
assert [b["type"] for b in messages[2]["content"]] == ["text"]
# idempotent
assert strip_output_only_request_blocks(messages) is False
def test_strip_is_noop_on_clean_or_invalid_input():
assert strip_output_only_request_blocks(None) is False
assert strip_output_only_request_blocks([{"role": "user", "content": "hi"}]) is False
assert (
strip_output_only_request_blocks(
[{"role": "user", "content": [{"type": "text", "text": "x"}]}]
)
is False
)
def test_reader_strips_and_reencodes_raw_bytes():
body = {
"model": "claude-fable-5",
"messages": [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": [dict(_FALLBACK)]},
],
}
raw = json.dumps(body).encode("utf-8")
result, out_raw = asyncio.run(read_request_json_with_bytes(_FakeRequest(raw)))
assert not _has_fallback(result["messages"])
# raw bytes re-encoded so byte-faithful passthrough cannot leak the pre-strip body
assert not _has_fallback(json.loads(out_raw)["messages"])
assert json.loads(out_raw) == result
def test_reader_leaves_clean_requests_byte_identical():
raw = json.dumps({"model": "x", "messages": [{"role": "user", "content": "hi"}]}).encode(
"utf-8"
)
_, out_raw = asyncio.run(read_request_json_with_bytes(_FakeRequest(raw)))
assert out_raw == raw

View file

@ -131,7 +131,9 @@ def test_version_prefers_source_tree_release_history() -> None:
patch.object(version_module, "_source_tree_version", return_value="0.21.17"),
patch.object(version_module, "version", return_value="0.9.1") as package_version,
):
assert version_module.get_version() == "0.21.17"
# Source checkouts are marked -dev so a dev build is never mistaken
# for the published release.
assert version_module.get_version() == "0.21.17-dev"
package_version.assert_not_called()

View file

@ -37,6 +37,20 @@ def test_pricing_lookup_candidates_include_provider_prefixes_and_aliases() -> No
assert candidates[-1] == MODEL_ALIASES["claude-3-5-sonnet-20241022"]
def test_retired_claude_3_sonnet_aliases_to_sonnet_tier_not_haiku() -> None:
"""Retired claude-3-sonnet must map to a Sonnet-tier price, not Haiku.
claude-3-sonnet-20240229 was a $3/$15-per-1M model; aliasing it to
claude-3-haiku-20240307 ($0.25/$1.25) underpriced its cost/savings ~12x.
"""
alias = MODEL_ALIASES["claude-3-sonnet-20240229"]
assert "haiku" not in alias
# Same-tier target as the other retired-Sonnet aliases.
assert alias == MODEL_ALIASES["claude-3-5-sonnet-20241022"]
assert alias == "claude-sonnet-4-20250514"
def test_resolve_litellm_model_name_returns_first_known_candidate() -> None:
known = {"openai/gpt-4o"}

View file

@ -110,6 +110,48 @@ def test_prefix_cache_stats_subtracts_write_premium_from_provider_net_savings(
assert anthropic["net_savings_usd"] == 0.0021
def test_prefix_cache_stats_prices_by_most_used_model(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Cache-read savings must be valued with the highest-volume model's price,
not whichever model was recorded first. A Claude Code session sends Haiku
(titles) and Sonnet (main loop); pricing the savings at Haiku's rate because
it was inserted first understates the dashboard figure ~3.75x."""
prices = {"claude-haiku-4-5": 0.80, "claude-sonnet-4-5": 3.00}
monkeypatch.setattr(CostTracker, "_get_list_price", lambda _self, m: prices.get(m))
def _savings(tokens_by_model: dict[str, int]) -> float:
metrics = PrometheusMetrics()
# Use a large read count so the reported savings_usd (rounded to 4 dp)
# stays exact and the price ratio is not lost to rounding.
metrics.cache_by_provider["anthropic"].update(
{
"requests": 1,
"hit_requests": 1,
"cache_read_tokens": 1_000_000,
"cache_write_tokens": 0,
"cache_write_5m_tokens": 0,
"cache_write_1h_tokens": 0,
"cache_write_5m_requests": 0,
"cache_write_1h_requests": 0,
}
)
tracker = CostTracker()
tracker._tokens_sent_by_model.update(tokens_by_model)
stats = build_prefix_cache_stats(metrics, tracker)
return stats["by_provider"]["anthropic"]["savings_usd"]
# Haiku recorded first, but Sonnet carries the higher token volume.
haiku_first = _savings({"claude-haiku-4-5": 500, "claude-sonnet-4-5": 50_000})
sonnet_only = _savings({"claude-sonnet-4-5": 50_000})
haiku_only = _savings({"claude-haiku-4-5": 500})
# Priced by Sonnet regardless of insertion order, not by first-seen Haiku.
assert haiku_first == sonnet_only
assert haiku_first > haiku_only
assert haiku_only == pytest.approx(sonnet_only * 0.80 / 3.00)
def test_prefix_cache_stats_subtracts_write_premium_from_total_net_savings(
monkeypatch: pytest.MonkeyPatch,
) -> None:

View file

@ -7,6 +7,7 @@ from types import SimpleNamespace
import pytest
from headroom.proxy.handlers import batch as batch_module
from headroom.proxy.handlers.gemini import GeminiHandlerMixin
class FakeResponse:
@ -72,7 +73,10 @@ class FakeMetrics:
self.failed_calls.append(kwargs)
class DummyBatchHandler(batch_module.BatchHandlerMixin):
class DummyBatchHandler(batch_module.BatchHandlerMixin, GeminiHandlerMixin):
# GeminiHandlerMixin supplies the real _rebuild_gemini_contents (and the
# other content helpers); the two converter methods below intentionally
# override the mixin's for the stub-based tests.
OPENAI_API_URL = "https://openai.example"
GEMINI_API_URL = "https://gemini.example"
@ -939,6 +943,97 @@ async def test_handle_google_batch_create_covers_passthrough_revert_and_store_fa
assert optimized["systemInstruction"] == {"parts": [{"text": "sys"}]}
@pytest.mark.asyncio
async def test_handle_google_batch_create_preserves_functioncall_response_order(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A batch request that interleaves text turns with text-less
functionCall/functionResponse entries must reach Google with all entries
intact and in order. The old raw-index restore loop overwrote the model's
answer with the functionCall and dropped the functionResponse."""
class RealConvHandler(batch_module.BatchHandlerMixin, GeminiHandlerMixin):
# Real Gemini converters + _rebuild_gemini_contents (no stubs), so the
# actual index interleaving runs.
GEMINI_API_URL = "https://gemini.example"
def __init__(self) -> None:
self.http_client = FakeHttpClient()
self.metrics = FakeMetrics()
self.config = SimpleNamespace(
optimize=True, ccr_inject_tool=False, ccr_inject_system_instructions=False
)
self.openai_provider = SimpleNamespace(get_context_limit=lambda m: 8192)
# No-op pipeline: return the messages unchanged, no token inflation.
self.openai_pipeline = SimpleNamespace(
apply=lambda **kw: SimpleNamespace(
messages=kw["messages"], timing={}, tokens_before=100, tokens_after=100
)
)
self.captured_body: dict | None = None
async def _next_request_id(self) -> str:
return "req-1"
async def _record_request_outcome(self, outcome) -> None: # noqa: ANN001
pass
def _extract_tags(self, headers: dict) -> dict[str, str]:
return {}
async def _run_compression_in_executor(self, fn, *, timeout): # noqa: ANN001, ANN201
return fn()
async def _store_google_batch_context(self, *a, **k) -> None: # noqa: ANN002, ANN003
pass
async def _retry_request(self, method, url, headers, body, **kwargs): # noqa: ANN001, ANN201
# Capture the (in-place mutated) forwarded batch body for assertions.
self.captured_body = body
return FakeResponse(status_code=200, content=b"{}", json_data={"name": "batches/1"})
handler = RealConvHandler()
contents = [
{"role": "user", "parts": [{"text": "What's the weather in Paris?"}]},
{
"role": "model",
"parts": [{"functionCall": {"name": "get_weather", "args": {"city": "Paris"}}}],
},
{
"role": "user",
"parts": [{"functionResponse": {"name": "get_weather", "response": {"temp_c": 18}}}],
},
{"role": "model", "parts": [{"text": "It's 18C and cloudy in Paris."}]},
]
batch_body = {
"batch": {
"input_config": {
"requests": {"requests": [{"request": {"contents": contents}, "metadata": {}}]}
}
}
}
async def payload(request): # noqa: ANN001, ANN201
return batch_body
monkeypatch.setattr("headroom.proxy.helpers._read_request_json", payload)
resp = await handler.handle_google_batch_create(FakeRequest("{}"), "gemini-pro")
assert resp.status_code == 200
out = handler.captured_body["batch"]["input_config"]["requests"]["requests"][0]["request"][
"contents"
]
# All four entries survive in order. The old loop produced only two, dropping
# the functionResponse and overwriting the model answer with the functionCall.
assert len(out) == 4
assert "text" in out[0]["parts"][0]
assert out[1]["parts"][0].get("functionCall", {}).get("name") == "get_weather"
assert out[2]["parts"][0].get("functionResponse", {}).get("name") == "get_weather"
assert "Paris" in out[3]["parts"][0]["text"]
@pytest.mark.asyncio
async def test_google_batch_passthrough_without_body_and_query_variants() -> None:
handler = DummyBatchHandler()

View file

@ -0,0 +1,34 @@
from fastapi.testclient import TestClient
from headroom.proxy.models import ProxyConfig
from headroom.proxy.server import create_app
def test_readyz_excludes_kompress_from_aggregate_readiness(monkeypatch):
monkeypatch.setenv("HEADROOM_SKIP_UPSTREAM_CHECK", "1")
app = create_app(
ProxyConfig(
optimize=False,
cache_enabled=False,
rate_limit_enabled=False,
)
)
app.state.ready = True
proxy = app.state.proxy
proxy.http_client = object()
proxy.warmup.kompress.mark_error("model not cached")
client = TestClient(app)
response = client.get("/readyz")
assert response.status_code == 200
payload = response.json()
assert payload["ready"] is True
assert payload["status"] == "healthy"
assert payload["checks"]["kompress"] == {
"enabled": True,
"ready": False,
"status": "unhealthy",
"backend": None,
}

View file

@ -1577,6 +1577,37 @@ def test_cache_read_savings_accumulate_and_survive_restart(tmp_path, monkeypatch
assert reloaded.history_response()["lifetime"]["cache_read_tokens"] == 1_600_000
def test_by_model_savings_accumulate_and_survive_restart(tmp_path):
path = tmp_path / "proxy_savings.json"
tracker = SavingsTracker(path=str(path))
tracker.record_request(
model="gpt-4o",
input_tokens=100,
tokens_saved=40,
timestamp="2026-07-01T09:00:00Z",
)
tracker.record_request(
model="claude-sonnet-4-6",
input_tokens=300,
tokens_saved=60,
timestamp="2026-07-01T09:01:00Z",
)
persisted = json.loads(path.read_text(encoding="utf-8"))
assert set(persisted["by_model"]) == {"gpt-4o", "claude-sonnet-4-6"}
assert persisted["by_model"]["gpt-4o"]["tokens_saved"] == 40
assert persisted["by_model"]["gpt-4o"]["total_input_tokens"] == 100
reloaded = SavingsTracker(path=str(path))
stats_by_model = reloaded.stats_preview()["by_model"]
assert set(stats_by_model) == {"gpt-4o", "claude-sonnet-4-6"}
assert stats_by_model["claude-sonnet-4-6"]["tokens_saved"] == 60
assert stats_by_model["claude-sonnet-4-6"]["total_input_tokens"] == 300
assert stats_by_model["claude-sonnet-4-6"]["savings_percent"] == 16.67
assert reloaded.history_response()["by_model"] == stats_by_model
def test_v3_state_without_cache_fields_loads_clean_and_saves_v4(tmp_path):
path = tmp_path / "proxy_savings.json"
path.write_text(

View file

@ -85,6 +85,85 @@ def test_stats_refreshes_recent_requests_when_cached() -> None:
assert second_payload["request_logs"][-1]["model"] == "claude-sonnet"
def test_stats_recent_requests_includes_token_incomplete_requests() -> None:
app = create_app(
ProxyConfig(
optimize=False,
cache_enabled=False,
rate_limit_enabled=False,
cost_tracking_enabled=False,
log_requests=False,
ccr_inject_tool=False,
ccr_handle_responses=False,
ccr_context_tracking=False,
http2=False,
)
)
logger = FakeRequestLogger()
app.state.proxy.logger = logger
logger.logs = [
FakeLogEntry(
{
"request_id": "req-haiku-1",
"timestamp": "2026-07-09T10:00:00Z",
"provider": "anthropic",
"model": "claude-haiku",
"transforms_applied": [],
}
),
FakeLogEntry(
{
"request_id": "req-haiku-2",
"timestamp": "2026-07-09T10:01:00Z",
"provider": "anthropic",
"model": "claude-haiku",
"input_tokens_original": None,
"input_tokens_optimized": None,
"output_tokens": None,
"tokens_saved": 0,
"savings_percent": 0.0,
"transforms_applied": [],
}
),
FakeLogEntry(
{
"request_id": "req-sonnet-1",
"timestamp": "2026-07-09T10:02:00Z",
"provider": "anthropic",
"model": "claude-sonnet",
"input_tokens_original": 200,
"input_tokens_optimized": 120,
"output_tokens": 40,
"tokens_saved": 80,
"savings_percent": 40.0,
"transforms_applied": ["smart_crusher"],
}
),
]
with TestClient(app, base_url="http://127.0.0.1", client=("127.0.0.1", 12345)) as client:
response = client.get("/stats")
assert response.status_code == 200
payload = response.json()
assert [req["model"] for req in payload["recent_requests"]] == [
"claude-haiku",
"claude-haiku",
"claude-sonnet",
]
assert payload["recent_requests"][0]["input_tokens_optimized"] is None
assert payload["recent_requests"][0]["token_accounting_status"] == "missing"
assert payload["recent_requests"][0]["has_exact_tokens"] is False
assert payload["recent_requests"][1]["output_tokens"] is None
assert payload["recent_requests"][1]["token_accounting_status"] == "partial"
assert payload["recent_requests"][1]["tokens_saved"] == 0
assert payload["recent_requests"][2]["token_accounting_status"] == "complete"
assert payload["recent_requests"][2]["has_exact_tokens"] is True
assert payload["summary"]["uncompressed_requests"]["unknown_token_accounting"] == 2
assert payload["request_logs"][-1]["model"] == "claude-sonnet"
def test_agent_usage_totals_use_proxy_only_savings(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("HEADROOM_REQUIRE_RUST_CORE", "false")
monkeypatch.setattr(

View file

@ -0,0 +1,63 @@
"""Regression: free (0-priced) models must not be billed the fallback rate.
`_estimate_compression_savings_usd` / `_estimate_input_cost_usd` read
`input_cost_per_token` from litellm and used `if not input_cost_per_token: raise`,
which treats a legitimate `0.0` (a free / local / vendored-at-0 model) as "price
unavailable" and falls back to DEFAULT_FALLBACK_INPUT_COST_PER_TOKEN ($3/M) —
fabricating savings/cost for a model that costs nothing. A missing key (unknown
model) must still fall back.
"""
from __future__ import annotations
import types
from headroom.proxy import savings_tracker as st
from headroom.proxy.savings_tracker import (
DEFAULT_FALLBACK_INPUT_COST_PER_TOKEN,
_estimate_compression_savings_usd,
_estimate_input_cost_usd,
)
def _fake_litellm(model_cost: dict) -> types.SimpleNamespace:
# cost_per_token succeeding makes _resolve_litellm_model return the name as-is.
return types.SimpleNamespace(
model_cost=model_cost,
cost_per_token=lambda **_kw: (0.0, 0.0),
)
def test_compression_savings_zero_for_free_model(monkeypatch):
monkeypatch.setattr(
st,
"_get_litellm_module",
lambda: _fake_litellm({"free-model": {"input_cost_per_token": 0.0}}),
)
assert _estimate_compression_savings_usd("free-model", 1_000_000) == 0.0
def test_compression_savings_falls_back_for_unknown_model(monkeypatch):
# Model absent from litellm → input_cost_per_token is None → fall back.
monkeypatch.setattr(st, "_get_litellm_module", lambda: _fake_litellm({}))
got = _estimate_compression_savings_usd("unknown-model", 1_000_000)
assert got == 1_000_000 * DEFAULT_FALLBACK_INPUT_COST_PER_TOKEN
def test_compression_savings_uses_real_price_for_paid_model(monkeypatch):
price = 3.0 / 1_000_000
monkeypatch.setattr(
st,
"_get_litellm_module",
lambda: _fake_litellm({"paid-model": {"input_cost_per_token": price}}),
)
assert _estimate_compression_savings_usd("paid-model", 1_000_000) == 1_000_000 * price
def test_input_cost_zero_for_free_model(monkeypatch):
monkeypatch.setattr(
st,
"_get_litellm_module",
lambda: _fake_litellm({"free-model": {"input_cost_per_token": 0.0}}),
)
assert _estimate_input_cost_usd("free-model", 500_000) == 0.0

View file

@ -51,6 +51,22 @@ class TestTiktokenCounter:
# gpt-4-turbo snapshots use cl100k_base.
assert get_encoding_for_model("gpt-4-turbo-2099") == "cl100k_base"
def test_gpt41_and_o4_families_use_o200k(self):
"""gpt-4.1 / gpt-4.5 / o4 use o200k_base, not cl100k_base.
Regression: gpt-4.1* and gpt-4.5* matched the broad "gpt-4" prefix and
resolved to cl100k_base, and o4* matched no prefix and fell to the
cl100k_base default both wrong encodings for those models.
"""
from headroom.tokenizers.tiktoken_counter import get_encoding_for_model
assert get_encoding_for_model("gpt-4.1") == "o200k_base"
assert get_encoding_for_model("gpt-4.1-mini") == "o200k_base"
assert get_encoding_for_model("gpt-4.5-preview") == "o200k_base"
assert get_encoding_for_model("o4-mini") == "o200k_base"
# A plain gpt-4 snapshot must still use cl100k_base (not shadowed).
assert get_encoding_for_model("gpt-4-0613") == "cl100k_base"
def test_count_text_empty(self):
"""Test counting empty text."""
counter = TiktokenCounter()
@ -103,6 +119,43 @@ class TestTiktokenCounter:
count = counter.count_messages(messages)
assert count > 0
def test_count_messages_image_block_is_not_stringified(self):
"""An Anthropic-style image block must be priced as an image, not text.
Over the wire the image arrives as a base64 string inside list content.
The old count_messages else-branch stringified any non-text/non-image_url
part and tokenized it as text, so a 1MB image counted as ~330K phantom
tokens. The base handler prices image blocks by a bounded estimate, so the
count must stay small regardless of the base64 payload size.
"""
import base64
counter = TiktokenCounter()
blob = base64.b64encode(b"\x89PNG\r\n\x1a\n" + b"\x00" * 200_000).decode()
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "What is in this screenshot?"},
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": blob,
},
},
],
}
]
count = counter.count_messages(messages)
# The base64 blob alone would be tens of thousands of text tokens; a
# bounded image estimate keeps the whole message well under that.
assert count < 5000, count
assert count < len(blob) // 10
def test_encode_decode_roundtrip(self):
"""Test encode/decode roundtrip."""
counter = TiktokenCounter()
@ -175,6 +228,26 @@ class TestEstimatingTokenCounter:
count = counter.count_text(text)
assert count == 10 # 50 / 5 = 10
def test_count_text_fixed_ratio_prices_cjk(self):
"""The fixed-ratio path must price dense scripts (CJK) like the auto path.
The registry builds provider counters (Anthropic 3.5, Google 4.0, ...)
with a fixed ratio; before the fix CJK was priced at the Latin ratio, so
a large CJK context read as ~40-55% of its true size and could skip
compression."""
counter = EstimatingTokenCounter(chars_per_token=3.5)
cjk = "これはテストです" * 100 # 800 dense-script chars, no ASCII
count = counter.count_text(cjk)
# ~1 token per 1.5 chars (CHARS_PER_TOKEN_CJK), not 1 per 3.5.
assert count == pytest.approx(len(cjk) / 1.5, rel=0.05)
# Far higher than the old Latin-ratio estimate.
assert count > len(cjk) / 3.5 * 2
# ASCII is unaffected by the fixed ratio.
assert counter.count_text("x" * 35) == 10
def test_count_text_minimum_one(self):
"""Test minimum of 1 token."""
counter = EstimatingTokenCounter()
@ -593,6 +666,38 @@ class TestLargeToolBlobEstimation:
exact = tok.count_text(json.dumps(big))
assert abs(tok._count_serialized(big) - exact) / exact < 0.10
def test_tool_result_list_recurses_into_image_block(self):
"""A tool that returns an image nests a base64 block inside a
`tool_result` list. Serializing it prices the base64 as text (a 50-200x
overcount); recursing into the block prices the image at ~1600 tokens."""
tok = EstimatingTokenCounter()
messages = [
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "t1",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": "A" * 280_000, # ~280KB base64
},
}
],
}
],
}
]
count = tok.count_messages(messages)
# The image is priced structurally (~1600), not as a huge text blob.
assert count < 5_000
def test_oversized_estimate_never_overcounts(self):
"""R4 (prefer false negatives): a token-dense head + sparse tail must not
over-count. Counting per leaf cannot extrapolate a dense front slice to the

View file

@ -1049,6 +1049,60 @@ class TestExcludeTools:
assert "router:excluded:tool" not in result.transforms_applied
def test_protect_recent_reads_fraction_zero_overrides_runtime_window(self, tokenizer):
"""protect_recent_reads_fraction == 0.0 (the --protect-tool-results
sentinel) means "protect all excluded-tool output forever". A
profile-derived read_protection_window kwarg must not be allowed to
shrink that back down -- regression test for the precedence bug
where the runtime kwarg unconditionally overrode this config-level
guarantee."""
config = ContentRouterConfig(
min_section_tokens=10,
min_chars_for_block_compression=10,
exclude_tools={"Glob"},
protect_recent_reads_fraction=0.0,
)
router = ContentRouter(config)
# Plain unstructured text (not grep/log/json-shaped) so
# _lossless_compact_excluded returns None and the router takes the
# bare "protect as before" branch, matching the tag this test
# asserts on.
old_tool_content = "\n".join(
f"line {i}: some output from a glob command that is long enough to compress"
for i in range(80)
)
messages = [
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "toolu_glob_old",
"name": "Glob",
"input": {"pattern": "*.py"},
}
],
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_glob_old",
"content": old_tool_content,
}
],
},
{"role": "assistant", "content": "ack"},
{"role": "user", "content": "continue"},
{"role": "assistant", "content": "ack"},
]
result = router.apply(messages, tokenizer, read_protection_window=2)
assert "router:excluded:tool" in result.transforms_applied
def test_mixed_excluded_and_non_excluded_tools(self, tokenizer):
"""Multiple tools in same conversation - only excluded ones pass through."""
config = ContentRouterConfig(

4
uv.lock generated
View file

@ -1558,6 +1558,7 @@ all = [
{ name = "openpyxl" },
{ name = "opentelemetry-exporter-otlp-proto-http" },
{ name = "opentelemetry-sdk" },
{ name = "orjson", marker = "platform_python_implementation != 'PyPy'" },
{ name = "pillow" },
{ name = "rapidocr", marker = "python_full_version >= '3.13'" },
{ name = "rapidocr-onnxruntime", marker = "python_full_version < '3.13'" },
@ -1673,6 +1674,7 @@ proxy = [
{ name = "onnxruntime", version = "1.23.2", source = { registry = "https://pypi.org/simple/" }, marker = "python_full_version < '3.14'" },
{ name = "onnxruntime", version = "1.26.0", source = { registry = "https://pypi.org/simple/" }, marker = "python_full_version >= '3.14'" },
{ name = "openai" },
{ name = "orjson", marker = "platform_python_implementation != 'PyPy'" },
{ name = "sqlite-vec" },
{ name = "transformers" },
{ name = "uvicorn" },
@ -1689,6 +1691,7 @@ proxy-prod = [
{ name = "onnxruntime", version = "1.23.2", source = { registry = "https://pypi.org/simple/" }, marker = "python_full_version < '3.14'" },
{ name = "onnxruntime", version = "1.26.0", source = { registry = "https://pypi.org/simple/" }, marker = "python_full_version >= '3.14'" },
{ name = "openai" },
{ name = "orjson", marker = "platform_python_implementation != 'PyPy'" },
{ name = "sqlite-vec" },
{ name = "transformers" },
{ name = "uvicorn" },
@ -1784,6 +1787,7 @@ requires-dist = [
{ name = "openai", marker = "extra == 'dev'", specifier = ">=1.0.0" },
{ name = "openai", marker = "extra == 'evals'", specifier = ">=1.0.0" },
{ name = "openai", marker = "extra == 'proxy'", specifier = ">=2.14.0" },
{ name = "orjson", marker = "platform_python_implementation != 'PyPy' and extra == 'proxy'", specifier = ">=3.9.14" },
{ name = "openpyxl", marker = "extra == 'dev'", specifier = ">=3.1.0" },
{ name = "openpyxl", marker = "extra == 'spreadsheet'", specifier = ">=3.1.0" },
{ name = "opentelemetry-api", specifier = ">=1.24.0" },