mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
Merge remote-tracking branch 'upstream/main' into pr2117
This commit is contained in:
commit
c3b2522d0b
89 changed files with 10168 additions and 328 deletions
4
.gitattributes
vendored
4
.gitattributes
vendored
|
|
@ -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
|
||||
|
|
|
|||
8
.github/workflows/docker.yml
vendored
8
.github/workflows/docker.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
4
.github/workflows/merge-conflicts.yml
vendored
4
.github/workflows/merge-conflicts.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
29
CHANGELOG.md
29
CHANGELOG.md
|
|
@ -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)).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
13
headroom/cache/compression_feedback.py
vendored
13
headroom/cache/compression_feedback.py
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
25
headroom/cache/compression_store.py
vendored
25
headroom/cache/compression_store.py
vendored
|
|
@ -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)
|
||||
|
|
|
|||
41
headroom/cache/dynamic_detector.py
vendored
41
headroom/cache/dynamic_detector.py
vendored
|
|
@ -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
|
||||
|
|
|
|||
23
headroom/cache/semantic.py
vendored
23
headroom/cache/semantic.py
vendored
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
6379
headroom/cli/wrap.py.orig
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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="")
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
1
tests/fixtures/issues/headroom_issue_2059.json
vendored
Normal file
1
tests/fixtures/issues/headroom_issue_2059.json
vendored
Normal 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"}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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] = []
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
411
tests/test_codex_ws_per_frame_memory.py
Normal file
411
tests/test_codex_ws_per_frame_memory.py
Normal 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])
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
248
tests/test_issue_1779_remote_control_gate.py
Normal file
248
tests/test_issue_1779_remote_control_gate.py
Normal 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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
33
tests/test_learn/test_error_classification.py
Normal file
33
tests/test_learn/test_error_classification.py
Normal 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
|
||||
|
|
@ -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
|
||||
# ----------------------------------------------------------------------
|
||||
|
|
|
|||
52
tests/test_memory/test_factory_embedder_cache.py
Normal file
52
tests/test_memory/test_factory_embedder_cache.py
Normal 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()
|
||||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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",)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
102
tests/test_output_only_request_blocks.py
Normal file
102
tests/test_output_only_request_blocks.py
Normal 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
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
34
tests/test_proxy_health.py
Normal file
34
tests/test_proxy_health.py
Normal 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,
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
63
tests/test_savings_tracker_zero_price.py
Normal file
63
tests/test_savings_tracker_zero_price.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
4
uv.lock
generated
|
|
@ -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" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue